第六章追踪的工具,最终都落在文件、进程或网络上。多 Agent 工具也从同一条 Tool Router 进入 Runtime,但它产生的副作用很特别:spawn_agent 创建的不是一个普通后台函数,而是一条新的 Codex Thread。
这条 Thread 有自己的 Session、历史、Turn、模型采样和工具调用。父 Agent 可以继续当前工作,子 Agent 同时处理另一项任务;子 Agent 还可以继续派生下一层。它们共用工作目录,因此一个 Agent 写入的文件会立刻被其他 Agent 看见,但它们不会共用同一段模型上下文。
这组边界决定了 Codex 中“协作”的真实含义:共享外部世界,分离思考过程,通过显式协议交换任务和结果。
Agent 不是套在模型外面的一个新类
在当前实现中,找不到一个包办一切的 Agent 对象。一个可运行的 Agent 是几部分状态的组合:
Agent
├── ThreadId / AgentPath 身份和寻址
├── CodexThread + Session 操作入口与长期运行状态
├── SessionSource 父 Thread、深度、角色、昵称
├── Config + Role 模型、指令、权限和运行配置
├── History + Turn + Task 独立的思考与执行过程
└── shared AgentControl 派生、通信、容量和拓扑控制
因此,创建子 Agent 不能简化为“再调用一次模型”。新的模型请求必须附着在一条可持续运行的 Thread 上,否则它无法接收后续消息、被中断、恢复历史或再次执行任务。
父子 Agent 之间也不是对象字段直接调用。每条 Thread 的 Session 都持有一个 AgentControl,而同一根节点派生出的所有子 Agent 共享同一个控制实例。这个实例内部再连接 Agent Registry、V2 驻留管理器、执行容量限制器和整棵树共享的 Rollout 预算。
AgentControl 只保存指向 ThreadManagerState 的弱引用。原因不是语法偏好,而是所有权边界:如果控制器反过来强持有全局 Thread Manager,就会形成 ThreadManager → CodexThread → Session → AgentControl → ThreadManager 的引用环,让已经关闭的会话继续存活,甚至造成影子持久化。
AgentPath 是任务树中的地址
V2 不再要求模型记住一串 UUID。根 Agent 的规范地址是 /root。如果它用任务名 research 派生子 Agent,新地址就是:
/root/research
research 再派生 tests,地址变成:
/root/research/tests
在 /root/research 内使用相对目标 tests,会解析到自己的子节点;若要联系兄弟节点 /root/implement,就要给出完整地址。路径段只允许小写字母、数字和下划线,root、.、.. 以及含 / 的任务名都会被拒绝。
这不是展示层的别名。AgentRegistry 同时维护 AgentPath → ThreadId 与 ThreadId → AgentPath,消息工具先解析路径,再把操作提交给目标 Thread。Spawn 还会提前预留路径,两个并发调用不能侥幸创建出同名子节点。
SessionSource::SubAgent(ThreadSpawn) 则把关系写入子 Session:父 Thread ID、派生深度、AgentPath、昵称和角色都随 Thread 保存。AgentPath 负责可读寻址,ThreadId 负责稳定身份,二者不能互相替代。
Spawn 是一项带回滚的创建事务
spawn_agent 的 Handler 先把模型参数转成子 Agent 配置和派生来源,再交给 AgentControl。控制层按下面的顺序工作:
- 判断当前会话实际使用 V1 还是 V2;
- 检查这次派生是否还有执行容量;
- V2 预留一个驻留槽,V1 预留总 Thread 名额;
- 在 Registry 中预留任务路径和昵称;
- 从父 Session 继承环境快照,以及允许继承时的 Exec Policy;
- 创建全新 Thread,或从父 Rollout 派生历史;
- Thread 成功后才提交 Registry 和驻留预留;
- 把父子边以
Open状态写入 AgentGraphStore; - 向子 Session 发送第一条
InterAgentCommunication,并触发它的第一个 Turn。
路径、昵称和容量预留都由带 Drop 清理语义的 Reservation 管理。只要 Thread 创建、历史加载或配置应用中途失败,未提交的预留就会自动归还。这一点解决的是并发下的泄漏,而不只是错误提示:若先增加计数,失败时靠每个分支手工减回,很容易留下“没有 Agent 却占着最后一个槽”的状态。
用接近 Python 的伪代码表达,这项事务大致是:
async def spawn_agent(request: SpawnRequest) -> AgentHandle:
check_execution_capacity(request.parent)
async with residency.reserve() as resident_slot:
async with registry.reserve(request.task_name) as agent_slot:
child_config = inherit_runtime_config(request.parent)
history = await build_child_history(request.fork_turns)
child = await thread_manager.create_thread(
config=child_config,
history=history,
source=request.thread_spawn_source,
agent_control=shared_agent_control,
)
agent_slot.commit(child.thread_id)
resident_slot.commit(child.thread_id)
await graph.upsert(request.parent.thread_id, child.thread_id, "open")
await child.mailbox.send(request.initial_task, trigger_turn=True)
return AgentHandle(child.thread_id, agent_slot.agent_path)
伪代码把 V1/V2 分支合并了,但保留了源码中的关键顺序:先预留,创建成功后提交,拓扑落盘后再投递首个任务。
Fork 复制的是可用上下文,不是父 Agent 的全部内部轨迹
V2 的 fork_turns 有三种取值:
| 参数 | 子 Agent 获得的父历史 | 适用情况 |
|---|---|---|
none | 不复制对话历史 | 任务自包含,只需要初始消息和共享工作区 |
all | 复制经过清洗的完整有效历史;也是默认值 | 子任务依赖当前讨论的来龙去脉 |
正整数,例如 3 | 只保留最近三个有效 Turn,再清洗 | 需要近期上下文,但不想携带整段会话 |
“完整历史”仍不是字节级克隆。派生前,父 Thread 会先把异步 Rollout 写入刷新到存储;截取所需 Turn 后,Runtime 再按模型上下文规则过滤条目:
- 保留 System、Developer、User 消息;
- Assistant 消息只保留最终回答阶段;
- 丢弃 Reasoning、函数调用、工具输出、Tool Search 和 Shell 调用等中间轨迹;
- 丢弃旧的 Agent 间通信,避免把发给父 Agent 的消息误当成子 Agent 的新任务;
- 保留 Compaction 和必要的 Session 元数据;
- 只有安全的全量 Fork 才沿用父 Thread 的 TurnContext、WorldState 参考基线,截断 Fork 在第一次子 Turn 中重新构造动态上下文。
这样处理同时服务两个目标。第一,子 Agent 能理解用户目标和父 Agent 已经确认的结论;第二,它不会继承一批已经完成的工具调用,再次面对无法配对的 Call/Result,或把父 Agent 的隐藏推理当成自己的待执行计划。
角色指令也不能简单复制。V2 会移除父 Agent 的协作提示,并在可识别时把父 Developer Instructions 片段替换成子 Agent 指令。压缩历史中的 replacement history 也使用同一清洗规则。若 fork_turns=all,源码禁止额外覆盖 agent_type,因为全量参考上下文与另一套角色指令可能产生冲突;none 或最近 N 个 Turn 的 Fork 才允许显式选择另一角色。
由此可以得到一个实用判断:子任务越独立,越适合 none;越依赖父 Agent 刚刚建立的概念和约束,越需要最近 N 个 Turn 或 all。Fork 越多并不天然越好,它会增加上下文成本,也会扩大子 Agent 需要重新辨认的历史范围。
消息进入 mailbox,是否启动 Turn 由一个位决定
V2 的通信协议不是“向目标 Prompt 追加字符串”。InterAgentCommunication 至少携带作者路径、接收者路径、内容和 trigger_turn。send_message 与 followup_task 走同一个提交函数,关键差异只有投递模式:
| 工具或事件 | trigger_turn | 目标空闲时 | 典型用途 |
|---|---|---|---|
| Spawn 的初始任务 | true | 立即启动第一个 Turn | 创建并开始工作 |
send_message | false | 只进入 mailbox,不开始采样 | 给正在工作的 Agent 补充信息 |
followup_task | true | 启动新 Turn | 让已完成或空闲的 Agent 继续任务 |
| 子 Agent Result | false | 只进入父 mailbox | 把完成结果交还父 Agent |
目标 Agent 正在运行时,消息先进入 Session 级 InputQueue。Runtime 会在可接收输入的消息边界,或当前工具调用完成后,把待处理内容交给活动 Turn;若当前阶段不允许同 Turn 投递,就留到下一 Turn。followup_task 还能在目标空闲时启动新 Turn,send_message 不能。
这个区别避免了两种常见浪费。补一句“测试文件在 tests/runtime.rs”不值得额外发起一次模型请求;而“现在根据审查结果修复问题”显然需要目标 Agent 再运行。是否唤醒由发送方明确表达,不让接收方靠猜测决定。
V2 还会在投递前调用 ensure_v2_agent_loaded。如果目标此前因驻留容量不足被卸载,Runtime 会从已存储 Rollout 恢复原 ThreadId、SessionSource、角色和历史,再把通信放进 mailbox。对发送方来说,目标仍由同一个 AgentPath 寻址,不需要显式执行 resume_agent。
子 Agent 的最终回答怎样回到父 Agent
子 Agent 每次发出事件时,Session 都会更新 AgentStatus:
TurnStarted → Running
TurnComplete(last_agent_message) → Completed(message)
TurnAborted(Interrupted/Budget) → Interrupted
其他 TurnAborted / Error → Errored(reason)
ShutdownComplete → Shutdown
Interrupted 在这里不是最终状态。它表示当前 Turn 停下了,但这条 Agent Thread 还能收到后续任务。Completed 也只是“这一轮已经给出可交付结果”,不等于 Thread 被销毁;一次 followup_task 可以让它重新进入 Running。
V2 子 Session 遇到 TurnComplete 或 TurnAborted 时,会检查状态是否真正终结。如果是 Completed、Errored 或 Shutdown,它把最后回答或错误包装成标准 Result 通信,然后投递到直接父 Session。这个 Result 的 trigger_turn=false,所以它不会为了宣布完成而强行启动父 Agent 的新 Turn。
这里最容易被工具名称误导。V1 的 wait_agent(targets) 会订阅指定 Thread 的状态,直到其中至少一个进入最终状态;V2 的 wait_agent 没有目标参数,它等待当前父 Session 出现两类活动之一:
- mailbox 收到 Agent 消息或结果;
- 用户输入对当前 Turn 进行了 Steer。
超时只表示这段时间没有活动。wait_agent 返回的也是“完成、被新输入打断或超时”,真正的子 Agent Result 仍是 mailbox 中的模型输入。这样 V2 不需要让父 Agent同时维护一份“我正在等哪些 UUID”的列表;任务地址和消息信封已经给出了来源。
因此不能把 wait_agent 当成必须调用的 Join。父 Agent 完全可以 Spawn 后继续检查其他文件,子 Agent 完成时 Result 自然进入 mailbox。只有下一步确实依赖结果、父 Agent 已没有别的可推进工作时,等待才有意义。
Interrupt、Close 和卸载是三种不同操作
“让 Agent 停下”至少有三种语义:
| 操作 | 当前 Turn | Thread 是否保留 | 父子边 | 能否继续 |
|---|---|---|---|---|
interrupt_agent | 发送 Op::Interrupt,状态可变为 Interrupted | 保留 | 保持 Open | 可以接收新任务 |
V1 close_agent | Flush 后 Shutdown,并清理活动后代 | 从内存 Registry/Manager 移除 | 标记为 Closed | 需按已存 Rollout 显式 Resume |
| V2 Residency 卸载 | 只选择无活动 Turn、无 mailbox 的终态/中断 Thread | 从 ThreadManager 卸载,身份元数据保留 | 保持 Open | 下次消息前自动恢复 |
V2 暴露的协作工具里有 interrupt_agent,没有 V1 的 close_agent 与 resume_agent。这不是少做了生命周期管理,而是把“暂时不占内存”和“从协作拓扑明确关闭”分开:普通空闲 Agent 由 Residency 自动换出和恢复;中断只停止当前工作;持久边的关闭仍是更强的内部生命周期操作。
关闭一个父 Agent 时,控制层还会遍历当前内存中的派生后代并逐一 Shutdown,防止留下失去控制入口的活动 Thread。中断不会级联,也不会释放路径或容量计数。
并发槽和驻留槽限制的不是同一件事
多 Agent 最容易出现的错误模型是“配置最多四个 Agent,所以第五个一定不能存在”。V2 实际把资源分成两个维度。
第一个维度是执行容量。只有正在运行 Turn 的 V2 子 Agent 占用 AgentExecutionGuard;根 Agent不计入这个 Guard。Guard 与 RunningTask 生命周期绑定,Task 完成或取消后通过 Drop 归还。UserInput 和 trigger_turn=true 的通信在启动空闲 Turn 前都要检查容量;只入队的 send_message 不消耗新的执行槽。
第二个维度是驻留容量。它限制当前加载在 ThreadManager 中的 V2 子 Thread 数量。容量用尽时,Residency 按近似 LRU 顺序寻找可卸载对象,但候选必须同时满足:
- 状态为
Completed、Errored或Interrupted; - 没有活动 Turn;
- mailbox 中没有待处理消息。
卸载前必须物化并刷新 Rollout,再 Shutdown Session、移出 ThreadManager。Registry 中的 AgentPath 和持久化 Open 边不因此消失,所以后续通信仍能用原路径恢复它。
两种容量解决不同问题:
Execution capacity = 同时有多少个子 Agent 正在花模型和工具计算
Residency capacity = 同时有多少条子 Thread 占用运行时内存
Persistent graph = 总共有多少条仍可恢复的协作关系
如果所有执行槽都在 Running,新 Spawn 或 Followup 会被拒绝,即使某个已加载 Thread 理论上可以卸载;如果只是驻留满了,但存在干净的已完成 Agent,Runtime 可以先卸载它,再创建或恢复另一条 Thread。
V1 的限制更直接:Registry 用总 Thread 计数和 agents.max_depth 限制派生。默认深度为 1,达到深度后连 Spawn 工具都不会继续暴露。V2 的工具规划明确忽略这项 V1 深度上限,允许子 Agent继续派生;它依靠执行容量、驻留容量、共享预算和调用规范约束资源。因此在解释配置时,必须先确认会话运行的是 V1 还是 V2。
Registry 管现在,Graph Store 管重启以后
AgentRegistry 与 AgentGraphStore 都保存“Agent 关系”,但权威范围不同。
Registry 是一棵 root 会话树共享的内存索引。它负责路径解析、ThreadId 反查、角色、昵称、并发 Spawn 预留和运行时计数。V2 Residency 卸载 Thread 时不会释放这份身份,因此消息仍能找到待恢复的目标;真正 Shutdown 或关闭才会释放运行时注册。
AgentGraphStore 是存储中立的持久化边界,只记录定向父子关系与 Open/Closed 状态:
parent_thread_id ──Open──> child_thread_id
parent_thread_id ─Closed─> child_thread_id
Open 表示子 Thread 仍处于活动或可恢复的协作图中,Closed 表示这条入边已经被明确关闭。它不保存当前是否 Running,也不替代 AgentStatus。查询后代时,状态过滤会应用到遍历的每条边;关闭父边后,不能绕过它继续把下层节点当成开放后代。
V1 从 Rollout 恢复一棵 Agent 树时,会沿 Graph Store 的开放边递归恢复后代。V2 则更多采用按需恢复:Registry 提供地址,存储提供历史,ensure_v2_agent_loaded 在通信前重新加载目标。下一章讨论持久化时,会继续拆开 AgentGraphStore、Rollout、ThreadStore 与 State 各自负责的事实。
共享工作区不等于共享记忆
同一棵 Agent 树通常使用相同 cwd、文件系统和工具能力。父 Agent 派出一个实现者和一个测试者时,两者可以同时读同一仓库,也可能同时修改同一文件。Codex 没有因为它们属于一支小队,就自动提供数据库事务或文件锁。
这带来两条工程约束:
- 子任务的写入范围应尽量不重叠;否则最后写入者可能覆盖另一方的改动,父 Agent 还要额外合并冲突。
- 共享文件变化不等于共享语义。一个 Agent 新建了
src/cache.rs,另一个 Agent 能在文件系统中看到它,但除非重新读取文件或收到消息,它的模型上下文并不知道为什么要这样设计。
反过来,Agent 的 History、InputQueue 和活动 Turn 都是独立的。发送消息会形成有作者和接收者的通信记录,不会把发送方整段对话偷偷合并进接收方 History。Fork 只发生在创建时,而且使用前述清洗策略。
这种“外部状态共享、内部上下文隔离”比共享一条长对话更容易控制 Token 和故障范围,代价是任务拆分与结果整合必须显式完成。
Role、Skill 和 MCP 不是三种 Agent
多 Agent 机制经常与扩展能力混在一起讨论,但它们改变 Runtime 的位置不同:
| 机制 | 改变什么 | 是否创建独立 Thread | 是否有独立状态与 mailbox |
|---|---|---|---|
| Agent Role | Spawn 时叠加子 Agent 配置、模型或指令 | 角色本身不创建;由 Spawn 创建 | 取决于对应子 Agent |
| Skill | 向当前 Agent 注入一套任务说明和资源 | 否 | 否 |
| MCP / Plugin / App | 给当前 Runtime 增加工具、资源或能力入口 | 否 | 否 |
| Multi-agent Spawn | 创建新的 Thread、Session、历史和任务循环 | 是 | 是 |
Role 回答“这条新 Thread 应按什么配置工作”,Skill 回答“当前 Agent 完成某类任务应遵守什么流程”,MCP 和 Plugin 回答“当前 Runtime 还能调用什么能力”。只有 Spawn 改变 Agent 树的拓扑。
这也是为什么“安装更多 Skill”不能替代并行 Agent,而“多开几个 Agent”也不会自动获得新的外部系统能力。前者扩充同一个执行者的方法,后者增加彼此隔离的执行者。
调试协作链要同时看地址、状态和队列
多 Agent 故障通常不是一句“子任务没回来”能够定位的。可以按现象拆开检查:
| 现象 | 优先检查 |
|---|---|
| Spawn 提示路径已存在 | 当前 AgentPath 下是否重复使用 task_name,失败预留是否已释放 |
| 子 Agent 缺少背景 | fork_turns 是否为 none,初始 message 是否自包含 |
| Fork 后出现旧工具噪声 | 是否错误绕过了 Rollout 清洗,Call/Result 是否仍成对 |
send_message 后目标没有开始工作 | 该工具本来就不触发 Turn;需要任务时用 Followup |
wait_agent 很快返回但没有指定状态表 | 当前是否为 V2;它等待 mailbox/Steer,不是 V1 目标状态订阅 |
| 完成 Result 没有唤醒空闲父 Agent | Result 的设计就是 queue-only;检查 mailbox,或让父 Turn主动等待 |
| Agent 显示 Interrupted 后仍可用 | Interrupted 不是最终状态,也没有关闭父子边 |
| 路径能解析但 Thread 不在 Manager | 可能被 Residency 卸载,应检查自动恢复和 Rollout 读取 |
| 达到上限但 Agent 总数看起来不多 | 区分正在 Running 的执行槽与已加载 Thread 的驻留槽 |
| 两个 Agent 的代码互相覆盖 | 文件系统共享但无自动写冲突协调,重新划分写入范围 |
日志至少要关联 root Session ID、ThreadId、AgentPath、parent ThreadId、Turn ID、通信 ID、trigger_turn、AgentStatus,以及 Registry/Residency 的变化。只记 Agent 昵称不够:昵称可以重复轮换,AgentPath 才是 V2 工具使用的规范地址。
一支小队仍然由同一个 Runtime 约束
Codex 的多 Agent 没有绕开前六章介绍的机制。Spawn、Message 和 Interrupt 首先仍是 Tool Call;每条子 Thread 仍按 Turn 和 Step 运行;工具仍受原有权限与沙箱约束;结果仍要写进 History 才能影响后续采样。
新增的只是另一层控制协议:
Tool Call
→ 预留路径、容量与驻留槽
→ 创建或 Fork 子 Thread
→ 持久化 Open 父子边
→ trigger_turn 任务通信
→ 子 Agent 独立运行
→ 终态 Result 写入父 mailbox
→ 父 Agent 整合结果
这条链中最重要的分界有四条:Agent 共享工作区但不共享模型历史;Full Fork 也要清洗上下文;消息投递与启动 Turn 是两个动作;运行时 Registry、加载中的 Thread 和持久拓扑分别回答不同问题。
下一章将沿最后一条分界继续:当进程退出、Thread 被卸载或历史经过压缩以后,Codex 到底把哪些事实写进 Rollout、ThreadStore、State 和 AgentGraphStore,又怎样从这些材料恢复一项尚未结束的工作。
延伸阅读
- AgentControl 与共享控制平面
- Spawn、Fork 与 V2 自动恢复
- AgentRegistry 与 Spawn Reservation
- V2 执行容量与 Residency
- V2 消息工具和 mailbox
- AgentGraphStore 持久拓扑接口
第七章细节导航(7.1—7.40)
- 7.1 Agent 身份怎样由 Role、Thread、Prompt、Tool 和 Status 组合
- 7.2 Built-in Role、Agent TOML 与模型覆盖
- 7.3 AgentRegistry 的注册、活动索引与 SpawnReservation
- 7.4 AgentStatus 的事件投影、终态和 Interrupted 语义
- 7.5 Spawn 深度、注册容量、执行容量与驻留容量
- 7.6 V2 Residency 的 LRU、Evict、Resume 与冷 Thread
- 7.7 Spawn 前的角色解析、模型选择和子 Thread 创建事务
- 7.8 Fork History 怎样过滤 Reasoning、Tool Call 和旧通信
- 7.9 父 Agent 指令怎样替换为子角色指令
- 7.10
spawn_agentV1 的参数、创建与 UUID 返回 - 7.11
send_inputV1 的 interrupt、queue 与新 Turn 选择 - 7.12
resume_agentV1 的 Rollout 恢复与开放后代 - 7.13
wait_agentV1 的多目标状态订阅与超时 - 7.14
close_agentV1 的边关闭、后代 Shutdown 与资源释放 - 7.15
spawn_agentV2 的 AgentPath、fork_turns 与元数据隐藏 - 7.16
send_message的 QueueOnly Mailbox 投递语义 - 7.17
followup_task怎样向空闲 Agent 启动新 Turn - 7.18
wait_agentV2 等待 Mailbox/Steer Activity 与超时范围 - 7.19
interrupt_agent的目标校验、previous status 与幂等取消 - 7.20
list_agents的路径前缀、树形投影与 loaded-only 快照 - 7.21 Result Mailbox 的终态通知、QueueOnly 与父 Agent 唤醒
- 7.22 AgentGraphStore 的唯一入边、Open/Closed 与广度优先恢复
- 7.23 V1 与 V2 协作协议的工具、地址、等待和恢复差异
- 7.24 Skill 根目录、层级优先级与有界发现算法
- 7.25 Skill 元数据怎样进入 Prompt,全文怎样按需注入
- 7.26 Skill 的 scripts/assets 读取边界与 MCP 依赖安装
- 7.27 MCP ConnectionManager 的并发启动、连接复用、认证与关闭
- 7.28 MCP 工具目录刷新、Binding 快照与 Catalog Revision
- 7.29 MCP Resource、Resource Template 与 Elicitation 的边界
- 7.30 Plugin Manifest、Bundle、Marketplace 与路径解析
- 7.31 Plugin 安装、升级、删除与启动同步的事务边界
- 7.32 App MCP Routing、Backend Auth 门控与同名 Server 去重
- 7.33 Hooks 配置发现、Trust、Matcher 与 Command Runner
- 7.34 Session、Prompt、Tool、Compact 与 Subagent Hook 事件负载
- 7.35 Hook 输出解析、Additional Context、输入改写与阻断结果
- 7.36 ExtensionRegistry 与 Session、Thread、Turn、Step Data
- 7.37 ContextContributor 的 Prompt Slot、Turn Context 与 WorldState Section
- 7.38 Tool、MCP、TurnInput 与 TurnItem Contributor 的接入点
- 7.39 Thread 与 Turn Lifecycle Contributor 的调用时机和状态清理
- 7.40 Hooks 与进程内 Extension 的信任、能力和失败边界
评论
登录后即可评论