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
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_toolcodex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
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 :: FunctionToolOutputcodex-rs/core/src/tools/context.rs :: function_tool_response
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
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
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
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
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
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_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)),
}4.2 取消结果
取消不是普通 handler 错误。调度器根据 runtime 是否需要 teardown 决定等待收尾还是中止任务;随后构造 AbortedToolOutput。Function/Custom 会收到取消文本,ToolSearch 仍得到 completed 空列表,因为它的协议形状 没有错误正文字段。
源码位置:codex-rs/core/src/tools/context.rs :: AbortedToolOutput
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_payloadcodex-rs/core/src/tools/context.rs :: ExecCommandToolOutput::post_tool_use_response
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
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
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
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_outputscodex-rs/core/src/tools/context_tests.rs :: function_payloads_remain_function_outputscodex-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_contentcodex-rs/core/src/tools/context_tests.rs :: exec_command_tool_output_formats_truncated_responsecodex-rs/core/src/tools/registry_tests.rs :: post_tool_use_feedback_output_keeps_code_mode_result_typed
8. 阅读练习
在 Codex 源码 workspace 中运行:
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然后尝试回答:
- 同一个
JsonToolOutput以 Function payload 和 Custom payload 返回时,哪一个ResponseInputItem字段发生变化? - MCP 输出中的 wall time、图像 detail 清理和截断分别服务什么消费者?哪些内容仍保留在 Code Mode 结果里?
- 一个 PostToolUse hook block 后,为什么模型收到 feedback,但 Code Mode 仍可能拿到原始 JSON?指出 wrapper 的两个 方法。
- 统一执行输出已经在采集阶段省略 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 仍拿到结构化结果”这类看似矛盾的现象。
