diff --git a/docs/concepts/openclaw-sdk.md b/docs/concepts/openclaw-sdk.md new file mode 100644 index 00000000000..16599878051 --- /dev/null +++ b/docs/concepts/openclaw-sdk.md @@ -0,0 +1,367 @@ +--- +summary: "Design proposal for a public OpenClaw app SDK for agent runs, sessions, tasks, artifacts, and managed environments" +title: "OpenClaw SDK design" +read_when: + - You are designing or implementing a public OpenClaw app SDK + - You are comparing OpenClaw agent APIs with Cursor, Claude Agent SDK, OpenAI Agents, Google ADK, OpenCode, Codex, or ACP + - You need to decide whether a feature belongs in the public app SDK, plugin SDK, Gateway protocol, ACP backend, or managed environment layer +--- + +This page is a design proposal for a future public **OpenClaw app SDK**. It is +separate from the existing [plugin SDK](/plugins/sdk-overview). + +The plugin SDK is for code that runs inside OpenClaw and extends providers, +channels, tools, hooks, and trusted runtimes. The app SDK should be for +external applications, scripts, dashboards, CI jobs, IDE extensions, and +automation systems that want to run and observe OpenClaw agents through a stable +public API. + +## Status + +Draft architecture. + +This document captures the design direction from a comparative review of these +agent SDK and runtime surfaces: + +| Project | Useful lesson | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cursor SDK cookbook | Best high-level product API: `Agent`, `Run`, local and cloud runtimes, streaming, cancellation, model discovery, repositories, artifacts, and cloud pull request flows. | +| Claude Agent SDK | Strong bidirectional session client, interrupt and steer support, permission modes, hooks, custom tools, session stores, and resumable transcripts. | +| OpenAI Agents SDK | Strong workflow concepts: handoffs, guardrails, human approvals, tracing, run state, streaming result objects, and resume after interruptions. | +| Google ADK | Strong internal architecture: runner, session service, memory service, artifact service, credential service, plugins, event actions, and long running tool confirmations. | +| OpenCode | Strong client/server shape: generated API client, REST plus SSE, sessions, workspaces, worktrees, permissions, questions, files, VCS, PTY, tools, agents, skills, and MCP. | +| Codex | Strong local runtime boundary: approvals, sandboxing, network policy, local and remote exec servers, structured protocol events, and thread aware app-server sessions. | +| ACP and acpx | Strong interoperability layer for external coding harnesses with named sessions, prompt queues, cooperative cancellation, and runtime adapters. | + +The recommendation is to build a Cursor-simple public facade on top of an +OpenCode-style generated Gateway client, while keeping Claude, OpenAI Agents, +ADK, Codex, and ACP concepts as internal design references where they fit. + +## Goals + +- Give app developers a tiny high-level API for running OpenClaw agents. +- Keep local-first OpenClaw as the default runtime. +- Make cloud or managed environments an additive environment provider, not a + different agent API. +- Preserve existing OpenClaw boundaries: Gateway owns public protocol, plugin + SDK owns in-process extensions, ACP owns external harness interop. +- Support `stream`, `wait`, `cancel`, `resume`, `fork`, artifacts, approvals, + and background tasks as first-class operations. +- Expose stable normalized events while preserving runtime-native raw events for + advanced consumers. +- Make SDK permissions, secret forwarding, approvals, sandboxing, and remote + environments explicit. +- Keep the public contract small enough to document, test, version, and + generate. + +## Non goals + +- Do not expose `openclaw/plugin-sdk/*` as the app SDK. +- Do not make ACP the only runtime model. +- Do not require a cloud service before the SDK is useful. +- Do not clone Cursor, Claude, OpenAI, ADK, OpenCode, Codex, or ACP APIs + exactly. +- Do not expose unbounded `any` event payloads as the only public contract. +- Do not promise sandbox or network isolation for an external harness unless + the selected environment can actually enforce it. +- Do not make plugin authors depend on app SDK objects inside plugin runtime + code. + +## Current OpenClaw fit + +OpenClaw already has most of the substrate: + +| Existing surface | What it contributes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| [Agent loop](/concepts/agent-loop) | `agent` and `agent.wait` run lifecycle, streaming, timeout, and session serialization. | +| [Agent runtimes](/concepts/agent-runtimes) | Provider, model, runtime, and channel separation. | +| [ACP agents](/tools/acp-agents) | External harness sessions for Claude Code, Cursor, Gemini CLI, OpenCode, explicit Codex ACP, and similar tools. | +| [Background tasks](/automation/tasks) | Detached activity ledger for ACP, subagents, cron, CLI operations, and async media jobs. | +| [Sub-agents](/tools/subagents) | Isolated background agent runs, optional forked context, delivery back to requester sessions. | +| [Agent harness plugins](/plugins/sdk-agent-harness) | Trusted native runtime registration for embedded harnesses such as Codex. | +| Gateway protocol schemas | Current typed method and event definitions for agent params, sessions, subscriptions, aborts, compaction, and checkpoints. | + +The gap is not agent execution. The gap is a stable, friendly public facade over +these pieces. + +## Core model + +The app SDK should use a small set of durable nouns. + +| Noun | Meaning | +| ------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `OpenClaw` | Client entry point. Owns Gateway discovery, auth, low-level client access, and namespace factories. | +| `Agent` | Configured actor. Carries agent id, default model, default runtime, default tool policy, and app-facing helpers. | +| `Session` | Durable transcript, routing, workspace, context, and runtime binding. | +| `Run` | One submitted turn or task. Streams events, waits for result, cancels, and exposes artifacts. | +| `Task` | Detached or background activity ledger entry. Covers subagents, ACP spawns, cron jobs, CLI runs, and async jobs. | +| `Artifact` | Files, patches, diffs, media, logs, trajectories, pull requests, screenshots, and generated bundles. | +| `Environment` | Where the run executes: local Gateway, local workspace, node host, ACP harness, managed runner, or future cloud workspace. | +| `ToolSpace` | The effective tool surface: OpenClaw tools, MCP servers, channel tools, app tools, approval rules, and tool metadata. | +| `Approval` | Human or policy decision requested by a run, tool, environment, or harness. | + +These nouns map cleanly to existing OpenClaw concepts but avoid leaking +implementation-specific names such as PI runner internals, plugin harness +registration, or ACP adapter details. + +## Product shape + +The high-level SDK should feel like this: + +```typescript +import { OpenClaw } from "@openclaw/sdk"; + +const oc = new OpenClaw({ gateway: "auto" }); +const agent = await oc.agents.get("main"); + +const run = await agent.run({ + input: "Review this pull request and suggest the smallest safe fix.", + model: "openai/gpt-5.5", +}); + +for await (const event of run.events()) { + if (event.type === "assistant.delta") { + process.stdout.write(event.text); + } +} + +const result = await run.wait(); +console.log(result.status); +``` + +The same app should be able to use a durable session: + +```typescript +const session = await oc.sessions.create({ + agentId: "main", + label: "release-review", +}); + +const run = await session.send("Prepare release notes from the current diff."); +await run.wait(); +``` + +Current implementation note: `@openclaw/sdk` starts with the Gateway-backed +surface that exists today. Provider-qualified model refs such as +`openai/gpt-5.5` are split into Gateway `provider` and `model` overrides. +Per-run `workspace`, `runtime`, `environment`, and `approvals` selections are +still design targets; the client throws when callers set them so requests do not +silently execute with defaults. Task, artifact, environment, and generic tool +invocation helpers are also scaffolded as future API shape and throw explicit +unsupported errors until Gateway RPCs exist for them. + +And the same API should be able to use an external ACP harness: + +```typescript +const run = await oc.runs.create({ + input: "Deep review this repository and return only high-risk findings.", + workspace: { cwd: process.cwd() }, + runtime: { type: "acp", harness: "claude" }, + mode: "task", +}); +``` + +Managed environments should not change the top-level API: + +```typescript +const run = await agent.run({ + input: "Run the full changed gate and summarize failures.", + workspace: { repo: "openclaw/openclaw", ref: "main" }, + runtime: { + type: "managed", + provider: "testbox", + timeoutMinutes: 90, + }, +}); +``` + +## Runtime selection + +The app SDK should expose runtime selection as a normalized union: + +```typescript +type RuntimeSelection = + | "auto" + | { type: "embedded"; id: "pi" | "codex" | string } + | { type: "cli"; id: "claude-cli" | string } + | { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | string } + | { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | string }; +``` + +Rules: + +- `auto` follows OpenClaw runtime selection rules. +- `embedded` targets trusted in-process harnesses registered through the plugin + SDK, such as `pi` or `codex`. +- `cli` targets OpenClaw-owned CLI backend execution where available. +- `acp` targets external harnesses through ACP/acpx. +- `managed` targets an environment provider and may still run an embedded, + CLI, or ACP runtime inside that environment. + +The runtime selection object should be descriptive. It should not be the place +where secret handling, sandbox policy, or workspace provisioning hides. + +## Environment model + +The environment is the execution substrate. It should be explicit because local +CLI runs, external harnesses, node hosts, and cloud workspaces have different +safety and lifecycle properties. + +```typescript +type EnvironmentSelection = + | { type: "local"; cwd?: string } + | { type: "gateway"; url?: string; cwd?: string } + | { type: "node"; nodeId: string; cwd?: string } + | { type: "managed"; provider: string; repo?: string; ref?: string } + | { type: "ephemeral"; provider: string; repo?: string; ref?: string }; +``` + +The environment owns: + +- checkout or workspace preparation +- process and file access +- sandbox and network enforcement +- environment variables and secret references +- logs, traces, and artifacts +- cleanup and retention +- runtime availability + +This separation makes managed agents a natural extension of the SDK. A managed +agent is a normal run in a managed environment, not a special product fork. + +The detailed namespace, event, result, approval, artifact, security, package, +and environment provider contracts live in +[OpenClaw SDK API design](/reference/openclaw-sdk-api-design). + +## Cookbook plan + +The SDK should ship with a cookbook, not just reference docs. + +Recommended examples: + +| Example | Shows | +| ---------------------------- | -------------------------------------------------------------------------------------------- | +| Quickstart | Create client, run an agent, stream output, wait for result. | +| Coding agent CLI | Local workspace, model picker, cancellation, approvals, JSON output. | +| Agent dashboard | Sessions, runs, background tasks, artifacts, event replay, status filters. | +| App builder | Agent edits a workspace while a preview server runs beside it. | +| Pull request reviewer | Run against a repository ref, collect diff comments and artifacts. | +| Approval console | Subscribe to approvals and answer them from a UI. | +| ACP harness runner | Run Claude Code, Cursor, Gemini CLI, or OpenCode through ACP using the same `Run` API. | +| Managed environment provider | Minimal provider that prepares a workspace, streams events, saves artifacts, and cleans up. | +| Slack or Discord bridge | External app receives events and posts progress summaries without becoming a channel plugin. | +| Multi-agent research | Spawn parallel runs, collect artifacts, and synthesize a final report. | + +Cookbook examples should use the high-level API first. Low-level generated +client examples belong in an advanced section. + +## Phased implementation + +### Phase 0: RFC and vocabulary + +- Agree on public nouns and names. +- Decide package names. +- Define the first event taxonomy. +- Mark the current plugin SDK as intentionally separate in docs. + +### Phase 1: Low-level generated client + +- Generate a TypeScript client from Gateway protocol schemas. +- Cover `agent`, `agent.wait`, sessions, subscriptions, aborts, and tasks first. +- Add smoke tests that generated methods match Gateway method names and schema + shapes. +- Publish as experimental or internal package. + +### Phase 2: High-level run API + +- Add `OpenClaw`, `Agent`, `Session`, and `Run`. +- Support `run.events()`, `run.wait()`, and `run.cancel()`. +- Support local Gateway discovery and explicit Gateway URLs. +- Support durable sessions and session send. + +### Phase 3: Normalized event projection + +- Add Gateway-side normalized event projection beside existing raw events. +- Preserve raw runtime events where policy allows. +- Add replay cursors and reconnect behavior. +- Map PI, Codex, ACP, and task events into the stable taxonomy. + +### Phase 4: Artifacts and approvals + +- Add artifact listing and download. +- Add approval subscription and response helpers. +- Add question subscription and response helpers. +- Add cookbook approval console. + +### Phase 5: Environment providers + +- Introduce local, node, and managed environment provider contracts. +- Start with an environment that already exists operationally. +- Add workspace preparation, logs, artifacts, timeout, cleanup, and retention. + +### Phase 6: Cloud style workflows + +- Add repository and branch oriented runs. +- Add pull request artifacts. +- Add run boards grouped by repo, branch, status, and assignee. +- Add long-running managed sessions and retention policy. + +## Design choices to copy + +Copy these ideas: + +- From Cursor: `Agent` plus `Run`, local and cloud symmetry, model discovery, + artifacts, and cookbook-driven onboarding. +- From Claude Agent SDK: bidirectional clients, interrupt, permissions, hooks, + custom tools, session stores, and resume semantics. +- From OpenAI Agents: handoffs, guardrails, human approval resume, tracing, and + structured streamed result objects. +- From Google ADK: services behind runner, event actions, memory, artifacts, + credential services, and plugin interception around run lifecycle. +- From OpenCode: generated protocol client, REST plus SSE, sessions, + workspaces, questions, permissions, files, VCS, PTY, MCP, agents, and skills. +- From Codex: explicit sandbox, approval, network, local and remote exec, and + app-server thread boundaries. +- From ACP and acpx: adapter based external harness interoperability and named + prompt queues. + +## Design choices to avoid + +Avoid these traps: + +- A public SDK that is just a thin dump of Gateway internals. +- A public SDK that imports plugin SDK subpaths. +- A public SDK where events are only `stream` plus `data`. +- A cloud-first API that makes local OpenClaw feel like a legacy mode. +- Runtime selection hidden in model id prefixes. +- Secret forwarding hidden in environment maps. +- ACP specific options at the top level of every run. +- Sandbox flags that cannot be enforced by the chosen runtime. +- One SDK object that tries to be provider plugin, channel plugin, app client, + and managed runner at once. + +## Open questions + +- Should the initial package live in this repo or a separate SDK repo? +- Should the generated low-level client be published publicly before the + high-level wrapper stabilizes? +- What is the first supported app auth mechanism: local token, admin token, + OAuth device flow, or signed app registration? +- How much session message history should the SDK expose by default? +- Should managed environments be configured only in Gateway config, or can SDK + callers request them directly with scoped tokens? +- What retention rules apply to artifacts generated by local runs? +- Which event payloads require redaction before app delivery? +- Should `Run` cover normal chat turns and detached tasks, or should detached + background work always return a `Task` wrapper with a nested `Run`? + +## Related docs + +- [Agent loop](/concepts/agent-loop) +- [Agent runtimes](/concepts/agent-runtimes) +- [Session](/concepts/session) +- [Sub-agents](/tools/subagents) +- [Background tasks](/automation/tasks) +- [ACP agents](/tools/acp-agents) +- [Agent harness plugins](/plugins/sdk-agent-harness) +- [Plugin SDK overview](/plugins/sdk-overview) diff --git a/docs/docs.json b/docs/docs.json index 2a2fa35c0a2..d977961ab50 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1114,6 +1114,7 @@ "concepts/agent", "concepts/agent-loop", "concepts/agent-runtimes", + "concepts/openclaw-sdk", "concepts/system-prompt", "concepts/context", "concepts/context-engine", @@ -1651,7 +1652,11 @@ }, { "group": "RPC and API", - "pages": ["reference/rpc", "reference/device-models"] + "pages": [ + "reference/rpc", + "reference/openclaw-sdk-api-design", + "reference/device-models" + ] }, { "group": "Templates", diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 17287c5923e..9a0fa2870b4 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -402,7 +402,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - `sessions.create` creates a new session entry. - `sessions.send` sends a message into an existing session. - `sessions.steer` is the interrupt-and-steer variant for an active session. - - `sessions.abort` aborts active work for a session. + - `sessions.abort` aborts active work for a session. A caller may pass `key` plus optional `runId`, or pass `runId` alone for active runs the Gateway can resolve to a session. - `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. - `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance. - `sessions.get` returns the full stored session row. diff --git a/docs/reference/openclaw-sdk-api-design.md b/docs/reference/openclaw-sdk-api-design.md new file mode 100644 index 00000000000..cc4107e8eb3 --- /dev/null +++ b/docs/reference/openclaw-sdk-api-design.md @@ -0,0 +1,378 @@ +--- +summary: "Reference design for the proposed public OpenClaw app SDK API, event taxonomy, artifacts, approvals, and package structure" +title: "OpenClaw SDK API design" +read_when: + - You are implementing the proposed public OpenClaw app SDK + - You need the draft namespace, event, result, artifact, approval, or security contract for the app SDK + - You are comparing Gateway protocol resources with the high-level OpenClaw SDK wrapper +--- + +This page is the detailed API reference design for the proposed public +[OpenClaw SDK](/concepts/openclaw-sdk). It is intentionally separate from the +[plugin SDK](/plugins/sdk-overview). + +The public app SDK should be built in two layers: + +1. A low-level generated Gateway client. +2. A high-level ergonomic wrapper with `OpenClaw`, `Agent`, `Session`, `Run`, + `Task`, `Artifact`, `Approval`, and `Environment` objects. + +## Namespace design + +The low-level namespaces should closely follow Gateway resources: + +```typescript +oc.agents.list(); +oc.agents.get("main"); +oc.agents.create(...); +oc.agents.update(...); + +oc.sessions.list(); +oc.sessions.create(...); +oc.sessions.resolve(...); +oc.sessions.send(...); +oc.sessions.messages(...); +oc.sessions.fork(...); +oc.sessions.compact(...); +oc.sessions.abort(...); + +oc.runs.create(...); +oc.runs.get(runId); +oc.runs.events(runId, { after }); +oc.runs.wait(runId); +oc.runs.cancel(runId); + +oc.tasks.list(); // future API: current SDK throws unsupported +oc.tasks.get(taskId); // future API: current SDK throws unsupported +oc.tasks.cancel(taskId); // future API: current SDK throws unsupported +oc.tasks.events(taskId, { after }); // future API + +oc.models.list(); +oc.models.status(); // Gateway models.authStatus + +oc.tools.list(); +oc.tools.invoke(...); // future API: current SDK throws unsupported + +oc.artifacts.list({ runId }); // future API: current SDK throws unsupported +oc.artifacts.get(artifactId); // future API: current SDK throws unsupported +oc.artifacts.download(artifactId); // future API: current SDK throws unsupported + +oc.approvals.list(); +oc.approvals.respond(approvalId, ...); + +oc.environments.list(); // future API: current SDK throws unsupported +oc.environments.create(...); // future API: current SDK throws unsupported +oc.environments.status(environmentId); // future API: current SDK throws unsupported +oc.environments.delete(environmentId); // future API: current SDK throws unsupported +``` + +High-level wrappers should return objects that make common flows pleasant: + +```typescript +const run = await agent.run(inputOrParams); +await run.cancel(); +await run.wait(); + +for await (const event of run.events()) { + // normalized event stream +} + +const artifacts = await run.artifacts.list(); +const session = await run.session(); +``` + +## Event contract + +The public SDK should expose versioned, replayable, normalized events. + +```typescript +type OpenClawEvent = { + version: 1; + id: string; + ts: number; + type: OpenClawEventType; + runId?: string; + sessionId?: string; + sessionKey?: string; + taskId?: string; + agentId?: string; + data: unknown; + raw?: unknown; +}; +``` + +`id` is a replay cursor. Consumers should be able to reconnect with +`events({ after: id })` and receive missed events when retention allows. + +Recommended normalized event families: + +| Event | Meaning | +| --------------------- | ----------------------------------------------------------- | +| `run.created` | Run accepted. | +| `run.queued` | Run is waiting for a session lane, runtime, or environment. | +| `run.started` | Runtime started execution. | +| `run.completed` | Run finished successfully. | +| `run.failed` | Run ended with an error. | +| `run.cancelled` | Run was cancelled. | +| `run.timed_out` | Run exceeded its timeout. | +| `assistant.delta` | Assistant text delta. | +| `assistant.message` | Complete assistant message or replacement. | +| `thinking.delta` | Reasoning or plan delta, when policy allows exposure. | +| `tool.call.started` | Tool call began. | +| `tool.call.delta` | Tool call streamed progress or partial output. | +| `tool.call.completed` | Tool call returned successfully. | +| `tool.call.failed` | Tool call failed. | +| `approval.requested` | A run or tool needs approval. | +| `approval.resolved` | Approval was granted, denied, expired, or cancelled. | +| `question.requested` | Runtime asks the user or host app for input. | +| `question.answered` | Host app supplied an answer. | +| `artifact.created` | New artifact available. | +| `artifact.updated` | Existing artifact changed. | +| `session.created` | Session created. | +| `session.updated` | Session metadata changed. | +| `session.compacted` | Session compaction happened. | +| `task.updated` | Background task state changed. | +| `git.branch` | Runtime observed or changed branch state. | +| `git.diff` | Runtime produced or changed a diff. | +| `git.pr` | Runtime opened, updated, or linked a pull request. | + +Runtime-native payloads should be available through `raw`, but apps should not +have to parse `raw` for normal UI. + +## Result contract + +`Run.wait()` should return a stable result envelope: + +```typescript +type RunResult = { + runId: string; + status: "completed" | "failed" | "cancelled" | "timed_out"; + sessionId?: string; + sessionKey?: string; + taskId?: string; + startedAt?: string | number; + endedAt?: string | number; + output?: { + text?: string; + messages?: SDKMessage[]; + }; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + costUsd?: number; + }; + artifacts?: ArtifactSummary[]; + error?: SDKError; +}; +``` + +The result should be boring and stable. Timestamp values preserve the Gateway +shape, so current lifecycle-backed runs usually report epoch millisecond +numbers while adapters may still surface ISO strings. Rich UI, tool traces, and +runtime-native details belong in events and artifacts. + +## Approvals and questions + +Approvals must be first-class because coding agents constantly cross safety +boundaries. + +```typescript +run.onApproval(async (request) => { + if (request.kind === "tool" && request.toolName === "exec") { + return request.approveOnce({ reason: "CI command allowed by policy" }); + } + + return request.askUser(); +}); +``` + +Approval events should carry: + +- approval id +- run id and session id +- request kind +- requested action summary +- tool name or environment action +- risk level +- available decisions +- expiration +- whether the decision can be reused + +Questions are separate from approvals. A question asks the user or host app for +information. An approval asks for permission to perform an action. + +## ToolSpace model + +Apps need to understand the tool surface without importing plugin internals. + +```typescript +const tools = await run.toolSpace(); + +for (const tool of tools.list()) { + console.log(tool.name, tool.source, tool.requiresApproval); +} +``` + +The SDK should expose: + +- normalized tool metadata +- source: OpenClaw, MCP, plugin, channel, runtime, or app +- schema summary +- approval policy +- runtime compatibility +- whether a tool is hidden, readonly, write capable, or host capable + +Tool invocation through the SDK should be explicit and scoped. Most apps should +run agents, not call arbitrary tools directly. + +## Artifact model + +Artifacts should cover more than files. + +```typescript +type ArtifactSummary = { + id: string; + runId?: string; + sessionId?: string; + type: + | "file" + | "patch" + | "diff" + | "log" + | "media" + | "screenshot" + | "trajectory" + | "pull_request" + | "workspace"; + title?: string; + mimeType?: string; + sizeBytes?: number; + createdAt: string; + expiresAt?: string; +}; +``` + +Common examples: + +- file edits and generated files +- patch bundles +- VCS diffs +- screenshots and media outputs +- logs and trace bundles +- pull request links +- runtime trajectories +- managed environment workspace snapshots + +Artifact access should support redaction, retention, and download URLs without +assuming every artifact is a normal local file. + +## Security model + +The app SDK must be explicit about authority. + +Recommended token scopes: + +| Scope | Allows | +| ------------------- | --------------------------------------------------- | +| `agent.read` | List and inspect agents. | +| `agent.run` | Start runs. | +| `session.read` | Read session metadata and messages. | +| `session.write` | Create, send to, fork, compact, and abort sessions. | +| `task.read` | Read background task state. | +| `task.write` | Cancel or modify task notification policy. | +| `approval.respond` | Approve or deny requests. | +| `tools.invoke` | Invoke exposed tools directly. | +| `artifacts.read` | List and download artifacts. | +| `environment.write` | Create or destroy managed environments. | +| `admin` | Administrative operations. | + +Defaults: + +- no secret forwarding by default +- no unrestricted environment variable pass-through +- secret references instead of secret values +- explicit sandbox and network policy +- explicit remote environment retention +- approvals for host execution unless policy proves otherwise +- raw runtime events redacted before they leave Gateway unless the caller has a + stronger diagnostic scope + +## Managed environment provider + +Managed agents should be implemented as environment providers. + +```typescript +type EnvironmentProvider = { + id: string; + capabilities: { + checkout?: boolean; + sandbox?: boolean; + networkPolicy?: boolean; + secrets?: boolean; + artifacts?: boolean; + logs?: boolean; + pullRequests?: boolean; + longRunning?: boolean; + }; +}; +``` + +The first implementation does not need to be a hosted SaaS. It can target +existing node hosts, ephemeral workspaces, CI-style runners, or Testbox-style +environments. The important contract is: + +1. prepare workspace +2. bind safe environment and secrets +3. start run +4. stream events +5. collect artifacts +6. clean up or retain by policy + +Once this is stable, a hosted cloud service can implement the same provider +contract. + +## Package structure + +Recommended packages: + +| Package | Purpose | +| ----------------------- | ------------------------------------------------------------- | +| `@openclaw/sdk` | Public high-level SDK and generated low-level Gateway client. | +| `@openclaw/sdk-react` | Optional React hooks for dashboards and app builders. | +| `@openclaw/sdk-testing` | Test helpers and fake Gateway server for app integrations. | + +The repo already has `openclaw/plugin-sdk/*` for plugins. Keep that namespace +separate to avoid confusing plugin authors with app developers. + +## Generated client strategy + +The low-level client should be generated from versioned Gateway protocol +schemas, then wrapped by handwritten ergonomic classes. + +Layering: + +1. Gateway schema source of truth. +2. Generated low-level TypeScript client. +3. Runtime validators for external inputs and event payloads. +4. High-level `OpenClaw`, `Agent`, `Session`, `Run`, `Task`, and `Artifact` + wrappers. +5. Cookbook examples and integration tests. + +Benefits: + +- protocol drift is visible +- tests can compare generated methods with Gateway exports +- app SDK stays independent from plugin SDK internals +- low-level consumers still have full protocol access +- high-level consumers get the small product API + +## Related docs + +- [OpenClaw SDK design](/concepts/openclaw-sdk) +- [Gateway RPC reference](/reference/rpc) +- [Agent loop](/concepts/agent-loop) +- [Agent runtimes](/concepts/agent-runtimes) +- [Background tasks](/automation/tasks) +- [ACP agents](/tools/acp-agents) +- [Plugin SDK overview](/plugins/sdk-overview) diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000000..8d6d785ce2e --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openclaw/sdk", + "version": "0.0.0-private", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + } +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 00000000000..623006bc609 --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,538 @@ +import { randomUUID } from "node:crypto"; +import { normalizeGatewayEvent } from "./normalize.js"; +import { GatewayClientTransport, isConnectableTransport } from "./transport.js"; +import type { + AgentRunParams, + GatewayEvent, + GatewayRequestOptions, + OpenClawEvent, + OpenClawTransport, + RunCreateParams, + RunResult, + RunTimestamp, + SessionCreateParams, + SessionSendParams, + SessionTarget, +} from "./types.js"; + +export type OpenClawOptions = { + gateway?: "auto" | (string & {}); + url?: string; + token?: string; + password?: string; + requestTimeoutMs?: number; + transport?: OpenClawTransport; +}; + +function resolveGatewayUrl(options: OpenClawOptions): string | undefined { + if (options.url) { + return options.url; + } + if (options.gateway && options.gateway !== "auto") { + return options.gateway; + } + return undefined; +} + +function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { + const record = + typeof payload === "object" && payload !== null ? (payload as { status?: unknown }) : {}; + const status = typeof record.status === "string" ? record.status : undefined; + if (status === "ok" || status === "completed" || status === "succeeded") { + return "completed"; + } + if (status === "timeout" || status === "timed_out") { + return "timed_out"; + } + if (status === "cancelled" || status === "canceled") { + return "cancelled"; + } + if (status === "accepted") { + return "accepted"; + } + return "failed"; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readOptionalTimestamp(value: unknown): RunTimestamp | undefined { + if (typeof value === "string" && value.length > 0) { + return value; + } + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined { + if (timeoutMs === undefined) { + return undefined; + } + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error("timeoutMs must be a finite non-negative number"); + } + return Math.floor(timeoutMs); +} + +function timeoutSecondsFromMs(timeoutMs: number | undefined): number | undefined { + const normalized = normalizeTimeoutMs(timeoutMs); + if (normalized === undefined) { + return undefined; + } + return normalized === 0 ? 0 : Math.ceil(normalized / 1000); +} + +function splitModelRef(model: string | undefined): { provider?: string; model?: string } { + if (!model) { + return {}; + } + const index = model.indexOf("/"); + if (index <= 0 || index === model.length - 1) { + return { model }; + } + return { + provider: model.slice(0, index), + model: model.slice(index + 1), + }; +} + +function assertNoUnsupportedRunOptions(params: AgentRunParams): void { + const unsupported = [ + params.workspace ? "workspace" : undefined, + params.runtime ? "runtime" : undefined, + params.environment ? "environment" : undefined, + params.approvals ? "approvals" : undefined, + ].filter((value): value is string => Boolean(value)); + if (unsupported.length === 0) { + return; + } + throw new Error( + `OpenClaw Gateway does not support per-run SDK option${ + unsupported.length === 1 ? "" : "s" + } yet: ${unsupported.join(", ")}`, + ); +} + +function buildAgentParams(params: AgentRunParams): Record { + assertNoUnsupportedRunOptions(params); + const modelRef = splitModelRef(params.model); + const timeoutSeconds = timeoutSecondsFromMs(params.timeoutMs); + return { + message: params.input, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(modelRef.provider ? { provider: modelRef.provider } : {}), + ...(modelRef.model ? { model: modelRef.model } : {}), + ...(params.sessionId ? { sessionId: params.sessionId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.thinking ? { thinking: params.thinking } : {}), + ...(typeof params.deliver === "boolean" ? { deliver: params.deliver } : {}), + ...(params.attachments ? { attachments: params.attachments } : {}), + ...(timeoutSeconds !== undefined ? { timeout: timeoutSeconds } : {}), + ...(params.label ? { label: params.label } : {}), + idempotencyKey: params.idempotencyKey ?? randomUUID(), + }; +} + +function unsupportedGatewayApi(api: string): never { + throw new Error(`${api} is not supported by the current OpenClaw Gateway yet`); +} + +export class OpenClaw { + readonly agents: AgentsNamespace; + readonly sessions: SessionsNamespace; + readonly runs: RunsNamespace; + readonly tasks: TasksNamespace; + readonly models: ModelsNamespace; + readonly tools: ToolsNamespace; + readonly artifacts: ArtifactsNamespace; + readonly approvals: ApprovalsNamespace; + readonly environments: EnvironmentsNamespace; + + private readonly transport: OpenClawTransport; + private connected = false; + + constructor(options: OpenClawOptions = {}) { + this.transport = + options.transport ?? + new GatewayClientTransport({ + url: resolveGatewayUrl(options), + token: options.token, + password: options.password, + requestTimeoutMs: options.requestTimeoutMs, + }); + this.agents = new AgentsNamespace(this); + this.sessions = new SessionsNamespace(this); + this.runs = new RunsNamespace(this); + this.tasks = new TasksNamespace(this); + this.models = new ModelsNamespace(this); + this.tools = new ToolsNamespace(this); + this.artifacts = new ArtifactsNamespace(this); + this.approvals = new ApprovalsNamespace(this); + this.environments = new EnvironmentsNamespace(this); + } + + async connect(): Promise { + if (this.connected) { + return; + } + if (isConnectableTransport(this.transport)) { + await this.transport.connect(); + } + this.connected = true; + } + + async close(): Promise { + await this.transport.close?.(); + this.connected = false; + } + + async request( + method: string, + params?: unknown, + options?: GatewayRequestOptions, + ): Promise { + await this.connect(); + return await this.transport.request(method, params, options); + } + + events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable { + const source = this.transport.events(); + async function* iterate(): AsyncIterable { + for await (const event of source) { + const normalized = normalizeGatewayEvent(event); + if (!filter || filter(normalized)) { + yield normalized; + } + } + } + return iterate(); + } + + rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable { + return this.transport.events(filter); + } +} + +export class Agent { + constructor( + private readonly client: OpenClaw, + readonly id: string, + ) {} + + async run(input: string | Omit): Promise { + const params: AgentRunParams = + typeof input === "string" ? { input, agentId: this.id } : { ...input, agentId: this.id }; + return await this.client.runs.create(params); + } + + async identity(params?: { sessionKey?: string }): Promise { + return await this.client.request("agent.identity.get", { + agentId: this.id, + ...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}), + }); + } +} + +export class Run { + constructor( + private readonly client: OpenClaw, + readonly id: string, + readonly sessionKey?: string, + ) {} + + events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable { + return this.client.events((event) => { + if (event.runId !== this.id) { + return false; + } + return filter ? filter(event) : true; + }); + } + + async wait(options?: { timeoutMs?: number }): Promise { + const timeoutMs = normalizeTimeoutMs(options?.timeoutMs); + const raw = await this.client.request( + "agent.wait", + { + runId: this.id, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }, + { timeoutMs: null }, + ); + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const status = runStatusFromWaitPayload(raw); + const error = readOptionalString(record.error) + ? { message: readOptionalString(record.error) ?? "run failed" } + : undefined; + return { + runId: this.id, + status, + sessionKey: readOptionalString(record.sessionKey) ?? this.sessionKey, + sessionId: readOptionalString(record.sessionId), + startedAt: readOptionalTimestamp(record.startedAt), + endedAt: readOptionalTimestamp(record.endedAt), + ...(error ? { error } : {}), + raw, + }; + } + + async cancel(): Promise { + return await this.client.request("sessions.abort", { + runId: this.id, + ...(this.sessionKey ? { key: this.sessionKey } : {}), + }); + } +} + +export class Session { + constructor( + private readonly client: OpenClaw, + readonly key: string, + readonly info?: unknown, + ) {} + + async send(input: string | Omit): Promise { + const params: SessionSendParams = + typeof input === "string" ? { key: this.key, message: input } : { ...input, key: this.key }; + const raw = await this.client.request("sessions.send", params, { expectFinal: true }); + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const runId = readOptionalString(record.runId); + if (!runId) { + throw new Error("sessions.send did not return a runId"); + } + return new Run(this.client, runId, this.key); + } + + async abort(runId?: string): Promise { + return await this.client.request("sessions.abort", { + key: this.key, + ...(runId ? { runId } : {}), + }); + } + + async patch(params: Record): Promise { + return await this.client.request("sessions.patch", { ...params, key: this.key }); + } + + async compact(params?: { maxLines?: number }): Promise { + return await this.client.request("sessions.compact", { key: this.key, ...params }); + } +} + +export class AgentsNamespace { + constructor(private readonly client: OpenClaw) {} + + async list(params?: Record): Promise { + return await this.client.request("agents.list", params); + } + + async get(id: string): Promise { + return new Agent(this.client, id); + } + + async create(params: Record): Promise { + return await this.client.request("agents.create", params); + } + + async update(params: Record): Promise { + return await this.client.request("agents.update", params); + } + + async delete(params: Record): Promise { + return await this.client.request("agents.delete", params); + } +} + +export class SessionsNamespace { + constructor(private readonly client: OpenClaw) {} + + async list(params?: Record): Promise { + return await this.client.request("sessions.list", params); + } + + async create(params: SessionCreateParams = {}): Promise { + const raw = await this.client.request("sessions.create", params); + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const key = + readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key; + if (!key) { + throw new Error("sessions.create did not return a session key"); + } + return new Session(this.client, key, raw); + } + + async get(target: SessionTarget | string): Promise { + const key = typeof target === "string" ? target : target.key; + return new Session(this.client, key); + } + + async resolve(params: Record): Promise { + return await this.client.request("sessions.resolve", params); + } + + async send(input: SessionSendParams): Promise { + return await new Session(this.client, input.key).send(input); + } +} + +export class RunsNamespace { + constructor(private readonly client: OpenClaw) {} + + async create(params: RunCreateParams): Promise { + const raw = await this.client.request("agent", buildAgentParams(params), { + expectFinal: false, + timeoutMs: params.timeoutMs, + }); + const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const runId = readOptionalString(record.runId); + if (!runId) { + throw new Error("agent did not return a runId"); + } + return new Run(this.client, runId, readOptionalString(record.sessionKey) ?? params.sessionKey); + } + + async get(runId: string): Promise { + return new Run(this.client, runId); + } + + events(runId: string): AsyncIterable { + return new Run(this.client, runId).events(); + } + + async wait(runId: string, options?: { timeoutMs?: number }): Promise { + return await new Run(this.client, runId).wait(options); + } + + async cancel(runId: string, sessionKey?: string): Promise { + return await new Run(this.client, runId, sessionKey).cancel(); + } +} + +class RpcNamespace { + constructor( + protected readonly client: OpenClaw, + private readonly prefix: string, + ) {} + + protected async call( + method: string, + params?: unknown, + options?: GatewayRequestOptions, + ): Promise { + return await this.client.request(`${this.prefix}.${method}`, params, options); + } +} + +export class TasksNamespace extends RpcNamespace { + constructor(client: OpenClaw) { + super(client, "tasks"); + } + + async list(params?: unknown): Promise { + void params; + return unsupportedGatewayApi("oc.tasks.list"); + } + + async get(taskId: string): Promise { + void taskId; + return unsupportedGatewayApi("oc.tasks.get"); + } + + async cancel(taskId: string): Promise { + void taskId; + return unsupportedGatewayApi("oc.tasks.cancel"); + } +} + +export class ModelsNamespace extends RpcNamespace { + constructor(client: OpenClaw) { + super(client, "models"); + } + + async list(params?: unknown): Promise { + return await this.call("list", params); + } + + async status(params?: unknown): Promise { + return await this.call("authStatus", params); + } +} + +export class ToolsNamespace extends RpcNamespace { + constructor(client: OpenClaw) { + super(client, "tools"); + } + + async list(params?: unknown): Promise { + return await this.call("catalog", params); + } + + async effective(params?: unknown): Promise { + return await this.call("effective", params); + } + + async invoke(name: string, params?: unknown): Promise { + void name; + void params; + return unsupportedGatewayApi("oc.tools.invoke"); + } +} + +export class ArtifactsNamespace extends RpcNamespace { + constructor(client: OpenClaw) { + super(client, "artifacts"); + } + + async list(params?: unknown): Promise { + void params; + return unsupportedGatewayApi("oc.artifacts.list"); + } + + async get(id: string): Promise { + void id; + return unsupportedGatewayApi("oc.artifacts.get"); + } + + async download(id: string): Promise { + void id; + return unsupportedGatewayApi("oc.artifacts.download"); + } +} + +export class ApprovalsNamespace { + constructor(private readonly client: OpenClaw) {} + + async list(params?: unknown): Promise { + return await this.client.request("exec.approval.list", params); + } + + async respond(approvalId: string, decision: Record): Promise { + return await this.client.request("exec.approval.resolve", { approvalId, ...decision }); + } +} + +export class EnvironmentsNamespace extends RpcNamespace { + constructor(client: OpenClaw) { + super(client, "environments"); + } + + async list(params?: unknown): Promise { + void params; + return unsupportedGatewayApi("oc.environments.list"); + } + + async create(params?: unknown): Promise { + void params; + return unsupportedGatewayApi("oc.environments.create"); + } + + async status(environmentId: string): Promise { + void environmentId; + return unsupportedGatewayApi("oc.environments.status"); + } + + async delete(environmentId: string): Promise { + void environmentId; + return unsupportedGatewayApi("oc.environments.delete"); + } +} diff --git a/packages/sdk/src/event-hub.ts b/packages/sdk/src/event-hub.ts new file mode 100644 index 00000000000..4a5a8eb56b0 --- /dev/null +++ b/packages/sdk/src/event-hub.ts @@ -0,0 +1,77 @@ +import type { GatewayEvent } from "./types.js"; + +type Listener = (event: T) => void; + +export class EventHub { + private closed = false; + private readonly listeners = new Set>(); + private readonly waiters = new Set<() => void>(); + + publish(event: T): void { + if (this.closed) { + return; + } + for (const listener of this.listeners) { + listener(event); + } + } + + close(): void { + this.closed = true; + this.listeners.clear(); + for (const wake of this.waiters) { + wake(); + } + this.waiters.clear(); + } + + async *stream(filter?: (event: T) => boolean): AsyncIterable { + const queue: T[] = []; + let wake: (() => void) | null = null; + const listener = (event: T) => { + if (!filter || filter(event)) { + queue.push(event); + wake?.(); + wake = null; + } + }; + + this.listeners.add(listener); + try { + while (!this.closed) { + const next = queue.shift(); + if (next) { + yield next; + continue; + } + await new Promise((resolve) => { + const wakeCurrent = () => { + this.waiters.delete(wakeCurrent); + resolve(); + }; + wake = wakeCurrent; + this.waiters.add(wakeCurrent); + }); + } + while (queue.length > 0) { + const next = queue.shift(); + if (next) { + yield next; + } + } + } finally { + this.listeners.delete(listener); + if (wake) { + this.waiters.delete(wake); + } + } + } +} + +export function isGatewayEvent(value: unknown): value is GatewayEvent { + return ( + typeof value === "object" && + value !== null && + typeof (value as { event?: unknown }).event === "string" + ); +} diff --git a/packages/sdk/src/index.e2e.test.ts b/packages/sdk/src/index.e2e.test.ts new file mode 100644 index 00000000000..534cd6627f5 --- /dev/null +++ b/packages/sdk/src/index.e2e.test.ts @@ -0,0 +1,278 @@ +import type { AddressInfo } from "node:net"; +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocketServer, type RawData, type WebSocket } from "ws"; +import { GatewayClientTransport, OpenClaw } from "./index.js"; + +type JsonObject = Record; + +const servers: WebSocketServer[] = []; + +function sendJson(socket: WebSocket, payload: JsonObject): void { + socket.send(JSON.stringify(payload)); +} + +function readRawMessage(raw: RawData): string { + if (typeof raw === "string") { + return raw; + } + if (Buffer.isBuffer(raw)) { + return raw.toString("utf8"); + } + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw).toString("utf8"); + } + return Buffer.concat(raw).toString("utf8"); +} + +async function reservePort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function createFakeGateway(port = 0): Promise<{ url: string; close: () => Promise }> { + const server = new WebSocketServer({ host: "127.0.0.1", port }); + servers.push(server); + await new Promise((resolve) => server.once("listening", resolve)); + let seq = 1; + + server.on("connection", (socket) => { + sendJson(socket, { + type: "event", + event: "connect.challenge", + seq: seq++, + payload: { nonce: "sdk-e2e-nonce" }, + }); + + socket.on("message", (raw) => { + const frame = JSON.parse(readRawMessage(raw)) as { + id: string; + method: string; + params?: unknown; + }; + + if (frame.method === "connect") { + sendJson(socket, { + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 1, + server: { version: "sdk-e2e", connId: "conn-sdk-e2e" }, + features: { + methods: [ + "agent", + "agent.wait", + "connect", + "sessions.abort", + "sessions.create", + "sessions.send", + ], + events: ["agent", "sessions.changed"], + }, + snapshot: { + presence: [], + health: {}, + stateVersion: { presence: 0, health: 0 }, + uptimeMs: 1, + }, + auth: { role: "operator", scopes: [] }, + policy: { + maxPayload: 262144, + maxBufferedBytes: 262144, + tickIntervalMs: 30000, + }, + }, + }); + return; + } + + if (frame.method === "agent") { + const params = frame.params as { sessionKey?: string } | undefined; + sendJson(socket, { + type: "res", + id: frame.id, + ok: true, + payload: { status: "accepted", runId: "run-sdk-e2e", sessionKey: params?.sessionKey }, + }); + setTimeout(() => { + sendJson(socket, { + type: "event", + event: "agent", + seq: seq++, + payload: { + runId: "run-sdk-e2e", + sessionKey: params?.sessionKey, + stream: "lifecycle", + ts: Date.now(), + data: { phase: "start" }, + }, + }); + sendJson(socket, { + type: "event", + event: "agent", + seq: seq++, + payload: { + runId: "run-sdk-e2e", + sessionKey: params?.sessionKey, + stream: "assistant", + ts: Date.now(), + data: { delta: "hello from fake gateway" }, + }, + }); + sendJson(socket, { + type: "event", + event: "agent", + seq: seq++, + payload: { + runId: "run-sdk-e2e", + sessionKey: params?.sessionKey, + stream: "lifecycle", + ts: Date.now(), + data: { phase: "end" }, + }, + }); + }, 50); + return; + } + + if (frame.method === "agent.wait") { + sendJson(socket, { + type: "res", + id: frame.id, + ok: true, + payload: { + status: "ok", + runId: "run-sdk-e2e", + sessionKey: "main", + startedAt: 123, + endedAt: 456, + }, + }); + } + + if (frame.method === "sessions.abort") { + sendJson(socket, { + type: "res", + id: frame.id, + ok: true, + payload: { + ok: true, + abortedRunId: "run-sdk-e2e", + status: "aborted", + }, + }); + } + }); + }); + + const { port: boundPort } = server.address() as AddressInfo; + return { + url: `ws://127.0.0.1:${boundPort}`, + close: () => { + const index = servers.indexOf(server); + if (index >= 0) { + servers.splice(index, 1); + } + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +describe("OpenClaw SDK websocket e2e", () => { + afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + }); + + it("runs an agent and streams normalized events over a Gateway websocket", async () => { + const gateway = await createFakeGateway(); + const transport = new GatewayClientTransport({ + url: gateway.url, + deviceIdentity: null, + requestTimeoutMs: 2_000, + }); + const oc = new OpenClaw({ transport }); + try { + const agent = await oc.agents.get("main"); + const run = await agent.run({ + input: "say hello", + sessionKey: "main", + idempotencyKey: "sdk-e2e", + }); + const seenPromise = (async () => { + const seen: string[] = []; + + for await (const event of run.events()) { + seen.push(event.type); + if (event.type === "run.completed") { + break; + } + } + + return seen; + })(); + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("timed out waiting for SDK run events")), 2_000); + }); + + const [seen, result] = await Promise.all([ + Promise.race([seenPromise, timeoutPromise]), + run.wait({ timeoutMs: 2_000 }), + ]); + + expect(run.id).toBe("run-sdk-e2e"); + expect(seen).toEqual(["run.started", "assistant.delta", "run.completed"]); + expect(result).toMatchObject({ + runId: "run-sdk-e2e", + sessionKey: "main", + status: "completed", + startedAt: 123, + endedAt: 456, + }); + await expect(run.cancel()).resolves.toMatchObject({ + abortedRunId: "run-sdk-e2e", + status: "aborted", + }); + } finally { + await oc.close(); + await gateway.close(); + } + }); + + it("retries after an initial websocket connection failure", async () => { + const port = await reservePort(); + const url = `ws://127.0.0.1:${port}`; + const transport = new GatewayClientTransport({ + url, + deviceIdentity: null, + connectChallengeTimeoutMs: 200, + preauthHandshakeTimeoutMs: 200, + requestTimeoutMs: 500, + }); + + await expect(transport.connect()).rejects.toThrow(); + + const gateway = await createFakeGateway(port); + try { + await expect(transport.connect()).resolves.toBeUndefined(); + } finally { + await transport.close(); + await gateway.close(); + } + }); +}); diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts new file mode 100644 index 00000000000..a431254e94d --- /dev/null +++ b/packages/sdk/src/index.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from "vitest"; +import { EventHub, OpenClaw, normalizeGatewayEvent } from "./index.js"; +import type { GatewayEvent, GatewayRequestOptions, OpenClawTransport } from "./types.js"; + +type RequestCall = { + method: string; + params?: unknown; + options?: GatewayRequestOptions; +}; + +class FakeTransport implements OpenClawTransport { + readonly calls: RequestCall[] = []; + private readonly eventHub = new EventHub(); + + constructor(private readonly responses: Record) {} + + async request( + method: string, + params?: unknown, + options?: GatewayRequestOptions, + ): Promise { + this.calls.push({ method, params, options }); + return this.responses[method] as T; + } + + events(filter?: (event: GatewayEvent) => boolean): AsyncIterable { + return this.eventHub.stream(filter); + } + + emit(event: GatewayEvent): void { + this.eventHub.publish(event); + } + + close(): void { + this.eventHub.close(); + } +} + +describe("OpenClaw SDK", () => { + it("runs an agent through the Gateway agent method", async () => { + const transport = new FakeTransport({ + agent: { status: "accepted", runId: "run_123" }, + "agent.wait": { status: "ok", runId: "run_123", sessionKey: "main" }, + }); + const oc = new OpenClaw({ transport }); + const agent = await oc.agents.get("main"); + + const run = await agent.run({ + input: "ship it", + model: "sonnet-4.6", + sessionKey: "main", + timeoutMs: 30_000, + idempotencyKey: "idempotent-test", + }); + const result = await run.wait({ timeoutMs: 500 }); + + expect(run.id).toBe("run_123"); + expect(result).toMatchObject({ + runId: "run_123", + sessionKey: "main", + status: "completed", + }); + expect(transport.calls).toEqual([ + { + method: "agent", + options: { expectFinal: false, timeoutMs: 30_000 }, + params: { + agentId: "main", + idempotencyKey: "idempotent-test", + message: "ship it", + model: "sonnet-4.6", + sessionKey: "main", + timeout: 30, + }, + }, + { + method: "agent.wait", + options: { timeoutMs: null }, + params: { runId: "run_123", timeoutMs: 500 }, + }, + ]); + }); + + it("preserves numeric wait timestamps", async () => { + const transport = new FakeTransport({ + "agent.wait": { status: "ok", runId: "run_numeric", startedAt: 123, endedAt: 456 }, + }); + const oc = new OpenClaw({ transport }); + + const result = await oc.runs.wait("run_numeric"); + + expect(result).toMatchObject({ + runId: "run_numeric", + status: "completed", + startedAt: 123, + endedAt: 456, + }); + expect(transport.calls).toEqual([ + { + method: "agent.wait", + params: { runId: "run_numeric" }, + options: { timeoutMs: null }, + }, + ]); + }); + + it("splits provider-qualified model refs and rejects unsupported run options", async () => { + const transport = new FakeTransport({ + agent: { status: "accepted", runId: "run_openrouter" }, + }); + const oc = new OpenClaw({ transport }); + + await oc.runs.create({ + input: "use a routed model", + model: "openrouter/deepseek/deepseek-r1", + idempotencyKey: "model-ref-test", + }); + + expect(transport.calls[0]).toMatchObject({ + method: "agent", + params: { + message: "use a routed model", + provider: "openrouter", + model: "deepseek/deepseek-r1", + idempotencyKey: "model-ref-test", + }, + }); + await expect( + oc.runs.create({ + input: "unsupported", + idempotencyKey: "unsupported-options-test", + workspace: { cwd: "/tmp/project" }, + runtime: { type: "managed", provider: "testbox" }, + environment: { type: "local" }, + approvals: "ask", + }), + ).rejects.toThrow( + "OpenClaw Gateway does not support per-run SDK options yet: workspace, runtime, environment, approvals", + ); + }); + + it("ceil-converts run timeoutMs to Gateway timeout seconds", async () => { + const transport = new FakeTransport({ + agent: { status: "accepted", runId: "run_timeout" }, + }); + const oc = new OpenClaw({ transport }); + + await oc.runs.create({ + input: "short run", + timeoutMs: 1_500, + idempotencyKey: "timeout-test", + }); + + expect(transport.calls[0]).toMatchObject({ + method: "agent", + options: { expectFinal: false, timeoutMs: 1_500 }, + params: { + message: "short run", + timeout: 2, + idempotencyKey: "timeout-test", + }, + }); + await expect( + oc.runs.create({ + input: "bad timeout", + timeoutMs: Number.NaN, + idempotencyKey: "bad-timeout-test", + }), + ).rejects.toThrow("timeoutMs must be a finite non-negative number"); + }); + + it("throws explicit unsupported errors for SDK namespaces without Gateway RPCs", async () => { + const transport = new FakeTransport({}); + const oc = new OpenClaw({ transport }); + + await expect(oc.tasks.list()).rejects.toThrow( + "oc.tasks.list is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.tasks.get("task_123")).rejects.toThrow( + "oc.tasks.get is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.tasks.cancel("task_123")).rejects.toThrow( + "oc.tasks.cancel is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.tools.invoke("demo")).rejects.toThrow( + "oc.tools.invoke is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.artifacts.list()).rejects.toThrow( + "oc.artifacts.list is not supported by the current OpenClaw Gateway yet", + ); + await expect(oc.environments.list()).rejects.toThrow( + "oc.environments.list is not supported by the current OpenClaw Gateway yet", + ); + expect(transport.calls).toEqual([]); + }); + + it("cancels runs and checks model auth status through current Gateway methods", async () => { + const transport = new FakeTransport({ + agent: { status: "accepted", runId: "run_without_session" }, + "sessions.abort": { ok: true, status: "aborted", abortedRunId: "run_without_session" }, + "models.authStatus": { providers: [] }, + }); + const oc = new OpenClaw({ transport }); + + const run = await oc.runs.create({ + input: "start", + idempotencyKey: "cancel-test", + }); + await run.cancel(); + await oc.models.status({ probe: false }); + + expect(transport.calls.map((call) => call.method)).toEqual([ + "agent", + "sessions.abort", + "models.authStatus", + ]); + expect(transport.calls[1]?.params).toEqual({ runId: "run_without_session" }); + expect(transport.calls[2]?.params).toEqual({ probe: false }); + }); + + it("creates a session and sends a message as a run", async () => { + const transport = new FakeTransport({ + "sessions.create": { key: "session-main", label: "Main" }, + "sessions.send": { status: "accepted", runId: "run_session" }, + }); + const oc = new OpenClaw({ transport }); + + const session = await oc.sessions.create({ key: "session-main" }); + const run = await session.send({ message: "continue", thinking: "medium" }); + + expect(run.id).toBe("run_session"); + expect(transport.calls).toEqual([ + { + method: "sessions.create", + options: undefined, + params: { key: "session-main" }, + }, + { + method: "sessions.send", + options: { expectFinal: true }, + params: { key: "session-main", message: "continue", thinking: "medium" }, + }, + ]); + }); + + it("normalizes Gateway agent stream events into SDK events", () => { + const ts = 1_777_000_000_000; + + expect( + normalizeGatewayEvent({ + event: "agent", + seq: 1, + payload: { runId: "run_1", stream: "lifecycle", ts, data: { phase: "start" } }, + }), + ).toMatchObject({ + type: "run.started", + runId: "run_1", + data: { phase: "start" }, + }); + expect( + normalizeGatewayEvent({ + event: "agent", + seq: 2, + payload: { runId: "run_1", stream: "assistant", ts, data: { delta: "hello" } }, + }), + ).toMatchObject({ + type: "assistant.delta", + runId: "run_1", + data: { delta: "hello" }, + }); + expect( + normalizeGatewayEvent({ + event: "agent", + seq: 3, + payload: { runId: "run_1", stream: "lifecycle", ts, data: { phase: "end" } }, + }), + ).toMatchObject({ + type: "run.completed", + runId: "run_1", + data: { phase: "end" }, + }); + expect( + normalizeGatewayEvent({ + event: "agent", + seq: 4, + payload: { + runId: "run_1", + stream: "lifecycle", + ts, + data: { phase: "end", aborted: true }, + }, + }), + ).toMatchObject({ + type: "run.timed_out", + runId: "run_1", + data: { phase: "end", aborted: true }, + }); + expect( + normalizeGatewayEvent({ + event: "agent", + seq: 5, + payload: { + runId: "run_1", + stream: "lifecycle", + ts, + data: { phase: "end", aborted: true, stopReason: "rpc" }, + }, + }), + ).toMatchObject({ + type: "run.cancelled", + runId: "run_1", + data: { phase: "end", aborted: true, stopReason: "rpc" }, + }); + expect( + normalizeGatewayEvent({ + event: "agent", + seq: 6, + payload: { + runId: "run_1", + stream: "lifecycle", + ts, + data: { phase: "end", stopReason: "timeout" }, + }, + }), + ).toMatchObject({ + type: "run.timed_out", + runId: "run_1", + data: { phase: "end", stopReason: "timeout" }, + }); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000000..fb1c0799697 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,42 @@ +export { + Agent, + AgentsNamespace, + ApprovalsNamespace, + ArtifactsNamespace, + EnvironmentsNamespace, + ModelsNamespace, + OpenClaw, + Run, + RunsNamespace, + Session, + SessionsNamespace, + TasksNamespace, + ToolsNamespace, + type OpenClawOptions, +} from "./client.js"; +export { EventHub, isGatewayEvent } from "./event-hub.js"; +export { normalizeGatewayEvent } from "./normalize.js"; +export { GatewayClientTransport, isConnectableTransport } from "./transport.js"; +export type { + AgentRunParams, + ApprovalMode, + ArtifactSummary, + ConnectableOpenClawTransport, + EnvironmentSelection, + GatewayEvent, + GatewayRequestOptions, + JsonObject, + OpenClawEvent, + OpenClawEventType, + OpenClawTransport, + RunCreateParams, + RunResult, + RunStatus, + RuntimeSelection, + SDKError, + SDKMessage, + SessionCreateParams, + SessionSendParams, + SessionTarget, + WorkspaceSelection, +} from "./types.js"; diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts new file mode 100644 index 00000000000..1bfaf47c01d --- /dev/null +++ b/packages/sdk/src/normalize.ts @@ -0,0 +1,159 @@ +import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js"; + +function asRecord(value: unknown): JsonObject { + return typeof value === "object" && value !== null ? (value as JsonObject) : {}; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readLowerString(value: unknown): string | undefined { + return readString(value)?.toLowerCase(); +} + +function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType { + const status = readLowerString(data.status); + const stopReason = readLowerString(data.stopReason); + if ( + status === "timeout" || + status === "timed_out" || + stopReason === "timeout" || + stopReason === "timed_out" + ) { + return "run.timed_out"; + } + if ( + status === "aborted" || + status === "cancelled" || + status === "canceled" || + status === "killed" || + stopReason === "aborted" || + stopReason === "cancelled" || + stopReason === "canceled" || + stopReason === "killed" || + stopReason === "rpc" || + stopReason === "user" || + (data.aborted === true && stopReason === "stop") + ) { + return "run.cancelled"; + } + if (data.aborted === true) { + return "run.timed_out"; + } + return "run.completed"; +} + +function normalizeAgentEventType(payload: JsonObject): OpenClawEventType { + const stream = readString(payload.stream); + const data = asRecord(payload.data); + const phase = readString(data.phase); + const status = readString(data.status); + + if (stream === "assistant") { + return data.delta === true || typeof data.delta === "string" + ? "assistant.delta" + : "assistant.message"; + } + if (stream === "thinking" || stream === "plan") { + return "thinking.delta"; + } + if (stream === "lifecycle") { + if (phase === "start") { + return "run.started"; + } + if (phase === "end") { + return normalizeLifecycleEndEventType(data); + } + if (phase === "error") { + return "run.failed"; + } + } + if (stream === "tool" || stream === "item" || stream === "command_output") { + if (phase === "start" || status === "running") { + return "tool.call.started"; + } + if (phase === "delta" || phase === "update") { + return "tool.call.delta"; + } + if (phase === "end" || status === "completed") { + return "tool.call.completed"; + } + if (status === "failed" || status === "blocked") { + return "tool.call.failed"; + } + return "tool.call.delta"; + } + if (stream === "approval") { + return phase === "resolved" ? "approval.resolved" : "approval.requested"; + } + if (stream === "patch") { + return "artifact.updated"; + } + if (stream === "error") { + return "run.failed"; + } + return "raw"; +} + +function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType { + const payload = asRecord(event.payload); + switch (event.event) { + case "agent": + return normalizeAgentEventType(payload); + case "sessions.changed": { + const reason = readString(payload.reason); + if (reason === "create") { + return "session.created"; + } + if (reason === "compact") { + return "session.compacted"; + } + return "session.updated"; + } + case "session.message": + return "assistant.message"; + case "session.tool": + return "tool.call.delta"; + case "exec.approval.requested": + case "plugin.approval.requested": + return "approval.requested"; + case "exec.approval.resolved": + case "plugin.approval.resolved": + return "approval.resolved"; + case "task.updated": + case "tasks.changed": + return "task.updated"; + default: + return "raw"; + } +} + +export function normalizeGatewayEvent(event: GatewayEvent): OpenClawEvent { + const payload = asRecord(event.payload); + const runId = readString(payload.runId); + const sessionId = readString(payload.sessionId); + const sessionKey = readString(payload.sessionKey); + const taskId = readString(payload.taskId); + const agentId = readString(payload.agentId); + const ts = readNumber(payload.ts) ?? Date.now(); + const idParts = [event.seq ?? "local", event.event, runId, sessionKey, ts].filter(Boolean); + + return { + version: 1, + id: idParts.join(":"), + ts, + type: normalizeNamedEventType(event), + ...(runId ? { runId } : {}), + ...(sessionId ? { sessionId } : {}), + ...(sessionKey ? { sessionKey } : {}), + ...(taskId ? { taskId } : {}), + ...(agentId ? { agentId } : {}), + data: payload.data ?? payload, + raw: event, + }; +} diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts new file mode 100644 index 00000000000..d9428e35726 --- /dev/null +++ b/packages/sdk/src/transport.ts @@ -0,0 +1,150 @@ +import { GatewayClient } from "../../../src/gateway/client.js"; +import { EventHub } from "./event-hub.js"; +import type { + ConnectableOpenClawTransport, + GatewayEvent, + GatewayRequestOptions, + OpenClawTransport, +} from "./types.js"; + +type GatewayClientLike = { + request( + method: string, + params?: unknown, + options?: GatewayRequestOptions, + ): Promise; + stopAndWait(): Promise; +}; + +export type GatewayClientTransportOptions = { + url?: string; + connectChallengeTimeoutMs?: number; + connectDelayMs?: number; + preauthHandshakeTimeoutMs?: number; + tickWatchMinIntervalMs?: number; + requestTimeoutMs?: number; + token?: string; + bootstrapToken?: string; + deviceToken?: string; + password?: string; + instanceId?: string; + clientName?: string; + clientDisplayName?: string; + clientVersion?: string; + platform?: string; + deviceFamily?: string; + mode?: string; + role?: string; + scopes?: string[]; + caps?: string[]; + commands?: string[]; + permissions?: Record; + pathEnv?: string; + deviceIdentity?: unknown; + minProtocol?: number; + maxProtocol?: number; + tlsFingerprint?: string; + onEvent?: (evt: GatewayEvent) => void; + onHelloOk?: (hello: unknown) => void; + onConnectError?: (err: Error) => void; + onReconnectPaused?: (info: unknown) => void; + onClose?: (code: number, reason: string) => void; + onGap?: (info: { expected: number; received: number }) => void; +}; + +function toGatewayEvent(event: unknown): GatewayEvent { + const record = + typeof event === "object" && event !== null ? (event as Record) : {}; + const eventName = typeof record.event === "string" ? record.event : "unknown"; + return { + event: eventName, + payload: record.payload, + ...(typeof record.seq === "number" ? { seq: record.seq } : {}), + ...(record.stateVersion ? { stateVersion: record.stateVersion } : {}), + }; +} + +export class GatewayClientTransport implements ConnectableOpenClawTransport { + private readonly eventsHub = new EventHub(); + private readonly options: GatewayClientTransportOptions; + private client: GatewayClientLike | null = null; + private connectPromise: Promise | null = null; + private closePromise: Promise | null = null; + + constructor(options: GatewayClientTransportOptions = {}) { + this.options = options; + } + + connect(): Promise { + if (this.connectPromise) { + return this.connectPromise; + } + this.connectPromise = new Promise((resolve, reject) => { + const client = new GatewayClient({ + ...this.options, + onEvent: (event: unknown) => { + const normalized = toGatewayEvent(event); + this.eventsHub.publish(normalized); + this.options.onEvent?.(normalized); + }, + onHelloOk: (_hello: unknown) => { + this.options.onHelloOk?.(_hello); + resolve(); + }, + onConnectError: (error: Error) => { + this.options.onConnectError?.(error); + if (this.client === client) { + this.client = null; + } + if (this.connectPromise) { + this.connectPromise = null; + } + void client.stopAndWait().catch(() => {}); + reject(error); + }, + onReconnectPaused: this.options.onReconnectPaused, + onClose: this.options.onClose, + onGap: this.options.onGap, + } as never); + + this.client = client; + client.start(); + }); + return this.connectPromise; + } + + async request( + method: string, + params?: unknown, + options?: GatewayRequestOptions, + ): Promise { + await this.connect(); + if (!this.client) { + throw new Error("gateway transport is not connected"); + } + return await this.client.request(method, params, options); + } + + events(filter?: (event: GatewayEvent) => boolean): AsyncIterable { + return this.eventsHub.stream(filter); + } + + async close(): Promise { + if (this.closePromise) { + return await this.closePromise; + } + this.eventsHub.close(); + const client = this.client; + this.client = null; + this.connectPromise = null; + this.closePromise = client?.stopAndWait() ?? Promise.resolve(); + await this.closePromise; + this.closePromise = null; + } +} + +export function isConnectableTransport( + transport: OpenClawTransport, +): transport is ConnectableOpenClawTransport { + return typeof (transport as { connect?: unknown }).connect === "function"; +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 00000000000..835d289d9e9 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,201 @@ +export type JsonObject = Record; + +export type GatewayRequestOptions = { + expectFinal?: boolean; + timeoutMs?: number | null; +}; + +export type GatewayEvent = { + event: string; + payload?: unknown; + seq?: number; + stateVersion?: unknown; +}; + +export type OpenClawTransport = { + request( + method: string, + params?: unknown, + options?: GatewayRequestOptions, + ): Promise; + events(filter?: (event: GatewayEvent) => boolean): AsyncIterable; + close?(): Promise | void; +}; + +export type ConnectableOpenClawTransport = OpenClawTransport & { + connect(): Promise; +}; + +export type RuntimeSelection = + | "auto" + | { type: "embedded"; id: "pi" | "codex" | (string & {}) } + | { type: "cli"; id: "claude-cli" | (string & {}) } + | { type: "acp"; harness: "claude" | "cursor" | "gemini" | "opencode" | (string & {}) } + | { type: "managed"; provider: "local" | "node" | "testbox" | "cloud" | (string & {}) }; + +export type EnvironmentSelection = + | { type: "local"; cwd?: string } + | { type: "gateway"; url?: string; cwd?: string } + | { type: "node"; nodeId: string; cwd?: string } + | { type: "managed"; provider: string; repo?: string; ref?: string } + | { type: "ephemeral"; provider: string; repo?: string; ref?: string }; + +export type WorkspaceSelection = { + cwd?: string; + repo?: string; + ref?: string; +}; + +export type ApprovalMode = "ask" | "never" | "auto" | "trusted"; + +export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out"; + +export type RunTimestamp = string | number; + +export type SDKMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string; + name?: string; + toolCallId?: string; +}; + +export type ArtifactSummary = { + id: string; + runId?: string; + sessionId?: string; + type: + | "file" + | "patch" + | "diff" + | "log" + | "media" + | "screenshot" + | "trajectory" + | "pull_request" + | "workspace" + | (string & {}); + title?: string; + mimeType?: string; + sizeBytes?: number; + createdAt?: string; + expiresAt?: string; +}; + +export type SDKError = { + code?: string; + message: string; + details?: unknown; +}; + +export type RunResult = { + runId: string; + status: RunStatus; + sessionId?: string; + sessionKey?: string; + taskId?: string; + startedAt?: RunTimestamp; + endedAt?: RunTimestamp; + output?: { + text?: string; + messages?: SDKMessage[]; + }; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + costUsd?: number; + }; + artifacts?: ArtifactSummary[]; + error?: SDKError; + raw?: unknown; +}; + +export type OpenClawEventType = + | "run.created" + | "run.queued" + | "run.started" + | "run.completed" + | "run.failed" + | "run.cancelled" + | "run.timed_out" + | "assistant.delta" + | "assistant.message" + | "thinking.delta" + | "tool.call.started" + | "tool.call.delta" + | "tool.call.completed" + | "tool.call.failed" + | "approval.requested" + | "approval.resolved" + | "question.requested" + | "question.answered" + | "artifact.created" + | "artifact.updated" + | "session.created" + | "session.updated" + | "session.compacted" + | "task.updated" + | "git.branch" + | "git.diff" + | "git.pr" + | "raw"; + +export type OpenClawEvent = { + version: 1; + id: string; + ts: number; + type: OpenClawEventType; + runId?: string; + sessionId?: string; + sessionKey?: string; + taskId?: string; + agentId?: string; + data: TData; + raw?: GatewayEvent; +}; + +export type AgentRunParams = { + input: string; + agentId?: string; + model?: string; + thinking?: string; + sessionId?: string; + sessionKey?: string; + deliver?: boolean; + attachments?: unknown[]; + timeoutMs?: number; + label?: string; + runtime?: RuntimeSelection; + environment?: EnvironmentSelection; + workspace?: WorkspaceSelection; + approvals?: ApprovalMode; + idempotencyKey?: string; +}; + +export type SessionCreateParams = { + key?: string; + agentId?: string; + label?: string; + model?: string; + parentSessionKey?: string; + task?: string; + message?: string; +}; + +export type SessionSendParams = { + key: string; + message: string; + thinking?: string; + attachments?: unknown[]; + timeoutMs?: number; + idempotencyKey?: string; +}; + +export type SessionTarget = { + key: string; + sessionId?: string; + agentId?: string; + label?: string; +}; + +export type RunCreateParams = AgentRunParams; diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index eb537090426..48540cd00aa 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -152,20 +152,39 @@ export function upsertCanonicalModelConfigEntry( params: { provider: string; model: string }, ) { const key = modelKey(params.provider, params.model); - const legacyKey = legacyModelKey(params.provider, params.model); - if (legacyKey && models[legacyKey]) { + const legacyKeys = [ + legacyModelKey(params.provider, params.model), + `${params.provider}/${key}`, + ].filter( + (legacyKey): legacyKey is string => + typeof legacyKey === "string" && legacyKey.length > 0 && legacyKey !== key, + ); + let legacyEntry: AgentModelEntryConfig | undefined; + for (const legacyKey of legacyKeys) { + const entry = models[legacyKey]; + if (!entry) { + continue; + } + Object.assign((legacyEntry ??= {}), entry); + legacyEntry.params = { + ...legacyEntry.params, + ...entry.params, + }; + } + + if (legacyEntry) { models[key] = { - ...models[legacyKey], + ...legacyEntry, ...models[key], params: { - ...models[legacyKey].params, + ...legacyEntry.params, ...models[key]?.params, }, }; } else if (!models[key]) { models[key] = {}; } - if (legacyKey) { + for (const legacyKey of legacyKeys) { delete models[legacyKey]; } return key; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 70572320dbf..6eb8dfd860a 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -123,7 +123,7 @@ export const SessionsMessagesUnsubscribeParamsSchema = Type.Object( export const SessionsAbortParamsSchema = Type.Object( { - key: NonEmptyString, + key: Type.Optional(NonEmptyString), runId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 851360a088b..a154067e427 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -64,6 +64,7 @@ import { validateSessionsResolveParams, validateSessionsSendParams, } from "../protocol/index.js"; +import { resolveSessionKeyForRun } from "../server-session-key.js"; import { getSessionCompactionCheckpoint, listSessionCompactionCheckpoints, @@ -1293,7 +1294,16 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } const p = params; - const key = requireSessionKey(p.key, respond); + const requestedRunId = readStringValue(p.runId); + const keyCandidate = + p.key ?? + (requestedRunId ? context.chatAbortControllers.get(requestedRunId)?.sessionKey : undefined) ?? + (requestedRunId ? resolveSessionKeyForRun(requestedRunId) : undefined); + if (!keyCandidate && requestedRunId) { + respond(true, { ok: true, abortedRunId: null, status: "no-active-run" }); + return; + } + const key = requireSessionKey(keyCandidate, respond); if (!key) { return; } @@ -1302,14 +1312,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { context, requestedKey: key, canonicalKey, - runId: readStringValue(p.runId), + runId: requestedRunId, }); let abortedRunId: string | null = null; await chatHandlers["chat.abort"]({ req, params: { sessionKey: abortSessionKey, - runId: readStringValue(p.runId), + runId: requestedRunId, }, respond: (ok, payload, error, meta) => { if (!ok) { diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index f91f178d2f0..45a8c24ed9d 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -292,6 +292,41 @@ describe("gateway server chat", () => { } }); + test("sessions.abort resolves active runs by runId without a caller session key", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-abort-runid-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + try { + await writeSessionStore({ + entries: { + "agent:main:dashboard:test-abort-runid": { + sessionId: "sess-dashboard-abort-runid", + updatedAt: Date.now(), + }, + }, + }); + + const sendRes = await rpcReq(ws, "sessions.send", { + key: "agent:main:dashboard:test-abort-runid", + message: "hello", + idempotencyKey: "idem-sessions-abort-runid-1", + timeoutMs: 30_000, + }); + expect(sendRes.ok).toBe(true); + + const abortRes = await rpcReq(ws, "sessions.abort", { + runId: "idem-sessions-abort-runid-1", + }); + expect(abortRes.ok).toBe(true); + expect(["aborted", "no-active-run"]).toContain(abortRes.payload?.status); + if (abortRes.payload?.status === "aborted") { + expect(abortRes.payload?.abortedRunId).toBe("idem-sessions-abort-runid-1"); + } + } finally { + testState.sessionStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + test("sanitizes inbound chat.send message text and rejects null bytes", async () => { const nullByteRes = await rpcReq(ws, "chat.send", { sessionKey: "main", diff --git a/src/infra/vitest-e2e-config.test.ts b/src/infra/vitest-e2e-config.test.ts index c490e8ba592..2e8e1dc3dba 100644 --- a/src/infra/vitest-e2e-config.test.ts +++ b/src/infra/vitest-e2e-config.test.ts @@ -15,6 +15,7 @@ describe("e2e vitest config", () => { expect(e2eConfig.test?.include).toEqual([ "test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts", + "packages/**/*.e2e.test.ts", "src/gateway/gateway.test.ts", "src/gateway/server.startup-matrix-migration.integration.test.ts", "src/gateway/sessions-history-http.test.ts", diff --git a/test/vitest/vitest.e2e.config.ts b/test/vitest/vitest.e2e.config.ts index aedba717a20..5ef1989763e 100644 --- a/test/vitest/vitest.e2e.config.ts +++ b/test/vitest/vitest.e2e.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ include: [ "test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts", + "packages/**/*.e2e.test.ts", "src/gateway/gateway.test.ts", "src/gateway/server.startup-matrix-migration.integration.test.ts", "src/gateway/sessions-history-http.test.ts", diff --git a/tsconfig.json b/tsconfig.json index b246fc2f4fa..4212fa486c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], + "@openclaw/sdk": ["./packages/sdk/src/index.ts"], "@openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "@openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"],