阅读契约。跟踪工具菜单、执行请求和结果记录,判断“可见”“已获准”与“已完成”分别在哪里成立。 本文核实于 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 等结构。 不推断模型服务内部怎样选择工具,也不推断私有环境里的额外工具。

这一篇追六个问题:

  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、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 为什么不像一个直接执行模型命令的脚本” 讲得更具体。

参考源码