Skip to content

ToolOutput与错误模型

从 ToolOutput trait 追踪函数、MCP、搜索、统一执行和取消结果,解释日志、Hook、模型回灌与 Code Mode 的不同投影。

基于rust-v0.150.0
CodexRustToolsRuntime

ToolOutput与错误模型 ​

工具执行完返回的对象并不只有一个“结果字符串”。同一个输出需要同时回答几个不同问题:日志显示什么、模型 下一次请求收到什么、PostToolUse hook 看到什么、Code Mode 得到什么、是否算成功、是否包含外部上下文,以及输出 过大或调用被取消时如何收束。Codex 用 ToolOutput 把这些消费者放在一个运行时契约下,再由具体输出类型 决定每种投影。

本文面向已经读过ToolPayload调用模型的读者。前文解释调用如何携带 payload 进入 handler;本文只研究 handler 返回之后的结果模型,不展开某个工具的审批或业务算法。读完后,读者应能从 一个 ToolOutput 实例找到模型 response item、Code Mode value 和 hook payload 的来源,并能判断普通失败、取消 和 Fatal 哪些会继续下一次 sampling。

1. 输出契约 ​

1.1 Trait职责 ​

ToolOutput 的方法按消费者分成三组:log_preview 与 success_for_logging 服务日志和指标; to_response_item 服务 Direct 模型历史;code_mode_result、post_tool_use_* 和 contains_external_context 服务嵌套运行时、Hook 与记忆状态。默认 code_mode_result 会先生成一个 response item, 再调用通用转换函数;具体类型可以覆盖它,MCP 和统一执行输出就这样做。

源码位置:codex-rs/tools/src/tool_output.rs :: ToolOutput

rust
pub trait ToolOutput: Send {
    fn log_preview(&self) -> String;

    fn success_for_logging(&self) -> bool;

    fn contains_external_context(&self) -> bool {
        false
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;

    fn post_tool_use_id(&self, call_id: &str) -> String {
        call_id.to_string()
    }

    fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
        None
    }

    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))
    }
}

success_for_logging 不等同于模型 response 中的 success 字段:它决定 telemetry 和 lifecycle 如何记录执行结果; to_response_item 才决定模型看到的协议形状。一个输出可以在日志上成功,却因为 PostToolUse hook 反馈而被替换成 模型可见的失败文本;因此调试时要分别查看这两个消费者。

1.2 消费时机 ​

Registry 在 handler 返回后先取日志预览与成功度量,再构造 PostToolUse payload;只有成功取得结果时才运行 PostToolUse hook。最后 AnyToolResult 保留原始 output,Direct 路径调用 into_response,Code Mode 路径调用 code_mode_result。

相关源码:

  • codex-rs/core/src/tools/registry.rs :: handle_any_tool
  • codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
rust
let output = tool.handle(invocation.clone()).await?;
if output.contains_external_context()
    && invocation.turn.config.memories.disable_on_external_context
{
    state_db::mark_thread_memory_mode_polluted(
        invocation.session.services.state_db.as_deref(),
        invocation.session.thread_id,
        "tool_output",
    )
    .await;
}
let post_tool_use_payload =
    CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref());
Ok(AnyToolResult {
    call_id,
    payload,
    result: output,
    post_tool_use_payload,
})

外部上下文标记在结果刚由 handler 产生时处理,而不是等 response item 写入历史后再猜测。这样 MCP 或其他外部 来源可以阻止后续 memory generation,即使 Code Mode 消费的是另一种结果投影。

2. 结果投影 ​

2.1 函数结果 ​

FunctionToolOutput 保存内容项和可选成功值,function_tool_response 再根据 payload 决定返回 FunctionCallOutput 还是 CustomToolCallOutput。因此输出类型本身可以相同,调用 payload 仍会影响协议变体。

相关源码:

  • codex-rs/core/src/tools/context.rs :: FunctionToolOutput
  • codex-rs/core/src/tools/context.rs :: function_tool_response
rust
impl ToolOutput for FunctionToolOutput {
    fn log_preview(&self) -> String {
        telemetry_preview(
            &function_call_output_content_items_to_text(&self.body).unwrap_or_default(),
        )
    }

    fn success_for_logging(&self) -> bool {
        self.success.unwrap_or(true)
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        function_tool_response(call_id, payload, self.body.clone(), self.success)
    }
}

2.2 JSON结果 ​

JsonToolOutput 是扩展和动态工具常用的结构化输出。它把 serde_json::Value 序列化为模型函数输出, 但 Code Mode 直接拿原始 JSON value,不经过文本化。with_external_context 只设置记忆策略标记,不改变模型输出。

源码位置:codex-rs/tools/src/tool_output.rs :: JsonToolOutput

rust
impl ToolOutput for JsonToolOutput {
    fn log_preview(&self) -> String {
        telemetry_preview(&self.value.to_string())
    }

    fn success_for_logging(&self) -> bool {
        self.success.unwrap_or(true)
    }

    fn contains_external_context(&self) -> bool {
        self.contains_external_context
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        let output = FunctionCallOutputPayload {
            body: FunctionCallOutputBody::Text(self.value.to_string()),
            success: self.success,
        };

        if matches!(payload, ToolPayload::Custom { .. }) {
            return ResponseInputItem::CustomToolCallOutput {
                call_id: call_id.to_string(),
                name: None,
                output,
            };
        }

        ResponseInputItem::FunctionCallOutput {
            call_id: call_id.to_string(),
            output,
        }
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        self.value.clone()
    }
}

2.3 MCP结果 ​

McpToolOutput 同时保存 MCP 原始结果、输入、wall time、图像能力和截断策略。它的模型投影不是简单调用 CallToolResult::as_function_call_output_payload():先清理不支持的原始图像 detail,再插入耗时头,最后按 模型截断策略压缩 payload。Code Mode 则可以继续拿原始 MCP 结果的结构化形式。

源码位置:codex-rs/core/src/tools/context.rs :: McpToolOutput::response_payload

rust
fn response_payload(&self) -> FunctionCallOutputPayload {
    let mut payload = self.result.as_function_call_output_payload();
    if let Some(items) = payload.content_items_mut() {
        sanitize_original_image_detail(self.original_image_detail_supported, items);
    }

    let wall_time_seconds = self.wall_time.as_secs_f64();
    let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");

    match &mut payload.body {
        FunctionCallOutputBody::Text(text) => {
            if text.is_empty() {
                *text = header;
            } else {
                *text = format!("{header}\n{text}");
            }
        }
        FunctionCallOutputBody::ContentItems(items) => {
            items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
        }
    }

    truncate_function_output_payload(&payload, self.truncation_policy * 1.2)
}

这里有两个独立边界:图像 detail 是模型能力兼容边界,wall time 是诊断信息,截断是上下文预算边界。三者发生 在同一个函数中,但不能把“输出被截断”解释为 MCP 调用失败;原始 CallToolResult 仍可用于 Code Mode 和 hook。

2.4 搜索结果 ​

ToolSearchOutput 将每个 LoadableToolSpec 序列化到 ResponseInputItem::ToolSearchOutput,并固定写入 status: completed 与 execution: client。空工具数组也使用同一协议形状,因此“completed”只代表搜索调用已经 收束,不代表命中了工具。

源码位置:codex-rs/core/src/tools/context.rs :: ToolSearchOutput::to_response_item

rust
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(),
    }
}

3. 截断策略 ​

3.1 预览截断 ​

通用 telemetry_preview 对日志预览设置字节和行数上限,并在发生截断时追加固定 notice。它只改变 telemetry 字符串,不改变 to_response_item 的模型输出;因此日志短并不代表模型收到的结果短。

源码位置:codex-rs/tools/src/tool_output.rs :: telemetry_preview

rust
const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024;
const TELEMETRY_PREVIEW_MAX_LINES: usize = 64;
const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]";

fn telemetry_preview(content: &str) -> String {
    let truncated_slice = take_bytes_at_char_boundary(content, TELEMETRY_PREVIEW_MAX_BYTES);
    let truncated_by_bytes = truncated_slice.len() < content.len();
    let mut preview = String::new();
    let mut lines_iter = truncated_slice.lines();
    for idx in 0..TELEMETRY_PREVIEW_MAX_LINES {
        match lines_iter.next() {
            Some(line) => {
                if idx > 0 {
                    preview.push('\n');
                }
                preview.push_str(line);
            }
            None => break,
        }
    }
    let truncated_by_lines = lines_iter.next().is_some();
    if !truncated_by_bytes && !truncated_by_lines {
        return content.to_string();
    }
    preview.push_str(TELEMETRY_PREVIEW_TRUNCATION_NOTICE);
    preview
}

3.2 执行输出 ​

统一执行结果需要同时表达 chunk id、wall time、退出码、仍在运行的 session id、原始 token 数量和实际输出。 ExecCommandToolOutput::truncated_output 先处理采集阶段已经省略的 bytes marker,再按模型 token 预算截断,必要时 写入原始 token count 警告。

源码位置:codex-rs/core/src/tools/context.rs :: ExecCommandToolOutput::truncated_output

rust
pub(crate) fn truncated_output(&self, max_tokens: usize) -> String {
    let text = String::from_utf8_lossy(&self.raw_output).to_string();
    let policy = TruncationPolicy::Tokens(max_tokens);
    let Some(omitted_bytes) = self.output_omitted_bytes else {
        return formatted_truncate_text(&text, policy);
    };

    let marker = format_output_omission_marker(omitted_bytes.get());
    if text.len() <= policy.byte_budget() {
        return if text.contains(&marker) {
            text
        } else {
            format!("{marker}\n{text}")
        };
    }

    let original_token_count = self
        .original_token_count
        .unwrap_or_else(|| approx_token_count(&text));
    let truncated = truncate_text(&text, policy);
    let omission_notice = if truncated.contains(&marker) {
        String::new()
    } else {
        format!("{marker}\n")
    };
    format!(
        "Warning: truncated output (original token count: {original_token_count})\n{omission_notice}\n{truncated}"
    )
}

采集上限和模型上限是两次不同截断:前者已经丢失的字节通过 marker 告知模型,后者通过 warning 告知模型当前 响应又被预算压缩。只看最终文本长度无法判断损失发生在哪一层。

这张图区分采集阶段和模型预算阶段:省略 marker 表示上游已经丢失的 bytes,token warning 表示本次响应又被 模型预算压缩,两个提示可以同时出现。

4. 错误终态 ​

4.1 普通失败 ​

ToolCallRuntime::handle_tool_call 把 FunctionCallError::RespondToModel 转成 payload 对应的失败 response item; Function 和 Custom 会保留错误文本并写 success: false,ToolSearch 则回灌空列表。这些结果会让 Turn 继续,模型 可以根据下一次上下文修正调用。

相关源码:

  • 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)),
}

4.2 取消结果 ​

取消不是普通 handler 错误。调度器根据 runtime 是否需要 teardown 决定等待收尾还是中止任务;随后构造 AbortedToolOutput。Function/Custom 会收到取消文本,ToolSearch 仍得到 completed 空列表,因为它的协议形状 没有错误正文字段。

源码位置:codex-rs/core/src/tools/context.rs :: AbortedToolOutput

rust
impl ToolOutput for AbortedToolOutput {
    fn log_preview(&self) -> String {
        telemetry_preview(&self.message)
    }

    fn success_for_logging(&self) -> bool {
        false
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        match payload {
            ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
                call_id: call_id.to_string(),
                status: "completed".to_string(),
                execution: "client".to_string(),
                tools: Vec::new(),
            },
            _ => function_tool_response(
                call_id,
                payload,
                vec![FunctionCallOutputContentItem::InputText {
                    text: self.message.clone(),
                }],
                None,
            ),
        }
    }
}

4.3 Fatal错误 ​

Fatal 不进入 failure_response,而是转成 CodexErr::Fatal。payload kind 不匹配就是这种内部不变量错误; 而 handler 的参数解析失败通常返回 RespondToModel。这条分类让模型可修复输入错误与维护者需要处理的程序错误 分开收束。

5. Hook边界 ​

5.1 输入与响应 ​

PostToolUse hook 需要稳定的 input/response,而不是任意模型文本。CoreToolRuntime::post_tool_use_payload 默认 只为 Function payload 构造 hook 输入;具体输出可以覆盖 post_tool_use_response。例如 unified exec 用 hook command 作为 input,并在进程已结束且有 hook command 时提供截断后的响应。

相关源码:

  • codex-rs/core/src/tools/registry.rs :: CoreToolRuntime::post_tool_use_payload
  • codex-rs/core/src/tools/context.rs :: ExecCommandToolOutput::post_tool_use_response
rust
fn post_tool_use_payload(
    &self,
    invocation: &ToolInvocation,
    result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
    let ToolPayload::Function { arguments } = &invocation.payload else {
        return None;
    };

    Some(PostToolUsePayload {
        tool_name: function_hook_tool_name(invocation),
        tool_use_id: result.post_tool_use_id(&invocation.call_id),
        tool_input: result
            .post_tool_use_input(&invocation.payload)
            .unwrap_or_else(|| function_hook_tool_input(arguments)),
        tool_response: result
            .post_tool_use_response(&invocation.call_id, &invocation.payload)
            .or_else(|| {
                let ResponseInputItem::FunctionCallOutput { output, .. } =
                    result.to_response_item(&invocation.call_id, &invocation.payload)
                else {
                    return None;
                };
                serde_json::to_value(output.body).ok()
            })?,
    })
}

5.2 Hook反馈 ​

PostToolUse hook 的 block 发生在 handler 已经完成之后。Registry 用 PostToolUseFeedbackOutput 保留原始 output 给 Code Mode,却把 hook feedback 作为模型可见 response。这样“用户看到 hook 拒绝”不等于“已经发生的外部副作用 被撤销”。

源码位置:codex-rs/core/src/tools/registry.rs :: PostToolUseFeedbackOutput

rust
struct PostToolUseFeedbackOutput {
    original: Box<dyn ToolOutput>,
    model_visible: FunctionToolOutput,
}

impl ToolOutput for PostToolUseFeedbackOutput {
    fn log_preview(&self) -> String {
        self.original.log_preview()
    }

    fn success_for_logging(&self) -> bool {
        self.original.success_for_logging()
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        self.model_visible.to_response_item(call_id, payload)
    }

    fn code_mode_result(&self, payload: &ToolPayload) -> Value {
        self.original.code_mode_result(payload)
    }
}

图中的两条输出箭头刻意分开:Hook 反馈只替换模型可见投影,不能把已经完成的 handler 调用倒滚回去;Code Mode 仍由 wrapper 的 original.code_mode_result 提供原始类型值。

6. Code Mode ​

6.1 结果转换 ​

默认 code_mode_result 会把 Function/Custom response body 转成 JSON string,把 ToolSearch output 转成数组, 把 MCP output 序列化为 JSON。MCP 的实现还会删除 _meta,因为该字段属于客户端私有元数据。

源码位置:codex-rs/tools/src/tool_output.rs :: response_input_to_code_mode_result

rust
fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue {
    match response {
        ResponseInputItem::FunctionCallOutput { output, .. }
        | ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body {
            FunctionCallOutputBody::Text(text) => JsonValue::String(text),
            FunctionCallOutputBody::ContentItems(items) => {
                content_items_to_code_mode_result(&items)
            }
        },
        ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
        ResponseInputItem::McpToolCallOutput { output, .. } => serde_json::to_value(output)
            .unwrap_or_else(|err| {
                JsonValue::String(format!("failed to serialize mcp result: {err}"))
            }),
        ResponseInputItem::Message { .. } => JsonValue::Null,
    }
}

ApplyPatchToolOutput 覆盖 code_mode_result 返回空对象,因为 patch 的模型可见文本不是 Code Mode API 想要的 结构化结果。ExecCommandToolOutput 则返回包含 chunk、退出码、session id、原始 token 数和 output 的对象; 这两个覆盖说明 Code Mode 结果是工具专属契约,不是模型 response 的机械复制。

6.2 MCP私有字段 ​

MCP 原始 CallToolResult 可以包含 _meta。普通模型 response 由 MCP 专用输出包装,Code Mode 路径调用 CallToolResult::code_mode_result 后删除 _meta;因此同一 MCP 调用在两个消费者处看到的字段集合不同。

源码位置:codex-rs/tools/src/tool_output.rs :: ToolOutput for CallToolResult

rust
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
    let mut result = serde_json::to_value(self).unwrap_or_else(|err| {
        JsonValue::String(format!("failed to serialize mcp result: {err}"))
    });
    if let JsonValue::Object(fields) = &mut result {
        fields.remove("_meta");
    }
    result
}

7. 测试路径 ​

7.1 变体断言 ​

context_tests::custom_tool_calls_should_roundtrip_as_custom_outputs 与 function_payloads_remain_function_outputs 使用相同的 FunctionToolOutput,只改变 payload,分别断言 CustomToolCallOutput 和 FunctionCallOutput。这证明协议变体由 payload 参与决定。

mcp_tool_output_response_item_includes_wall_time 构造 1.25 秒的 MCP 结果,断言模型文本以 Wall time: 1.2500 seconds 开头,且 JSON 内容仍然存在。它证明诊断头发生在模型投影层,不是原始 MCP 结果字段。

相关测试:

  • 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
  • codex-rs/core/src/tools/context_tests.rs :: mcp_tool_output_response_item_includes_wall_time

7.2 截断与钩子 ​

mcp_tool_output_response_item_truncates_large_structured_content 使用超大 structured content 和 128 字节策略, 断言输出包含截断标记且不再包含被 structured content 替代的普通文本。exec_command_tool_output_formats_truncated_response 则检查 chunk id、退出码、原始 token 数和 warning 共同出现在函数输出中。

registry_tests::post_tool_use_feedback_output_keeps_code_mode_result_typed 让原始 JSON output 和 hook feedback 同时存在,断言 Direct 得到 feedback 文本,而 Code Mode 仍得到原始 typed JSON。这是“hook 改变模型投影但不撤销 原始执行结果”的直接证据。

相关测试:

  • codex-rs/core/src/tools/context_tests.rs :: mcp_tool_output_response_item_truncates_large_structured_content
  • codex-rs/core/src/tools/context_tests.rs :: exec_command_tool_output_formats_truncated_response
  • codex-rs/core/src/tools/registry_tests.rs :: post_tool_use_feedback_output_keeps_code_mode_result_typed

8. 阅读练习 ​

在 Codex 源码 workspace 中运行:

bash
cargo test -p codex-core custom_tool_calls_should_roundtrip_as_custom_outputs
cargo test -p codex-core mcp_tool_output_response_item_includes_wall_time
cargo test -p codex-core mcp_tool_output_response_item_truncates_large_structured_content
cargo test -p codex-core exec_command_tool_output_formats_truncated_response
cargo test -p codex-core post_tool_use_feedback_output_keeps_code_mode_result_typed

然后尝试回答:

  1. 同一个 JsonToolOutput 以 Function payload 和 Custom payload 返回时,哪一个 ResponseInputItem 字段发生变化?
  2. MCP 输出中的 wall time、图像 detail 清理和截断分别服务什么消费者?哪些内容仍保留在 Code Mode 结果里?
  3. 一个 PostToolUse hook block 后,为什么模型收到 feedback,但 Code Mode 仍可能拿到原始 JSON?指出 wrapper 的两个 方法。
  4. 统一执行输出已经在采集阶段省略 bytes、又在模型预算阶段截断时,最终文本如何告诉模型这两次损失?

9. 边界 ​

ToolOutput 统一的是结果投影接口,不是所有工具的业务语义:

  • success_for_logging 只负责日志/lifecycle 成功度量,不替代 response payload 的 success;
  • to_response_item 只描述 Direct 模型协议,Code Mode 可能使用完全不同的 typed value;
  • 截断保证上下文预算可控,不保证原始外部输出完整保留;
  • PostToolUse feedback 改变模型可见结果,但不回滚已经完成的 handler 副作用;
  • contains_external_context 影响 memory eligibility,不是一个表示工具失败的标志。

排查结果时应分开看五条线:日志预览、生命周期成功度量、模型 response item、Hook payload 和 Code Mode value。 只有把这些消费者逐一对应到具体 ToolOutput 实现,才能解释“日志说成功、模型却收到失败反馈”或“模型输出被截断、 Code Mode 仍拿到结构化结果”这类看似矛盾的现象。