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:
Owen Lin
2026-04-30 16:30:43 +00:00
committed by GitHub
parent 8a97f3cf03
commit 3516cb9751
8 changed files with 351 additions and 6 deletions
+1 -1
View File
@@ -452,7 +452,7 @@ impl ContextManager {
}
}
fn truncate_function_output_payload(
pub(crate) fn truncate_function_output_payload(
output: &FunctionCallOutputPayload,
policy: TruncationPolicy,
) -> FunctionCallOutputPayload {
+1
View File
@@ -7,3 +7,4 @@ pub(crate) use history::TotalTokenUsageBreakdown;
pub(crate) use history::estimate_response_item_model_visible_bytes;
pub(crate) use history::is_codex_generated_item;
pub(crate) use history::is_user_turn_boundary;
pub(crate) use history::truncate_function_output_payload;
+50 -2
View File
@@ -60,6 +60,9 @@ use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use codex_rollout::state_db;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::Serialize;
@@ -81,6 +84,7 @@ const MCP_RESULT_TELEMETRY_TARGET_ID_SPAN_ATTR: &str = "codex.mcp.target.id";
const MCP_RESULT_TELEMETRY_SERVER_USER_FLOW_SPAN_ATTR: &str =
"codex.mcp.server_user_flow.triggered";
const MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS: usize = 256;
const MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP;
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
@@ -362,7 +366,7 @@ async fn handle_approved_mcp_tool_call(
invocation,
mcp_app_resource_uri,
duration,
result: result.clone(),
result: truncate_mcp_tool_result_for_event(&result),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event.clone()).await;
maybe_track_codex_app_used(sess, turn_context, &server, &tool_name).await;
@@ -648,6 +652,50 @@ fn sanitize_mcp_tool_result_for_model(
})
}
fn truncate_mcp_tool_result_for_event(
result: &Result<CallToolResult, String>,
) -> Result<CallToolResult, String> {
match result {
Ok(call_tool_result) => {
// The app-server rebuilds `ThreadItem::McpToolCall` from this event,
// so avoid persisting multi-megabyte results in rollout storage.
let Ok(serialized) = serde_json::to_string(call_tool_result) else {
return Ok(call_tool_result.clone());
};
if serialized.len() <= MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES {
return Ok(call_tool_result.clone());
}
// A huge MCP result can put bytes in `content`, `structuredContent`,
// or `_meta`. Collapse the event copy to a text preview of the whole
// serialized result so the UI still has useful context without
// preserving a multi-megabyte structured payload.
//
// This budget applies to the preview text, not the final event JSON.
// The preview is itself serialized into a JSON string, so quotes and
// backslashes can be escaped again and the stored event may end up
// somewhat larger than this byte budget.
let truncated = truncate_text(
&serialized,
TruncationPolicy::Bytes(MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES),
);
Ok(CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": truncated,
})],
structured_content: None,
is_error: call_tool_result.is_error,
meta: None,
})
}
Err(message) => Err(truncate_text(
message,
TruncationPolicy::Bytes(MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES),
)),
}
}
async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) {
sess.send_event(turn_context, event).await;
}
@@ -1939,7 +1987,7 @@ async fn notify_mcp_tool_call_skip(
invocation,
mcp_app_resource_uri,
duration: Duration::ZERO,
result: Err(message.clone()),
result: truncate_mcp_tool_result_for_event(&Err(message.clone())),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event).await;
Err(message)
+66
View File
@@ -838,6 +838,72 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() {
assert_eq!(got, original);
}
#[test]
fn truncate_mcp_tool_result_for_event_preserves_small_result() {
let original = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "hello",
})],
structured_content: Some(serde_json::json!({"x": 1})),
is_error: Some(false),
meta: Some(serde_json::json!({"k": "v"})),
};
let got = truncate_mcp_tool_result_for_event(&Ok(original.clone()))
.expect("small result should remain successful");
assert_eq!(got, original);
}
#[test]
fn truncate_mcp_tool_result_for_event_bounds_large_result() {
let original = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "long-message-with-newlines-\n".repeat(200_000),
})],
structured_content: Some(serde_json::json!({
"structured": "structured-value-".repeat(200_000),
})),
is_error: Some(false),
meta: Some(serde_json::json!({
"meta": "meta-value-".repeat(200_000),
})),
};
let got = truncate_mcp_tool_result_for_event(&Ok(original))
.expect("large result should remain successful");
let serialized = serde_json::to_string(&got).expect("truncated result should serialize");
// The truncated preview is embedded as a JSON string, so quotes and
// backslashes can be escaped again. That can roughly double the preview
// bytes in the worst case. The extra buffer covers the small result wrapper
// and marker.
assert!(serialized.len() < MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES * 2 + 1024);
assert_eq!(got.structured_content, None);
assert_eq!(got.meta, None);
assert_eq!(got.is_error, Some(false));
assert!(
got.content[0]
.get("text")
.and_then(serde_json::Value::as_str)
.is_some_and(|text| text.contains("truncated")),
"large event result should contain a truncation marker: {got:?}"
);
}
#[test]
fn truncate_mcp_tool_result_for_event_bounds_large_error() {
let got = truncate_mcp_tool_result_for_event(&Err("error-message-".repeat(200_000)))
.expect_err("large error should remain an error");
// `truncate_text` includes its own marker, so allow a small amount of
// overhead beyond the requested byte budget.
assert!(got.len() < MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES + 1024);
assert!(got.contains("truncated"));
}
#[tokio::test]
async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() {
let (_, turn_context) = make_session_and_context().await;
+9 -1
View File
@@ -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)
}
}
+51 -2
View File
@@ -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,
})
+2
View File
@@ -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 {