# 工具系统总览 · 暴露、注册、路由与并行

> Codex 在每次采样前重新规划一遍工具：spec_plan 依据特性开关、模型目录、提供方能力与执行环境挑出工具登记进 ToolRegistry，再决定每个工具是直接给模型、留给 tool_search 还是只给 Code mode。模型发出的调用在流还没结束时就被派发，一把读写锁区分可并行与需独占的工具，再经 hook、处理器与编排器落地。

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

# 工具系统总览 · 暴露、注册、路由与并行

[run_turn 主循环](https://daiw.net/manual/codex-source/turn-loop)里，模型每要一次工具，Codex 就得回答四个问题：这一步给模型看哪些工具（规划与暴露），模型点名的工具由谁处理（注册与路由），几个调用能不能同时跑（并行），会改动系统的操作要不要先问人、放不放进沙箱（编排）。这一篇把四件事串成一张地图，后面六篇再分头深入。

## 用户看到的样子

在界面上，工具调用就是历史记录里的一条条条目：跑了什么命令、改了哪些文件、调了哪个 MCP 工具、搜了什么网页。决定“这一轮有哪些工具”的配置散在几处：`[features]` 里的开关（`shell_tool`、`unified_exec`、`view_image` 默认开，`code_mode`、`request_permissions_tool` 默认关，见[配置参考](https://daiw.net/manual/codex/config-reference)）；顶层的 `web_search`（`disabled`、`cached`、`indexed`、`live`）决定带不带托管的网页搜索；`tools.update_plan.enabled` 控制计划工具，不写就是关；`[mcp_servers.<名字>]` 里的每个服务器都会把自己的工具加进来（见 [MCP](https://daiw.net/manual/codex/mcp)）；`PreToolUse`、`PostToolUse` hook 还能在执行前后拦截调用（见 [Hooks](https://daiw.net/manual/codex/hooks)）。

## 两层代码：`codex-tools` 与 `core/src/tools`

`codex-tools` crate 的文件头注释说，这里放的是“可以放在 `codex-core` 之外”的共享工具定义与 Responses API 原语：`ToolSpec`（序列化后就是请求里的一个 tool，分 `function`、`namespace`、`tool_search`、`web_search`、`custom` 五种，`custom` 在 Rust 里叫 `Freeform`，`apply_patch` 与 Code mode 的 `exec` 都是它）、`ToolExposure`、JSON Schema 的清洗与压缩（MCP 与动态工具的输入 schema 超过 5,000 字节时，依次去掉描述、去掉 `$defs`，再把第 3 层以下的复杂子结构和 `anyOf` 一类组合整块换成空 schema，直到放得下），以及给延迟加载工具生成检索文本的 `tool_search`。核心契约是 `ToolExecutor`：

```rust
pub trait ToolExecutor<Invocation>: Send + Sync {
    /// The concrete tool name handled by this runtime instance.
    fn tool_name(&self) -> ToolName;

    fn spec(&self) -> ToolSpec;

    /// The preferred exposure before the host applies step-specific policy.
    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    // ...
    fn supports_parallel_tool_calls(&self) -> bool {
        false
    }

    /// Handles one invocation without retaining capabilities borrowed by the host.
    fn handle<'a>(&'a self, invocation: Invocation) -> ToolExecutorFuture<'a>
    where
        Invocation: 'a;
}
```

（`codex-rs/tools/src/tool_executor.rs:106`）

spec 与执行逻辑绑在同一个对象上，模型看到的描述和实际处理器不会走散；`supports_parallel_tool_calls` 默认 `false`，并发要工具自己声明。`codex-core` 在它之上加了 `CoreToolRuntime`（`codex-rs/core/src/tools/registry.rs:56`），补上 hook 负载、遥测标签、所属 MCP 服务器、流式参数 diff 等可选能力。`core/src/tools/` 下的分工是：`spec_plan.rs` 规划，`registry.rs` 注册与分派，`router.rs` 路由，`parallel.rs` 并发，`orchestrator.rs` 审批加沙箱，`handlers/` 是各工具的处理器，`runtimes/` 是真正起进程、写文件的执行后端。

## 每次采样前重建工具表

工具表不是会话级的常量。`run_turn` 每发一次模型请求前都会捕获一个 `StepContext`，注释写着 “Capture once so context, advertised tools, and tool calls share one request view.”——同一次请求里，发给模型的工具清单和随后执行调用的注册表是同一份。捕获过程调用 `turn::built_tools`（`codex-rs/core/src/session/turn.rs:1742`），它先准备插件安装推荐的候选，再交给 `build_tool_router`：

```rust
    let mut registry = ToolRegistry::with_tool_policy(Arc::clone(&session.tool_policy));
    add_core_tool_sources(&context, &mut registry);

    let registered_mcp_tools = session.services.mcp_handler_cache.append_mcp_tools(
        // ...
    );
    apply_mcp_tool_exposure_policy(
        // ...
    );
    let standalone_web_search_tool = append_extension_tool_executors(
        // ...
    );
    append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry);
    let hosted_specs = hosted_model_tool_specs(
        // ...
    );

    finalize_tool_router(
```

（`codex-rs/core/src/tools/spec_plan.rs:150`）

登记顺序就是来源顺序：内置工具（shell 类、MCP 资源类、通用工具、协作工具四组）、MCP 工具、扩展贡献的工具（`ext/` 下各扩展通过 `tools_for_step` 提供，例如独立网页搜索 `web.run`、图片生成 `image_gen.imagegen`）、动态工具（app-server 客户端在线程上声明、由客户端自己执行的工具，处理器发出 `DynamicToolCall` 条目后等客户端回话），最后是只有 spec、由服务端执行的托管工具 `web_search`。决定某个工具进不进来的依据有五类：特性开关、模型目录里的 `ModelInfo`（如 `shell_type`、`apply_patch_tool_type`、`supports_search_tool`）、提供方能力（`namespace_tools`、`web_search`）、执行环境的数量（没有环境就没有 shell、`apply_patch`、`view_image`，多个环境时参数里多一个 `environment_id`），以及扩展 API 的 `ToolPolicy`（`allowed_tools` 一旦设置就是白名单）。

```mermaid
flowchart TB
  RT[run_turn 每次采样前] --> CSC[capture_step_context]
  CSC --> BT[turn::built_tools]
  BT --> BTR[spec_plan::build_tool_router]
  BTR --> S1[内置工具<br/>shell · 资源 · 通用 · 协作]
  BTR --> S2[MCP 工具<br/>McpHandlerCache]
  BTR --> S3[扩展工具<br/>web.run · imagegen 等]
  BTR --> S4[动态工具<br/>客户端声明并执行]
  S1 & S2 & S3 & S4 --> REG[ToolRegistry<br/>IndexMap 按登记顺序]
  BTR --> HS[托管 spec<br/>web_search]
  REG --> FIN[finalize_tool_router<br/>Code mode · tool_search · 冲突检查]
  HS --> FIN
  FIN --> TR[ToolRouter<br/>模型可见 spec + 注册表]
  TR --> SC[StepContext.tool_router]
  SC --> PR[Prompt.tools 发给模型]
```

`finalize_tool_router` 做收尾：Code mode 开启时注册 `exec` 与 `wait` 两个工具，把可嵌套的工具登记成脚本里能调用的函数，`code_mode_only` 下这些工具就不再直接暴露（见 [Code mode](https://daiw.net/manual/codex-source/code-mode)）；模型支持工具搜索、又有带检索信息的延迟工具时，补上 `tool_search`；打开 `features.tool_registry.error_on_tool_collisions` 时，重名或同名命名空间描述不一致会直接报 `ToolCollision`；最后 `build_model_visible_specs` 挑出可直接暴露的工具，把同名 `namespace` 合并、组内按名字排序，交给 `ToolRouter::from_parts`。

## 六种暴露方式

同一个注册好的工具，可以出现在三个“面”上：初始工具清单（`DIRECT`）、`tool_search` 检索结果（`DEFERRED`）、Code mode 脚本里的嵌套调用（`CODE_MODE`）。`ToolExposures` 是这三位的 bitflags，枚举 `ToolExposure` 则是六个合法组合：`Direct`、`DirectModelOnly`、`Deferred`、`DeferredModelOnly`、`CodeModeOnly`、`Hidden`（登记在册、可以分派，但不给模型看）。MCP 工具的换算最能说明问题：

```rust
        tool.exposure = match (
            exposures.contains(ToolExposures::DIRECT),
            exposures.contains(ToolExposures::DEFERRED),
            exposures.contains(ToolExposures::CODE_MODE),
        ) {
            (false, false, false) => ToolExposure::Hidden,
            (false, false, true) => ToolExposure::CodeModeOnly,
            (true, false, false) => ToolExposure::DirectModelOnly,
            (true, false, true) => ToolExposure::Direct,
            (false, true, false) => ToolExposure::DeferredModelOnly,
            (false, true, true) => ToolExposure::Deferred,
            (true, true, _) => unreachable!("direct and deferred exposure are mutually exclusive"),
        };
```

（`codex-rs/core/src/tools/spec_plan.rs:256`）

输入来自两处：服务器配置里的 `omit_tools_from`（ChatGPT 应用连接器还有自己的同名配置）先减掉若干面；然后看模型支不支持工具搜索（`supports_search_tool` 且提供方支持 `namespace_tools`），支持就去掉 `DIRECT` 面、保留 `DEFERRED`，否则去掉 `DEFERRED`。所以在支持搜索的模型上，MCP 工具默认不进初始清单，模型要先调 `tool_search`（BM25 检索，默认返回 8 个）把 schema 取回来。直接暴露与延迟加载互斥，最后一行的 `unreachable!` 就是这条不变式。

## 注册表：先到先得

`ToolRegistry`（`codex-rs/core/src/tools/registry.rs:294`）的主体是 `IndexMap<ToolName, RegisteredTool>`，键是带命名空间的 `ToolName`（不带命名空间的归入默认的 `functions`），保留登记顺序。登记分两种口径：内核自己的工具走 `register_trusted`，重名是程序错误，调试构建直接 panic，发布构建记一条错误日志；外部来源（MCP、扩展、动态工具）走 `register_external`，重名时跳过后来者、记下第一次冲突，而且不许占用保留名 `exec_command` 与 `shell_command`。两种口径都先过 `ToolPolicy` 白名单。

## 分派：边流边执行

模型的流里每完成一个输出项，`handle_output_item_done`（`codex-rs/core/src/stream_events_utils.rs:315`）就用 `ToolRouter::build_tool_call` 看它是不是工具调用——`FunctionCall`、`execution` 为 `client` 的 `ToolSearchCall`、`CustomToolCall` 三种会变成 `ToolCall`，负载分别是 `ToolPayload::Function`、`ToolSearch`、`Custom`。是的话立刻派发，不等整条回复结束：调用本身先写进历史，`ToolCallRuntime::handle_tool_call` 在返回 future 之前就 `tokio::spawn` 了执行任务，future 放进 `FuturesOrdered`；流结束后 `drain_in_flight` 按模型发出调用的顺序收集结果、写回历史，下一次采样就能看到。工具因此和模型的后续输出同时在跑。

```mermaid
sequenceDiagram
  participant M as 模型流
  participant T as try_run_sampling_request
  participant R as ToolCallRuntime
  participant G as ToolRegistry
  participant H as 处理器
  participant O as ToolOrchestrator
  M->>T: output_item.done 一个工具调用
  T->>T: build_tool_call 并把调用写进历史
  T->>R: handle_tool_call 立即 spawn
  R->>R: 可并行的拿读锁 其余拿写锁
  R->>G: dispatch_any_with_state
  G->>G: 查表 · 负载类型检查 · PreToolUse hook
  G->>H: handle
  H->>O: 起进程或写文件时 run
  O-->>H: 审批 · 选沙箱 · 执行 · 必要时重试
  H-->>G: ToolOutput
  G->>G: PostToolUse hook · 生命周期通知
  G-->>R: AnyToolResult
  M->>T: 流结束
  T->>T: drain_in_flight 按顺序写回历史
```

`ToolRegistry::dispatch_any_with_state` 是所有调用的必经之路：找不到工具就回给模型一句 `unsupported call: <工具名>`；负载类型对不上是 `Fatal`；接着跑 `PreToolUse` hook，它可以拦下调用，也可以改写输入；然后通知扩展的生命周期观察者、调用处理器；成功后再跑 `PostToolUse` hook，它可以否决结果，或者用一段反馈文本替换模型看到的输出。错误分两级：`FunctionCallError::RespondToModel` 变成一条 `success: false` 的工具输出交还模型，让它自己纠正；`Fatal` 不产生工具输出，直接作为错误向上抛。hook 的细节见 [Hooks](https://daiw.net/manual/codex-source/hooks)。

## 并行：一把读写锁

每轮采样时 `build_prompt` 总把 `Prompt` 的 `parallel_tool_calls` 设为 `true`（`codex-rs/core/src/session/turn.rs:1561`），但发出的请求还要与模型信息里的 `use_responses_lite` 取反相与（`codex-rs/core/src/client.rs:1005`）：随附目录里除 `gpt-5.5` 外的模型都走 Responses Lite，请求里的值其实是 `false`；为 `true` 时模型可以一次发出多个调用。不管模型一次给几个调用，真正的并发控制在 `ToolCallRuntime`（`codex-rs/core/src/tools/parallel.rs:45`）里，只有一个 `Arc<RwLock<()>>`：

```rust
                let guard = if supports_parallel {
                    Either::Left(lock.read().await)
                } else {
                    Either::Right(lock.write().await)
                };
                // Admission through the parallel-execution gate marks the end
                // of dispatch waiting and the start of handler execution.
```

（`codex-rs/core/src/tools/parallel.rs:202`）

声明可并行的工具拿读锁、彼此并发；其余工具拿写锁，独占执行。0.158.0 的内置工具里声明可并行的有 `exec_command`、`write_stdin`、`view_image`、`tool_search` 与三个 MCP 资源工具，MCP 工具则看服务器配置的 `supports_parallel_tool_calls`，或者工具自己带了只读注解 `readOnlyHint`，扩展工具（如 `web.run`）各自声明；`apply_patch` 没有覆盖默认值，改文件时独占。注册表还加了一道保险：`Hidden` 工具一律按不可并行处理。用户中断时，还没结束的任务被 abort，模型收到 `aborted by user after 1.2s` 这样的结果，`exec_command` 则是带 `Wall time` 的另一种措辞。

## 编排器在链条里的位置

处理器分两种。`update_plan`、`tool_search` 这类纯内核工具在 `handle` 里就做完了；要在执行环境里起进程或写文件的工具（`exec_command`、`apply_patch`），处理器会把请求交给 `ToolOrchestrator::run`，由它按“审批 → 选沙箱 → 执行 → 沙箱拒绝时视策略请求批准并提权重试”的顺序驱动一个实现了 `ToolRuntime<Req, Out>` 的执行后端（`codex-rs/core/src/tools/sandboxing.rs:364`）。注意这是另一个 trait：`CoreToolRuntime` 面向模型，`ToolRuntime` 面向沙箱。审批与沙箱的规则留给[审批流程](https://daiw.net/manual/codex-source/approvals-flow)。命令与补丁的开始、结束事件由 `ToolEmitter`（`codex-rs/core/src/tools/events.rs`）统一发出；命令输出怎样截断、拼成模型看到的文本，下一篇细讲。

## 和《从 LLM 到 Coding Agent》对照

[工具系统的抽象设计](https://daiw.net/manual/llm-to-agent/tool-abstraction)提炼的富工具接口，在 Codex 里拆成了 `ToolExecutor` 加 `CoreToolRuntime`，“并发安全默认 `false`”的约定也一模一样。[边流边执行](https://daiw.net/manual/llm-to-agent/streaming-tool-execution)讲的“工具块一结束就派发”正是 `handle_output_item_done` 的做法，但教学实现把一批调用分成并行组和串行组，Codex 用一把读写锁达到同样效果：不需要事先看到整批调用，来一个排一个。[MCP 与工具懒加载](https://daiw.net/manual/llm-to-agent/mcp)里的 `search_tools` 元工具，对应这里的 `tool_search` 与 `Deferred` 暴露。

---

上一篇：[任务类型与 Plan 模式 · 一轮里跑的不只是对话](https://daiw.net/manual/codex-source/tasks-and-plan-mode) · 下一篇：[执行命令 · shell 与 unified exec](https://daiw.net/manual/codex-source/unified-exec)
