先从一个普通动作开始:模型想跑测试。界面上看起来像是“调用 shell 工具”,但源码里至少要回答几件事: 这轮模型为什么能看到 shell?它返回的 tool call 怎样被解析?并发执行时谁负责排队? 命令执行前谁检查审批和 sandbox?如果工具失败,模型下一轮看到什么?客户端又从哪里看到 begin / end 事件?

这就是 Codex 工具系统的阅读入口。不要先背工具列表,先抓一条链: 工具先作为 spec 进入模型请求,再作为 call 回到运行时,最后作为 output 和 event 留下证据。 这一篇只讲这条链,不展开每个工具自己的业务逻辑。

读这章只需要先记住四个对象: ToolSpec 是模型看到的菜单; ToolRouter 是本轮的工具路由器; ToolInvocation 是真正交给运行时的执行请求; ResponseInputItem 是工具结果回到下一次模型调用的形状。

证据边界。 本文只描述 openai/codex 公开源码里能看到的工具构造、路由、执行和归档逻辑。 文中把 “工具菜单”“路由器”“执行请求”“证据” 作为阅读辅助词,对应源码里的 ToolSpecToolRouterToolInvocationEventMsgResponseInputItem 等结构。 不推断模型服务内部怎样选择工具,也不推断私有环境里的额外工具。

这一篇追六个问题:

  1. 每一轮模型请求里,工具菜单从哪里来?
  2. 模型可见的工具和运行时注册表为什么要分开看?
  3. 模型输出的 function call 怎样变成 ToolInvocation
  4. 并行、取消、hook 和生命周期通知在哪里介入?
  5. 审批和 sandbox 在工具运行时的哪一段介入?
  6. 工具结果怎样同时进入下一次模型调用、客户端事件和历史记录?

一、先把“工具”拆成四层

“工具”这个词太宽了。它可以指模型提示里的 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_promptrouter.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 自己就把这件事写得很清楚: 结构体里同时有 registrymodel_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 时,会把 ExecCommandHandlerWriteStdinHandler 加进去, 同时把 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。 FunctionToolOutputApplyPatchToolOutputExecCommandToolOutput 都实现了 to_response_item,把结果转成 ResponseInputItem。 对 shell 来说,输出还会包含 wall time、exit code 或 session id、token count 和截断后的输出文本。

客户端看到的进度,则来自事件层。工具事件代码里有 emit_exec_command_beginToolEmitter,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_specsToolRegistry 分开看。
模型发出 function call。 它被解析成哪种 ToolPayload ToolRouter::build_tool_call
工具开始执行。 运行时给 handler 带了哪些 turn 级上下文? ToolInvocation
多个工具似乎并行。 每个工具 runtime 是否声明支持并行? ToolCallRuntime 的读写锁。
命令被审批、被 sandbox 拦住或重试。 它是否进入了 orchestrator 的审批和 sandbox 流程? ToolOrchestrator::run
工具返回输出。 用户、模型、历史分别看到了哪种形状? EventMsgToolOutputResponseInputItem

到这里,工具系统的主线就闭合了:本轮上下文决定工具面,工具面进入模型请求; 模型返回 call,router 解析并构造 invocation;registry 负责派发和横切逻辑; 有副作用的 handler 进入审批和 sandbox;输出再回到模型、事件流和历史。

下一篇可以顺着这条线继续往下挖:当工具真的要碰文件系统、网络或进程时, approval policy、permission hooks、sandbox policy 和 exec policy 到底分别拦在哪里? 这会把 “Codex 为什么不像一个直接执行模型命令的脚本” 讲得更具体。

参考源码