先从一个普通动作开始:模型想跑测试。界面上看起来像是“调用 shell 工具”,但源码里至少要回答几件事: 这轮模型为什么能看到 shell?它返回的 tool call 怎样被解析?并发执行时谁负责排队? 命令执行前谁检查审批和 sandbox?如果工具失败,模型下一轮看到什么?客户端又从哪里看到 begin / end 事件?
这就是 Codex 工具系统的阅读入口。不要先背工具列表,先抓一条链: 工具先作为 spec 进入模型请求,再作为 call 回到运行时,最后作为 output 和 event 留下证据。 这一篇只讲这条链,不展开每个工具自己的业务逻辑。
读这章只需要先记住四个对象:
ToolSpec 是模型看到的菜单;
ToolRouter 是本轮的工具路由器;
ToolInvocation 是真正交给运行时的执行请求;
ResponseInputItem 是工具结果回到下一次模型调用的形状。
证据边界。
本文只描述 openai/codex 公开源码里能看到的工具构造、路由、执行和归档逻辑。
文中把 “工具菜单”“路由器”“执行请求”“证据” 作为阅读辅助词,对应源码里的
ToolSpec、ToolRouter、ToolInvocation、
EventMsg 和 ResponseInputItem 等结构。
不推断模型服务内部怎样选择工具,也不推断私有环境里的额外工具。
这一篇追六个问题:
- 每一轮模型请求里,工具菜单从哪里来?
- 模型可见的工具和运行时注册表为什么要分开看?
- 模型输出的 function call 怎样变成
ToolInvocation? - 并行、取消、hook 和生命周期通知在哪里介入?
- 审批和 sandbox 在工具运行时的哪一段介入?
- 工具结果怎样同时进入下一次模型调用、客户端事件和历史记录?
一、先把“工具”拆成四层
“工具”这个词太宽了。它可以指模型提示里的 schema,也可以指 shell、apply patch、MCP、 插件、动态工具、多 agent 协作工具,甚至还可以指客户端后来通过 tool search 暴露出来的工具。 如果不先分层,读源码时会反复混淆“模型能看见”和“运行时能分发”。
| 层次 | 源码对象 | 人话职责 | 读错会怎样 |
|---|---|---|---|
| 模型菜单 | ToolSpec |
告诉模型本轮可以怎样发起工具请求。 | 把 schema 误以为已经有执行权限。 |
| 运行时索引 | ToolRegistry |
按工具名找到真正的 handler,并保存工具能力元数据。 | 以为只要不在 prompt 里,就不会被运行时处理。 |
| 本轮路由 | ToolRouter |
把模型输出转成 tool call,再派发到 registry。 | 看不见 direct、deferred、dispatch-only 的差别。 |
| 执行请求 | ToolInvocation |
携带 session、turn、call id、取消信号、diff tracker 和 payload。 | 误以为工具只收到模型给出的参数。 |
| 返回证据 | ToolOutput / ResponseInputItem |
把执行结果整理成模型下一轮能消费的内容。 | 只关注终端输出,忽略模型和历史看到的形状。 |
这五层合在一起,才是 Codex 的工具合约。模型只提出请求;运行时决定这个请求能否被识别、 能否并发、是否要经过 hook、是否要审批和 sandbox、最后以什么形式回到模型。
二、本轮工具菜单:按 turn 生成
工具菜单是在每次 sampling request 前构造的。
run_sampling_request
先调用 built_tools(sess, turn_context, cancellation_token),拿到本轮
ToolRouter,再创建 ToolCallRuntime。紧接着
build_prompt
把 router.model_visible_specs() 塞进 Prompt.tools,
同时把模型是否支持 parallel tool calls 写进 prompt。
所以工具面会跟随本轮 TurnContext 变化:
当前环境有没有 shell,模型是否支持某种工具形态,MCP server 暴露了什么,插件启用了哪些 app,
dynamic tools 是否传进来了,都会影响这一轮的 router。
built_tools
里能看到这一步的来源:它加载 MCP tools、plugins、connectors/apps、discoverable tools,
构造 MCP exposure,再把 direct MCP、deferred MCP、extension executors 和 dynamic tools
交给 ToolRouter::from_turn_context。
这里的关键点是 turn-scoped: 同一个 Codex 进程里,不同 turn 的工具面可以不同。读工具系统时,先问“本轮 router 怎么构造”, 比先问“仓库里一共有多少工具”更接近源码真实路径。
三、模型可见和运行时注册,是两张表
ToolRouter 自己就把这件事写得很清楚:
结构体里同时有 registry 和 model_visible_specs。
前者用于派发,后者用于进模型请求。两者相关,却承担不同职责。
这两张表在
build_tool_specs_and_registry
里一起生成。源码先创建 PlannedTools,依次加入 shell、MCP resource、核心工具、
collaboration、MCP runtime、extension、dynamic 和 hosted model specs;然后追加 tool search executor,
再把 code mode executors 插到前面。
真正把两张表分开的,是
build_model_visible_specs_and_registry:
它遍历 planned runtimes,只把 direct exposure 且没有被 code-mode-only 隐藏的工具做成 model-visible spec;
但 registry 会从所有 runtimes 生成。也就是说,某个工具可以对模型不可见,却仍然在运行时里保留派发能力。
一个具体例子在 shell 工具里。
add_shell_tools
选择 unified exec 时,会把 ExecCommandHandler 和 WriteStdinHandler 加进去,
同时把 legacy shell handler 以 dispatch-only 的方式保留。模型看到的是新工具面,
运行时还保留旧 call 的处理路径,这样兼容性不会被工具 schema 的变化直接打断。
四、模型返回 call 后,router 把它变成执行请求
模型流里出现 tool call 时,Codex 不会直接执行原始 JSON。
ToolRouter::build_tool_call
先按响应类型解析:普通 FunctionCall 会变成带 namespace 的 ToolName;
client-side 的 ToolSearchCall 会解析成 tool search payload;
CustomToolCall 会保留 custom input。其他响应项不会变成工具调用。
接下来,
dispatch_tool_call_with_code_mode_result_inner
会把这次 call 包成
ToolInvocation。
这里才是执行请求的完整形状:它不只包含模型参数,还包含 session、turn、取消 token、
diff tracker、call id、tool name、来源和 payload。最后 router 调用 registry 的
dispatch_any_with_terminal_outcome。
形状示意:
模型输出 function_call
↓
ToolRouter::build_tool_call(...)
↓
ToolCall { tool_name, call_id, payload }
↓
ToolInvocation { session, turn, cancellation_token, tracker, source, payload }
↓
ToolRegistry dispatch
这一步解释了为什么工具 handler 能拿到 turn 级别的信息。它处理的是带运行时上下文的请求, 不只是一段参数字符串。
五、registry 包住 hook、生命周期和输出规范化
ToolRegistry 首先是一张从工具名到 runtime 的表。
from_tools
会拒绝重复工具名。派发时,
dispatch_any_with_terminal_outcome
先更新 active turn 的工具计数,再按名字找 handler。找不到工具时,它不会让系统无声失败,
它会生成 RespondToModel 错误,让模型下一步能看到这次调用不可用。
真正值得注意的是,registry 还包住了工具前后的横切逻辑。 在执行 handler 之前,它会发出 lifecycle start,并根据工具提供的 payload 跑 pre-tool-use hooks; hook 可以阻止执行,也可以改写输入。handler 结束后, registry 会记录 telemetry,运行 post-tool-use hooks,必要时追加上下文或替换模型可见输出,再发出 lifecycle finish。
这让 registry 更像一个执行壳,职责远超过按 tool_name 做分发。
工具结果在离开 handler 前,已经经过了日志、hook、生命周期通知和模型可见输出规范化。
六、并行和取消:按工具能力收口
模型请求里有 parallel_tool_calls,但这不表示所有工具都可以同时跑。
ToolCallRuntime
持有一个 parallel_execution: RwLock<()>。
当它处理某个 call 时,会先问 router 这个工具是否支持并行。
具体逻辑在
handle_tool_call_with_source:
支持并行的工具拿读锁,不支持并行的工具拿写锁。这样并行不由一个全局开关决定;
每个工具 runtime 先声明能力,再由统一运行时收口。取消也在这里处理:如果 turn 被取消,runtime 会决定等待工具自己清理,
或者 abort task,然后生成一个 aborted response 并通知 lifecycle。
所以 parallel tool calls 的源码含义更精确:模型可以发出并行意图,
但实际并发度由工具 runtime 的能力和 ToolCallRuntime 的锁策略共同决定。
七、有副作用的工具,还要进审批和 sandbox
shell、apply patch 这类工具会影响外部环境,所以它们还会进入更窄的运行时关口。
orchestrator.rs 文件头
直接说明它负责 approvals、sandbox selection 和 retry semantics。
它的主流程在
ToolOrchestrator::run:
先判断是否跳过、禁止或需要审批,再选择初次 sandbox,最后运行工具 attempt。
如果第一次 sandboxed attempt 被拒绝,后面还有一段 retry 逻辑。 源码会根据 denial、网络策略、approval policy 和工具是否允许升级来决定是否再次请求审批,并选择 retry sandbox。 这就是为什么“模型请求执行命令”和“命令真的执行”之间还有一段运行时判断。
这部分下一篇会单独展开。这里先把边界放清楚:审批和 sandbox 不靠 prompt 里的文字约定完成; 它们是工具 handler 进入真实副作用之前必须经过的运行时路径。
八、工具结果会回到模型和历史
工具跑完后,结果首先要变成模型下一轮能读的 shape。
FunctionToolOutput、
ApplyPatchToolOutput、
ExecCommandToolOutput
都实现了 to_response_item,把结果转成 ResponseInputItem。
对 shell 来说,输出还会包含 wall time、exit code 或 session id、token count 和截断后的输出文本。
客户端看到的进度,则来自事件层。工具事件代码里有
emit_exec_command_begin
和 ToolEmitter,shell、apply patch、unified exec 会发 begin / end 或 file change 相关事件。
生命周期扩展还会通过
notify_tool_start / notify_tool_finish
通知 extension contributors。
历史记录在 turn 流程里收口。
drain_in_flight
会等 in-flight 工具 future 完成,把 ResponseInputItem 转成 ResponseItem,
调用 record_conversation_items 记录下来。这样工具输出既能进入下一轮模型输入,
也能成为后续恢复和审计的一部分。
九、读完工具系统,可以带走的几条规则
| 看到现象 | 源码里重新问 | 抓手 |
|---|---|---|
| 模型能调用某个工具。 | 这个工具是 direct、deferred,还是只在 registry 里 dispatch-only? | model_visible_specs 与 ToolRegistry 分开看。 |
| 模型发出 function call。 | 它被解析成哪种 ToolPayload? |
ToolRouter::build_tool_call。 |
| 工具开始执行。 | 运行时给 handler 带了哪些 turn 级上下文? | ToolInvocation。 |
| 多个工具似乎并行。 | 每个工具 runtime 是否声明支持并行? | ToolCallRuntime 的读写锁。 |
| 命令被审批、被 sandbox 拦住或重试。 | 它是否进入了 orchestrator 的审批和 sandbox 流程? | ToolOrchestrator::run。 |
| 工具返回输出。 | 用户、模型、历史分别看到了哪种形状? | EventMsg、ToolOutput、ResponseInputItem。 |
到这里,工具系统的主线就闭合了:本轮上下文决定工具面,工具面进入模型请求; 模型返回 call,router 解析并构造 invocation;registry 负责派发和横切逻辑; 有副作用的 handler 进入审批和 sandbox;输出再回到模型、事件流和历史。
下一篇可以顺着这条线继续往下挖:当工具真的要碰文件系统、网络或进程时, approval policy、permission hooks、sandbox policy 和 exec policy 到底分别拦在哪里? 这会把 “Codex 为什么不像一个直接执行模型命令的脚本” 讲得更具体。
参考源码
- openai/codex 固定源码快照
- build_prompt 把 router.model_visible_specs() 放进 Prompt.tools
- run_sampling_request 构造 ToolRouter 和 ToolCallRuntime
- built_tools
- ToolRouter 的 registry 与 model_visible_specs
- ToolRouter::build_tool_call
- router 构造 ToolInvocation 并派发
- build_tool_specs_and_registry
- add_tool_sources
- add_shell_tools
- MCP runtime tools、dynamic tools、extension tools、tool search
- ToolInvocation
- ToolOutput 到 ResponseInputItem 的转换
- ToolRegistry
- dispatch_any_with_terminal_outcome
- ToolCallRuntime 并行与取消控制
- ToolOrchestrator 文件头说明
- ToolOrchestrator::run 审批、sandbox 与 retry
- 工具事件发射
- 工具 lifecycle contributors
- drain_in_flight 记录工具输出