From 2ddf8fa6d5a5df00f2caf2f3aeee712fd4840a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Wed, 24 Jun 2026 17:02:48 +0800 Subject: [PATCH 1/7] docs(kernel): add local runtime sandbox transition design spec Document the interim local provider strategy so OAK can run file/bash workloads on the host runtime without AGS, while preserving the existing ags-stateful path for future TCB productization. Co-authored-by: Cursor --- ...-06-23-oak-local-runtime-sandbox-design.md | 476 ++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 packages/open-agent-kernel/docs/superpowers/specs/2026-06-23-oak-local-runtime-sandbox-design.md diff --git a/packages/open-agent-kernel/docs/superpowers/specs/2026-06-23-oak-local-runtime-sandbox-design.md b/packages/open-agent-kernel/docs/superpowers/specs/2026-06-23-oak-local-runtime-sandbox-design.md new file mode 100644 index 0000000..5525fd4 --- /dev/null +++ b/packages/open-agent-kernel/docs/superpowers/specs/2026-06-23-oak-local-runtime-sandbox-design.md @@ -0,0 +1,476 @@ +# OAK 本地 Runtime 沙箱过渡方案 + +**Status:** Draft — 待 Phase 0 实施 +**Date:** 2026-06-23 +**Scope:** `@cloudbase/open-agent-kernel` +**Related:** `AgsStatefulSandbox`、`tcb-remote-workspace` workspace snapshot、Claude Agent SDK 内置工具 + +--- + +## 1. 背景 + +### 1.1 现状 + +OAK 当前默认沙箱方案基于 **腾讯云 AGS(Agent Sandbox)** 产品: + +- 控制面:AGS OpenAPI(CreateSandboxTool / StartSandboxInstance) +- 数据面:TRW 业务镜像 HTTP Gateway(`/api/tools/*`、`/api/workspace/*`) +- 工具暴露:禁用 Claude SDK 内置工具,改注入 `mcp__sandbox__*` MCP server + +用户启用沙箱需自行配置:`CLOUDBASE_APIKEY`、`OAK_SANDBOX_IMAGE`、`OAK_SANDBOX_TOOL_ROLE_ARN`、`credentials` 等 AGS 控制面凭证。 + +### 1.2 问题 + +1. **AGS 产品化未完成**:云开发用户接入 OAK 仍需自行打通 AGS 链路,接入成本高。 +2. **TCB↔AGS 对接排期中**:产品化链路尚未上线,短期无法依赖默认 AGS 路径。 +3. **云函数等 Serverless Runtime 已可运行 OAK**:宿主进程本身具备可写目录(如 `/tmp`),具备「本地文件 + bash」的物理条件。 + +### 1.3 目标 + +在 AGS 产品化就绪前,提供 **Local Runtime 沙箱** 过渡方案: + +- 在 OAK **宿主进程**(HTTP 云函数、CloudRun 等)内直接完成文件读写与 bash +- 在配置的工作目录下,于合适时机完成 **COS 双向同步**(restore + snapshot) +- **保留** 现有 AGS 实现,通过 provider 分支切换,便于产品化后平滑迁移 + +### 1.4 非目标 + +- 不替代 TRW 全能力(PTY、preview、in-sandbox agents、mcporter 动态 MCP 等) +- 不提供容器级隔离(接受过渡期的安全风险) +- Phase 0 不要求 CloudBase MCP 在 local 模式立即可用 + +--- + +## 2. 设计原则 + +| 原则 | 说明 | +|------|------| +| **Strategy 分支,不删 AGS** | `SandboxRuntime` 协议不变;新增 `LocalRuntimeSandbox`,保留 `AgsStatefulSandbox` | +| **默认 local,显式 ags** | 新用户 `sandbox: { enabled: true }` 默认 `provider: 'local'`;AGS 用户显式 `provider: 'ags-stateful'` | +| **cwd 由调用方主导** | kernel 不硬编码 `/home/user`;推导规则透明、可覆盖 | +| **COS 格式对齐 TRW** | local 模式 snapshot 使用与 TRW 兼容的 tar.zst + key 布局,便于日后切换 backend | +| **生命周期钩子复用** | send 前 restore、send 后 snapshot 沿用现有 `create-agent.ts` 编排 | + +--- + +## 3. 架构总览 + +``` +createAgent({ sandbox: { provider: 'local' | 'ags-stateful' } }) + │ + ▼ +resolveSandboxConfig() + │ + ┌────┴────┐ + │ │ + local ags-stateful + │ │ + ▼ ▼ +LocalRuntimeSandbox AgsStatefulSandbox.acquire() + │ │ + │ └──► SandboxInstance.request() ──► TRW :9000 + │ + ├──► SDK 内置工具 (Bash/Read/Write/Edit/Glob/Grep) + │ + └──► LocalWorkspaceSyncEngine ──► COS (restore / snapshot) +``` + +### 3.1 工具面分支 + +| 模式 | 文件 / Shell | CloudBase 工具 | +|------|-------------|----------------| +| **local** | Claude SDK **内置工具** | Phase 0 默认关闭;Phase 1+ 进程内 MCP | +| **ags-stateful** | `mcp__sandbox__*`(TRW HTTP) | `mcp__cloudbase__*`(沙箱内 mcporter) | +| **none** | 无(维持现状) | 无 | + +`permissionMode: 'bypassPermissions'` + PreToolUse hook **两种模式均保留**。 + +--- + +## 4. 配置模型 + +### 4.1 SandboxConfig 扩展 + +```typescript +export interface SandboxConfig { + enabled?: boolean + + /** + * - 'local'(新默认):宿主进程本地 FS + SDK 内置工具 + * - 'ags-stateful':现有 AGS + TRW 远程沙箱 + */ + provider?: 'local' | 'ags-stateful' + + /** local 模式:工作区根目录;未设则按 deriveDefaultWorkspaceRoot 推导 */ + workspaceRoot?: string + + /** ags-stateful 专用 */ + apiKey?: string + scope?: 'session' | 'shared' + runtime?: unknown + + cloudbaseTools?: boolean + userCredentials?: SandboxUserCredentials | (() => Promise) + + workspaceSnapshot?: 'auto' | 'enabled' | 'disabled' + workspaceSnapshotTimeoutMs?: number + workspaceInitTimeoutMs?: number + + /** local 模式 COS 配置(Phase 1) */ + cos?: LocalCosConfig +} + +interface LocalCosConfig { + bucket?: string + region?: string + /** 对象前缀,默认 `/oak-workspaces/{userId}` */ + prefix?: string +} +``` + +### 4.2 resolveSandboxConfig 行为 + +```typescript +function resolveSandboxConfig(config: AgentConfig): AgentConfig['sandbox'] | undefined { + const sandbox = config.sandbox + if (!sandbox || sandbox.enabled === false) return undefined + if (sandbox.runtime) return sandbox // 显式 runtime 优先 + + const provider = sandbox.provider ?? 'local' + + if (provider === 'local') { + return { + ...sandbox, + enabled: true, + provider: 'local', + runtime: new LocalRuntimeSandbox({ workspaceRoot: sandbox.workspaceRoot }), + } + } + + if (provider === 'ags-stateful') { + // 现有逻辑:apiKey、AgsStatefulSandbox、scope 默认 shared + } + + throw new InvalidConfigError(`unsupported provider: ${provider}`) +} +``` + +### 4.3 配置示例 + +**云函数 / Serverless(过渡默认):** + +```typescript +createAgent({ + envId: 'my-env', + credentials: { secretId, secretKey }, + model: 'glm-5.1', + cwd: '/tmp/oak-workspaces/demo', // 或省略,使用自动推导 + sandbox: { + enabled: true, + // provider: 'local' // 可省略 + workspaceSnapshot: 'auto', + cloudbaseTools: false, // Phase 0 + }, +}) +``` + +**AGS 产品化路径(不变):** + +```typescript +createAgent({ + envId: 'my-env', + credentials: { secretId, secretKey }, + model: 'glm-5.1', + sandbox: { + enabled: true, + provider: 'ags-stateful', + apiKey: process.env.CLOUDBASE_APIKEY, + scope: 'shared', + workspaceSnapshot: 'auto', + }, +}) +``` + +--- + +## 5. LocalRuntimeSandbox + +### 5.1 职责 + +实现现有 `SandboxRuntime` 协议(`backend = 'local'`): + +- `acquire(ctx)`:创建/确认 workspace 目录,返回轻量 `SandboxInstance` +- `release()`:可选触发最终 snapshot;**不删除**本地目录(COS 为 source of truth) + +local 模式 **不暴露 HTTP 数据面**。`SandboxInstance.request()` 若被调用应抛明确错误;snapshot 走 `LocalWorkspaceSyncEngine`,不经过 TRW。 + +### 5.2 acquire 流程 + +``` +1. resolveWorkspacePath(ctx) + → config.cwd ?? sandbox.workspaceRoot ?? deriveDefaultWorkspaceRoot(ctx) + +2. fs.mkdir(root, { recursive: true }) + +3. probeWritable(root) — 不可写则 ConfigError(serverless 只读 FS 早 fail) + +4. return SandboxInstance { id: `local:${conversationId}`, release } +``` + +--- + +## 6. cwd 与工作目录 + +### 6.1 为何不用 `/home/user` 作为通用默认 + +| 环境 | 说明 | +|------|------| +| TRW / AGS 容器 | `/home/user` 是镜像约定 ✅ | +| SCF 云函数 | 常见可写路径为 `/tmp`,非 `/home/user` | +| CloudRun | 取决于镜像,不一定存在 `/home/user` | + +**OAK 不应假设容器布局**;由调用方或平台 env 指定可写路径。 + +### 6.2 推导优先级 + +``` +effectiveWorkspaceRoot = + config.cwd // 1. AgentConfig.cwd(同时作为 SDK cwd) + ?? config.sandbox?.workspaceRoot // 2. sandbox 级覆盖 + ?? deriveDefaultWorkspaceRoot(ctx) // 3. 平台推导 + +deriveDefaultWorkspaceRoot(ctx): + base = process.env.OAK_WORKSPACE_ROOT ?? path.join(os.tmpdir(), 'oak-workspaces') + return path.join(base, ctx.envId, ctx.userId, ctx.conversationId) +``` + +### 6.3 与 Claude SDK 对齐 + +local 模式下 **`AgentConfig.cwd` 必须等于 workspace root**(或与 `sandbox.workspaceRoot` 显式一致)。SDK 内置工具以 `cwd` 为工作目录;COS 同步同一目录树。 + +若 `config.cwd` 与 `sandbox.workspaceRoot` 冲突 → `ConfigError`。 + +--- + +## 7. SDK 内置工具分支 + +### 7.1 当前行为 + +`agent-builder.ts` 禁用全部 SDK 内置工具,仅保留 `Skill`(若启用 skills): + +```typescript +tools: config.skills?.enabled !== undefined ? ['Skill'] : [], +``` + +远程沙箱能力通过 `createSandboxMcpServer(sandboxInstance)` 注入。 + +### 7.2 改造后 + +```typescript +function resolveSdkTools(config, sandboxMode: 'local' | 'remote' | 'none') { + if (sandboxMode === 'local') { + const tools = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'] + if (config.skills?.enabled !== undefined) tools.push('Skill') + return tools + } + if (sandboxMode === 'remote') { + return config.skills?.enabled !== undefined ? ['Skill'] : [] + } + return config.skills?.enabled !== undefined ? ['Skill'] : [] +} +``` + +| sandboxMode | 注入 sandbox MCP | SDK tools | +|-------------|------------------|-----------| +| `local` | 否 | Bash, Read, Write, Edit, Glob, Grep (+ Skill) | +| `remote` | 是 (`mcp__sandbox__*`) | Skill only 或 [] | +| `none` | 否 | 维持现状 | + +--- + +## 8. COS 双向同步(Local 模式) + +### 8.1 与 AGS 模式对比 + +| | AGS + TRW | Local | +|--|-----------|-------| +| Restore | `POST /api/workspace/init`(TRW 内 restoreFromCos) | OAK 进程内 `restoreFromCos()` | +| Snapshot | `POST /api/workspace/snapshot` | OAK 进程内 `snapshotToCos()` | +| 格式 | tar.zst | **兼容同一格式** | + +### 8.2 模块结构(Phase 1 新增) + +``` +src/sandbox/local-workspace-sync/ +├── cos-client.ts # CloudBase / COS SDK 上传下载 +├── snapshot-pack.ts # tar + zstd(参考 TRW tests/helpers) +├── restore.ts +├── snapshot.ts +├── excludes.ts # node_modules、.git 等(对齐 TRW snapshot-excludes) +└── engine.ts # LocalWorkspaceSyncEngine +``` + +### 8.3 COS Key 布局(与 TRW 对齐) + +``` +cos://{bucket}/oak-workspaces/{userId}/.snapshot-{timestamp}.tar.zst +cos://{bucket}/oak-workspaces/{userId}/.snapshot-latest.json +``` + +### 8.4 触发时机 + +沿用 `create-agent.ts` / `runClaudeQuery` 现有钩子: + +| 时机 | 动作 | 失败策略 | +|------|------|----------| +| 首次 `send()` 前 | `restoreFromCos(workspaceRoot)` | **致命**(与 Spec B 一致) | +| 每次 `send()` finally | `snapshotToCos(workspaceRoot)` | **非致命** warning | +| `session.abort()` | 可选最终 snapshot | best-effort | +| 写操作后 debounced sync | Phase 2 可选 | 非致命 | + +### 8.5 resolveSnapshotMode 扩展 + +```typescript +const supportsSnapshot = backend === 'ags-stateful' || backend === 'local' + +// ags-stateful:仍要求 scope === 'shared'(现有约束) +// local:不要求 shared scope;用 conversationId 子目录 + COS userId prefix 隔离 +``` + +--- + +## 9. CloudBase MCP(local 模式) + +当前 `cloudbase-mcp.ts` 依赖 `SandboxInstance.request` + 沙箱内 mcporter bash,**无法在 local 模式直接复用**。 + +| Phase | 方案 | +|-------|------| +| **P0** | local 默认 `cloudbaseTools: false` | +| **P1** | `createCloudBaseMcpServerInProcess()`:进程内 mcporter 或 TCB Node SDK 封装 | +| **P2** | TCB 产品化 MCP Gateway,统一 local / remote | + +--- + +## 10. Session 编排改造 + +### 10.1 抽象 sandboxMode + +```typescript +type SandboxMode = 'none' | 'local' | 'remote' + +async function prepareSandboxSession(...) { + if (mode === 'local') { + const workspaceRoot = await localRuntime.prepare(ctx) + if (syncEngine) await syncEngine.restore(workspaceRoot) + return { mode: 'local', workspaceRoot } + } + if (mode === 'remote') { + const sandbox = await agsRuntime.acquire(ctx) + if (syncEngine) await syncEngine.bootstrap(sandbox) + return { mode: 'remote', sandboxInstance: sandbox } + } +} +``` + +### 10.2 buildClaudeQueryOptions 入参 + +```typescript +buildClaudeQueryOptions(config, { + sandboxMode: 'local' | 'remote' | 'none', + sandboxInstance?: SandboxInstance, // remote only + workspaceRoot?: string, // local only + ... +}) +``` + +--- + +## 11. 安全与风险 + +local 模式为 **临时过渡**,必须在文档与运行时 warning 中明确: + +| 风险 | 说明 | 缓解 | +|------|------|------| +| 无容器隔离 | agent bash 与 OAK 同进程/同 VM | 仅 trusted 场景;生产加 network policy | +| 路径逃逸 | 内置工具可能访问 cwd 外 | Phase 2:workspaceRoot path guard | +| 多租户 | 同实例多 user | 必须 userId + conversationId 子目录 + COS prefix | +| 凭证泄露 | bash 可读进程 env | 延续 AGENTS.md 日志静态字符串规则 | +| 并发 snapshot | 云函数多并发同 conversation | Phase 2:COS 乐观锁 / 版本号 | + +**启动 warning(每 session 一次):** + +``` +[oak][sandbox] provider=local is transitional — no container isolation. +Use provider='ags-stateful' when TCB sandbox product is available. +``` + +--- + +## 12. 实施分期 + +### Phase 0 — 最小可用 + +- [ ] `LocalRuntimeSandbox` + `provider: 'local'` 默认 +- [ ] `resolveSdkTools`:local 启用 SDK builtin +- [ ] `workspaceRoot` / `cwd` 推导与可写性检查 +- [ ] `workspaceSnapshot: 'disabled'` 为 local 默认(先验证工具链) +- [ ] 示例 `examples/08-local-sandbox.ts` +- [ ] AGS 路径零行为变化(显式 `provider: 'ags-stateful'`) + +### Phase 1 — COS 持久化 + +- [ ] `local-workspace-sync` 模块(restore + snapshot) +- [ ] `resolveSnapshotMode` 支持 `backend = 'local'` +- [ ] send 前 restore / send 后 snapshot +- [ ] E2E:写文件 → 新 session 读回 + +### Phase 2 — 体验与安全 + +- [ ] workspaceRoot path guard +- [ ] debounced snapshot、并发锁 +- [ ] CloudBase in-process MCP +- [ ] OpenVibeCoding server 默认切 local provider + +### Phase 3 — 产品化切换 + +- [ ] TCB↔AGS 上线后:`provider` 默认改 `ags-stateful` 或 `auto`(平台能力探测) +- [ ] local 标记 `@deprecated`,保留供本地 dev / 单测 + +--- + +## 13. 文件改动清单(预估) + +| 文件 | 改动 | +|------|------| +| `src/public/types.ts` | `provider`、`workspaceRoot`、`cos` | +| `src/public/create-agent.ts` | `resolveSandboxConfig` 默认 local;session 编排 | +| `src/sandbox/local-runtime-sandbox.ts` | **新增** | +| `src/sandbox/local-workspace-sync/*` | **新增**(Phase 1) | +| `src/runtime/agent-builder.ts` | `resolveSdkTools`、snapshot mode、MCP 分支 | +| `src/sandbox/workspace-snapshot/*` | 抽象 sync backend(HTTP vs local) | +| `src/sandbox/ags-stateful-sandbox.ts` | 保留,无删改 | +| `examples/08-local-sandbox.ts` | **新增** | +| `README.md` | 过渡方案说明 | + +--- + +## 14. 决策记录 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 是否删除 AGS 实现 | **否** | 产品化排期中,避免重复建设 | +| 默认 provider | **local** | 降低云开发用户接入门槛 | +| cwd 默认值 | **不硬编码 /home/user** | Serverless 环境差异大 | +| local 工具来源 | **SDK 内置** | 无需 TRW HTTP,与 remote MCP 对称分支 | +| COS 格式 | **对齐 TRW tar.zst** | 便于 backend 切换与数据迁移 | +| CloudBase MCP P0 | **默认关闭** | 依赖沙箱 bash,需独立 Phase | + +--- + +## 15. 参考 + +- `packages/open-agent-kernel/src/sandbox/ags-stateful-sandbox.ts` — AGS 控制面/数据面 +- `packages/open-agent-kernel/docs/superpowers/specs/2026-06-08-oak-workspace-snapshot.md` — Spec B 快照 +- `tcb-remote-workspace/src/cos-sync.ts` — TRW COS rsync / tar.zst 实现 +- `tcb-remote-workspace/src/routes/api.ts` — TRW HTTP 路由 +- `packages/open-agent-kernel/src/runtime/agent-builder.ts` — SDK tools / cwd 逻辑 From 0491787abe227cec67dc36449f636d6f371021f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Thu, 25 Jun 2026 07:55:33 +0800 Subject: [PATCH 2/7] feat(kernel): add ACP stream adapter foundation Introduce self-contained ACP update types and an exported AcpStreamAdapter so SDK-shaped streams can be converted without an external adapter package. Co-authored-by: Cursor --- .../examples/20-acp-stream-adapter-fixture.ts | 71 +++++++ packages/open-agent-kernel/src/acp/index.ts | 21 +++ packages/open-agent-kernel/src/acp/types.ts | 174 ++++++++++++++++++ .../src/adapters/acp-stream-adapter.ts | 153 +++++++++++++++ .../open-agent-kernel/src/adapters/index.ts | 2 + .../open-agent-kernel/src/adapters/types.ts | 12 ++ packages/open-agent-kernel/src/index.ts | 24 +++ 7 files changed, 457 insertions(+) create mode 100644 packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts create mode 100644 packages/open-agent-kernel/src/acp/index.ts create mode 100644 packages/open-agent-kernel/src/acp/types.ts create mode 100644 packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts create mode 100644 packages/open-agent-kernel/src/adapters/index.ts create mode 100644 packages/open-agent-kernel/src/adapters/types.ts diff --git a/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts b/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts new file mode 100644 index 0000000..223d952 --- /dev/null +++ b/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts @@ -0,0 +1,71 @@ +/** + * Example 20: built-in ACP stream adapter fixture. + * + * This example does not call a real model. It feeds Claude Agent SDK-shaped + * messages into AcpStreamAdapter and prints ACP session/update objects. + * + * Run after build: + * pnpm --filter @cloudbase/open-agent-kernel build + * pnpm dlx tsx packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts + */ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import { AcpStreamAdapter } from '@cloudbase/open-agent-kernel' + +async function* fixtureMessages(): AsyncIterable { + yield { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: '你好,' }, + }, + } as unknown as SDKMessage + + yield { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'ACP。' }, + }, + } as unknown as SDKMessage + + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: '你好,ACP。' }, + { type: 'tool_use', id: 'toolu_fixture_1', name: 'mcp__demo__echo', input: { text: 'hello' } }, + ], + }, + } as unknown as SDKMessage + + yield { + type: 'user', + message: { + content: [{ type: 'tool_result', tool_use_id: 'toolu_fixture_1', content: 'echo: hello' }], + }, + } as unknown as SDKMessage + + yield { + type: 'result', + subtype: 'success', + } as unknown as SDKMessage +} + +async function main(): Promise { + const adapter = new AcpStreamAdapter() + const updates = adapter.adapt(fixtureMessages(), { + conversationId: 'conv_fixture', + sessionId: 'conv_fixture', + userId: 'u1', + turnId: 'turn_fixture', + }) + + for await (const update of updates) { + process.stdout.write(`${JSON.stringify(update)}\n`) + } +} + +main().catch((err) => { + process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`) + process.exit(1) +}) diff --git a/packages/open-agent-kernel/src/acp/index.ts b/packages/open-agent-kernel/src/acp/index.ts new file mode 100644 index 0000000..0823370 --- /dev/null +++ b/packages/open-agent-kernel/src/acp/index.ts @@ -0,0 +1,21 @@ +export type { + AcpSessionUpdate, + AcpTextBlock, + AgentMessageChunkUpdate, + AgentThoughtChunkUpdate, + ToolCallUpdate, + ToolCallStatusUpdate, + AvailableCommandsUpdate, + LogUpdate, + TaskProgressUpdate, + FileChangeUpdate, + ThinkingUpdate, + AskUserUpdate, + ToolConfirmUpdate, + ArtifactUpdate, + HistoryMessage, + HistoryMessagePart, + HistoryPageUpdate, + AgentPhaseName, + AgentPhaseUpdate, +} from './types.js' diff --git a/packages/open-agent-kernel/src/acp/types.ts b/packages/open-agent-kernel/src/acp/types.ts new file mode 100644 index 0000000..01602b5 --- /dev/null +++ b/packages/open-agent-kernel/src/acp/types.ts @@ -0,0 +1,174 @@ +/** + * Self-contained ACP session/update types exposed by OAK. + * + * The shape intentionally mirrors OpenVibeCoding's ExtendedSessionUpdate, + * but this package does not depend on the monorepo shared package at runtime. + */ + +export interface AcpTextBlock { + type: 'text' + text: string +} + +export interface AgentMessageChunkUpdate { + sessionUpdate: 'agent_message_chunk' + content: AcpTextBlock +} + +export interface AgentThoughtChunkUpdate { + sessionUpdate: 'agent_thought_chunk' + content: string +} + +export interface ToolCallUpdate { + sessionUpdate: 'tool_call' + toolCallId: string + title: string + kind: 'function' | 'other' + status: 'in_progress' | 'completed' | 'failed' + input?: unknown + parentToolCallId?: string +} + +export interface ToolCallStatusUpdate { + sessionUpdate: 'tool_call_update' + toolCallId: string + status: 'in_progress' | 'completed' | 'failed' + result?: unknown + input?: unknown + error?: { message: string } + parentToolCallId?: string +} + +export interface AvailableCommandsUpdate { + sessionUpdate: 'available_commands_update' + availableCommands: Array<{ + name: string + description: string + _meta?: Record + }> +} + +export interface LogUpdate { + sessionUpdate: 'log' + level: 'info' | 'error' | 'success' | 'command' + message: string + timestamp: number +} + +export interface TaskProgressUpdate { + sessionUpdate: 'task_progress' + progress: number + status: 'pending' | 'processing' | 'completed' | 'error' | 'stopped' +} + +export interface FileChangeUpdate { + sessionUpdate: 'file_change' + filename: string + action: 'add' | 'modify' | 'delete' +} + +export interface ThinkingUpdate { + sessionUpdate: 'thinking' + content: string +} + +export interface AskUserUpdate { + sessionUpdate: 'ask_user' + toolCallId: string + assistantMessageId: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiSelect: boolean + }> +} + +export interface ToolConfirmUpdate { + sessionUpdate: 'tool_confirm' + toolCallId: string + assistantMessageId: string + toolName: string + input: Record + planContent?: string +} + +export interface ArtifactUpdate { + sessionUpdate: 'artifact' + artifact: { + title: string + description?: string + contentType: 'image' | 'link' | 'json' + data: string + metadata?: Record + } +} + +export interface HistoryMessagePartToolCall { + type: 'tool_call' + toolCallId: string + toolName: string + input?: unknown + status?: string + parentToolCallId?: string +} + +export interface HistoryMessagePartToolResult { + type: 'tool_result' + toolCallId: string + toolName?: string + content: string + isError?: boolean + status?: string + parentToolCallId?: string +} + +export type HistoryMessagePart = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'image'; data: string; mimeType: string } + | HistoryMessagePartToolCall + | HistoryMessagePartToolResult + +export interface HistoryMessage { + id: string + taskId: string + role: 'user' | 'agent' + content: string + parts?: HistoryMessagePart[] + status?: string + createdAt: number +} + +export interface HistoryPageUpdate { + sessionUpdate: 'history_page' + messages: HistoryMessage[] + cursor?: string | null + nextCursor?: string | null +} + +export type AgentPhaseName = 'preparing' | 'model_responding' | 'tool_executing' | 'compacting' | 'idle' + +export interface AgentPhaseUpdate { + sessionUpdate: 'agent_phase' + phase: AgentPhaseName + toolName?: string + timestamp: number +} + +export type AcpSessionUpdate = + | AgentMessageChunkUpdate + | AgentThoughtChunkUpdate + | ToolCallUpdate + | ToolCallStatusUpdate + | AvailableCommandsUpdate + | LogUpdate + | TaskProgressUpdate + | FileChangeUpdate + | ThinkingUpdate + | AskUserUpdate + | ToolConfirmUpdate + | ArtifactUpdate + | HistoryPageUpdate + | AgentPhaseUpdate diff --git a/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts b/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts new file mode 100644 index 0000000..7371500 --- /dev/null +++ b/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts @@ -0,0 +1,153 @@ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import type { AcpSessionUpdate } from '../acp/types.js' +import type { StreamAdapter, StreamAdapterContext } from './types.js' + +interface AcpAdapterState { + toolCallNames: Map + streamedText: boolean +} + +export interface AcpStreamAdapterOptions { + /** + * When partial stream events are enabled, assistant text blocks replay the + * final text. Keep this on to avoid duplicate text chunks. + */ + dedupeAssistantText?: boolean +} + +export class AcpStreamAdapter implements StreamAdapter { + private readonly dedupeAssistantText: boolean + + constructor(options: AcpStreamAdapterOptions = {}) { + this.dedupeAssistantText = options.dedupeAssistantText ?? true + } + + async *adapt(messages: AsyncIterable, context: StreamAdapterContext): AsyncIterable { + const state: AcpAdapterState = { + toolCallNames: new Map(), + streamedText: false, + } + + for await (const message of messages) { + for (const update of this.translateMessage(message, context, state)) { + yield update + } + } + } + + private *translateMessage( + message: SDKMessage, + context: StreamAdapterContext, + state: AcpAdapterState, + ): Generator { + switch (message.type) { + case 'stream_event': + yield* translateStreamEvent(message, state) + return + case 'assistant': + yield* translateAssistantMessage(message, state, this.dedupeAssistantText) + return + case 'user': + yield* translateUserMessage(message, state) + return + case 'result': + yield { + sessionUpdate: 'agent_phase', + phase: 'idle', + timestamp: Date.now(), + } + return + default: + void context + return + } + } +} + +function* translateStreamEvent( + message: SDKMessage, + state: AcpAdapterState, +): Generator { + const event = (message as { event?: { type?: string; delta?: { type?: string; text?: string } } }).event + if ( + event?.type === 'content_block_delta' && + event.delta?.type === 'text_delta' && + typeof event.delta.text === 'string' && + event.delta.text.length > 0 + ) { + state.streamedText = true + yield { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: event.delta.text }, + } + } +} + +function* translateAssistantMessage( + message: SDKMessage, + state: AcpAdapterState, + dedupeAssistantText: boolean, +): Generator { + const content = (message as { message?: { content?: unknown[] } }).message?.content ?? [] + for (const block of content) { + if (!isRecord(block)) continue + if ( + block.type === 'text' && + typeof block.text === 'string' && + block.text.length > 0 && + (!dedupeAssistantText || !state.streamedText) + ) { + yield { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: block.text }, + } + continue + } + if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + state.toolCallNames.set(block.id, block.name) + yield { + sessionUpdate: 'tool_call', + toolCallId: block.id, + title: block.name, + kind: 'function', + status: 'in_progress', + input: isRecord(block.input) ? block.input : (block.input ?? {}), + } + } + } +} + +function* translateUserMessage( + message: SDKMessage, + state: AcpAdapterState, +): Generator { + const content = (message as { message?: { content?: unknown[] } }).message?.content + if (!Array.isArray(content)) return + + for (const block of content) { + if (!isRecord(block) || block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue + const output = block.content ?? null + const isError = Boolean(block.is_error) + yield { + sessionUpdate: 'tool_call_update', + toolCallId: block.tool_use_id, + status: isError ? 'failed' : 'completed', + result: output, + ...(isError ? { error: { message: stringifyToolResult(output) } } : {}), + } + state.toolCallNames.delete(block.tool_use_id) + } +} + +function stringifyToolResult(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/packages/open-agent-kernel/src/adapters/index.ts b/packages/open-agent-kernel/src/adapters/index.ts new file mode 100644 index 0000000..ae662df --- /dev/null +++ b/packages/open-agent-kernel/src/adapters/index.ts @@ -0,0 +1,2 @@ +export { AcpStreamAdapter, type AcpStreamAdapterOptions } from './acp-stream-adapter.js' +export type { StreamAdapter, StreamAdapterContext } from './types.js' diff --git a/packages/open-agent-kernel/src/adapters/types.ts b/packages/open-agent-kernel/src/adapters/types.ts new file mode 100644 index 0000000..1117974 --- /dev/null +++ b/packages/open-agent-kernel/src/adapters/types.ts @@ -0,0 +1,12 @@ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' + +export interface StreamAdapterContext { + conversationId: string + sessionId: string + userId: string + turnId: string +} + +export interface StreamAdapter { + adapt(messages: AsyncIterable, context: StreamAdapterContext): AsyncIterable +} diff --git a/packages/open-agent-kernel/src/index.ts b/packages/open-agent-kernel/src/index.ts index 6c8c290..e73fe7f 100644 --- a/packages/open-agent-kernel/src/index.ts +++ b/packages/open-agent-kernel/src/index.ts @@ -10,6 +10,30 @@ // 公共 API:唯一的入口点 export { createAgent } from './public/create-agent.js' +export { AcpStreamAdapter } from './adapters/index.js' + +export type { AcpStreamAdapterOptions, StreamAdapter, StreamAdapterContext } from './adapters/index.js' +export type { + AcpSessionUpdate, + AcpTextBlock, + AgentMessageChunkUpdate, + AgentThoughtChunkUpdate, + ToolCallUpdate, + ToolCallStatusUpdate, + AvailableCommandsUpdate, + LogUpdate, + TaskProgressUpdate, + FileChangeUpdate, + ThinkingUpdate, + AskUserUpdate, + ToolConfirmUpdate, + ArtifactUpdate, + HistoryMessage, + HistoryMessagePart, + HistoryPageUpdate, + AgentPhaseName, + AgentPhaseUpdate, +} from './acp/index.js' // 公共类型:完整对外契约 export type { From d88b7b555bbcf9627c23322d4e95945689be988e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Thu, 25 Jun 2026 07:57:12 +0800 Subject: [PATCH 3/7] feat(kernel): map Claude SDK stream events to ACP updates Extend the built-in ACP adapter with tool input streaming and OAK interrupt sentinel handling so UI clients receive ACP-native tool and HITL updates. Co-authored-by: Cursor --- .../examples/20-acp-stream-adapter-fixture.ts | 75 +++++++ .../src/adapters/acp-stream-adapter.ts | 188 +++++++++++++++++- 2 files changed, 254 insertions(+), 9 deletions(-) diff --git a/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts b/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts index 223d952..a9ad906 100644 --- a/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts +++ b/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts @@ -38,6 +38,41 @@ async function* fixtureMessages(): AsyncIterable { }, } as unknown as SDKMessage + yield { + type: 'stream_event', + event: { + type: 'content_block_start', + index: 1, + content_block: { type: 'tool_use', id: 'toolu_fixture_2', name: 'mcp__demo__write', input: {} }, + }, + } as unknown as SDKMessage + + yield { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 1, + delta: { type: 'input_json_delta', partial_json: '{"path":' }, + }, + } as unknown as SDKMessage + + yield { + type: 'stream_event', + event: { + type: 'content_block_delta', + index: 1, + delta: { type: 'input_json_delta', partial_json: '"/tmp/demo.txt"}' }, + }, + } as unknown as SDKMessage + + yield { + type: 'stream_event', + event: { + type: 'content_block_stop', + index: 1, + }, + } as unknown as SDKMessage + yield { type: 'user', message: { @@ -45,6 +80,46 @@ async function* fixtureMessages(): AsyncIterable { }, } as unknown as SDKMessage + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_fixture_2', + is_error: true, + content: JSON.stringify({ + __OAK_INTERRUPT__: true, + conversationId: 'conv_fixture', + toolUseId: 'toolu_fixture_2', + toolName: 'mcp__demo__write', + toolInput: { path: '/tmp/demo.txt' }, + }), + }, + ], + }, + } as unknown as SDKMessage + + yield { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_fixture_ask', + is_error: true, + content: JSON.stringify({ + __OAK_ASK_USER__: true, + conversationId: 'conv_fixture', + toolUseId: 'toolu_fixture_ask', + question: '是否继续?', + options: ['继续', '停止'], + }), + }, + ], + }, + } as unknown as SDKMessage + yield { type: 'result', subtype: 'success', diff --git a/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts b/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts index 7371500..4e8a0f5 100644 --- a/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts +++ b/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts @@ -1,12 +1,22 @@ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' import type { AcpSessionUpdate } from '../acp/types.js' +import { parseAskUserSignal, parseClientToolSignal, parseInterruptSignal } from '../permissions/hooks.js' import type { StreamAdapter, StreamAdapterContext } from './types.js' interface AcpAdapterState { + activeToolBlocks: Map + emittedToolCalls: Set toolCallNames: Map streamedText: boolean } +interface StreamingToolCall { + toolCallId: string + toolName: string + partialJson: string + parentToolCallId?: string +} + export interface AcpStreamAdapterOptions { /** * When partial stream events are enabled, assistant text blocks replay the @@ -24,6 +34,8 @@ export class AcpStreamAdapter implements StreamAdapter { async *adapt(messages: AsyncIterable, context: StreamAdapterContext): AsyncIterable { const state: AcpAdapterState = { + activeToolBlocks: new Map(), + emittedToolCalls: new Set(), toolCallNames: new Map(), streamedText: false, } @@ -48,7 +60,7 @@ export class AcpStreamAdapter implements StreamAdapter { yield* translateAssistantMessage(message, state, this.dedupeAssistantText) return case 'user': - yield* translateUserMessage(message, state) + yield* translateUserMessage(message, context, state) return case 'result': yield { @@ -68,7 +80,17 @@ function* translateStreamEvent( message: SDKMessage, state: AcpAdapterState, ): Generator { - const event = (message as { event?: { type?: string; delta?: { type?: string; text?: string } } }).event + const parentToolCallId = getParentToolCallId(message) + const event = ( + message as { + event?: { + type?: string + index?: number + content_block?: { type?: string; id?: string; name?: string; input?: unknown } + delta?: { type?: string; text?: string; partial_json?: string } + } + } + ).event if ( event?.type === 'content_block_delta' && event.delta?.type === 'text_delta' && @@ -81,6 +103,68 @@ function* translateStreamEvent( content: { type: 'text', text: event.delta.text }, } } + + if ( + event?.type === 'content_block_start' && + typeof event.index === 'number' && + event.content_block?.type === 'tool_use' && + typeof event.content_block.id === 'string' && + typeof event.content_block.name === 'string' + ) { + const tool: StreamingToolCall = { + toolCallId: event.content_block.id, + toolName: event.content_block.name, + partialJson: '', + ...(parentToolCallId ? { parentToolCallId } : {}), + } + state.activeToolBlocks.set(event.index, tool) + state.emittedToolCalls.add(tool.toolCallId) + state.toolCallNames.set(tool.toolCallId, tool.toolName) + yield { + sessionUpdate: 'tool_call', + toolCallId: tool.toolCallId, + title: tool.toolName, + kind: 'function', + status: 'in_progress', + input: toRecordInput(event.content_block.input), + ...(tool.parentToolCallId ? { parentToolCallId: tool.parentToolCallId } : {}), + } + return + } + + if ( + event?.type === 'content_block_delta' && + typeof event.index === 'number' && + event.delta?.type === 'input_json_delta' && + typeof event.delta.partial_json === 'string' + ) { + const tool = state.activeToolBlocks.get(event.index) + if (!tool) return + tool.partialJson += event.delta.partial_json + yield { + sessionUpdate: 'tool_call_update', + toolCallId: tool.toolCallId, + status: 'in_progress', + input: parseJsonOrText(tool.partialJson), + ...(tool.parentToolCallId ? { parentToolCallId: tool.parentToolCallId } : {}), + } + return + } + + if (event?.type === 'content_block_stop' && typeof event.index === 'number') { + const tool = state.activeToolBlocks.get(event.index) + if (!tool) return + state.activeToolBlocks.delete(event.index) + if (tool.partialJson.length > 0) { + yield { + sessionUpdate: 'tool_call_update', + toolCallId: tool.toolCallId, + status: 'in_progress', + input: parseJsonOrText(tool.partialJson), + ...(tool.parentToolCallId ? { parentToolCallId: tool.parentToolCallId } : {}), + } + } + } } function* translateAssistantMessage( @@ -105,13 +189,24 @@ function* translateAssistantMessage( } if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { state.toolCallNames.set(block.id, block.name) - yield { - sessionUpdate: 'tool_call', - toolCallId: block.id, - title: block.name, - kind: 'function', - status: 'in_progress', - input: isRecord(block.input) ? block.input : (block.input ?? {}), + const input = isRecord(block.input) ? block.input : (block.input ?? {}) + if (state.emittedToolCalls.has(block.id)) { + yield { + sessionUpdate: 'tool_call_update', + toolCallId: block.id, + status: 'in_progress', + input, + } + } else { + state.emittedToolCalls.add(block.id) + yield { + sessionUpdate: 'tool_call', + toolCallId: block.id, + title: block.name, + kind: 'function', + status: 'in_progress', + input, + } } } } @@ -119,6 +214,7 @@ function* translateAssistantMessage( function* translateUserMessage( message: SDKMessage, + context: StreamAdapterContext, state: AcpAdapterState, ): Generator { const content = (message as { message?: { content?: unknown[] } }).message?.content @@ -127,6 +223,49 @@ function* translateUserMessage( for (const block of content) { if (!isRecord(block) || block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue const output = block.content ?? null + const reasonText = extractTextContent(output) + const interrupt = reasonText ? parseInterruptSignal(reasonText) : null + if (interrupt) { + yield { + sessionUpdate: 'tool_confirm', + toolCallId: interrupt.toolUseId, + assistantMessageId: context.turnId, + toolName: interrupt.toolName, + input: toRecordInput(interrupt.toolInput), + } + continue + } + + const clientSignal = reasonText ? parseClientToolSignal(reasonText) : null + if (clientSignal) { + yield { + sessionUpdate: 'tool_confirm', + toolCallId: clientSignal.toolUseId, + assistantMessageId: context.turnId, + toolName: clientSignal.toolName, + input: toRecordInput(clientSignal.toolInput), + } + continue + } + + const askUserSignal = reasonText ? parseAskUserSignal(reasonText) : null + if (askUserSignal) { + yield { + sessionUpdate: 'ask_user', + toolCallId: askUserSignal.toolUseId, + assistantMessageId: context.turnId, + questions: [ + { + question: askUserSignal.question, + header: 'Agent asks a question', + options: (askUserSignal.options ?? []).map((option) => ({ label: option, description: '' })), + multiSelect: false, + }, + ], + } + continue + } + const isError = Boolean(block.is_error) yield { sessionUpdate: 'tool_call_update', @@ -139,6 +278,37 @@ function* translateUserMessage( } } +function getParentToolCallId(message: SDKMessage): string | undefined { + const parent = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id + return typeof parent === 'string' && parent.length > 0 ? parent : undefined +} + +function parseJsonOrText(value: string): unknown { + try { + return JSON.parse(value) + } catch { + return value + } +} + +function toRecordInput(value: unknown): Record { + if (isRecord(value)) return value + if (value === undefined || value === null) return {} + return { value } +} + +function extractTextContent(content: unknown): string | null { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + + for (const part of content) { + if (isRecord(part) && part.type === 'text' && typeof part.text === 'string') { + return part.text + } + } + return null +} + function stringifyToolResult(value: unknown): string { if (typeof value === 'string') return value try { From fb9f0615058d8efc1f86c29df9d1ef5831446a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Thu, 25 Jun 2026 07:59:48 +0800 Subject: [PATCH 4/7] feat(kernel): make ACP the default session stream Route Claude SDK messages through the built-in ACP adapter by default and remove the old SessionEvent translator from the public path. Co-authored-by: Cursor --- .../21-default-acp-session-contract.ts | 23 ++ packages/open-agent-kernel/src/index.ts | 1 - .../src/public/create-agent.ts | 124 ++++----- .../open-agent-kernel/src/public/types.ts | 16 +- .../src/runtime/agent-builder.ts | 2 + .../src/runtime/event-translator.ts | 238 ------------------ 6 files changed, 84 insertions(+), 320 deletions(-) create mode 100644 packages/open-agent-kernel/examples/21-default-acp-session-contract.ts delete mode 100644 packages/open-agent-kernel/src/runtime/event-translator.ts diff --git a/packages/open-agent-kernel/examples/21-default-acp-session-contract.ts b/packages/open-agent-kernel/examples/21-default-acp-session-contract.ts new file mode 100644 index 0000000..b2c3ef7 --- /dev/null +++ b/packages/open-agent-kernel/examples/21-default-acp-session-contract.ts @@ -0,0 +1,23 @@ +/** + * Example 21: default session stream contract. + * + * This example is type-level only: it verifies that Session.send() and + * respond*() methods expose AsyncIterable by default. + * + * Verify after build: + * pnpm --filter @cloudbase/open-agent-kernel build + * pnpm exec tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck packages/open-agent-kernel/examples/21-default-acp-session-contract.ts + */ +import type { AcpSessionUpdate, Session } from '@cloudbase/open-agent-kernel' + +type Assert = T +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false + +type SendReturnsAcp = Assert, AsyncIterable>> +type ApprovalReturnsAcp = Assert, AsyncIterable>> +type ToolUseReturnsAcp = Assert, AsyncIterable>> +type AskUserReturnsAcp = Assert, AsyncIterable>> + +const checks: [SendReturnsAcp, ApprovalReturnsAcp, ToolUseReturnsAcp, AskUserReturnsAcp] = [true, true, true, true] + +void checks diff --git a/packages/open-agent-kernel/src/index.ts b/packages/open-agent-kernel/src/index.ts index e73fe7f..899fd44 100644 --- a/packages/open-agent-kernel/src/index.ts +++ b/packages/open-agent-kernel/src/index.ts @@ -45,7 +45,6 @@ export type { SessionSummary, // 输入 / 事件 SessionInput, - SessionEvent, MessageRecord, MessagePart, AttachmentInput, diff --git a/packages/open-agent-kernel/src/public/create-agent.ts b/packages/open-agent-kernel/src/public/create-agent.ts index 6484b2f..ba6981e 100644 --- a/packages/open-agent-kernel/src/public/create-agent.ts +++ b/packages/open-agent-kernel/src/public/create-agent.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk' import type { McpServerConfig as SdkMcpServerConfig } from '@anthropic-ai/claude-agent-sdk' +import { AcpStreamAdapter } from '../adapters/index.js' +import type { AcpSessionUpdate } from '../acp/index.js' import { InvalidConfigError, ResourceError } from '../internal/errors.js' import { createHookLocalState, @@ -14,7 +16,6 @@ import { type PreToolUseHookLocalState, } from '../permissions/index.js' import { buildClaudeQueryOptions } from '../runtime/agent-builder.js' -import { createTranslatorState, translateSdkMessage } from '../runtime/event-translator.js' import { buildPromptAsync } from '../runtime/prompt-builder.js' import { createCloudBaseMcpServer, type CloudBaseUserCredentials } from '../sandbox/cloudbase-mcp.js' import { AgsStatefulSandbox } from '../sandbox/index.js' @@ -33,7 +34,6 @@ import type { PermissionStore, SandboxUserCredentials, Session, - SessionEvent, SessionInput, SessionStartOptions, SessionSummary, @@ -454,7 +454,7 @@ function createSession(deps: SessionDeps): Session { id: conversationId, userId, - send(input: string | SessionInput): AsyncIterable { + send(input: string | SessionInput): AsyncIterable { abortController = new AbortController() const isContinuation = hasStarted hasStarted = true @@ -487,7 +487,7 @@ function createSession(deps: SessionDeps): Session { * * 调用方不需要持有"那次 send 的 generator"——业务可在任意进程 / 节点(store 共享前提下)调本方法。 */ - respondApproval(opts: { toolUseId: string; decision: ApprovalDecision }): AsyncIterable { + respondApproval(opts: { toolUseId: string; decision: ApprovalDecision }): AsyncIterable { abortController = new AbortController() return runApprovalResume({ config, @@ -521,7 +521,7 @@ function createSession(deps: SessionDeps): Session { * the transcript but is harmless because the hook's deny outcome * already aborted that branch of reasoning. */ - respondToolUse(opts: { toolUseId: string; output: unknown; isError?: boolean }): AsyncIterable { + respondToolUse(opts: { toolUseId: string; output: unknown; isError?: boolean }): AsyncIterable { abortController = new AbortController() return runClientToolResume({ config, @@ -549,7 +549,7 @@ function createSession(deps: SessionDeps): Session { * 1. 把回答写入 askUserStore * 2. 起一轮 SDK query(resume)→ 模型重发 askUser 工具 → hook 从 store 读到回答 → 放行 */ - respondAskUser(opts: { toolUseId: string; answer: string }): AsyncIterable { + respondAskUser(opts: { toolUseId: string; answer: string }): AsyncIterable { abortController = new AbortController() return runAskUserResume({ config, @@ -977,7 +977,7 @@ interface RunClaudeQueryArgs { askUserStore?: AskUserStore } -async function* runClaudeQuery(args: RunClaudeQueryArgs): AsyncGenerator { +async function* runClaudeQuery(args: RunClaudeQueryArgs): AsyncGenerator { const { config, input, @@ -1087,23 +1087,20 @@ async function* runClaudeQuery(args: RunClaudeQueryArgs): AsyncGenerator { + yield { + sessionUpdate: 'log', + level: 'error', + message, + timestamp: Date.now(), + } + yield { + sessionUpdate: 'agent_phase', + phase: 'idle', + timestamp: Date.now(), + } +} + // ============================================================ // 内部:注入审批决策并 resume agent 运行(PR #7.0) // ============================================================ @@ -1161,7 +1173,7 @@ interface RunApprovalResumeArgs { askUserStore?: AskUserStore } -async function* runApprovalResume(args: RunApprovalResumeArgs): AsyncGenerator { +async function* runApprovalResume(args: RunApprovalResumeArgs): AsyncGenerator { const { config, conversationId, @@ -1180,32 +1192,17 @@ async function* runApprovalResume(args: RunApprovalResumeArgs): AsyncGenerator { +async function* runClientToolResume(args: RunClientToolResumeArgs): AsyncGenerator { const { config, conversationId, @@ -1277,34 +1274,17 @@ async function* runClientToolResume(args: RunClientToolResumeArgs): AsyncGenerat } = args if (!clientToolStore) { - yield { - type: 'error', - error: new InvalidConfigError( - 'session.respondToolUse requires AgentConfig.tools[] to be configured. ' + - 'Without client-side tool definitions, no client-tool flow exists to resume.', - ), - } - yield { type: 'session_idle', reason: 'error' } + yield* createErrorUpdates('Client tool resume is not configured') return } const existing = await clientToolStore.get({ conversationId, toolUseId }) if (!existing) { - yield { - type: 'error', - error: new ResourceError( - `No pending client tool found for toolUseId=${toolUseId}. ` + 'It may have expired or already been resolved.', - ), - } - yield { type: 'session_idle', reason: 'error' } + yield* createErrorUpdates('No pending client tool found') return } if (existing.result) { - yield { - type: 'error', - error: new ResourceError(`Client tool result for toolUseId=${toolUseId} has already been resolved.`), - } - yield { type: 'session_idle', reason: 'error' } + yield* createErrorUpdates('Client tool result has already been resolved') return } @@ -1363,7 +1343,7 @@ interface RunAskUserResumeArgs { askUserStore: AskUserStore } -async function* runAskUserResume(args: RunAskUserResumeArgs): AsyncGenerator { +async function* runAskUserResume(args: RunAskUserResumeArgs): AsyncGenerator { const { config, conversationId, @@ -1383,21 +1363,11 @@ async function* runAskUserResume(args: RunAskUserResumeArgs): AsyncGenerator } /** @@ -695,7 +703,7 @@ export interface Session { * 发送用户消息,返回事件流。 * 字符串糖:等价于 { type: 'message', content: input } */ - send(input: string | SessionInput): AsyncIterable + send(input: string | SessionInput): AsyncIterable /** * 响应工具审批(PR #7.0)。 @@ -709,7 +717,7 @@ export interface Session { * * 注意:调用方应确保同一 toolUseId 不被并发响应;重复响应会用最后一次为准。 */ - respondApproval(opts: { toolUseId: string; decision: ApprovalDecision }): AsyncIterable + respondApproval(opts: { toolUseId: string; decision: ApprovalDecision }): AsyncIterable /** * PR #7.1: 注入客户端工具结果并 resume agent 运行。 @@ -724,7 +732,7 @@ export interface Session { * 返回的事件流是"结果注入后"的运行流(可能包含 message_delta / tool_call / * tool_result / session_idle 等)。 */ - respondToolUse(opts: { toolUseId: string; output: unknown; isError?: boolean }): AsyncIterable + respondToolUse(opts: { toolUseId: string; output: unknown; isError?: boolean }): AsyncIterable /** * 注入用户对 askUser 提问的回答并 resume agent 运行。 @@ -737,7 +745,7 @@ export interface Session { * * 返回的事件流是"回答注入后"的运行流。 */ - respondAskUser(opts: { toolUseId: string; answer: string }): AsyncIterable + respondAskUser(opts: { toolUseId: string; answer: string }): AsyncIterable /** 拉取历史消息 */ getHistory(opts?: { limit?: number; before?: number }): Promise diff --git a/packages/open-agent-kernel/src/runtime/agent-builder.ts b/packages/open-agent-kernel/src/runtime/agent-builder.ts index 1ce957d..0bd3961 100644 --- a/packages/open-agent-kernel/src/runtime/agent-builder.ts +++ b/packages/open-agent-kernel/src/runtime/agent-builder.ts @@ -390,6 +390,8 @@ export function buildClaudeQueryOptions( strictMcpConfig: true, // 持久化策略:注入 store 时必须 true(SDK 强制约束) persistSession: enablePersist, + // ACP adapter consumes SDK stream_event messages for token/tool input streaming. + includePartialMessages: true, ...(sessionStore ? { sessionStore } : {}), ...(config.session?.flush ? { sessionStoreFlush: config.session.flush } : {}), // ── 系统提示 ── diff --git a/packages/open-agent-kernel/src/runtime/event-translator.ts b/packages/open-agent-kernel/src/runtime/event-translator.ts deleted file mode 100644 index bb10f8c..0000000 --- a/packages/open-agent-kernel/src/runtime/event-translator.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Event translator: Claude Agent SDK SDKMessage → kernel SessionEvent - * - * SDK 端的事件类型(@anthropic-ai/claude-agent-sdk): - * - SDKAssistantMessage: assistant turn,message.content[] 是 Anthropic ContentBlock 数组 - * - text block → kernel 'message_delta' / 'message_complete' - * - tool_use block → kernel 'tool_call' - * - thinking block → 暂不映射(v0.2 可作为单独事件) - * - SDKUserMessage(含 tool_result block)→ kernel 'tool_result' - * - **特殊:含 OAK_INTERRUPT_SENTINEL 的 tool_result(PR #7.0 假 deny)** - * → 翻译成 kernel 'tool_approval_required',不再 yield 'tool_result' - * - SDKResultMessage(success/error) → kernel 'session_idle' - * - SDKPartialAssistantMessage(流式 chunk)→ kernel 'message_delta' - * - 其他 SDK 内部消息(hook_started / status / system 等)→ 暂不暴露 - * - * 设计原则: - * - kernel 不暴露任何 SDK 内部类型给上层 - * - 翻译失败/未知消息类型 → 静默丢弃(不抛错,避免阻断主流程) - * - 一个 SDKAssistantMessage 可能携带多个 content block,逐个 yield 多个 SessionEvent - * - * 状态: - * PR #2 起 translator 是无状态的;PR #7.0 引入 `TranslatorState` 用于记录一轮内 - * 是否触发过 approval(让最终的 session_idle 能正确翻译为 'requires_action')。 - */ - -import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' -import { parseAskUserSignal, parseClientToolSignal, parseInterruptSignal } from '../permissions/hooks.js' -import type { SessionEvent } from '../public/types.js' - -/** - * 翻译器的轮内状态(一次 query 的整个事件流共享一个)。 - */ -export interface TranslatorState { - /** 本轮是否触发过 PR #7.0 审批中断 */ - approvalTriggered: boolean - /** - * 已 yield 的 tool_call 索引:toolUseId → toolName。 - * 用于在审批中断时给 'tool_approval_required' 事件补 toolName(兜底,主路径从 sentinel JSON 直接拿)。 - */ - toolCallNames: Map -} - -export function createTranslatorState(): TranslatorState { - return { approvalTriggered: false, toolCallNames: new Map() } -} - -/** - * 翻译单个 SDK 消息为 0~N 个 kernel SessionEvent(generator 形式,方便上层 for-of 消费)。 - * - * 注意:这是"轻状态翻译器",PR #7.0 起在 state 上记录"是否触发过审批", - * 用于把最终的 result 消息翻译成 'session_idle.requires_action'。 - */ -export function* translateSdkMessage( - msg: SDKMessage, - state: TranslatorState = createTranslatorState(), -): Generator { - switch (msg.type) { - case 'assistant': { - // BetaMessage.content 是 ContentBlock[] - const content = msg.message?.content ?? [] - for (const block of content) { - if (!block || typeof block !== 'object') continue - switch (block.type) { - case 'text': { - const text = (block as { text?: string }).text - if (typeof text === 'string' && text.length > 0) { - yield { type: 'message_delta', text } - yield { type: 'message_complete', text } - } - break - } - case 'tool_use': { - const toolUseBlock = block as { - id?: string - name?: string - input?: unknown - } - if (toolUseBlock.id && toolUseBlock.name) { - state.toolCallNames.set(toolUseBlock.id, toolUseBlock.name) - yield { - type: 'tool_call', - toolUseId: toolUseBlock.id, - toolName: toolUseBlock.name, - input: toolUseBlock.input ?? {}, - } - } - break - } - // thinking / server_tool_use / 等其他 block 类型暂不映射 - default: - break - } - } - break - } - - case 'user': { - // 用户消息回流时,可能包含 tool_result(agent 自己执行完工具后回灌) - const content = msg.message?.content - if (Array.isArray(content)) { - for (const block of content) { - if (!block || typeof block !== 'object') continue - if ((block as { type?: string }).type === 'tool_result') { - const trBlock = block as { - tool_use_id?: string - content?: unknown - is_error?: boolean - } - if (!trBlock.tool_use_id) continue - - // ── PR #7.0:识别审批中断 sentinel ── - // tool_result.content 在 SDK 里既可能是 string,也可能是 [{type:'text', text:'...'}]。 - const reasonText = extractReasonText(trBlock.content) - const interrupt = reasonText ? parseInterruptSignal(reasonText) : null - if (interrupt) { - state.approvalTriggered = true - yield { - type: 'tool_approval_required', - toolUseId: interrupt.toolUseId, - toolName: interrupt.toolName, - input: interrupt.toolInput, - ...(interrupt.hints ? { hints: interrupt.hints } : {}), - // PR #7.0:runStateJson 里只放 conversationId + toolUseId(业务侧 - // 不需要解析;后续若做 RunState 序列化再扩展)。 - runStateJson: JSON.stringify({ - conversationId: interrupt.conversationId, - toolUseId: interrupt.toolUseId, - schema: 'oak/v1/approvalRef', - }), - } - continue // ← 关键:不再 yield 这条 tool_result(吃掉假 deny) - } - - // ── PR #7.1: client-side tool sentinel ── - // PreToolUse hook denied with the client-tool sentinel; turn - // pauses so the host can run the tool and resume via - // session.respondToolUse(). Same trick as the approval flow: - // emit tool_use_required, swallow the synthetic tool_result. - const clientSignal = reasonText ? parseClientToolSignal(reasonText) : null - if (clientSignal) { - state.approvalTriggered = true - yield { - type: 'tool_use_required', - toolUseId: clientSignal.toolUseId, - toolName: clientSignal.toolName, - input: clientSignal.toolInput, - } - continue - } - - // ── askUser: 内置提问工具 sentinel ── - // PreToolUse hook denied with askUser sentinel; turn pauses so - // the host can collect user answer and resume via - // session.respondAskUser(). - const askUserSignal = reasonText ? parseAskUserSignal(reasonText) : null - if (askUserSignal) { - state.approvalTriggered = true - yield { - type: 'ask_user_required', - toolUseId: askUserSignal.toolUseId, - question: askUserSignal.question, - ...(askUserSignal.options ? { options: askUserSignal.options } : {}), - } - continue - } - - // 正常 tool_result - yield { - type: 'tool_result', - toolUseId: trBlock.tool_use_id, - toolName: state.toolCallNames.get(trBlock.tool_use_id) ?? '', - output: trBlock.content ?? null, - isError: Boolean(trBlock.is_error), - } - } - } - } - break - } - - case 'result': { - // SDKResultSuccess | SDKResultError - const resultMsg = msg as { subtype?: string; is_error?: boolean } - // 先看本轮是否触发过审批,触发了就报 requires_action - if (state.approvalTriggered) { - yield { type: 'session_idle', reason: 'requires_action' } - } else if (resultMsg.is_error) { - yield { type: 'session_idle', reason: 'error' } - } else if (resultMsg.subtype === 'success') { - yield { type: 'session_idle', reason: 'completed' } - } else { - yield { type: 'session_idle', reason: 'aborted' } - } - break - } - - case 'stream_event': { - // SDKPartialAssistantMessage(流式增量 chunk,仅在 options.includePartialMessages: true 时发出) - const partial = msg as { - event?: { type?: string; delta?: { type?: string; text?: string } } - } - const evt = partial.event - if ( - evt?.type === 'content_block_delta' && - evt.delta?.type === 'text_delta' && - typeof evt.delta.text === 'string' && - evt.delta.text.length > 0 - ) { - yield { type: 'message_delta', text: evt.delta.text } - } - break - } - - // 其他 SDK 内部消息(system / status / hook_* / task_* / 等) - default: - break - } -} - -/** - * 从 tool_result.content 字段抽出文本(兼容 string / Array<{type:'text', text}>)。 - */ -function extractReasonText(content: unknown): string | null { - if (typeof content === 'string') return content - if (Array.isArray(content)) { - for (const part of content) { - if ( - part && - typeof part === 'object' && - (part as { type?: string }).type === 'text' && - typeof (part as { text?: string }).text === 'string' - ) { - return (part as { text: string }).text - } - } - } - return null -} From c29f3e2d6b9e0fb90ac747b7b7e9993179e7eb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Thu, 25 Jun 2026 08:08:05 +0800 Subject: [PATCH 5/7] docs(kernel): document default ACP stream output Update OAK docs and examples around the built-in ACP stream contract so users consume AcpSessionUpdate without declaring an adapter. Co-authored-by: Cursor --- packages/open-agent-kernel/HANDOVER.md | 30 +++-- packages/open-agent-kernel/README.md | 61 ++++------ .../docs/architecture-acp-stream-adapter.md | 76 ++++++++++++ .../examples/01-quickstart.ts | 21 +--- .../open-agent-kernel/examples/02-debug.ts | 42 ++++--- .../examples/09-sandbox-shared.ts | 17 +-- .../examples/12-hitl-acp-adapter.ts | 82 +++++-------- .../examples/18-workspace-snapshot.ts | 4 +- packages/open-agent-kernel/examples/README.md | 4 +- .../open-agent-kernel/examples/_shared/acp.ts | 37 ++++++ .../src/permissions/hooks.ts | 18 +-- .../src/permissions/store.ts | 2 +- .../src/public/create-agent.ts | 8 +- .../open-agent-kernel/src/public/types.ts | 112 ++---------------- 14 files changed, 242 insertions(+), 272 deletions(-) create mode 100644 packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md create mode 100644 packages/open-agent-kernel/examples/_shared/acp.ts diff --git a/packages/open-agent-kernel/HANDOVER.md b/packages/open-agent-kernel/HANDOVER.md index f8d6ca2..5c63eef 100644 --- a/packages/open-agent-kernel/HANDOVER.md +++ b/packages/open-agent-kernel/HANDOVER.md @@ -144,7 +144,7 @@ COS bucket/oak-workspaces/ └─ createAgent(config) ├─ Session(会话实例,含 send / getHistory / respondApproval / abort) │ └─ runClaudeQuery() → Claude Agent SDK → 流式 SDKMessage - │ └─ event-translator.ts → SessionEvent(message_delta / tool_call / tool_result / ...) + │ └─ AcpStreamAdapter → AcpSessionUpdate(agent_message_chunk / tool_call / tool_confirm / ...) │ ├─ SessionStore(可选,持久化会话) │ └─ CloudBaseSessionStore → SessionStoreDriver @@ -174,8 +174,12 @@ src/ ├── runtime/ │ ├── agent-builder.ts # buildClaudeQueryOptions(薄封装 Claude SDK) │ ├── credential-factory.ts # model → ANTHROPIC_BASE_URL / AUTH_TOKEN -│ ├── event-translator.ts # SDKMessage → SessionEvent 翻译 │ └── prompt-builder.ts # system prompt 构建 +├── acp/ +│ └── types.ts # ACP session/update 类型 +├── adapters/ +│ ├── acp-stream-adapter.ts # 默认 SDKMessage → AcpSessionUpdate +│ └── types.ts # StreamAdapter 接口 ├── resources/ │ ├── credential-provider.ts # CloudBase AI gateway APIKey 加载 │ └── name-resolver.ts # envId → 集合名/函数名/网关 URL 派生 @@ -233,21 +237,23 @@ const agent = createAgent({ **文件**: `src/public/create-agent.ts`(内部 `createSession()`) 核心方法: -- `send(input)` → `AsyncIterable`(流式事件) +- `send(input)` → `AsyncIterable`(ACP 更新流) - `getHistory(opts)` → `MessageRecord[]`(消息历史查询) - `respondApproval(opts)` → 注入审批决策并 resume - `abort()` → 终止会话 + 释放沙箱 - `getState()` → JSON 序列化的会话引用 -### 4.3 SessionEvent — 流式事件类型 +### 4.3 AcpSessionUpdate — 默认流式输出 ```typescript -type SessionEvent = - | { type: 'message_delta'; text: string } - | { type: 'tool_call'; toolUseId: string; toolName: string; input: unknown } - | { type: 'tool_result'; toolUseId: string; output: unknown; isError: boolean } - | { type: 'session_idle'; reason: 'completed' | 'aborted' | 'error' } - | { type: 'error'; error: Error } +type AcpSessionUpdate = + | { sessionUpdate: 'agent_message_chunk'; content: { type: 'text'; text: string } } + | { sessionUpdate: 'tool_call'; toolCallId: string; title: string; input?: unknown } + | { sessionUpdate: 'tool_call_update'; toolCallId: string; status: string; result?: unknown } + | { sessionUpdate: 'tool_confirm'; toolCallId: string; toolName: string; input: Record } + | { sessionUpdate: 'ask_user'; toolCallId: string; questions: unknown[] } + | { sessionUpdate: 'agent_phase'; phase: 'preparing' | 'model_responding' | 'tool_executing' | 'compacting' | 'idle' } + | { sessionUpdate: 'log'; level: 'info' | 'error' | 'success' | 'command'; message: string } ``` ### 4.4 SessionStore — 会话持久化 @@ -293,7 +299,7 @@ acquire() → CreateSandboxTool + StartSandboxInstance → SandboxInstance **范式**: 流终止 + 重新进入(跨进程友好) ``` -send() → PreToolUse hook 检测到 requireApproval → 发 approval_required 事件 → 流终止 +send() → PreToolUse hook 检测到 requireApproval → ACP tool_confirm → 流终止 ↓ 业务层展示给用户,收集决策 ↓ @@ -309,7 +315,7 @@ PreToolUse hook 从 store 读到决策 → 放行/拒绝 核心类型: - `Agent` / `Session` — Agent 和会话接口 - `AgentConfig` — createAgent 配置 -- `SessionEvent` — 流式事件 +- `AcpSessionUpdate` — 默认 ACP 更新流 - `MessageRecord` / `MessagePart` — 消息记录和部件 - `SessionStoreDriver` / `SessionMessageMeta` — 存储驱动接口 - `SandboxRuntime` / `SandboxInstance` — 沙箱接口 diff --git a/packages/open-agent-kernel/README.md b/packages/open-agent-kernel/README.md index f6c2819..698a9a3 100644 --- a/packages/open-agent-kernel/README.md +++ b/packages/open-agent-kernel/README.md @@ -13,7 +13,7 @@ Open Agent Kernel(OAK)适合在 Node.js 服务端中构建 CloudBase Agent - 让 Agent 使用 CloudBase 数据库、云存储、云函数、静态托管等资源。 - 把会话、审批状态、附件、用户长期记忆持久化到 CloudBase。 - 运行远程 sandbox,让 Agent 具备文件系统、Shell、代码执行和 CloudBase MCP 工具能力。 -- OAK 本身只提供协议中立的 `AsyncIterable`,开发者自行接入 ACP / AG-UI / SSE / 自定义协议。 +- OAK 默认输出 ACP `session/update` 语义(`AsyncIterable`),可直接接入 Web/SSE/JSON-RPC 消费方。 ## 安装 @@ -404,7 +404,7 @@ sandbox: { ### HITL 工具审批 -配置 `permissions.requireApproval` 后,命中的工具调用会暂停并发出 `tool_approval_required` 事件。 +配置 `permissions.requireApproval` 后,命中的工具调用会暂停并发出 ACP `tool_confirm` 更新。 ```typescript const agent = createAgent({ @@ -423,8 +423,8 @@ const agent = createAgent({ ```typescript for await (const event of session.send('删除测试数据')) { - if (event.type === 'tool_approval_required') { - // 展示审批 UI,并保存 event.toolUseId + if (event.sessionUpdate === 'tool_confirm') { + // 展示审批 UI,并保存 event.toolCallId } } @@ -676,7 +676,7 @@ Agent / Session 运行时 API 详见文末 [API 参考](#api-参考)。 | `09-sandbox-shared.ts` | shared sandbox | `config.local.json`(含 `credentials`) | | `10-sandbox-cloudbase-tools.ts` | sandbox 内 CloudBase MCP | `config.local.json`(含 `credentials`) | | `11-hitl-approval.ts` | 单进程 HITL | `config.local.json` | -| `12-hitl-acp-adapter.ts` | ACP 风格审批适配 | `config.local.json` | +| `12-hitl-acp-adapter.ts` | 内置 ACP 审批流 | `config.local.json` | | `13-hitl-distributed-cloudbase.ts` | 分布式 HITL | `config.local.json`(含 `credentials`) | | `14-session-history.ts` | 历史查询 / 聚合验证 | `config.local.json` | | `15-skills.ts` | Skills | `config.local.json` | @@ -768,8 +768,8 @@ await agent.sessions.delete(session.id) |------|------|------| | `id` | `string` | 会话 ID / conversationId(只读)。 | | `userId` | `string` | 所属用户 ID(只读)。 | -| `send(input)` | `(input) => AsyncIterable` | 发送用户消息,返回事件流。 | -| `respondApproval(opts)` | `(opts) => AsyncIterable` | 注入 HITL 审批决策后继续运行。 | +| `send(input)` | `(input) => AsyncIterable` | 发送用户消息,返回 ACP 更新流。 | +| `respondApproval(opts)` | `(opts) => AsyncIterable` | 注入 HITL 审批决策后继续运行。 | | `getHistory(opts?)` | `() => Promise` | 获取聚合后的历史消息(供 UI 渲染)。 | | `clearHistory()` | `() => Promise` | 清除消息元数据索引,不影响 SDK transcript。 | | `getState()` | `() => Promise` | 序列化当前 RunState 为 JSON。 | @@ -788,8 +788,8 @@ await agent.sessions.delete(session.id) ```typescript // 文本消息 for await (const event of session.send('你好')) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'session_idle') break // 本轮结束 + if (event.sessionUpdate === 'agent_message_chunk') process.stdout.write(event.content.text) + if (event.sessionUpdate === 'agent_phase' && event.phase === 'idle') break } // 带附件 @@ -806,13 +806,13 @@ for await (const event of session.send({ #### `respondApproval(opts)` -收到 `tool_approval_required` 事件后,收集用户决策并继续运行: +收到 ACP `tool_confirm` 更新后,收集用户决策并继续运行: ```typescript for await (const event of session.send('删除这个集合')) { - if (event.type === 'tool_approval_required') { + if (event.sessionUpdate === 'tool_confirm') { for await (const resumed of session.respondApproval({ - toolUseId: event.toolUseId, + toolUseId: event.toolCallId, decision: { kind: 'allow', scope: 'once' }, // kind: allow | deny;scope: once | session | forever })) { // 决策注入后的后续事件 @@ -837,34 +837,21 @@ await session.snapshotWorkspace?.() // 手动触发 cwd 快照 const status = await session.getRestoreStatus?.() // 'full' | 'fresh' | 'partial' | 'failed' | null ``` -### `SessionEvent` +### `AcpSessionUpdate` -`send()` 和 `respondApproval()` 返回的 `AsyncIterable` 中,常见事件类型: +`send()` 和 `respondApproval()` 返回 `AsyncIterable`。常见更新类型: -| 事件 | 含义 | 典型处理 | -|------|------|----------| -| `message_delta` | 模型输出增量文本 | 流式渲染到 UI | -| `message_complete` | 模型输出完整文本 | 落盘 / 展示最终回复 | -| `tool_call` | Agent 发起工具调用 | 展示工具名和参数 | -| `tool_result` | 工具执行结果 | 展示工具输出 | -| `tool_approval_required` | 工具需要人工审批 | 调 `respondApproval()` | -| `handoff` | 子 Agent 切换 | 展示 handoff 信息 | -| `session_idle` | 本轮运行结束 | `reason`: `completed` / `requires_action` / `aborted` / `error` | -| `error` | 运行错误 | 展示 `error.message` | +| `sessionUpdate` | 含义 | 典型处理 | +|-----------------|------|----------| +| `agent_message_chunk` | 模型输出增量文本 | 流式渲染 `content.text` | +| `tool_call` | Agent 发起工具调用 | 展示 `title` 和 `input` | +| `tool_call_update` | 工具参数或结果更新 | 根据 `status` 渲染进度 / 结果 | +| `tool_confirm` | 工具需要人工审批 | 调 `respondApproval()` | +| `ask_user` | Agent 主动向用户提问 | 收集回答后调 `respondAskUser()` | +| `agent_phase` | Agent 阶段变化 | `phase='idle'` 表示本轮空闲 | +| `log` | 运行日志 / 错误 | 展示 `message` | -```typescript -type SessionEvent = - | { type: 'message_delta'; text: string } - | { type: 'message_complete'; text: string } - | { type: 'tool_call'; toolUseId: string; toolName: string; input: unknown } - | { type: 'tool_result'; toolUseId: string; toolName: string; output: unknown; isError: boolean } - | { type: 'tool_approval_required'; toolUseId: string; toolName: string; input: unknown; runStateJson: string; hints?: {...} } - | { type: 'handoff'; fromAgent: string; toAgent: string } - | { type: 'session_idle'; reason: 'completed' | 'requires_action' | 'aborted' | 'error' } - | { type: 'error'; error: Error } -``` - -OAK 只提供协议中立的 `AsyncIterable`;接入 SSE / ACP / AG-UI 时由业务层做事件映射。 +ACP 是 OAK 的默认输出协议;常规 `createAgent()` 无需传 `streamAdapter`。 ## 常见问题 diff --git a/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md b/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md new file mode 100644 index 0000000..4b147c6 --- /dev/null +++ b/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md @@ -0,0 +1,76 @@ +# Open Agent Kernel:内置 ACP 流式输出方案 + +> 文档状态:已落地(2026-06) +> 适用范围:`@cloudbase/open-agent-kernel` 的 `session.send()` / `respond*()` 流式输出 + +## 决策 + +OAK 默认输出 ACP `session/update` 语义: + +```typescript +const agent = createAgent({ envId, model, credentials }) +const session = await agent.startSession({ userId: 'u1' }) + +for await (const update of session.send('你好')) { + // update: AcpSessionUpdate +} +``` + +用户不需要声明 `streamAdapter`。`createAgent()` 内部默认使用内置 `AcpStreamAdapter`。 + +## 架构 + +```text +Claude Agent SDK query() + -> SDKMessage stream + -> AcpStreamAdapter + -> AsyncIterable +``` + +关键点: + +- 不对外暴露 raw `SDKMessage`。 +- 不再公开 `SessionEvent`。 +- 不保留独立 `event-translator` / `sdk-message-translator` 层。 +- 原 translator 必要能力(`stream_event` 状态机、文本去重、工具参数流、HITL sentinel 识别)全部内聚在 `AcpStreamAdapter`。 +- `streamAdapter` 仅作为高级覆盖入口保留,常规 examples 和 README 不要求用户配置。 + +## 主要映射 + +| SDK 来源 | ACP `sessionUpdate` | +|----------|---------------------| +| `stream_event.content_block_delta.text_delta` | `agent_message_chunk` | +| `stream_event.content_block_start.tool_use` | `tool_call` | +| `stream_event.input_json_delta` | `tool_call_update` | +| `assistant.tool_use` replay | `tool_call` 或 `tool_call_update`(去重) | +| `user.tool_result` | `tool_call_update` | +| OAK approval/client-tool sentinel | `tool_confirm` | +| OAK askUser sentinel | `ask_user` | +| `result` | `agent_phase` with `phase: 'idle'` | + +## 文件结构 + +```text +packages/open-agent-kernel/src/ +├── acp/ +│ ├── index.ts +│ └── types.ts +├── adapters/ +│ ├── acp-stream-adapter.ts +│ ├── index.ts +│ └── types.ts +├── public/ +│ ├── create-agent.ts +│ └── types.ts +└── runtime/ + └── agent-builder.ts +``` + +## 验证 + +```bash +pnpm --filter @cloudbase/open-agent-kernel type-check +pnpm --filter @cloudbase/open-agent-kernel build +pnpm dlx tsx packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts +pnpm exec tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck packages/open-agent-kernel/examples/21-default-acp-session-contract.ts +``` diff --git a/packages/open-agent-kernel/examples/01-quickstart.ts b/packages/open-agent-kernel/examples/01-quickstart.ts index 2b3198c..a3116d6 100644 --- a/packages/open-agent-kernel/examples/01-quickstart.ts +++ b/packages/open-agent-kernel/examples/01-quickstart.ts @@ -6,6 +6,7 @@ * * 配置:examples/config.local.json(见 config.example.json) */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -23,25 +24,7 @@ async function main(): Promise { process.stdout.write('Assistant: ') for await (const event of session.send('你好,请用一句话介绍你自己。')) { - switch (event.type) { - case 'message_delta': - process.stdout.write(event.text) - break - case 'tool_call': - console.log(`\n[tool_call] ${event.toolName}(${JSON.stringify(event.input)})`) - break - case 'tool_result': - console.log(`\n[tool_result] ${JSON.stringify(event.output)}`) - break - case 'session_idle': - console.log(`\n\n[session_idle] reason=${event.reason}`) - break - case 'error': - console.error(`\n[error] ${event.error.message}`) - break - default: - break - } + printAcpUpdate(event) } } diff --git a/packages/open-agent-kernel/examples/02-debug.ts b/packages/open-agent-kernel/examples/02-debug.ts index 1ac4b32..3dad585 100644 --- a/packages/open-agent-kernel/examples/02-debug.ts +++ b/packages/open-agent-kernel/examples/02-debug.ts @@ -1,8 +1,8 @@ /** * 02-debug.ts —— 诊断脚本(PR #3 排错用) * - * 打印 Claude SDK 发出的所有原始消息 type,以及 kernel 翻译出的 SessionEvent。 - * 用来定位"为什么 message_delta 没触发"。 + * 打印 Claude SDK 发出的所有原始消息 type,以及 OAK 输出的 ACP update。 + * 用来定位"为什么 agent_message_chunk 没触发"。 * * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/02-debug.ts @@ -12,8 +12,8 @@ import { getEnvId, getModel } from './_shared/env.js' import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk' +import { AcpStreamAdapter } from '@cloudbase/open-agent-kernel' import { buildClaudeQueryOptions } from '../src/runtime/agent-builder.js' -import { translateSdkMessage } from '../src/runtime/event-translator.js' async function main(): Promise { const { options } = buildClaudeQueryOptions({ @@ -38,22 +38,30 @@ async function main(): Promise { }) console.log('=== Stream events ===') - for await (const msg of q) { - console.log('raw msg', JSON.stringify(msg)) - // 打印 SDK 原始消息的 type + subtype(不打印 content 避免太长) - const summary: Record = { sdk_type: msg.type } - if ('subtype' in msg) summary.subtype = msg.subtype - if ('subagent_type' in msg) summary.subagent_type = msg.subagent_type - if (msg.type === 'assistant' && 'message' in msg) { - const m = msg.message as { content?: Array<{ type: string }> } - summary.content_blocks = m.content?.map((b) => b.type) ?? [] + const adapter = new AcpStreamAdapter() + const messages = (async function* () { + for await (const msg of q) { + console.log('raw msg', JSON.stringify(msg)) + // 打印 SDK 原始消息的 type + subtype(不打印 content 避免太长) + const summary: Record = { sdk_type: msg.type } + if ('subtype' in msg) summary.subtype = msg.subtype + if ('subagent_type' in msg) summary.subagent_type = msg.subagent_type + if (msg.type === 'assistant' && 'message' in msg) { + const m = msg.message as { content?: Array<{ type: string }> } + summary.content_blocks = m.content?.map((b) => b.type) ?? [] + } + console.log('SDK msg:', JSON.stringify(summary)) + yield msg } - console.log('SDK msg:', JSON.stringify(summary)) + })() - // 同时打印 kernel 翻译结果 - for (const event of translateSdkMessage(msg)) { - console.log(' → kernel event:', JSON.stringify({ type: event.type })) - } + for await (const update of adapter.adapt(messages, { + conversationId: 'debug', + sessionId: 'debug', + userId: 'debug', + turnId: 'debug-turn', + })) { + console.log(' → ACP update:', JSON.stringify({ sessionUpdate: update.sessionUpdate })) } } diff --git a/packages/open-agent-kernel/examples/09-sandbox-shared.ts b/packages/open-agent-kernel/examples/09-sandbox-shared.ts index f2c1719..6933b96 100644 --- a/packages/open-agent-kernel/examples/09-sandbox-shared.ts +++ b/packages/open-agent-kernel/examples/09-sandbox-shared.ts @@ -11,30 +11,21 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/09-sandbox-shared.ts */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' -import { createAgent } from '@cloudbase/open-agent-kernel' -import type { SessionEvent } from '@cloudbase/open-agent-kernel' +import { createAgent, type AcpSessionUpdate } from '@cloudbase/open-agent-kernel' async function streamSession( label: string, - session: { send: (input: string) => AsyncIterable }, + session: { send: (input: string) => AsyncIterable }, prompt: string, ): Promise { console.log(`\n=== ${label} ===`) console.log('User:', prompt, '\n') process.stdout.write('Assistant: ') for await (const e of session.send(prompt)) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_result') { - const out = JSON.stringify(e.output).slice(0, 300) - process.stdout.write(`\n ← ${out}\n `) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) - } + printAcpUpdate(e) } console.log() } diff --git a/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts b/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts index 6a121ae..f995427 100644 --- a/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts +++ b/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts @@ -1,22 +1,16 @@ /** - * Example 12: PR #7.0 —— ACP 协议适配演示 + * Example 12: built-in ACP HITL flow * - * 演示 kernel 的 HITL 事件流如何映射到 ACP 协议: - * - kernel 出 'tool_approval_required' → 业务发 ACP `session/request_permission` - * - ACP 客户端回 selectedOption.optionId → 业务调 session.respondApproval() - * - * **重点:协议适配代码完全在用户业务侧**,kernel 不内置 ACP——这就是 - * "kernel 是协议中立的纯库" 的真实含义。下面的 `AcpAdapter` 是 30 行业务代码示意。 - * - * 真实生产中,AcpAdapter 一边接 @zed-industries/agent-client-protocol(或自家实现), - * 另一边消费 kernel SessionEvent。 + * 演示 OAK 内置 ACP 输出后的 HITL 事件: + * - session.send() 直接输出 ACP `tool_confirm` + * - 业务拿到用户决策后调用 session.respondApproval() * * 运行(本 example 不依赖真实 ACP 客户端,模拟一个"Always allow_once" 客户端): * pnpm dlx tsx packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts */ import { getEnvId, getModel } from './_shared/env.js' -import { CloudBaseSessionStore, createAgent, InMemoryDriver, type SessionEvent } from '@cloudbase/open-agent-kernel' +import { CloudBaseSessionStore, createAgent, InMemoryDriver, type AcpSessionUpdate } from '@cloudbase/open-agent-kernel' import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk' import { z } from 'zod' @@ -63,53 +57,37 @@ async function fakeAcpRequestPermission(req: AcpPermissionRequest): Promise, - session: { respondApproval: (opts: any) => AsyncIterable }, + events: AsyncIterable, + session: { respondApproval: (opts: any) => AsyncIterable }, ): Promise { for await (const e of events) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - console.log(`\n[kernel → ACP] session_update.tool_call: ${e.toolName}`) - } else if (e.type === 'tool_result') { - const out = JSON.stringify(e.output).slice(0, 100) - console.log(`\n[kernel → ACP] session_update.tool_call_update: result=${out}`) - } else if (e.type === 'tool_approval_required') { - // ── kernel → ACP 映射 ── + if (e.sessionUpdate === 'agent_message_chunk') { + process.stdout.write(e.content.text) + } else if (e.sessionUpdate === 'tool_call') { + process.stdout.write(`\n[kernel ACP] tool_call: ${e.title}\n`) + } else if (e.sessionUpdate === 'tool_call_update') { + const out = JSON.stringify(e.result ?? e.error ?? null).slice(0, 100) + process.stdout.write(`\n[kernel ACP] tool_call_update: result=${out}\n`) + } else if (e.sessionUpdate === 'tool_confirm') { + const options: AcpPermissionRequest['options'] = [ + { optionId: 'allow_once', label: '本次允许', kind: 'allow_once' }, + { optionId: 'reject_once', label: '本次拒绝', kind: 'reject_once' }, + { optionId: 'allow_always', label: '本会话内总是允许', kind: 'allow_always' }, + ] const acpReq: AcpPermissionRequest = { toolCall: { - toolCallId: e.toolUseId, + toolCallId: e.toolCallId, toolName: e.toolName, args: e.input, }, - options: (e.hints?.suggestedScopes ?? ['once', 'session']).flatMap((scope) => { - if (scope === 'once') { - return [ - { optionId: 'allow_once', label: '本次允许', kind: 'allow_once' as const }, - { optionId: 'reject_once', label: '本次拒绝', kind: 'reject_once' as const }, - ] - } - if (scope === 'session') { - return [ - { - optionId: 'allow_always', - label: '本会话内总是允许', - kind: 'allow_always' as const, - }, - ] - } - return [] - }), + options, } const acpResp = await fakeAcpRequestPermission(acpReq) @@ -118,7 +96,7 @@ async function pumpThroughAcp( // 客户端取消 → kernel 视为 deny+interrupt await pumpThroughAcp( session.respondApproval({ - toolUseId: e.toolUseId, + toolUseId: e.toolCallId, decision: { kind: 'deny', reason: 'ACP client cancelled', interrupt: true }, }), session, @@ -140,12 +118,12 @@ async function pumpThroughAcp( } as const) // ── 注入决策并继续消费事件流(递归式抽干)── - await pumpThroughAcp(session.respondApproval({ toolUseId: e.toolUseId, decision }), session) + await pumpThroughAcp(session.respondApproval({ toolUseId: e.toolCallId, decision }), session) return - } else if (e.type === 'session_idle') { - console.log(`\n[kernel → ACP] session_update.idle: ${e.reason}`) - } else if (e.type === 'error') { - console.error(`\n[kernel → ACP] error: ${e.error.message}`) + } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + process.stdout.write('\n[kernel ACP] idle\n') + } else if (e.sessionUpdate === 'log') { + process.stdout.write(`\n[kernel ACP] log: ${e.message}\n`) } } } diff --git a/packages/open-agent-kernel/examples/18-workspace-snapshot.ts b/packages/open-agent-kernel/examples/18-workspace-snapshot.ts index 2bb5b49..6c3baf1 100644 --- a/packages/open-agent-kernel/examples/18-workspace-snapshot.ts +++ b/packages/open-agent-kernel/examples/18-workspace-snapshot.ts @@ -33,8 +33,8 @@ function buildModel() { } /** - * 把任意 event 简短打印 — 展示 SessionEvent 全貌而不只 message_delta。 - * 这是 debug 用,生产代码请按 type 分支处理。 + * 把任意 update 简短打印 — 展示 ACP update 全貌而不只 agent_message_chunk。 + * 这是 debug 用,生产代码请按 sessionUpdate 分支处理。 */ function fmtEvent(ev: unknown): string { if (typeof ev !== 'object' || ev === null) return String(ev) diff --git a/packages/open-agent-kernel/examples/README.md b/packages/open-agent-kernel/examples/README.md index 10c21bf..e208067 100644 --- a/packages/open-agent-kernel/examples/README.md +++ b/packages/open-agent-kernel/examples/README.md @@ -54,7 +54,7 @@ pnpm dlx tsx packages/open-agent-kernel/examples/01-quickstart.ts | `09-sandbox-shared.ts` | shared sandbox | `pnpm dlx tsx packages/open-agent-kernel/examples/09-sandbox-shared.ts` | | `10-sandbox-cloudbase-tools.ts` | sandbox 内 CloudBase MCP 工具 | `pnpm dlx tsx packages/open-agent-kernel/examples/10-sandbox-cloudbase-tools.ts` | | `11-hitl-approval.ts` | 单进程 HITL 审批 | `pnpm dlx tsx packages/open-agent-kernel/examples/11-hitl-approval.ts` | -| `12-hitl-acp-adapter.ts` | ACP 风格审批适配 | `pnpm dlx tsx packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts` | +| `12-hitl-acp-adapter.ts` | 内置 ACP 审批流 | `pnpm dlx tsx packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts` | | `13-hitl-distributed-cloudbase.ts` | 分布式 HITL 审批 | `pnpm dlx tsx packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts` | | `14-session-history.ts` | 历史查询 / 聚合验证 | `pnpm dlx tsx packages/open-agent-kernel/examples/14-session-history.ts` | | `15-skills.ts` | Skills | `pnpm dlx tsx packages/open-agent-kernel/examples/15-skills.ts` | @@ -63,6 +63,8 @@ pnpm dlx tsx packages/open-agent-kernel/examples/01-quickstart.ts | `18-workspace-snapshot.ts` | workspace snapshot 单进程 | `pnpm dlx tsx packages/open-agent-kernel/examples/18-workspace-snapshot.ts` | | `19a-snapshot-write.ts` | workspace snapshot 写入阶段 | `pnpm dlx tsx packages/open-agent-kernel/examples/19a-snapshot-write.ts` | | `19b-snapshot-read.ts` | workspace snapshot 读取阶段 | `pnpm dlx tsx packages/open-agent-kernel/examples/19b-snapshot-read.ts` | +| `20-acp-stream-adapter-fixture.ts` | ACP adapter fixture(不调用真实模型) | `pnpm dlx tsx packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts` | +| `21-default-acp-session-contract.ts` | 默认 session ACP 类型契约 | `pnpm exec tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck packages/open-agent-kernel/examples/21-default-acp-session-contract.ts` | ## 凭证依赖矩阵 diff --git a/packages/open-agent-kernel/examples/_shared/acp.ts b/packages/open-agent-kernel/examples/_shared/acp.ts new file mode 100644 index 0000000..789405a --- /dev/null +++ b/packages/open-agent-kernel/examples/_shared/acp.ts @@ -0,0 +1,37 @@ +import type { AcpSessionUpdate } from '@cloudbase/open-agent-kernel' + +export function writeAcpText(update: AcpSessionUpdate): void { + if (update.sessionUpdate === 'agent_message_chunk') { + process.stdout.write(update.content.text) + } +} + +export function printAcpUpdate(update: AcpSessionUpdate): void { + switch (update.sessionUpdate) { + case 'agent_message_chunk': + process.stdout.write(update.content.text) + break + case 'tool_call': + process.stdout.write(`\n -> ${update.title}(${JSON.stringify(update.input ?? {}).slice(0, 200)})\n `) + break + case 'tool_call_update': + if (update.status === 'completed' || update.status === 'failed') { + process.stdout.write(`\n <- ${JSON.stringify(update.result ?? update.error ?? null).slice(0, 300)}\n `) + } + break + case 'tool_confirm': + process.stdout.write(`\n ? ${update.toolName} requires confirmation\n `) + break + case 'ask_user': + process.stdout.write('\n ? agent asks user\n ') + break + case 'log': + process.stdout.write(`\n[${update.level}] ${update.message}\n`) + break + case 'agent_phase': + if (update.phase === 'idle') process.stdout.write('\n') + break + default: + break + } +} diff --git a/packages/open-agent-kernel/src/permissions/hooks.ts b/packages/open-agent-kernel/src/permissions/hooks.ts index b3398e0..65648d1 100644 --- a/packages/open-agent-kernel/src/permissions/hooks.ts +++ b/packages/open-agent-kernel/src/permissions/hooks.ts @@ -9,11 +9,11 @@ * - 有 'deny' → 返回 deny + 用户拒绝理由(**不**带 sentinel) * - 没有 → 检查规则,需要审批 → 写 store + 返回 deny + sentinel * - 不需要审批 → 返回 {} 放行 - * 3. 事件翻译层(event-translator)识别 sentinel 后吐出 'tool_approval_required' 事件 + * 3. AcpStreamAdapter 识别 sentinel 后吐出 ACP `tool_confirm` 更新 * 并吃掉这条假 deny tool_result,避免污染业务事件流和 transcript * * 这里 "sentinel" 是把 magic string 塞进 `permissionDecisionReason`——这是 SDK Hook - * 接口能传递信号的唯一通道。具体实现是一个 JSON 字符串,业务侧不会看到(被 translator 吃掉)。 + * 接口能传递信号的唯一通道。具体实现是一个 JSON 字符串,业务侧不会看到(被 AcpStreamAdapter 吃掉)。 */ import type { ApprovalDecision, PendingApproval, PermissionConfig } from '../public/types.js' @@ -83,7 +83,7 @@ export function createHookLocalState(): PreToolUseHookLocalState { /** * Sentinel reason payload(写到 permissionDecisionReason 的 JSON 字符串)。 - * event-translator 解析这个 JSON 把 toolUseId / toolName / input 还原成 'tool_approval_required' 事件。 + * AcpStreamAdapter 解析这个 JSON,把 toolUseId / toolName / input 还原成 ACP `tool_confirm`。 */ export interface InterruptSignalPayload { [OAK_INTERRUPT_SENTINEL]: true @@ -130,7 +130,7 @@ export function parseInterruptSignal(reason: string): InterruptSignalPayload | n // must be executed by the client". Used for tools whose definitions live in // AgentConfig.tools[] and whose `execute()` is a stub: the kernel never // actually runs them, it pauses the turn (via permissionDecision='deny' + -// sentinel) and emits a `tool_use_required` SessionEvent so the host can +// sentinel) and emits an ACP `tool_confirm` update so the host can // run the tool elsewhere and feed the result back via session.send({type: // 'tool_result'}). // @@ -165,7 +165,7 @@ export function parseClientToolSignal(reason: string): ClientToolSignalPayload | // 与 approval / client-tool 同一流终止+resume 范式: // 1. 模型调用内置 askUser 工具 → PreToolUse hook 拦截 // 2. 写 PendingAskUserEntry 到 store → 返回 deny + sentinel -// 3. translator 识别 sentinel → yield 'ask_user_required' 事件 +// 3. AcpStreamAdapter 识别 sentinel → yield ACP `ask_user` 更新 // 4. Host 收集用户回答 → session.respondAskUser() → resume // // 与 codebuddy 的区别:codebuddy 的 AskUser 会 hang 住进程; @@ -229,7 +229,7 @@ export interface PreToolUsePermissionHookArgs { * Names of user-defined client-side tools (config.tools[].name). When the * model invokes one of these, the hook denies with a client-tool sentinel * so the SDK never calls execute(); the runtime intercepts the sentinel - * to surface a 'tool_use_required' event and pause the turn. + * to surface an ACP `tool_confirm` update and pause the turn. * * On resume, the hook reads the host-supplied result from the * clientToolStore and ALLOWs the call after rewriting `updatedInput` to @@ -342,8 +342,8 @@ export function createPreToolUsePermissionHook( } // Phase B: no result → pause. Mirror the approval flow: write a - // pending entry, return deny + sentinel. Translator detects the - // sentinel and emits a 'tool_use_required' SessionEvent. + // pending entry, return deny + sentinel. AcpStreamAdapter detects the + // sentinel and emits an ACP `tool_confirm` update. if (!toolUseId) { return { hookSpecificOutput: { @@ -604,7 +604,7 @@ export function createPreToolUsePermissionHook( // permissionDecisionReason 这段 JSON 既是 kernel 内部的 sentinel,又会被 SDK // 当作 tool_result 喂给模型 context(SDK 接口约束)。我们让它对模型也"读得通": // 加一个明确的 message 字段,引导模型停止重试、等待审批。 - // event-translator 仍然识别 sentinel 字段并把这条消息从业务事件流里吃掉。 + // AcpStreamAdapter 仍然识别 sentinel 字段并把这条消息从业务事件流里吃掉。 const reasonForModel = `Tool call paused for user approval (toolUseId=${toolUseId}). ` + `Do not retry this tool yourself; the user is reviewing it. ` + diff --git a/packages/open-agent-kernel/src/permissions/store.ts b/packages/open-agent-kernel/src/permissions/store.ts index 451721f..1b7bbcd 100644 --- a/packages/open-agent-kernel/src/permissions/store.ts +++ b/packages/open-agent-kernel/src/permissions/store.ts @@ -82,7 +82,7 @@ function buildKey(conversationId: string, toolUseId: string): string { * * Used to stash a client-supplied tool result between two SDK runs: * 1. SDK turn 1: PreToolUse hook denies a custom tool with a sentinel. - * Stream emits 'tool_use_required'; turn ends. + * ACP stream emits `tool_confirm`; turn ends. * 2. Host calls session.respondToolUse(...) → store.put(... result). * 3. SDK turn 2 (resume): PreToolUse hook scans the store, finds the * result, allows + injects it via updatedInput. The wrapped MCP stub diff --git a/packages/open-agent-kernel/src/public/create-agent.ts b/packages/open-agent-kernel/src/public/create-agent.ts index ba6981e..77cc1db 100644 --- a/packages/open-agent-kernel/src/public/create-agent.ts +++ b/packages/open-agent-kernel/src/public/create-agent.ts @@ -342,8 +342,8 @@ function createSession(deps: SessionDeps): Session { // PR #7.1: client-side tools store + name set. The set lets the // PreToolUse hook recognise mcp__custom__* tools (custom = user-declared, // execute() in the wrapped MCP server is a stub). The store carries - // host-supplied tool results between SDK turns (turn-1 emits - // tool_use_required; respondToolUse() stashes; turn-2 reads). + // host-supplied tool results between SDK turns (turn-1 emits ACP + // tool_confirm; respondToolUse() stashes; turn-2 reads). const clientToolNames: Set = new Set((config.tools ?? []).map((t) => t.name)) const clientToolStore: ClientToolResultStore | undefined = clientToolNames.size > 0 @@ -417,7 +417,7 @@ function createSession(deps: SessionDeps): Session { * 持久化为 .workspace-env.json,init body 的 env 必须跟它语义一致) * * 失败处理:bootstrap 抛出(SandboxRestoreFailed / 网络错误)时让异常向上冒, - * 由 runClaudeQuery 的 catch 块翻译为 'error' 事件 + session_idle('error')。 + * 由 runClaudeQuery 的 catch 块翻译为 ACP log + agent_phase idle。 * 这是 spec §6.2"restore failed → 视为致命"行为。 */ async function ensureSnapshotBootstrap(engine: WorkspaceSnapshotEngine, sandbox: SandboxInstance): Promise { @@ -508,7 +508,7 @@ function createSession(deps: SessionDeps): Session { }, /** - * PR #7.1: respond to a client-side tool_use_required pause. + * PR #7.1: respond to a client-side tool_confirm pause. * * Wire flow: * 1. Stash the host-supplied result in the in-memory clientToolStore. diff --git a/packages/open-agent-kernel/src/public/types.ts b/packages/open-agent-kernel/src/public/types.ts index f356e03..ff41179 100644 --- a/packages/open-agent-kernel/src/public/types.ts +++ b/packages/open-agent-kernel/src/public/types.ts @@ -236,7 +236,7 @@ export type McpServerConfig = SdkMcpServerConfig // ============================================================ /** - * 审批决策(用户对 tool_approval_required 的响应)。 + * 审批决策(用户对 ACP tool_confirm 的响应)。 * * 这是协议无关的超集——业务侧的 ACP / AG-UI / 自家 SSE 等协议只需要把 * 自己的决策枚举映射成下面的字段即可。 @@ -708,12 +708,12 @@ export interface Session { /** * 响应工具审批(PR #7.0)。 * - * 当事件流给出 `tool_approval_required` 后,业务收集到用户决策(allow/deny/scope/...) + * 当 ACP 更新流给出 `tool_confirm` 后,业务收集到用户决策(allow/deny/scope/...) * 调本方法注入决策。kernel 把决策写入 PermissionStore,然后内部 resume 一次 SDK 运行: * Hook 再次触发时从 store 读到决策并放行 / 拒绝,agent 继续往下跑。 * - * 返回的事件流是"决策注入后"的运行流(可能包含 message_delta / tool_call / - * tool_result / 再次的 tool_approval_required / session_idle 等)。 + * 返回的事件流是"决策注入后"的 ACP 更新流(可能包含 agent_message_chunk / + * tool_call / tool_call_update / 再次的 tool_confirm / agent_phase 等)。 * * 注意:调用方应确保同一 toolUseId 不被并发响应;重复响应会用最后一次为准。 */ @@ -722,22 +722,21 @@ export interface Session { /** * PR #7.1: 注入客户端工具结果并 resume agent 运行。 * - * 配套 'tool_use_required' 事件使用:业务侧在客户端执行完 AgentConfig.tools[] + * 配套 ACP `tool_confirm` 使用:业务侧在客户端执行完 AgentConfig.tools[] * 中声明的工具后,调本方法把结果回灌给 kernel: * 1. kernel 把结果写入内部 client-tool store * 2. 起一轮 SDK query(resume)→ 模型重发同名工具 → PreToolUse hook 这次 * 把结果通过 updatedInput 注入 → 包装的 MCP stub 直接返回它,写一条 * 正常(非 error)的 tool_result 进 transcript。 * - * 返回的事件流是"结果注入后"的运行流(可能包含 message_delta / tool_call / - * tool_result / session_idle 等)。 + * 返回的事件流是"结果注入后"的 ACP 更新流。 */ respondToolUse(opts: { toolUseId: string; output: unknown; isError?: boolean }): AsyncIterable /** * 注入用户对 askUser 提问的回答并 resume agent 运行。 * - * 配套 'ask_user_required' 事件使用:业务侧收集到用户回答后, + * 配套 ACP `ask_user` 更新使用:业务侧收集到用户回答后, * 调本方法把回答回灌给 kernel: * 1. kernel 把回答写入内部 askUser store * 2. 起一轮 SDK query(resume)→ 模型重发 askUser 工具 → PreToolUse hook 这次 @@ -810,103 +809,6 @@ export type AttachmentInput = | { type: 'url'; url: string; mimeType?: string } | { type: 'cos'; fileId: string; mimeType?: string } -// ============================================================ -// Session 事件流 -// ============================================================ - -export type SessionEvent = - | { type: 'message_delta'; text: string } - | { type: 'message_complete'; text: string } - | { - type: 'tool_call' - toolUseId: string - toolName: string - input: unknown - } - | { - type: 'tool_result' - toolUseId: string - toolName: string - output: unknown - isError: boolean - } - | { - /** - * 工具调用需要用户审批(PR #7.0)。 - * - * 收到此事件后,本轮 SDK 运行会自然结束(紧跟 `session_idle.requires_action`)。 - * 业务收集到决策后调 `session.respondApproval({ toolUseId, decision })` 继续。 - * - * 协议无关字段:客户端协议(ACP/AG-UI/SSE)适配只需把这些字段映射到自家协议。 - */ - type: 'tool_approval_required' - toolUseId: string - toolName: string - input: unknown - /** - * 给客户端 UI 的辅助提示,**协议无关**。 - * - displayName:UI 按钮 / 标题用的短名 - * - description:长描述("will read files in ~/Downloads") - * - suggestedScopes:UI 可呈现的"作用范围"选项(once/session/forever) - */ - hints?: { - displayName?: string - description?: string - suggestedScopes?: Array<'once' | 'session' | 'forever'> - } - /** - * Resume token(业务可不持久化,conversationId + toolUseId 就够 resumeApproval; - * 此字段留作未来跨进程 RunState 持久化的扩展点)。 - */ - runStateJson: string - } - | { - /** - * 客户端工具需要客户端执行(PR #7.1)。 - * - * 当模型调用 AgentConfig.tools[] 中声明的"client-side custom tool"时, - * kernel 不会真的调 execute(),而是让 PreToolUse hook 拦截: - * 1. 写一个 pending entry 到内部 client-tool store - * 2. 用一个 sentinel deny 让 SDK 终止本轮 - * 3. 翻译层识别 sentinel 后吐出本事件 - * - * 业务侧收到本事件 → 在客户端实际执行工具 → 调 - * `session.respondToolUse({ toolUseId, output, isError? })` 注入结果, - * kernel 会 resume 一轮 SDK 让模型重发同名工具,hook 这次会注入结果, - * 模型基于真实结果继续。 - */ - type: 'tool_use_required' - toolUseId: string - toolName: string - input: unknown - } - | { - /** - * Agent 主动向用户提问。 - * - * 当模型调用内置 askUser 工具时,kernel 用 sentinel 中断 turn, - * 翻译层识别后吐出本事件。业务侧收集用户回答后调 - * `session.respondAskUser({ toolUseId, answer })` 注入回答并 resume。 - * - * 与 codebuddy 的区别:codebuddy 的 AskUser hang 住进程; - * OAK 的 askUser 是流终止+resume,不阻塞、支持分布式。 - */ - type: 'ask_user_required' - toolUseId: string - question: string - options?: string[] - } - | { - type: 'handoff' - fromAgent: string - toAgent: string - } - | { - type: 'session_idle' - reason: 'completed' | 'requires_action' | 'aborted' | 'error' - } - | { type: 'error'; error: Error } - // ============================================================ // 历史消息记录(PR #4.6 扩展) // ============================================================ From 6883631054aacc5e4eb7b52fb30a93237e21659e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=9D=99=E8=BF=9C?= <837317210@qq.com> Date: Thu, 25 Jun 2026 09:17:49 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E6=94=B9=E9=80=A0=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20acp=20=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../examples/03-multi-turn.ts | 17 ++-- .../examples/04-multi-turn-db.ts | 70 +++++++-------- .../examples/05-multimodal.ts | 23 +++-- .../examples/06-mcp-sdk-server.ts | 11 +-- .../examples/07-mcp-stdio.ts | 11 +-- .../open-agent-kernel/examples/08-sandbox.ts | 12 +-- .../examples/10-sandbox-cloudbase-tools.ts | 12 +-- .../examples/11-hitl-approval.ts | 61 +++++-------- .../examples/13-hitl-distributed-cloudbase.ts | 56 +++++------- .../examples/14-session-history.ts | 33 +++---- .../open-agent-kernel/examples/15-skills.ts | 5 +- .../examples/16-client-tool-history-live.ts | 47 +++------- .../examples/16-user-memory.ts | 3 +- .../examples/17-session-store-debug.ts | 25 +++--- .../examples/17-user-memory-distributed.ts | 3 +- .../examples/18-workspace-snapshot.ts | 41 ++------- .../examples/19a-snapshot-write.ts | 19 ++-- .../examples/19b-snapshot-read.ts | 30 +++---- packages/open-agent-kernel/examples/README.md | 3 +- .../open-agent-kernel/examples/_shared/acp.ts | 88 ++++++++++++++++++- .../open-agent-kernel/examples/_shared/env.ts | 8 ++ .../examples/config.example.json | 1 + 22 files changed, 279 insertions(+), 300 deletions(-) diff --git a/packages/open-agent-kernel/examples/03-multi-turn.ts b/packages/open-agent-kernel/examples/03-multi-turn.ts index 26a7ba3..bdb4ee5 100644 --- a/packages/open-agent-kernel/examples/03-multi-turn.ts +++ b/packages/open-agent-kernel/examples/03-multi-turn.ts @@ -11,6 +11,7 @@ * * 配置:examples/config.local.json。 */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel } from './_shared/env.js' import { CloudBaseSessionStore, InMemoryDriver, createAgent } from '@cloudbase/open-agent-kernel' @@ -34,12 +35,8 @@ async function main(): Promise { console.log('User: 我叫小明,喜欢吃西红柿炒蛋。') process.stdout.write('Assistant: ') for await (const event of session.send('我叫小明,喜欢吃西红柿炒蛋。')) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'session_idle') console.log() - if (event.type === 'error') { - console.error('[error]', event.error.message) - return - } + printAcpUpdate(event) + if (event.sessionUpdate === 'log' && event.level === 'error') return } // 看看 driver 里 SDK 真实派生的 projectKey 是什么样子 @@ -55,12 +52,8 @@ async function main(): Promise { console.log('User: 还记得我的名字吗?我喜欢什么菜?') process.stdout.write('Assistant: ') for await (const event of session.send('还记得我的名字吗?我喜欢什么菜?')) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'session_idle') console.log() - if (event.type === 'error') { - console.error('[error]', event.error.message) - return - } + printAcpUpdate(event) + if (event.sessionUpdate === 'log' && event.level === 'error') return } console.log('\n--- Done ---') diff --git a/packages/open-agent-kernel/examples/04-multi-turn-db.ts b/packages/open-agent-kernel/examples/04-multi-turn-db.ts index 9adc840..0fe7daa 100644 --- a/packages/open-agent-kernel/examples/04-multi-turn-db.ts +++ b/packages/open-agent-kernel/examples/04-multi-turn-db.ts @@ -3,76 +3,74 @@ * * 演示: * 1. 传入 credentials 后默认启用 CloudBase FlexDB session 持久化 - * 2. 同一个 session 跑两轮对话,第二轮模型应该能引用第一轮的内容 - * 3. 跨进程 resume:第二次运行时配置 OAK_RESUME_CONVERSATION_ID= - * 把上次的 conversationId 传入,agent.resumeSession 从 DB 拉历史继续 + * 2. **第一次运行**(无 resumeConversationId):告知个人信息,落库后输出 conversationId + * 3. **第二次运行**(填入 examples.resumeConversationId):跨进程 resume,提问验证 DB 记忆 * * 配置:examples/config.local.json(见 config.example.json) * * 运行: + * # 第一次:写入个人信息 + * pnpm dlx tsx packages/open-agent-kernel/examples/04-multi-turn-db.ts + * # 把输出的 conversationId 填入 config.local.json → examples.resumeConversationId + * # 第二次:跨进程 resume + 回忆测试 * pnpm dlx tsx packages/open-agent-kernel/examples/04-multi-turn-db.ts * * 验证 DB: * 在 CloudBase 控制台 → 数据库 → 看 oak_sessions / oak_session_entries / oak_session_summaries */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials, getResumeConversationId } from './_shared/env.js' -import { createAgent } from '@cloudbase/open-agent-kernel' +import { createAgent, type AcpSessionUpdate } from '@cloudbase/open-agent-kernel' + +const SEED_PROMPT = '我叫小明,喜欢吃西红柿炒蛋。' +const RECALL_PROMPT = '还记得我的名字吗?我喜欢什么菜?' + +async function streamTurn( + session: { send: (input: string) => AsyncIterable }, + label: string, + prompt: string, +) { + console.log(`\n--- ${label} ---`) + console.log(`User: ${prompt}`) + process.stdout.write('Assistant: ') + for await (const event of session.send(prompt)) { + printAcpUpdate(event) + if (event.sessionUpdate === 'log' && event.level === 'error') { + throw new Error('session ended with error') + } + } +} async function main(): Promise { const envId = getEnvId() const credentials = getPlatformCredentials() + const resumeId = getResumeConversationId() const agent = createAgent({ envId, credentials, model: getModel(), systemPrompt: 'You are a helpful assistant. Reply concisely in Chinese. ' + 'Remember details across turns.', - // 不配置 session 时,credentials 存在会默认启用 CloudBase FlexDB session store。 - // 如需自定义表前缀:session: { tablePrefix: 'my_agent_' } }) - const resumeId = getResumeConversationId() const session = resumeId ? await agent.resumeSession(resumeId) : await agent.startSession({ userId: 'demo-user' }) if (resumeId) { console.log(`[resume] continuing conversation=${resumeId}`) + await streamTurn(session, 'Turn (cross-process recall)', RECALL_PROMPT) } else { console.log(`[start] new conversation=${session.id}`) - console.log(` → 下次跑可在 config.local.json 的 examples.resumeConversationId 填入 ${session.id} 来 resume`) + await streamTurn(session, 'Turn 1 (seed profile to DB)', SEED_PROMPT) + console.log('\n--- Next step ---') + console.log(`把 conversationId 写入 config.local.json → examples.resumeConversationId:`) + console.log(` "${session.id}"`) + console.log('然后重新运行本 example,将跨进程 resume 并提问回忆。') } - // ── 第一轮 ───────────────────────────────────────────────── - console.log('\n--- Turn 1 ---') - console.log('User: 我是谁') - process.stdout.write('Assistant: ') - for await (const event of session.send('我是谁')) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'session_idle') console.log() - if (event.type === 'error') { - console.error('[error]', event.error.message) - return - } - } - - // ── 第二轮(同一个 session,验证记忆) ────────────────────── - console.log('\n--- Turn 2 ---') - console.log('User: 还记得我的名字吗?我喜欢什么菜?') - process.stdout.write('Assistant: ') - for await (const event of session.send('还记得我的名字吗?我喜欢什么菜?')) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'session_idle') console.log() - if (event.type === 'error') { - console.error('[error]', event.error.message) - return - } - } - - // ── 验证:用 envId 直接查 DB 确认数据真的落库 ───────────────── console.log('\n--- Diagnostic ---') console.log(`conversation=${session.id}`) console.log('→ 在 CloudBase 控制台 oak_session_entries 集合里按 sessionId 过滤可见全部 transcript 条目。') - console.log('\n--- Done ---') } diff --git a/packages/open-agent-kernel/examples/05-multimodal.ts b/packages/open-agent-kernel/examples/05-multimodal.ts index a4abc28..a6e9e05 100644 --- a/packages/open-agent-kernel/examples/05-multimodal.ts +++ b/packages/open-agent-kernel/examples/05-multimodal.ts @@ -10,7 +10,14 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/05-multimodal.ts */ -import { getEnvId, getExampleImagePath, getExampleStorage, getModel, getPlatformCredentials } from './_shared/env.js' +import { printAcpUpdate } from './_shared/acp.js' +import { + getEnvId, + getExampleImagePath, + getExampleStorage, + getPlatformCredentials, + getVisionModel, +} from './_shared/env.js' import * as path from 'node:path' import { InMemoryStorage, createAgent } from '@cloudbase/open-agent-kernel' @@ -28,13 +35,15 @@ async function main(): Promise { const agent = createAgent({ envId: getEnvId(), ...(credentials ? { credentials } : {}), - // 视觉模型:需当前 CloudBase 环境已开通对应多模态模型 - model: getModel('glm-5v-turbo'), + // 必须用视觉模型;config.model 常为文本模型(glm-5.1),不能用于识图 + model: 'glm-5v-turbo', systemPrompt: 'You are a helpful image analysis assistant. Reply concisely in Chinese.', ...(storage ? { storage } : {}), }) + const visionModel = getVisionModel() console.log(`[storage] using ${storageName}`) + console.log(`[model] ${visionModel} (vision; config.model 不影响本 example)`) console.log(`[image] ${imagePath}`) const session = await agent.startSession({ userId: 'demo-user' }) @@ -48,12 +57,8 @@ async function main(): Promise { content: '这张图里展示了什么?请用一两句话描述关键内容。', attachments: [{ type: 'file', source: imagePath }], })) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'session_idle') console.log() - if (event.type === 'error') { - console.error('\n[error]', event.error.message) - return - } + printAcpUpdate(event) + if (event.sessionUpdate === 'log' && event.level === 'error') return } console.log('\n--- Done ---') diff --git a/packages/open-agent-kernel/examples/06-mcp-sdk-server.ts b/packages/open-agent-kernel/examples/06-mcp-sdk-server.ts index 3458211..bf75dba 100644 --- a/packages/open-agent-kernel/examples/06-mcp-sdk-server.ts +++ b/packages/open-agent-kernel/examples/06-mcp-sdk-server.ts @@ -14,6 +14,7 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/06-mcp-sdk-server.ts */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel } from './_shared/env.js' import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk' @@ -53,15 +54,7 @@ async function main(): Promise { console.log('User: 帮我算一下 23 * 47 + 100') process.stdout.write('Assistant: ') for await (const e of session.send('帮我算一下 23 * 47 + 100')) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → calling ${e.toolName}(${JSON.stringify(e.input)})\n `) - } else if (e.type === 'tool_result') { - process.stdout.write(`\n ← result: ${JSON.stringify(e.output)}\n `) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) - } + printAcpUpdate(e) } console.log('\n\n--- Done ---') diff --git a/packages/open-agent-kernel/examples/07-mcp-stdio.ts b/packages/open-agent-kernel/examples/07-mcp-stdio.ts index c7585d5..f18cb0f 100644 --- a/packages/open-agent-kernel/examples/07-mcp-stdio.ts +++ b/packages/open-agent-kernel/examples/07-mcp-stdio.ts @@ -17,6 +17,7 @@ * * 注意:第一次运行 npx 会拉包,需要 ~10s。 */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -45,15 +46,7 @@ async function main(): Promise { process.stdout.write('Assistant: ') for await (const e of session.send('帮我把 17 和 25 相加,再让 echo 工具回显 "hello mcp"')) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → calling ${e.toolName}(${JSON.stringify(e.input)})\n `) - } else if (e.type === 'tool_result') { - process.stdout.write(`\n ← result: ${JSON.stringify(e.output)}\n `) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) - } + printAcpUpdate(e) } console.log('\n\n--- Done ---') diff --git a/packages/open-agent-kernel/examples/08-sandbox.ts b/packages/open-agent-kernel/examples/08-sandbox.ts index d5020b8..4d9a687 100644 --- a/packages/open-agent-kernel/examples/08-sandbox.ts +++ b/packages/open-agent-kernel/examples/08-sandbox.ts @@ -17,6 +17,7 @@ * - 第一次运行会触发 CreateSandboxTool(~30s)+ StartSandboxInstance(~30-60s) * - 之后同一 envId 会复用 ToolId(内存 cache),但每个 session 仍会启新实例 */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -53,16 +54,7 @@ async function main(): Promise { process.stdout.write('Assistant: ') for await (const e of session.send(prompt)) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_result') { - const out = JSON.stringify(e.output).slice(0, 300) - process.stdout.write(`\n ← ${out}\n `) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) - } + printAcpUpdate(e) } console.log('\n\n--- Cleaning up sandbox ---') diff --git a/packages/open-agent-kernel/examples/10-sandbox-cloudbase-tools.ts b/packages/open-agent-kernel/examples/10-sandbox-cloudbase-tools.ts index e93d92b..b336c7c 100644 --- a/packages/open-agent-kernel/examples/10-sandbox-cloudbase-tools.ts +++ b/packages/open-agent-kernel/examples/10-sandbox-cloudbase-tools.ts @@ -22,6 +22,7 @@ * - 镜像必须自带 mcporter + cloudbase-mcp(默认 OpenVibeCoding 公开 vibecoding 镜像满足) * - 镜像不带这两个工具时,cloudbase tools 自动 degrade(仍能用 sandbox 文件系统工具) */ +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -67,16 +68,7 @@ async function main(): Promise { process.stdout.write('Assistant: ') for await (const e of session.send(prompt)) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_result') { - const out = JSON.stringify(e.output).slice(0, 300) - process.stdout.write(`\n ← ${out}\n `) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) - } + printAcpUpdate(e) } console.log('\n\n--- Cleaning up sandbox ---') diff --git a/packages/open-agent-kernel/examples/11-hitl-approval.ts b/packages/open-agent-kernel/examples/11-hitl-approval.ts index 19b22cb..677ece9 100644 --- a/packages/open-agent-kernel/examples/11-hitl-approval.ts +++ b/packages/open-agent-kernel/examples/11-hitl-approval.ts @@ -3,7 +3,7 @@ * * 演示: * - `permissions.requireApproval` 把指定工具变成"需审批" - * - 事件流出 `tool_approval_required` 后流自然结束(reason: 'requires_action') + * - 事件流出 `tool_confirm` 后流自然结束 * - 业务侧通过 readline 在终端收集用户决定 * - `session.respondApproval({ toolUseId, decision })` 注入决策并 resume * - 决策为 allow → 工具执行,agent 继续;deny → 模型收到拒绝并解释 @@ -16,6 +16,7 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/11-hitl-approval.ts */ +import { captureToolConfirm, printAcpUpdate, type PendingToolConfirm } from './_shared/acp.js' import { getEnvId, getModel } from './_shared/env.js' import { CloudBaseSessionStore, createAgent, InMemoryDriver } from '@cloudbase/open-agent-kernel' @@ -95,25 +96,19 @@ async function main(): Promise { console.log(`User: ${prompt}\n`) process.stdout.write('Assistant: ') - let pendingApproval: { toolUseId: string; toolName: string; input: unknown } | undefined + let pendingApproval: PendingToolConfirm | undefined for await (const e of session.send(prompt)) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_result') { - process.stdout.write(`\n ← ${JSON.stringify(e.output).slice(0, 200)}\n `) - } else if (e.type === 'tool_approval_required') { + printAcpUpdate(e) + const captured = captureToolConfirm(e) + if (captured) { console.log('\n\n⏸ 审批请求:') - console.log(` 工具: ${e.toolName}`) - console.log(` 参数: ${JSON.stringify(e.input)}`) - console.log(` toolUseId: ${e.toolUseId}`) - pendingApproval = { toolUseId: e.toolUseId, toolName: e.toolName, input: e.input } - } else if (e.type === 'session_idle') { - console.log(`\n[session_idle: ${e.reason}]`) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) + console.log(` 工具: ${captured.toolName}`) + console.log(` 参数: ${JSON.stringify(captured.input)}`) + console.log(` toolUseId: ${captured.toolUseId}`) + pendingApproval = captured + } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + console.log('\n[agent_phase: idle]') } } @@ -136,31 +131,21 @@ async function main(): Promise { ? { kind: 'allow', scope: 'once' } : { kind: 'deny', reason: '用户在 CLI 拒绝', interrupt: false }, })) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_result') { - process.stdout.write(`\n ← ${JSON.stringify(e.output).slice(0, 200)}\n `) - } else if (e.type === 'tool_approval_required') { - // 第二个工具调用又触发了审批;为简化 demo,直接 allow - console.log('\n\n⏸ 又一个审批请求(demo 自动 allow):', e.toolName, e.input) - // 注意:这里嵌套调用同一 session.respondApproval 来再次 resume——支持 + printAcpUpdate(e) + const captured = captureToolConfirm(e) + if (captured) { + console.log('\n\n⏸ 又一个审批请求(demo 自动 allow):', captured.toolName, captured.input) for await (const e2 of session.respondApproval({ - toolUseId: e.toolUseId, + toolUseId: captured.toolUseId, decision: { kind: 'allow', scope: 'once' }, })) { - if (e2.type === 'message_delta') process.stdout.write(e2.text) - else if (e2.type === 'tool_call') - process.stdout.write(`\n → ${e2.toolName}(${JSON.stringify(e2.input).slice(0, 200)})\n `) - else if (e2.type === 'tool_result') process.stdout.write(`\n ← ${JSON.stringify(e2.output).slice(0, 200)}\n `) - else if (e2.type === 'session_idle') console.log(`\n[session_idle: ${e2.reason}]`) - else if (e2.type === 'error') console.error('\n[error]', e2.error.message) + printAcpUpdate(e2) + if (e2.sessionUpdate === 'agent_phase' && e2.phase === 'idle') { + console.log('\n[agent_phase: idle]') + } } - } else if (e.type === 'session_idle') { - console.log(`\n[session_idle: ${e.reason}]`) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) + } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + console.log('\n[agent_phase: idle]') } } diff --git a/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts b/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts index 1fc897f..6d24f8b 100644 --- a/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts +++ b/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts @@ -21,6 +21,7 @@ * 验证 DB: * 在 CloudBase 控制台 → 数据库 → 看 oak_state 集合(pending / decided 都会落到这里) */ +import { captureToolConfirm, printAcpUpdate, type PendingToolConfirm } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -74,23 +75,19 @@ async function main(): Promise { console.log(`User: ${prompt}\n`) process.stdout.write('Assistant: ') - let pendingApproval: { toolUseId: string; toolName: string; input: unknown } | undefined + let pendingApproval: PendingToolConfirm | undefined for await (const e of sessionA.send(prompt)) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_approval_required') { + printAcpUpdate(e) + const captured = captureToolConfirm(e) + if (captured) { console.log('\n\n⏸ 审批请求(已写入 CloudBase DB):') - console.log(` 工具: ${e.toolName}`) - console.log(` 参数: ${JSON.stringify(e.input)}`) - console.log(` toolUseId: ${e.toolUseId}`) - pendingApproval = { toolUseId: e.toolUseId, toolName: e.toolName, input: e.input } - } else if (e.type === 'session_idle') { - console.log(`\n[session_idle: ${e.reason}]`) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) + console.log(` 工具: ${captured.toolName}`) + console.log(` 参数: ${JSON.stringify(captured.input)}`) + console.log(` toolUseId: ${captured.toolUseId}`) + pendingApproval = captured + } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + console.log('\n[agent_phase: idle]') } } @@ -129,30 +126,21 @@ async function main(): Promise { toolUseId: pendingApproval.toolUseId, decision: { kind: 'allow', scope: 'once' }, })) { - if (e.type === 'message_delta') { - process.stdout.write(e.text) - } else if (e.type === 'tool_call') { - process.stdout.write(`\n → ${e.toolName}(${JSON.stringify(e.input).slice(0, 200)})\n `) - } else if (e.type === 'tool_result') { - process.stdout.write(`\n ← ${JSON.stringify(e.output).slice(0, 200)}\n `) - } else if (e.type === 'tool_approval_required') { - // 同会话再次触发审批(demo 自动 allow) - console.log('\n\n⏸ 又一个审批请求(demo 自动 allow):', e.toolName) + printAcpUpdate(e) + const captured = captureToolConfirm(e) + if (captured) { + console.log('\n\n⏸ 又一个审批请求(demo 自动 allow):', captured.toolName) for await (const e2 of sessionB.respondApproval({ - toolUseId: e.toolUseId, + toolUseId: captured.toolUseId, decision: { kind: 'allow', scope: 'once' }, })) { - if (e2.type === 'message_delta') process.stdout.write(e2.text) - else if (e2.type === 'tool_call') - process.stdout.write(`\n → ${e2.toolName}(${JSON.stringify(e2.input).slice(0, 200)})\n `) - else if (e2.type === 'tool_result') process.stdout.write(`\n ← ${JSON.stringify(e2.output).slice(0, 200)}\n `) - else if (e2.type === 'session_idle') console.log(`\n[session_idle: ${e2.reason}]`) - else if (e2.type === 'error') console.error('\n[error]', e2.error.message) + printAcpUpdate(e2) + if (e2.sessionUpdate === 'agent_phase' && e2.phase === 'idle') { + console.log('\n[agent_phase: idle]') + } } - } else if (e.type === 'session_idle') { - console.log(`\n[session_idle: ${e.reason}]`) - } else if (e.type === 'error') { - console.error('\n[error]', e.error.message) + } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + console.log('\n[agent_phase: idle]') } } diff --git a/packages/open-agent-kernel/examples/14-session-history.ts b/packages/open-agent-kernel/examples/14-session-history.ts index 2a27c81..099bd04 100644 --- a/packages/open-agent-kernel/examples/14-session-history.ts +++ b/packages/open-agent-kernel/examples/14-session-history.ts @@ -16,6 +16,7 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/14-session-history.ts */ +import { captureToolConfirm, logAcpUpdate, type PendingToolConfirm } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' import { randomUUID } from 'node:crypto' @@ -106,11 +107,7 @@ console.log(`User: ${prompt1}\n`) process.stdout.write('Assistant: ') for await (const e of session.send(prompt1)) { - if (e.type === 'message_delta') process.stdout.write(e.text) - else if (e.type === 'tool_call') console.log(`\n → [tool_call] ${e.toolName}(${JSON.stringify(e.input)})`) - else if (e.type === 'tool_result') console.log(` ← [tool_result] ${JSON.stringify(e.output).slice(0, 200)}`) - else if (e.type === 'session_idle') console.log(`\n[session_idle: ${e.reason}]`) - else if (e.type === 'error') console.error('\n[error]', e.error.message) + logAcpUpdate(e) } // ═══════════════════════════════════════════════════════════════════ @@ -123,20 +120,18 @@ const prompt2 = '请用 bash 工具执行 echo "hello from sandbox" && date 命 console.log(`User: ${prompt2}\n`) process.stdout.write('Assistant: ') -let pendingApproval: { toolUseId: string; toolName: string; input: unknown } | undefined +let pendingApproval: PendingToolConfirm | undefined for await (const e of session.send(prompt2)) { - if (e.type === 'message_delta') process.stdout.write(e.text) - else if (e.type === 'tool_call') console.log(`\n → [tool_call] ${e.toolName}(${JSON.stringify(e.input)})`) - else if (e.type === 'tool_result') console.log(` ← [tool_result] ${JSON.stringify(e.output).slice(0, 200)}`) - else if (e.type === 'tool_approval_required') { + logAcpUpdate(e) + const captured = captureToolConfirm(e) + if (captured) { console.log('\n\n ⏸ 审批请求触发!') - console.log(` 工具: ${e.toolName}`) - console.log(` 参数: ${JSON.stringify(e.input)}`) - console.log(` toolUseId: ${e.toolUseId}`) - pendingApproval = { toolUseId: e.toolUseId, toolName: e.toolName, input: e.input } - } else if (e.type === 'session_idle') console.log(`\n[session_idle: ${e.reason}]`) - else if (e.type === 'error') console.error('\n[error]', e.error.message) + console.log(` 工具: ${captured.toolName}`) + console.log(` 参数: ${JSON.stringify(captured.input)}`) + console.log(` toolUseId: ${captured.toolUseId}`) + pendingApproval = captured + } } // 自动批准 @@ -148,11 +143,7 @@ if (pendingApproval) { toolUseId: pendingApproval.toolUseId, decision: { kind: 'allow', scope: 'once' }, })) { - if (e.type === 'message_delta') process.stdout.write(e.text) - else if (e.type === 'tool_call') console.log(`\n → [tool_call] ${e.toolName}(${JSON.stringify(e.input)})`) - else if (e.type === 'tool_result') console.log(` ← [tool_result] ${JSON.stringify(e.output).slice(0, 200)}`) - else if (e.type === 'session_idle') console.log(`\n[session_idle: ${e.reason}]`) - else if (e.type === 'error') console.error('\n[error]', e.error.message) + logAcpUpdate(e) } } else { console.log('\n ⚠️ 未触发审批流程(模型可能没有调用 bash 工具)') diff --git a/packages/open-agent-kernel/examples/15-skills.ts b/packages/open-agent-kernel/examples/15-skills.ts index c8a0b75..aa0a5a9 100644 --- a/packages/open-agent-kernel/examples/15-skills.ts +++ b/packages/open-agent-kernel/examples/15-skills.ts @@ -21,6 +21,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { isSkillToolCall, printAcpUpdate } from './_shared/acp.js' import { getEnvId, getModel, loadEnv } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -74,8 +75,8 @@ async function main() { console.log('[example] session started, sending prompt...\n') let sawSkillInvocation = false for await (const event of session.send('请问候我')) { - if (event.type === 'message_delta') process.stdout.write(event.text) - if (event.type === 'tool_call' && event.toolName === 'Skill') { + printAcpUpdate(event) + if (isSkillToolCall(event)) { sawSkillInvocation = true console.log(`\n[example] ✓ Skill tool invoked with input:`, event.input) } diff --git a/packages/open-agent-kernel/examples/16-client-tool-history-live.ts b/packages/open-agent-kernel/examples/16-client-tool-history-live.ts index 1efdb0e..ff04307 100644 --- a/packages/open-agent-kernel/examples/16-client-tool-history-live.ts +++ b/packages/open-agent-kernel/examples/16-client-tool-history-live.ts @@ -12,6 +12,8 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { CloudBaseDbDriver, CloudBaseSessionStore, createAgent } from '@cloudbase/open-agent-kernel' +import { captureToolConfirm, logAcpUpdate } from './_shared/acp.js' + // ─── 配置 ────────────────────────────────────────────────────────── const envId = process.env.TCB_ENV_ID! @@ -72,26 +74,17 @@ console.log(`👤 User: ${prompt}\n`) let toolUseId: string | undefined -// Step 1: 发送消息,等待 tool_use_required +// Step 1: 发送消息,等待 tool_confirm(client-tool) process.stdout.write('🤖 Assistant: ') for await (const e of session.send(prompt)) { - switch (e.type) { - case 'message_delta': - process.stdout.write(e.text) - break - case 'tool_use_required': - console.log(`\n\n ⏸ client-tool 触发!`) - console.log(` 工具: ${e.toolName}`) - console.log(` 参数: ${JSON.stringify(e.input)}`) - console.log(` toolUseId: ${e.toolUseId}`) - toolUseId = e.toolUseId - break - case 'session_idle': - console.log(`\n[session_idle: ${e.reason}]`) - break - case 'error': - console.error('\n[error]', e.error.message) - break + logAcpUpdate(e) + const captured = captureToolConfirm(e) + if (captured) { + console.log(`\n\n ⏸ client-tool 触发!`) + console.log(` 工具: ${captured.toolName}`) + console.log(` 参数: ${JSON.stringify(captured.input)}`) + console.log(` toolUseId: ${captured.toolUseId}`) + toolUseId = captured.toolUseId } } @@ -106,23 +99,7 @@ if (toolUseId) { output: mockResult, isError: false, })) { - switch (e.type) { - case 'message_delta': - process.stdout.write(e.text) - break - case 'tool_call': - console.log(`\n → [tool_call] ${e.toolName}(${JSON.stringify(e.input)})`) - break - case 'tool_result': - console.log(` ← [tool_result] ${JSON.stringify(e.output).slice(0, 200)}`) - break - case 'session_idle': - console.log(`\n[session_idle: ${e.reason}]`) - break - case 'error': - console.error('\n[error]', e.error.message) - break - } + logAcpUpdate(e) } } diff --git a/packages/open-agent-kernel/examples/16-user-memory.ts b/packages/open-agent-kernel/examples/16-user-memory.ts index 88102b6..ed08f80 100644 --- a/packages/open-agent-kernel/examples/16-user-memory.ts +++ b/packages/open-agent-kernel/examples/16-user-memory.ts @@ -26,6 +26,7 @@ import { createAgent } from '@cloudbase/open-agent-kernel' +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getPlatformCredentials, loadEnv } from './_shared/env.js' import { clearSeededClaudeHome, seedClaudeHome } from './_shared/seed-claude-home.js' @@ -73,7 +74,7 @@ async function runConversation(prompt: string, userId: string) { console.log(`[example] user: ${prompt}`) process.stdout.write('[example] assistant: ') for await (const event of session.send(prompt)) { - if (event.type === 'message_delta') process.stdout.write(event.text) + printAcpUpdate(event) } console.log('\n[example] aborting session...') await session.abort() diff --git a/packages/open-agent-kernel/examples/17-session-store-debug.ts b/packages/open-agent-kernel/examples/17-session-store-debug.ts index 90b6a9d..80ae964 100644 --- a/packages/open-agent-kernel/examples/17-session-store-debug.ts +++ b/packages/open-agent-kernel/examples/17-session-store-debug.ts @@ -13,6 +13,8 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { CloudBaseDbDriver, CloudBaseSessionStore, createAgent } from '@cloudbase/open-agent-kernel' +import { captureToolConfirm } from './_shared/acp.js' + // ─── 配置 ────────────────────────────────────────────────────────── const envId = process.env.TCB_ENV_ID! @@ -48,11 +50,12 @@ const session = await agent.startSession({ userId: 'debug-user', conversationId console.log('=== Step 1: send ===') let toolUseId: string | undefined for await (const e of session.send('Call get_weather tool with city="Beijing". Do not skip the tool.')) { - if (e.type === 'tool_use_required') { - console.log(` tool_use_required: ${e.toolName}, toolUseId=${e.toolUseId}`) - toolUseId = e.toolUseId - } else if (e.type === 'session_idle') { - console.log(` session_idle: ${e.reason}`) + const captured = captureToolConfirm(e) + if (captured) { + console.log(` tool_confirm: ${captured.toolName}, toolUseId=${captured.toolUseId}`) + toolUseId = captured.toolUseId + } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + console.log(' agent_phase: idle') } } @@ -64,12 +67,12 @@ if (toolUseId) { output: JSON.stringify({ temp: 25, city: 'Beijing', condition: 'sunny' }), isError: false, })) { - if (e.type === 'session_idle') { - console.log(` session_idle: ${e.reason}`) - } else if (e.type === 'tool_call') { - console.log(` tool_call: ${e.toolName}`) - } else if (e.type === 'tool_result') { - console.log(` tool_result: ${JSON.stringify(e.output).slice(0, 100)}`) + if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { + console.log(' agent_phase: idle') + } else if (e.sessionUpdate === 'tool_call') { + console.log(` tool_call: ${e.title}`) + } else if (e.sessionUpdate === 'tool_call_update') { + console.log(` tool_call_update: ${JSON.stringify(e.result ?? e.error ?? null).slice(0, 100)}`) } } } diff --git a/packages/open-agent-kernel/examples/17-user-memory-distributed.ts b/packages/open-agent-kernel/examples/17-user-memory-distributed.ts index bb18eeb..abb2561 100644 --- a/packages/open-agent-kernel/examples/17-user-memory-distributed.ts +++ b/packages/open-agent-kernel/examples/17-user-memory-distributed.ts @@ -24,6 +24,7 @@ import { createAgent } from '@cloudbase/open-agent-kernel' +import { printAcpUpdate } from './_shared/acp.js' import { getEnvId, getPlatformCredentials, loadEnv } from './_shared/env.js' import { clearSeededClaudeHome, seedClaudeHome } from './_shared/seed-claude-home.js' @@ -62,7 +63,7 @@ async function runOnNode(nodeName: string, userId: string, prompt: string) { console.log(`[${nodeName}] user: ${prompt}`) process.stdout.write(`[${nodeName}] assistant: `) for await (const event of session.send(prompt)) { - if (event.type === 'message_delta') process.stdout.write(event.text) + printAcpUpdate(event) } console.log(`\n[${nodeName}] aborting (final push)...`) await session.abort() diff --git a/packages/open-agent-kernel/examples/18-workspace-snapshot.ts b/packages/open-agent-kernel/examples/18-workspace-snapshot.ts index 6c3baf1..3b7450a 100644 --- a/packages/open-agent-kernel/examples/18-workspace-snapshot.ts +++ b/packages/open-agent-kernel/examples/18-workspace-snapshot.ts @@ -15,8 +15,9 @@ * OAK_DEBUG=1 pnpm dlx tsx packages/open-agent-kernel/examples/18-workspace-snapshot.ts */ -import { createAgent } from '@cloudbase/open-agent-kernel' +import { createAgent, type AcpSessionUpdate } from '@cloudbase/open-agent-kernel' +import { appendAcpAssistantText, fmtAcpUpdate } from './_shared/acp.js' import { getPlatformCredentials, loadEnv } from './_shared/env.js' function buildModel() { @@ -33,35 +34,10 @@ function buildModel() { } /** - * 把任意 update 简短打印 — 展示 ACP update 全貌而不只 agent_message_chunk。 - * 这是 debug 用,生产代码请按 sessionUpdate 分支处理。 + * 把 ACP update 简短打印 — 展示 update 全貌而不只 agent_message_chunk。 */ -function fmtEvent(ev: unknown): string { - if (typeof ev !== 'object' || ev === null) return String(ev) - const e = ev as Record - const type = String(e.type ?? '') - switch (type) { - case 'message_delta': - return `Δ ${JSON.stringify(e.text)}` - case 'message_complete': - return `▣ message_complete len=${(e.text as string | undefined)?.length ?? 0}` - case 'tool_call': { - const inputStr = JSON.stringify(e.input) - const trim = inputStr.length > 200 ? `${inputStr.slice(0, 200)}…` : inputStr - return `→ tool_call ${e.toolName} ${trim}` - } - case 'tool_result': { - const out = JSON.stringify(e.output) - const trim = out.length > 300 ? `${out.slice(0, 300)}…` : out - return `← tool_result ${e.toolName} isError=${e.isError} ${trim}` - } - case 'error': { - const err = e.error as { name?: string; message?: string } | undefined - return `✗ error ${err?.name}: ${err?.message}` - } - default: - return `· ${type} ${JSON.stringify(e).slice(0, 200)}` - } +function fmtEvent(ev: AcpSessionUpdate): string { + return fmtAcpUpdate(ev) } async function runOne(label: string, userId: string, prompt: string, opts: { manualSnapshotAfter?: boolean } = {}) { @@ -85,15 +61,14 @@ async function runOne(label: string, userId: string, prompt: string, opts: { man const tSend = Date.now() let eventCount = 0 - let assistantText = '' + const assistantText = { text: '' } for await (const event of session.send(prompt)) { eventCount += 1 console.log(`[${label}][evt#${eventCount}] ${fmtEvent(event)}`) - if (event.type === 'message_delta') assistantText += event.text - if (event.type === 'message_complete') assistantText = event.text // 完整版覆盖 + appendAcpAssistantText(event, assistantText) } console.log( - `[${label}] send-end ms=${Date.now() - tSend} events=${eventCount} finalText=${JSON.stringify(assistantText.slice(0, 300))}`, + `[${label}] send-end ms=${Date.now() - tSend} events=${eventCount} finalText=${JSON.stringify(assistantText.text.slice(0, 300))}`, ) if (opts.manualSnapshotAfter && session.snapshotWorkspace) { diff --git a/packages/open-agent-kernel/examples/19a-snapshot-write.ts b/packages/open-agent-kernel/examples/19a-snapshot-write.ts index 799e65e..1236d10 100644 --- a/packages/open-agent-kernel/examples/19a-snapshot-write.ts +++ b/packages/open-agent-kernel/examples/19a-snapshot-write.ts @@ -38,6 +38,7 @@ import { fileURLToPath } from 'node:url' import { createAgent } from '@cloudbase/open-agent-kernel' +import { writeAcpText } from './_shared/acp.js' import { getPlatformCredentials, loadEnv } from './_shared/env.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -88,19 +89,17 @@ ${stamp} let toolCalls = 0 for await (const ev of session.send(prompt)) { - if (ev.type === 'message_delta') process.stdout.write(ev.text) - if (ev.type === 'tool_call') { + writeAcpText(ev) + if (ev.sessionUpdate === 'tool_call') { toolCalls += 1 - console.log(`\n[19a][tool#${toolCalls}] → ${ev.toolName}`) + console.log(`\n[19a][tool#${toolCalls}] → ${ev.title}`) } - if (ev.type === 'tool_result') { - const out = JSON.stringify(ev.output) - console.log(`[19a][tool#${toolCalls}] ← isError=${ev.isError} ${out.slice(0, 200)}${out.length > 200 ? '…' : ''}`) + if (ev.sessionUpdate === 'tool_call_update' && (ev.status === 'completed' || ev.status === 'failed')) { + const out = JSON.stringify(ev.result ?? ev.error ?? null) + console.log(`[19a][tool#${toolCalls}] ← status=${ev.status} ${out.slice(0, 200)}${out.length > 200 ? '…' : ''}`) } - if (ev.type === 'error') { - console.warn( - `\n[19a][error] ${(ev.error as { name?: string; message?: string }).name}: ${(ev.error as { message?: string }).message}`, - ) + if (ev.sessionUpdate === 'log' && ev.level === 'error') { + console.warn(`\n[19a][error] ${ev.message}`) } } console.log(`\n[19a] write phase done.`) diff --git a/packages/open-agent-kernel/examples/19b-snapshot-read.ts b/packages/open-agent-kernel/examples/19b-snapshot-read.ts index 051f16b..2bbf6db 100644 --- a/packages/open-agent-kernel/examples/19b-snapshot-read.ts +++ b/packages/open-agent-kernel/examples/19b-snapshot-read.ts @@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url' import { AgsStatefulSandbox, createAgent } from '@cloudbase/open-agent-kernel' +import { appendAcpAssistantText, writeAcpText } from './_shared/acp.js' import { getPlatformCredentials, getSandboxApiKey, loadEnv } from './_shared/env.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -105,26 +106,21 @@ async function main() { '请用 cat 命令读取 /home/user/.last-update.txt,把里面的内容(单行 ISO 时间戳)原样复述给我,不要添加任何说明。' console.log(`\n[19b] prompt: ${prompt}`) - let assistantText = '' + let assistantText = { text: '' } let toolCalls = 0 for await (const ev of session.send(prompt)) { - if (ev.type === 'message_delta') { - process.stdout.write(ev.text) - assistantText += ev.text - } - if (ev.type === 'message_complete') assistantText = ev.text - if (ev.type === 'tool_call') { + writeAcpText(ev) + appendAcpAssistantText(ev, assistantText) + if (ev.sessionUpdate === 'tool_call') { toolCalls += 1 - console.log(`\n[19b][tool#${toolCalls}] → ${ev.toolName}`) + console.log(`\n[19b][tool#${toolCalls}] → ${ev.title}`) } - if (ev.type === 'tool_result') { - const out = JSON.stringify(ev.output) - console.log(`[19b][tool#${toolCalls}] ← isError=${ev.isError} ${out.slice(0, 300)}${out.length > 300 ? '…' : ''}`) + if (ev.sessionUpdate === 'tool_call_update' && (ev.status === 'completed' || ev.status === 'failed')) { + const out = JSON.stringify(ev.result ?? ev.error ?? null) + console.log(`[19b][tool#${toolCalls}] ← status=${ev.status} ${out.slice(0, 300)}${out.length > 300 ? '…' : ''}`) } - if (ev.type === 'error') { - console.warn( - `\n[19b][error] ${(ev.error as { name?: string }).name}: ${(ev.error as { message?: string }).message}`, - ) + if (ev.sessionUpdate === 'log' && ev.level === 'error') { + console.warn(`\n[19b][error] ${ev.message}`) } } @@ -146,10 +142,10 @@ async function main() { } console.log('\n\n──── 验收 ────') - const matched = assistantText.includes(expectedStamp) + const matched = assistantText.text.includes(expectedStamp) console.log(`[19b] expected stamp 是否出现在模型回答里: ${matched ? '✅ 是' : '❌ 否'}`) console.log(`[19b] expected: ${expectedStamp}`) - console.log(`[19b] got : ${JSON.stringify(assistantText.slice(0, 200))}`) + console.log(`[19b] got : ${JSON.stringify(assistantText.text.slice(0, 200))}`) await session.abort() diff --git a/packages/open-agent-kernel/examples/README.md b/packages/open-agent-kernel/examples/README.md index e208067..30d1f1c 100644 --- a/packages/open-agent-kernel/examples/README.md +++ b/packages/open-agent-kernel/examples/README.md @@ -31,6 +31,7 @@ pnpm dlx tsx packages/open-agent-kernel/examples/01-quickstart.ts | `examples.resumeConversationId` | example 04 使用;指定上一次输出的 conversationId 做跨进程 resume。 | | `examples.storage` | example 05 使用;设为 `memory` 时改用 `InMemoryStorage`。 | | `examples.imagePath` | example 05 使用;指定自定义图片路径。 | +| `examples.visionModel` | example 05 使用;视觉模型 ID(默认 `glm-5v-turbo`,不受顶层 `model` 影响)。 | | `examples.debug` | 为 `true` 时打开 `OAK_DEBUG` 调试日志。 | ## 运行索引 @@ -46,7 +47,7 @@ pnpm dlx tsx packages/open-agent-kernel/examples/01-quickstart.ts | `01-quickstart.ts` | 快速开始 | `pnpm dlx tsx packages/open-agent-kernel/examples/01-quickstart.ts` | | `02-debug.ts` | 打印调试事件 | `pnpm dlx tsx packages/open-agent-kernel/examples/02-debug.ts` | | `03-multi-turn.ts` | 进程内多轮对话 | `pnpm dlx tsx packages/open-agent-kernel/examples/03-multi-turn.ts` | -| `04-multi-turn-db.ts` | CloudBase session 持久化 / resume | `pnpm dlx tsx packages/open-agent-kernel/examples/04-multi-turn-db.ts` | +| `04-multi-turn-db.ts` | CloudBase session 持久化 / resume | 第一次跑写入个人信息;把输出的 `conversationId` 填入 `examples.resumeConversationId` 后再跑,验证跨进程回忆 | | `05-multimodal.ts` | 图片附件 / Storage | `pnpm dlx tsx packages/open-agent-kernel/examples/05-multimodal.ts` | | `06-mcp-sdk-server.ts` | 进程内 MCP | `pnpm dlx tsx packages/open-agent-kernel/examples/06-mcp-sdk-server.ts` | | `07-mcp-stdio.ts` | stdio MCP | `pnpm dlx tsx packages/open-agent-kernel/examples/07-mcp-stdio.ts` | diff --git a/packages/open-agent-kernel/examples/_shared/acp.ts b/packages/open-agent-kernel/examples/_shared/acp.ts index 789405a..ced9314 100644 --- a/packages/open-agent-kernel/examples/_shared/acp.ts +++ b/packages/open-agent-kernel/examples/_shared/acp.ts @@ -1,5 +1,11 @@ import type { AcpSessionUpdate } from '@cloudbase/open-agent-kernel' +export interface PendingToolConfirm { + toolUseId: string + toolName: string + input: unknown +} + export function writeAcpText(update: AcpSessionUpdate): void { if (update.sessionUpdate === 'agent_message_chunk') { process.stdout.write(update.content.text) @@ -26,7 +32,11 @@ export function printAcpUpdate(update: AcpSessionUpdate): void { process.stdout.write('\n ? agent asks user\n ') break case 'log': - process.stdout.write(`\n[${update.level}] ${update.message}\n`) + if (update.level === 'error') { + process.stderr.write(`\n[error] ${update.message}\n`) + } else { + process.stdout.write(`\n[${update.level}] ${update.message}\n`) + } break case 'agent_phase': if (update.phase === 'idle') process.stdout.write('\n') @@ -35,3 +45,79 @@ export function printAcpUpdate(update: AcpSessionUpdate): void { break } } + +/** Console-oriented logging (example 14 style). */ +export function logAcpUpdate(update: AcpSessionUpdate): void { + switch (update.sessionUpdate) { + case 'agent_message_chunk': + process.stdout.write(update.content.text) + break + case 'tool_call': + console.log(`\n → [tool_call] ${update.title}(${JSON.stringify(update.input ?? {})})`) + break + case 'tool_call_update': + if (update.status === 'completed' || update.status === 'failed') { + console.log(` ← [tool_result] ${JSON.stringify(update.result ?? update.error ?? null).slice(0, 200)}`) + } + break + case 'tool_confirm': + console.log('\n ⏸ tool_confirm:') + console.log(` 工具: ${update.toolName}`) + console.log(` 参数: ${JSON.stringify(update.input)}`) + console.log(` toolCallId: ${update.toolCallId}`) + break + case 'agent_phase': + if (update.phase === 'idle') console.log('\n[agent_phase: idle]') + break + case 'log': + if (update.level === 'error') console.error('\n[error]', update.message) + else console.log(`\n[${update.level}] ${update.message}`) + break + default: + break + } +} + +export function captureToolConfirm(update: AcpSessionUpdate): PendingToolConfirm | undefined { + if (update.sessionUpdate !== 'tool_confirm') return undefined + return { + toolUseId: update.toolCallId, + toolName: update.toolName, + input: update.input, + } +} + +export function isSkillToolCall(update: AcpSessionUpdate): boolean { + return update.sessionUpdate === 'tool_call' && update.title === 'Skill' +} + +export function fmtAcpUpdate(update: AcpSessionUpdate): string { + switch (update.sessionUpdate) { + case 'agent_message_chunk': + return `Δ ${JSON.stringify(update.content.text)}` + case 'tool_call': { + const inputStr = JSON.stringify(update.input ?? {}) + const trim = inputStr.length > 200 ? `${inputStr.slice(0, 200)}…` : inputStr + return `→ tool_call ${update.title} ${trim}` + } + case 'tool_call_update': { + const out = JSON.stringify(update.result ?? update.error ?? null) + const trim = out.length > 300 ? `${out.slice(0, 300)}…` : out + return `← tool_call_update status=${update.status} ${trim}` + } + case 'tool_confirm': + return `? tool_confirm ${update.toolName} id=${update.toolCallId}` + case 'log': + return update.level === 'error' ? `✗ error ${update.message}` : `[${update.level}] ${update.message}` + case 'agent_phase': + return update.phase === 'idle' ? '· agent_phase idle' : `· agent_phase ${update.phase}` + default: + return `· ${update.sessionUpdate} ${JSON.stringify(update).slice(0, 200)}` + } +} + +export function appendAcpAssistantText(update: AcpSessionUpdate, buffer: { text: string }): void { + if (update.sessionUpdate === 'agent_message_chunk') { + buffer.text += update.content.text + } +} diff --git a/packages/open-agent-kernel/examples/_shared/env.ts b/packages/open-agent-kernel/examples/_shared/env.ts index 15089ec..033f141 100644 --- a/packages/open-agent-kernel/examples/_shared/env.ts +++ b/packages/open-agent-kernel/examples/_shared/env.ts @@ -27,6 +27,8 @@ interface ExampleConfig { resumeConversationId?: string storage?: string imagePath?: string + /** 多模态 example 专用;不受顶层 model(常为文本模型)影响 */ + visionModel?: string debug?: boolean } } @@ -74,6 +76,12 @@ export function getModel(defaultModel = 'glm-5.1'): string { return loadConfig().model ?? defaultModel } +/** 视觉 / 多模态 example 专用模型(忽略 config.model,避免误用文本模型) */ +export function getVisionModel(defaultModel = 'glm-5v-turbo'): string { + const visionModel = loadConfig().examples?.visionModel + return visionModel && visionModel.length > 0 ? visionModel : defaultModel +} + export function getPlatformCredentials(): PlatformCredentials { const config = loadConfig() const credentials = config.credentials diff --git a/packages/open-agent-kernel/examples/config.example.json b/packages/open-agent-kernel/examples/config.example.json index 40c2c75..d1c8c70 100644 --- a/packages/open-agent-kernel/examples/config.example.json +++ b/packages/open-agent-kernel/examples/config.example.json @@ -11,6 +11,7 @@ "resumeConversationId": "", "storage": "", "imagePath": "", + "visionModel": "glm-5v-turbo", "debug": false } } From 8a7cc25feb321b48c75792a0fcb273343d67c7a8 Mon Sep 17 00:00:00 2001 From: yang Date: Thu, 25 Jun 2026 18:37:15 +0800 Subject: [PATCH 7/7] feat(kernel): align AcpSessionUpdate with standard ACP 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @agentclientprotocol/sdk@^1.0.0 dependency (stable release, zero runtime deps) - Rewrite src/acp/types.ts: import standard SessionUpdate from SDK, define OAK extensions (request_permission, ask_user, log, artifact, history_page, agent_phase) on top with _meta.oak for non-standard fields - Rewrite src/adapters/acp-stream-adapter.ts: - tool_call: input→rawInput, kind:'function'→standard ToolKind enum (toolKindFromName ported from official claude-agent-acp) - tool_call_update: result→rawOutput, error→content:ToolCallContent[] - tool_confirm→request_permission: standard RequestPermissionRequest payload (sessionId + toolCall:ToolCallUpdate + options:PermissionOption[]) - thinking→agent_thought_chunk (standard ContentChunk) - Add usage_update emission on result message - parentToolCallId/assistantMessageId moved to _meta.oak - Update all examples to consume new field names - Update design doc (architecture-acp-stream-adapter.md) Breaking: callers consuming AcpSessionUpdate must update field access: tool_confirm→request_permission, input→rawInput, result→rawOutput, error→content[], kind:'function'→ToolKind enum --- .../docs/architecture-acp-stream-adapter.md | 72 +++-- .../examples/11-hitl-approval.ts | 10 +- .../examples/12-hitl-acp-adapter.ts | 76 ++--- .../examples/13-hitl-distributed-cloudbase.ts | 8 +- .../examples/14-session-history.ts | 6 +- .../open-agent-kernel/examples/15-skills.ts | 2 +- .../examples/16-client-tool-history-live.ts | 4 +- .../examples/17-session-store-debug.ts | 6 +- .../examples/19a-snapshot-write.ts | 2 +- .../examples/19b-snapshot-read.ts | 2 +- .../examples/20-acp-stream-adapter-fixture.ts | 1 + .../open-agent-kernel/examples/_shared/acp.ts | 103 ++++--- packages/open-agent-kernel/package.json | 3 +- packages/open-agent-kernel/src/acp/index.ts | 55 +++- packages/open-agent-kernel/src/acp/types.ts | 263 ++++++++++++------ .../src/adapters/acp-stream-adapter.ts | 203 ++++++++++++-- packages/open-agent-kernel/src/index.ts | 51 +++- .../open-agent-kernel/src/public/types.ts | 8 +- pnpm-lock.yaml | 12 + 19 files changed, 640 insertions(+), 247 deletions(-) diff --git a/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md b/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md index 4b147c6..b17a213 100644 --- a/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md +++ b/packages/open-agent-kernel/docs/architecture-acp-stream-adapter.md @@ -1,18 +1,18 @@ # Open Agent Kernel:内置 ACP 流式输出方案 -> 文档状态:已落地(2026-06) +> 文档状态:已落地(2026-06,v2 对齐标准 ACP 1.0.0) > 适用范围:`@cloudbase/open-agent-kernel` 的 `session.send()` / `respond*()` 流式输出 ## 决策 -OAK 默认输出 ACP `session/update` 语义: +OAK 默认输出 ACP `session/update` 语义,**标准 variant 直接复用 `@agentclientprotocol/sdk@^1.0.0` 的 `SessionUpdate` 类型**,OAK 扩展定义在 `src/acp/types.ts` 并明确标注: ```typescript const agent = createAgent({ envId, model, credentials }) const session = await agent.startSession({ userId: 'u1' }) for await (const update of session.send('你好')) { - // update: AcpSessionUpdate + // update: AcpSessionUpdate = SessionUpdate | OAK extensions } ``` @@ -29,34 +29,74 @@ Claude Agent SDK query() 关键点: +- 标准 variant(13 个)直接来自 `@agentclientprotocol/sdk` 的 `SessionUpdate`。 +- OAK 扩展 variant(6 个)定义在 `src/acp/types.ts`,处理标准 ACP 不覆盖的场景。 - 不对外暴露 raw `SDKMessage`。 - 不再公开 `SessionEvent`。 -- 不保留独立 `event-translator` / `sdk-message-translator` 层。 -- 原 translator 必要能力(`stream_event` 状态机、文本去重、工具参数流、HITL sentinel 识别)全部内聚在 `AcpStreamAdapter`。 -- `streamAdapter` 仅作为高级覆盖入口保留,常规 examples 和 README 不要求用户配置。 - -## 主要映射 +- `streamAdapter` 仅作为高级覆盖入口保留。 + +## SessionUpdate variant 清单 + +### 标准 variant(来自 `@agentclientprotocol/sdk@1.0.0`) + +| `sessionUpdate` | 类型 | 说明 | +|-----------------|------|------| +| `user_message_chunk` | `ContentChunk` | 用户消息流式回放 | +| `agent_message_chunk` | `ContentChunk` | 模型文本输出 | +| `agent_thought_chunk` | `ContentChunk` | 模型思考流(替代旧 `thinking`) | +| `tool_call` | `ToolCall` | 工具调用开始(`rawInput` / `kind: ToolKind` / `locations`) | +| `tool_call_update` | `ToolCallUpdate` | 工具状态/结果更新(`rawOutput` / `content[]`) | +| `plan` | `Plan` | 执行计划 | +| `plan_update` | `PlanUpdate` | 计划更新 | +| `plan_removed` | `PlanRemoved` | 计划移除 | +| `available_commands_update` | `AvailableCommandsUpdate` | 可用斜杠命令 | +| `current_mode_update` | `CurrentModeUpdate` | 当前模式切换 | +| `config_option_update` | `ConfigOptionUpdate` | 配置选项更新 | +| `session_info_update` | `SessionInfoUpdate` | 会话信息更新 | +| `usage_update` | `UsageUpdate` | token / cost 用量 | + +### OAK 扩展 variant(非标准,payload 兼容标准形状) + +| `sessionUpdate` | 说明 | 对标标准概念 | +|-----------------|------|-------------| +| `request_permission` | HITL 审批请求(stop-and-resume 模式) | `session/request_permission` JSON-RPC(payload 镜像 `RequestPermissionRequest`) | +| `ask_user` | AskUserQuestion 问询(stop-and-resume) | `session/elicitation`(OAK 用独立 variant) | +| `log` | 错误 / 状态消息 | 无标准对应 | +| `artifact` | 部署产物通知 | 无标准对应 | +| `history_page` | 历史分页回放 | `session/load` 请求-响应(OAK 用 sessionUpdate 推一页) | +| `agent_phase` | 执行阶段指示器 | 无标准对应 | + +## 主要映射(SDK → ACP) | SDK 来源 | ACP `sessionUpdate` | |----------|---------------------| | `stream_event.content_block_delta.text_delta` | `agent_message_chunk` | -| `stream_event.content_block_start.tool_use` | `tool_call` | -| `stream_event.input_json_delta` | `tool_call_update` | +| `stream_event.content_block_delta.thinking_delta` | `agent_thought_chunk` | +| `stream_event.content_block_start.tool_use` | `tool_call`(`rawInput` / `kind: toolKindFromName()`) | +| `stream_event.input_json_delta` | `tool_call_update`(`rawInput`) | | `assistant.tool_use` replay | `tool_call` 或 `tool_call_update`(去重) | -| `user.tool_result` | `tool_call_update` | -| OAK approval/client-tool sentinel | `tool_confirm` | +| `user.tool_result` | `tool_call_update`(`rawOutput` / `content[]`) | +| OAK approval/client-tool sentinel | `request_permission`(`toolCall: ToolCallUpdate` + `options: PermissionOption[]`) | | OAK askUser sentinel | `ask_user` | -| `result` | `agent_phase` with `phase: 'idle'` | +| `result` | `usage_update` + `agent_phase: idle` | + +## `_meta.oak` 扩展命名空间 + +OAK 专有数据通过标准 `_meta.oak.*` 扩展传递,不污染标准字段: + +- `_meta.oak.parentToolCallId` — 子 agent 工具链父 ID +- `_meta.oak.assistantMessageId` — SSE 关联用 assistant 消息 ID +- `_meta.oak.planContent` — ExitPlanMode 计划内容 ## 文件结构 ```text packages/open-agent-kernel/src/ ├── acp/ -│ ├── index.ts -│ └── types.ts +│ ├── index.ts # re-export 标准 + OAK 类型 +│ └── types.ts # OAK 扩展定义 ├── adapters/ -│ ├── acp-stream-adapter.ts +│ ├── acp-stream-adapter.ts # SDKMessage → AcpSessionUpdate │ ├── index.ts │ └── types.ts ├── public/ diff --git a/packages/open-agent-kernel/examples/11-hitl-approval.ts b/packages/open-agent-kernel/examples/11-hitl-approval.ts index 677ece9..3e8cf7e 100644 --- a/packages/open-agent-kernel/examples/11-hitl-approval.ts +++ b/packages/open-agent-kernel/examples/11-hitl-approval.ts @@ -3,7 +3,7 @@ * * 演示: * - `permissions.requireApproval` 把指定工具变成"需审批" - * - 事件流出 `tool_confirm` 后流自然结束 + * - 事件流出 `request_permission` 后流自然结束 * - 业务侧通过 readline 在终端收集用户决定 * - `session.respondApproval({ toolUseId, decision })` 注入决策并 resume * - 决策为 allow → 工具执行,agent 继续;deny → 模型收到拒绝并解释 @@ -16,7 +16,7 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/11-hitl-approval.ts */ -import { captureToolConfirm, printAcpUpdate, type PendingToolConfirm } from './_shared/acp.js' +import { captureRequestPermission, printAcpUpdate, type PendingRequestPermission } from './_shared/acp.js' import { getEnvId, getModel } from './_shared/env.js' import { CloudBaseSessionStore, createAgent, InMemoryDriver } from '@cloudbase/open-agent-kernel' @@ -96,11 +96,11 @@ async function main(): Promise { console.log(`User: ${prompt}\n`) process.stdout.write('Assistant: ') - let pendingApproval: PendingToolConfirm | undefined + let pendingApproval: PendingRequestPermission | undefined for await (const e of session.send(prompt)) { printAcpUpdate(e) - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log('\n\n⏸ 审批请求:') console.log(` 工具: ${captured.toolName}`) @@ -132,7 +132,7 @@ async function main(): Promise { : { kind: 'deny', reason: '用户在 CLI 拒绝', interrupt: false }, })) { printAcpUpdate(e) - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log('\n\n⏸ 又一个审批请求(demo 自动 allow):', captured.toolName, captured.input) for await (const e2 of session.respondApproval({ diff --git a/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts b/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts index f995427..c3b890c 100644 --- a/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts +++ b/packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts @@ -2,10 +2,10 @@ * Example 12: built-in ACP HITL flow * * 演示 OAK 内置 ACP 输出后的 HITL 事件: - * - session.send() 直接输出 ACP `tool_confirm` + * - session.send() 直接输出 ACP `request_permission`(OAK stop-and-resume 适配) * - 业务拿到用户决策后调用 session.respondApproval() * - * 运行(本 example 不依赖真实 ACP 客户端,模拟一个"Always allow_once" 客户端): + * 运行(本 example 不依赖真实 ACP 客户端,模拟一个 "allow" 客户端): * pnpm dlx tsx packages/open-agent-kernel/examples/12-hitl-acp-adapter.ts */ import { getEnvId, getModel } from './_shared/env.js' @@ -15,49 +15,55 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk' import { z } from 'zod' // ───────────────────────────────────────────────────────────────────── -// 模拟一个 ACP 客户端协议形态(实际项目里来自 @zed-industries/agent-client-protocol) +// 模拟一个 ACP 客户端协议形态(实际项目里来自 @agentclientprotocol/sdk) // ───────────────────────────────────────────────────────────────────── /** - * ACP `session/request_permission` 请求体(精简版,对齐 ACP spec)。 + * ACP `session/request_permission` 请求体(对齐 ACP spec + OAK stop-and-resume 适配)。 + * + * OAK 把它作为 sessionUpdate 通知发出(而非 JSON-RPC 反向 request), + * payload shape 与标准 RequestPermissionRequest 一致: + * - `toolCall`: 标准 ToolCallUpdate(toolCallId / title / kind / rawInput / locations) + * - `options`: 标准 PermissionOption[](optionId / name / kind) */ interface AcpPermissionRequest { toolCall: { toolCallId: string - toolName: string - args: unknown + title: string + kind: string + rawInput: unknown } options: Array<{ optionId: string - label: string - /** 决策语义类别 */ + name: string kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always' }> } /** - * ACP 客户端的批准响应。 + * ACP 客户端的批准响应(对齐标准 RequestPermissionResponse.outcome)。 */ interface AcpPermissionResponse { - outcome: { kind: 'selected'; optionId: string } | { kind: 'cancelled' } + outcome: { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' } } /** * 模拟的 ACP 客户端(实际是 WebSocket / JSON-RPC 双向连接)。 - * 这里直接 hardcode "总是 allow_once"。 + * 这里直接 hardcode "allow"。 */ async function fakeAcpRequestPermission(req: AcpPermissionRequest): Promise { - console.log(`\n[ACP server → client] session/request_permission`) - console.log(` toolCall.toolName: ${req.toolCall.toolName}`) - console.log(` toolCall.args: ${JSON.stringify(req.toolCall.args)}`) - console.log(` options: ${req.options.map((o) => o.optionId).join(', ')}`) - console.log(`[ACP client → server] selected: allow_once (模拟)`) + console.log(`\n[ACP server → client] request_permission`) + console.log(` toolCall.title: ${req.toolCall.title}`) + console.log(` toolCall.kind: ${req.toolCall.kind}`) + console.log(` toolCall.rawInput: ${JSON.stringify(req.toolCall.rawInput)}`) + console.log(` options: ${req.options.map((o) => `${o.optionId}(${o.kind})`).join(', ')}`) + console.log(`[ACP client → server] selected: allow (模拟)`) await new Promise((r) => setTimeout(r, 50)) - return { outcome: { kind: 'selected', optionId: 'allow_once' } } + return { outcome: { outcome: 'selected', optionId: 'allow' } } } // ───────────────────────────────────────────────────────────────────── -// ACP HITL pump:消费 OAK 内置 ACP 更新,遇到 tool_confirm 后注入审批决策 +// ACP HITL pump:消费 OAK 内置 ACP 更新,遇到 request_permission 后注入审批决策 // ───────────────────────────────────────────────────────────────────── /** @@ -69,34 +75,30 @@ async function pumpThroughAcp( ): Promise { for await (const e of events) { if (e.sessionUpdate === 'agent_message_chunk') { - process.stdout.write(e.content.text) + process.stdout.write(e.content.type === 'text' ? e.content.text : '') } else if (e.sessionUpdate === 'tool_call') { - process.stdout.write(`\n[kernel ACP] tool_call: ${e.title}\n`) + process.stdout.write(`\n[kernel ACP] tool_call: ${e.title} (kind=${e.kind})\n`) } else if (e.sessionUpdate === 'tool_call_update') { - const out = JSON.stringify(e.result ?? e.error ?? null).slice(0, 100) - process.stdout.write(`\n[kernel ACP] tool_call_update: result=${out}\n`) - } else if (e.sessionUpdate === 'tool_confirm') { - const options: AcpPermissionRequest['options'] = [ - { optionId: 'allow_once', label: '本次允许', kind: 'allow_once' }, - { optionId: 'reject_once', label: '本次拒绝', kind: 'reject_once' }, - { optionId: 'allow_always', label: '本会话内总是允许', kind: 'allow_always' }, - ] + const out = JSON.stringify(e.rawOutput ?? e.content ?? null).slice(0, 100) + process.stdout.write(`\n[kernel ACP] tool_call_update: status=${e.status} out=${out}\n`) + } else if (e.sessionUpdate === 'request_permission') { const acpReq: AcpPermissionRequest = { toolCall: { - toolCallId: e.toolCallId, - toolName: e.toolName, - args: e.input, + toolCallId: e.toolCall.toolCallId, + title: e.toolCall.title, + kind: e.toolCall.kind ?? 'other', + rawInput: e.toolCall.rawInput, }, - options, + options: e.options, } const acpResp = await fakeAcpRequestPermission(acpReq) // ── ACP 响应 → kernel 决策 ── - if (acpResp.outcome.kind === 'cancelled') { + if (acpResp.outcome.outcome === 'cancelled') { // 客户端取消 → kernel 视为 deny+interrupt await pumpThroughAcp( session.respondApproval({ - toolUseId: e.toolCallId, + toolUseId: e.toolCall.toolCallId, decision: { kind: 'deny', reason: 'ACP client cancelled', interrupt: true }, }), session, @@ -105,11 +107,11 @@ async function pumpThroughAcp( } const optionId = acpResp.outcome.optionId const decision = - optionId === 'allow_once' + optionId === 'allow' ? ({ kind: 'allow', scope: 'once' } as const) : optionId === 'allow_always' ? ({ kind: 'allow', scope: 'session' } as const) - : optionId === 'reject_once' + : optionId === 'reject' ? ({ kind: 'deny', scope: 'once', reason: 'User rejected' } as const) : ({ kind: 'deny', @@ -118,7 +120,7 @@ async function pumpThroughAcp( } as const) // ── 注入决策并继续消费事件流(递归式抽干)── - await pumpThroughAcp(session.respondApproval({ toolUseId: e.toolCallId, decision }), session) + await pumpThroughAcp(session.respondApproval({ toolUseId: e.toolCall.toolCallId, decision }), session) return } else if (e.sessionUpdate === 'agent_phase' && e.phase === 'idle') { process.stdout.write('\n[kernel ACP] idle\n') diff --git a/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts b/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts index 6d24f8b..7bceb57 100644 --- a/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts +++ b/packages/open-agent-kernel/examples/13-hitl-distributed-cloudbase.ts @@ -21,7 +21,7 @@ * 验证 DB: * 在 CloudBase 控制台 → 数据库 → 看 oak_state 集合(pending / decided 都会落到这里) */ -import { captureToolConfirm, printAcpUpdate, type PendingToolConfirm } from './_shared/acp.js' +import { captureRequestPermission, printAcpUpdate, type PendingRequestPermission } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' import { createAgent } from '@cloudbase/open-agent-kernel' @@ -75,11 +75,11 @@ async function main(): Promise { console.log(`User: ${prompt}\n`) process.stdout.write('Assistant: ') - let pendingApproval: PendingToolConfirm | undefined + let pendingApproval: PendingRequestPermission | undefined for await (const e of sessionA.send(prompt)) { printAcpUpdate(e) - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log('\n\n⏸ 审批请求(已写入 CloudBase DB):') console.log(` 工具: ${captured.toolName}`) @@ -127,7 +127,7 @@ async function main(): Promise { decision: { kind: 'allow', scope: 'once' }, })) { printAcpUpdate(e) - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log('\n\n⏸ 又一个审批请求(demo 自动 allow):', captured.toolName) for await (const e2 of sessionB.respondApproval({ diff --git a/packages/open-agent-kernel/examples/14-session-history.ts b/packages/open-agent-kernel/examples/14-session-history.ts index 099bd04..42101bb 100644 --- a/packages/open-agent-kernel/examples/14-session-history.ts +++ b/packages/open-agent-kernel/examples/14-session-history.ts @@ -16,7 +16,7 @@ * 运行: * pnpm dlx tsx packages/open-agent-kernel/examples/14-session-history.ts */ -import { captureToolConfirm, logAcpUpdate, type PendingToolConfirm } from './_shared/acp.js' +import { captureRequestPermission, logAcpUpdate, type PendingRequestPermission } from './_shared/acp.js' import { getEnvId, getModel, getPlatformCredentials } from './_shared/env.js' import { randomUUID } from 'node:crypto' @@ -120,11 +120,11 @@ const prompt2 = '请用 bash 工具执行 echo "hello from sandbox" && date 命 console.log(`User: ${prompt2}\n`) process.stdout.write('Assistant: ') -let pendingApproval: PendingToolConfirm | undefined +let pendingApproval: PendingRequestPermission | undefined for await (const e of session.send(prompt2)) { logAcpUpdate(e) - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log('\n\n ⏸ 审批请求触发!') console.log(` 工具: ${captured.toolName}`) diff --git a/packages/open-agent-kernel/examples/15-skills.ts b/packages/open-agent-kernel/examples/15-skills.ts index aa0a5a9..afb199a 100644 --- a/packages/open-agent-kernel/examples/15-skills.ts +++ b/packages/open-agent-kernel/examples/15-skills.ts @@ -78,7 +78,7 @@ async function main() { printAcpUpdate(event) if (isSkillToolCall(event)) { sawSkillInvocation = true - console.log(`\n[example] ✓ Skill tool invoked with input:`, event.input) + console.log(`\n[example] ✓ Skill tool invoked with input:`, event.rawInput) } } console.log('\n[example] done.') diff --git a/packages/open-agent-kernel/examples/16-client-tool-history-live.ts b/packages/open-agent-kernel/examples/16-client-tool-history-live.ts index ff04307..93cad65 100644 --- a/packages/open-agent-kernel/examples/16-client-tool-history-live.ts +++ b/packages/open-agent-kernel/examples/16-client-tool-history-live.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { CloudBaseDbDriver, CloudBaseSessionStore, createAgent } from '@cloudbase/open-agent-kernel' -import { captureToolConfirm, logAcpUpdate } from './_shared/acp.js' +import { captureRequestPermission, logAcpUpdate } from './_shared/acp.js' // ─── 配置 ────────────────────────────────────────────────────────── @@ -78,7 +78,7 @@ let toolUseId: string | undefined process.stdout.write('🤖 Assistant: ') for await (const e of session.send(prompt)) { logAcpUpdate(e) - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log(`\n\n ⏸ client-tool 触发!`) console.log(` 工具: ${captured.toolName}`) diff --git a/packages/open-agent-kernel/examples/17-session-store-debug.ts b/packages/open-agent-kernel/examples/17-session-store-debug.ts index 80ae964..5592fc7 100644 --- a/packages/open-agent-kernel/examples/17-session-store-debug.ts +++ b/packages/open-agent-kernel/examples/17-session-store-debug.ts @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { CloudBaseDbDriver, CloudBaseSessionStore, createAgent } from '@cloudbase/open-agent-kernel' -import { captureToolConfirm } from './_shared/acp.js' +import { captureRequestPermission } from './_shared/acp.js' // ─── 配置 ────────────────────────────────────────────────────────── @@ -50,7 +50,7 @@ const session = await agent.startSession({ userId: 'debug-user', conversationId console.log('=== Step 1: send ===') let toolUseId: string | undefined for await (const e of session.send('Call get_weather tool with city="Beijing". Do not skip the tool.')) { - const captured = captureToolConfirm(e) + const captured = captureRequestPermission(e) if (captured) { console.log(` tool_confirm: ${captured.toolName}, toolUseId=${captured.toolUseId}`) toolUseId = captured.toolUseId @@ -72,7 +72,7 @@ if (toolUseId) { } else if (e.sessionUpdate === 'tool_call') { console.log(` tool_call: ${e.title}`) } else if (e.sessionUpdate === 'tool_call_update') { - console.log(` tool_call_update: ${JSON.stringify(e.result ?? e.error ?? null).slice(0, 100)}`) + console.log(` tool_call_update: ${JSON.stringify(e.rawOutput ?? e.content ?? null).slice(0, 100)}`) } } } diff --git a/packages/open-agent-kernel/examples/19a-snapshot-write.ts b/packages/open-agent-kernel/examples/19a-snapshot-write.ts index 1236d10..488c1cc 100644 --- a/packages/open-agent-kernel/examples/19a-snapshot-write.ts +++ b/packages/open-agent-kernel/examples/19a-snapshot-write.ts @@ -95,7 +95,7 @@ ${stamp} console.log(`\n[19a][tool#${toolCalls}] → ${ev.title}`) } if (ev.sessionUpdate === 'tool_call_update' && (ev.status === 'completed' || ev.status === 'failed')) { - const out = JSON.stringify(ev.result ?? ev.error ?? null) + const out = JSON.stringify(ev.rawOutput ?? ev.content ?? null) console.log(`[19a][tool#${toolCalls}] ← status=${ev.status} ${out.slice(0, 200)}${out.length > 200 ? '…' : ''}`) } if (ev.sessionUpdate === 'log' && ev.level === 'error') { diff --git a/packages/open-agent-kernel/examples/19b-snapshot-read.ts b/packages/open-agent-kernel/examples/19b-snapshot-read.ts index 2bbf6db..deb16bb 100644 --- a/packages/open-agent-kernel/examples/19b-snapshot-read.ts +++ b/packages/open-agent-kernel/examples/19b-snapshot-read.ts @@ -116,7 +116,7 @@ async function main() { console.log(`\n[19b][tool#${toolCalls}] → ${ev.title}`) } if (ev.sessionUpdate === 'tool_call_update' && (ev.status === 'completed' || ev.status === 'failed')) { - const out = JSON.stringify(ev.result ?? ev.error ?? null) + const out = JSON.stringify(ev.rawOutput ?? ev.content ?? null) console.log(`[19b][tool#${toolCalls}] ← status=${ev.status} ${out.slice(0, 300)}${out.length > 300 ? '…' : ''}`) } if (ev.sessionUpdate === 'log' && ev.level === 'error') { diff --git a/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts b/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts index a9ad906..d006665 100644 --- a/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts +++ b/packages/open-agent-kernel/examples/20-acp-stream-adapter-fixture.ts @@ -123,6 +123,7 @@ async function* fixtureMessages(): AsyncIterable { yield { type: 'result', subtype: 'success', + usage: { input_tokens: 100, output_tokens: 50 }, } as unknown as SDKMessage } diff --git a/packages/open-agent-kernel/examples/_shared/acp.ts b/packages/open-agent-kernel/examples/_shared/acp.ts index ced9314..bac8c17 100644 --- a/packages/open-agent-kernel/examples/_shared/acp.ts +++ b/packages/open-agent-kernel/examples/_shared/acp.ts @@ -1,32 +1,57 @@ -import type { AcpSessionUpdate } from '@cloudbase/open-agent-kernel' +import type { AcpSessionUpdate, ContentBlock, ToolCallContent } from '@cloudbase/open-agent-kernel' -export interface PendingToolConfirm { +export interface PendingRequestPermission { toolUseId: string toolName: string input: unknown } +/** + * Extract text from a ContentBlock. Standard ACP ContentBlock is a union + * (text | image | audio | resource_link | resource); OAK currently only + * emits text blocks, but the helper narrows safely for the examples. + */ +function textOf(block: ContentBlock): string { + return block.type === 'text' ? block.text : '' +} + +/** Extract text from a ToolCallContent[] (content / diff / terminal). */ +function toolContentText(parts: ToolCallContent[]): string { + return parts + .map((p) => { + if (p.type !== 'content') return '' + const block = p.content + return block.type === 'text' ? block.text : '' + }) + .join('') +} + export function writeAcpText(update: AcpSessionUpdate): void { if (update.sessionUpdate === 'agent_message_chunk') { - process.stdout.write(update.content.text) + process.stdout.write(textOf(update.content)) } } export function printAcpUpdate(update: AcpSessionUpdate): void { switch (update.sessionUpdate) { case 'agent_message_chunk': - process.stdout.write(update.content.text) + process.stdout.write(textOf(update.content)) + break + case 'agent_thought_chunk': + process.stdout.write(`\n (thought) ${textOf(update.content)}\n `) break case 'tool_call': - process.stdout.write(`\n -> ${update.title}(${JSON.stringify(update.input ?? {}).slice(0, 200)})\n `) + process.stdout.write(`\n -> ${update.title}(${JSON.stringify(update.rawInput ?? {}).slice(0, 200)})\n `) break - case 'tool_call_update': + case 'tool_call_update': { if (update.status === 'completed' || update.status === 'failed') { - process.stdout.write(`\n <- ${JSON.stringify(update.result ?? update.error ?? null).slice(0, 300)}\n `) + const out = update.rawOutput ?? toolContentText(update.content ?? []) + process.stdout.write(`\n <- ${JSON.stringify(out).slice(0, 300)}\n `) } break - case 'tool_confirm': - process.stdout.write(`\n ? ${update.toolName} requires confirmation\n `) + } + case 'request_permission': + process.stdout.write(`\n ? ${update.toolCall.title} requires confirmation\n `) break case 'ask_user': process.stdout.write('\n ? agent asks user\n ') @@ -41,6 +66,9 @@ export function printAcpUpdate(update: AcpSessionUpdate): void { case 'agent_phase': if (update.phase === 'idle') process.stdout.write('\n') break + case 'usage_update': + process.stdout.write(`\n [usage] ${update.used}/${update.size || '?'} tokens\n`) + break default: break } @@ -50,24 +78,33 @@ export function printAcpUpdate(update: AcpSessionUpdate): void { export function logAcpUpdate(update: AcpSessionUpdate): void { switch (update.sessionUpdate) { case 'agent_message_chunk': - process.stdout.write(update.content.text) + process.stdout.write(textOf(update.content)) + break + case 'agent_thought_chunk': + console.log(`\n (thought) ${textOf(update.content)}`) break case 'tool_call': - console.log(`\n → [tool_call] ${update.title}(${JSON.stringify(update.input ?? {})})`) + console.log(`\n → [tool_call] ${update.title}(${JSON.stringify(update.rawInput ?? {})})`) break - case 'tool_call_update': + case 'tool_call_update': { if (update.status === 'completed' || update.status === 'failed') { - console.log(` ← [tool_result] ${JSON.stringify(update.result ?? update.error ?? null).slice(0, 200)}`) + const out = update.rawOutput ?? toolContentText(update.content ?? []) + console.log(` ← [tool_result] ${JSON.stringify(out).slice(0, 200)}`) } break - case 'tool_confirm': - console.log('\n ⏸ tool_confirm:') - console.log(` 工具: ${update.toolName}`) - console.log(` 参数: ${JSON.stringify(update.input)}`) - console.log(` toolCallId: ${update.toolCallId}`) + } + case 'request_permission': + console.log('\n ⏸ request_permission:') + console.log(` 工具: ${update.toolCall.title}`) + console.log(` 参数: ${JSON.stringify(update.toolCall.rawInput)}`) + console.log(` toolCallId: ${update.toolCall.toolCallId}`) break case 'agent_phase': if (update.phase === 'idle') console.log('\n[agent_phase: idle]') + else console.log(`\n[agent_phase: ${update.phase}]`) + break + case 'usage_update': + console.log(`\n[usage] ${update.used}/${update.size || '?'} tokens`) break case 'log': if (update.level === 'error') console.error('\n[error]', update.message) @@ -78,39 +115,43 @@ export function logAcpUpdate(update: AcpSessionUpdate): void { } } -export function captureToolConfirm(update: AcpSessionUpdate): PendingToolConfirm | undefined { - if (update.sessionUpdate !== 'tool_confirm') return undefined +export function captureRequestPermission(update: AcpSessionUpdate): PendingRequestPermission | undefined { + if (update.sessionUpdate !== 'request_permission') return undefined return { - toolUseId: update.toolCallId, - toolName: update.toolName, - input: update.input, + toolUseId: update.toolCall.toolCallId, + toolName: update.toolCall.title, + input: update.toolCall.rawInput, } } -export function isSkillToolCall(update: AcpSessionUpdate): boolean { +export function isSkillToolCall(update: AcpSessionUpdate): update is AcpSessionUpdate & { sessionUpdate: 'tool_call' } { return update.sessionUpdate === 'tool_call' && update.title === 'Skill' } export function fmtAcpUpdate(update: AcpSessionUpdate): string { switch (update.sessionUpdate) { case 'agent_message_chunk': - return `Δ ${JSON.stringify(update.content.text)}` + return `Δ ${JSON.stringify(textOf(update.content))}` + case 'agent_thought_chunk': + return `Δ (thought) ${JSON.stringify(textOf(update.content))}` case 'tool_call': { - const inputStr = JSON.stringify(update.input ?? {}) + const inputStr = JSON.stringify(update.rawInput ?? {}) const trim = inputStr.length > 200 ? `${inputStr.slice(0, 200)}…` : inputStr return `→ tool_call ${update.title} ${trim}` } case 'tool_call_update': { - const out = JSON.stringify(update.result ?? update.error ?? null) - const trim = out.length > 300 ? `${out.slice(0, 300)}…` : out + const out = update.rawOutput ?? toolContentText(update.content ?? []) + const trim = JSON.stringify(out).slice(0, 300) return `← tool_call_update status=${update.status} ${trim}` } - case 'tool_confirm': - return `? tool_confirm ${update.toolName} id=${update.toolCallId}` + case 'request_permission': + return `? request_permission ${update.toolCall.title} id=${update.toolCall.toolCallId}` case 'log': return update.level === 'error' ? `✗ error ${update.message}` : `[${update.level}] ${update.message}` case 'agent_phase': return update.phase === 'idle' ? '· agent_phase idle' : `· agent_phase ${update.phase}` + case 'usage_update': + return `· usage ${update.used}/${update.size || '?'}` default: return `· ${update.sessionUpdate} ${JSON.stringify(update).slice(0, 200)}` } @@ -118,6 +159,6 @@ export function fmtAcpUpdate(update: AcpSessionUpdate): string { export function appendAcpAssistantText(update: AcpSessionUpdate, buffer: { text: string }): void { if (update.sessionUpdate === 'agent_message_chunk') { - buffer.text += update.content.text + buffer.text += textOf(update.content) } } diff --git a/packages/open-agent-kernel/package.json b/packages/open-agent-kernel/package.json index 19a769c..35a41ae 100644 --- a/packages/open-agent-kernel/package.json +++ b/packages/open-agent-kernel/package.json @@ -1,6 +1,6 @@ { "name": "@cloudbase/open-agent-kernel", - "version": "0.1.0-beta.6", + "version": "0.1.0-beta.8", "description": "CloudBase Open Agent Kernel — server-side agentic agent SDK with built-in CloudBase resources", "license": "Apache-2.0", "type": "module", @@ -28,6 +28,7 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.0.0", "@anthropic-ai/claude-agent-sdk": "^0.3.146", "@anthropic-ai/sdk": "^0.93.0", "@cloudbase/manager-node": "^4.10.6", diff --git a/packages/open-agent-kernel/src/acp/index.ts b/packages/open-agent-kernel/src/acp/index.ts index 0823370..9497870 100644 --- a/packages/open-agent-kernel/src/acp/index.ts +++ b/packages/open-agent-kernel/src/acp/index.ts @@ -1,21 +1,52 @@ export type { - AcpSessionUpdate, - AcpTextBlock, - AgentMessageChunkUpdate, - AgentThoughtChunkUpdate, + // Standard ACP types (re-exported from @agentclientprotocol/sdk) + SessionUpdate, + ToolCall, ToolCallUpdate, - ToolCallStatusUpdate, + ToolKind, + ToolCallStatus, + ToolCallLocation, + ToolCallContent, + ContentChunk, + ContentBlock, + TextContent, + Plan, + PlanEntry, + PlanUpdate, + PlanRemoved, + UsageUpdate, AvailableCommandsUpdate, + AvailableCommand, + CurrentModeUpdate, + ConfigOptionUpdate, + SessionInfoUpdate, + PermissionOption, + PermissionOptionKind, + PermissionOptionId, + RequestPermissionRequest, + RequestPermissionOutcome, + SelectedPermissionOutcome, + Diff, + Terminal, + Cost, + MessageId, + // OAK convenience alias + AcpTextBlock, + // OAK _meta extension namespace + OakMeta, + // OAK extension variants LogUpdate, - TaskProgressUpdate, - FileChangeUpdate, - ThinkingUpdate, - AskUserUpdate, - ToolConfirmUpdate, ArtifactUpdate, + HistoryPageUpdate, + AgentPhaseUpdate, + RequestPermissionUpdate, + AskUserUpdate, + // OAK history types HistoryMessage, HistoryMessagePart, - HistoryPageUpdate, + HistoryMessagePartToolCall, + HistoryMessagePartToolResult, AgentPhaseName, - AgentPhaseUpdate, + // Top-level union + AcpSessionUpdate, } from './types.js' diff --git a/packages/open-agent-kernel/src/acp/types.ts b/packages/open-agent-kernel/src/acp/types.ts index 01602b5..4feb5e6 100644 --- a/packages/open-agent-kernel/src/acp/types.ts +++ b/packages/open-agent-kernel/src/acp/types.ts @@ -1,54 +1,127 @@ /** - * Self-contained ACP session/update types exposed by OAK. + * ACP session/update types exposed by OAK. * - * The shape intentionally mirrors OpenVibeCoding's ExtendedSessionUpdate, - * but this package does not depend on the monorepo shared package at runtime. + * Built on top of the standard ACP {@link SessionUpdate} from + * `@agentclientprotocol/sdk`. Standard variants are re-exported as-is; OAK + * extensions are defined below for capabilities the spec does not cover: + * + * - `log` / `artifact` / `history_page` / `agent_phase` — OAK-specific + * notifications with no standard equivalent. + * - `request_permission` — OAK's stop-and-resume adaptation of the standard + * `session/request_permission` JSON-RPC request. Serverless deployments have + * no session affinity to hold a reverse-RPC channel open, so the payload is + * delivered as a `session/update` notification and the turn ends with + * `stopReason: 'awaiting_permission'`. The client resumes via a new + * `session/prompt` POST carrying the selected `optionId`. The payload shape + * mirrors {@link RequestPermissionRequest} so a standard ACP client can + * consume it directly. + * - `ask_user` — OAK's AskUserQuestion flow (stop-and-resume, same reason as + * `request_permission`). + * + * OAK-specific data on standard variants (e.g. `parentToolCallId`) is carried + * in `_meta.oak.*`, per the ACP extensibility convention. */ -export interface AcpTextBlock { - type: 'text' - text: string -} +import type { + SessionUpdate, + ToolCall, + ToolCallUpdate, + ToolKind, + ToolCallStatus, + ToolCallLocation, + ToolCallContent, + ContentChunk, + ContentBlock, + TextContent, + Plan, + PlanEntry, + PlanUpdate, + PlanRemoved, + UsageUpdate, + AvailableCommandsUpdate, + AvailableCommand, + CurrentModeUpdate, + ConfigOptionUpdate, + SessionInfoUpdate, + PermissionOption, + PermissionOptionKind, + PermissionOptionId, + RequestPermissionRequest, + RequestPermissionOutcome, + SelectedPermissionOutcome, + Diff, + Terminal, + Cost, + MessageId, +} from '@agentclientprotocol/sdk' -export interface AgentMessageChunkUpdate { - sessionUpdate: 'agent_message_chunk' - content: AcpTextBlock -} +// ── Re-export standard ACP types for consumer convenience ─────────────── -export interface AgentThoughtChunkUpdate { - sessionUpdate: 'agent_thought_chunk' - content: string +export type { + SessionUpdate, + ToolCall, + ToolCallUpdate, + ToolKind, + ToolCallStatus, + ToolCallLocation, + ToolCallContent, + ContentChunk, + ContentBlock, + TextContent, + Plan, + PlanEntry, + PlanUpdate, + PlanRemoved, + UsageUpdate, + AvailableCommandsUpdate, + AvailableCommand, + CurrentModeUpdate, + ConfigOptionUpdate, + SessionInfoUpdate, + PermissionOption, + PermissionOptionKind, + PermissionOptionId, + RequestPermissionRequest, + RequestPermissionOutcome, + SelectedPermissionOutcome, + Diff, + Terminal, + Cost, + MessageId, } -export interface ToolCallUpdate { - sessionUpdate: 'tool_call' - toolCallId: string - title: string - kind: 'function' | 'other' - status: 'in_progress' | 'completed' | 'failed' - input?: unknown - parentToolCallId?: string -} +/** + * Convenience alias for a text content block — `TextContent & { type: 'text' }`. + * Useful when constructing `agent_message_chunk` / `agent_thought_chunk` + * updates that only carry text. + */ +export type AcpTextBlock = TextContent & { type: 'text' } -export interface ToolCallStatusUpdate { - sessionUpdate: 'tool_call_update' - toolCallId: string - status: 'in_progress' | 'completed' | 'failed' - result?: unknown - input?: unknown - error?: { message: string } - parentToolCallId?: string -} +// ── OAK _meta extension namespace ─────────────────────────────────────── +// +// Standard ACP types reserve `_meta` for extensibility. OAK carries its own +// non-standard fields under `_meta.oak.*` so they don't collide with the +// spec and standard clients can ignore them. -export interface AvailableCommandsUpdate { - sessionUpdate: 'available_commands_update' - availableCommands: Array<{ - name: string - description: string - _meta?: Record - }> +export interface OakMeta { + oak?: { + /** Parent tool call ID for sub-agent tool chains. */ + parentToolCallId?: string + /** OAK-internal assistant message id (for SSE correlation). */ + assistantMessageId?: string + /** ExitPlanMode plan content (Markdown), when the tool is ExitPlanMode. */ + planContent?: string + [key: string]: unknown + } | null + [key: string]: unknown } +// ── OAK extension: log ────────────────────────────────────────────────── +// +// Standard ACP has no log sessionUpdate. OAK uses this for error/status +// messages that need to surface in the conversation stream (e.g. abort +// reasons, model errors). + export interface LogUpdate { sessionUpdate: 'log' level: 'info' | 'error' | 'success' | 'command' @@ -56,43 +129,11 @@ export interface LogUpdate { timestamp: number } -export interface TaskProgressUpdate { - sessionUpdate: 'task_progress' - progress: number - status: 'pending' | 'processing' | 'completed' | 'error' | 'stopped' -} - -export interface FileChangeUpdate { - sessionUpdate: 'file_change' - filename: string - action: 'add' | 'modify' | 'delete' -} - -export interface ThinkingUpdate { - sessionUpdate: 'thinking' - content: string -} - -export interface AskUserUpdate { - sessionUpdate: 'ask_user' - toolCallId: string - assistantMessageId: string - questions: Array<{ - question: string - header: string - options: Array<{ label: string; description: string }> - multiSelect: boolean - }> -} - -export interface ToolConfirmUpdate { - sessionUpdate: 'tool_confirm' - toolCallId: string - assistantMessageId: string - toolName: string - input: Record - planContent?: string -} +// ── OAK extension: artifact ────────────────────────────────────────────── +// +// Standard ACP has no artifact sessionUpdate. OAK uses this to surface +// deployment artifacts (web links, miniprogram QR codes, JSON blobs) to +// the client for dedicated UI treatment. export interface ArtifactUpdate { sessionUpdate: 'artifact' @@ -105,6 +146,13 @@ export interface ArtifactUpdate { } } +// ── OAK extension: history_page ────────────────────────────────────────── +// +// Standard ACP uses `session/load` as a request-response method. OAK's +// stop-and-resume model surfaces history as a session/update notification +// carrying one page of messages, so the client can reuse the same render +// pipeline as live updates. + export interface HistoryMessagePartToolCall { type: 'tool_call' toolCallId: string @@ -148,6 +196,12 @@ export interface HistoryPageUpdate { nextCursor?: string | null } +// ── OAK extension: agent_phase ─────────────────────────────────────────── +// +// Standard ACP has no phase concept. OAK reports execution-phase transitions +// (preparing / model_responding / tool_executing / compacting / idle) so the +// client can render a status indicator. + export type AgentPhaseName = 'preparing' | 'model_responding' | 'tool_executing' | 'compacting' | 'idle' export interface AgentPhaseUpdate { @@ -157,18 +211,55 @@ export interface AgentPhaseUpdate { timestamp: number } +// ── OAK extension: request_permission (stop-and-resume HITL) ──────────── +// +// Mirrors the standard RequestPermissionRequest payload, but delivered as a +// session/update notification (not a JSON-RPC request) because serverless +// deployments cannot hold a reverse-RPC channel open. The turn ends after +// emitting this and the client resumes via a new session/prompt POST. + +export interface RequestPermissionUpdate { + sessionUpdate: 'request_permission' + sessionId: string + toolCall: ToolCallUpdate + options: PermissionOption[] + _meta?: OakMeta | null +} + +// ── OAK extension: ask_user (stop-and-resume AskUserQuestion) ──────────── +// +// OAK's AskUserQuestion flow. Same stop-and-resume rationale as +// request_permission. Kept as a separate variant (rather than folded into +// request_permission) because the question/options shape is richer than a +// permission option list. + +export interface AskUserUpdate { + sessionUpdate: 'ask_user' + toolCallId: string + assistantMessageId: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiSelect: boolean + }> +} + +// ── AcpSessionUpdate: standard SessionUpdate + OAK extensions ──────────── +// +// `sessionUpdate` discriminators: +// standard: user_message_chunk | agent_message_chunk | agent_thought_chunk +// | tool_call | tool_call_update | plan | plan_update | plan_removed +// | available_commands_update | current_mode_update +// | config_option_update | session_info_update | usage_update +// OAK: log | artifact | history_page | agent_phase +// | request_permission | ask_user + export type AcpSessionUpdate = - | AgentMessageChunkUpdate - | AgentThoughtChunkUpdate - | ToolCallUpdate - | ToolCallStatusUpdate - | AvailableCommandsUpdate + | SessionUpdate | LogUpdate - | TaskProgressUpdate - | FileChangeUpdate - | ThinkingUpdate - | AskUserUpdate - | ToolConfirmUpdate | ArtifactUpdate | HistoryPageUpdate | AgentPhaseUpdate + | RequestPermissionUpdate + | AskUserUpdate diff --git a/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts b/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts index 4e8a0f5..78a3e1d 100644 --- a/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts +++ b/packages/open-agent-kernel/src/adapters/acp-stream-adapter.ts @@ -1,5 +1,5 @@ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' -import type { AcpSessionUpdate } from '../acp/types.js' +import type { AcpSessionUpdate, OakMeta, PermissionOption, ToolKind } from '../acp/types.js' import { parseAskUserSignal, parseClientToolSignal, parseInterruptSignal } from '../permissions/hooks.js' import type { StreamAdapter, StreamAdapterContext } from './types.js' @@ -63,11 +63,7 @@ export class AcpStreamAdapter implements StreamAdapter { yield* translateUserMessage(message, context, state) return case 'result': - yield { - sessionUpdate: 'agent_phase', - phase: 'idle', - timestamp: Date.now(), - } + yield* translateResultMessage(message) return default: void context @@ -76,6 +72,83 @@ export class AcpStreamAdapter implements StreamAdapter { } } +// ── Translation helpers ───────────────────────────────────────────────── + +/** + * Derive the standard ACP {@link ToolKind} from a tool name. Ported from the + * official @agentclientprotocol/claude-agent-acp `tools.ts` (toolInfoFromToolUse). + */ +function toolKindFromName(toolName: string): ToolKind { + switch (toolName) { + case 'Bash': + case 'bash': + return 'execute' + case 'Read': + case 'read': + return 'read' + case 'Write': + case 'write': + case 'Edit': + case 'edit': + case 'Patch': + case 'patch': + case 'ApplyPatch': + return 'edit' + case 'Grep': + case 'grep': + case 'Glob': + case 'glob': + return 'search' + case 'WebFetch': + case 'webfetch': + case 'Fetch': + return 'fetch' + case 'Agent': + case 'Task': + case 'TaskCreate': + case 'TaskUpdate': + case 'TaskGet': + case 'TaskList': + return 'think' + default: + // MCP tools (mcp__*) and other SDK built-ins default to 'other'. + // Skills / custom tools surface as 'other' too — the title carries + // enough context for the client to render. + return 'other' + } +} + +/** + * Build the `_meta.oak` extension object for parentToolCallId / assistantMessageId. + * Returns undefined when there's nothing to carry, so the emitted update stays + * clean for the common (non-subagent, non-HITL) case. + */ +function oakMeta(opts: { + parentToolCallId?: string | undefined + assistantMessageId?: string | undefined + planContent?: string | undefined +}): OakMeta | null { + const oak: Record = {} + if (opts.parentToolCallId) oak.parentToolCallId = opts.parentToolCallId + if (opts.assistantMessageId) oak.assistantMessageId = opts.assistantMessageId + if (opts.planContent) oak.planContent = opts.planContent + if (Object.keys(oak).length === 0) return null + return { oak } +} + +/** + * Standard permission options for the `request_permission` variant. + * Matches the optionId/kind conventions used by the official claude-agent-acp + * wrapper (see acp-agent.ts canUseTool). + */ +function buildPermissionOptions(): PermissionOption[] { + return [ + { optionId: 'allow_always', name: 'Always allow', kind: 'allow_always' }, + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'reject', name: 'Reject', kind: 'reject_once' }, + ] +} + function* translateStreamEvent( message: SDKMessage, state: AcpAdapterState, @@ -87,10 +160,12 @@ function* translateStreamEvent( type?: string index?: number content_block?: { type?: string; id?: string; name?: string; input?: unknown } - delta?: { type?: string; text?: string; partial_json?: string } + delta?: { type?: string; text?: string; partial_json?: string; thinking?: string } } } ).event + + // text_delta → agent_message_chunk if ( event?.type === 'content_block_delta' && event.delta?.type === 'text_delta' && @@ -104,6 +179,20 @@ function* translateStreamEvent( } } + // thinking_delta → agent_thought_chunk (standard variant, replaces OAK's old 'thinking') + if ( + event?.type === 'content_block_delta' && + event.delta?.type === 'thinking_delta' && + typeof event.delta.thinking === 'string' && + event.delta.thinking.length > 0 + ) { + yield { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: event.delta.thinking }, + } + } + + // content_block_start (tool_use) → tool_call if ( event?.type === 'content_block_start' && typeof event.index === 'number' && @@ -124,14 +213,15 @@ function* translateStreamEvent( sessionUpdate: 'tool_call', toolCallId: tool.toolCallId, title: tool.toolName, - kind: 'function', + kind: toolKindFromName(tool.toolName), status: 'in_progress', - input: toRecordInput(event.content_block.input), - ...(tool.parentToolCallId ? { parentToolCallId: tool.parentToolCallId } : {}), + rawInput: toRecordInput(event.content_block.input), + ...(tool.parentToolCallId ? { _meta: oakMeta({ parentToolCallId: tool.parentToolCallId }) ?? undefined } : {}), } return } + // input_json_delta → tool_call_update (rawInput refinement) if ( event?.type === 'content_block_delta' && typeof event.index === 'number' && @@ -145,12 +235,13 @@ function* translateStreamEvent( sessionUpdate: 'tool_call_update', toolCallId: tool.toolCallId, status: 'in_progress', - input: parseJsonOrText(tool.partialJson), - ...(tool.parentToolCallId ? { parentToolCallId: tool.parentToolCallId } : {}), + rawInput: parseJsonOrText(tool.partialJson), + ...(tool.parentToolCallId ? { _meta: oakMeta({ parentToolCallId: tool.parentToolCallId }) ?? undefined } : {}), } return } + // content_block_stop → finalize tool_call_update (rawInput) if (event?.type === 'content_block_stop' && typeof event.index === 'number') { const tool = state.activeToolBlocks.get(event.index) if (!tool) return @@ -160,8 +251,8 @@ function* translateStreamEvent( sessionUpdate: 'tool_call_update', toolCallId: tool.toolCallId, status: 'in_progress', - input: parseJsonOrText(tool.partialJson), - ...(tool.parentToolCallId ? { parentToolCallId: tool.parentToolCallId } : {}), + rawInput: parseJsonOrText(tool.partialJson), + ...(tool.parentToolCallId ? { _meta: oakMeta({ parentToolCallId: tool.parentToolCallId }) ?? undefined } : {}), } } } @@ -173,6 +264,7 @@ function* translateAssistantMessage( dedupeAssistantText: boolean, ): Generator { const content = (message as { message?: { content?: unknown[] } }).message?.content ?? [] + const parentToolCallId = getParentToolCallId(message) for (const block of content) { if (!isRecord(block)) continue if ( @@ -189,13 +281,15 @@ function* translateAssistantMessage( } if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { state.toolCallNames.set(block.id, block.name) - const input = isRecord(block.input) ? block.input : (block.input ?? {}) + const rawInput = isRecord(block.input) ? block.input : (block.input ?? {}) if (state.emittedToolCalls.has(block.id)) { + // Replay after streaming: refine the existing tool_call with rawInput yield { sessionUpdate: 'tool_call_update', toolCallId: block.id, status: 'in_progress', - input, + rawInput, + ...(parentToolCallId ? { _meta: oakMeta({ parentToolCallId }) ?? undefined } : {}), } } else { state.emittedToolCalls.add(block.id) @@ -203,9 +297,10 @@ function* translateAssistantMessage( sessionUpdate: 'tool_call', toolCallId: block.id, title: block.name, - kind: 'function', + kind: toolKindFromName(block.name), status: 'in_progress', - input, + rawInput, + ...(parentToolCallId ? { _meta: oakMeta({ parentToolCallId }) ?? undefined } : {}), } } } @@ -224,30 +319,45 @@ function* translateUserMessage( if (!isRecord(block) || block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue const output = block.content ?? null const reasonText = extractTextContent(output) + const toolName = state.toolCallNames.get(block.tool_use_id) ?? 'unknown' + + // OAK HITL sentinel → request_permission (stop-and-resume) const interrupt = reasonText ? parseInterruptSignal(reasonText) : null if (interrupt) { yield { - sessionUpdate: 'tool_confirm', - toolCallId: interrupt.toolUseId, - assistantMessageId: context.turnId, - toolName: interrupt.toolName, - input: toRecordInput(interrupt.toolInput), + sessionUpdate: 'request_permission', + sessionId: context.sessionId, + toolCall: { + toolCallId: interrupt.toolUseId, + title: interrupt.toolName, + kind: toolKindFromName(interrupt.toolName), + rawInput: toRecordInput(interrupt.toolInput), + }, + options: buildPermissionOptions(), + _meta: oakMeta({ assistantMessageId: context.turnId }) ?? undefined, } continue } + // OAK client-tool sentinel → request_permission (client-side tool flow) const clientSignal = reasonText ? parseClientToolSignal(reasonText) : null if (clientSignal) { yield { - sessionUpdate: 'tool_confirm', - toolCallId: clientSignal.toolUseId, - assistantMessageId: context.turnId, - toolName: clientSignal.toolName, - input: toRecordInput(clientSignal.toolInput), + sessionUpdate: 'request_permission', + sessionId: context.sessionId, + toolCall: { + toolCallId: clientSignal.toolUseId, + title: clientSignal.toolName, + kind: toolKindFromName(clientSignal.toolName), + rawInput: toRecordInput(clientSignal.toolInput), + }, + options: buildPermissionOptions(), + _meta: oakMeta({ assistantMessageId: context.turnId }) ?? undefined, } continue } + // OAK askUser sentinel → ask_user (OAK extension) const askUserSignal = reasonText ? parseAskUserSignal(reasonText) : null if (askUserSignal) { yield { @@ -266,18 +376,51 @@ function* translateUserMessage( continue } + // Normal tool_result → tool_call_update with rawOutput const isError = Boolean(block.is_error) + const resultText = stringifyToolResult(output) yield { sessionUpdate: 'tool_call_update', toolCallId: block.tool_use_id, status: isError ? 'failed' : 'completed', - result: output, - ...(isError ? { error: { message: stringifyToolResult(output) } } : {}), + rawOutput: output, + ...(isError + ? { + content: [ + { + type: 'content' as const, + content: { type: 'text' as const, text: resultText }, + }, + ], + } + : {}), } state.toolCallNames.delete(block.tool_use_id) } } +function* translateResultMessage(message: SDKMessage): Generator { + // Emit usage_update (standard) when the SDK result carries token usage. + const usage = (message as { usage?: { input_tokens?: number; output_tokens?: number } }).usage + if (usage && typeof usage.input_tokens === 'number' && typeof usage.output_tokens === 'number') { + const used = usage.input_tokens + usage.output_tokens + yield { + sessionUpdate: 'usage_update', + used, + // size unknown without the model context window; report 0 so clients + // treat it as "unbounded" rather than mis-rendering a full bar. + size: 0, + } + } + // Signal turn end. Standard ACP has no 'agent_phase' sessionUpdate; this is + // an OAK extension that the client uses to clear its streaming indicator. + yield { + sessionUpdate: 'agent_phase', + phase: 'idle', + timestamp: Date.now(), + } +} + function getParentToolCallId(message: SDKMessage): string | undefined { const parent = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id return typeof parent === 'string' && parent.length > 0 ? parent : undefined diff --git a/packages/open-agent-kernel/src/index.ts b/packages/open-agent-kernel/src/index.ts index 899fd44..62eda80 100644 --- a/packages/open-agent-kernel/src/index.ts +++ b/packages/open-agent-kernel/src/index.ts @@ -14,25 +14,56 @@ export { AcpStreamAdapter } from './adapters/index.js' export type { AcpStreamAdapterOptions, StreamAdapter, StreamAdapterContext } from './adapters/index.js' export type { + // Top-level union AcpSessionUpdate, + // OAK convenience alias AcpTextBlock, - AgentMessageChunkUpdate, - AgentThoughtChunkUpdate, + // OAK _meta extension namespace + OakMeta, + // Standard ACP types (re-exported from @agentclientprotocol/sdk) + SessionUpdate, + ToolCall, ToolCallUpdate, - ToolCallStatusUpdate, + ToolKind, + ToolCallStatus, + ToolCallLocation, + ToolCallContent, + ContentChunk, + ContentBlock, + TextContent, + Plan, + PlanEntry, + PlanUpdate, + PlanRemoved, + UsageUpdate, AvailableCommandsUpdate, + AvailableCommand, + CurrentModeUpdate, + ConfigOptionUpdate, + SessionInfoUpdate, + PermissionOption, + PermissionOptionKind, + PermissionOptionId, + RequestPermissionRequest, + RequestPermissionOutcome, + SelectedPermissionOutcome, + Diff, + Terminal, + Cost, + MessageId, + // OAK extension variants LogUpdate, - TaskProgressUpdate, - FileChangeUpdate, - ThinkingUpdate, - AskUserUpdate, - ToolConfirmUpdate, ArtifactUpdate, + HistoryPageUpdate, + AgentPhaseUpdate, + RequestPermissionUpdate, + AskUserUpdate, + // OAK history types HistoryMessage, HistoryMessagePart, - HistoryPageUpdate, + HistoryMessagePartToolCall, + HistoryMessagePartToolResult, AgentPhaseName, - AgentPhaseUpdate, } from './acp/index.js' // 公共类型:完整对外契约 diff --git a/packages/open-agent-kernel/src/public/types.ts b/packages/open-agent-kernel/src/public/types.ts index ff41179..6be203e 100644 --- a/packages/open-agent-kernel/src/public/types.ts +++ b/packages/open-agent-kernel/src/public/types.ts @@ -236,7 +236,7 @@ export type McpServerConfig = SdkMcpServerConfig // ============================================================ /** - * 审批决策(用户对 ACP tool_confirm 的响应)。 + * 审批决策(用户对 ACP request_permission 的响应)。 * * 这是协议无关的超集——业务侧的 ACP / AG-UI / 自家 SSE 等协议只需要把 * 自己的决策枚举映射成下面的字段即可。 @@ -708,12 +708,12 @@ export interface Session { /** * 响应工具审批(PR #7.0)。 * - * 当 ACP 更新流给出 `tool_confirm` 后,业务收集到用户决策(allow/deny/scope/...) + * 当 ACP 更新流给出 `request_permission` 后,业务收集到用户决策(allow/deny/scope/...) * 调本方法注入决策。kernel 把决策写入 PermissionStore,然后内部 resume 一次 SDK 运行: * Hook 再次触发时从 store 读到决策并放行 / 拒绝,agent 继续往下跑。 * * 返回的事件流是"决策注入后"的 ACP 更新流(可能包含 agent_message_chunk / - * tool_call / tool_call_update / 再次的 tool_confirm / agent_phase 等)。 + * tool_call / tool_call_update / 再次的 request_permission / agent_phase 等)。 * * 注意:调用方应确保同一 toolUseId 不被并发响应;重复响应会用最后一次为准。 */ @@ -722,7 +722,7 @@ export interface Session { /** * PR #7.1: 注入客户端工具结果并 resume agent 运行。 * - * 配套 ACP `tool_confirm` 使用:业务侧在客户端执行完 AgentConfig.tools[] + * 配套 ACP `request_permission` 使用:业务侧在客户端执行完 AgentConfig.tools[] * 中声明的工具后,调本方法把结果回灌给 kernel: * 1. kernel 把结果写入内部 client-tool store * 2. 起一轮 SDK query(resume)→ 模型重发同名工具 → PreToolUse hook 这次 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efd50f8..b2aceb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: packages/open-agent-kernel: dependencies: + '@agentclientprotocol/sdk': + specifier: ^1.0.0 + version: 1.0.0(zod@4.3.6) '@anthropic-ai/claude-agent-sdk': specifier: ^0.3.146 version: 0.3.146(@anthropic-ai/sdk@0.93.0(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) @@ -573,6 +576,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentclientprotocol/sdk@1.0.0': + resolution: {integrity: sha512-5FHgLbLZhSAq9F+9SU7AOCXTDzaw/IYx/AvMjrZDs/YFZ9ZPxkdn9ti3fSkny0huHfj2hrqF6pqdns9gN5/Fig==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -6581,6 +6589,10 @@ snapshots: dependencies: zod: 4.3.6 + '@agentclientprotocol/sdk@1.0.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0':