mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix(core): truncate large mcp tool outputs in rollouts (#20260)
## Why
Large MCP tool call outputs can make rollout JSONL files enormous. In
the session that motivated this change, the biggest JSONL records were:
- `event_msg/mcp_tool_call_end`
- `response_item/function_call_output`
both containing the same unbounded MCP payloads - just 3 MCP tool calls
that each were multi-hundred MBs 😱
This PR truncates both of those JSONL records.
## How
#### For `response_item/function_call_output`
Unified exec already bounds tool output before it is injected into
model-facing history, which also keeps the corresponding rollout
`response_item/function_call_output` records small.
MCP should follow the same pattern: truncate the model-facing tool
output at the tool-output boundary, while leaving code-mode/raw hook
consumers alone.
#### For `event_msg/mcp_tool_call_end`
`McpToolCallEnd` also needs its own bounded event copy because it is the
app-server/replay/UI event shape that backs `ThreadItem::McpToolCall`.
Unfortunately this is _not_ downstream of the `ToolOutput` trait.
## Model behavior
Model behavior is actually unchanged as a result of this PR.
Before this PR, MCP output was:
1. Converted to `FunctionCallOutput`.
2. Recorded into in-memory history.
3. Truncated by `ContextManager::record_items()` before later model
turns saw it.
After this branch, MCP output is truncated earlier, in
`McpToolOutput::response_payload()`, using the same helper. Then
`ContextManager::record_items()` sees an already-truncated output and
effectively has little/no additional work to do.
So the model should still see the same kind of truncated function-call
output. The practical difference is where truncation happens: earlier,
before rollout persistence/app-server emission can see the giant
payload.
## Verification
- `cargo test -p codex-core mcp_tool_output`
- `cargo test -p codex-core
mcp_tool_call::tests::truncate_mcp_tool_result_for_event`
- `cargo test -p codex-core
mcp_post_tool_use_payload_uses_model_tool_name_args_and_result`
- `just fmt`
- `just fix -p codex-core`
- `git diff --check`
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use crate::context_manager::truncate_function_output_payload;
|
||||
use crate::original_image_detail::sanitize_original_image_detail;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -142,6 +143,7 @@ pub struct McpToolOutput {
|
||||
pub tool_input: JsonValue,
|
||||
pub wall_time: Duration,
|
||||
pub original_image_detail_supported: bool,
|
||||
pub truncation_policy: TruncationPolicy,
|
||||
}
|
||||
|
||||
impl ToolOutput for McpToolOutput {
|
||||
@@ -199,7 +201,13 @@ impl McpToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
payload
|
||||
// This is the context-injection form, so keep it aligned with the
|
||||
// function-call output truncation that conversation history already
|
||||
// applies. Code-mode consumers still get the raw `CallToolResult`.
|
||||
//
|
||||
// The text is serialized again inside the Responses payload, so allow
|
||||
// a small buffer for JSON escaping and wrapper overhead.
|
||||
truncate_function_output_payload(&payload, self.truncation_policy * 1.2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ fn mcp_tool_output_response_item_includes_wall_time() {
|
||||
tool_input: json!({}),
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
original_image_detail_supported: false,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
@@ -137,6 +138,51 @@ fn mcp_tool_output_response_item_includes_wall_time() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_output_response_item_truncates_large_structured_content() {
|
||||
let output = McpToolOutput {
|
||||
result: CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "ignored when structured content is present",
|
||||
})],
|
||||
structured_content: Some(serde_json::json!({
|
||||
"items": "large structured value ".repeat(1_000),
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
},
|
||||
tool_input: json!({}),
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
original_image_detail_supported: false,
|
||||
truncation_policy: TruncationPolicy::Bytes(128),
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
"mcp-call-large",
|
||||
&ToolPayload::Mcp {
|
||||
server: "server".to_string(),
|
||||
tool: "tool".to_string(),
|
||||
raw_arguments: "{}".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
assert_eq!(call_id, "mcp-call-large");
|
||||
assert_eq!(output.success, Some(true));
|
||||
let text = output
|
||||
.body
|
||||
.to_text()
|
||||
.expect("MCP output should serialize as text");
|
||||
assert!(text.starts_with("Wall time: 1.2500 seconds\nOutput:\n"));
|
||||
assert!(text.contains("chars truncated"));
|
||||
assert!(!text.contains("ignored when structured content is present"));
|
||||
}
|
||||
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_output_response_item_preserves_content_items() {
|
||||
let image_url = "data:image/png;base64,AAA";
|
||||
@@ -154,6 +200,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
|
||||
tool_input: json!({}),
|
||||
wall_time: std::time::Duration::from_millis(500),
|
||||
original_image_detail_supported: false,
|
||||
truncation_policy: TruncationPolicy::Bytes(1024),
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
@@ -193,6 +240,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
||||
let large_content = "large structured value ".repeat(1_000);
|
||||
let output = McpToolOutput {
|
||||
result: CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
@@ -200,7 +248,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
||||
"text": "ignored",
|
||||
})],
|
||||
structured_content: Some(serde_json::json!({
|
||||
"content": "done",
|
||||
"content": large_content,
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
@@ -208,6 +256,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
||||
tool_input: json!({}),
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
original_image_detail_supported: false,
|
||||
truncation_policy: TruncationPolicy::Bytes(64),
|
||||
};
|
||||
|
||||
let result = output.code_mode_result(&ToolPayload::Mcp {
|
||||
@@ -224,7 +273,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
||||
"text": "ignored",
|
||||
}],
|
||||
"structuredContent": {
|
||||
"content": "done",
|
||||
"content": "large structured value ".repeat(1_000),
|
||||
},
|
||||
"isError": false,
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ impl ToolHandler for McpHandler {
|
||||
tool_input: result.tool_input,
|
||||
wall_time: started.elapsed(),
|
||||
original_image_detail_supported: can_request_original_image_detail(&turn.model_info),
|
||||
truncation_policy: turn.truncation_policy,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -181,6 +182,7 @@ mod tests {
|
||||
}),
|
||||
wall_time: Duration::from_millis(42),
|
||||
original_image_detail_supported: true,
|
||||
truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
|
||||
};
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let invocation = ToolInvocation {
|
||||
|
||||
Reference in New Issue
Block a user