统一工具注册、路由与并行执行
本章回答一个问题:模型返回工具调用后,Codex 如何从工具名找到真正的执行代码,并把结果送回下一轮模型请求。
先区分三个对象
ToolRegistry
保存系统实际拥有的工具执行器
model_visible_specs
保存本轮允许模型看到的工具定义
ToolRouter
把模型返回的 tool-call 路由到 Registry 中的执行器
Registry 和 model_visible_specs 不是同一张表:工具可以已经注册,但因为权限、工具模式或暴露级别,本轮不一定会出现在模型请求的 tools 字段里。
1. 本轮先构建工具注册表
源码:spec_plan.rs
let mut registry = ToolRegistry::default();
// Registry 是本轮所有可执行工具的总表
add_core_tool_sources(&context, &mut registry);
// 注册 Codex 内置工具:exec_command、write_stdin、view_image、协作工具等
MCP 和扩展工具也进入这张表:
for tool in mcp_tools {
registry.register_external_with_exposure(
tool.runtime,
tool.exposure,
);
}
// MCP 工具已经被包装成统一的运行时对象
append_extension_tool_executors(
turn_context,
model_info,
extension_tool_executors,
registry,
);
// 扩展工具也加入同一个 Registry
源码:spec_plan.rs
注册后的对象包含两部分:
pub struct RegisteredTool {
pub(crate) runtime: Arc<dyn CoreToolRuntime>,
pub(crate) exposure: ToolExposure,
}
源码:registry.rs
runtime
真正负责执行工具
exposure
决定工具是直接暴露、延迟暴露,还是隐藏
2. 从完整注册表筛出模型可见工具
for tool in registry.entries() {
let exposure = tool.exposure;
if !exposure.is_direct() {
continue;
}
let spec = tool.runtime.spec();
specs.push(spec_for_model_request(..., spec));
}
源码:spec_plan.rs
这一步生成的是 ToolSpec,随后放入模型请求的 tools 字段:
ToolRegistry
→ 根据 ToolExposure、工具模式、冲突规则筛选
→ build_model_visible_specs()
→ ToolSpec[]
→ 本轮模型请求的 tools
因此模型看到的是“工具定义”,而不是 Rust 中的执行器对象。
3. 模型返回 tool-call 后转换为内部对象
模型协议中的工具调用类似:
{
"type": "function_call",
"name": "exec_command",
"arguments": "{\"cmd\":\"ls\"}",
"call_id": "call_123"
}
ToolRouter::build_tool_call() 将它转换为 Codex 内部的 ToolCall:
let tool_name =
ToolName::new(namespace, name)
.with_default_namespace();
Ok(Some(ToolCall {
tool_name,
call_id,
payload: ToolPayload::Function { arguments },
}))
源码:router.rs
4. 一轮模型可以产生多个工具调用
流式响应中,每完成一个工具输出项,Codex 就记录并创建一个工具 Future:
if let Some(tool_future) = output_result.tool_future {
in_flight.push_back(tool_future);
}
源码:turn.rs
因此一轮模型响应可以形成:
tool-call-1 → ToolFuture-1 → in_flight
tool-call-2 → ToolFuture-2 → in_flight
tool-call-3 → ToolFuture-3 → in_flight
这里的 in_flight 是结果收集队列,不是等三个调用全部入队后才开始执行的任务队列。handle_tool_call 内部会立即 tokio::spawn;模型还在继续输出时,已完成参数的工具调用就可能开始执行。
5. 根据工具能力决定并行还是串行
let supports_parallel =
router.tool_supports_parallel(&call);
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {
Either::Right(lock.write().await)
};
源码:parallel.rs
含义是:
支持并行的工具
→ 获取 RwLock 读锁
→ 多个工具可以同时进入执行区
不支持并行的工具
→ 获取 RwLock 写锁
→ 独占执行,其他工具等待
所以实际情况是:
每个工具调用参数完成
├─ 创建工具任务 → 按并行能力争用读锁或写锁 → 执行
└─ 将对应 Future 加入 in_flight,稍后按序收集结果
6. ToolRouter 将 ToolCall 绑定成 ToolInvocation
let invocation = ToolInvocation {
session,
turn,
step_context,
cancellation_token,
tracker,
call_id,
tool_name,
source,
payload,
};
源码:router.rs
ToolInvocation 不只是工具参数,还带着执行上下文:
Session
当前 Turn / Step
权限和环境
取消信号
文件变更跟踪器
tool-call ID
工具名和参数
之后统一进入:
self.registry
.dispatch_any_with_terminal_outcome(
invocation,
terminal_outcome_reached,
)
.await
7. Registry 统一校验并调用具体 Handler
let tool = match self.tool(&tool_name) {
Some(tool) => tool,
None => {
return Err(
FunctionCallError::RespondToModel(
unsupported_tool_call_message(...),
)
);
}
};
// 根据模型返回的工具名找到注册的 runtime
随后检查参数类型、执行前 Hook,并最终调用具体实现:
let output =
tool.handle(invocation.clone()).await?;
// 这里才真正执行本地命令、文件操作、MCP 请求或子 Agent 操作
源码:registry.rs
权限、审批和沙箱不是由 ToolRouter 直接完成的;它们会在统一分派之后,由具体工具处理链和安全模块继续判定。
8. MCP 与本地工具在同一条主链上
MCP Handler 同样实现 CoreToolRuntime,只是在最终 handle() 内部调用 MCP Server:
let prepared_mcp_call = invocation
.session
.prepare_mcp_call(
&self.tool_info.server_name,
self.tool_info.tool.name.as_ref(),
)
.await;
let result = handle_mcp_tool_call(
...,
prepared_mcp_call,
...,
)
.await;
所以差异只在最后的具体执行动作:
exec_command → 受沙箱控制的本地进程
文件工具 → 工作区文件系统
MCP 工具 → MCP Server
spawn_agent → 创建子 Agent 并发送初始任务
它们之前的路径相同:
模型 tool-call
→ ToolCall
→ ToolRouter
→ ToolInvocation
→ ToolRegistry
→ CoreToolRuntime::handle()
9. 工具结果先收口,再进入下一轮模型请求
正常处理返回的工具结果统一包装成 AnyToolResult;错误则通过对应的错误转换路径,形成模型可见的失败输出或向上报错:
pub(crate) struct AnyToolResult {
pub(crate) call_id: String,
pub(crate) payload: ToolPayload,
pub(crate) result: Box<dyn ToolOutput>,
pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}
然后转换成模型协议中的 ResponseItem:
ResponseItemEnvelope {
item: result
.to_response_item(&call_id, &payload)
.into(),
metadata: ...,
}
源码:registry.rs
工具 Future 最后由 FuturesOrdered 收口:
while let Some(res) = in_flight.next().await {
if let Ok(envelope) = res {
sess.record_annotated_conversation_items(
&turn_context,
vec![envelope],
)
.await;
}
}
源码:turn.rs
这里要区分两件事:
执行层面
工具可能并行执行
结果收集层面
按入队顺序收口并写入 Session
因此一轮多个工具的完整过程是:
模型流中先后完成 3 个 tool-call
↓
每完成一个调用,就启动工具任务,并把 Future 加入 in_flight
↓
工具按并行能力争用执行锁;执行可与剩余模型流重叠
↓
模型流收尾后,按入队顺序等待并收集工具结果
↓
工具结果写入 Session,编译进下一轮模型上下文
↓
模型继续输出文本,或再次产生 tool-call
源码基线:OpenAI Codex
d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。