Agent X-Ray
NotesSkillsAbout
Notes/源码拆解/Codex Harness/第8章

第8章:工具系统 —— 注册、路由与延迟加载

9 分钟 · 更新于 2026-09-01

第8章:工具系统 —— 注册、路由与延迟加载

agent 的工具系统在过去两年里换了一个主要矛盾:从"怎么让模型正确调用工具"变成了**"工具太多,喂不进上下文"**。本章拆 Codex 对这个新问题的回答——一个有六档可见性的注册表。


一、注册表与路由器:spec 和 runtime 的分离

1.1 两个对象

rust
pub struct ToolRegistry {                    // 注册表:名字 → 运行时 + 可见性
    tools: IndexMap<ToolName, RegisteredTool>,
    first_collision: Option<ToolName>,
}

pub struct ToolRouter {                      // 路由器:注册表 + 这一步广告出去的 spec
    registry: ToolRegistry,
    model_visible_specs: Arc<[ToolSpec]>,
}

[源码 core/src/tools/registry.rs:271core/src/tools/router.rs:68]

ToolRouter 就是第 4 章 StepContext 里那个字段——"为这次具体的采样请求最终确定的、既用于广告也用于执行的工具方案"

注意 IndexMap 而不是 HashMap工具的注册顺序被保留,因为顺序会影响模型看到的工具列表顺序,而顺序会影响模型的选择倾向。还有 prepend_trusted 方法可以把某个工具插到最前面。

1.2 统一的运行时契约

所有工具——内置的、MCP 的、插件的、动态的——都实现同一个 trait [源码 tools/src/tool_executor.rs:101]:

rust
/// Shared runtime contract for model-visible tools.
///
/// Implementations keep the model-visible spec tied to the executable runtime.
/// Host crates can layer routing, hooks, telemetry, or other orchestration on
/// top without reopening the spec/runtime split.
pub trait ToolExecutor<Invocation>: Send + Sync {
    fn tool_name(&self) -> ToolName;
    fn spec(&self) -> ToolSpec;
    …
}

注释里那句 "keep the model-visible spec tied to the executable runtime"(让模型可见的 spec 和可执行的运行时绑在一起)是关键。常见的做法是 schema 一个地方定义、执行逻辑另一个地方实现,然后两边慢慢漂移——模型看到的参数名和代码里读的参数名对不上,这是 agent 系统里最烦人的一类 bug。

1.3 冲突处理

rust
Entry::Occupied(entry) => {
    let tool_name = entry.key();
    error_or_panic(format!("tool {tool_name} already registered"));
}

同名工具重复注册 = error_or_panic(debug 下 panic,release 下报错)。同时 ToolRegistry 记了个 first_collision 字段——第 4 章的主循环里有一个 CodexErrorDetails::ToolCollision(_) 错误分支,会中断这一轮并告诉用户。

这是 MCP 生态的真实问题:装了两个 MCP 服务器,都提供了一个叫 search 的工具。Codex 的解法是命名空间ToolNamenamespace 字段,with_default_namespace() 补默认值),冲突时明确报错而不是静默覆盖。


二、六档可见性:本章的核心

ToolExposure 有六个变体 [源码 tools/src/tool_executor.rs:51]。这是整个工具系统里最值得研究的设计:

变体初始工具列表可被 tool_search 发现可在 Code Mode 里调
Direct——
DirectModelOnly——
Deferred
DeferredModelOnly
CodeModeOnly
Hidden

三个维度(是否直出、是否可搜、是否进 Code Mode)交叉出六种组合。原始注释:

  • Direct: "Include this tool in the initial model-visible tool list. When code mode is enabled, this tool is also available as a nested code-mode tool."
  • Deferred: "Register this tool for later discovery, but omit it from the initial model-visible tool list. Deferred tools must provide search metadata via ToolExecutor::search_info."
  • CodeModeOnly: "Expose this tool only to nested Code Mode calls, without including it in the initial model-visible tool list or making it available to tool search."
  • Hidden: "Keep this tool registered for dispatch without exposing it to the model."

2.1 为什么需要 Deferred

一个装了 5 个 MCP 服务器的会话,可能有 100+ 个工具。每个工具的 JSON schema 平均几百 token,光工具列表就能吃掉几万 token——在模型说第一句话之前

Deferred 的意思是:注册但不广告。模型的初始工具列表里没有它,但有一个叫 tool_search 的工具。模型需要什么就搜什么,搜到的工具在下一次模型调用时才出现在工具列表里。

tool_search 的描述模板 [源码 core/templates/search_tool/tool_description.md]:

Searches over apps/connectors tool metadata with BM25 and exposes matching tools for the next model call.

You have access to all the tools of the following apps/connectors: {{app_descriptions}} Some of the tools may not have been provided to you upfront, and you should use this tool (tool_search) to search for the required tools and load them for the apps mentioned above.

三个细节:

  1. BM25,不是向量检索。工具名和描述是短文本、关键词密集,BM25 在这个场景上又快又准,而且不需要 embedding 服务
  2. "exposes matching tools for the next model call" —— 加载是异步的,这一轮搜、下一轮用
  3. {{app_descriptions}} 是模板变量 —— 提示词里会列出有哪些 app 可搜,模型不至于盲搜

2.2 Hidden 有什么用

"注册但不暴露给模型"听起来矛盾,但有用:

  • 工具在 MCP 服务器还没连上时先占位(wait_until_ready
  • 内部调度用的工具(比如多 agent 之间的消息传递)
  • 被策略临时禁用但不想从注册表里摘掉的工具

supports_parallel_tool_calls 的实现里就用到了它 [源码 registry.rs:472]:

rust
Some(tool.exposure != ToolExposure::Hidden && tool.runtime.supports_parallel_tool_calls())

可迁移的判断 ⑬ 工具可见性不是布尔值。 至少要拆成三个独立维度:初始是否直出、是否可被检索发现、是否能被"代码化"调用。

只用 on/off 的系统迟早会遇到"我想让模型能用但别占初始上下文"这个需求,然后打各种补丁。Codex 用一个六变体枚举一次性把这个空间划清楚了。

对本仓的启示:本仓 35+ 个 skill 也面临同一个问题——全部塞进上下文太贵。Claude Code 的解法(disable-model-invocation + description 触发匹配)本质上就是 Direct / Deferred 两档。


三、内置工具清单

core/src/tools/handlers/ 下的实现 [源码,实测]:

工具作用
apply_patch文件编辑(下一节详述)
unified_exec命令执行(第 9 章)
view_image看图
plan计划工具
current_time当前时间
sleep等待
get_context_remaining问自己还剩多少上下文
new_context_window主动要求开一个新上下文窗口
request_user_input向用户提问(带选项与"其它"自由输入)
request_permissions请求权限升级
tool_search工具检索
request_plugin_install请求安装插件
list_available_plugins_to_install列可装插件
mcp_resource读 MCP 资源
multi_agents / multi_agents_v2多 agent(第 14 章)
send_user_message_async异步给用户发消息
wait_for_environment等环境就绪
extension_tools / dynamic扩展与动态工具

有四个值得单独说,因为它们代表了工具设计的新方向:

get_context_remaining + new_context_window —— 把上下文管理暴露给模型自己。模型可以问"我还剩多少",也可以说"我需要开个新窗口"。第 7 章讲的 token budget 模式里,new_context 是模型主动发起的请求。这是从"harness 替模型管上下文"到"模型参与管理自己的上下文"的转变。

request_plugin_install —— 模型可以请求安装一个它现在没有的能力。配合 list_available_plugins_to_install,形成"我需要 X → 有没有提供 X 的插件 → 请求安装"的闭环。第 13 章会讲这条链。

request_user_input —— 提问是一等工具,不是特例。协议里有对应的 EventMsg::RequestUserInput / Op::UserInputAnswer(第 3 章),支持选项列表加 isOther 自由输入。


四、apply_patch:一个自定义 diff 方言

Codex 不用标准 unified diff,而是自己定义了一种格式。语法用 Lark 文法写死在仓库里 [源码 core/src/tools/handlers/apply_patch.lark]:

lark
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?

hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?

change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF

长这样:

text
*** Begin Patch
*** Update File: src/main.rs
@@ fn main() {
-    println!("hello");
+    println!("world");
*** End Patch

为什么不用 unified diff?三个原因:

  1. 不需要行号。unified diff 的 @@ -12,7 +12,9 @@ 要求模型准确数行数——模型数不准。这里的 @@ 后面跟的是上下文文本(比如函数签名),靠内容定位
  2. 多文件操作在一个补丁里:Add / Delete / Update / Move 四种操作可以混在同一个 patch
  3. 有明确的起止标记,流式输出时能判断补丁是否完整

把文法写成 Lark 文件的意义在于它可以被喂给模型——这是一种可以放进提示词的、无歧义的格式说明。

apply_patch 还有三种执行路径:作为工具调用、作为 argv[1] 分发的内部路径(第 2 章的 CODEX_CORE_APPLY_PATCH_ARG1)、作为 PATH 里的独立命令(arg0 符号链接)。同一份实现,三种调用方式。

可迁移的判断 ⑭ 需要模型产出结构化编辑时,设计一种"不要求计数、靠内容定位、有明确边界"的格式,并把文法写成可以放进提示词的形式。

模型不擅长的:数行号、数缩进、闭合括号。模型擅长的:复述看到过的文本片段。apply_patch 的格式设计完全建立在这个认知上。


五、并行工具调用

ToolCallRuntime 管理并行执行 [源码 core/src/tools/parallel.rs:39]:

rust
pub(crate) struct ToolCallRuntime {
    session: Arc<Session>,
    // Tool calls may run later, so retain the step whose tool list advertised them.
    step_context: Arc<StepContext>,
    tracker: SharedTurnDiffTracker,
    parallel_execution: Arc<RwLock<()>>,
}

两个设计点:

① 保留广告它的那个 step。 注释直说了原因:工具调用可能晚一点才跑,那时 StepContext 可能已经换了。必须用当初广告这个工具时的那份视图去执行它——又是第 4 章那条原则。

Arc<RwLock<()>> 一个空元组的读写锁。 这是个纯粹的并发闸门:支持并行的工具拿读锁(可以多个同时进),不支持并行的工具拿写锁(独占)。数据本身是 (),锁只用来协调。

哪些工具能并行由工具自己声明:

rust
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
    self.registry.supports_parallel_tool_calls(&call.tool_name).unwrap_or(false)
}

默认不并行unwrap_or(false))。读文件可以并行,写文件不行——这个判断留给工具实现者,框架不猜。


六、工具调用的完整流水线

把散落的部分串起来,一次工具调用要过这些关:

text
模型输出 FunctionCall
    ↓
ToolRouter::build_tool_call  ← 解析成 ToolCall(含命名空间解析)
    ↓
PreToolUse 钩子              ← 可以改参数、可以直接拒绝(第 13 章)
    ↓
审批判定                     ← safety.rs / execpolicy / Guardian(第 11 章)
    ↓
沙箱包装                     ← 按 SandboxPolicy 包命令(第 10 章)
    ↓
wait_until_ready             ← MCP 服务器还没连上就等
    ↓
并发闸门                     ← 支持并行拿读锁,否则写锁
    ↓
实际执行                     ← runtime.execute()
    ↓
PostToolUse 钩子             ← 可以改结果
    ↓
on_tool_result_accepted      ← 「所有 PostToolUse 钩子都接受之后」才回调
    ↓
写回历史 + 发事件            ← ToolCallBegin/End 事件(第 3 章)

on_tool_result_accepted 的注释值得注意 [源码 registry.rs:93]:

Observes a tool result only after all PostToolUse hooks accept it.

(只在所有 PostToolUse 钩子都接受之后才观察工具结果。)

工具自己想记录/统计结果时,看到的必须是钩子处理后的最终结果,不是原始结果。否则钩子改了结果,工具的内部统计就和历史里的对不上。

对照 dsh 教程第 7 章的"五段流水线"——两者形状高度相似,都是"解析 → 前置拦截 → 权限 → 执行 → 后置处理"。这个形状大概已经是行业共识了。


七、Code Mode:工具作为代码

ToolExposure 里反复出现的 "code mode" 需要交代一下。

相关 crate 有四个:code-mode(9402 行)、code-mode-host(9202)、code-mode-runtime(7121)、code-mode-protocol(4532),另有一个 v8-poccode-mode-runtime 下有 v8_init.rs——它跑的是 V8

Code Mode 的思路是:与其让模型一次调一个工具、等结果、再调下一个,不如让它写一段 JavaScript,在这段代码里调用多个工具、做循环和条件判断,一次执行完。

这不是 Codex 独有的想法(dsh 教程第 7 章也讲了 Code Mode),但 Codex 把它做成了一个独立进程 + 独立协议codex-code-mode-host 是个独立二进制),而不是在主进程里嵌 V8。

从工具系统的角度看,Code Mode 的影响就是那六档可见性里的第三个维度:有些工具适合在代码里调(可以循环、可以组合),有些只适合模型直接调(比如 request_user_input——在循环里问用户十次是灾难)。DirectModelOnlyDeferredModelOnly 就是为后者准备的。


八、动手复核

bash
cd codex/codex-rs

# 1. 六档可见性及其语义
sed -n '45,100p' tools/src/tool_executor.rs

# 2. 注册表的冲突处理
sed -n '300,330p' core/src/tools/registry.rs

# 3. 内置工具清单
ls core/src/tools/handlers/*.rs | sed 's/.*\///' | grep -v tests | grep -v _spec

# 4. apply_patch 的文法
cat core/src/tools/handlers/apply_patch.lark

# 5. tool_search 的提示词
cat core/templates/search_tool/tool_description.md

# 6. 并行闸门
sed -n '39,60p' core/src/tools/parallel.rs

# 7. Code Mode 四件套规模
for c in code-mode code-mode-host code-mode-runtime code-mode-protocol; do
  echo -n "$c: "; find $c -name '*.rs' -print0 | xargs -0 cat | wc -l
done

九、总结

  1. spec 和 runtime 绑在一起,避免"模型看到的参数名和代码读的参数名对不上"
  2. 六档可见性是本章核心:直出 / 可搜 / 可 Code Mode 三个维度交叉。工具可见性从来不是布尔值
  3. Deferred + tool_search(BM25)解决"工具太多喂不下",加载是异步的:这轮搜、下轮用
  4. 四个新方向的工具get_context_remaining / new_context_window(模型参与管理自己的上下文)、request_plugin_install(模型请求新能力)、request_user_input(提问是一等工具)
  5. apply_patch 是为模型的能力边界设计的格式:不数行号、靠内容定位、有明确边界、文法可放进提示词
  6. 工具调用可能晚于广告它的那一步执行,所以要保留当时的 StepContext
  7. 并行由工具自己声明,默认不并行Arc<RwLock<()>> 当纯闸门用

下一章看工具里最重的那个:跑一条命令,为什么需要 5000 行代码。


  • 第7章-上下文压缩-本地与远端两条路
  • 第9章-命令执行-快照统一执行与后台终端
  • 第13章-扩展面-MCP技能钩子插件与CodeMode —— MCP / 插件工具如何进入注册表
  • Pi 教程第 5 章
  • dsh 教程第 7 章 —— 五段流水线与 Code Mode 的对照

本章目录
一、注册表与路由器:spec 和 runtime 的分离二、六档可见性:本章的核心三、内置工具清单四、applypatch:一个自定义 diff 方言五、并行工具调用六、工具调用的完整流水线七、Code Mode:工具作为代码八、动手复核九、总结Related Documents
苏ICP备2025204887号-2