SpecPlan生成算法
同一个 Codex 线程在不同 Step 中看到的工具表可能不同:环境数量会改变 shell 参数,feature 会改变交互工具, provider capability 会改变 namespace 与 hosted search,MCP 配置会改变 direct/deferred/code mode 表面,Code Mode 还会重新包装一部分工具。build_tool_router 的工作不是返回一个静态列表,而是把这些输入编译成一个与当前 Step 绑定的 ToolRouter。
本文面向已经读过工具系统架构总览、ToolSpec与函数规格 和ToolPayload调用模型的读者。本文回答“最终工具集合如何生成”,不展开 Registry 内部索引或单个 handler 的参数解析。读完后,读者应能从 TurnContext、环境快照、MCP binding 和 feature 设置推断一个工具是 registered、direct、deferred、code-mode-only 还是 hidden,并能定位冲突和 hosted tool 消失的原因。
1. 输入快照
1.1 计划上下文
build_tool_router 接收 Session、TurnContext、环境快照、MCP binding、Apps 开关、step extension data 和可选的 tool suggest candidates。函数先把这些引用收进 CoreToolPlanContext,再让多个注册阶段共享同一份输入。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: CoreToolPlanContext、build_tool_router
struct CoreToolPlanContext<'a> {
turn_context: &'a TurnContext,
environments: &'a TurnEnvironmentSnapshot,
mcp: &'a codex_mcp::McpBinding,
tool_suggest_candidates: Option<&'a ToolSuggestCandidates>,
wait_for_environment_tool_config: Option<&'a Arc<WaitForEnvironmentToolConfig>>,
default_agent_type_description: &'a str,
wait_agent_timeouts: WaitAgentTimeoutOptions,
}
pub(crate) fn build_tool_router(
session: &Session,
turn_context: &TurnContext,
environments: &TurnEnvironmentSnapshot,
mcp: &codex_mcp::McpBinding,
apps_enabled: bool,
step_store: &ExtensionData,
tool_suggest_candidates: Option<&ToolSuggestCandidates>,
) -> CodexResult<ToolRouter> {
let context = CoreToolPlanContext {
turn_context,
environments,
mcp,
tool_suggest_candidates,
wait_for_environment_tool_config: session
.services
.thread_extension_data
.get::<WaitForEnvironmentToolConfig>()
.as_ref(),
default_agent_type_description: "...",
wait_agent_timeouts: wait_agent_timeout_options(turn_context),
};
let mut registry = ToolRegistry::default();
add_core_tool_sources(&context, &mut registry);
let hosted_specs = if crate::guardian::is_guardian_reviewer_source(
&turn_context.session_source,
) {
Vec::new()
} else {
let registered_mcp_tools = append_mcp_tools(
mcp.tools(),
&turn_context.config,
apps_enabled,
&mcp.config().mcp_server_catalog,
search_tool_enabled(turn_context),
&mut registry,
);
apply_mcp_tool_exposure_policy(turn_context, mcp, ®istered_mcp_tools, &mut registry);
let standalone_web_search_tool = append_extension_tool_executors(
turn_context,
extension_tool_executors(session, step_store),
&mut registry,
);
append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry);
hosted_model_tool_specs(turn_context, standalone_web_search_tool.as_slice())
};
finalize_tool_router(
turn_context,
registry,
hosted_specs,
&session.services.tool_search_handler_cache,
)
}这段代码保留了当前函数的关键控制流:Guardian reviewer 在外部来源追加前就得到空 hosted specs,普通路径则按 MCP → exposure policy → extension → dynamic → hosted 的顺序处理,最后统一进入 finalize。所有来源使用同一个 Step registry,最终可见规格从它和同一个 TurnContext 派生。
1.2 注册与可见
注册表中的 runtime 是执行所有权;model_visible_specs 是请求所有权。一个 runtime 可以注册但不 direct visible, 例如 legacy shell、deferred MCP 或 code-mode-only 工具。
2. 来源注册
2.1 Core工具
普通路径先调用 add_core_tool_sources。Guardian reviewer 是特殊收窄路径:只在有 environment 时加入 exec_command、write_stdin 和可选 view_image,直接返回,不注册一般 shell、MCP resource、交互、协作和 动态工具。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: add_core_tool_sources
if crate::guardian::is_guardian_reviewer_source(&context.turn_context.session_source) {
let environment_mode = tool_environment_mode(context.environments);
if environment_mode.has_environment() {
registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
allow_login_shell: any_environment_allows_login_shell(context.environments),
exec_permission_approvals_enabled: false,
include_environment_id: matches!(environment_mode, ToolEnvironmentMode::Multiple),
include_shell_parameter: unified_exec_should_include_shell_parameter(
context.turn_context,
context.environments,
),
}));
registry.add(WriteStdinHandler);
if context.turn_context.config.features.enabled(Feature::ViewImage) {
registry.add(ViewImageHandler::new(/* options */));
}
}
return;
}
add_shell_tools(context, registry);
add_mcp_resource_tools(context, registry);
add_core_utility_tools(context, registry);
add_collaboration_tools(context, registry);Guardian 的限制发生在 Core source registration,而不是最后过滤阶段;因此它不会先注册所有工具再依赖模型不可见 来“假装隐藏”。这是安全边界和普通 exposure 隐藏的差别。
2.2 环境工具
add_shell_tools 根据 environment 数量、shell 类型、feature 和本地 environment 判断注册对象。Unified Exec 模型可见时,legacy shell 仍可能以 Hidden exposure 注册,以便兼容或内部 dispatch;没有单一本地 environment 时,shell_command 不会注册。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: add_shell_tools
let environment_mode = tool_environment_mode(context.environments);
if !environment_mode.has_environment() {
return;
}
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
let supports_shell_command = context.environments.single_local_environment().is_some();
match shell_type_for_model_and_features(&turn_context.model_info, features) {
ConfigShellToolType::UnifiedExec => {
registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
allow_login_shell,
exec_permission_approvals_enabled,
include_environment_id,
include_shell_parameter: unified_exec_should_include_shell_parameter(
turn_context,
context.environments,
),
}));
registry.add(WriteStdinHandler);
if supports_shell_command {
registry.add_with_exposure(
ShellCommandHandler::new(shell_command_options),
ToolExposure::Hidden,
);
}
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::Default
| ConfigShellToolType::Local
| ConfigShellToolType::ShellCommand => {
if supports_shell_command {
registry.add(ShellCommandHandler::new(shell_command_options));
}
}
}环境数量同时影响 environment_id 参数和 legacy shell 是否可用;这也是为什么不能只凭 model capability 推断 最终 shell schema。
2.3 外部来源
普通路径在 Core 来源之后追加 MCP、extension 和 dynamic。MCP exposure 先由 server omit_tools_from 和当前 code mode direct-only namespace 计算,再决定 direct/deferred/code-mode 位。extension 会过滤 standalone web search 和 image generation;dynamic 转换失败只跳过当前工具。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: build_tool_router
相关源码:
codex-rs/core/src/tools/spec_plan.rs :: build_tool_routercodex-rs/core/src/tools/spec_plan.rs :: apply_mcp_tool_exposure_policycodex-rs/core/src/tools/spec_plan.rs :: append_extension_tool_executorscodex-rs/core/src/tools/spec_plan.rs :: append_dynamic_tool_runtimes
源码位置:codex-rs/core/src/tools/spec_plan.rs :: build_tool_router
let registered_mcp_tools = append_mcp_tools(
mcp.tools(),
&turn_context.config,
apps_enabled,
&mcp.config().mcp_server_catalog,
search_tool_enabled(turn_context),
&mut registry,
);
apply_mcp_tool_exposure_policy(turn_context, mcp, ®istered_mcp_tools, &mut registry);
let standalone_web_search_tool = append_extension_tool_executors(
turn_context,
extension_tool_executors(session, step_store),
&mut registry,
);
append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry);ToolExposure 的三个位(direct、deferred、code mode)不是三个同时公开的列表。MCP policy 会先扣除配置禁止的 surface,再把剩余组合压缩为一个枚举:全无为 Hidden,只有 code mode 为 CodeModeOnly,只有 direct 为 DirectModelOnly,direct+code mode 才是 Direct,deferred+code mode 才是 Deferred。
3. 特殊来源
3.1 Tool Search
finalize_tool_router 只有在 search tool enabled 且存在可搜索 deferred runtime 时才注册 ToolSearch handler。它会 移除同名旧 runtime,删除占据 tool_search namespace 的普通 namespace runtime,然后从 deferred entries 建立 search infos。DeferredToolWorldState 决定搜索描述是否列出 source names。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: finalize_tool_router、append_tool_search_executor
if search_tool_enabled(turn_context)
&& registry.entries().any(|tool| {
tool.runtime.tool_name() != tool_search_name
&& tool.exposure.is_deferred()
&& tool.runtime.search_info().is_some()
})
{
if registry.remove(&tool_search_name).is_some() {
registry.record_collision(tool_search_name.clone());
}
let conflicting_tool_names = registry
.entries()
.filter_map(|tool| {
let ToolSpec::Namespace(namespace) = tool.runtime.spec() else {
return None;
};
(namespace.name == tool_search_name.name).then(|| tool.runtime.tool_name())
})
.collect::<Vec<_>>();
for tool_name in conflicting_tool_names {
if registry.remove(&tool_name).is_some() {
registry.record_collision(tool_name);
}
}
append_tool_search_executor(turn_context, &mut registry, tool_search_handler_cache);
}这段逻辑的消费者是下一次模型请求,而不是当前请求:deferred runtime 保留在 registry,模型只看到 tool_search, 命中后加载的 specs 才进入后续请求。
3.2 Code Mode
Code Mode 注册阶段会扫描所有 is_available_in_code_mode() 的 runtime,排除配置 namespace,按 normalized code-mode identifier 选出第一个 winner,并把 direct/deferred 工具分别放入 enabled/deferred prompt definitions。重复 normalized name 的工具被跳过并记录 warning。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: register_code_mode_executors
for tool in registry.entries() {
if !tool.exposure.is_available_in_code_mode() {
continue;
}
let tool_name = tool.runtime.tool_name();
if is_excluded_from_code_mode(turn_context, &tool_name) {
continue;
}
let spec = tool.runtime.spec();
let code_mode_name = match &spec {
ToolSpec::Function(_) | ToolSpec::Freeform(_) => {
codex_tools::code_mode_name_for_tool_name(&tool_name)
}
ToolSpec::Namespace(namespace) if !namespace.tools.is_empty() => {
codex_tools::code_mode_name_for_tool_name(&tool_name)
}
ToolSpec::Namespace(_) | ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => {
continue;
}
};
let normalized = codex_code_mode::normalize_code_mode_identifier(&code_mode_name);
if code_mode_tool_names.insert(normalized, tool_name).is_some() {
tracing::warn!("skipping tool with a duplicate normalized code-mode name");
continue;
}
code_mode_nested_tool_specs.push(spec);
}3.3 Hosted工具
hosted spec 不进入 registry runtime。hosted_model_tool_specs 在 Responses Lite 或 Guardian reviewer 时直接返回空; 否则只有 provider 支持 web search、没有可用 standalone extension web tool 且 web search mode 允许时,才生成 hosted WebSearch spec。Cached/Indexed/Live 的字段映射由 hosted spec 工厂负责。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: hosted_model_tool_specs
if turn_context.model_info.use_responses_lite
|| crate::guardian::is_guardian_reviewer_source(&turn_context.session_source)
{
return Vec::new();
}
let standalone_web_search_available = standalone_web_search_enabled(turn_context)
&& registered_extension_tool_names.contains(&ToolName::namespaced("web", "run"));
let web_search_mode = (!standalone_web_search_available
&& turn_context.provider.capabilities().web_search)
.then_some(turn_context.config.web_search_mode.value());
if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions {
web_search_mode,
web_search_config: web_search_mode
.as_ref()
.and(turn_context.config.web_search_config.as_ref()),
web_search_tool_type: turn_context.model_info.web_search_tool_type,
}) {
specs.push(hosted_web_search_tool);
}4. 最终收束
4.1 冲突检查
finalize_tool_router 在构造可见 specs 前先处理 code mode 公开工具冲突、tool search 同名冲突和 namespace description 冲突。开启 error_on_tool_collisions 时,任一 runtime 名称冲突或同 namespace 不同 description 都会 返回 ToolCollision;关闭时 registry 保留既定 winner,namespace 合并时保留第一个非空 description。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: finalize_tool_router、merge_into_namespaces
if turn_context.config.tool_registry.error_on_tool_collisions {
if let Some(tool_name) = registry.first_collision() {
let namespace = tool_name.namespace.as_deref().unwrap_or("functions");
let name = format!("{namespace}.{}", tool_name.name);
return Err(CodexErrorDetails::ToolCollision(name).into());
}
let mut namespace_descriptions = BTreeMap::new();
for tool in registry.entries() {
let ToolSpec::Namespace(namespace) = tool.runtime.spec() else {
continue;
};
if namespace.description.trim().is_empty() {
continue;
}
match namespace_descriptions.entry(namespace.name) {
Entry::Vacant(entry) => {
entry.insert(namespace.description);
}
Entry::Occupied(entry) if entry.get() != &namespace.description => {
return Err(CodexErrorDetails::ToolCollision(entry.key().clone()).into());
}
Entry::Occupied(_) => {}
}
}
}4.2 Namespace合并
build_model_visible_specs 先收集 direct runtime specs,再追加 hosted specs,调用 merge_into_namespaces。同名 namespace 的 children 会合并并按名称排序;空 description 会补默认描述。最后,如果 provider 不支持 namespace tools,所有 namespace specs 会被过滤掉。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: build_model_visible_specs、merge_into_namespaces
let mut specs = Vec::new();
for tool in registry.entries() {
if tool.exposure.is_direct()
&& !is_hidden_by_code_mode_only(turn_context, &tool.runtime.tool_name(), tool.exposure)
{
specs.push(spec_for_model_request(
turn_context,
tool.exposure,
&tool.runtime.tool_name(),
code_mode_tool_names,
tool.runtime.spec(),
));
}
}
specs.extend(hosted_specs);
merge_into_namespaces(specs)
.into_iter()
.filter(|spec| {
namespace_tools_enabled(turn_context) || !matches!(spec, ToolSpec::Namespace(_))
})
.collect()4.3 Code Mode增强
spec_for_model_request 只在 Code Mode/CodeModeOnly、工具可参与 code mode、未被排除、且 normalized winner 对应 当前 runtime 时增强 spec。它不会把所有 direct spec 都改写;增强是最终模型请求规格的一次条件投影。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: spec_for_model_request
if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
&& exposure.is_available_in_code_mode()
&& !is_excluded_from_code_mode(turn_context, tool_name)
&& codex_code_mode::is_code_mode_nested_tool(spec.name())
&& code_mode_tool_names
.get(&codex_code_mode::normalize_code_mode_identifier(
&codex_tools::code_mode_name_for_tool_name(tool_name),
))
.is_some_and(|winner| winner == tool_name)
{
codex_tools::augment_tool_spec_for_code_mode(spec)
} else {
spec
}这张图强调顺序依赖:MCP exposure 必须在 MCP runtime 注册后计算,hosted web search 又要知道 extension 是否已经 提供 standalone web/run,因此这些阶段不能任意交换。
5. 测试路径
5.1 环境与功能
spec_plan_tests::environment_count_controls_environment_backed_tools 改变 environment 数量,断言 environment_id 参数和 shell family 注册面变化。shell_command_is_not_registered_without_a_single_local_environment 证明 remote 或多环境不能直接注册 legacy shell。environment_tools_follow_the_step_context 则验证工具计划消费当前 Step 的 environment snapshot,而不是过期全局状态。
源码位置:
codex-rs/core/src/tools/spec_plan_tests.rs :: environment_count_controls_environment_backed_toolscodex-rs/core/src/tools/spec_plan_tests.rs :: shell_command_is_not_registered_without_a_single_local_environmentcodex-rs/core/src/tools/spec_plan_tests.rs :: environment_tools_follow_the_step_context
5.2 Exposure与搜索
mcp_and_tool_search_follow_direct_and_deferred_tool_exposure 构造 direct/deferred MCP 工具,断言 direct 进入 model-visible specs,deferred 不进入首个列表但能形成搜索表面。deferred_extension_tools_are_discoverable_with_tool_search 进一步证明 extension executor 的 deferred exposure 会产生搜索条目。
源码位置:
codex-rs/core/src/tools/spec_plan_tests.rs :: mcp_and_tool_search_follow_direct_and_deferred_tool_exposurecodex-rs/core/src/tools/spec_plan_tests.rs :: deferred_extension_tools_are_discoverable_with_tool_search
5.3 冲突与 Code Mode
strict_tool_collisions_reject_external_and_synthetic_duplicates 验证严格模式会把 runtime collision 转为错误; code_mode_uses_the_first_normalized_tool_identity 验证 normalized code-mode name 冲突时选择第一个 winner; hosted_web_search_fallback_follows_winning_browser_runtime 验证 standalone browser runtime 存在时不会重复生成 hosted web search。
源码位置:
codex-rs/core/src/tools/spec_plan_tests.rs :: strict_tool_collisions_reject_external_and_synthetic_duplicatescodex-rs/core/src/tools/spec_plan_tests.rs :: code_mode_uses_the_first_normalized_tool_identitycodex-rs/core/src/tools/spec_plan_tests.rs :: hosted_web_search_fallback_follows_winning_browser_runtime
这些测试证明计划输入、暴露策略、冲突和 fallback 的局部断言,不证明所有 provider capability 组合或真实模型 采样行为。
6. 阅读练习
在 Codex 源码 workspace 中运行:
cargo test -p codex-core environment_count_controls_environment_backed_tools
cargo test -p codex-core mcp_and_tool_search_follow_direct_and_deferred_tool_exposure
cargo test -p codex-core strict_tool_collisions_reject_external_and_synthetic_duplicates
cargo test -p codex-core code_mode_uses_the_first_normalized_tool_identity
cargo test -p codex-core hosted_web_search_fallback_follows_winning_browser_runtime然后尝试回答:
- 一个 MCP 工具同时被 server 配置禁止 direct、允许 deferred 和 code mode 时,
ToolExposure会是哪一个变体? - 为什么 Unified Exec 可见时 legacy shell 仍可能 registered?分别指出它的 exposure 和执行消费者。
- Responses Lite、Guardian reviewer、standalone web extension 三个条件分别在哪一层阻止 hosted web search?
- 开启严格 collision 后,同名工具冲突和同 namespace 不同 description 分别在哪里变成
ToolCollision? - Code Mode normalized name 冲突时,为什么不能简单把两个工具都加入 nested schema?
7. 边界
SpecPlan 只负责“本 Step 的工具面生成”,不负责:
- handler 的具体参数校验、审批、sandbox 和副作用;
- deferred tool 命中后的完整搜索排序与结果消费;
- provider 真实接受某个 hosted/schema wire shape;
- runtime 执行时的并行锁、取消 teardown 和输出回灌。
排查工具缺失时,应按“来源注册 → exposure 重写 → 特殊 executor → 冲突检查 → namespace 合并 → provider 过滤”的 顺序检查。只看最终 Prompt.tools 无法判断工具是从未注册、被 deferred、被 code mode 排除、被 collision 删除, 还是被 provider capability 过滤。
