夜雨聆风学习资料网

ARTICLE · 1036243

DeepSeek Harness 源码-ACP 自动化协议

DeepSeek Harness 源码-ACP 自动化协议

DeepSeek Harness 源码解析系列

第 36 讲:ACP 自动化协议

基于 DeepSeek Harness 源码 · 2026-09-19

💡 本讲一句话:ACP(Agent Client Protocol)是 harness 的"自动化门面"——程序化客户端通过 stdin/stdout 上的 JSON-RPC 创建全新 agent、发文本 prompt、收已提交的 assistant 文本、按策略回答一次性权限请求;读完你能看懂为什么 stdout 被独占给协议帧、prompt 为什么要等整个 agent idle 才结算、以及拆除时为什么必须先 drain 子代理森林再释放顶层 agent。

一、ACP 是什么:和 SDK 的分工

上一讲我们看了 harness 自己的 SDK——那是"自家客户端驱动自家 runtime"。而 ACP(Agent Client Protocol,agentclientprotocol.com)是开放协议:任何实现了 Agent 侧的进程都能被任意 Client 驱动。DeepSeek Harness 在 packages/acp/acp/ 下实现了一个"仅面向自动化"的 Agent 侧服务器,模块注释(index.ts 第 1-9 行)把边界划得非常死:

🔹 桥接层只带四样东西:prompt 文本、已提交的 assistant 文本、取消、一次性权限决策。展示与人类交互(编辑器导航、transcript 回放、命令、模式选择器、提问)全部留在 harness 的 UI 模块里——README.zh.md 原话:"此包是传输适配器,而非 UI 集成或能力 seam"。

仓库里的主要客户端是 dsh-subagent-acp:父 harness 通过它 spawn 一个子进程 ACP agent,把"委托子代理"这件事跨进程化。整个包只有三个源文件——index.ts(436 行)、codec.ts(66 行)、invariant.ts(30 行),表面积小得惊人,但每一处都有明确的取舍。

文件
职责
关键内容
src/index.ts(436 行)
桥接层主体:cordis 函数插件,把 ctx.agents 暴露成 ACP Agent
会话记录、事件路由、prompt 生命周期、权限桥、quiesce 拆除
src/codec.ts(66 行)
纯翻译层:harness 生命周期 ↔ ACP wire 词汇表
turnEndToStopReason、acpPromptToText、promptHasUnsupportedContent
src/invariant.ts(30 行)
包级 invariant 伴生插件(仓库强制每个包都有)
空安装器 + "No runtime invariant" 理由:传输层不拥有持久事件流

还有一个容易被忽略的硬约束:stdout 专用于协议帧。demo 应用(packages/examples/acp-demo)的诊断全部走 stderr,README 甚至把"兄弟插件可能污染 stdout"列为已知限制——因为一旦有非协议字节混进 stdout,JSON-RPC 管道就废了。

二、插件骨架:一个注入点 + 两个错误助手

先看 index.ts 的头部(第 42-77 行)。这是一个标准的 cordis 函数插件——命名导出 name / inject / Config / apply,没有默认导出(仓库 postmortem 0001 专门记录过:混用两种形态会让 Loader 丢掉函数插件的命名空间):

📄 packages/acp/acp/src/index.ts (第 42-77 行)

export const name = 'acp' // 插件名:cordis 组合图里的唯一标识/** The bridge creates and owns agents; every other concern is carried by the agent composition. */ // 注释:桥接层只负责"创建并拥有 agent",其余全部交给 agent 自身的组合export const inject = ['agents'] // 唯一的注入点——只需要 agent 工厂,其他服务一律不碰/** Preserve invalid-parameter detail in the SDK wire error message. */ // 注释:保留参数错误的细节到 wire 错误消息里function invalidParams(detail: string): RequestError { // 构造 JSON-RPC -32602(invalid params)错误,detail 会透传给客户端  return RequestError.invalidParams(undefined, detail) // 用 SDK 的工厂方法而不是手拼对象——保证错误码与形状符合协议} // 函数结束:参数类问题统一走这里,客户端能拿到可读原因/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ // 注释:保留失败 turn 的细节;普通 handler 异常会变成泛化的内部错误function internalError(detail: string): RequestError { // 构造 JSON-RPC -32603(internal error)错误,detail 同样透传  return RequestError.internalError(undefined, detail) // 与 invalidParams 对称:服务端自身故障走这个码} // 函数结束:两个助手把"谁的错"编码进 wire 状态码/** Plugin config: the provider/model selection used for each ACP-created agent. */ // 注释:插件配置——每个 ACP 创建的 agent 用哪个 provider/modelexport interface AcpConfig { // 配置接口:两个字段都可选,方便由别的监听器提供目标  /** Provider route for created agents. */ // provider 路由(如 deepseek-chat)  provider?: string // 可选:不配就沿用 agent 组合的默认路由  /** Model name for created agents. */ // 模型名  model?: string // 可选:不配就沿用默认模型} // 接口结束:demo 应用会同时要求两者必填(见 acp-demo README)export const Config: Schema<AcpConfig> = Schema.object({ // 用 schemastery 声明配置 schema,Loader 据此校验 cordis.yml  provider: Schema.string(), // provider 字段:字符串  model: Schema.string(), // model 字段:字符串) // schema 结束

为什么这样设计?inject = ['agents'] 是刻意的最小依赖:桥接层不注入 session、tools、approval 任何服务——它只跟 agent 工厂 + ctx 事件流打交道,所有能力都藏在 agent 的组合里。这样 ACP 包可以挂到任意"有 agents 服务"的 context 上(demo bundle、subagent-acp spawn 的子进程),而不需要知道那个组合长什么样。两个错误助手则把"参数错 vs 内部错"编码进 JSON-RPC 状态码,客户端程序可以据此区分重试策略。

三、会话记录:每个 session 一个 inflight 槽位

apply() 内部(第 105-143 行)先搭好状态骨架。核心是一张 Map<SessionId, SessionRecord>——一个 ACP 连接可以拥有多个会话,每个会话一条记录:

📄 packages/acp/acp/src/index.ts (第 84-98 行)

/** Per-session protocol state. */ // 注释:每个会话的协议状态——桥接层为它创建的 agent 记的账interface SessionRecord { // 一条记录 = 一个 ACP session 的全部私有状态  agent: Agent // 本会话拥有的那个 harness agent(对象引用,后面要做同一性校验)  /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ // 注释:精确的拥有者拆除器;registry/loop/session 全部拆完才 resolve  dispose: () => Promise<void> // 闭包住 agents.create() 返回的 handle.dispose——只有桥接层能拆它  /** In-flight prompt and its captured turn number for exact settlement. */ // 注释:在途 prompt + 捕获到的轮次号,用于精确结算  inflight: { // 单槽位设计:同一会话同时只允许一个 prompt(prompt() 里会检查)    resolve: (reason: StopReason) => void // 正常结算入口:带 ACP stop reason    reject: (error: Error) => void // 异常结算入口:turn 失败时立即拒绝    messageId: string // 入队消息的 id——用来和 agent/inbox/claimed 事件做关联    turn: number | undefined // 该消息实际触发的轮次号;undefined = 还没被认领    /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ // 注释:关联轮次的结束原因,turn/end 时记下、整个 agent idle 时才结算    endReason: TurnEndReason | undefined // 先记后结——因为 idle 前还可能有 steering/注入的后续轮次} | undefined // undefined = 当前没有在途 prompt(会话空闲)

为什么这样设计?注意 inflight 是单槽位而不是队列:ACP prompt 的语义是"一次完整的委托任务",并发 prompt 会让 stopReason 归属变得不可解释——所以 prompt() 里直接拒绝第二个在途请求(第六节细讲)。而 messageId → turn 的关联字段,是为了解决一个时序问题:消息入队后,agent 什么时候真正开始处理它、算第几轮?这要靠 agent/inbox/claimed 事件回填(第七节)。

apply() 开头还有三行防御性基础设施(第 105-128 行),值得单独看:

📄 packages/acp/acp/src/index.ts (第 105-128 行)

export function apply(ctx: Context, config: AcpConfig): void { // 插件入口:挂载自动化 ACP 服务器  // ACP handlers execute outside this plugin's injection scope, so capture the  // injected service during apply rather than reading it lazily in a callback.  const agents = ctx.agents // 在 apply 阶段就把注入的 agent 工厂抓进闭包——handler 执行时已不在注入作用域内,懒读会拿不到  const logger = ctx.logger // 日志器同样提前捕获  const sessions = new Map<SessionId, SessionRecord>() // 会话表:ACP sessionId → 本桥接层拥有的记录  let closed = false // 拆除开关:一旦置位,newSession/prompt 全部拒绝(assertOpen)  let conn: AgentSideConnection // ACP SDK 连接句柄;makeAgent 里赋值,notify/权限请求要用它回写客户端  /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ // 注释:按 agent 反查记录,同时拒绝"同 id 冒名者"  const ownedRecord = (agent: Agent): SessionRecord | undefined => { // 事件里带的是 agent 对象——必须确认它真是本桥接层创建的那个    const record = sessions.get(agent.session.id) // 先按 session id 查表    return record?.agent === agent ? record : undefined // 再比对对象同一性:id 相同但对象不同(比如别的入口创建的)→ 视为不存在} // 函数结束:这是"桥接层只响应自己拥有的会话"的守门员  const assertOpen = (): void => { // 拆除检查:closed 之后一切新工作都拒绝    if (closed) throw internalError('the ACP bridge has been disposed') // 抛 wire 内部错误,客户端能明确知道桥已死} // 函数结束  const requireSession = (sessionId: SessionId): SessionRecord => { // prompt/cancel 的会话查找:找不到就是参数错    const record = sessions.get(sessionId) // 查表    if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`) // 未知 id → -32602,把 id 带回去方便客户端排查    return record // 命中则返回记录} // 函数结束

为什么这样设计?ownedRecord() 的 record?.agent === agent 是整篇文章最容易被忽略的一行。事件流(session/event、approval/request)里带的是 agent 对象,而 session id 理论上可能被别的入口复用——只做 sessions.get(id) 就可能把"别人家的会话事件"误路由到本桥接层的 wire 上。对象同一性比对让桥接层只响应自己创建的 agent,这是多前端共享一个 Context 时(demo bundle 里 ACP + 其他入口并存)的隔离底线。

四、事件路由:只把"已提交文本"送上 wire

桥接层最核心的监听器挂在 session/event 上(第 152-196 行)。它做两件事:把已提交的 assistant 文本转成 agent_message_chunk 通知推给客户端;在关联轮次结束时记录 endReason:

📄 packages/acp/acp/src/index.ts (第 152-196 行)

// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,// titles, and retry markers are presentation or trace data and stay off the// automation wire.ctx.on('session/event', (session, event: SessionEvent) => { // 监听会话日志事件流——这是 harness 的权威事件源(第7讲分析过)  const record = sessions.get(session.header.id) // 按事件里的 session id 查本桥接层的记录  if (record === undefined || record.agent.session !== session) return // 不是自己拥有的会话 → 直接忽略,绝不转发别人的流量  try { // 进入"文本转发"分支;finally 里还要做结算检查    if (event.type === 'assistant/message') { // 只关心已提交的 assistant 消息(committed),delta-chunk 等中间态不处理      for (const block of event.data.message.content) { // 遍历消息内容块:text / image / tool_use…        if (block.type === 'text' && block.text.length > 0) { // 非空文本块 → 原样上 wire          notify({ // 发一条 session/update 通知(notify 内部吞掉传输错误,见下)            sessionId: record.agent.session.id, // 带上 ACP 会话 id            update: { // 更新载荷              sessionUpdate: 'agent_message_chunk', // ACP 词汇:assistant 消息的一个片段              content: { type: 'text', text: block.text }, // 文本内容原样转发——不做任何改写            }, // 载荷结束          }) // notify 调用结束} else if (block.type === 'image') { // 图片块:ACP 基线不支持内联图,降级成占位文本          notify({ // 同样走 agent_message_chunk            sessionId: record.agent.session.id, // 会话 id            update: { // 载荷              sessionUpdate: 'agent_message_chunk', // 片段类型不变              content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` }, // 渲染成 [image attachment <id>]——客户端至少知道"这里有过一张图"            }, // 载荷结束          }) // notify 调用结束} // else-if 分支结束:其他块类型(tool_use/thinking…)一律不上 wire} // for 循环结束} // if (assistant/message) 结束} finally { // 无论转发成功与否,都要做结算检查——错误不能吞掉 turn/end 信号      const inflight = record.inflight // 取当前在途槽位(可能没有)      if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { // 三条件:有在途 prompt + 这是 turn/end + 轮次号正是我们关联的那个        if (event.data.reason.kind === 'error') { // 模型/工具失败 → 立即拒绝,不等 idle          record.inflight = undefined // 先清槽位——防止 whenIdle 分支再结算一次(双结算防护)          rejectFromError(inflight, event.data.reason) // 用错误详情 reject prompt promise} else { // 非 error 结束 → 只记录,不结算          inflight.endReason = event.data.reason // 记下原因;真正的结算等整个 agent idle(第六节)} // if/else 结束} // turn/end 检查结束} // finally 结束}) // ctx.on 注册结束

为什么这样设计?这是 ACP 最重要的取舍:只转发已提交(committed)的 assistant 文本,牺牲逐 token 低延迟换干净的自动化结果。未提交的 provider 分片、重试尝试不会泄漏半截答案;reasoning、工具活动、计划、标题全部留在会话日志里供其他界面观测——wire 上只有"最终会出现在对话里的话"。图片降级成 [image attachment id] 占位符而不是丢弃,是因为 initialize() 里明确声明了 promptCapabilities.image = false——能力没公布,内容就不能悄悄消失。

再看 notify()(第 130-136 行):它把 conn.sessionUpdate() 的失败 catch 掉只打 warn。为什么?因为通知是单向的——客户端断连时,转发失败不应该反过来让 agent 的 turn 崩掉;agent 继续跑完、结果落进会话日志,才是正确行为。

五、prompt 生命周期:入队前四道闸 + idle 结算

session/prompt 是 ACP 最复杂的方法(第 277-336 行)。先看入队前的四道校验闸:

📄 packages/acp/acp/src/index.ts (第 277-316 行)

async prompt(params: PromptRequest): Promise<PromptResponse> { // ACP 核心方法:提交一条 prompt,等整个 agent idle 后返回 stopReason        assertOpen() // 闸0:桥接层已拆除 → 直接内部错误(第五节讲过)        const record = requireSession(SessionId(params.sessionId)) // 闸1:会话必须存在且归本桥接层所有,否则 -32602        if (record.inflight !== undefined) { // 闸2:单槽位——同一会话已有在途 prompt          throw invalidParams('a prompt is already in flight for this session') // 拒绝并发委托:stopReason 归属必须唯一可解释} // 闸2结束        if (promptHasUnsupportedContent(params.prompt)) { // 闸3:内容类型检查(codec.ts,第八节细讲)          throw invalidParams('only text and resource_link prompt content is supported') // 超出基线(image/audio/embedded)→ 拒绝而不是静默丢弃} // 闸3结束        const text = acpPromptToText(params.prompt) // 把 ACP 内容块拍平成纯文本:text 原样拼接,resource_link 渲染成方括号引用        if (text.trim().length === 0) throw invalidParams('empty prompt') // 闸4:空 prompt 拒绝——避免给 agent 喂一条无意义消息        // Not driving a retired agent is this bridge's contract: an        // agent-loop-only reload disposes the loop's agents while the bridge        // record survives, so validate the record against the live registry        // before sending — a disposed machine would accept the item silently.        if (ctx.agents.get(record.agent.id) !== record.agent) { // 闸5(最隐蔽):拿记录里的 agent 去活注册表里对——对象必须还是同一个          throw internalError('prompt was not queued: the agent was disposed outside the bridge') // 若 agent-loop 重载已把旧 agent 拆掉而桥接层记录还在,disposed 的机器会"静默收下"消息——这里 fail loud} // 闸5结束:五道闸全过才允许入队        const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) // 拍平后的文本变成一条标准用户消息(第12讲分析过的 LLM 消息结构)        const stopReason = await new Promise<StopReason>((resolve, reject) => { // prompt 的"结算 promise":谁 resolve/reject,stopReason 就是什么          // Arm the slot before followup() so a listener-driven synchronous          // turn cannot slip past correlation;          const inflight: NonNullable<SessionRecord['inflight']> = { // 构造在途槽位:resolve/reject 直接闭包住外层 promise            resolve, reject, messageId: message.id, turn: undefined, endReason: undefined, // messageId 用于 claimed 关联;turn/endReason 先留空} // 槽位对象结束          record.inflight = inflight // 关键顺序:先占槽再 followup()——同步触发的轮次不可能绕过关联检查          try { // 入队尝试            record.agent.followup(message) // 把用户消息塞进 agent 收件箱(第9讲分析过的 agent loop 入口)} catch (error: unknown) { // followup() 同步抛错(如非法输入)→ 必须释放槽位,否则会话永远"在途"            record.inflight = undefined // 清槽            const detail = error instanceof Error ? error.message : String(error) // 提取可读错误详情            throw internalError(`prompt was not queued: ${detail}`) // 抛给外层 → prompt() reject,客户端拿到原因} // catch 结束

为什么这样设计?五道闸里最容易被跳过的是闸5(注册表同一性校验)。场景:agent-loop 插件热重载时,会拆掉旧 loop 的 agent,但 ACP 桥接层的 sessions Map 里记录还在。此时对已 disposed 的 agent 调 followup(),消息会被静默吞掉——客户端拿到一个"成功入队"的假象。用 ctx.agents.get(id) !== record.agent 对活注册表做对象比对,把这种静默失败变成显式错误。

然后是结算逻辑(第 317-334 行)——ACP prompt 的"完成"定义比直觉更严格:

📄 packages/acp/acp/src/index.ts (第 317-336 行)

          // Settlement waits for whole-agent idle: a correlated turn/end arms          // `endReason`, while a turnless slot (admission discarded the          // prompt) stays cancelled. Other producers may run further turns          // before quiescence; the prompt settles only when the agent stops.          void record.agent.whenIdle().then(() => { // 等整个 agent 进入空闲(不是单轮结束!)——idle 前还可能有 steering/注入的后续轮次参与本次委托            if (record.inflight !== inflight) return // 槽位已被别人结算过(error 路径/cancel/quiesce)→ 幂等防护,绝不二次 resolve            record.inflight = undefined // 清槽:会话回到空闲态            const end = inflight.endReason // 取关联轮次记下的结束原因(可能没有)            if (end === undefined) { // 没有任何关联轮次 → 准入阶段就把 prompt 丢了(admission discarded)              inflight.resolve('cancelled') // 结算为 cancelled:客户端知道"没跑"} else { // 有关联轮次的结束原因              // Token-limit and other non-terminal endings are not prompt-level              // stop reasons (see README); only normal quiescence reports end_turn.              inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) // max-tokens 特判成 end_turn(token 上限不是"提示词级停止原因");其余走 codec 映射} // if/else 结束          }) // whenIdle 链结束——注意是 void:结算完全由事件驱动,prompt() 只 await 外层 promise        }) // new Promise 构造器结束        return { stopReason } // ACP 要求每个 prompt 响应都带 stopReason(end_turn/cancelled/max_tokens/refusal…)} // prompt() 方法结束

为什么这样设计?"等整个 agent idle"而不是"等关联轮次 turn/end",是 README 里专门解释过的语义:ACP prompt 的 stopReason 不声称表示提示词专属的轮次结果——已提交的 assistant 消息会在整个自有活动期间流式输出,idle 前发生的 steering(中途引导)或注入工作也可能参与其中。所以结算点选在"agent 彻底停稳"这个更晚的时刻;而 token 上限这种非终态结束被特判成 end_turn,因为对自动化客户端来说它等价于"正常跑完了一轮"。反过来,模型错误走的是另一条路:turn/end 的 error 分支立即 reject(第四节),不等 idle——失败要尽快暴露。

结束场景
结算路径
客户端看到
正常完成(completed)
turn/end 记 endReason → whenIdle 结算
stopReason = end_turn
token 上限(max-tokens)
同上,但特判映射
stopReason = end_turn(不是 max_tokens)
模型/工具错误(error)
turn/end error 分支立即 reject,不等 idle
JSON-RPC internal error(带错误详情)
客户端显式取消 / 桥接层拆除
cancel()/quiesce() 直接 settlePrompt
stopReason = cancelled
准入丢弃(无关联轮次)
whenIdle 时 endReason 仍为 undefined
stopReason = cancelled("没跑")

🔹 这张表浓缩了第五节的五条结算路径。注意 cancelled 在 ACP 词汇里是"保留字"——只给显式取消和拆除用;hook 中止(aborted)反而映射成 end_turn,因为对客户端来说那只是"agent 自己停了"。这个区分在 codec.ts 里(第八节)。

六、权限桥:一次性决策,绝不推断持久授权

harness 的工具执行有审批瀑布(第27讲分析过 interaction/approval)。ACP 客户端是机器——它怎么回答"允许吗?"桥接层在 approval/request 上挂了一个监听器(第 212-229 行),把审批请求反向转发给 ACP 客户端:

📄 packages/acp/acp/src/index.ts (第 212-229 行)

// Permission requests are a machine policy channel for ACP clients such as// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a// durable grant from an unknown client response.ctx.on('approval/request', (request, next) => { // 挂进审批瀑布:本桥接层是其中一级决策者(next() = 交给下一级)    const record = ownedRecord(request.agent) // 守门员再出场:只处理自己拥有的 agent 的审批请求    if (record === undefined || request.callId === undefined) return next() // 不是我的会话、或没有工具调用 id → 放行给瀑布下一级,不越权决策    return conn.requestPermission({ // 反向 RPC:向 ACP 客户端发起 session/request_permission(这是少数"服务端→客户端"的请求)      sessionId: record.agent.session.id, // 告诉客户端是哪个会话在要权限      toolCall: { toolCallId: request.callId }, // 带上工具调用 id——客户端可以据此做策略匹配      options: [ // 只给两个一次性选项:刻意不提供 allow_always(持久授权)        { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, // 允许这一次        { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, // 拒绝这一次      ], // options 结束    }).then(({ outcome }) => { // 客户端回答后,把 ACP 词汇翻译回 harness 审批词汇      if (outcome.outcome === 'cancelled') return 'cancelled' // 客户端没选(超时/断开)→ cancelled:工具按取消处理      return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected' // allow-once → allowed-once;其余一律 rejected——fail closed    }) // then 链结束:返回值交还给审批瀑布继续流转} // ctx.on 注册结束

为什么这样设计?两个安全细节。其一,只给一次性选项:options 里没有 allow_always——桥接层"绝不从未知客户端的回答里推断持久授权"(注释原话)。机器客户端答错一次不该污染后续所有调用。其二,fail closed:客户端没选(cancelled)映射成 'cancelled' 而不是默认允许;任何非 allow-once 的回答都落到 rejected。权限通道是"机器策略通道",不是信任通道。

客户端那一侧长什么样?仓库里的主要消费者 dsh-subagent-acp(packages/subagent/subagent-acp/src/run.ts 第 252-262 行)按配置策略自动回答:

📄 packages/subagent/subagent-acp/src/run.ts (第 252-262 行)

requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { // 客户端侧的权限回答器:由 spawn 配置里的 permission 策略驱动      // Auto-answer by the configured policy. `allow` selects the first option      // whose kind is `allow_once` or `allow_always`; if the child offered none (or we      // reject), answer `cancelled` so the child does not proceed.      if (spec.permission === 'allow') { // 策略为 allow → 在子进程给的选项里找第一个"允许类"        const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always') // 按 kind 匹配(不是 optionId)——对服务端措辞变化免疫        if (allow !== undefined) { // 找到了 → 选它          return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } }) // 回传 selected + optionId,子进程侧翻译成 allowed-once} // 没找到允许类选项 → 落到下面的 cancelled} // if (allow) 结束:策略为 reject 时直接跳过整个分支      return Promise.resolve({ outcome: { outcome: 'cancelled' } }) // 兜底一律 cancelled——子进程不会"默认放行"} // requestPermission 结束

为什么这样设计?注意客户端按 kind(allow_once/allow_always)匹配而不是按 optionId——optionId 是服务端自定义的字符串,kind 才是协议词汇。两端都 fail closed:服务端不给持久选项、客户端找不到允许项就 cancelled,任何一环含糊都不会变成"默认放行"。

七、codec:两套词汇表的翻译层

harness 的 TurnEndReason(completed/max-tokens/aborted/interrupted/blocked/error)和 ACP 的 StopReason(end_turn/max_tokens/refusal/cancelled/max_turn_requests…)是两套独立演化的词汇表。codec.ts(66 行)就是纯翻译层,第一个函数 turnEndToStopReason()(第 14-34 行):

📄 packages/acp/acp/src/codec.ts (第 14-34 行)

export function turnEndToStopReason(reason: TurnEndReason): StopReason { // 把 harness 轮次结束原因映射到 ACP 终态词汇  switch (reason.kind) { // 按 kind 分支——TurnEndReason 是封闭联合,每个成员都要有去处    case 'completed': // 正常完成      return 'end_turn' // → ACP 的"轮次结束"    case 'max-tokens': // token 上限      return 'max_tokens' // → ACP 同名概念(注意:prompt() 里会特判成 end_turn,见第五节)    // `cancelled` is reserved for explicit client cancellation (`session/cancel`)    // and disposal, both settled out of band; a turn aborted by a hook or    // another owner is ordinary quiescence and reports `end_turn`. // 注释:cancelled 是保留字,只给显式取消/拆除(带外结算);hook 中止算普通停稳    case 'aborted': // 被 hook 或其他拥有者中止      return 'end_turn' // → 对客户端就是"agent 停了",不占用 cancelled 语义    case 'interrupted': // 被打断(如用户中断)      return 'cancelled' // → 这是 harness 侧最接近"取消"的终态    case 'blocked': // 被策略拦截    case 'error': // 出错(prompt() 里 error 走 reject 路径,这里是兜底映射)      return 'end_turn' // → 保守映射:不向客户端谎报成功细节    /* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */ // 覆盖率豁免:封闭联合已全部处理    default: // 防御性兜底(理论上不可达)      return 'end_turn' // → 未知终态按"停稳"上报,绝不抛异常打断结算链} // switch 结束

为什么这样设计?映射表里最反直觉的是 aborted → end_turn。注释解释得很清楚:cancelled 在 ACP 里是保留字,只给"客户端显式取消(session/cancel)和桥接层拆除"这两种带外结算用;而 hook 中止的轮次对客户端来说只是"agent 自己停了"——如果也报 cancelled,客户端就无法区分"是我取消的"还是"它自己停的"。语义精确性优先于词汇直觉。

另外两个函数处理 prompt 内容(第 43-66 行):

📄 packages/acp/acp/src/codec.ts (第 43-66 行)

export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { // 把 ACP prompt 的基线内容块拍平成纯文本  return prompt.flatMap((block): string[] => { // flatMap:每个块展开成 0..n 段文本,最后拼接——保持 wire 顺序    switch (block.type) { // 按块类型分支      case 'text': // 文本块        return [block.text] // → 原样保留(单元素数组)      case 'resource_link': // 资源链接:ACP 基线要求所有 agent 必须接受它        return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`] // → 渲染成显式方括号引用——客户端可以"指文件",桥接层不会悄悄丢掉这个上下文(模型可用自己的工具打开它)      default: // 其他类型(image/audio/embedded resource…)        return [] // → 展开为空:但注意 prompt() 里会先被 promptHasUnsupportedContent 拒绝,这里只是双保险} // switch 结束  }).join('') // 所有片段按序拼接成最终文本} // acpPromptToText 结束export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean { // 检查 prompt 是否携带超出 ACP 基线的内容  return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link') // 只要有一个块不是 text/resource_link → true:image/audio/embedded 是可选能力,本桥接层没公布(initialize 里 image:false),所以拒绝而不是静默丢弃} // promptHasUnsupportedContent 结束

为什么这样设计?"拒绝而非静默丢弃"是 ACP 桥接层反复出现的原则(对照第五节的五道闸)。resource_link 被渲染成 [resource_link name=… uri=…] 文本引用而不是去抓取内容——桥接层不做 I/O,模型拿到引用后可以用自己的工具打开。这保持了"传输适配器不碰能力"的边界。

八、拆除:quiesce 的顺序学问

客户端断开(stdin EOF)或 Cordis context 释放,都会触发同一个记忆化的 quiesce()(第 355-414 行)。这段代码的顺序每一步都有理由:

📄 packages/acp/acp/src/index.ts (第 355-401 行)

  let quiescing: Promise<void> | undefined // 记忆化槽:并发触发(断开 + context dispose)只跑一次拆除  const quiesce = (): Promise<void> => { // 拆除入口:返回同一个 promise,调用方都能 await 到最终结果    if (quiescing !== undefined) return quiescing // 已在拆 → 直接复用(幂等)    closed = true // 第一步:先关门——newSession/prompt 立刻开始拒绝(assertOpen/requireSession 都会炸)    const records = [...sessions.values()] // 快照当前所有会话记录    sessions.clear() // 清空表:后续事件路由查不到记录 → 自动静默,不再转发任何 wire 流量    // Stop the bridge's own work before any await: a descendant drain can block    // on persistence or scoped cleanup, and the top-level agents must not keep    // running model and tool calls for its whole duration.    for (const record of records) { // 第二步:在任何 await 之前,同步停掉所有顶层 agent      record.agent.cancel({ kind: 'user' }) // cancel 每个 agent——拆除期间不允许再跑模型调用和工具(drain 可能阻塞在持久化上)      settlePrompt(record, 'cancelled') // 结算所有在途 prompt 为 cancelled:客户端立刻拿到结果,不用等拆除完成} // for 结束:此刻桥接层已"静默"    quiescing = (async () => { // 第三步:异步拆除主体(记忆化)      // Continuable subagents outlive the turn that started them, and their      // Activations own descendant teardown. Drain only these sessions' forests      // child-first BEFORE disposing the top-level agents,      const subagents = ctx.get('subagents') as ContinuableDrain | undefined // 结构化读取子代理服务的唯一拆除方法(第51-57行定义的接口)——本包因此不依赖 subagent 包;服务不存在 = 没有可继续后代      if (subagents !== undefined) { // 有 → drain 这些会话森林里的可继续后代        try { // drain 可能失败(持久化阻塞等),不能炸掉整个拆除          await subagents.drainContinuableDescendants(records.map(record => record.agent)) // 关键顺序:child-first 拆完所有后代,再动顶层 agent——否则会有后代还握着"主人已释放的运行时"} catch (error: unknown) { // drain 失败 → 降级为警告          logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) // 记录后继续:顶层拆除仍要执行,不能因为后代清理失败就泄漏顶层 agent} // try/catch 结束} // if (subagents) 结束      const disposals = await Promise.allSettled(records.map(record => record.dispose())) // 第四步:并行释放所有顶层 agent handle(registry/loop/session 全拆)      const failures: unknown[] = [] // 收集失败项      for (const result of disposals) { // 遍历 allSettled 结果        if (result.status === 'rejected') failures.push(result.reason as unknown) // rejected → 记入失败列表(fulfilled 的跳过)} // for 结束      if (failures.length > 0) { // 有拆除失败 → 必须上报,不能静默吞掉        const detail = failures.map(failure => errorChain(failure)).join('; ') // 把每个失败的完整错误链(含嵌套 cause)拼进 message——因为生产消费者用 String() 渲染 AggregateError 时只显示 message        throw new AggregateError( // 聚合抛出:调用方(context dispose 链)能看到"几个会话拆失败 + 各自原因"          failures, // 原始错误对象列表          `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, // message 里内嵌全部诊断) // AggregateError 构造结束} // if (failures) 结束:全成功则静默返回    })() // async IIFE 结束,赋给 quiescing    return quiescing // 返回拆除 promise} // quiesce 函数结束

为什么这样设计?四步顺序是这段代码的灵魂:关门 → 停 agent → drain 后代 → 拆顶层。最容易犯的错误是先拆顶层:可继续子代理(continuable subagent)会活过启动它的那一轮,它的 Activation 拥有后代的拆除权——如果顶层先走,就会留下"还握着已释放运行时的孤儿后代",而共享同一 Context 的其他前端还在跑。所以必须 child-first drain 完森林再动顶层。

另一个细节是 ctx.get('subagents') as ContinuableDrain:桥接层只结构化地读取子代理服务的唯一拆除方法(第 51-57 行的接口),而不是 import 整个 subagent 包——"一个包不该为一条 shutdown hook 依赖另一个 seam"。服务不存在就当作没有可继续后代,优雅降级。

quiesce 的触发点有两处(第 403-414 行):连接关闭 promise 链 conn.closed.catch(...).then(quiesce),以及 ctx.effect(() => quiesce, 'acp.connection')——客户端断开和 Cordis 释放共用同一个记忆化流程(README:"仅 ACP 的插件重载不会遗留 agent")。effect 注册的是 disposer:context 销毁时 cordis 会调用它,而记忆化保证两条路径只拆一次。

九、客户端视角:subagent-acp 如何消费这个协议

前面都是服务端。仓库里最真实的 ACP 客户端是 dsh-subagent-acp——父 harness 用它把"委托子代理"跨进程化(run.ts,368 行)。它的 stopReason 反向映射(第 135-157 行)和服务端 codec 正好镜像:

📄 packages/subagent/subagent-acp/src/run.ts (第 135-157 行)

export function acpStopReason(reason: StopReason): SubagentStopReason { // 客户端侧反向映射:ACP 终态 → harness 子代理停止原因  switch (reason) { // ACP StopReason 是封闭 wire 联合,逐成员处理    case 'end_turn': // 正常结束      return 'completed' // → 子代理任务完成    case 'max_tokens': // token 上限      return 'max-tokens' // → harness 同名概念    case 'refusal': // 模型拒绝      return 'refusal' // → harness 同名概念    case 'cancelled': // 被取消(服务端第五节的带外结算路径)      return 'aborted' // → harness 的"中止"    // `max_turn_requests` (the child hit its turn-request budget) has no direct    // harness equivalent and means the task did NOT finish cleanly — surface it    // as a generic failure so the consumer maps it to an isError result rather    // than reporting a partial answer as success.    case 'max_turn_requests': // 子进程用完了轮次预算——任务没干净完成,但 harness 没有对应终态      return 'error' // → 故意映射成 error:消费者会把它当 isError 结果,而不是把"半成品答案"报成功(fail loud)} // case 结束    // ACP StopReason is a closed wire union, but a future SDK could add a    // variant; treat an unknown terminal reason as a failure (never silently 'completed').    default: // 未来 SDK 新增的未知终态      return 'error' // → 一律按失败处理——绝不静默报 completed(协议演化的安全网)} // switch 结束

为什么这样设计?客户端的映射哲学是"未知即失败":服务端 codec 对未知终态保守报 end_turn(不炸结算链),客户端却把一切非白名单终态都当 error——因为对父 harness 来说,"子代理没干净完成"比"误报成功"安全得多。两端各自在自己的边界上 fail safe,拼起来就是端到端的 fail loud。

客户端的进程管理也值得记一笔(run.ts 第 209-215、242-262 行):spawn 时 stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }——stdin/stdout 是协议管道,stderr 直接继承到父进程终端(诊断不污染 wire);客户端的 sessionUpdate handler 只消费 agent_message_chunk(fold.pushText 累积),thoughts/tool calls/plans 一律"消费但不呈现"——子代理只返回最终答案。这和服务端"只发已提交文本"的取舍严丝合缝。

协议方法
方向
行为(源码实测)
initialize
客户端→服务端
返回单版本 PROTOCOL_VERSION + agentInfo;只公布基线 prompt 能力(image/audio/embeddedContext 全 false),不公布会话/编辑器/MCP 能力
authenticate
客户端→服务端
空操作(authMethods: [])——服务器不公布任何认证方法
session/new
客户端→服务端
创建新 agent(randomUUID 会话 id);cwd 必须绝对路径,非空 additionalDirectories/mcpServers 直接拒绝
session/prompt
客户端→服务端
五道闸校验 → followup() 入队 → 等整个 agent idle 结算;单会话单在途;error 立即 reject,其余按 codec 映射
session/cancel
客户端→服务端(通知)
只取消指定 agent + settlePrompt('cancelled');未知 id 是空操作(不报错)
session/update
服务端→客户端(通知)
每个非空已提交文本块发一条 agent_message_chunk;图片降级为占位文本;reasoning/工具/计划不上 wire
session/request_permission
服务端→客户端(请求)
审批瀑布反向转发;只给 allow-once/reject-once 两个一次性选项;cancelled/非允许 → fail closed

🔹 这张表浓缩了全文的协议行为。七个方法里只有 request_permission 是"服务端→客户端"的请求——其余全是客户端发起。这个不对称正是 ACP 的定位:agent 是被驱动的,不是主动方(权限询问是唯一例外,因为决策权在客户端策略手里)。

十、数据流:一条 prompt 的完整旅程

把全文串起来——从客户端发出 session/prompt 到拿到 stopReason,中间经过的每个环节:

prompt 路径(session/prompt → stopReason)

① 入队:prompt() → 五道闸 → followup(message)

assertOpen/会话存在/单在途/内容基线/注册表同一性全过,文本拍平后入 agent 收件箱。

② 关联:agent/inbox/claimed → inflight.turn = turn

按 messageId 匹配在途槽位,回填轮次号——结算时只认这个轮次的 turn/end。

③ 执行:agent loop 跑模型 + 工具(审批走权限桥)

approval/request → conn.requestPermission → 客户端按策略答 allow-once/reject/cancelled。

④ 流式:session/event → agent_message_chunk

只转发已提交 assistant 文本块;图片降级占位符;reasoning/工具活动留在会话日志。

⑤ 结算:turn/end 记 endReason → whenIdle() → resolve(stopReason)

error 立即 reject;其余等整个 agent idle,max-tokens 特判 end_turn,无关联轮次报 cancelled。

🔹 拆除路径:conn.closed / ctx.effect → quiesce():关门 → cancel+settlePrompt('cancelled') → drainContinuableDescendants(child-first)→ Promise.allSettled(dispose) → AggregateError 上报

🔹 本讲带走三句话:① ACP 桥接层是"传输适配器"——stdout 独占协议帧,wire 上只有已提交文本 + 一次性权限,能力边界由 initialize() 的能力声明锁定;② prompt 结算点是"整个 agent idle"而非单轮结束,cancelled 是保留字只给显式取消/拆除;③ 拆除顺序是关门→停 agent→child-first drain 子代理森林→拆顶层,任何一步乱序都会留下孤儿后代或泄漏运行时。

📚 系列导航

← 第 35 讲:SDK(TypeScript + Python)

→ 第 37 讲:Web 客户端架构

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料