第 9 章:工具规格、路由与分发
阅读契约: 用本章跟踪一次 tool request 如何从模型可见的 schema 走到真正能执行的 handler。请盯住三个 owner:planner 负责暴露能力, router 负责规范化模型 item,registry 负责判断 runtime handler 是否能执行这类 payload。读完后,你应该能解释:为什么一个已经暴露给模型的 tool schema,仍然可能在产生副作用前失败。

源码边界: 本章解释的是公开 Codex 仓库在 commit
569ff6a1
下的源码。type、enum variant、test、function 行为只有在链接到这个固定快照时,才视为
verified source。关于边界为什么重要的判断,是从这些可见源码形状得出的
surrounding contract inference。本章不推断 OpenAI 私有服务实现、隐藏模型策略或托管后端调度。
Codex 要记录 tool result 之前,必须先判断这次请求的工具到底能不能运行。本章从观察进入行动。模型可以产出 function call、custom tool call、hosted-tool request、local shell action 或 tool-search request,但这个 item 本身不是授权。它只是语法,必须和当前 turn 的 capability plan 匹配。
核心不变量可以压缩成一句话:
Tool spec 告诉模型应该产出什么形状;handler 证明 runtime 能执行什么;registry 和 router 防止这两件事混成一件事。
这个不变量很容易被 UI 体验掩盖。模型请求一个工具,然后工具看起来运行了。但源码里二者之间有多道门:
- Planner 根据配置、feature flag、MCP 状态、dynamic tools、hosted tools 和 discoverable tools 构造 configured specs 与 handler registrations。
- Router 过滤出本 turn 的 model-visible specs。
- Router 把 provider response items 解析成规范化的
ToolCall。 - Registry 用
ToolName查找 handler,并确认 payload kind 和 handler kind 匹配。 - Dispatch 用 cancellation、parallelism、hooks、telemetry、trace records 和 response shaping 包住真正的 handler 执行。
如果其中一层失败,Codex 不应该“猜一个最接近的工具试试”。正确结果要么是结构化失败,要么是在 runtime contract 被破坏时让 turn 以 fatal error 停止。
一、Tool Spec 是模型语法,不是运行时权限
codex-rs/tools crate 定义可序列化的工具词汇。在固定源码里,
ToolSpec
包含多种公开形状:
pub enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
ImageGeneration { output_format: String },
#[serde(rename = "web_search")]
WebSearch { /* fields omitted */ },
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
这个 enum 覆盖面很广:普通 function calling、namespace tools、deferred tool search、local
shell 风格工具、provider-hosted image/web search,以及 patch 这类 freeform custom tool。
但它仍然只是 request shape。下一个结构体把区别讲得更清楚:
ConfiguredToolSpec
在 spec 上增加了 parallelism flag。
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
即使这样,它也还不是执行权限。它只说明:“如果这个工具出现在 configured set 中,这就是模型侧 schema,
以及它的 parallel-call contract。”真正的运行时权限在按 ToolName 注册的 handler 里。
这个拆分避免三类常见错误:
| 误读 | 为什么会错 |
|---|---|
| 把 schema 当权限 | 模型可见 JSON schema 不说明 handler 是否存在、policy 是否允许、当前 turn 是否能运行。 |
| 把名字当 provenance | calendar.create_event、namespaced MCP tool 和 plain local function 可能名字相近,但 owner 不同。 |
| 把并行当模型特性 | 模型可以产出多个调用,但 Codex 必须判断具体 handler 或 server 是否能承受并发。 |
后面的所有机制,都是为了防止这些误读变成真实副作用。
二、Planner 会产出两类结果
Planner 的任务不是拼一个扁平工具列表。它会产出两类相互关联但不能混同的结果:
- Configured specs,供 router 检查。
- Runtime handlers,供 registry 分发。

这层规划的公开入口是
build_specs_with_discoverable_tools。
它接收的输入正好是 planner 需要考虑的所有可见维度:tool configuration、MCP tools、deferred
MCP tools、模型已经尝试调用但当前不可用的工具、discoverable tools,以及 dynamic tools。
pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<Vec<ToolInfo>>,
deferred_mcp_tools: Option<Vec<ToolInfo>>,
unavailable_called_tools: Vec<ToolName>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
/* planning body omitted */
}
在调用更底层的 registry builder 前,函数先做几件关键映射。MCP tools 被转成 plan inputs, 保留 canonical name 和 namespace description;deferred MCP tools 被转成 tool search 的 source records;deferred dynamic tools 被收集起来,让它们可以被发现,但不一定直接出现在模型初始工具列表里。
let deferred_mcp_tool_sources = deferred_mcp_tools.as_ref().map(|tools| {
tools
.iter()
.map(|tool| ToolRegistryBuildDeferredTool {
name: tool.canonical_tool_name(),
server_name: tool.server_name.as_str(),
connector_name: tool.connector_name.as_deref(),
description: tool.namespace_description.as_deref(),
})
.collect::<Vec<_>>()
});
let deferred_dynamic_tools = dynamic_tools
.iter()
.filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none()))
.cloned()
.collect::<Vec<_>>();
这已经是一条架构结论:deferred tool 并不是“不存在”。它可以有 handler、search metadata 和
provenance,只是暂时不进入 immediate model-visible spec list。这样 Codex 可以避免把所有
dynamic 或 external schema 一次性塞给模型,同时仍然保留后续解析能力。Deferred MCP tools 把这件事说得更直:
planner 后面还会为那些尚未直接注册的 deferred tools
注册 McpHandler,
这样 search 发现之后仍然能回到 runtime handler。
更底层的
build_tool_registry_builder
会根据配置注册具体工具族。Shell exposure 受 environment mode 和 shell type 约束:
if config.environment_mode.has_environment() {
let include_environment_id =
matches!(config.environment_mode, ToolEnvironmentMode::Multiple);
match &config.shell_type {
ConfigShellToolType::Default => {
builder.register_handler(Arc::new(ShellHandler::new(ShellToolOptions {
exec_permission_approvals_enabled,
})));
}
ConfigShellToolType::UnifiedExec => {
builder.register_handler(Arc::new(ExecCommandHandler::new(
ExecCommandHandlerOptions {
allow_login_shell: config.allow_login_shell,
exec_permission_approvals_enabled,
include_environment_id,
},
)));
builder.register_handler(Arc::new(WriteStdinHandler));
}
/* other shell modes omitted */
}
}
同一个 builder 还会根据配置和 feature 状态添加 plan、goal、request-input、permission、patch、 test、hosted web search、image generation、view-image、multi-agent、agent-job、MCP 和 dynamic-tool handlers。最终产物不是一个全局工具面,而是当前 turn 的 capability set。
2.1 Deferred tool search 是规划路径,不是补丁
当 tool search 开启,并且 deferred MCP source list 存在,或确实有 deferred dynamic tools 时,builder 会注册
ToolSearchHandler:
if config.search_tool
&& (deferred_mcp_tools_for_search.is_some() || !deferred_dynamic_tools.is_empty())
{
let mut search_source_infos = deferred_mcp_tools_for_search
.map(|deferred_mcp_tools| {
collect_tool_search_source_infos(deferred_mcp_tools.iter().map(|tool| {
ToolSearchSource {
server_name: tool.server_name,
connector_name: tool.connector_name,
description: tool.description,
}
}))
})
.unwrap_or_default();
if !deferred_dynamic_tools.is_empty() {
search_source_infos.push(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
});
}
builder.register_handler(Arc::new(ToolSearchHandler::new(
params.tool_search_entries.to_vec(),
search_source_infos,
)));
}
这里保护的不变量很实际:隐藏能力可以通过显式 search action 被发现。模型不需要在初始工具列表里背上每一个 dynamic 或 external schema,runtime 也不会在模型选中某个能力后丢掉原始来源信息。
2.2 Unavailable tools 也要有受控结果
Unavailable tool 是相反的边界情况。模型可能引用之前存在、在另一个 session 中存在、或在不同 auth 状态下发现过的工具。
Builder 会根据名字是否已经存在,插入 unavailable-tool placeholder 或无 spec 的 handler。在
spec.rs
中,placeholder description 明确说明调用它会返回 error。
for unavailable_tool in unavailable_called_tools {
let tool_name = flat_tool_name(&unavailable_tool).into_owned();
if existing_spec_names.insert(tool_name.clone()) {
let spec = codex_tools::ToolSpec::Function(ResponsesApiTool {
name: tool_name.clone(),
description: unavailable_tool_message(
&tool_name,
"Calling this placeholder returns an error explaining that the tool is unavailable.",
),
strict: false,
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
Some(AdditionalProperties::Boolean(false)),
),
output_schema: None,
defer_loading: None,
});
builder.register_handler(Arc::new(UnavailableToolHandler::new(
unavailable_tool,
spec,
)));
} else {
builder.register_handler(Arc::new(UnavailableToolHandler::without_spec(
unavailable_tool,
)));
}
}
这比静默丢弃更好。模型得到结构化原因,用户也能看到可审计的失败,而不是一个凭空消失的工具调用。
三、模型友好的名字背后仍要保留 provenance
外部和客户端拥有的工具让命名问题变得复杂。Raw MCP tool name 未必适合作为 Responses API 的模型可见工具名。 Dynamic tool 可能位于客户端提供的 namespace 下。Deferred tool 可能 registry 知道,但不在 immediate model list 里。

在 planner 中,MCP tools 会按 namespace 排序,并通过
mcp_tool_to_responses_api_tool
转换。每个转换成功的工具,都会在 canonical tool name 下注册 McpHandler。
for (namespace, mut entries) in namespace_entries {
entries.sort_by_key(|tool| tool.name.name.clone());
let mut tools = Vec::new();
for tool in entries {
match mcp_tool_to_responses_api_tool(&tool.name, tool.tool) {
Ok(converted_tool) => {
tools.push(ResponsesApiNamespaceTool::Function(converted_tool));
builder.register_handler(Arc::new(McpHandler::new(tool.name)));
}
Err(error) => {
let tool_name = &tool.name;
tracing::error!(
"Failed to convert `{tool_name}` MCP tool to OpenAI tool: {error:?}"
);
}
}
}
if config.namespace_tools && !tools.is_empty() {
builder.push_spec(
ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace,
description,
tools,
}),
/*supports_parallel_tool_calls*/ false,
);
}
}
重点不是“模型可见表示”和“route-back identity”完全相同。它们相关,但不是一回事。后续 response item 必须根据 session state 解析,这样一个 MCP call 才会变成带 server、raw tool name 和 raw arguments 的 payload。
Dynamic tools 是另一族工具,但架构相似。每个成功转换的 dynamic tool,都会变成 loadable spec,并在
namespaced 或 plain handler name 下注册 DynamicToolHandler:
let mut dynamic_tool_specs = Vec::new();
for tool in params.dynamic_tools {
match dynamic_tool_to_loadable_tool_spec(tool) {
Ok(loadable_tool) => {
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
dynamic_tool_specs.push(loadable_tool);
builder.register_handler(Arc::new(DynamicToolHandler::new(handler_name)));
}
Err(error) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {error:?}",
tool.name
);
}
}
}
这不意味着 MCP 和 dynamic tools 是同一个东西。它们不是。真正共同的点是:两者都经过一个 planning boundary, 这个边界会记录足够的身份信息,供后续 routing 使用。
四、Router 过滤模型能看见什么
Builder 返回 configured specs 和 registry 后,
ToolRouter
会同时保留两者:
pub struct ToolRouter {
registry: ToolRegistry,
specs: Vec<ConfiguredToolSpec>,
model_visible_specs: Vec<ToolSpec>,
parallel_mcp_server_names: HashSet<String>,
}
ToolRouter::from_config 会 build registry、计算 deferred dynamic tool names、过滤 model-visible
list,并保存支持 parallel tool calls 的精确 MCP server 集合:
let (specs, registry) = builder.build();
let deferred_dynamic_tools = dynamic_tools
.iter()
.filter(|tool| tool.defer_loading)
.map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone()))
.collect::<HashSet<_>>();
let model_visible_specs = specs
.iter()
.filter_map(|configured_tool| {
if config.code_mode_only_enabled
&& codex_code_mode::is_code_mode_nested_tool(configured_tool.name())
{
return None;
}
filter_deferred_dynamic_tool_spec(
configured_tool.spec.clone(),
&deferred_dynamic_tools,
)
})
.collect();
关键是:model_visible_specs 从 specs 派生,但不会替换 specs。Deferred dynamic tool 可以继续留在
configured set 中,供 find_spec 和 routing 逻辑使用,同时从初始模型请求里隐藏。
过滤函数本身也很直接:
filter_deferred_dynamic_tool_spec
会移除 deferred function tools,或剪掉 namespace 内部的 deferred members。如果 namespace 被剪空,它也会从
model-visible list 中消失。
fn filter_deferred_dynamic_tool_spec(
spec: ToolSpec,
deferred_dynamic_tools: &HashSet<ToolName>,
) -> Option<ToolSpec> {
if deferred_dynamic_tools.is_empty() {
return Some(spec);
}
match spec {
ToolSpec::Function(tool) => {
if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) {
None
} else {
Some(ToolSpec::Function(tool))
}
}
ToolSpec::Namespace(mut namespace) => {
let namespace_name = namespace.name.clone();
namespace.tools.retain(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains(
&ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()),
),
});
if namespace.tools.is_empty() {
None
} else {
Some(ToolSpec::Namespace(namespace))
}
}
spec => Some(spec),
}
}
测试
model_visible_specs_filter_deferred_dynamic_tools
固定了这个行为:同一个 namespace 下构造 hidden 和 visible 两个 dynamic tool,然后确认
router.specs() 中两个都在,而 router.model_visible_specs() 只剩 visible 那一个。
这正是大型 agent runtime 需要的边界。Visibility 是 turn surface;capability 是 runtime fact; search 是二者之间的发现路径。
五、Response Item 会先变成 typed ToolCall
模型响应回来之后,router 不再处理 specs,而是处理 response items。一个 response item 可能是普通消息、
function call、tool search call、custom call、local shell call,或其他类型。Codex 只把工具调用类 item
规范化成 ToolCall:
pub struct ToolCall {
pub tool_name: ToolName,
pub call_id: String,
pub payload: ToolPayload,
}

Payload enum 是防止“只按名字分发”的第一道防线。
ToolPayload
把 function、tool search、custom、local shell 和 MCP calls 分开:
pub enum ToolPayload {
Function {
arguments: String,
},
ToolSearch {
arguments: SearchToolCallParams,
},
Custom {
input: String,
},
LocalShell {
params: ShellToolCallParams,
},
Mcp {
server: String,
tool: String,
raw_arguments: String,
},
}
Router 的
build_tool_call
会把 response variants 映射到这个 payload vocabulary。Function-call 分支尤其重要:它先检查 tool name
是否能解析到 MCP provenance。
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
} => {
let tool_name = ToolName::new(namespace, name);
if let Some(tool_info) = session.resolve_mcp_tool_info(&tool_name).await {
Ok(Some(ToolCall {
tool_name: tool_info.canonical_tool_name(),
call_id,
payload: ToolPayload::Mcp {
server: tool_info.server_name,
tool: tool_info.tool.name.to_string(),
raw_arguments: arguments,
},
}))
} else {
Ok(Some(ToolCall {
tool_name,
call_id,
payload: ToolPayload::Function { arguments },
}))
}
}
Tool search 也有特殊逻辑。只有当 ToolSearchCall 的 execution == "client" 且存在 call_id 时,
它才会变成 client-side ToolCall。其他 tool-search items 在这个 router path 中会被忽略:
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 },
}))
}
ResponseItem::ToolSearchCall { .. } => Ok(None),
Custom calls 和 local shell calls 也各自有 payload variants。原因很简单:freeform patch body 和 JSON function call 都可能是字符串,但它们不应该因为“都像字符串”就互换。
六、Registry lookup 会拒绝缺失和 wrong-kind 调用
Registry 拥有 runtime handler map。它不是“按名字调用函数”的 switch。下面这个节选来自
ToolHandler
trait,展示了运行时边界:
pub trait ToolHandler: Send + Sync {
type Output: ToolOutput + 'static;
fn tool_name(&self) -> ToolName;
fn spec(&self) -> Option<ToolSpec> {
None
}
fn supports_parallel_tool_calls(&self) -> bool {
false
}
fn kind(&self) -> ToolKind;
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
(self.kind(), payload),
(ToolKind::Function, ToolPayload::Function { .. })
| (ToolKind::Function, ToolPayload::ToolSearch { .. })
| (ToolKind::Mcp, ToolPayload::Mcp { .. })
)
}
/* mutation, hook, and streaming-diff methods omitted */
fn handle(
&self,
invocation: ToolInvocation,
) -> impl std::future::Future<Output = Result<Self::Output, FunctionCallError>> + Send;
}
kind() 和 matches_kind() 是安静但很关键的护栏。期望 MCP payload 的 handler 不该收到 custom
payload;function handler 不该因为名字相近就收到 MCP server payload。ToolSearch 允许通过
function-kind handling,因为它是 client-side runtime tool,但它仍有自己的 payload variant。
核心分发路径从
ToolRegistry::dispatch_any
开始。它准备 telemetry tags,启动 dispatch trace,查 handler,并在真正执行前拒绝 missing 或
incompatible calls:
let dispatch_trace = ToolDispatchTrace::start(&invocation);
let handler = match self.handler(&tool_name) {
Some(handler) => handler,
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
otel.tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
log_payload.as_ref(),
Duration::ZERO,
/*success*/ false,
&message,
&metric_tags,
mcp_server_ref,
mcp_server_origin_ref,
);
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
};
if !handler.matches_kind(&invocation.payload) {
let message = format!("tool {tool_name} invoked with incompatible payload");
otel.tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
log_payload.as_ref(),
Duration::ZERO,
/*success*/ false,
&message,
&metric_tags,
mcp_server_ref,
mcp_server_origin_ref,
);
let err = FunctionCallError::Fatal(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
这两个分支语义不同。Unsupported call 在可能时会变成 model-facing failure;incompatible payload 是 fatal,因为这说明 routing layer 和 handler boundary 对 runtime contract 的理解已经不一致。
tool_dispatch_trace_tests.rs
里的测试还说明:unsupported 和 incompatible calls 都会被记录成 failed dispatch traces。失败本身也是可观测的运行事实。
七、Dispatch 是受监督的工作,不是 switch statement
一旦 handler 和 payload kind 匹配,dispatch 仍然要监督这项工作。Registry 负责 pre-tool hooks、 mutating-tool gates、telemetry、post-tool hooks、goal accounting 和 trace completion。Parallel runtime 则负责 cancellation 和 read/write locking。

Registry 会先运行可选的 pre-tool hooks。如果 hook 返回 blocking message,调用会在 handler 执行前失败:
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation)
&& let Some(message) = run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
&pre_tool_use_payload.tool_name,
&pre_tool_use_payload.tool_input,
)
.await
{
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
随后 handler 在 telemetry logging 中执行。Mutating tools 在运行前还要等待 turn-level gate:
let is_mutating = handler.is_mutating(&invocation).await;
let response_cell = tokio::sync::Mutex::new(None);
let invocation_for_tool = invocation.clone();
let result = otel
.log_tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
log_payload.as_ref(),
&metric_tags,
mcp_server_ref,
mcp_server_origin_ref,
|| {
let handler = handler.clone();
let response_cell = &response_cell;
async move {
if is_mutating {
invocation_for_tool.turn.tool_call_gate.wait_ready().await;
}
match handler.handle_any(invocation_for_tool).await {
Ok(result) => {
let preview = result.result.log_preview();
let success = result.result.success_for_logging();
let mut guard = response_cell.lock().await;
*guard = Some(result);
Ok((preview, success))
}
Err(err) => Err(err),
}
}
},
)
.await;
成功得到 handler result 后,
post-tool hooks
可以添加上下文,或者在结果被转换成 model/code-mode response item 前替换已保存的 response body。这不是装饰路径:
它意味着 tool output 在下一个 consumer 看到之前,仍然是 runtime-owned value。
最后,dispatch trace 会被标记为 completed 或 failed:
match result {
Ok(_) => {
let mut guard = response_cell.lock().await;
let result = guard.take().ok_or_else(|| {
FunctionCallError::Fatal("tool produced no output".to_string())
})?;
dispatch_trace.record_completed(
&invocation,
&result.call_id,
&result.payload,
result.result.as_ref(),
);
Ok(result)
}
Err(err) => {
dispatch_trace.record_failed(&err);
Err(err)
}
}
7.1 并行用 runtime locks 决定
Parallelism 在 dispatch 前判断。在
ToolCallRuntime
里,每个 runtime 持有一个叫 parallel_execution 的 RwLock<()>。
handle_tool_call_with_source
先问 router 这个 call 是否支持 parallel execution,然后对 parallel-safe calls 使用 read lock,对需要串行的
calls 使用 write lock。
let supports_parallel = self.router.tool_supports_parallel(&call);
let lock = Arc::clone(&self.parallel_execution);
let handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
AbortOnDropHandle::new(tokio::spawn(async move {
tokio::select! {
_ = cancellation_token.cancelled() => {
let secs = started.elapsed().as_secs_f32().max(0.1);
dispatch_span.record("aborted", true);
Ok(Self::aborted_response(&call, secs))
},
res = async {
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {
Either::Right(lock.write().await)
};
router
.dispatch_tool_call_with_code_mode_result(
session,
turn,
invocation_cancellation_token,
tracker,
call.clone(),
source,
)
.instrument(dispatch_span.clone())
.await
} => res,
}
}));
这不是相信模型来决定并发。模型可以产出 parallel calls;Codex 决定哪些调用真正能共享 read lock。
Router 的 parallel decision 有两条分支。非 MCP 工具检查 configured specs 中显式支持 parallel
calls 的项;MCP 则使用 payload 中的 server name 去查 parallel_mcp_server_names:
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
match &call.payload {
ToolPayload::Mcp { server, .. } => self.parallel_mcp_server_names.contains(server),
_ => self.configured_tool_supports_parallel(&call.tool_name),
}
}
测试
mcp_parallel_support_uses_exact_payload_server
解释了原因:来自不同 server 的相似 MCP tools 不能继承彼此的并发合同。
7.2 输出服务多个读者
Tool output 不是一个字符串。ToolOutput
trait 要求每个 output type 提供 telemetry preview、success signal、model-facing
ResponseInputItem、可选 post-tool hook data,以及 code-mode result。
pub trait ToolOutput: Send {
fn log_preview(&self) -> String;
fn success_for_logging(&self) -> bool;
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
None
}
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
response_input_to_code_mode_result(self.to_response_item("", payload))
}
}
同一个 handler result 因而可以服务不同 consumer:
| Consumer | Source mechanism |
|---|---|
| 下一次模型请求 | to_response_item 产出 ResponseInputItem。 |
| Telemetry | log_preview 和 success_for_logging 产出有边界的 logging data。 |
| Hooks | post_tool_use_response 在 handler opt in 时暴露稳定 hook-facing data。 |
| Code mode | code_mode_result 可以返回适合 JS/runtime 的 value。 |
| Rollout trace | ToolDispatchTrace 保存 invocation 和 result payloads,供 replay 使用。 |
ToolSearchOutput 是一个紧凑例子。它的
to_response_item
不会伪装成普通 function output,而是返回 ResponseInputItem::ToolSearchOutput,带
execution: "client" 和 loadable tool specs 列表。
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::ToolSearchOutput {
call_id: call_id.to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect(),
}
}
这个形状很重要,因为模型不是唯一观察者。Runtime 还可以发事件、记录 telemetry、喂给 post-tool hooks,并保留 trace evidence。
八、Dispatch trace 让 tool calls 可回放
Dispatch trace adapter 是这条执行路径变成 replay evidence 的地方。Registry 在 early returns 前就启动
ToolDispatchTrace,
所以 unsupported 或 incompatible calls 也会有 trace end events:
pub(crate) struct ToolDispatchTrace {
context: ToolDispatchTraceContext,
}
impl ToolDispatchTrace {
pub(crate) fn start(invocation: &ToolInvocation) -> Self {
let context = invocation
.session
.services
.rollout_thread_trace
.start_tool_dispatch_trace(|| tool_dispatch_invocation(invocation));
Self { context }
}
}
Invocation payload 会记录 thread id、turn id、tool-call id、tool name、namespace、requester 和 typed payload:
fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option<ToolDispatchInvocation> {
let requester = match &invocation.source {
ToolCallSource::Direct => ToolDispatchRequester::Model {
model_visible_call_id: invocation.call_id.clone(),
},
ToolCallSource::CodeMode {
cell_id,
runtime_tool_call_id,
} => ToolDispatchRequester::CodeCell {
runtime_cell_id: cell_id.clone(),
runtime_tool_call_id: runtime_tool_call_id.clone(),
},
};
Some(ToolDispatchInvocation {
thread_id: invocation.session.conversation_id.to_string(),
codex_turn_id: invocation.turn.sub_id.clone(),
tool_call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.name.clone(),
tool_namespace: invocation.tool_name.namespace.clone(),
requester,
payload: tool_dispatch_payload(&invocation.payload),
})
}
源码可见的 contract 因而比“工具返回了一段文本”更强。它是:“一个具体 requester 用 typed payload 请求了某个命名工具; handler path completed 或 failed;response payload 被记录给合适的 consumer。”后续 replay 和 debug 可以消费的就是这种证据。
九、常见误读
| 误读 | 修正 |
|---|---|
| “模型拥有工具,因为它产出 tool calls。” | 模型产出语法。Runtime 拥有 handler lookup、payload validation、policy、hooks、cancellation 和 output shaping。 |
| “request 里的 tool list 就是 registry。” | model_visible_specs 是从 configured specs 过滤出来的。Registry 可以包含没有直接暴露的 handlers。 |
| “有 namespaced name 就足够路由 MCP。” | MCP routing 依赖 canonical tool identity 和 payload provenance,不只是看起来像 namespace 的字符串。 |
| “Provider 支持 parallel tool calls 就安全。” | Codex 仍要检查 handler/server concurrency support,并在 dispatch 外使用 read/write locks。 |
| “失败表示什么都没发生。” | Unsupported、incompatible、aborted、hook-blocked calls 仍会产生结构化模型响应或 trace facts。 |
| “Tool output 就是一段字符串。” | Output 会被塑形成 model continuation、telemetry、hooks、code-mode callers 和 rollout traces 各自需要的值。 |
十、应用到实践
- 把语法和权限分开。 Model-visible schema 描述 call shape;runtime handler 拥有执行权。
- 暴露前先从所有能力源规划。 Config、feature flags、MCP tools、deferred tools、dynamic tools、hosted tools 和 unavailable placeholders 都会影响最终 plan;如果 hidden tool 未来要被发现,它仍然需要 search metadata 和 handler。
- 先规范化再分发。 把 provider response items 转成带
ToolPayload的 typedToolCall,再拒绝 payload kind 与 handler kind 不匹配的调用。 - 把 parallelism 作为 runtime decision。 只有明确 handler/server contract 才能选择 read-lock concurrency;其他都串行。
- 把 output 当成多读者值。 结果要同时服务模型、code-mode caller、hooks、telemetry 和 replay,不能假装这些读者是同一个。
第 10 章进入最重要的 handler 家族:shell 与 filesystem execution。那里,抽象边界会变成具体 process
supervision:command parsing、approval、sandbox selection、exec-server、ordered output 和 filesystem
operations。