阅读契约。跟踪工具菜单、执行请求和结果记录,判断“可见”“已获准”与“已完成”分别在哪里成立。 本文核实于 2026-09-20,依据公开源码 固定公开源码快照;不推断未公开的服务实现。
先从一个普通动作开始:模型想跑测试。界面上看起来像是“调用 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、code-mode-only 或 hidden 的差别。 |
| 执行请求 | ToolInvocation |
携带 session、step context、call id、取消信号、diff tracker 和 payload。 | 误以为工具只收到模型给出的参数。 |
| 返回证据 | ToolOutput / ResponseInputItem |
把执行结果整理成模型下一轮能消费的内容。 | 只关注终端输出,忽略模型和历史看到的形状。 |
这五层合在一起,给出了一组可以从源码核对的保证:模型只能提出请求;运行时检查工具名、 并行能力、hook、审批和 sandbox,并决定最终以什么形式把结果交还模型。
二、工具菜单与每次模型请求一起固定
每次模型请求使用一份 StepContext。它同时保存这一步的模型信息、设置、环境、MCP binding 和 tool_router。built_tools 从这份输入准备可发现的插件建议,再调用 build_tool_router;build_prompt 把 router.model_visible_specs() 放进 Prompt.tools。
所以菜单可以随下一次 sampling 更新,不只在 turn 起点决定一次。ToolCallRuntime 保留当时的 Arc<StepContext>:即使某个 call 稍后才执行,它仍使用向模型发布该工具时的配置与环境,不会临时读取另一轮菜单。
例如模型看见 exec_command 后发出测试请求,运行时要保留那一步选中的执行环境。后续设置更新可以影响下一步,但不能悄悄把已排队命令送到不同环境。这是同时保存菜单与执行上下文的实际用途。
三、模型可见和运行时注册,是两张表
ToolRouter 仍然分别保存 registry 和 model_visible_specs。build_tool_router 先创建受 allowed-tools 约束的 ToolRegistry,加入核心、MCP、extension 和 dynamic runtimes,再交给 finalize_tool_router 生成模型菜单。hosted model specs 也在最后参与组装。
工具的 ToolExposure 决定它直接显示、延迟搜索、只用于 code mode,还是隐藏;运行时注册不等于所有调用来源都能访问。模型菜单与 code-mode 工具面分别由同一注册表派生,避免把“已有 handler”误解为“模型已经看见并获准调用”。
命令工具是一个容易核对的例子。add_shell_tools 在 UnifiedExec 启用时注册 ExecCommandHandler 与 WriteStdinHandler;托管要求关闭 UnifiedExec 时,改为 ExecCommandHandler::one_shot,不暴露可恢复进程和 write_stdin。当前路径已经不再保留 legacy shell handler 作为 dispatch-only 兼容入口。
四、模型返回 call 后,router 把它变成执行请求
模型流里出现 tool call 时,Codex 不会直接执行原始 JSON。
ToolRouter::build_tool_call
先按响应类型解析:普通 FunctionCall 会变成带 namespace 的 ToolName;
client-side 的 ToolSearchCall 会解析成 tool search payload;
CustomToolCall 会保留 custom input。其他响应项不会变成工具调用。
router 的 dispatch_tool_call_with_terminal_outcome 将 call 包成 ToolInvocation。它包含 session、step_context、取消 token、diff tracker、call id、tool name、来源和 payload;handler 通过 step context 读取这次请求对应的 turn 与环境,然后由 registry 派发。
形状示意:
模型输出 function_call
↓
ToolRouter::build_tool_call(...)
↓
ToolCall { tool_name, call_id, payload }
↓
ToolInvocation { session, step_context, cancellation_token, tracker, source, payload }
↓
ToolRegistry dispatch
这一步解释了为什么工具 handler 能拿到 turn 级别的信息。它处理的是带运行时上下文的请求, 不只是一段参数字符串。
五、registry 包住 hook、生命周期和输出规范化
ToolRegistry 记录工具名冲突,并对外部工具执行 allowed-tools 检查;finalize_tool_router 在 error_on_tool_collisions 启用时把冲突变成构建错误。派发时,registry 检查名称、来源和 payload;不可调用的工具会产生 RespondToModel 错误,模型下一步能够看到原因。
真正值得注意的是,registry 还包住了工具前后的横切逻辑。 在执行 handler 之前,它根据工具提供的 payload 跑 pre-tool-use hooks; hook 可以阻止执行,也可以改写输入。通过后才进入工具启动通知和 handler 执行;部分工具自行管理启动通知。handler 结束后, registry 会记录 telemetry,运行 post-tool-use hooks,必要时追加上下文或替换模型可见输出,再发出 lifecycle finish。
这让 registry 更像一个执行壳,职责远超过按 tool_name 做分发。
handler 返回后的结果还要经过日志、hook、生命周期通知和模型可见输出规范化,才离开 registry。
六、并行和取消由工具声明与 runtime 共同决定
模型请求里有 parallel_tool_calls,但这不表示所有工具都可以同时跑。
ToolCallRuntime
持有一个 parallel_execution: RwLock<()>。
当它处理某个 call 时,会先问 router 这个工具是否支持并行。
具体逻辑在
handle_tool_call_with_source:
支持并行的工具拿读锁,不支持并行的工具拿写锁。这样并行不由一个全局开关决定;
每个工具 runtime 先声明能力,ToolCallRuntime 再据此控制锁。取消也在这里处理:如果 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 流程统一写入。ToolCallRuntime::handle_tool_call 把结果整理为 ResponseItemEnvelope,并附上执行记录元数据;drain_in_flight 收集 future 后调用 record_conversation_items。返回 session id 只说明进程仍在运行,并不是测试已经完成;后续还要读取输出和退出状态。
九、读完工具系统,可以带走的几条规则
| 看到现象 | 源码里重新问 | 对应源码入口 |
|---|---|---|
| 模型能调用某个工具。 | 这个工具是 direct、deferred、code-mode-only,还是 hidden? | 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 为什么不像一个直接执行模型命令的脚本” 讲得更具体。