mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
feat: add OpenClaw SDK package
This commit is contained in:
367
docs/concepts/openclaw-sdk.md
Normal file
367
docs/concepts/openclaw-sdk.md
Normal file
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
378
docs/reference/openclaw-sdk-api-design.md
Normal file
378
docs/reference/openclaw-sdk-api-design.md
Normal file
@@ -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)
|
||||
21
packages/sdk/package.json
Normal file
21
packages/sdk/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
538
packages/sdk/src/client.ts
Normal file
538
packages/sdk/src/client.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<void> {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
if (isConnectableTransport(this.transport)) {
|
||||
await this.transport.connect();
|
||||
}
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.transport.close?.();
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
await this.connect();
|
||||
return await this.transport.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
events(filter?: (event: OpenClawEvent) => boolean): AsyncIterable<OpenClawEvent> {
|
||||
const source = this.transport.events();
|
||||
async function* iterate(): AsyncIterable<OpenClawEvent> {
|
||||
for await (const event of source) {
|
||||
const normalized = normalizeGatewayEvent(event);
|
||||
if (!filter || filter(normalized)) {
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return iterate();
|
||||
}
|
||||
|
||||
rawEvents(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
|
||||
return this.transport.events(filter);
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
constructor(
|
||||
private readonly client: OpenClaw,
|
||||
readonly id: string,
|
||||
) {}
|
||||
|
||||
async run(input: string | Omit<AgentRunParams, "agentId">): Promise<Run> {
|
||||
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<unknown> {
|
||||
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<OpenClawEvent> {
|
||||
return this.client.events((event) => {
|
||||
if (event.runId !== this.id) {
|
||||
return false;
|
||||
}
|
||||
return filter ? filter(event) : true;
|
||||
});
|
||||
}
|
||||
|
||||
async wait(options?: { timeoutMs?: number }): Promise<RunResult> {
|
||||
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<string, unknown>) : {};
|
||||
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<unknown> {
|
||||
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<SessionSendParams, "key">): Promise<Run> {
|
||||
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<string, unknown>) : {};
|
||||
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<unknown> {
|
||||
return await this.client.request("sessions.abort", {
|
||||
key: this.key,
|
||||
...(runId ? { runId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async patch(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.patch", { ...params, key: this.key });
|
||||
}
|
||||
|
||||
async compact(params?: { maxLines?: number }): Promise<unknown> {
|
||||
return await this.client.request("sessions.compact", { key: this.key, ...params });
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("agents.list", params);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Agent> {
|
||||
return new Agent(this.client, id);
|
||||
}
|
||||
|
||||
async create(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("agents.create", params);
|
||||
}
|
||||
|
||||
async update(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("agents.update", params);
|
||||
}
|
||||
|
||||
async delete(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("agents.delete", params);
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.list", params);
|
||||
}
|
||||
|
||||
async create(params: SessionCreateParams = {}): Promise<Session> {
|
||||
const raw = await this.client.request("sessions.create", params);
|
||||
const record = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
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<Session> {
|
||||
const key = typeof target === "string" ? target : target.key;
|
||||
return new Session(this.client, key);
|
||||
}
|
||||
|
||||
async resolve(params: Record<string, unknown>): Promise<unknown> {
|
||||
return await this.client.request("sessions.resolve", params);
|
||||
}
|
||||
|
||||
async send(input: SessionSendParams): Promise<Run> {
|
||||
return await new Session(this.client, input.key).send(input);
|
||||
}
|
||||
}
|
||||
|
||||
export class RunsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async create(params: RunCreateParams): Promise<Run> {
|
||||
const raw = await this.client.request("agent", buildAgentParams(params), {
|
||||
expectFinal: false,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const record = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
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<Run> {
|
||||
return new Run(this.client, runId);
|
||||
}
|
||||
|
||||
events(runId: string): AsyncIterable<OpenClawEvent> {
|
||||
return new Run(this.client, runId).events();
|
||||
}
|
||||
|
||||
async wait(runId: string, options?: { timeoutMs?: number }): Promise<RunResult> {
|
||||
return await new Run(this.client, runId).wait(options);
|
||||
}
|
||||
|
||||
async cancel(runId: string, sessionKey?: string): Promise<unknown> {
|
||||
return await new Run(this.client, runId, sessionKey).cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class RpcNamespace {
|
||||
constructor(
|
||||
protected readonly client: OpenClaw,
|
||||
private readonly prefix: string,
|
||||
) {}
|
||||
|
||||
protected async call<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
return await this.client.request<T>(`${this.prefix}.${method}`, params, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class TasksNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "tasks");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.tasks.list");
|
||||
}
|
||||
|
||||
async get(taskId: string): Promise<unknown> {
|
||||
void taskId;
|
||||
return unsupportedGatewayApi("oc.tasks.get");
|
||||
}
|
||||
|
||||
async cancel(taskId: string): Promise<unknown> {
|
||||
void taskId;
|
||||
return unsupportedGatewayApi("oc.tasks.cancel");
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelsNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "models");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.call("list", params);
|
||||
}
|
||||
|
||||
async status(params?: unknown): Promise<unknown> {
|
||||
return await this.call("authStatus", params);
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolsNamespace extends RpcNamespace {
|
||||
constructor(client: OpenClaw) {
|
||||
super(client, "tools");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.call("catalog", params);
|
||||
}
|
||||
|
||||
async effective(params?: unknown): Promise<unknown> {
|
||||
return await this.call("effective", params);
|
||||
}
|
||||
|
||||
async invoke(name: string, params?: unknown): Promise<unknown> {
|
||||
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<unknown> {
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.artifacts.list");
|
||||
}
|
||||
|
||||
async get(id: string): Promise<unknown> {
|
||||
void id;
|
||||
return unsupportedGatewayApi("oc.artifacts.get");
|
||||
}
|
||||
|
||||
async download(id: string): Promise<unknown> {
|
||||
void id;
|
||||
return unsupportedGatewayApi("oc.artifacts.download");
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovalsNamespace {
|
||||
constructor(private readonly client: OpenClaw) {}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
return await this.client.request("exec.approval.list", params);
|
||||
}
|
||||
|
||||
async respond(approvalId: string, decision: Record<string, unknown>): Promise<unknown> {
|
||||
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<unknown> {
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.environments.list");
|
||||
}
|
||||
|
||||
async create(params?: unknown): Promise<unknown> {
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.environments.create");
|
||||
}
|
||||
|
||||
async status(environmentId: string): Promise<unknown> {
|
||||
void environmentId;
|
||||
return unsupportedGatewayApi("oc.environments.status");
|
||||
}
|
||||
|
||||
async delete(environmentId: string): Promise<unknown> {
|
||||
void environmentId;
|
||||
return unsupportedGatewayApi("oc.environments.delete");
|
||||
}
|
||||
}
|
||||
77
packages/sdk/src/event-hub.ts
Normal file
77
packages/sdk/src/event-hub.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { GatewayEvent } from "./types.js";
|
||||
|
||||
type Listener<T> = (event: T) => void;
|
||||
|
||||
export class EventHub<T> {
|
||||
private closed = false;
|
||||
private readonly listeners = new Set<Listener<T>>();
|
||||
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<T> {
|
||||
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<void>((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"
|
||||
);
|
||||
}
|
||||
278
packages/sdk/src/index.e2e.test.ts
Normal file
278
packages/sdk/src/index.e2e.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
async function createFakeGateway(port = 0): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port });
|
||||
servers.push(server);
|
||||
await new Promise<void>((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<void>((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<void>((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<never>((_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();
|
||||
}
|
||||
});
|
||||
});
|
||||
331
packages/sdk/src/index.test.ts
Normal file
331
packages/sdk/src/index.test.ts
Normal file
@@ -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<GatewayEvent>();
|
||||
|
||||
constructor(private readonly responses: Record<string, unknown>) {}
|
||||
|
||||
async request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
this.calls.push({ method, params, options });
|
||||
return this.responses[method] as T;
|
||||
}
|
||||
|
||||
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
42
packages/sdk/src/index.ts
Normal file
42
packages/sdk/src/index.ts
Normal file
@@ -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";
|
||||
159
packages/sdk/src/normalize.ts
Normal file
159
packages/sdk/src/normalize.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
150
packages/sdk/src/transport.ts
Normal file
150
packages/sdk/src/transport.ts
Normal file
@@ -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<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T>;
|
||||
stopAndWait(): Promise<void>;
|
||||
};
|
||||
|
||||
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<string, boolean>;
|
||||
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<string, unknown>) : {};
|
||||
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<GatewayEvent>();
|
||||
private readonly options: GatewayClientTransportOptions;
|
||||
private client: GatewayClientLike | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(options: GatewayClientTransportOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
this.connectPromise = new Promise<void>((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<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("gateway transport is not connected");
|
||||
}
|
||||
return await this.client.request<T>(method, params, options);
|
||||
}
|
||||
|
||||
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent> {
|
||||
return this.eventsHub.stream(filter);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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";
|
||||
}
|
||||
201
packages/sdk/src/types.ts
Normal file
201
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type GatewayRequestOptions = {
|
||||
expectFinal?: boolean;
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
export type GatewayEvent = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
stateVersion?: unknown;
|
||||
};
|
||||
|
||||
export type OpenClawTransport = {
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: GatewayRequestOptions,
|
||||
): Promise<T>;
|
||||
events(filter?: (event: GatewayEvent) => boolean): AsyncIterable<GatewayEvent>;
|
||||
close?(): Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ConnectableOpenClawTransport = OpenClawTransport & {
|
||||
connect(): Promise<void>;
|
||||
};
|
||||
|
||||
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<TData = unknown> = {
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user