多 agent · 子代理的派生与协作
Codex 的子代理就是同一个 ThreadManager 里的另一条线程。两代多 agent 工具(V1 按线程 id 寻址,V2 按任务路径寻址)共用一套 AgentControl:子代理从父轮次的实时设置派生,角色只能收窄、不能放宽权限;V1 限深度与打开数,V2 限驻留数并淘汰空闲子代理;V2 的消息与结果都经收件方的信箱投递。
多 agent · 子代理的派生与协作
你让 Codex“派三个子代理并行评审”,模型就会调用 spawn_agent:每个子代理在自己的线程里跑模型和工具,做完把结论交回主线程。怎么触发、/subagents 怎么切换、自定义 agent 文件与 [agents] 配置怎么写,见手册的子代理。这一篇只看实现。
先给结论:子代理没有单独的运行时。它就是同一个 ThreadManager 里的另一个 CodexThread(见 ThreadManager 与 CodexThread),会话来源标成 SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }),里面记着父线程 id、深度、任务路径、昵称和角色。多 agent 这套代码做的,是在这些线程之间维护一棵树:谁能派生、能派多少、怎么传话、结果怎么回来。
两代工具,一个控制面
源码里并存两代实现,枚举是 MultiAgentVersion(Disabled、V1、V2)。一个会话用哪一代按这个顺序定:打开 features.multi_agent_v2 就是 V2,agents.enabled = false 就是 Disabled;否则看模型目录 codex-rs/models-manager/models.json 里该模型的 multi_agent_version 字段;再否则由 features.multi_agent(默认开启)给出 V1。结果存进 Session 的一个 OnceLock,定了就不再变;子线程沿用父线程的版本。
| V1 | V2 | |
|---|---|---|
| 工具 | spawn_agent、send_input、wait_agent、resume_agent、close_agent,放在 multi_agent_v1 命名空间下 | spawn_agent、send_message、followup_task、wait_agent、interrupt_agent、list_agents;提供方支持命名空间工具时放进 features.multi_agent_v2.tool_namespace(默认 collaboration) |
| 怎么指代子代理 | 线程 id | 任务路径,如 /root/task1/task_3,也可写相对名;名字只许小写字母、数字和下划线 |
| 派生参数 | message 或 items、agent_type、fork_context(布尔)、model、reasoning_effort | 必填 task_name 与 message;可选 agent_type、fork_turns(none、all 或正整数,默认 all)、model、reasoning_effort |
wait_agent | 等指定 id 中任意一个进入终态,带回状态与最后一条消息 | 只等“信箱有动静”,不返回内容 |
| 结果怎么回来 | 父线程收到一段 <subagent_notification> | 父线程信箱收到一条 FINAL_ANSWER 消息 |
两代工具都不直接碰线程,而是调用 AgentControl trait(codex-rs/core/src/agent/api.rs):resolve、spawn、send、interrupt、list、turn_finished 等。仓库里唯一的实现是本地的 LocalAgentControl(agent/control.rs);ThreadManager::with_agent_control_factory 给宿主留了换成自家实现的口子,注释写明宿主实现只支持 V2。一棵树上所有线程共享同一个 LocalAgentRuntime:登记表 AgentRegistry、V2 驻留表、执行计数器、rollout 预算、根线程选定的服务档位都在里面。
工具要不要暴露给模型,由 spec_plan.rs 里的一个函数把关:
fn collab_tools_enabled(turn_context: &TurnContext, model_info: &ModelInfo) -> bool {
match turn_context.multi_agent_version {
MultiAgentVersion::Disabled => false,
MultiAgentVersion::V1 => !exceeds_thread_spawn_depth_limit(
next_thread_spawn_depth(&turn_context.session_source),
turn_context.config.agent_max_depth,
),
MultiAgentVersion::V2 => {
turn_context.session_source.get_agent_path().is_none()
|| model_info.multi_agent_version == Some(MultiAgentVersion::V2)
}
}
}(codex-rs/core/src/tools/spec_plan.rs:672)
V1 按深度把关:根线程深度 0,子线程是父深度加 1,agents.max_depth 默认 1,所以子线程的下一层是 2,它根本看不到这组工具;V1 的 spawn_agent 处理器里还有一道同样的检查,失败时回一句 Agent depth limit reached. Solve the task yourself.。V2 不看 max_depth:根线程总有工具,子线程只有当它用的模型在目录里标为 V2 时才有。
agent_type 参数并不总在。两代的 spawn_agent 都按 expose_agent_type: !turn_context.config.agent_roles.is_empty() 构造,而 agent_roles 只装用户自己定义的角色:一个都没定义时,模型看不到这个参数,也就选不了内置的 explorer、worker。内置角色本身也很薄:explorer 的配置文件 explorer.toml 是 0 字节,worker 没有配置文件,二者只是写进工具说明的两段描述。
派生:一次工具调用变成一条新线程
子配置从父轮次来,不从配置文件来。child_config.rs 的 build_agent_shared_config 先克隆父轮次的 Config,用这一轮实际生效的模型、提供方、推理强度与摘要、开发者指令覆盖一遍(V2 可以换成 subagent_developer_instructions),再把运行时状态抄过来:
fn apply_spawn_agent_runtime_overrides(
config: &mut Config,
turn: &TurnContext,
) -> Result<(), String> {
config
.permissions
.approval_policy
.set(turn.approval_policy())
.map_err(|err| format!("approval_policy is invalid: {err}"))?;
config.approvals_reviewer = turn.config.approvals_reviewer;
#[allow(deprecated)]
let turn_cwd = turn.cwd.clone();
config.cwd = turn_cwd;
// ...
}(codex-rs/core/src/agent/child_config.rs:171)
省略的部分还抄了权限档案。这些都是“这一轮的实时状态”,你中途用 /permissions 改过的也算。prepare_agent_spawn_config 随后依次叠上:显式的 model、reasoning_effort(没给就取 agents.default_subagent_model 与 agents.default_subagent_reasoning_effort)→ 角色(全量分叉又没指定角色时跳过)→ 树根的服务档位 → 再调一次上面这个函数。所以不管角色写了什么,子代理的审批与沙箱都回到父轮次的值。父线程的执行环境、以及兼容时父线程的执行策略,也原样交给子线程。
角色的约束写在 role.rs 的文件头:“Roles may customize the child or reduce its capabilities, but never replace the parent session's authority.”角色文件里真正生效的只是一张白名单 AgentRoleOverrides:developer_instructions、model、model_reasoning_effort、model_reasoning_summary、model_verbosity、personality、service_tier;features 只能关掉 shell_tool、apps、plugins、memories、request_permissions_tool 五项;skills 只能禁用。角色从每个配置层的 [agents.<name>] 和该层配置目录下 agents/ 里的 .toml 文件(递归查找)加载,实现在 codex-agent-roles crate;同一层重名的只留第一个,其余记一条启动警告。
手册(来自官方文档)说 agent 文件可以写 sandbox_mode、mcp_servers 等任意 config.toml 键。v0.158.0 的代码不是这样:白名单之外的键只做校验、不生效,测试 apply_role_cannot_expand_parent_authority 明确断言角色层里不能出现 approval_policy、sandbox_mode、mcp_servers、model_provider 等键。以代码为准。
分叉是另一种派生:V1 的 fork_context: true,或 V2 的 fork_turns。spawn_forked_thread 先让父线程把 rollout 刷盘,读出它的模型上下文,按需截到最近 N 轮,再过滤:保留 system、developer、user 消息和助手的最终答复,丢掉推理、工具调用与输出、token 用量记录,并从开发者消息里剥掉父线程的多 agent 使用提示、当前时间提醒等片段。V1 的全量分叉不能再指定 agent_type,分叉出的子代理沿用父代理的类型。
父子关系还会落盘:codex-agent-graph-store 把每条边写进 SQLite 的 thread_spawn_edges 表(child_thread_id 为主键,status 是 open 或 closed),临时会话不写。V1 的 close_agent 把边标成 closed,再关掉目标和它所有还活着的子孙;resume_agent 重新打开一个子代理时,顺着 open 的边把它的子孙逐层一起恢复。V2 的根会话被恢复时,只按这些边恢复子代理的身份登记,线程等用到时再加载。
并发:V1 数打开的,V2 数驻留的
两代的名额都从 Config::effective_agent_max_threads 算出(codex-rs/core/src/config/mod.rs:1603),但数的东西不一样:
- V1 数“打开着的子代理”,上限
agents.max_concurrent_threads_per_session,默认 6。AgentRegistry::reserve_spawn_slot用比较交换给计数加一,超了报AgentLimitReached;关闭或关停时计数才减回去,所以close_agent的工具说明提醒模型:已完成但没关的子代理照样占名额。 - V2 的上限是
features.multi_agent_v2.max_concurrent_threads_per_session减 1(这个数含根线程,默认 4,所以子代理默认 3 个;没设时取agents.max_concurrent_threads_per_session加 1)。V2Residency用它限制同时加载在内存里的子代理,满了就按最久未用的顺序,挑一个已完成、出错或被中断、没有进行中的轮次、信箱也空着的子代理卸载:先落盘 rollout、记下它的执行环境再关停,之后再给它发消息,ensure_v2_agent_loaded会从 rollout 把它加载回来。AgentExecutionLimiter再用同一个数限制同时在跑的子代理轮次,根线程不计在内。派出去的子代理总数本身没有硬上限。
通信:信箱
V2 的每条消息都是一个 InterAgentCommunication:作者路径、收件路径、正文,以及关键的 trigger_turn。send_message 以 MessageDeliveryMode::QueueOnly 投递,不唤醒对方;followup_task 以 TriggerTurn 投递,对方空闲就开一轮,而且不能发给根线程;spawn_agent 的首条任务也是一条 TriggerTurn 消息。控制面把它包成 Op::InterAgentCommunication 发给收件线程,收件线程的 inter_agent_communication(codex-rs/core/src/session/handlers.rs:79)先把信件压进信箱,只有 trigger_turn 为真(或者线程正挂着一次 sleep 工具的持久睡眠)时才开一轮。
信箱就是 InputQueue 里的一个 VecDeque,入队时顺手通过一个 watch 通道广播“信箱有动静”。V2 的 wait_agent 订阅的正是这个通道:有新信件返回 Wait completed.,用户中途插话返回 Wait interrupted by new input.,到时间返回 Wait timed out.,都不带信件内容,信件本身在轮次取待处理输入时并入上下文。超时默认 30 秒,最短 10 秒(再短会被抬到 10 秒并在结果里说明),最长 1 小时。V1 走的是更直接的路:send_input 调 start_or_steer_turn,对方空闲就开新轮,正在跑就插进当前轮;interrupt: true 时先中断再发。
结果:完成消息回到父线程
V2 子线程每轮结束,maybe_notify_parent_of_terminal_turn(codex-rs/core/src/session/mod.rs:2387)从事件推出终态,调用 AgentControl::turn_finished;控制面按任务路径找到直接父线程,把下面拼出的文字作为一条 trigger_turn 为假的消息投进父线程的信箱:
let payload = match status {
AgentStatus::Completed(Some(message)) => message.clone(),
AgentStatus::Completed(None) => String::new(),
AgentStatus::Errored(error) => {
let error = truncate_text(error, TruncationPolicy::Tokens(ERROR_MAX_TOKENS));
format!("Agent errored: {error}\n\n{ERROR_NEXT_ACTION}")
}
AgentStatus::Shutdown => "Agent shut down.".to_string(),
AgentStatus::NotFound => "Agent was not found.".to_string(),
AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted => return None,
};
Some(InterAgentCompletionMessage::new(task_name, sender, payload).render())(codex-rs/core/src/session_prefix.rs:24)
交回去的是子代理最后一条助手消息,渲染成以 Message Type: FINAL_ANSWER 开头的文本;出错信息截到 900 token(1000 减去 100 的信封预留);被中断的轮次不回报。V1 则在派生时起一个分离的监视任务,订阅子线程状态,进入终态后把一段 <subagent_notification> 注入父线程、不开新轮次。
另外两条相关的线
- 留言板:
features.agent_message_board(开发中,默认关)配合 V2,给一棵树加一套共享讨论工具:create_channel、get_channels、list_threads、search_posts、read_thread、read_post、subscribe、unsubscribe、post。实现在codex-rs/ext/agent-message-board/,作为扩展装进来(扩展机制见下一篇),默认存 SQLite,也可以放内存;通知只推给正在跑的 agent,错过的不补发。打开disable_direct_message后,send_message与followup_task不再注册,交流只能走留言板。 - 内部委派:
SubAgentSource除了ThreadSpawn,还有Review、Compact、MemoryConsolidation、Other。codex_delegate.rs的run_codex_thread_interactive是内核自用的子 Codex,比如/review任务就经run_codex_thread_one_shot起一个;它要求审批策略必须是never。
和《从 LLM 到 Coding Agent》对照
那本的子 Agent 把子 Agent 做成一个工具:run 里递归跑一个 Agent Loop,跑完把结论当工具结果返回,并给子 Agent 一套不含 spawn_agent 的工具集防止无限套娃。Codex 保留了“上下文隔离、只交回结论”的核心,但把同步的函数调用改成了异步的线程树:spawn_agent 立即返回,结论经通知或信箱回来;防套娃靠深度上限(V1)或模型目录(V2);再加上名额、驻留淘汰、落盘的父子边和恢复。对照 Grok Build 的 subagents:grok 的只读子代理靠专用工具集硬性裁掉 shell 与编辑工具,Codex 的角色只能关掉少数几个功能开关,权限一律跟父轮次走。
上一篇:MCP 客户端 · 传输与 OAuth 登录 · 下一篇:扩展 API · goal 与内部扩展点