ToolPayload调用模型
工具调用进入 Core 后并不是一份随意的 JSON。模型响应中的 function call、custom tool call 和 client-side tool search 会先被压缩成 ToolCall,再携带当前 Step 的资源和取消控制转成 ToolInvocation。这两个对象分别 解决“模型说了什么”和“Core 要在什么上下文中执行”。
本文面向已经读过工具系统架构总览和ToolSpec与函数规格 的读者。前者说明 Router 与 Registry 的责任边界,后者说明模型看到的规格形状;本文继续向下追踪实际调用 载荷,不展开具体 handler 的审批、沙箱或输出截断策略。读完后,读者应能从 ResponseItem 找到对应的 ToolPayload,解释 namespace 为什么影响 Registry 查找,并判断一个 payload 错误会被回复模型还是结束 Turn。
1. 两个边界
ToolCall 是一次调用的轻量协议对象:它保存规范化后的 ToolName、模型提供的 call_id、payload 和可选的 加密 function 参数。它还没有 Session、锁或取消令牌。ToolInvocation 是执行对象:它把调用绑定到 Session、Turn、Step、diff tracker 和来源,交给具体 runtime 使用。
相关源码:
codex-rs/core/src/tools/router.rs :: ToolCallcodex-rs/core/src/tools/context.rs :: ToolInvocation
pub struct ToolCall {
pub tool_name: ToolName,
pub call_id: String,
pub payload: ToolPayload,
pub encrypted_function_args: Option<Vec<String>>,
}
pub struct ToolInvocation {
pub session: Arc<Session>,
pub turn: Arc<TurnContext>,
pub(crate) step_context: Arc<StepContext>,
pub cancellation_token: CancellationToken,
pub tracker: SharedTurnDiffTracker,
pub call_id: String,
pub tool_name: ToolName,
pub source: ToolCallSource,
pub payload: ToolPayload,
}这里的 Arc<StepContext> 很关键:工具规格是在这个 Step 建出的,调用可能在异步队列中稍后执行,因此 handler 读取的 environment、MCP 绑定和 Router 必须与模型看到的工具表属于同一个快照。turn 字段暂时保留给旧 handler, 源码注释明确表示未来会迁移到 step_context.turn;它不是第二份独立 Turn 状态。
图中两次转换的边界决定了调试方法:如果 ResponseItem 没有生成 ToolCall,问题在协议归一化;如果 ToolCall 已经存在但 Registry 找不到 runtime,问题在名称/注册表;如果 ToolInvocation 已交给 handler, 再去检查 payload 类型、参数解析和执行策略。
2. 载荷变体
ToolPayload 是 Core 接受的三种规范形状,而不是模型协议所有字段的镜像:普通函数保留原始 JSON 字符串, client tool search 解析成结构化搜索参数,custom tool 保留原始文本。这样做让下游 handler 选择正确的解析器, 也让结果回灌可以根据 payload 选择 function output 或 custom output。
源码位置:codex-rs/tools/src/tool_payload.rs :: ToolPayload
pub enum ToolPayload {
Function { arguments: String },
ToolSearch { arguments: SearchToolCallParams },
Custom { input: String },
}
impl ToolPayload {
pub fn log_payload(&self) -> Cow<'_, str> {
match self {
ToolPayload::Function { arguments } => Cow::Borrowed(arguments),
ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()),
ToolPayload::Custom { input } => Cow::Borrowed(input),
}
}
}函数参数故意保留字符串,而不是在 Router 中反序列化成通用 Value。不同 handler 需要不同的参数类型,过早 反序列化会丢失“原始 JSON 到具体参数类型”的错误位置;plan、MCP 和 shell handler 都在自己的边界调用 parse_arguments。tool search 则例外,因为 Router 需要先验证 query 和 limit 的协议结构,才能交给搜索 handler。
3. 响应归一化
3.1 Function调用
ToolRouter::build_tool_call 对 ResponseItem::FunctionCall 不解析 arguments,只做两件事:把 namespace 和 name 交给 ToolName::new(...).with_default_namespace(),再把原始 arguments 放入 ToolPayload::Function。
源码位置: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,
}))
}with_default_namespace() 会把缺失、空字符串和默认 functions namespace 归一到同一个名称空间表示。非默认 namespace 不会被抹掉,因为 mcp__codex_apps__calendar.create_event 与默认函数 create_event 可能对应两个 不同 Registry key。
3.2 ToolSearch调用
只有 execution == "client" 且存在 call_id 的 ToolSearchCall 才进入本地 ToolCall。客户端先用 serde_json::from_value 解析 SearchToolCallParams;解析失败返回 RespondToModel,让外层把错误写成工具输出。 服务端执行的 tool search 和缺失 call id 的形状返回 Ok(None),不会误投给本地搜索 handler。
源码位置: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),这解释了一个常见现象:模型响应里出现了 ToolSearchCall,不代表 Core 一定创建了待执行任务。只有客户端执行 标记和可用 call id 同时满足,才会进入 handle_output_item_done 的 tool future 队列。
3.3 Custom调用
CustomToolCall 的 input 是文本,Router 不尝试按 Lark grammar 或 patch 语法解析;它只把文本放到 ToolPayload::Custom。语法验证属于具体 custom runtime,例如 ApplyPatchHandler 调用 patch parser。
源码位置: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,
})),4. 来源语义
4.1 Direct调用
模型直接返回的 function、custom 或 client tool search 默认使用 ToolCall::direct_source()。绝大多数调用 得到 ToolCallSource::Direct,但 collaboration namespace 下的 spawn_agent、send_message 和 followup_task 如果没有加密参数,会得到 DirectPlaintextMessage。
源码位置:codex-rs/core/src/tools/router.rs :: ToolCall::direct_source
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
}
}这个分支不改变 payload 或 Registry key,只改变“调用来源”标签。stream_events_utils 会用它记录工具调用日志, lifecycle 会把它映射成扩展 API 的 Direct 来源,tool_dispatch_trace 则把它归入模型请求者;真正的明文保护 发生在日志预览函数。
4.2 日志预览
DirectPlaintextMessage 的 payload 不应出现在普通 telemetry preview 中。tool_log_payload 对它返回固定文本, 而普通 Direct 和 Code Mode payload 仍使用各自的可记录内容。注意这不是加密:它只是在日志层不展示明文,handler 仍然会收到原始 JSON 参数。
源码位置:codex-rs/core/src/tools/router.rs :: tool_log_payload
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()
}相关消费者:
codex-rs/core/src/stream_events_utils.rs :: handle_output_item_donecodex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcomecodex-rs/core/src/tools/tool_dispatch_trace.rs :: tool_dispatch_invocation
三处消费者都使用相同的来源概念,但职责不同:前者写即时 trace 日志,Registry 记录工具结果,rollout trace 保存结构化调用。不能把“日志被脱敏”理解为“所有历史或 handler 输入都被脱敏”。
4.3 Code Mode
Code Mode 不经过模型 ResponseItem 的 build_tool_call 分支。嵌套运行时根据工具种类自行构造 payload:函数工具 要求 JSON object,freeform 工具要求 string,然后创建一个新的 ToolCall,调用 handle_tool_call_with_source 并传入 ToolCallSource::CodeMode。
相关源码:
codex-rs/core/src/tools/code_mode/mod.rs :: dispatch_nested_toolcodex-rs/core/src/tools/code_mode/mod.rs :: build_nested_tool_payload
let payload = match build_nested_tool_payload(tool_kind, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
};
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?;CodeMode 的 runtime_tool_call_id 只在一个 cell 内需要唯一,不是模型可见的 call_id。因此 trace 和扩展 API 同时保存 cell id 与 runtime id,结果也不回灌成普通 ResponseInputItem,而是通过 ToolOutput::code_mode_result 返回给代码运行时。
图中 Code Mode 与模型路径共享 Registry,但消费者不同:Direct 结果要进入下一次模型请求,Code Mode 结果要 进入代码 cell。相同的 handler 并不意味着相同的输出协议。
5. 调用上下文
ToolRouter 在 dispatch 内部把 ToolCall 字段和执行资源组装成 ToolInvocation,然后交给 dispatch_any_with_terminal_outcome。此时才把取消 token、diff tracker 和当前 Step 的环境带入 handler。
源码位置: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)
.await这里的解构会消费 ToolCall,把 payload 和 call id 移进 invocation;step_context 则作为 Arc 转移给 invocation。 之后 Registry 可以把 invocation clone 给异步 hook、trace 和 handler,而无需重建模型请求上下文。
Registry 首先按规范化的 ToolName 找 runtime,再校验 runtime 声明的 payload kind。默认 CoreToolRuntime 接受 Function 与 ToolSearch;ApplyPatchHandler 覆盖为 Custom。名称存在但 payload 变体错误是 Fatal,因为这说明 规格、Router 或 runtime 注册之间出现了内部不变量破坏,不是模型可以修复的普通参数错误。
相关源码:
codex-rs/core/src/tools/registry.rs :: CoreToolRuntime::matches_kindcodex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
payload,
ToolPayload::Function { .. } | ToolPayload::ToolSearch { .. }
)
}
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));
}未知名称和 payload kind 不匹配看起来都像“工具没执行”,但恢复策略不同:未知名称会生成模型可见失败,模型 可以改调用;kind 不匹配升级为 Turn 级 Fatal,提醒维护者检查注册和规格是否错配。
6. Handler消费
6.1 Function
PlanHandler 直接匹配 ToolPayload::Function,然后才把字符串解析为 UpdatePlanArgs。它还检查当前 Turn 是否处于 Plan mode;因此 payload kind 正确不代表业务条件一定允许执行。
源码位置:codex-rs/core/src/tools/handlers/plan.rs :: PlanHandler::handle_call
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"update_plan handler received unsupported payload".to_string(),
));
}
};
if turn.mode == ModeKind::Plan {
return Err(FunctionCallError::RespondToModel(
"update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(),
));
}
let args = parse_update_plan_arguments(&arguments)?;
session
.send_event(turn.as_ref(), EventMsg::PlanUpdate(args))
.await;这里有三层检查:Registry 的 kind 检查、handler 的再次匹配、参数反序列化和 Turn mode 业务条件。后两层返回 RespondToModel,说明“payload 形状正确”与“当前调用可接受”是两个不同问题。
6.2 Custom
ApplyPatchHandler 对 Custom payload 取出原始 patch 文本,调用 patch parser 和环境验证。Router 不知道 *** Begin Patch 语法,handler 才是 grammar 和文件系统语义的所有者。
源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ApplyPatchHandler::handle_call
let ToolPayload::Custom { input: patch_input } = payload else {
return Err(FunctionCallError::RespondToModel(
"apply_patch handler received unsupported payload".to_string(),
));
};
let args = match codex_apply_patch::parse_patch(&patch_input) {
Ok(args) => args,
Err(parse_error) => {
return Err(FunctionCallError::RespondToModel(format!(
"apply_patch verification failed: {parse_error}"
)));
}
};这条路径体现了 Custom 的生命周期:输入先以文本形式穿过 Router 和 hook,随后在 handler 内变成结构化 patch 动作;解析失败时不会产生文件系统副作用,也不会被误报成 payload kind 错误。
6.3 ToolSearch
ToolSearchHandler 只接受 ToolPayload::ToolSearch。query 为空或 limit 为零是模型可修复的输入错误,返回 RespondToModel;搜索结果为空则是成功的空列表,不是失败。当前失败适配还有一个容易忽略的边界: ToolSearch 的 RespondToModel 不会把错误文本写进输出,而是回灌一个 status: completed、tools: [] 的 ToolSearchOutput。因此不能只看 response item 的 completed 状态判断搜索输入是否合法。
源码位置:codex-rs/core/src/tools/handlers/tool_search.rs :: ToolSearchHandler::handle_call
let args = match payload {
ToolPayload::ToolSearch { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(
"tool_search handler received unsupported payload".to_string(),
));
}
};
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}
let limit = args.limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT);
if limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}7. 结果边界
Direct 和 Code Mode 共用 ToolPayload,但结果消费者不同。Direct 由 ToolCallRuntime::handle_tool_call 把 AnyToolResult 转成 ResponseInputItem;普通 RespondToModel 也会被包装成失败 output,只有 Fatal 才成为 CodexErr。Code Mode 则调用 result.code_mode_result(),不会伪装成模型历史项。
相关源码:
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)),
}
fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem {
let message = err.to_string();
match call.payload {
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call.call_id,
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
call_id: call.call_id,
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
_ => ResponseInputItem::FunctionCallOutput {
call_id: call.call_id,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
}
}因此,payload 变体不仅决定 handler 如何读取输入,也决定失败结果的协议类型。把 custom 失败包装成 FunctionCallOutput 会让后续模型历史失去原始 custom 调用语义;这正是 failure_response 按 payload 分支的原因。 ToolSearch 分支则丢弃 message 并返回空列表,这是当前实现与 Function/Custom 失败语义的不对称之处。
8. 测试路径
8.1 名称与形状
router_tests::build_tool_call_normalizes_default_function_and_custom_namespaces 为 function 和 custom 分别传入 缺失、空字符串和默认 namespace,断言三种输入最终得到同一个默认 namespace。另一个 namespace 测试断言 MCP function 的 Registry 名称保留 namespace,custom 测试则断言文本进入 ToolPayload::Custom。
相关测试:
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
源码位置:codex-rs/core/src/tools/router_tests.rs :: build_tool_call_normalizes_default_function_and_custom_namespaces
for namespace in [None, Some(""), Some(DEFAULT_FUNCTION_NAMESPACE)] {
let function_call = ToolRouter::build_tool_call(ResponseItem::FunctionCall {
id: None,
name: "lookup".to_string(),
namespace: namespace.map(str::to_string),
arguments: "{}".to_string(),
encrypted_function_args: None,
call_id: "call-function".to_string(),
internal_chat_message_metadata_passthrough: None,
})?
.expect("function_call should produce a tool call");
assert_eq!(
function_call.tool_name,
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup")
);
}这些测试证明归一化和 payload 构造,不证明 Registry 一定有对应 runtime,也不证明 handler 参数内容业务上有效。
8.2 来源与输出
router_tests::tool_log_payload_redacts_plaintext_multi_agent_messages 输入包含目标路径和消息的 function JSON, 断言 DirectPlaintextMessage 只产生 [plaintext arguments],普通 Direct 仍保留 payload。这个断言只覆盖日志 预览,不代表历史、hook 或 handler 输入被替换。
context_tests::custom_tool_calls_should_roundtrip_as_custom_outputs 和 function_payloads_remain_function_outputs 使用同一个 FunctionToolOutput,分别断言 Custom 与 Function payload 会生成不同的 ResponseInputItem 变体,说明输出协议由 payload 保持的调用形状决定。
源码位置:
codex-rs/core/src/tools/router_tests.rs :: tool_log_payload_redacts_plaintext_multi_agent_messagescodex-rs/core/src/tools/context_tests.rs :: custom_tool_calls_should_roundtrip_as_custom_outputscodex-rs/core/src/tools/context_tests.rs :: function_payloads_remain_function_outputs
9. 阅读练习
在 Codex 源码 workspace 中运行:
cargo test -p codex-core build_tool_call_normalizes_default_function_and_custom_namespaces
cargo test -p codex-core build_custom_tool_call_uses_namespace_for_registry_name
cargo test -p codex-core tool_log_payload_redacts_plaintext_multi_agent_messages
cargo test -p codex-core custom_tool_calls_should_roundtrip_as_custom_outputs然后尝试回答:
- 一个
ResponseItem::FunctionCall的arguments为什么在 Router 中保持字符串,而ToolSearchCall.arguments却必须立即反序列化?分别指出它们的第一个类型消费者。 - 将一个 function call 的 namespace 从
None改成mcp__calendar__后,应该先检查ToolName、Registry 注册名还是 handler 参数解析?说明每一步能排除什么问题。 - 对
DirectPlaintextMessage输入一个错误参数,判断哪些地方仍能看到原始参数,哪些日志只会看到占位文本。 - 让 Code Mode 以字符串调用一个 Function 工具,指出错误发生在
build_nested_tool_payload还是 Registry kind 检查,并说明为什么不会进入 handler。
10. 边界
ToolPayload 只描述调用载荷的协议形状,不负责:
- 规格是否对模型可见,这由
ToolExposure和ToolRouter决定; - JSON Schema 是否符合兼容子集,这由 schema 解析层决定;
- 参数是否通过 approval、sandbox、hook 或业务状态检查,这由 Registry 和具体 handler 决定;
- 输出如何截断、写入历史或产生结构化事件,这属于工具输出与 Turn 集成层。
真正排查一次工具调用时,应沿着 ResponseItem → ToolCall → ToolInvocation → runtime → ToolOutput 顺序检查, 不要把“模型生成了调用”“Registry 找到工具”和“handler 接受参数”混成一个状态。
