ACP 会话生成
ACP(Agent Client Protocol)是 OpenClaw 与外部智能体编排框架之间的双向接口约定。核心实现集中在 src/agents/acp-spawn.ts——这是一个长达 1708 行的文件,负责把一次 ACP 会话从「策略校验」一路推进到「子进程派生」与「父子流接续」。
OpenClaw 在 ACP 这条协议上同时站在两端。一端是被集成方:它可以作为子代理被外部 harness(例如 Claude Code、Codex 这类宿主)拉起,对外暴露符合 ACP 约定的会话能力,宿主通过协议向它下达任务、接收增量输出。另一端是编排方:OpenClaw 自身也能把外部 ACP harness 当作子代理来调度。
作为编排方时,它并不是在自己进程内「内联」运行外部 harness,而是派生出一个独立子进程来承载该 harness,再让这个子进程反连(reverse-connect,「反连」)回父进程的控制面。这种「父派生、子反连」的拓扑把外部代理彻底隔离在独立进程里,既划清了崩溃域,也让父进程能以统一的会话句柄来管理任意数量的异构子代理。
acp-spawn.ts 正是承接这套拓扑的枢纽:它先经过策略闸门判定本次派生是否被允许,再向会话管理器登记句柄,随后派生子进程并建立 stdout 接续;任何一步失败都会触发回滚清理,避免资源泄漏或留下孤儿进程。
会话生成
校验策略后向会话管理器登记句柄,派生独立子进程承载外部 ACP harness,完成一次会话的冷启动。
父子 relay
子进程的 stdout 经 parent-stream 接续回父进程的会话流,让外部代理的增量输出在父侧统一可见。
派生限制
以最大子会话数与最大派生深度双重约束扇出与递归,并在父进程崩溃后回收孤儿子运行。
导入链与 acp-core
acp-spawn.ts 的导入头部就是它的依赖地图:会话管理、失败清理、策略闸门、会话元数据、以及来自独立包 @openclaw/acp-core 的标识符解析与运行时类型,共同拼出整条派生链。
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import { cleanupFailedAcpSpawn, type AcpSpawnRuntimeCloseHandle } from "../acp/control-plane/spawn.js";
import { isAcpEnabledByPolicy, resolveAcpAgentPolicyError } from "../acp/policy.js";
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
import { resolveAcpSessionCwd, resolveAcpThreadSessionDetailLines } from "@openclaw/acp-core/runtime/session-identifiers";
import type { AcpRuntimeSessionMode } from "@openclaw/acp-core/runtime/types";
这条导入链横跨两个边界。前四行落在仓库内部的 src/acp/ 子树——控制面(control-plane)、策略(policy)与运行时(runtime);后两行则越过包边界,指向独立发布的 @openclaw/acp-core。
@openclaw/acp-core 是一个独立包,把 ACP 协议中与具体宿主无关的「纯逻辑」抽离出来:会话标识符解析(session identifier resolution)、运行时类型定义(如 AcpRuntimeSessionMode),以及会话元数据的读写(session-meta.js)。把这些下沉到独立包,既让被集成方与编排方共享同一份标识符语义,也避免了仓库内部代码与协议核心相互纠缠。
会话管理器
getAcpSessionManager() 返回进程级的会话管理器,负责对每个 ACP 会话句柄做登记、查找与回收,是父侧统一管理子代理的中枢。
失败清理
cleanupFailedAcpSpawn() 在派生中途失败时回滚已分配的资源;AcpSpawnRuntimeCloseHandle 是对应的关闭句柄类型。
策略闸门
isAcpEnabledByPolicy() 判定某代理是否被允许使用 ACP;resolveAcpAgentPolicyError() 在被拒绝时给出具体的拒绝原因。
会话生成流程
一次 ACP 会话的冷启动是一条线性流水线:从策略校验、句柄登记,到子进程派生,再到反连建立父子流。每一阶段都依赖上一阶段的产物,任一环节失败都会触发回滚。
第一步 · 策略校验。派生开始前,acp-spawn.ts 先调用 isAcpEnabledByPolicy() 判定发起方代理是否被允许使用 ACP。若不被允许,则用 resolveAcpAgentPolicyError() 取出具体拒绝原因并提前终止——这一步是整条链的闸门,杜绝未授权的派生。
第二步 · 登记句柄。通过校验后,调用 getAcpSessionManager() 取得进程级会话管理器,并在其中登记本次会话的句柄。此后子进程的查找、状态更新与最终回收都以这个句柄为索引。
第三步 · 派生子进程。会话管理器就位后,派生出独立的子进程来承载外部 ACP harness。resolveAcpSessionCwd() 在此前确定子进程的工作目录,AcpRuntimeSessionMode 决定其运行时模式。
第四步 · 反连接续。子进程启动后反连回父进程,由 parent-stream 把子进程的 stdout 接续进父侧的会话流,至此一次会话冷启动完成,外部代理的增量输出开始在父侧可见。
cleanupFailedAcpSpawn() 回滚已分配的资源,确保不残留半成品会话或孤儿进程。父子进程 stdout relay
外部 ACP harness 运行在子进程里,它的 stdout 不会被父进程直接消费,而是经由 acp-spawn-parent-stream.ts 接续进父进程的会话流——这一层 relay 是「父派生、子反连」拓扑能统一可见的关键。
子进程并不知道父侧会话流的内部结构,它只管把增量输出写到自己的 stdout。relay 层负责消费这条 stdout,逐段解析后写回父进程对应的会话流。会话的标识与定位则下沉到 @openclaw/acp-core 一侧:resolveAcpThreadSessionDetailLines() 解析线程会话的明细行,readAcpSessionMeta() 读取会话元数据,二者共同把一段裸输出还原成「属于哪个会话、处于什么状态」的结构化信息。resolveAcpSessionCwd() 则负责解析子进程的工作目录。
| relay 组件 | 职责 |
|---|---|
acp-spawn-parent-stream.ts | 消费子进程 stdout,逐段接续回父进程会话流,是父子之间的数据通道。 |
resolveAcpThreadSessionDetailLines() | 解析线程会话的明细行,定位输出所属的会话上下文。acp-core 侧 |
readAcpSessionMeta() | 读取会话元数据(session-meta.js),还原会话的状态与身份。acp-core 侧 |
resolveAcpSessionCwd() | 解析子进程的工作目录,确保 harness 在正确的路径下运行。 |
派生限制与孤儿回收
允许子代理派生子代理,就必须防止它失控地无限繁殖。OpenClaw 在 src/config/agent-limits.ts 里用两条默认限制——扇出宽度与递归深度——共同框住派生规模,并在父进程崩溃后回收孤儿子运行。
const DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT = ...; // 每个 agent 最大子会话数
const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = ...; // 最大 spawn 深度(防止嵌套失控)
两条限制管的是两个不同维度。MAX_CHILDREN_PER_AGENT 限制扇出宽度——单个 agent 同时拥有的子会话数量上限;MAX_SPAWN_DEPTH 限制递归深度——子代理派生子代理、再派生孙代理的嵌套层数上限。前者防「横向爆炸」,后者防「纵向失控」,二者相乘才是一棵派生树的规模上界,缺一则约束就会被另一维度绕过。
扇出宽度
限定单个 agent 同时持有的子会话数量。超出上限的派生请求会被拒绝,防止某个代理一次性拉起过多子进程。
递归深度
限定派生链的嵌套层数。子代理派生子代理时深度逐级累加,触顶即停,杜绝无限递归式的自我繁殖。
reconcileOrphanedRun() 负责在重新对账时识别并回收这些孤儿子运行——释放其占用的句柄与进程资源,让会话状态重新收敛到一致。