工具系统架构总览
模型看见 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
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
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
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, ®istered_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
let model_visible_specs =
build_model_visible_specs(turn_context, ®istry, &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
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
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
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
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
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
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
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 中运行:
cargo test -p codex-core spec_plan
cargo test -p codex-core router
cargo test -p codex-core tool_results_grouped只读练习:
- 从
build_tool_router分别找出 core、MCP、extension、dynamic 和 hosted 的来源,说明哪些进入 registry, 哪些只进入请求 spec。 - 让一个 MCP tool 的 exposure 变为 deferred,比较 registry、tool search 和
model_visible_specs三者。 - 对一个未知工具名追踪
tool_runtime()返回None后的 registry 分派,区分“没有 runtime”和“hook block”。 - 在
ToolCallRuntime中将一个工具标为不支持并行,解释为什么写锁保护的是 dispatch 时段而非模型 response 流。 - 对照
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 编排;本篇只保留 它们之间的责任边界。
