agent 的工具系统在过去两年里换了一个主要矛盾:从"怎么让模型正确调用工具"变成了**"工具太多,喂不进上下文"**。本章拆 Codex 对这个新问题的回答——一个有六档可见性的注册表。
[源码 core/src/tools/registry.rs:271、core/src/tools/router.rs:68]
ToolRouter 就是第 4 章 StepContext 里那个字段——"为这次具体的采样请求最终确定的、既用于广告也用于执行的工具方案"。
注意 IndexMap 而不是 HashMap:工具的注册顺序被保留,因为顺序会影响模型看到的工具列表顺序,而顺序会影响模型的选择倾向。还有 prepend_trusted 方法可以把某个工具插到最前面。
所有工具——内置的、MCP 的、插件的、动态的——都实现同一个 trait [源码 tools/src/tool_executor.rs:101]:
注释里那句 "keep the model-visible spec tied to the executable runtime"(让模型可见的 spec 和可执行的运行时绑在一起)是关键。常见的做法是 schema 一个地方定义、执行逻辑另一个地方实现,然后两边慢慢漂移——模型看到的参数名和代码里读的参数名对不上,这是 agent 系统里最烦人的一类 bug。
同名工具重复注册 = error_or_panic(debug 下 panic,release 下报错)。同时 ToolRegistry 记了个 first_collision 字段——第 4 章的主循环里有一个 CodexErrorDetails::ToolCollision(_) 错误分支,会中断这一轮并告诉用户。
这是 MCP 生态的真实问题:装了两个 MCP 服务器,都提供了一个叫 search 的工具。Codex 的解法是命名空间(ToolName 带 namespace 字段,with_default_namespace() 补默认值),冲突时明确报错而不是静默覆盖。
ToolExposure 有六个变体 [源码 tools/src/tool_executor.rs:51]。这是整个工具系统里最值得研究的设计:
| 变体 | 初始工具列表 | 可被 tool_search 发现 | 可在 Code Mode 里调 |
|---|---|---|---|
| Direct | ✅ | —— | ✅ |
| DirectModelOnly | ✅ | —— | ❌ |
| Deferred | ❌ | ✅ | ✅ |
| DeferredModelOnly | ❌ | ✅ | ❌ |
| CodeModeOnly | ❌ | ❌ | ✅ |
| Hidden | ❌ | ❌ | ❌ |
三个维度(是否直出、是否可搜、是否进 Code Mode)交叉出六种组合。原始注释:
一个装了 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.
三个细节:
"注册但不暴露给模型"听起来矛盾,但有用:
supports_parallel_tool_calls 的实现里就用到了它 [源码 registry.rs:472]:
可迁移的判断 ⑬ 工具可见性不是布尔值。 至少要拆成三个独立维度:初始是否直出、是否可被检索发现、是否能被"代码化"调用。
只用 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 自由输入。
Codex 不用标准 unified diff,而是自己定义了一种格式。语法用 Lark 文法写死在仓库里 [源码 core/src/tools/handlers/apply_patch.lark]:
长这样:
为什么不用 unified diff?三个原因:
把文法写成 Lark 文件的意义在于它可以被喂给模型——这是一种可以放进提示词的、无歧义的格式说明。
apply_patch 还有三种执行路径:作为工具调用、作为 argv[1] 分发的内部路径(第 2 章的 CODEX_CORE_APPLY_PATCH_ARG1)、作为 PATH 里的独立命令(arg0 符号链接)。同一份实现,三种调用方式。
可迁移的判断 ⑭ 需要模型产出结构化编辑时,设计一种"不要求计数、靠内容定位、有明确边界"的格式,并把文法写成可以放进提示词的形式。
模型不擅长的:数行号、数缩进、闭合括号。模型擅长的:复述看到过的文本片段。apply_patch 的格式设计完全建立在这个认知上。
ToolCallRuntime 管理并行执行 [源码 core/src/tools/parallel.rs:39]:
两个设计点:
① 保留广告它的那个 step。 注释直说了原因:工具调用可能晚一点才跑,那时 StepContext 可能已经换了。必须用当初广告这个工具时的那份视图去执行它——又是第 4 章那条原则。
② Arc<RwLock<()>> 一个空元组的读写锁。 这是个纯粹的并发闸门:支持并行的工具拿读锁(可以多个同时进),不支持并行的工具拿写锁(独占)。数据本身是 (),锁只用来协调。
哪些工具能并行由工具自己声明:
默认不并行(unwrap_or(false))。读文件可以并行,写文件不行——这个判断留给工具实现者,框架不猜。
把散落的部分串起来,一次工具调用要过这些关:
on_tool_result_accepted 的注释值得注意 [源码 registry.rs:93]:
Observes a tool result only after all PostToolUse hooks accept it.
(只在所有 PostToolUse 钩子都接受之后才观察工具结果。)
工具自己想记录/统计结果时,看到的必须是钩子处理后的最终结果,不是原始结果。否则钩子改了结果,工具的内部统计就和历史里的对不上。
对照 dsh 教程第 7 章的"五段流水线"——两者形状高度相似,都是"解析 → 前置拦截 → 权限 → 执行 → 后置处理"。这个形状大概已经是行业共识了。
ToolExposure 里反复出现的 "code mode" 需要交代一下。
相关 crate 有四个:code-mode(9402 行)、code-mode-host(9202)、code-mode-runtime(7121)、code-mode-protocol(4532),另有一个 v8-poc。code-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——在循环里问用户十次是灾难)。DirectModelOnly 和 DeferredModelOnly 就是为后者准备的。
下一章看工具里最重的那个:跑一条命令,为什么需要 5000 行代码。