Skip to content

工具系统架构总览

从 StepContext 的工具计划出发,追踪 ToolRouter 如何区分模型可见规格、已注册 runtime、调用分派与模型回灌。

基于rust-v0.150.0
CodexRustToolsRuntime

工具系统架构总览 ​

模型看见 exec_command 的 JSON schema,不等于该工具一定能执行;一个已经注册的 MCP 工具也不一定会出现在 本次模型请求;工具返回失败文本时,Turn 也不一定失败。Codex 把这些问题拆成四个层次:本 Step 的工具计划、 模型可见规格、runtime 分派和结果回灌。理解这条链,才能定位“模型没看到工具”“工具名找不到”“工具被拒绝” 和“工具失败后为什么模型还能继续”的不同原因。

本文面向已读过 StepContext模型请求、 Turn主循环与退出条件 和 Codex信任边界 的读者。本文建立工具系统的共同骨架,不逐字段解释 ToolSpec,不展开某一个 handler,也不把 approval/sandbox 当成 Router 的私有能力。读完后,你应能从 build_tool_router 追到一次模型 tool call 的 ResponseInputItem,并能区分“未注册”“已注册但隐藏”“被 hook 拦截”“handler 失败”和“取消后合成输出”。

1. 四层对象 ​

当前 ToolRouter 不保存一份“所有工具定义”。它拥有 ToolRegistry 和已经筛选好的 model_visible_specs; 注册表保存 runtime 与 exposure,规格计划负责把本 Step 的 core、MCP、extension、dynamic 和 hosted tool 组织起来。

源码位置:codex-rs/core/src/tools/router.rs :: ToolRouter

rust
pub struct ToolRouter {
    registry: ToolRegistry,
    model_visible_specs: Arc<[ToolSpec]>,
}

pub(crate) fn model_visible_specs(&self) -> Arc<[ToolSpec]> {
    Arc::clone(&self.model_visible_specs)
}

pub(crate) fn tool_runtime(&self, call: &ToolCall) -> Option<Arc<dyn CoreToolRuntime>> {
    self.registry.tool(&call.tool_name)
}

ToolSpec 是模型协议面;CoreToolRuntime 是本地执行面;ToolExposure 决定二者何时相连。一个工具可以被 保留在 registry 中以服务 code mode、deferred search 或历史兼容,同时不对模型公开。

2. Step计划 ​

工具计划在 Step 捕获时创建,因此 MCP binding、environment、extension data 和 Turn feature 都来自同一份 StepContext。这避免模型看到旧工具表、但调用由新 router 执行的竞态。

源码位置:codex-rs/core/src/session/turn.rs :: built_tools

rust
let tool_router = built_tools(
    self,
    turn_context.as_ref(),
    &environments,
    &mcp,
    apps_enabled,
    &extension_data,
    tool_suggest_candidates.as_ref(),
)?;

Ok(Arc::new(StepContext {
    turn: turn_context,
    environments,
    mcp,
    tool_router: Arc::new(tool_router),
    loaded_agents_md,
    ..
}))

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

rust
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(/* ... */, &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、extension 和 dynamic runtime 的追加。 这不等于普通工具系统失效,而是 review 子会话的工具面被刻意缩小。

3. 暴露决策 ​

finalize_tool_router 是 registered 与 visible 的分界。它处理 code mode 的替换、tool search 的冲突和严格 collision 检查,然后把 direct exposure 的 spec 与 hosted spec 合并为本次模型请求的工具表。

源码位置:codex-rs/core/src/tools/spec_plan.rs :: finalize_tool_router 与 build_model_visible_specs

rust
let model_visible_specs =
    build_model_visible_specs(turn_context, &registry, &code_mode_tool_names, hosted_specs);
Ok(ToolRouter::from_parts(registry, model_visible_specs))

for tool in registry.entries() {
    let tool_name = tool.runtime.tool_name();
    let exposure = tool.exposure;
    if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) {
        specs.push(spec_for_model_request(
            turn_context,
            exposure,
            &tool_name,
            code_mode_tool_names,
            tool.runtime.spec(),
        ));
    }
}
specs.extend(hosted_specs);

hosted web search 是另一个边界:它不是 registry runtime。Responses Lite 或 Guardian reviewer 时 hosted_model_tool_specs 直接返回空;否则 provider capability、standalone web extension 和 web search mode 共同决定是否加入 hosted spec。后续 TLS007 会单独解释 wire schema。

4. 调用归一化 ​

模型响应到达后,Router 只接受三种可分派形状:FunctionCall、客户端执行的 ToolSearchCall、 CustomToolCall。服务端执行的 tool search 以及普通消息返回 Ok(None),不会误投本地 handler。

源码位置:codex-rs/core/src/tools/router.rs :: ToolRouter::build_tool_call

rust
match item {
    ResponseItem::FunctionCall { name, namespace, arguments, call_id, .. } => {
        let tool_name = ToolName::new(namespace, name).with_default_namespace();
        Ok(Some(ToolCall {
            tool_name,
            call_id,
            payload: ToolPayload::Function { arguments },
            encrypted_function_args,
        }))
    }
    ResponseItem::ToolSearchCall { call_id: Some(call_id), execution, arguments, .. }
        if execution == "client" => {
            let arguments = serde_json::from_value(arguments).map_err(|err| {
                FunctionCallError::RespondToModel(format!("failed to parse tool_search arguments: {err}"))
            })?;
            Ok(Some(ToolCall { tool_name: ToolName::plain("tool_search"), call_id,
                payload: ToolPayload::ToolSearch { arguments }, encrypted_function_args: None }))
        }
    ResponseItem::CustomToolCall { name, namespace, input, call_id, .. } => Ok(Some(ToolCall {
        tool_name: ToolName::new(namespace, name).with_default_namespace(),
        call_id, payload: ToolPayload::Custom { input }, encrypted_function_args: None,
    })),
    _ => Ok(None),
}

解析失败使用 RespondToModel,不是立刻杀死 Turn。外层会把这类错误转换成带 success: false 的 tool output, 让模型得到可修复反馈;只有 Fatal 才成为 Turn 错误。

5. 执行门 ​

ToolCallRuntime 保留产生本次工具表的 Arc<StepContext>。工具调用可能晚于模型 response 到达,因此不能在 执行前改读全局 MCP/router。并行安全工具拿读锁,其他工具拿写锁;等待 runtime teardown 的工具在取消后获得 不同的收尾策略。

源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call_with_source

rust
let router = &self.step_context.tool_router;
let supports_parallel = router.tool_supports_parallel(&call);
let tool_runtime = router.tool_runtime(&call);
let wait_for_runtime_cancellation = router.tool_waits_for_runtime_cancellation(&call);

let mut dispatch_handle = AbortOnDropHandle::new(tokio::spawn(async move {
    if let Some(tool_runtime) = tool_runtime
        && let Some(readiness) = tool_runtime.wait_until_ready(&session)
    {
        readiness.await;
    }
    let _guard = if supports_parallel {
        Either::Left(lock.read().await)
    } else {
        Either::Right(lock.write().await)
    };
    router.dispatch_tool_call_with_terminal_outcome(/* invocation */).await
}));

取消时,如果 runtime 声明需要 teardown,调度器让它完成自己的收尾;否则 abort task 并合成 aborted output。 这保证“模型得到取消结果”与“进程或外部 runtime 已释放资源”各有明确 owner。

6. Registry分派 ​

Registry 在执行 handler 前检查 payload 类型、发出 tool lifecycle、运行 PreToolUse hook;hook 可以阻止调用, 也可以返回改写后的输入。这个位置是策略扩展点,不是 Router 解析的责任。

源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome

rust
if !tool.matches_kind(&invocation.payload) {
    return Err(FunctionCallError::Fatal(
        format!("tool {tool_name} invoked with incompatible payload"),
    ));
}

notify_tool_start(&invocation).await;
if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) {
    match run_pre_tool_use_hooks(/* ... */).await {
        PreToolUseHookResult::Blocked(message) => {
            notify_tool_finish_if_unclaimed(
                &invocation, terminal_outcome_reached.as_deref(), ToolCallOutcome::Blocked,
            ).await;
            return Err(FunctionCallError::RespondToModel(message));
        }
        PreToolUseHookResult::Continue { updated_input: Some(updated_input) } => {
            invocation = tool.with_updated_hook_input(invocation.clone(), updated_input)?;
        }
        PreToolUseHookResult::Continue { updated_input: None } => {}
    }
}

7. 结果回灌 ​

AnyToolResult::into_response 调用 ToolOutput::to_response_item,把 runtime 输出变成可写入 history 的 ResponseInputItem。MCP 输出还会加入 wall time、图像 detail 清理和 truncation;不同工具共享回灌接口, 不要求共享同一种实际执行器。

源码位置:codex-rs/core/src/tools/registry.rs :: AnyToolResult::into_response

rust
pub(crate) fn into_response(self) -> ResponseInputItem {
    let Self { call_id, payload, result, .. } = self;
    result.to_response_item(&call_id, &payload)
}

源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call

rust
match future.await {
    Ok(response) => Ok(response.into_response()),
    Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
    Err(other) => Ok(Self::failure_response(error_call, other)),
}

普通拒绝、参数错误或 hook block 会回到模型;fatal 才离开工具循环。这是“工具失败后仍有下一次 sampling”的 原因,不是模型忽略了失败。

8. 测试入口 ​

规格计划测试比较 registered 名称、exposure 和 model-visible specs;Router 测试覆盖 payload 解析与分派; Turn 集成测试才覆盖工具结果是否真正进入下一次请求。

源码位置:codex-rs/core/src/tools/spec_plan_tests.rs :: shell_command_is_not_registered_without_a_single_local_environment

rust
let remote_environment = build_tool_plan(/* remote selection */).await;
remote_environment.assert_registered_lacks(&["shell_command", "exec_command", "write_stdin"]);

let multiple_local_environments = build_tool_plan(/* two local selections */).await;
multiple_local_environments.assert_registered_lacks(&["shell_command"]);

源码位置:codex-rs/core/src/tools/router_tests.rs :: extension_tool_executors_are_model_visible_and_dispatchable

rust
let call = ToolRouter::build_tool_call(ResponseItem::FunctionCall {
    id: None,
    name: "echo".to_string(),
    namespace: Some("extension/".to_string()),
    arguments: serde_json::json!({ "message": "hello" }).to_string(),
    call_id: "call-extension".to_string(),
    encrypted_function_args: None,
    internal_chat_message_metadata_passthrough: None,
})?
.expect("function_call should produce a tool call");

let result = router
    .dispatch_tool_call_with_code_mode_result(
        Arc::new(session),
        step_context,
        CancellationToken::new(),
        tracker,
        call,
        ToolCallSource::Direct,
    )
    .await?;

match result.into_response() {
    ResponseInputItem::FunctionCallOutput { call_id, output } => {
        assert_eq!(call_id, "call-extension");
        let FunctionCallOutputBody::Text(text) = output.body else {
            panic!("expected text function call output")
        };
        let value: serde_json::Value = serde_json::from_str(&text)?;
        assert_eq!(value["ok"], true);
    }
    other => panic!("expected function call output, got {other:?}"),
}

第一组证明 environment 会改变注册面;第二组证明 extension 工具既能进入模型可见规格,也能由同一 Router 找到 runtime 并产出成功结果。它们不证明真实 handler 的外部副作用、approval/sandbox 决策或所有并发交错。 要验证完整回灌,还应继续运行 Turn tool fixture。

9. 验证路径 ​

在本版本源码对应的 codex-rs workspace 中运行:

bash
cargo test -p codex-core spec_plan
cargo test -p codex-core router
cargo test -p codex-core tool_results_grouped

只读练习:

  1. 从 build_tool_router 分别找出 core、MCP、extension、dynamic 和 hosted 的来源,说明哪些进入 registry, 哪些只进入请求 spec。
  2. 让一个 MCP tool 的 exposure 变为 deferred,比较 registry、tool search 和 model_visible_specs 三者。
  3. 对一个未知工具名追踪 tool_runtime() 返回 None 后的 registry 分派,区分“没有 runtime”和“hook block”。
  4. 在 ToolCallRuntime 中将一个工具标为不支持并行,解释为什么写锁保护的是 dispatch 时段而非模型 response 流。
  5. 对照 FunctionCallError::RespondToModel 与 Fatal,判断哪一种会生成工具输出,哪一种会结束 Turn。

10. 边界 ​

工具系统不保证模型只调用可见工具:历史、兼容或模型错误都可能产生未知名称,因此 registry 仍需要分派错误路径。 反过来,工具已注册也不等于模型能看到它,exposure、code mode、tool search、namespace 能力和 hosted tool 条件 都会改变请求表。Router 也不替代安全层:hook、approval、sandbox、network policy 和具体 runtime 分别拥有后续 决策。后续工具专题将分别展开 ToolSpec 字段、注册表构建、Router payload 解析和 handler 编排;本篇只保留 它们之间的责任边界。