# 多 agent · 子代理的派生与协作

> Codex 的子代理就是同一个 ThreadManager 里的另一条线程。两代多 agent 工具（V1 按线程 id 寻址，V2 按任务路径寻址）共用一套 AgentControl：子代理从父轮次的实时设置派生，角色只能收窄、不能放宽权限；V1 限深度与打开数，V2 限驻留数并淘汰空闲子代理；V2 的消息与结果都经收件方的信箱投递。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/multi-agent
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 多 agent · 子代理的派生与协作

你让 Codex“派三个子代理并行评审”，模型就会调用 `spawn_agent`：每个子代理在自己的线程里跑模型和工具，做完把结论交回主线程。怎么触发、`/subagents` 怎么切换、自定义 agent 文件与 `[agents]` 配置怎么写，见手册的[子代理](https://daiw.net/manual/codex/subagents)。这一篇只看实现。

先给结论：**子代理没有单独的运行时**。它就是同一个 `ThreadManager` 里的另一个 `CodexThread`（见 [ThreadManager 与 CodexThread](https://daiw.net/manual/codex-source/thread-manager)），会话来源标成 `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` 里的一个函数把关：

```rust
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 时才有。

<Callout type="info">
  `agent_type` 参数并不总在。两代的 `spawn_agent` 都按 `expose_agent_type: !turn_context.config.agent_roles.is_empty()` 构造，而 `agent_roles` 只装用户自己定义的角色：一个都没定义时，模型看不到这个参数，也就选不了内置的 `explorer`、`worker`。内置角色本身也很薄：`explorer` 的配置文件 `explorer.toml` 是 0 字节，`worker` 没有配置文件，二者只是写进工具说明的两段描述。
</Callout>

## 派生：一次工具调用变成一条新线程

```mermaid
flowchart TB
  M[模型调用 spawn_agent] --> C[prepare_agent_spawn_config<br/>从父轮次派生子配置]
  C --> S[AgentControl::spawn]
  S --> R{占名额}
  R -->|V1| RG[AgentRegistry 计数<br/>分配昵称]
  R -->|V2| RS[V2Residency 驻留槽位<br/>满了先淘汰空闲子代理]
  RG --> T[ThreadManagerState<br/>新建线程或分叉父历史]
  RS --> T
  T --> E[写 thread_spawn_edges<br/>父子边状态 open]
  E --> I[投递首条输入<br/>V1 用户输入 V2 信箱消息]
  I --> OUT[返回 agent_id 或 task_name]
```

**子配置从父轮次来，不从配置文件来**。`child_config.rs` 的 `build_agent_shared_config` 先克隆父轮次的 `Config`，用这一轮实际生效的模型、提供方、推理强度与摘要、开发者指令覆盖一遍（V2 可以换成 `subagent_developer_instructions`），再把运行时状态抄过来：

```rust
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；同一层重名的只留第一个，其余记一条启动警告。

<Callout type="warn">
  手册（来自官方文档）说 agent 文件可以写 `sandbox_mode`、`mcp_servers` 等任意 `config.toml` 键。v0.158.0 的代码不是这样：白名单之外的键只做校验、不生效，测试 `apply_role_cannot_expand_parent_authority` 明确断言角色层里不能出现 `approval_policy`、`sandbox_mode`、`mcp_servers`、`model_provider` 等键。以代码为准。
</Callout>

**分叉**是另一种派生：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` 时先中断再发。

## 结果：完成消息回到父线程

```mermaid
sequenceDiagram
  participant P as 父线程 /root
  participant C as 子线程 /root/fix_tests
  P->>C: spawn_agent 投递首条任务 触发一轮
  P->>P: 继续干别的 或调用 wait_agent
  C->>C: 模型与工具来回多轮
  C-->>P: 轮次结束 turn_finished 投递 FINAL_ANSWER 不触发新轮次
  Note over P: 信箱有动静 wait_agent 返回
  P->>C: followup_task 追加任务 或 send_message 只排队
```

V2 子线程每轮结束，`maybe_notify_parent_of_terminal_turn`（`codex-rs/core/src/session/mod.rs:2387`）从事件推出终态，调用 `AgentControl::turn_finished`；控制面按任务路径找到直接父线程，把下面拼出的文字作为一条 `trigger_turn` 为假的消息投进父线程的信箱：

```rust
    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/`，作为扩展装进来（扩展机制见[下一篇](https://daiw.net/manual/codex-source/extension-api)），默认存 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](https://daiw.net/manual/llm-to-agent/subagents) 把子 Agent 做成一个工具：`run` 里递归跑一个 Agent Loop，跑完把结论当工具结果返回，并给子 Agent 一套不含 `spawn_agent` 的工具集防止无限套娃。Codex 保留了“上下文隔离、只交回结论”的核心，但把同步的函数调用改成了异步的线程树：`spawn_agent` 立即返回，结论经通知或信箱回来；防套娃靠深度上限（V1）或模型目录（V2）；再加上名额、驻留淘汰、落盘的父子边和恢复。对照 [Grok Build 的 subagents](https://daiw.net/manual/grok-build/subagents)：grok 的只读子代理靠专用工具集硬性裁掉 shell 与编辑工具，Codex 的角色只能关掉少数几个功能开关，权限一律跟父轮次走。

---

上一篇：[MCP 客户端 · 传输与 OAuth 登录](https://daiw.net/manual/codex-source/mcp-client-oauth) · 下一篇：[扩展 API · goal 与内部扩展点](https://daiw.net/manual/codex-source/extension-api)
