CHAPTER 5 / 5
Subagent Orchestration 子代理编排
概念与为什么
单个 agent loop 只有一条注意力流:读什么、改什么、验证什么都排在同一个 context 里。Subagent orchestration 把这条流拆成多个 worker,让 parent / coordinator 决定任务边界,再把结果收回来。它解决的不只是“更快”,而是三个结构性问题:
- Context isolation —— 探索日志不必污染主对话,不同 worker 可以各自保留与任务相关的 history。
- Capability specialization —— research worker、implementation worker、review worker 可以使用不同 model、tools 与 permission policy。
- Latency hiding —— 相互独立的工作可以并行;parent 在等待期间还能继续处理不重叠的任务。
但一旦 fan-out,原来隐含在单 loop 里的问题都会显形:child 何时算完成、谁能取消它、失败是否重试、并发上限在哪里、文件冲突怎样处理、多个答案由谁合并。一个实用的 orchestration layer 至少要回答下面这条 lifecycle:
flowchart LR
A[Parent / Coordinator] -->|dispatch task + policy| B[Child A]
A -->|dispatch task + policy| C[Child B]
B -->|result / error / progress| D[Fan-in boundary]
C -->|result / error / progress| D
D --> E[Parent synthesis]
A -. cancel / follow-up .-> B
A -. cancel / follow-up .-> C
B <--> F[(Shared files?)]
C <--> F
图里最关键的是最后一步没有画成 reduce()。三家 harness 都能创建 child、记录状态并回传结果,但在本章检查的路径中,语义拆分、依赖排序、冲突调解与最终 synthesis 主要仍由 parent model 承担;它们是 model-directed orchestration,不是 durable workflow engine。Claude Code 的 coordinator prompt 明确要求主模型 dispatch 与 synthesize;Codex V2 的 wait_agent 等的是 mailbox activity;OpenCode 的 task instruction 则规定每次 child 返回一条 message,由 parent 对用户重述。src/coordinator/coordinatorMode.ts:116-144; codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs:68; packages/opencode/src/tool/task.txt:13
三家实现对比
先把三家的 orchestration unit 放在同一张表里。这里比较的是 pinned revision 的主 subagent path,不是所有实验性 team、plugin 或 UI 功能。
| 维度 | Claude Code | Codex CLI MultiAgentV2 | OpenCode |
|---|---|---|---|
| 核心 unit | Agent tool 创建 sidechain worker;background 模式登记为 local_agent task。src/tools/AgentTool/AgentTool.tsx:81-101,686-764; src/tasks/LocalAgentTask/LocalAgentTask.tsx:116-148 | spawn_agent 创建可寻址 child thread,registry 保存 canonical AgentPath 与 lineage。spawn.rs:365; protocol.rs:2823 | task tool 创建或恢复带 parentID 的 persistent child session。task.ts:136; task.ts:156 |
| Lifecycle / state | background task 记录 running,最终转为 completed / failed / killed;结果、错误、progress 与 abort controller 都在 task state。src/tasks/LocalAgentTask/LocalAgentTask.tsx:116-147; src/tools/AgentTool/agentToolUtils.ts:597-681 | normal lifecycle states 包括 pending_init → running → interrupted / completed / errored / shutdown;enum 还用 not_found 表示查找不到 agent,而 completed 可携 final message。protocol.rs:1717 | foreground 默认等待 result;experimental background 先回 running,随后向 parent 注入 completed 或 error synthetic result。task.ts:216; task.ts:305 |
| Context isolation / fork | 普通 worker 不复制 parent messages,而是独立组装 system / user context;fork mode 才把过滤后的 parent messages 放在 prompt messages 前。普通 worker 的 read-file state 是 fresh cache,replacement state 则默认从 parent clone;child 另有独立 agent ID 与 query chain。src/tools/AgentTool/runAgent.ts:368-410; src/utils/forkedAgent.ts:345-455 | 可 fresh spawn,也可 full / last-N fork;fork 只保留 system/developer/user 与 final answer,丢掉 tool calls、reasoning 与 inter-agent messages。spawn.rs:47; spawn.rs:633 | fresh task 只把 supplied prompt 发到 child session;task_id 才复用 child history。通用 Session.fork 虽能复制 transcript,但 TaskTool 不调用它。task.ts:136; task.ts:200; session.ts:693 |
| Concurrency / depth bound | agent definition 可设 per-worker maxTurns;本路径未发现全局 numeric worker semaphore。src/tools/AgentTool/loadAgentsDir.ts:73-98; src/tools/AgentTool/runAgent.ts:747-786 | V2 有共享 registry / execution / residency capacity;V1 的 agent_max_depth 明确不作用于 V2。registry.rs:16; spawn.rs:365; config/mod.rs:878 | subagent_depth 默认 1并在 spawn 前沿 parentID 计数;agent steps 只在最后一步追加 prompt,不是 hard stop。config.ts:84; task.ts:104; prompt.ts:1178 |
| Tool / permission / model | worker tool pool 用自己的 permission mode 重建;agent definition 可指定 model、effort、tools、MCP、skills 与 maxTurns;async worker 通常避免 UI permission prompt。src/tools/AgentTool/AgentTool.tsx:555-577; src/tools/AgentTool/loadAgentsDir.ts:73-98; src/tools/AgentTool/runAgent.ts:412-502,648-685 | child 从 live parent config 刷新 model、reasoning、developer instructions 与 runtime policy;role / model override 有额外规则。multi_agents_common.rs:166; multi_agents_v2/spawn.rs:61 | child agent 自带 tool permission 与可选 model;parent 的 deny / external_directory 规则继续生效,缺省还 deny child 的 task / todowrite。subagent-permissions.ts:4; task.ts:181 |
| Communication / fan-in | background result 以 task notification 回到 coordinator;可 SendMessage 在 tool-round boundary 追加消息,也可 TaskOutput poll/block。semantic merge 由 coordinator 做。src/coordinator/coordinatorMode.ts:120-159; src/tools/SendMessageTool/SendMessageTool.ts:800-870; src/tools/TaskOutputTool/TaskOutputTool.tsx:91-143,242-281 | send_message 只 queue,followup_task 可唤醒 idle child;completion watcher 把 final status/result 写入 parent mailbox;wait_agent 只等 activity,不是 targeted join。message_tool.rs:12; control.rs:431; wait.rs:127 | foreground 把最后一个 text part 包成单 task result;background 逐个注入 synthetic result。parent 收到的是 per-child handoff,不是 reducer output。task.ts:200; task.ts:216 |
| Cancellation / failure | background child 不绑定 parent ESC;显式 kill abort 后标 killed,并可保留 partial result;其他 exception 标 failed。src/tools/AgentTool/AgentTool.tsx:686-698; src/tasks/LocalAgentTask/LocalAgentTask.tsx:281-314; src/tools/AgentTool/agentToolUtils.ts:638-681 | interrupt_agent 中止当前 turn;runtime 先 cancel token,短暂等待后 abort task,thread 仍可接 follow-up。tasks/mod.rs:854; protocol.rs:1727 | foreground parent call 被 interrupt 时同时 cancel child prompt 与 job;run-state 会沿 job metadata 递归取消 descendants。错误变成 failed tool result或 synthetic background error。task.ts:317; run-state.ts:111; prompt.ts:395 |
| Shared filesystem | isolation: worktree 可创建临时 Git worktree;未启用 isolation 的 worker 没有这层 checkout 隔离。src/tools/AgentTool/AgentTool.tsx:90-101,579-590 | V2 usage hint 明说所有 agent 共用 container、cwd 与 filesystem,修改立即互相可见。config/mod.rs:251 | child 是 session boundary,不是 filesystem namespace;background prompt还要求 coordinator 避免与 child 操作相同文件或 topic。task.ts:35; session.ts:680 |
三个不同的协调性格
Claude Code 是 observable background worker fabric。 普通 subagent 不复制 parent messages:task prompt 与独立组装的 system / user context 形成新输入;fork mode 才在其前面放入过滤后的 parent messages。async path 很早就返回 agent ID 与 output file;task registry 负责 progress、terminal state 与取消。sidechain transcript 让已停止甚至已从 memory state eviction 的 agent 有机会恢复,但能否恢复取决于 transcript 是否仍在。src/tools/AgentTool/runAgent.ts:368-410,747-818; src/tools/AgentTool/AgentTool.tsx:686-764; src/tools/SendMessageTool/SendMessageTool.ts:800-870
Codex V2 是 bounded persistent thread tree。 model-facing surface 注册六个 collaboration tools;child 用 canonical path 寻址,completion watcher 异步投递结果,terminal thread 在 residency 紧张时可以先 materialize rollout 再被 LRU unload。它把“agent identity”做得比一次 tool call 更长寿。spec_plan.rs:802; control.rs:478; residency.rs:116
OpenCode 是 capability-scoped child conversation。 新 task 建一条 child session;resume 只需要 task_id;built-in general 适合多步执行,explore 则默认 deny 后只放开 read/search 类能力。它的 foreground request/response 最容易理解,background 则是 feature-gated extension。task.ts:43; agent.ts:182; task.ts:96
代码走读
本章选择走读 OpenCode 的 child-session TaskTool path。理由不是它功能最多,而是这条 MIT 开源路径在一个短文件里同时展示 depth gate、permission derivation、session create/resume、model selection、foreground/background fan-in 与 cancellation。packages/opencode/src/tool/task.ts:104-346 Codex V2 更像完整 control plane:shared registry 管 agent identity / capacity,control layer 创建 fresh 或 forked thread,protocol 再保存 parent / depth / canonical path / role metadata。registry.rs:16-25; control/spawn.rs:365-470; protocol.rs:2823-2838 Claude Code 的 proprietary snapshot 可据行号描述,但本项目的 source policy 不允许大段引用,所以也不选作 open-source walkthrough。
1. Dispatch 前先卡 depth 与 permission
TaskTool 先沿 parentID 向上数 ancestry。达到 subagent_depth 就直接失败;默认值是 1,所以默认 child 不能继续派 child。通过 depth gate 后,tool 还要针对选定的 subagent_type 发起 task permission check。task.ts:104; config.ts:84; task.ts:119
while (current.parentID) {
depth++
current = yield* sessions.get(current.parentID)
}
if (depth >= (cfg.subagent_depth ?? 1)) {
return yield* Effect.fail(new Error("Subagent depth limit reached"))
}
packages/opencode/src/tool/task.ts:104— depth 是 lineage property,不是“当前同时跑了几条 task”的 concurrency count。
child permission 也不是简单复制 parent。deriver 只保留 parent 的 deny 与 external_directory rules,再看 child agent 自己是否显式拥有 task / todowrite;没有就补 deny。这样“parent 能派人”不会自动变成“所有 child 也能无限递归派人”。subagent-permissions.ts:14; task.ts:139
2. Fresh session 与 resume 走同一入口
如果传入 task_id,runtime 尝试拿回旧 session;否则创建带 parentID、agent name 与 derived permission 的 child。注意这里没有调用 Session.fork:fresh child 不复制 parent transcript。另一个通用 Session.fork API 的确会逐条 clone messages / parts,但它不在这个 dispatch path 上。task.ts:136; task.ts:156; session.ts:693
model 选择顺序是 child agent override 优先,否则继承 parent assistant message 的 provider/model;真正的 prompt 明确写到 nextSession.id,最后只抽取 child 最后一个 text part 作为 handoff text。task.ts:174; task.ts:200
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
const result = yield* ops.prompt({
sessionID: nextSession.id,
model,
agent: next.name,
parts,
})
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
packages/opencode/src/tool/task.txt:16;task.ts:200-214— fresh invocation 从新 context 开始,而 supplied prompt 被送进 child session;parent 因此必须把必要背景写进prompt,不能假设 child 看过主对话。
3. 同一个 job,两种 fan-in timing
runtime 总是先把 child run 注册到 background-job layer。foreground path 只是等待这个 job 完成;若 job 被 promote 成 background,它就改为返回 running。正常完成返回 <task ... state="completed">,child error / cancellation 则变成 tool failure。task.ts:273; task.ts:317
显式 background: true 受 experimental flag 保护。调用立即得到 running;另一个 waiter 等 job terminal state,再对 parent session 发一条 synthetic: true prompt,内容是单个 child 的 completed/error wrapper。它是 asynchronous delivery,不是把一批 child 自动合并成一个 answer。task.ts:96; task.ts:216; task.ts:245
4. Cancellation 是 propagation,不是 rollback
foreground tool call 的 abort listener 会 cancel child session;若整个 effect 被 interrupt,release 还会同时 cancel child 与 background job。更外层的 run-state 从目标 session ID 出发,反复扫描 job 的 id、sessionId 与 parentSessionId,把 descendant background jobs 一并取消。task.ts:310; task.ts:336; run-state.ts:111
这条 cancellation path 调用 child cancel / job abort,但在该 release path 中没有实现 filesystem rollback。task.ts:310-346 源码里的 background guidance 因而要求 coordinator 避免同时处理相同 files/topics;session isolation 与 filesystem isolation 是两件事。task.ts:35-40
设计权衡
Durable identity vs one-shot simplicity
Codex 与 OpenCode 都给 child durable/session-shaped identity:前者可在 terminal child 被 residency unload 后从 rollout reload,后者用 task_id 恢复 child history。Claude Code 也保存 sidechain transcript并能从 transcript 尝试 resume。好处是 follow-up 不用重做探索;成本是需要 registry、retention、eviction 与 stale-ID failure handling。residency.rs:116; message_tool.rs:92; packages/opencode/src/tool/task.ts:47; src/tools/SendMessageTool/SendMessageTool.ts:800-870
Fresh context vs context packaging burden
OpenCode fresh task 与 Claude Code ordinary worker 都不复制 parent transcript;Claude Code 会独立组装 system / user context,只有 fork mode 才 prepend 过滤后的 parent messages。Codex 则显式提供 fresh、full 与 last-N fork,但 fork 仍过滤 tool/reasoning/inter-agent traces。越 fresh,token 与污染越少;越依赖 coordinator 写出 self-contained task contract。src/tools/AgentTool/runAgent.ts:368-410; packages/opencode/src/tool/task.txt:16; codex-rs/core/src/agent/control/spawn.rs:47
Shared workspace vs isolated checkout
Codex V2 明确选择 shared cwd;OpenCode 的 child-session boundary 也沿用 instance directory / workspace,没有创建 per-child filesystem namespace。Claude Code 额外提供 opt-in worktree isolation,减少同时编辑的碰撞,代价是创建、保留与整合 checkout。src/tools/AgentTool/AgentTool.tsx:90-101,579-590; codex-rs/core/src/config/mod.rs:251; packages/opencode/src/session/session.ts:669-690 基于 OpenCode 明示的 overlap warning,实务上应由 coordinator 预先分配 file ownership,不要让并行 worker 编辑同一 target。task.ts:35-40
Hard bounds vs prompt-level policy
三家的 bounds 落在不同层。OpenCode 有 hard depth gate,但 steps 只是最后一步 prompt;Codex V2 有 execution/residency capacity,却明确忽略 V1 的 depth max;Claude Code 有 per-agent maxTurns,而 inspected coordinator/task path 没有 numeric global semaphore。只限制 turn 数、tree depth 或 simultaneous workers 中的一项,都不能替代另外两项。packages/opencode/src/tool/task.ts:104; packages/opencode/src/session/prompt.ts:1178; codex-rs/core/src/agent/control/spawn.rs:382; codex-rs/core/src/config/mod.rs:878; src/tools/AgentTool/runAgent.ts:747-786
Narrow absence audit:这里没有 durable DAG / reducer
absence claim 必须限定 search scope,不能从“没看到”外推到整个产品。以下结论只覆盖 pinned revision 与列出的 dispatch path:
- **Claude Code:**在
src/coordinator/coordinatorMode.ts、src/tools/AgentTool/、src/tasks/LocalAgentTask/与 task framework 搜索semaphore|queue|dependency|dag|join|retry|reducer|aggregate|conflict;命中包含普通 string join、prompt 中的 retry 建议与通知聚合,但观察到的 runtime 主体仍是 per-agent state、notification、poll/stop。该范围未发现 runtime-owned durable dependency graph、automatic semantic reducer / retry scheduler或 conflict resolver。正面路径见src/coordinator/coordinatorMode.ts:116-159,250-305;src/tools/AgentTool/agentToolUtils.ts:597-681;src/tools/TaskOutputTool/TaskOutputTool.tsx:242-281。 - **Codex V2:**对
codex-rs/core/src/{agent,tasks,tools/handlers/multi_agents_v2,tools/spec_plan.rs}搜索join_agent|await_agent|collect_results|result_collection,无命中;对 V2 handler/registration 搜索CloseAgentHandlerV2|close_agent,也没有 model-visible close-subtree tool。已注册的是 spawn/message/follow-up/wait/interrupt/list 六项,而wait_agent返回 mailbox / steer / timeout activity,不是 target → result map。spec_plan.rs:802;wait.rs:127 - **OpenCode:**对
packages/opencode/src/tool/task.ts搜索aggregate|reduc|fan.?in|dependency|graph无命中;搜索 task/subagent concurrency pattern、semaphore|queue也无命中;搜索timeout|retry|backoff|repair仍无命中。观察到的正面实现是 per-childrenderOutput,depth gate,background wait/inject 与 cancel path;因此只可说 TaskTool subsystem 没有 DAG scheduler、subagent-specific concurrency cap、task-level timeout/retry 或 semantic reducer,不能外推到 provider retry 或外部 extension。task.ts:64;task.ts:104;task.ts:216
共同结论不是“它们缺功能”,而是 orchestration policy 仍在 model prompt:runtime 提供 lifecycle primitives,model 负责形成 task graph 的临时表示并做 final synthesis。若进程退出,这些 parent 的隐式依赖与 merge decision 并没有一个独立 durable reducer 可以自动重放。Claude Code 明示 coordinator synthesize;Codex 的 completion 是单 child mailbox message;OpenCode 的 output也是单 child wrapper。src/coordinator/coordinatorMode.ts:120-159; codex-rs/core/src/agent/control.rs:478; packages/opencode/src/tool/task.ts:64
迁移到你自己的 agent
先从一个小而可恢复的 contract 开始,不要一上来造 workflow platform。
- 把 child 做成 addressable record。 最少保存
child_id、parent_id、task prompt、model / tool / permission snapshot、status、result / error、timestamps 与 abort handle。状态转换必须单向且幂等:pending → running → completed | failed | cancelled。 - 把 context policy 写进 API。 明确支持
fresh、fork_all、fork_last_n与resume(child_id)中哪些模式;fork 时列出会过滤的 item type。不要用一个含糊的inherit_context: true。 - 同时设三类 budget。
max_active_workers管并发,max_depth管递归,max_turns/ wall-clock timeout 管单 worker 消耗。三个计数器分别记录,错误信息也分别暴露。 - permission 采用 restrictive merge。 parent 的 deny 与 sandbox boundary 永远向下传;child role 只能在边界内缩小或专门化 tools。background worker 无法弹 UI 时,launch 前就应把 unresolved permission 变成 deny 或显式 parent-mediated request。
- 区分 message 与 follow-up。 running child 的 message 可在 tool boundary 注入;idle / completed child 的 follow-up 要显式开始新 turn。每条通信带 author、target、sequence 与 delivery state,避免“发了但没醒”变成幽灵 bug。
- fan-in 先做 mechanical,再做 semantic。 mechanical layer 收集每个 child 的 terminal state、text、artifacts 与 usage;semantic layer让 parent或独立 reviewer synthesize。若任务真的要求可重放 DAG,再把 dependency、join condition、retry 与 reducer schema 持久化,不要假装一段 coordinator prompt 就是 durable plan。
- 文件写入默认串行 ownership。 dispatch contract 写明每个 worker 可修改的 file / directory;相同 target 不并行。高风险写任务用 worktree / copy-on-write workspace,完成后跑 diff、tests 与 merge gate。cancel 只停计算,不等于 rollback。
- failure 要保留 partial evidence。 cancelled child 的已读文件、测试输出与 partial answer可以供 parent判断是否重派;failed child 要区分 invalid dispatch、permission denial、provider error、timeout 与 internal crash。只给一句“agent failed”会让 recovery policy失去依据。
- 给 observability 留稳定接口。 parent应能 list、wait、poll、interrupt 与 resume;UI 使用同一份 task registry,不要另造一套状态。terminal child 可 eviction,但 durable transcript / artifact pointer 要先落盘。
- 用冲突场景测试 orchestration,不只测 happy path。 至少覆盖:两个 child 同改一文件、parent cancel 时 child 正在跑命令、child 再派 child、result 与 follow-up 同时到达、terminal child 被 eviction 后 resume、一个 child失败而 siblings 成功、coordinator在 fan-in 前重启。
实现顺序可以很朴素:fresh foreground child → persistent ID / resume → cancellation → bounded parallelism → background notification → optional fork → isolated workspace。每加一层,都先证明 lifecycle、permission 与 artifact ownership 仍能被测试和解释。