Skip to content

ToolRouter解析与分派

从 ResponseItem 追踪 ToolRouter 的调用归一化、namespace lookup、payload kind 校验和 Registry 分派错误。

基于rust-v0.150.0
CodexRustToolsRuntime

ToolRouter解析与分派 ​

ToolRouter 不是旧版本里保存所有 specs 的“大路由器”。当前实现的职责更窄:把可本地分派的 ResponseItem 归一化成 ToolCall,把 ToolCall 组装成 ToolInvocation,再把执行交给 ToolRegistry。 它还提供模型可见规格、并行能力、取消 teardown、deferred namespace 和 diff consumer 的查询入口。

本文面向已经读过ToolPayload调用模型、ToolRegistry数据结构 和ToolRegistry构建流程的读者。本文只讲 Router 的解析与分派边界,不重复 Registry 注册顺序,也不展开 handler 的具体业务。读完后,读者应能解释一个响应项为什么没有进入本地 future、 namespace 如何决定 lookup key,以及未知工具和 payload kind 错误为什么采取不同终态。

1. Router边界 ​

1.1 持有对象 ​

当前 ToolRouter 只持有 Registry 和已经计算好的 model_visible_specs。它不另存一份 runtime/spec 对照表, 也不负责重新构建工具计划。

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

rust
pub struct ToolRouter {
    registry: ToolRegistry,
    model_visible_specs: Vec<ToolSpec>,
}

pub(crate) fn from_parts(
    registry: ToolRegistry,
    model_visible_specs: Vec<ToolSpec>,
) -> Self {
    Self {
        registry,
        model_visible_specs,
    }
}

from_parts 的调用者是 SpecPlan finalize;Router 接收的是已经收束的 Registry 和模型规格。这样 Router 的解析 不会重新解释 feature、MCP exposure 或 provider capability。

1.2 查询入口 ​

Router 把 Registry 的部分信息重新投影给并行运行时和请求构造:

相关源码:

  • codex-rs/core/src/tools/router.rs :: model_visible_specs
  • codex-rs/core/src/tools/router.rs :: tool_supports_parallel
  • codex-rs/core/src/tools/router.rs :: tool_runtime
rust
pub(crate) fn model_visible_specs(&self) -> Vec<ToolSpec> {
    self.model_visible_specs.clone()
}

pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
    self.registry
        .supports_parallel_tool_calls(&call.tool_name)
        .unwrap_or(false)
}

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

模型请求读取的是 specs 副本;执行查询读取的是 Registry runtime。一个 spec 出现在请求里,不代表 lookup 一定成功, 反之一个 runtime 已注册,也可能因 exposure 不是 direct 而不在这次请求中。

2. 响应归一化 ​

2.1 Function ​

ResponseItem::FunctionCall 只在 Router 中完成名称归一化和 payload 包装;arguments 保留原始字符串,具体 JSON 类型由 handler 负责解析。

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

rust
ResponseItem::FunctionCall {
    name,
    namespace,
    arguments,
    encrypted_function_args,
    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,
    }))
}

2.2 ToolSearch ​

只有 client execution 且存在 call id 的 ToolSearchCall 会被本地 Router 接收;服务端 search call、缺失 call id 或 其他 execution 形状直接返回 Ok(None)。client 参数解析失败则是 RespondToModel。

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

rust
ResponseItem::ToolSearchCall {
    call_id: Some(call_id),
    execution,
    arguments,
    ..
} if execution == "client" => {
    let arguments: SearchToolCallParams =
        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::ToolSearchCall { .. } => Ok(None),

2.3 Custom ​

Custom call 的 input 是文本,Router 不解析 grammar;它只保留 ToolPayload::Custom,将 patch/Lark/其他自由格式 解析留给具体 runtime。

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

rust
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,
})),

3. 名称与来源 ​

3.1 Namespace ​

Function 和 Custom 都调用 ToolName::new(...).with_default_namespace()。默认 namespace alias 会进入同一 Registry key,MCP/extension namespace 则必须完整保留。Router 不检查这个名称是否已经注册,lookup 在后续 dispatch 阶段完成。

3.2 Direct来源 ​

ToolCall::direct_source 默认返回 Direct;collaboration namespace 的三个明文消息工具在 encrypted args 为空时 返回 DirectPlaintextMessage。来源标签不改变 tool name 或 payload,只影响日志与下游 requester 语义。

相关源码:

  • codex-rs/core/src/tools/router.rs :: ToolCall::direct_source
  • codex-rs/core/src/tools/router.rs :: tool_log_payload
rust
pub(crate) fn direct_source(&self) -> ToolCallSource {
    if self.tool_name.namespace.as_deref() == Some("collaboration")
        && matches!(
            self.tool_name.name.as_str(),
            "spawn_agent" | "send_message" | "followup_task"
        )
        && self
            .encrypted_function_args
            .as_ref()
            .is_some_and(Vec::is_empty)
    {
        ToolCallSource::DirectPlaintextMessage
    } else {
        ToolCallSource::Direct
    }
}

pub(crate) fn tool_log_payload<'a>(
    payload: &'a ToolPayload,
    source: &ToolCallSource,
) -> Cow<'a, str> {
    if matches!(source, ToolCallSource::DirectPlaintextMessage) {
        return Cow::Borrowed("[plaintext arguments]");
    }
    payload.log_payload()
}

3.3 Code Mode ​

Code Mode 不从 ResponseItem 进入 Router 解析分支,而是自行构造 ToolCall,再调用 handle_tool_call_with_source。但它仍复用 Router 的 Registry lookup、parallel/cancellation 查询和 invocation 组装。

源码位置:codex-rs/core/src/tools/code_mode/mod.rs :: dispatch_nested_tool

rust
let call = ToolCall {
    tool_name: tool_name.with_default_namespace(),
    call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
    payload,
    encrypted_function_args: None,
};

tool_runtime
    .handle_tool_call_with_source(
        call,
        ToolCallSource::CodeMode {
            cell_id: cell_id.to_string(),
            runtime_tool_call_id,
        },
        cancellation_token,
    )
    .await?;

4. 分派上下文 ​

4.1 Invocation ​

Router 的 dispatch 内部消费 ToolCall,把 session、StepContext、cancellation token、diff tracker 和来源组装进 ToolInvocation,然后调用 Registry。这个函数是 Router 从“协议解析”进入“运行时执行”的最后边界。

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

rust
let ToolCall {
    tool_name,
    call_id,
    payload,
    ..
} = call;

let turn = Arc::clone(&step_context.turn);
let invocation = ToolInvocation {
    session,
    turn,
    step_context,
    cancellation_token,
    tracker,
    call_id,
    tool_name,
    source,
    payload,
};

self.registry
    .dispatch_any_with_terminal_outcome(invocation, terminal_outcome_reached)
    .await

4.2 Registry查找 ​

Registry 用 canonical ToolName 查找 runtime;未知名称返回 RespondToModel,payload kind 不匹配则返回 Fatal。 这两种错误都发生在 handler 执行前,区别在于前者可能是模型调用了未知名称,后者说明规格、runtime 和 payload 之间的内部契约已经错配。

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

rust
let tool = match self.tool(&tool_name) {
    Some(tool) => tool,
    None => {
        let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
        return Err(FunctionCallError::RespondToModel(message));
    }
};

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

4.3 后续策略 ​

查找和 kind 检查成功后,Registry 才通知 lifecycle、构造 PreToolUse payload、运行 hook、执行 handler,并把结果交回 ToolCallRuntime。Router 不直接调用 handler,也不直接决定 hook block 的 response item。

5. 错误边界 ​

5.1 Ok(None) ​

build_tool_call 返回 Ok(None) 并不代表错误:普通消息、reasoning、hosted WebSearchCall、server-side tool search 等响应项由其他 stream item 逻辑消费。只有可由本地 Registry 执行的 Function、client ToolSearch 和 Custom 才产生 Some(ToolCall)。

5.2 RespondToModel ​

client ToolSearch 参数解析失败、未知工具名称、handler 参数错误和 PreToolUse block 通常使用 RespondToModel。 外层会把它转换为模型可见失败输出或对应的 ToolSearch 空结果,让 Turn 继续。

5.3 Fatal ​

payload kind mismatch、handler task join 失败等内部不变量问题使用 Fatal,由 ToolCallRuntime 转成 CodexErr::Fatal,不会进入普通模型修复路径。

相关源码:

  • codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call
  • codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::failure_response
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)),
}

6. 测试路径 ​

6.1 解析断言 ​

build_tool_call_normalizes_default_function_and_custom_namespaces 验证缺失、空字符串和默认 namespace 归一化; build_tool_call_uses_namespace_for_registry_name 与 build_custom_tool_call_uses_namespace_for_registry_name 验证非默认 namespace 保留并进入 ToolCall。

相关测试:

  • codex-rs/core/src/tools/router_tests.rs :: build_tool_call_normalizes_default_function_and_custom_namespaces
  • codex-rs/core/src/tools/router_tests.rs :: build_tool_call_uses_namespace_for_registry_name
  • codex-rs/core/src/tools/router_tests.rs :: build_custom_tool_call_uses_namespace_for_registry_name

6.2 Registry断言 ​

parallel_support_does_not_match_namespaced_local_tool_names 使用本地 parallel-safe tool 和同名 MCP namespace, 断言 runtime lookup 与 parallel 查询分别依据 canonical namespace 工作。extension_tool_executors_are_model_visible_and_dispatchable 则把 namespace tool 从 spec 到 dispatch 结果串起来。

相关测试:

  • codex-rs/core/src/tools/router_tests.rs :: parallel_support_does_not_match_namespaced_local_tool_names
  • codex-rs/core/src/tools/router_tests.rs :: extension_tool_executors_are_model_visible_and_dispatchable

这些测试证明 Router 解析、namespace lookup 和 extension dispatch 的局部行为,不证明 handler 所有副作用、真实 provider stream 或所有错误组合。

7. 阅读练习 ​

在 Codex 源码 workspace 中运行:

bash
cargo test -p codex-core build_tool_call_normalizes_default_function_and_custom_namespaces
cargo test -p codex-core build_tool_call_uses_namespace_for_registry_name
cargo test -p codex-core parallel_support_does_not_match_namespaced_local_tool_names
cargo test -p codex-core extension_tool_executors_are_model_visible_and_dispatchable

然后尝试回答:

  1. 为什么 WebSearchCall 返回 Ok(None) 不等于解析失败?它的消费者在哪里?
  2. 一个 namespace 错误的 FunctionCall 会在 Router 的哪一步变成 unknown tool?
  3. 为什么 payload kind mismatch 是 Fatal,而未知名称是 RespondToModel?
  4. Code Mode 没有 ResponseItem 时,如何仍然复用 Router 的 Registry lookup 和 cancellation 查询?
  5. 哪些信息只在 ToolCall 中存在,哪些信息要到 ToolInvocation 才获得?

8. 边界 ​

ToolRouter 负责协议归一化和 Registry 分派入口,不负责:

  • 当前 Step 的工具注册和 exposure 计算;
  • schema、handler 参数业务校验、approval 或 sandbox;
  • 并行锁、取消 teardown、ToolOutput 截断和最终历史写回。

排查调用失败时,应按“响应项类型 → canonical ToolName → ToolPayload → Registry lookup → payload kind → Hook → handler” 逐层定位。不要把 Ok(None) 当成统一错误,也不要把模型可见 spec 的存在当成 runtime lookup 一定成功。