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
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_specscodex-rs/core/src/tools/router.rs :: tool_supports_parallelcodex-rs/core/src/tools/router.rs :: tool_runtime
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
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
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
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_sourcecodex-rs/core/src/tools/router.rs :: tool_log_payload
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
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
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)
.await4.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
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_callcodex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::failure_response
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_namespacescodex-rs/core/src/tools/router_tests.rs :: build_tool_call_uses_namespace_for_registry_namecodex-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_namescodex-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 中运行:
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然后尝试回答:
- 为什么
WebSearchCall返回Ok(None)不等于解析失败?它的消费者在哪里? - 一个 namespace 错误的 FunctionCall 会在 Router 的哪一步变成 unknown tool?
- 为什么 payload kind mismatch 是 Fatal,而未知名称是 RespondToModel?
- Code Mode 没有 ResponseItem 时,如何仍然复用 Router 的 Registry lookup 和 cancellation 查询?
- 哪些信息只在
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 一定成功。
