Skip to content

ACP:IDE 桥接

源码版本v2026.6.11

职责

serveAcpGateway(serveAcpGateway:42-173) 是 OpenClaw 给 IDE 用的 stdio 桥接进程:它是个独立 Node 程序(从 openclaw acp CLI 启动),用 stdin/stdout 跑 Agent Client Protocol 的 JSON-RPC,把 IDE 发来的 initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession 等方法翻译成 Gateway 的 WebSocket chat/run 调用,再把 Gateway 推过来的事件 (event) 翻译回 ACP 的 SessionUpdate 推给 IDE。

它的关键定位是"翻译器 (translator) + 中间人":自己不做 agent 推理、不直接跑工具、不存长期记忆,只把 ACP 协议和 Gateway 协议双向对齐。ACP 这套协议被 Zed 等编辑器采用,所以 OpenClaw 通过 serveAcpGateway 就能被这些 IDE 当作一个标准 agent server 接入,无需 IDE 知道 OpenClaw 的内部协议。

设计动机

为什么不直接让 IDE 连 gateway 的 WebSocket?

  1. stdio 友好:IDE 启动 agent server 的标准做法是 fork 一个子进程、用 stdin/stdout 通信。这避免 IDE 关闭时泄漏 TCP socket,也不要求 IDE 知道 gateway 的端口/token——只需要 IDE 知道 openclaw acp --gateway-url ... --token-file ... 这条命令。
  2. 协议差异:ACP 用 JSON-RPC over ndjson,方法名是 initialize/prompt/cancel 这些 agent 语义;Gateway 用 WebSocket 加自己的事件帧 (chat/agent/exec.approval.requested)。两边的事件粒度、字段命名、能力声明完全不同——AcpGatewayAgent(AcpGatewayAgent:250)就是专门做这个翻译的类。
  3. 断线重连语义:ACP 是请求-响应模型,IDE 发 prompt 等结果;但 Gateway 是事件流,WebSocket 可能中途断开重连。AcpGatewayAgent 维护 pendingPrompts Map(pendingPrompts:258),把"在跑的 prompt"按 sessionId 索引,断线期间事件丢失了能在 reconnect 后 reconcilePendingPrompts(handleGatewayReconnect:329-356)重新对齐状态。
  4. session 持久化与重放:IDE 重启后想看到上次的会话历史,ACP 协议有 loadSession/listSessions/resumeSessionAcpEventLedger(AcpEventLedger:49-73)把每个 prompt 和 SessionUpdate 落到 SQLite,gateway 重启后仍能 replay。

关键文件

  • serveAcpGateway:42-173 — 入口:路由日志到 stderr、建 GatewayClient、等 hello、起 AgentSideConnection
  • GatewayClient 选项:83-115clientName: CLIclientDisplayName: "ACP"caps: [TOOL_EVENTS],onEvent/onHelloOk/onConnectError/onClose 四个回调。
  • AgentSideConnection:163-170 — 用 ACP SDK 的 AgentSideConnection 包裹 ndjson stream,工厂函数返回 AcpGatewayAgent
  • normalizeAcpInitializeProtocolVersion:175-197 — 兼容性 hack:SDK 0.22 严格校验 protocolVersion 是 uint16,有些编辑器会传 MCP 日期字符串,这里强制重写。
  • AcpGatewayAgent 类:250-307 — 实现 ACP SDK 的 Agent 接口,持有 connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer。
  • handleGatewayEvent:356-368 — 事件分流:chat → handleChatEvent,exec.approval.requested → handleExecApprovalRequestEvent,agent → handleAgentEvent。
  • initialize:370-395 — 返回 agentCapabilities(loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities)。
  • newSession:397-427 — 生成 sessionId、解析 sessionKey、startLedgerSession、推送第一个 SessionSnapshotUpdate
  • loadSession:429-490 — 三层 ledger replay 兜底:exact-by-sessionId → listed-by-sessionKey → fallback。
  • prompt:664-730 — 拼接 cwd 前缀、MAX_PROMPT_BYTES 防爆、extractAttachmentsFromPromptidempotencyKey: runId、设 pendingPrompt。
  • AcpEventLedger:49-90 — ledger 接口,SQLite-backed,默认 MAX_SESSIONS=200MAX_EVENTS_PER_SESSION=5000MAX_SERIALIZED_BYTES=16MB
  • LEDGER_VERSION + 文件锁:17-30 — 旧文件 ledger 还在,启动时 migrateFileAcpEventLedgerToSQLite 把它迁移到 SQLite 并归档源文件。
  • event-mapper:1-60 — ACP ContentBlock/ToolCallContent ↔ Gateway 文本/附件/metadata 的双向映射。
  • AcpSession + AcpServerOptions:33-48 — session 数据结构 + CLI 选项(gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit)。
  • AcpTranslatorSessionUpdates — 推送 SessionUpdate 帧,记录到 ledger。

数据流

serveAcpGateway 启动流程(serveAcpGateway:42)是严格的"先 gateway ready 再开 ACP":

typescript
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
  routeLogsToStderr();                              // stdout 留给 ndjson,日志全部走 stderr
  const cfg = getRuntimeConfig();
  const bootstrap = await resolveGatewayClientBootstrap({ ... });
  const gateway = new GatewayClient({
    url: bootstrap.url,
    token: bootstrap.auth.token,
    password: bootstrap.auth.password,
    clientName: GATEWAY_CLIENT_NAMES.CLI,
    clientDisplayName: "ACP",
    clientVersion: "acp",
    mode: GATEWAY_CLIENT_MODES.CLI,
    caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
    onEvent: (evt) => { void agent?.handleGatewayEvent(evt); },
    onHelloOk: () => { resolveGatewayReady(); agent?.handleGatewayReconnect(); },
    onConnectError: (err) => { rejectGatewayReady(err); },
    onClose: (code, reason) => { /* reject or onClosed */ },
  });
  process.once("SIGINT", shutdown);
  process.once("SIGTERM", shutdown);
  const readiness = await startGatewayClientWhenEventLoopReady(gateway, { ... });
  // ...
  await gatewayReady.catch((err) => { shutdown(); throw err; });
  // ... 然后才起 AgentSideConnection
}

关键顺序:日志全部路由到 stderr(routeLogsToStderr:43)——stdout 是 ndjson 通道,任何 console.log 都会污染协议帧。GatewayClient 必须先 hello 成功再开 ACP,否则 IDE 收到的第一个 prompt 会发到还没握手的 gateway。SIGINT/SIGTERM 都触发 shutdown,保证 IDE 关闭时 gateway.stop() 也会调用,避免悬挂的 WebSocket 连接。

prompt 是双向翻译最典型的一处(prompt:664):

typescript
async prompt(params: PromptRequest): Promise<PromptResponse> {
  const session = this.sessionStore.getSession(params.sessionId);
  if (!session) throw new Error(`Session ${params.sessionId} not found`);
  if (session.abortController) {
    this.sessionStore.cancelActiveRun(params.sessionId);  // 同 session 旧 run 未结束就先取消
  }
  const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES);  // CWE-400: 分块防爆
  const attachments = extractAttachmentsFromPrompt(params.prompt);
  const displayCwd = shortenHomePath(session.cwd);
  const message = prefixCwd
    ? `[Working directory: ${displayCwd}]\n\n${userText}`   // 让 agent 知道当前工作目录
    : userText;
  // Defense-in-depth: 也要复核组装后(message 含 cwd 前缀)的总大小
  if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
    throw new Error(`Prompt exceeds maximum allowed size of ${MAX_PROMPT_BYTES} bytes`);
  }
  const abortController = new AbortController();
  const runId = randomUUID();
  this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
  const requestParams = {
    sessionKey: session.sessionKey,
    message,
    attachments: attachments.length > 0 ? attachments : undefined,
    idempotencyKey: runId,                                // Gateway 侧 dedupe
    thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
    deliver: readBool(params["_meta"], ["deliver"]),
    timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
  };
  // ... 返回 Promise,resolve 由 handleChatEvent/handleAgentEvent 触发
}

几个细节:prefixCwd 默认开,把 cwd 信息以人类可读方式塞进 prompt 文本(而不是元数据)——这样 agent 不需要专门处理 _meta 也能知道工作目录;idempotencyKey = runId 保证 IDE 重发同一个 prompt 时 Gateway 不会跑两次;同 session 有活跃 run 时先 cancel(ACP 允许一个 session 同时只有一个 prompt 在跑)。MAX_PROMPT_BYTESextractTextFromPrompt 内部按 block 拒绝超大内容,组装完再复核一遍——这是 defense-in-depth,防止 block 拼接后超过阈值(对应 CWE-400)。

事件回流方向:GatewayClient.onEvent 收到 gateway 事件 → agent.handleGatewayEvent 分流到 chat/approval/agent 三类 handler → handler 调 sessionUpdates.send*SessionUpdate 帧 → sessionUpdates 同时把 update 写进 AcpEventLedger(SQLite)→ IDE 收到 update。

边界与失败

  • stdout 污染防御:routeLogsToStderr() 是第一条语句(routeLogsToStderr:43)。任何走 console.log 的依赖都必须改路由到 stderr,否则日志行会被 ACP SDK 当作协议帧解析,导致连接崩溃。这种坑在第三方 plugin 里很常见。
  • protocolVersion 兼容性 hack:normalizeAcpInitializeProtocolVersion(normalizeAcpInitializeProtocolVersion:175)只对 initialize 消息的 protocolVersion 字段做强制规范——ACP SDK 0.22 在 schema 校验前会拒绝非 uint16 值,而某些编辑器(尤其是 MCP 兼容层)会传日期字符串。这里在帧进入 SDK 之前先规范化,避免 IDE 一握手就被 SDK 报错。
  • disconnectTimer 与 reconnect:handleGatewayDisconnect(handleGatewayDisconnect:339)会启动一个 disconnectTimer,disconnect 期间所有 pendingPrompt 既不 resolve 也不 reject——reconcilePendingPromptshandleGatewayReconnect 时按 generation 过滤,只重投还在等且 generation 匹配的 prompt。这避免了"gateway 断了 5 秒,所有 prompt 同时报错给 IDE"的灾难。
  • 三层 ledger replay:loadSession(loadSession:429)按顺序尝试:exact-by-sessionId → listed-by-sessionKey → fallback。前两层都不 complete 时,意味着 sessionId 在 ledger 里不存在或不完整(可能是文件 ledger 时代的旧 session,或 SQLite 迁移中被截断),会触发一次新的 readLedgerReplaysessionId + sessionKey 联合查。任何一层 complete 就返回,保证 IDE 重开能尽量恢复状态。
  • ledger 容量上限:DEFAULT_MAX_SESSIONS = 200DEFAULT_MAX_EVENTS_PER_SESSION = 5_000DEFAULT_MAX_SERIALIZED_BYTES = 16 MB(ledger 上限:18-20)。超限会逐出最老 session 或截断事件流——这避免了长期运行的 OpenClaw 把 ledger 表写爆。文件 ledger 还有 withFileLock 8 次指数退避(retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000)和 15s stale 检测,迁移到 SQLite 后这些路径只用于历史数据。
  • sessionCreateRateLimiter:newSession/loadSession(未知 session 时)都会调 enforceSessionCreateRateLimit(enforceSessionCreateRateLimit:399),默认固定窗口限流。这防 IDE 因为 bug 或脚本疯狂 newSession 打爆 Gateway 的 session store。
  • MAX_PROMPT_BYTES 双重防御:extractTextFromPrompt 在块级别拒绝超大内容,组装成完整 message 后再 Buffer.byteLength 复核。两层独立检查,即使块级别忘了 limit,组装后也能拦住(CWE-400)。
  • ACP 与 OpenClaw agent 解耦:AcpGatewayAgent 只实现 ACP SDK 的 Agent 接口(implements Agent:250),所有"思考"都在 gateway 后端的 agent 主循环里。ACP 进程崩了不会丢 agent 状态——session/run 记录都在 gateway 里,IDE 重连后 loadSession 能 replay 回来。

小结

ACP 把 OpenClaw 接入到 IDE 的标准 agent 协议,自己只做翻译——stdio ndjson ↔ Gateway WebSocket,ACP 方法 ↔ chat run,ACP SessionUpdate ↔ gateway 事件帧。serveAcpGateway 是进程入口(先建 GatewayClient 等 hello 再开 ACP),AcpGatewayAgent 是协议翻译器,AcpEventLedger 是 session 重放的 SQLite 持久层。Gateway 那一侧怎么处理 chat/run 请求看 Gateway 核心,真实跑 agent 主循环的细节看 Agent 主循环,ACP 透传的工具调用如何执行看 Capabilities/Tools。ACP 触发的 detached prompt 在 Tasks 里以 runtime: "acp" 出现,可以享受 task 的统一取消/delivery/重放语义。