ARTICLE · 1148746
Codex 源码-Tool Registry 与 Spec Plan
Codex 源码解析系列
第 15 讲:Tool Registry 与 Spec Plan
基于 OpenAI Codex 源码 · 2026-10-09
💡 本讲一句话:Codex 的工具系统分两层——ToolRegistry(core/src/tools/registry.rs)管"谁能被调用",spec_plan(core/src/tools/spec_plan.rs)管"模型每轮能看到谁"。前者用 trusted/external 双轨注册防冲突,后者按 feature flag、环境、MCP server、Code Mode 现场裁剪出六档曝光位;所有调用最终汇入 dispatch_any_with_terminal_outcome 一条流水线:PreToolUse hooks → 执行 → PostToolUse hooks。读完你能说清一个工具从"注册"到"模型可见"再到"被调用"的完整链路。
一、为什么工具清单不能是静态的
第 14 讲我们拆了上下文压缩——那是"历史太长怎么办"。本讲换个方向:模型每轮请求里带的 tools 列表,到底是谁、按什么规则拼出来的?答案藏在 core/src/tools/ 这个目录里,核心就四个文件:registry.rs(826 行)、spec_plan.rs(1552 行)、router.rs(385 行)、context.rs。工具清单必须动态,原因至少有四个:feature flag 开关(UnifiedExec、TokenBudget……)随时变;MCP server 是用户配置的,每个 server 的工具集不同;Code Mode 开启后嵌套工具面要重新洗牌;Guardian 审查会话只允许三个工具。所以 Codex 的做法是:每个 turn 现场 build 一份 ToolRegistry + 模型可见 spec,而不是全局单例。
📌 本讲地图:① ToolRegistry 双轨注册(trusted vs external)→ ② dispatch 单入口流水线 → ③ spec_plan 按 turn 组装计划 → ④ MCP 曝光位策略 → ⑤ 模型可见 spec 与命名空间合并。每节都带真实源码行号。
二、ToolRegistry:双轨注册,冲突策略完全不同
先看数据结构本体。整个 registry 就两个字段——一个保序的 IndexMap,加一个"第一次撞名"的记录:
📄 codex-rs/core/src/tools/registry.rs (第 286-350 行)
#[derive(Default)] // 派生 Default:空 registry,两个字段都有默认值pub struct ToolRegistry { // 工具注册表本体——一个 turn 一份,不是全局单例 tools: IndexMap<ToolName, RegisteredTool>, // 保序映射:插入顺序即模型可见顺序(IndexMap 而非 HashMap) first_collision: Option<ToolName>, // 只记第一次撞名的工具名——后面统一报错用,不覆盖}impl ToolRegistry { // 注册表方法集——下面对比 trusted/external 双轨 pub(crate) fn register_trusted_with_exposure( // trusted = Codex 自己内置的工具,注册冲突是编程错误 &mut self, runtime: Arc<dyn CoreToolRuntime>, exposure: ToolExposure, // 参数:运行时实例 + 指定曝光位(trusted/external 共用签名) ) { let tool_name = runtime.tool_name().with_default_namespace(); // 补默认命名空间,保证 key 归一化 match self.tools.entry(tool_name) { // entry API:一次拿到"空位/已占"两种情况 Entry::Vacant(entry) => { // 名字没被占 → 直接插入 entry.insert(RegisteredTool { runtime, exposure }); // 存入"运行时+曝光位"组合体,保序追加到 IndexMap 尾部 } Entry::Occupied(entry) => { // 内置工具撞名 = 代码 bug,不能静默吞掉 let tool_name = entry.key(); // 取出已占用的名字用于报错信息 error_or_panic(format!("tool {tool_name} already registered")); // fail loud:测试里 panic、生产里报错退出 } } } pub(crate) fn register_external_with_exposure( // external = MCP/扩展带来的工具,撞名是常态要容忍 &mut self, runtime: Arc<dyn CoreToolRuntime>, exposure: ToolExposure, // 参数:运行时实例 + 指定曝光位(trusted/external 共用签名) ) -> bool { // external 轨返回注册是否成功——调用方据此提示用户 let tool_name = runtime.tool_name().with_default_namespace(); // 归一化命名空间,保证查表 key 一致 if tool_name.is_default_namespace() // 只拦默认命名空间下的保留名——带命名空间的同名工具放行 && matches!(tool_name.name.as_str(), "exec_command" | "shell_command") { // exec/shell 是内置执行入口,外部工具不许冒充 tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name"); // 保留名被外部工具占用 → 打 warn(不 panic) if self.tools.contains_key(&tool_name) { // 撞上了保留名 → 记一次冲突供后续报错 self.record_collision(tool_name); // 记一次冲突——error_on_tool_collisions 开启时 finalize 会硬报错 } return false; // 拒绝注册,返回 false 让调用方知道没成功 } match self.tools.entry(tool_name) { // entry API:Vacant/Occupied 两分支分别处理 Entry::Vacant(entry) => { entry.insert(RegisteredTool { runtime, exposure }); true } // 空位 → 插入并报告成功 Entry::Occupied(entry) => { // 外部工具撞名 → 只告警不崩溃,先到先得 tracing::warn!(tool_name = %entry.key(), "skipping duplicate external tool that is already registered"); // 外部工具撞名是常态 → 只告警,先到先得 self.first_collision.get_or_insert_with(|| entry.key().clone()); // get_or_insert:只保留第一次撞名的名字 false // 报告注册失败——调用方(MCP 层)可以据此提示用户 } } }为什么这样设计:关键在"双轨"。内置工具(exec_command、view_image……)是 Codex 自己写的,两个 handler 抢同一个名字只可能是 bug——所以 register_trusted 走 error_or_panic,宁可崩也不让模型看到一份"谁说了算不清楚"的工具表。而 MCP server 是用户装的,两个 server 都暴露 search 这种名字太正常了——register_external 就降级为 warn + skip,并把第一次撞名记进 first_collision。这个字段后面会用到:配置了 error_on_tool_collisions 时,finalize 阶段直接把它变成硬错误(见第五节)。另外注意保留名拦截只针对默认命名空间——mcp_server.exec_command 这种带前缀的名字不冲突,放行。用 IndexMap 而不是 HashMap 也是刻意的:工具顺序会原样进入模型请求的 tools 数组,保序让行为可复现、diff 可读。
| 撞名策略 | ||
| 保留名 | ||
| 冲突记账 |
再看每个工具要实现的运行时契约 CoreToolRuntime——它继承通用的 ToolExecutor(tools/src/tool_executor.rs),再加一堆"有默认实现"的钩子位:
📄 codex-rs/core/src/tools/registry.rs (第 51-93 行)
/// Typed runtime contract for locally executed tools. // 本地执行工具的运行时契约pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> { // 继承通用 ToolExecutor:必须提供 tool_name/spec/handle fn is_builtin_control_tool(&self) -> bool { false } // 是否内置控制工具——决定要不要额外打 analytics 点 fn immutable_spec(&self) -> Option<&Arc<ToolSpec>> { None } // spec 不可变时返回共享引用,省掉每次 clone(热路径优化) fn wait_until_ready(&'a self, _session: &'a Arc<Session>) -> Option<BoxFuture<'a, ()>> { None } // 执行前需要等待就绪(如 MCP server 连接),返回 future 则先 await fn mcp_server_name(&self) -> Option<&str> { None } // 只有 MCP 工具返回所属 server——遥测和通知分流用 fn matches_kind(&self, payload: &ToolPayload) -> bool { // 校验调用载荷类型是否匹配(Function vs ToolSearch) matches!(payload, ToolPayload::Function { .. } | ToolPayload::ToolSearch { .. }) // 默认接受 Function/ToolSearch 两种载荷——Custom 等由具体工具收紧 } fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags { Vec::new() } // 自定义遥测标签,如 mcp_server 归属 fn on_tool_result_accepted(&self, _invocation: &ToolInvocation, _result: &dyn ToolOutput) {} // PostToolUse hooks 放行后的观察点——副作用钩子为什么这样设计:这个 trait 是典型的"默认实现 + 选择性覆盖"模式:绝大多数工具只需要实现 handle,其余钩子位按需打开。immutable_spec 是个很务实的微优化——spec 构建不便宜(要序列化 JSON schema),如果工具声明"我的 spec 永远不变",registry 就能反复借用同一个 Arc<ToolSpec>,避免每个 turn 重复构建。on_tool_result_accepted 的注释写得很清楚:"only after all PostToolUse hooks accept it"——观察点放在 hook 放行之后,保证工具看到的"最终结果"和用户/模型看到的一致。
三、dispatch:所有工具调用的一条流水线
模型发起一次 function call,最终都会走到 ToolRegistry::dispatch_any_with_terminal_outcome(registry.rs 第 495-756 行)。这个函数长,但结构是线性的:查表 → 校验 → PreToolUse hooks → 执行 → PostToolUse hooks → 返回。先看"工具不存在"和"载荷不匹配"两个早退分支:
📄 codex-rs/core/src/tools/registry.rs (第 516-565 行)
let tool = match self.tool(&tool_name) { // 按名字查表——注意是"调用时"才查,不是请求开始时 Some(tool) => tool, // 查到了 → 继续走流水线 None => { // 模型调了一个不存在的工具(幻觉或 spec 漂移) let message = unsupported_tool_call_message(&invocation.payload, &tool_name); // 生成给模型看的错误文案(区分 custom/普通调用) otel.tool_result_with_tags( // 失败也要打点——否则遥测里"消失的调用"无法归因 &tool_name, &call_id_owned, log_payload.as_ref(), Duration::ZERO, // 打点参数:工具名、call_id、载荷日志、耗时为零(根本没执行) /*success*/ false, &message, &tool_result_tags, /*extra_trace_fields*/ &[], // success=false + 错误消息进遥测——失败路径也要可归因 ); let err = FunctionCallError::RespondToModel(message); // RespondToModel:把错误文本回给模型让它自我纠正 dispatch_trace.record_failed(&err); // 记录到 dispatch trace(调试用) return Err(err); // 早退——不执行、不走 hooks }};if !tool.matches_kind(&invocation.payload) { // 工具存在但载荷类型不对(如拿 ToolSearch 载荷调 function 工具) let message = format!("tool {tool_name} invoked with incompatible payload"); // 载荷类型不匹配的错误文案(协议级问题) otel.tool_result_with_tags( /*success*/ false, &message, ...); // 同样先打点再返回 let err = FunctionCallError::Fatal(message); // Fatal:协议级错误,比 RespondToModel 更重——直接终止 turn dispatch_trace.record_failed(&err); // 记入 dispatch trace——调试时能看到失败点 return Err(err); // 把错误上抛给调用方,流水线到此终止}为什么这样设计:注意错误分两级:RespondToModel(工具不存在)会把消息回给模型——这是"可恢复的软失败",模型下一轮可以换个名字再试;而 Fatal(载荷不匹配)说明协议层已经乱了,继续跑只会浪费 token。两个分支都坚持"先打点、后返回"——失败路径的遥测和成功路径一样完整,这是线上排障时能区分"模型幻觉调错工具"和"spec 构建 bug"的前提。
然后是 PreToolUse hooks——用户可以在配置里挂钩子拦截或改写工具调用:
📄 codex-rs/core/src/tools/registry.rs (第 567-623 行)
if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { // 工具提供 hook 载荷才跑 hooks(非 Function 载荷直接跳过) match run_pre_tool_use_hooks( // 执行用户配置的 PreToolUse hooks,可返回三种结果 &invocation.session, &invocation.turn, invocation.call_id.clone(), // hooks 需要的上下文:session/turn/call_id(定位是哪次调用) &pre_tool_use_payload.tool_name, &pre_tool_use_payload.tool_input, // hook 看到的工具名 + 入参——稳定的 JSON 契约面 ).await { // await hooks 执行完,按三态结果分支处理 PreToolUseHookResult::Blocked(message) => { // hook 明确拦截 → 工具根本不执行 if tool.is_builtin_control_tool() { // 控制类工具被拦也要记 analytics(审计要求) let mut analytics = ControlToolCallGuard::new(&invocation); // 控制类工具被拦也要记一笔审计点 analytics.finish(ControlToolCallStatus::Rejected); // 状态标记为 Rejected——和 Failed(执行失败)区分开 } let err = FunctionCallError::RespondToModel(message); // hook 的拦截理由直接回给模型 notify_tool_finish_if_unclaimed( /*outcome*/ ToolCallOutcome::Blocked, ...).await; // 生命周期通知:标记"被拦" return Err(err); // 把错误上抛给调用方,流水线到此终止 } PreToolUseHookResult::Continue { updated_input: Some(updated_input) } => { // hook 改写了入参(如替换敏感路径) match tool.with_updated_hook_input(invocation.clone(), updated_input) { // 用新入参重建 invocation Ok(updated_invocation) => { invocation = updated_invocation; } // 后续执行用的是改写后的参数 Err(err) => { /* 记 Failed + notify_tool_finish,return Err */ } // 改写失败按"未执行"上报 } } PreToolUseHookResult::Continue { updated_input: None } => {} // hook 放行且不改参 → 原样继续 }}if tool.mcp_server_name().is_none() { // MCP 工具由 server 侧自己通知,这里跳过避免重复 notify_tool_start(&invocation, /*mcp_tool*/ None).await; // 扩展 API 的 tool start 生命周期事件}为什么这样设计:hook 三态(Blocked / Continue+改写 / Continue)覆盖了"拦截、审计、参数净化"三类需求,而且改写是双向契约:工具在 pre_tool_use_payload 里暴露什么形状的 tool_input,就要在 with_updated_hook_input 里能把它还原回 invocation——registry.rs 第 140-165 行的注释原话是"Tools that opt into input-rewriting hooks should invert the same stable hook contract"。这个对称性保证了 hook 作者不需要了解工具内部结构,只操作稳定的 JSON 契约。
执行阶段本身被遥测闭包包住——otel.log_tool_result_with_tags(..., || handle_any_tool(tool.as_ref(), invocation.clone()), ...)(第 646-661 行):真正的 handler 调用是闭包参数,执行完由 otel 层统一记录耗时和成败。紧接着是 PostToolUse hooks——注意它拦截的是结果而不是执行:
📄 codex-rs/core/src/tools/registry.rs (第 707-755 行)
// A PostToolUse block rejects the result, not the already-completed tool execution. // 注释点破语义:拦的是结果,执行已经发生let lifecycle_outcome = match &result { // 先算生命周期结果——扩展 API 消费者按此分类处理 Ok(_) => ToolCallOutcome::Completed { success }, // 成功 → Completed(带 success 标志) Err(_) => ToolCallOutcome::Failed { handler_executed: true }, // 失败但 handler 跑过了——和 PreToolUse 的 Blocked 区分开};match result { // 再按成功/失败分支收尾(PostToolUse block 走的是 Err 路径) Ok(mut result) => { // 执行成功 → 检查 PostToolUse hooks 的裁决 if let Some(outcome) = post_tool_use_outcome { // 有 hook 结果才需要处理(无 hook 时 outcome 为 None) if outcome.should_block { // PostToolUse hook 否决了结果 let message = outcome.feedback_message.unwrap_or_else(|| "PostToolUse hook blocked the tool result".to_string()); // hook 没给理由时用默认文案——模型必须知道被拦了 return Err(FunctionCallError::RespondToModel(message)); // 模型看到的是 hook 的反馈,不是原始输出 } if let Some(feedback_message) = outcome.feedback_message { // 不拦截但附加反馈 → 包装结果 result.result = Box::new(PostToolUseFeedbackOutput { // 装饰器:原结果保留(日志/元数据用),模型看到的是 feedback original: result.result, // 原结果保留:日志/元数据仍看真实执行输出 model_visible: FunctionToolOutput::from_text(feedback_message, /*success*/ None), // 模型可见面换成 hook 的 feedback——装饰器只改这一处 }); } } tool.on_tool_result_accepted(&invocation, result.result.as_ref()); // 观察点:hook 全部放行后才回调工具自身 dispatch_trace.record_completed(...); // trace 记完成(含最终结果)——调试链路闭环 Ok(result) // 返回(可能被装饰过的)结果给上层 } Err(err) => { dispatch_trace.record_failed(&err); Err(err) } // 执行失败原样上抛}为什么这样设计:PostToolUseFeedbackOutput(第 220-249 行)是个教科书级的装饰器:log_output / success_for_logging / tool_result_metadata 全部转发给 original(审计和遥测看到真实执行结果),只有 to_response_item 换成 hook 的 feedback——模型看到的和日志里的是两份内容,各取所需。而"PreToolUse 拦执行、PostToolUse 拦结果"的分工让生命周期语义精确:Blocked(没跑)vs Completed{success:false}(跑了但失败)vs Failed{handler_executed:true},扩展 API 的消费者可以据此做不同的 UI/审计处理。
四、spec_plan:每个 turn 现场组装工具计划
注册表是"谁存在",build_tool_router(spec_plan.rs 第 125-195 行)才是"这一轮给模型看什么"。它的组装顺序很讲究:
📄 codex-rs/core/src/tools/spec_plan.rs (第 153-195 行)
let mut registry = ToolRegistry::default(); // 每个 turn 从零开始——上一轮的工具面不继承add_core_tool_sources(&context, &mut registry); // 第一步:内置工具(shell/MCP资源/实用工具/collaboration)let hosted_specs = if crate::guardian::is_basic_session_source(&turn_context.session_source) { // Guardian 审查会话:hosted/MCP/扩展整条分支直接跳过——最小权限 Vec::new() // Guardian 审查会话:hosted 工具一律不给——最小权限原则} else { // 普通会话:MCP/扩展/hosted 全量注册 let registered_mcp_tools = session.services.mcp_handler_cache.append_mcp_tools( // 第二步:MCP server 的工具批量注册(external 轨) mcp, &turn_context.config, apps_enabled, &mcp.config().mcp_server_catalog, // append_mcp_tools 的其余参数:配置/apps开关/server目录 search_tool_enabled(turn_context, model_info), &mut registry, // tool_search 是否可用 + 注册表(MCP 工具走 external 轨) ); apply_mcp_tool_exposure_policy( // 第三步:按 server 配置裁剪 MCP 工具的曝光位(下一节细拆) turn_context, model_info, mcp, ®istered_mcp_tools, &mut registry, // 曝光位策略的输入:已注册的 MCP 工具名集合 ); let standalone_web_search_tool = append_extension_tool_executors( // 第四步:扩展工具(web.run 等) turn_context, model_info, extension_tool_executors(session, step_store), &mut registry, // 扩展执行器(web.run 等)也注册进同一张表 ); append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry); // 第五步:动态注册的工具 hosted_model_tool_specs(turn_context, model_info, standalone_web_search_tool.as_slice()) // 第六步:hosted 工具 spec(如 web search)不进 registry,单独走};finalize_tool_router( // 收尾:Code Mode 洗牌、tool_search 装配、冲突硬校验、生成模型可见 specs turn_context, model_info, registry, hosted_specs, &session.services.tool_search_handler_cache, // finalize 的输入:registry + hosted specs + tool_search 缓存)为什么这样设计:两个细节值得注意。其一,hosted 工具不进 registry——web search 这类由 provider 侧执行的工具没有本地 handler,所以单独走 hosted_specs 通道直接拼进模型请求;registry 只装"本地能 dispatch"的工具。其二,Guardian 会话在源头掐断:不是注册完再删,而是整个 hosted/MCP/扩展分支都不走——第 974-1036 行的 add_core_tool_sources 里,Guardian basic session 只允许 exec_command + write_stdin + view_image 三个工具(且要求 Managed 权限档),审查者看到的攻击面被压到最小。组装顺序也暗含优先级:内置 → MCP → 扩展 → 动态,后注册的在撞名时天然处于劣势(external 轨先到先得)。
内置工具也不是无脑全加——add_shell_tools(第 1079-1113 行)展示了 feature flag 如何直接改变工具形态:
📄 codex-rs/core/src/tools/spec_plan.rs (第 1096-1113 行)
let options = ExecCommandHandlerOptions { // 同一工具、不同 flag → 不同的参数面(schema 都变) allow_login_shell, // 环境是否允许 login shell allow_tty: features.enabled(Feature::UnifiedExecTty), // TTY 支持单独开关 exec_permission_approvals_enabled, // 审批流开关 include_environment_id, // 多环境时才暴露 environment_id 参数——单环境时模型根本不需要它 ...};if features.enabled(Feature::UnifiedExec) { // UnifiedExec 开:可续跑进程模式(配 write_stdin) registry.add(ExecCommandHandler::new(options)); // UnifiedExec 开:可续跑的进程 + write_stdin 配套注册 registry.add(WriteStdinHandler); // stdin 写入工具配套注册——和 exec_command 成对出现} else { // UnifiedExec 关(Managed 策略)→ 降级为一次性命令 // Managed requirements are the only configuration path that can keep unified exec disabled. // 注释说明这是唯一能关掉它的配置路径 registry.add(ExecCommandHandler::one_shot(options)); // 关:退化为一次性命令——不暴露 write_stdin 权限(策略禁止)}为什么这样设计:注意 include_environment_id——参数面本身是动态的:单环境会话里模型看不到这个参数,少一个字段就少一分幻觉空间。而 UnifiedExec 关闭时不是"禁用 exec_command",而是换成 one_shot 变体:命令还能跑,但进程不可续、write_stdin 不注册——注释点明这是 Managed 策略的唯一关闭路径。工具"降级而不消失"比一刀切禁用对模型更友好(它不会反复尝试一个不存在的工具)。
五、曝光位:一个工具,六种"可见性"
Codex 把"模型能不能看到这个工具"拆成三个独立维度:DIRECT(进初始 tools 列表)、DEFERRED(可通过 tool_search 发现)、CODE_MODE(可在 Code Mode 嵌套脚本里调用)。三个布尔组合出六种 ToolExposure(tools/src/tool_executor.rs 第 50-99 行):Direct、Deferred、DeferredModelOnly、DirectModelOnly、CodeModeOnly、Hidden。MCP server 可以在配置里声明 omit_tools_from,apply_mcp_tool_exposure_policy(spec_plan.rs 第 197-269 行)负责把"声明的省略"翻译成最终曝光位:
📄 codex-rs/core/src/tools/spec_plan.rs (第 234-267 行)
let mut exposures = ToolExposures::ALL.difference(*omitted_exposures); // 从"全开"里减去 server 声明要省略的面if tool_name.namespace.as_ref().is_some_and(|namespace| { // 该命名空间被配置为 direct-only(如某些敏感 MCP) turn_context.config.code_mode.direct_only_tool_namespaces.contains(namespace) // 该命名空间在 direct-only 配置里?→ 只许 DIRECT 面}) { exposures = exposures.difference(ToolExposures::DEFERRED | ToolExposures::CODE_MODE); // 只留 DIRECT——禁止搜索发现、禁止 Code Mode 调用}exposures = if search_tool_enabled(turn_context, model_info) // tool_search 可用且该工具有 DEFERRED 面 && exposures.contains(ToolExposures::DEFERRED) // 且当前还有 DEFERRED 面可撤 && (effective_tool_mode(turn_context, model_info) != ToolMode::CodeModeOnly || exposures.contains(ToolExposures::CODE_MODE)) // CodeModeOnly 会话里必须保留 CODE_MODE 面,否则工具彻底失联{ exposures.difference(ToolExposures::DIRECT) // 有搜索兜底 → 撤掉 DIRECT:省 token,模型需要时再搜出来} else { exposures.difference(ToolExposures::DEFERRED) // 没有搜索兜底 → 撤掉 DEFERRED:不能让它"只可发现不可直调"却没人能发现};tool.exposure = match ( // 三个布尔 → 六种枚举,穷举匹配让非法组合在编译期就不存在 exposures.contains(ToolExposures::DIRECT), // match 元组第 1 位:DIRECT 开没开 exposures.contains(ToolExposures::DEFERRED), // 第 2 位:DEFERRED 开没开 exposures.contains(ToolExposures::CODE_MODE), // 第 3 位:CODE_MODE 开没开——三位组合出六档) { (false, false, false) => ToolExposure::Hidden, // 三面全关:只保留 dispatch 能力(内部调用) (false, false, true) => ToolExposure::CodeModeOnly, // 只能被 Code Mode 脚本调 (true, false, false) => ToolExposure::DirectModelOnly, // 模型直调,不进 Code Mode (true, false, true) => ToolExposure::Direct, // 默认档:直调 + Code Mode (false, true, false) => ToolExposure::DeferredModelOnly, // 只能搜索发现 (false, true, true) => ToolExposure::Deferred, // 搜索发现 + Code Mode (true, true, _) => unreachable!("direct and deferred exposure are mutually exclusive"), // DIRECT/DEFERRED 互斥——上面 if-else 已保证,这里兜底断言};为什么这样设计:核心思想是"token 预算换发现性":工具越多,初始 tools 列表越贵(每个 spec 都是 JSON schema)。所以 Codex 的策略是——有 tool_search 兜底时,把非核心工具的 DIRECT 面撤掉,让它们"懒加载";没有搜索能力时则反过来保证至少有一个面可达。最后用穷举 match 把位运算结果映射回枚举,unreachable! 分支是设计不变量的运行时断言:DIRECT 和 DEFERRED 互斥由上面的 if-else 保证,如果哪天有人改了逻辑让两者共存,这里会立刻炸出来而不是静默产生一个"既直调又可搜索"的怪状态。
| Direct | ||
| Deferred | ||
| DirectModelOnly | ||
| DeferredModelOnly | ||
| CodeModeOnly | ||
| Hidden |
add_core_utility_tools(第 1137-1283 行)里能看到这些档位被实际使用:request_user_input、send_message_to_user_async、new_context_window 都注册为 DirectModelOnly——它们涉及"和用户交互/重置上下文",绝不该被 Code Mode 脚本在循环里偷偷调用;而 get_context_remaining(查询剩余 token)保持 Direct——高频、无副作用。
六、模型可见 spec:过滤、合并、命名空间
registry 建好后,build_model_visible_specs(spec_plan.rs 第 531-568 行)负责生成真正发给模型的 tools 数组:
📄 codex-rs/core/src/tools/spec_plan.rs (第 538-567 行)
let mut specs = Vec::new(); // 收集本轮模型可见的 spec(顺序=注册顺序)for tool in registry.entries() { // 遍历注册表(IndexMap → 顺序稳定) let exposure = tool.exposure; // 取该工具最终曝光位(finalize 阶段已按策略改写) if !exposure.is_direct() { continue; } // 只有 DIRECT/DirectModelOnly 进初始列表——Deferred/Hidden 在这里被过滤掉 let tool_name = tool.runtime.tool_name(); // 工具名——后面 Code Mode 隐藏判定要用 if is_hidden_by_code_mode_only(turn_context, model_info, &tool_name, exposure) { continue; } // CodeModeOnly 会话里,非嵌套工具隐藏 let spec = tool.runtime.spec(); // 取工具的 JSON schema(immutable_spec 命中时是共享引用) specs.push(spec_for_model_request( // Code Mode 下对"胜出者"做 augment(补嵌套调用说明),否则原样 turn_context, model_info, exposure, &tool_name, code_mode_tool_names, spec, // spec_for_model_request:Code Mode 胜出者做 augment,否则原样返回 ));}specs.extend(hosted_specs); // hosted 工具(web search)直接追加——它们没有 registry 条目merge_into_namespaces(specs) // 同名 namespace 的 spec 合并成一个(tools 数组按名字排序,保证输出确定性) .into_iter() // 转迭代器准备过滤(链式写法) .filter(|spec| { // namespace_tools_enabled 关闭时,Namespace 类型整体过滤掉 namespace_tools_enabled(turn_context) || !matches!(spec, ToolSpec::Namespace(_)) // 命名空间工具被禁用时,Namespace spec 整体剔除 }) .collect() // 收进 Vec——这就是发给模型的 tools 数组主体为什么这样设计:merge_into_namespaces(第 900-950 行)解决的是"多个工具共享一个命名空间"的 wire 格式问题:Responses API 允许把一组工具包进 ToolSpec::Namespace,合并时同名 namespace 的工具列表 append、描述取第一个非空值、最后按名字排序——排序是为了输出确定性:同样的配置必须产生字节级一致的 tools 数组,否则 prompt cache 命中率会掉。而 spec_for_model_request(第 570-593 行)里有个精巧的"胜出者"判定:Code Mode 下多个工具映射到同一个嵌套标识符时,只有 code_mode_tool_names 记录的 winner 会被 augment——避免两个工具都声称自己是那个嵌套名。
最后,finalize_tool_router(第 352-495 行)在返回 ToolRouter 前做三件收尾:① Code Mode 开启时移除与嵌套工具撞名的顶层工具并记冲突;② tool_search 启用且存在 deferred 工具时,装配 tool_search executor 并清掉命名空间冲突者(第 372-407 行);③ 若配置了 error_on_tool_collisions,把之前记账的 first_collision 和命名空间归属冲突(两个不同 MCP server 声明同一 namespace)升级为硬错误 CodexErrorDetails::ToolCollision——这就是第二节那个 first_collision 字段的归宿:注册时宽容记账,finalize 时按配置决定"容忍还是报错"。
一次工具调用的完整链路(plan → dispatch)
① 计划:build_tool_router(每 turn)
内置 → MCP → 扩展 → 动态,四路注册进 ToolRegistry;Guardian 会话只留三工具。
▼
② 裁剪:exposure policy + finalize
MCP omit_tools_from → 六档曝光位;Code Mode/tool_search 洗牌;冲突按配置容忍或硬报错。
▼
③ 发布:build_model_visible_specs
只留 DIRECT 面 → namespace 合并 + 排序(确定性输出)→ hosted specs 追加 → 进模型请求。
▼
④ 调用:dispatch_any_with_terminal_outcome
查表 → matches_kind 校验 → PreToolUse hooks(拦/改写)→ otel 包裹执行。
▼
⑤ 收尾:PostToolUse hooks + 生命周期
🔹 block → RespondToModel(执行已发生,拦的是结果)🔹 feedback → PostToolUseFeedbackOutput 装饰器换模型可见输出🔹 on_tool_result_accepted + notify_tool_finish 收尾
七、小结:注册表是"能力面",spec plan 是"预算面"
把本讲的两个核心对象放在一起看:ToolRegistry 回答"这个 turn 里哪些工具能执行"——它管注册、冲突、dispatch,是能力面;spec_plan 回答"模型这一轮看到什么"——它按 feature flag、环境、MCP 配置、Code Mode 现场裁剪曝光位,是预算面。两者通过 ToolExposure 这个六值枚举解耦:注册时给默认档(exposure()),finalize 时按策略改写,发布时只取 DIRECT。这种"能力与可见性分离"让同一份工具集可以在不同会话形态(普通 / Guardian / Code Mode / 多环境)下呈现完全不同的模型面,而 dispatch 路径始终唯一——所有调用都过 dispatch_any_with_terminal_outcome 这一条流水线,hooks、遥测、生命周期通知一个都不漏。下一讲(第 16 讲)我们进入 Unified Exec:exec_command 背后的进程管理运行时。
📚 系列导航
← 第 14 讲:Compaction 上下文压缩
→ 第 16 讲:Unified Exec 统一执行运行时
关注公众号「AI技术推荐官」获取更多源码解析内容