Codex Subagent 逆向笔记:从 spawn_agent 到父子线程协作

Codex 的 subagent 看起来像“主 Agent 派出几个小 Agent 并行工作”。从实现证据看,更准确的模型是:父 Agent 通过协作工具请求创建一个有独立线程身份的子 Agent;共享的 Agent 控制器负责登记、调度、通信和回收父子关系;客户端再把这些状态呈现成活动列表。

本文结合本机 ChatGPT 桌面应用内置 codex-cli 的静态分析、应用资源中的 UI JavaScript,以及 OpenAI codex 仓库与本机版本号一致的源码 tag 交叉整理。版本号相同提高了源码对照的可信度,但没有签名构建清单证明本机二进制确由该 commit 构建;因此我仍把本机观察和源码解释分开说明。

先看整条链路

父 Agent 的模型选择 spawn_agent
↓
工具处理器解析请求、构造 SpawnRequest
↓
AgentControl 检查容量并预留 agent-tree membership / spawn slot
↓
准备 child 的来源元数据、agent path、继承设置与可选历史
↓
ThreadManager 创建 child thread,记录父子 spawn edge
↓
把首条消息作为 child 的新一轮输入提交
↓
父子通过 send_message / followup_task 等协作
↓
child 终止时向 parent 投递结果消息,并发出完成活动事件
↓
UI 根据协作工具调用和 sub-agent 活动事件更新列表

上图中从工具处理器到子线程创建、持久化关系和首条输入的步骤,来自版本 tag 源码;本机二进制中也能找到相应符号和校验字符串,但静态反汇编没有独立证明所有异步分支的具体运行顺序。

1. 工具调用不是直接启动一个 shell 子进程

该版本源码中的 V2 spawn handler 会把模型给出的 agent 描述整理成 SpawnRequest,其中包含发起者、配置、初始消息、父子关系及 fork 选项,然后交给 agent_control.spawn(...)。初始消息被指定为触发 child 新一轮执行的输入。可对照 V2 spawn handler。

控制器的 spawn_agent_internal 随后会:

  • 取得 agent-tree membership,并确定 parent thread;
  • 计算适用的 multi-agent 版本并检查执行容量;
  • 向 registry 预留 slot;
  • 解析继承的 environment、插件设置和执行策略;
  • 生成 SessionSource::SubAgent(ThreadSpawn { ... }) 及 AgentMetadata;
  • 按需准备 fork history,再调用 ThreadManager 创建 child thread;
  • 持久化 thread-spawn edge,之后提交首条输入。

这条路径表明 Codex 的抽象中心是 thread/session 与 agent tree。被分析的调用路径没有显示“每个 subagent 再启动一份独立 codex 可执行文件”的证据;但仅凭这段静态证据,也不应把它扩大成对所有平台、所有运行模式的绝对断言。相关实现见 AgentControl spawn 流程。

2. 子线程有独立身份,也保留父子来源关系

child 并非只是在父会话里追加一段带标签的对话。它有自己的 thread id、session source 和 agent path;source 元数据记录父 thread、层级和路径。控制器还会把 spawn edge 持久化,供后续恢复和父子关系查找使用。

“有独立线程身份”不等于“完全隔离”。创建时仍可能从父级继承环境选择、插件禁用列表、执行策略和动态工具;如果请求指定 fork mode,还会准备历史记录。公开源码注释特别指出:复制对话历史不应改变发起 spawn 的那一轮原有能力。具体继承项取决于请求、配置及当前 feature,不能把它简化成“所有上下文都复制”或“子 Agent 从零开始”。

在新 thread 建好并建立关系后,控制器才提交初始输入;代码分别处理普通 user input 和 inter-agent communication。V2 下还会解析模型信息等启动配置。此后 child 作为独立执行单元继续跑自己的 agent loop。

3. 并行受容量与登记机制控制

spawn 不只是创建 thread。控制器会先登记 membership、检查 execution capacity,并通过 registry 预留 spawn slot;V2 resident 模式还有对应的 residency slot。成功后才 commit 这些登记。源码也有 PendingSpawn,在创建尚未完成时暂时持有 agent-tree membership,避免一个“待完成 spawn”失去树上的归属。

本机二进制字符串里出现了 max_concurrent_threads_per_session、min_wait_timeout_ms、max_wait_timeout_ms 等配置名,以及“pending spawn must own agent-tree membership”一类错误文本。这些只能证明该构建包含相关配置与防护逻辑;没有检查运行时最终配置,不能据此给出具体并发上限。

4. wait_agent 等待的是活动,不是 join

当前 V2 wait_agent handler 先应用配置里的 timeout 下限、上限和默认值,然后订阅 session input queue 的 activity。等待结果分为三类:收到 mailbox activity、被新输入 steer、达到超时。它返回的状态也明确对应这三种情况。

因此它不是传统线程库里的 join(child):它不只是盯着 child 是否退出,也不保证“所有 child 都完成后才返回”。一条协作消息就可能唤醒等待;父 Agent 随后可以读取结果、继续等、给 child 发 follow-up,或自行整合现有进度。具体实现见 V2 wait handler。

本机二进制中还能找到 spawn、wait、list_agents、send_message、followup_task、interrupt_agent 等 V2 handler 名称,也能找到 legacy agent handler 符号。它们说明构建中存在这些操作面;不同版本、feature flag 和产品界面暴露哪些工具,需要另外确认。

5. 完成通知按 agent 版本分流

与本机 CLI 版本号对应的公开 tag 源码中,control.rs::maybe_start_completion_watcher 是 legacy 路径。它订阅 child 状态直到终态;订阅流结束或失败时会再查询一次状态。终态出来后,它会把结果送回 parent:带 V2 agent path 的情况使用 inter-agent completion message 并设 trigger_turn=false,legacy 情况则注入不启动新 turn 的 SubagentNotification。树正在关闭时的 shutdown 通知会被抑制。更关键的是,spawn.rs 调用点明确只在 multi_agent_version != V2 时启动这个 watcher,因此不能把 watcher 当作 V2 child 的通用完成机制。见 watcher 实现 和 spawn 调用点。

同一版本 tag 中,V2 的调用链是另一条:child session 发出 TurnComplete 或 TurnAborted 时,Session::send_event 会进入 maybe_notify_parent_of_terminal_turn;该方法先要求版本为 V2、来源是带 agent path 的 ThreadSpawn,再从终态事件和已记录错误构造 AgentTurnOutcome,交给 AgentControl::turn_finished。控制器随后由 completion.rs::notify_parent_of_terminal_turn 路由结果。成功完成且有 parent turn id 时会发 SubAgentActivityKind::Completed,之后按终态构造结果消息并排入 parent 通信队列,trigger_turn 为 false。错误终态使用相应的 completion 文案,部分拒绝错误有专门处理。见 Session 终态事件调用链 和 terminal outcome 路由实现。

这两套实现都说明:状态/活动事件与结果消息不是同一个事件,而且排入 parent 队列不等于 parent 模型已经消费并整合了答案。客户端另外有一个针对 /flora/subagent/thread/turns 的查询模块,会在有可用记录时选择 child 的最新 final answer;那属于另一条结果展示/读取路径。尽管版本号相同,公开源码 tag 与本机安装包之间仍缺少构建来源证明;以上版本分流描述的是被检查的 tag 源码,并由本机符号/字符串作静态交叉佐证。

6. UI 显示的是协作事件的投影

应用 JavaScript bundle 中观察到两类相关结构:

结构 能看到的字段 UI 侧用途
codex_collab_agent_tool_call tool、status、sender/receiver thread ids、prompt、agentsStates 表示 spawn、send input、resume、wait、close 等协作工具调用及其状态
codex_sub_agent_activity agentPath、agentThreadId、kind、id 表示 started、interacted、completed、interrupted 等子 Agent 活动

bundle 把活动状态映射成 waiting、working、done、failed,并分组放进 Active / Done。换言之,界面列表是控制层事件的呈现结果,不是 Agent 调度本身;UI 的 completed 也不等于上文所说的结果消息必然已被主模型消费。

7. 本机版本与官方源码 tag 对照

本机 CLI 版本是 0.162.0-alpha.17.2;OpenAI 仓库中存在同名 tag rust-v0.162.0-alpha.17.2,其 peeled commit 为 740e5af33c71225640e0c1c1555c514c2c93ab74。我对照了该 tag 下的 V2 spawn handler、AgentControl::spawn_agent_internal、session terminal-event 路由、turn_finished completion delivery 与 legacy completion watcher。源码中可直接看到:只有非 V2 spawn 会启动 watcher;V2 child 的终态由 session 事件路径转交控制器。

这一步把原先“当前 main 的实现可能与本机版本不同”的不确定性缩小为“同版本号源码 tag 可用于解释本机构建,但构建来源尚未被签名元数据证明”。本机 SHA-256 为 0d5ac7aae20c4eda6242428d5e218c55ba96d8763fda2a271088ff37104b9d28,Mach-O UUID 为 2CE1483B-FF35-344F-9958-9D48FFE5D5ED;这些身份值和符号可用只读脚本复核。

本机直接观察

  • ChatGPT.app 内嵌 CLI 的 --version 输出为 codex-cli 0.162.0-alpha.17.2,目标是 arm64 Mach-O。
  • 该二进制 SHA-256:0d5ac7aae20c4eda6242428d5e218c55ba96d8763fda2a271088ff37104b9d28。
  • 符号表包含 LocalAgentControl::prepare_thread_spawn、maybe_start_completion_watcher、validate_loaded_v2_child、persist_thread_spawn_edge_for_source、restore_v2_agent_metadata、resume_agent_from_rollout 等名字。
  • 只读反汇编确认本机 watcher 入口会调用 LocalAgentRuntime::admit_start;本机符号表没有独立的 notify_parent_of_terminal_turn 主函数符号,但存在该函数闭包的 callsite metadata。由于优化/内联可能改变符号形态,这不能证明本机缺少该逻辑,也不能证明它被调用。
  • 二进制字符串含有多 agent V2 配置字段、工具错误文本及 codex.multi_agent.result_delivery 等指标名。
  • 针对应用 bundle 的两个子 Agent UI 模块,可见协作工具/活动 schema 和 final-answer 查询逻辑。该分析只覆盖被提取的目标资源,不代表遍历了整个桌面应用。
  • 后续用 REA 对本机 app.asar 做了全包静态扫描:分析器完成了 16,240 个源文件的解析并重建应用图。ASAR 声明的 permissions.node 哈希与伴随文件实测哈希不一致,因此采用 record-and-continue 并把该原生文件标记为不可信;大型启动 bundle 的语义图受资源上限影响,保留了静态和模块事实。这个全包结果没有提供可用的子 Agent 定向调用链,本文 UI 结论仍限定在上面明确检查的模块。

以上均是静态文件观察,不是运行时抓包或真实 spawn 实验。特别是 maybe_start_completion_watcher 这个符号只能证明二进制中存在该方法,不能单靠符号名推出 watcher 的精确触发条件。

匹配版本的公开源码交叉核对

上文的精确处理顺序来自 openai/codex 的版本 tag rust-v0.162.0-alpha.17.2,peeled commit 为 740e5af33c71225640e0c1c1555c514c2c93ab74。版本号匹配加上本机符号/字符串交叉检查,使该 tag 成为高可信源码参照;但没有签名构建元数据证明本机二进制确由该 commit 产生,所以它仍不能替代逐指令语义证明。官方多 Agent 指南也可帮助理解用户可见的上下文隔离与 parent coordination 心智模型,但不是桌面版内部实现规格。

仍未知

  • 本机这一构建对应的精确 Codex 源码 commit;同版本 tag 是强参照,但构建来源尚未被证明。
  • 运行时实际启用的 multi-agent 版本、feature flag、并发/等待配置和权限策略。
  • maybe_start_completion_watcher 在本机版本中创建哪些具体 watcher、取消和恢复条件是什么;也未从本机反汇编恢复 V2 terminal-event 到 parent 通知的完整调用链。
  • 不同桌面端版本的 UI bundle、服务端 /flora/... 接口和远端 executor 的实现细节。
  • 子 Agent 在真实任务中的调度时序、资源成本和失败率;这次没有执行受控实验。

小结

目前证据支持一个更精确的图景:spawn 是经共享 AgentControl 进入统一的线程与 Agent tree 管理流程;child 有独立 thread/session 标识,同时按策略继承设置或历史;spawn 关系先被持久化,再投递首条消息;legacy completion watcher 与 V2 terminal-event forwarding 走不同路径,父子通信、等待、终态消息和 UI 活动事件是彼此相关但不同的通道。

最重要的实现细节不是“同时开几个模型”,而是如何可靠地登记子节点、约束容量、建立可恢复的父子边,并让协作消息与界面活动保持一致。下一步若要把逆向再推进,需要把本机二进制的 watcher 异步状态机和公开源码逐段对齐,或在明确授权的本机 Codex 会话中做可重复的运行时观察;可复核的采集脚本与证据台账已整理在 Codex Subagent Reverse Engineering 仓库。目前这篇笔记没有把静态分析写成运行时结论。