Skip to content

SpecPlan生成算法

从 StepContext 追踪 Core、MCP、extension、dynamic、tool search、Code Mode 与 hosted tool 如何共同生成最终工具集合。

基于rust-v0.150.0
CodexRustToolsRuntime

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

rust
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, &registered_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

rust
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

rust
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_router
  • codex-rs/core/src/tools/spec_plan.rs :: apply_mcp_tool_exposure_policy
  • codex-rs/core/src/tools/spec_plan.rs :: append_extension_tool_executors
  • codex-rs/core/src/tools/spec_plan.rs :: append_dynamic_tool_runtimes

源码位置:codex-rs/core/src/tools/spec_plan.rs :: build_tool_router

rust
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, &registered_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. 特殊来源 ​

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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_tools
  • codex-rs/core/src/tools/spec_plan_tests.rs :: shell_command_is_not_registered_without_a_single_local_environment
  • codex-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_exposure
  • codex-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_duplicates
  • codex-rs/core/src/tools/spec_plan_tests.rs :: code_mode_uses_the_first_normalized_tool_identity
  • codex-rs/core/src/tools/spec_plan_tests.rs :: hosted_web_search_fallback_follows_winning_browser_runtime

这些测试证明计划输入、暴露策略、冲突和 fallback 的局部断言,不证明所有 provider capability 组合或真实模型 采样行为。

6. 阅读练习 ​

在 Codex 源码 workspace 中运行:

bash
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

然后尝试回答:

  1. 一个 MCP 工具同时被 server 配置禁止 direct、允许 deferred 和 code mode 时,ToolExposure 会是哪一个变体?
  2. 为什么 Unified Exec 可见时 legacy shell 仍可能 registered?分别指出它的 exposure 和执行消费者。
  3. Responses Lite、Guardian reviewer、standalone web extension 三个条件分别在哪一层阻止 hosted web search?
  4. 开启严格 collision 后,同名工具冲突和同 namespace 不同 description 分别在哪里变成 ToolCollision?
  5. 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 过滤。