Skip to content

ToolPayload调用模型

从 Responses 响应项追踪 ToolCall、ToolPayload 与 ToolInvocation,解释函数、自定义工具、工具搜索和 Code Mode 调用的分叉。

基于rust-v0.150.0
CodexRustToolsRuntime

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 :: ToolCall
  • codex-rs/core/src/tools/context.rs :: ToolInvocation
rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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_done
  • codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
  • codex-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_tool
  • codex-rs/core/src/tools/code_mode/mod.rs :: build_nested_tool_payload
rust
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

rust
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_kind
  • codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
rust
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

rust
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

rust
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

rust
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_call
  • codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::failure_response
rust
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_namespaces
  • codex-rs/core/src/tools/router_tests.rs :: build_tool_call_uses_namespace_for_registry_name
  • codex-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

rust
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_messages
  • codex-rs/core/src/tools/context_tests.rs :: custom_tool_calls_should_roundtrip_as_custom_outputs
  • codex-rs/core/src/tools/context_tests.rs :: function_payloads_remain_function_outputs

9. 阅读练习 ​

在 Codex 源码 workspace 中运行:

bash
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

然后尝试回答:

  1. 一个 ResponseItem::FunctionCall 的 arguments 为什么在 Router 中保持字符串,而 ToolSearchCall.arguments 却必须立即反序列化?分别指出它们的第一个类型消费者。
  2. 将一个 function call 的 namespace 从 None 改成 mcp__calendar__ 后,应该先检查 ToolName、Registry 注册名还是 handler 参数解析?说明每一步能排除什么问题。
  3. 对 DirectPlaintextMessage 输入一个错误参数,判断哪些地方仍能看到原始参数,哪些日志只会看到占位文本。
  4. 让 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 接受参数”混成一个状态。