mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Allow deferring plain-text MCP output in thread item listings (#60390)
## What changed Add experimental `deferOutput.mcpToolCall` support to `thread/items/list` requests scoped to a `turnId`. Successful, completed MCP calls with nonempty, unannotated plain-text results can return empty `result.content` with `outputDeferred: true`. Clients can retrieve the saved output through `thread/items/read`; connector identity remains on the listed item. Keep rich results, JSON or fenced text, app UI results, and built-in `codex_app` results inline. Preserve plain-text output when creating bounded previews of oversized MCP results so those previews remain eligible for deferral. Reads return the saved preview when output exceeds the 64 KiB history limit. ## Testing Extend thread history integration coverage for independent and combined command/MCP deferral, experimental capability gating, excluded result types, connector identity, oversized previews, and retrieval of deferred items. GitOrigin-RevId: 5c160b30a4eac2a9b70e3fa65b6dbd83e935338b
This commit is contained in:
BIN
Binary file not shown.
@@ -1879,6 +1879,10 @@ pub struct ThreadItemOutputDeferral {
|
||||
#[experimental("thread/items/list.deferOutput.commandExecution")]
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub command_execution: bool,
|
||||
/// Defer nonempty plain-text results from successful MCP tool calls.
|
||||
#[experimental("thread/items/list.deferOutput.mcpToolCall")]
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub mcp_tool_call: bool,
|
||||
}
|
||||
|
||||
/// Starting position for an item-history page.
|
||||
@@ -1909,7 +1913,7 @@ pub struct ThreadItemEntry {
|
||||
/// Turn containing this item.
|
||||
pub turn_id: String,
|
||||
pub item: ThreadItem,
|
||||
/// The saved command output was omitted. Fetch the complete item with `thread/items/read`.
|
||||
/// The saved output was omitted. Fetch the complete item with `thread/items/read`.
|
||||
#[experimental("thread/items/list.outputDeferred")]
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub output_deferred: bool,
|
||||
|
||||
@@ -755,13 +755,23 @@ Omitted or null cursors preserve normal first-page behavior. Continue anchored
|
||||
pages with the returned opaque string `nextCursor`; response fields and
|
||||
`backwardsCursor` semantics are unchanged.
|
||||
|
||||
Experimental clients can set `"deferOutput": {"commandExecution": true}`
|
||||
with a `turnId`. Omitted or false options keep the full output. Unknown fields in
|
||||
`deferOutput` are ignored.
|
||||
For a completed command with a recorded completion time, exit code zero, and
|
||||
nonempty saved output, the entry returns `outputDeferred: true` and
|
||||
`item.aggregatedOutput: null`. Use `thread/items/read` with that turn and item ID
|
||||
to retrieve the full output. All other entries retain their recorded output.
|
||||
Experimental clients can request `"deferOutput": {"commandExecution": true, "mcpToolCall": true}`
|
||||
with a `turnId`. Each option applies independently to items with a recorded
|
||||
completion time and defaults to false. The server ignores unknown options.
|
||||
|
||||
- `commandExecution` returns `item.aggregatedOutput: null` for a successful command with
|
||||
exit code zero and nonempty saved output.
|
||||
- `mcpToolCall` returns `item.result.content: []` for a successful tool call with
|
||||
nonempty output made up entirely of plain, unannotated text blocks. Calls with
|
||||
an app resource URI or UI, result metadata, structured content, non-text blocks, or
|
||||
text beginning with a JSON object, array, or Markdown fence retain the full result.
|
||||
Connector identity stays on the item when its output is deferred.
|
||||
The built-in `codex_app` server also keeps its results inline because Desktop
|
||||
renders those calls as native tools, including terminal reads.
|
||||
|
||||
Both forms set `outputDeferred: true`. Use `thread/items/read` with the turn
|
||||
and item ID to retrieve the saved output. For MCP results over the 64 KiB
|
||||
history limit, this returns the bounded preview saved at completion.
|
||||
|
||||
## Workspace switching (experimental)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::thread_sample::generate_sample;
|
||||
use codex_app_server_protocol::SelectedCapabilityRoot;
|
||||
use codex_app_server_protocol::ThreadHistoryMode as ApiThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadItemOutputDeferral;
|
||||
use codex_app_server_protocol::ThreadItemPayload;
|
||||
use codex_app_server_protocol::ThreadItemsListAnchor;
|
||||
use codex_app_server_protocol::ThreadItemsListCursor;
|
||||
@@ -46,6 +47,7 @@ use codex_protocol::SanitizedGitUrl;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::mcp::plain_output_text;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_thread_store::LocalHistoryAccount;
|
||||
use codex_thread_store::PersistContext;
|
||||
@@ -3733,8 +3735,7 @@ impl ThreadRequestProcessor {
|
||||
sort_direction,
|
||||
defer_output,
|
||||
} = params;
|
||||
let defer_command_output =
|
||||
turn_id.is_some() && defer_output.is_some_and(|options| options.command_execution);
|
||||
let defer_output = defer_output.filter(|_| turn_id.is_some());
|
||||
let position = cursor.map(|cursor| match cursor {
|
||||
ThreadItemsListCursor::Opaque(cursor) => StoreListItemsPosition::Cursor(cursor),
|
||||
ThreadItemsListCursor::Anchor(ThreadItemsListAnchor::Item { item_id }) => {
|
||||
@@ -3787,20 +3788,10 @@ impl ThreadRequestProcessor {
|
||||
let mut data = Vec::with_capacity(page.items.len());
|
||||
for stored_item in page.items {
|
||||
let mut entry = stored_thread_item_to_entry(stored_item)?;
|
||||
if defer_command_output
|
||||
if let Some(options) = &defer_output
|
||||
&& entry.completed_at_ms.is_some()
|
||||
&& let ThreadItemPayload::CommandExecution {
|
||||
status: codex_app_server_protocol::CommandExecutionStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
aggregated_output,
|
||||
..
|
||||
} = &mut entry.item.payload
|
||||
&& aggregated_output
|
||||
.as_ref()
|
||||
.is_some_and(|output| !output.is_empty())
|
||||
{
|
||||
*aggregated_output = None;
|
||||
entry.output_deferred = true;
|
||||
entry.output_deferred = defer_item_output(&mut entry.item.payload, options);
|
||||
}
|
||||
data.push(entry);
|
||||
}
|
||||
@@ -6492,6 +6483,57 @@ fn stored_thread_item_to_api_item(
|
||||
})
|
||||
}
|
||||
|
||||
fn defer_item_output(item: &mut ThreadItemPayload, options: &ThreadItemOutputDeferral) -> bool {
|
||||
match item {
|
||||
ThreadItemPayload::CommandExecution {
|
||||
status: codex_app_server_protocol::CommandExecutionStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
aggregated_output,
|
||||
..
|
||||
} if options.command_execution
|
||||
&& aggregated_output
|
||||
.as_ref()
|
||||
.is_some_and(|output| !output.is_empty()) =>
|
||||
{
|
||||
*aggregated_output = None;
|
||||
}
|
||||
ThreadItemPayload::McpToolCall {
|
||||
server,
|
||||
status: codex_app_server_protocol::McpToolCallStatus::Completed,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
app_context,
|
||||
mcp_app_resource_uri: None,
|
||||
mcp_app_ui: None,
|
||||
..
|
||||
} if options.mcp_tool_call
|
||||
&& server != "codex_app"
|
||||
&& app_context
|
||||
.as_ref()
|
||||
.is_none_or(|ctx| ctx.resource_uri.is_none())
|
||||
&& is_deferrable_mcp_result(result) =>
|
||||
{
|
||||
result.content.clear();
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_deferrable_mcp_result(result: &codex_app_server_protocol::McpToolCallResult) -> bool {
|
||||
if result.structured_content.is_some() || result.meta.is_some() {
|
||||
return false;
|
||||
}
|
||||
let mut has_text = false;
|
||||
for block in &result.content {
|
||||
let Some(text) = plain_output_text(block) else {
|
||||
return false;
|
||||
};
|
||||
has_text |= !text.is_empty();
|
||||
}
|
||||
has_text
|
||||
}
|
||||
|
||||
fn stored_thread_item_to_entry(
|
||||
stored_item: codex_thread_store::StoredThreadItem,
|
||||
) -> Result<ThreadItemEntry, JSONRPCErrorError> {
|
||||
|
||||
@@ -1835,13 +1835,15 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_items_list_defers_successful_command_output() -> Result<()> {
|
||||
async fn thread_items_list_defers_requested_outputs() -> Result<()> {
|
||||
use codex_protocol::items::CommandExecutionStatus::Completed;
|
||||
use codex_protocol::items::CommandExecutionStatus::Declined;
|
||||
use codex_protocol::items::CommandExecutionStatus::Failed;
|
||||
use codex_protocol::items::CommandExecutionStatus::InProgress;
|
||||
|
||||
async fn list(server: &mut TestAppServer, params: Value) -> Result<Value> {
|
||||
async fn list(server: &mut TestAppServer, mut params: Value) -> Result<Value> {
|
||||
// Compare all fixture items on one page.
|
||||
params["limit"] = json!(100);
|
||||
let id = server
|
||||
.send_request("thread/items/list", Some(params))
|
||||
.await?;
|
||||
@@ -1891,6 +1893,9 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> {
|
||||
}),
|
||||
));
|
||||
}
|
||||
let mcp_items = mcp_output_deferral_items(thread_id)?;
|
||||
let expected_count = commands.len() + mcp_items.len();
|
||||
items.extend(mcp_items);
|
||||
store
|
||||
.append_items(AppendThreadItemsParams { thread_id, items })
|
||||
.await?;
|
||||
@@ -1914,7 +1919,28 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> {
|
||||
let mut params = json!({"threadId": thread_id, "turnId": "turn"});
|
||||
let full = list(&mut stable, params.clone()).await?;
|
||||
let entries = full["data"].as_array().expect("list entries");
|
||||
assert_eq!(entries.len(), commands.len());
|
||||
assert_eq!(entries.len(), expected_count);
|
||||
let mcp_index = commands.len();
|
||||
assert_eq!(entries[mcp_index]["item"]["id"], "large-mcp");
|
||||
let saved = &entries[mcp_index]["item"]["result"];
|
||||
let preview = saved["content"][0]["text"].as_str().expect("saved preview");
|
||||
assert!(preview.contains("truncated"));
|
||||
assert!(preview.contains("end of plain output"));
|
||||
assert!(preview.len() < 64 * 1024);
|
||||
let connector = entries
|
||||
.iter()
|
||||
.find(|entry| entry["item"]["id"] == "connector-mcp")
|
||||
.expect("hosted connector");
|
||||
assert_eq!(
|
||||
connector["item"]["appContext"]["connectorId"],
|
||||
"fixture-connector"
|
||||
);
|
||||
let empty = entries
|
||||
.iter()
|
||||
.find(|entry| entry["item"]["id"] == "empty-result")
|
||||
.expect("empty MCP result");
|
||||
assert_eq!(empty["item"]["result"]["content"], json!([]));
|
||||
assert!(empty.get("outputDeferred").is_none());
|
||||
for (entry, (id, _, _, output)) in entries.iter().zip(commands) {
|
||||
assert_eq!(entry["item"]["id"], id);
|
||||
assert_eq!(
|
||||
@@ -1927,23 +1953,25 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> {
|
||||
for options in [
|
||||
Value::Null,
|
||||
json!({}),
|
||||
json!({"commandExecution": false}),
|
||||
json!({"commandExecution": false, "mcpToolCall": false}),
|
||||
json!({"unknownItemKind": true}),
|
||||
] {
|
||||
params["deferOutput"] = options;
|
||||
assert_eq!(list(&mut stable, params.clone()).await?, full);
|
||||
}
|
||||
|
||||
params["deferOutput"] = json!({"commandExecution": true, "unknownItemKind": true});
|
||||
let id = stable
|
||||
.send_request("thread/items/list", Some(params.clone()))
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
stable.read_stream_until_error_message(RequestId::Integer(id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
for option in ["commandExecution", "mcpToolCall"] {
|
||||
params["deferOutput"] = json!({(option): true});
|
||||
let id = stable
|
||||
.send_request("thread/items/list", Some(params.clone()))
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
stable.read_stream_until_error_message(RequestId::Integer(id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
}
|
||||
|
||||
let mut experimental = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
@@ -1958,43 +1986,166 @@ async fn thread_items_list_defers_successful_command_output() -> Result<()> {
|
||||
.await?,
|
||||
full
|
||||
);
|
||||
let deferred = list(&mut experimental, params).await?;
|
||||
let mut expected = full.clone();
|
||||
for i in 0..2 {
|
||||
expected["data"][i]["outputDeferred"] = json!(true);
|
||||
expected["data"][i]["item"]["aggregatedOutput"] = Value::Null;
|
||||
}
|
||||
assert_eq!(deferred, expected);
|
||||
for (commands, mcp) in [(true, false), (false, true), (true, true)] {
|
||||
params["deferOutput"] =
|
||||
json!({"commandExecution": commands, "mcpToolCall": mcp, "unknownItemKind": true});
|
||||
let mut expected = full.clone();
|
||||
let mut originals = Vec::new();
|
||||
if commands {
|
||||
for (i, original) in entries.iter().take(2).enumerate() {
|
||||
originals.push(original);
|
||||
expected["data"][i]["outputDeferred"] = json!(true);
|
||||
expected["data"][i]["item"]["aggregatedOutput"] = Value::Null;
|
||||
}
|
||||
}
|
||||
if mcp {
|
||||
for (entry, original) in expected["data"]
|
||||
.as_array_mut()
|
||||
.expect("list entries")
|
||||
.iter_mut()
|
||||
.zip(entries)
|
||||
{
|
||||
if matches!(
|
||||
entry["item"]["id"].as_str(),
|
||||
Some("large-mcp" | "connector-mcp" | "small-mcp")
|
||||
) {
|
||||
entry["outputDeferred"] = json!(true);
|
||||
entry["item"]["result"]["content"] = json!([]);
|
||||
originals.push(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
let response = list(&mut experimental, params.clone()).await?;
|
||||
assert_eq!(response, expected);
|
||||
|
||||
let projected: ThreadItemsListResponse = serde_json::from_value(deferred)?;
|
||||
let mut entries_to_fetch = projected
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|entry| ThreadItem::try_from(entry).err());
|
||||
let large_command = entries_to_fetch.next().expect("large command needs a read");
|
||||
let small_command = entries_to_fetch.next().expect("small command needs a read");
|
||||
assert!(entries_to_fetch.next().is_none());
|
||||
let page: ThreadItemsListResponse = serde_json::from_value(response)?;
|
||||
let deferred: Vec<_> = page
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|entry| ThreadItem::try_from(entry).err())
|
||||
.collect();
|
||||
let exact: Value = experimental
|
||||
.request(|request_id| ClientRequest::ThreadItemsRead {
|
||||
request_id,
|
||||
params: ThreadItemsReadParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: deferred
|
||||
.first()
|
||||
.expect("each opt-in defers an item")
|
||||
.turn_id
|
||||
.clone(),
|
||||
item_ids: deferred.into_iter().map(|item| item.item_id).collect(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(exact, json!({"data": originals}));
|
||||
}
|
||||
|
||||
let across_turns = list(
|
||||
&mut experimental,
|
||||
json!({"threadId": thread_id, "deferOutput": {"commandExecution": true}}),
|
||||
json!({"threadId": thread_id, "deferOutput": {"commandExecution": true, "mcpToolCall": true}}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(across_turns["data"], full["data"]);
|
||||
let exact: Value = experimental
|
||||
.request(|request_id| ClientRequest::ThreadItemsRead {
|
||||
request_id,
|
||||
params: ThreadItemsReadParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: small_command.turn_id,
|
||||
item_ids: vec![large_command.item_id, small_command.item_id],
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(exact, json!({"data": [entries[0], entries[1]]}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mcp_output_deferral_items(thread_id: codex_protocol::ThreadId) -> Result<Vec<RolloutItem>> {
|
||||
let text = |text: &str| json!({"type": "text", "text": text});
|
||||
let content = |blocks: Vec<Value>| json!({"result": {"content": blocks}});
|
||||
let oversized = "é".repeat(40 * 1024);
|
||||
let large = "x".repeat(4096);
|
||||
let plain = || text(&large);
|
||||
let notion = json!({"results": [{"object": "page", "url": "https://www.notion.so/page", "title": large}]}).to_string();
|
||||
let cases = [
|
||||
(
|
||||
"large-mcp",
|
||||
content(vec![text(&oversized), text("end of plain output")]),
|
||||
),
|
||||
(
|
||||
"connector-mcp",
|
||||
json!({"server": "codex_apps", "connectorId": "fixture-connector", "linkId": "link-1"}),
|
||||
),
|
||||
(
|
||||
"builtin-terminal",
|
||||
json!({"server": "codex_app", "tool": "read_thread_terminal"}),
|
||||
),
|
||||
("small-mcp", content(vec![text("x"), text("")])),
|
||||
("empty-text", content(vec![text("")])),
|
||||
("empty-result", content(vec![])),
|
||||
(
|
||||
"failed-mcp",
|
||||
json!({"status": "failed", "error": {"message": "tool failed"}}),
|
||||
),
|
||||
("running-mcp", json!({"status": "inProgress"})),
|
||||
("missing-result", json!({"result": null})),
|
||||
(
|
||||
"annotated",
|
||||
content(vec![
|
||||
plain(),
|
||||
json!({"type": "text", "text": "caption", "annotations": {"audience": ["user"]}}),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"image-result",
|
||||
content(vec![
|
||||
plain(),
|
||||
json!({"type": "image", "mimeType": "image/png", "data": "YQ=="}),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"structured-result",
|
||||
json!({"result": {"content": [plain()], "structuredContent": {"title": "Source"}}}),
|
||||
),
|
||||
(
|
||||
"metadata-result",
|
||||
json!({"result": {"content": [plain()], "_meta": {"uri": "ui://result"}}}),
|
||||
),
|
||||
(
|
||||
"app-resource",
|
||||
json!({"server": "codex_apps", "connectorId": "fixture-connector", "mcpAppResourceUri": "ui://panel"}),
|
||||
),
|
||||
("legacy-app-ui", json!({"mcpAppResourceUri": "ui://panel"})),
|
||||
(
|
||||
"app-ui",
|
||||
json!({"mcpAppUi": {"resourceUri": "ui://panel", "preferredModelDisplayMode": "inline"}}),
|
||||
),
|
||||
(
|
||||
"json-result",
|
||||
content(vec![text(&format!("\u{feff} {notion}"))]),
|
||||
),
|
||||
(
|
||||
"mixed-json",
|
||||
content(vec![text(&oversized), text(" \n []")]),
|
||||
),
|
||||
(
|
||||
"fenced-result",
|
||||
content(vec![plain(), text(" \n ```json\n{}\n```")]),
|
||||
),
|
||||
(
|
||||
"tilde-fence",
|
||||
content(vec![plain(), text("~~~json\n{}\n~~~")]),
|
||||
),
|
||||
];
|
||||
let mut items = Vec::new();
|
||||
for (id, overrides) in cases {
|
||||
let mut call = json!({
|
||||
"id": id, "server": "fixture", "tool": "lookup", "status": "completed",
|
||||
"arguments": {"query": "saved"}, "pluginId": "fixture@local", "readOnlyHint": true,
|
||||
"result": {"content": [plain()]},
|
||||
});
|
||||
for (field, value) in overrides.as_object().expect("fixture overrides") {
|
||||
call[field] = value.clone();
|
||||
}
|
||||
items.push(paginated_completed_item(
|
||||
thread_id,
|
||||
"turn",
|
||||
CoreTurnItem::McpToolCall(serde_json::from_value(call)?),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
@@ -361,6 +361,21 @@ pub struct CallToolResult {
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Returns display text from an unannotated MCP text block. Some tools encode
|
||||
/// sources and UI data as JSON or fenced text, which history must keep inline.
|
||||
pub fn plain_output_text(block: &serde_json::Value) -> Option<&str> {
|
||||
let fields = block.as_object()?;
|
||||
if fields.len() != 2 || fields.get("type")?.as_str()? != "text" {
|
||||
return None;
|
||||
}
|
||||
let text = fields.get("text")?.as_str()?;
|
||||
let trimmed = text.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}');
|
||||
if trimmed.starts_with(['{', '[']) || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
|
||||
return None;
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
// === Adapter helpers ===
|
||||
//
|
||||
// These types and conversions intentionally live in `codex-protocol` so other crates can convert
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::mcp::plain_output_text;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
@@ -72,7 +73,7 @@ pub fn truncate_mcp_tool_result(
|
||||
result: &CallToolResult,
|
||||
max_bytes: usize,
|
||||
) -> Cow<'_, CallToolResult> {
|
||||
let Ok(serialized) = serde_json::to_string(result) else {
|
||||
let Ok(mut serialized) = serde_json::to_string(result) else {
|
||||
return Cow::Borrowed(result);
|
||||
};
|
||||
if serialized.len() <= max_bytes {
|
||||
@@ -81,6 +82,26 @@ pub fn truncate_mcp_tool_result(
|
||||
|
||||
let meta = None;
|
||||
|
||||
// Keep plain output recognizable after truncation. Rich results retain their
|
||||
// serialized preview so history can distinguish them from plain output.
|
||||
if result.is_error != Some(true)
|
||||
&& result.structured_content.is_none()
|
||||
&& result.meta.is_none()
|
||||
&& let Some(blocks) = result
|
||||
.content
|
||||
.iter()
|
||||
.map(plain_output_text)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
&& blocks.iter().any(|text| !text.is_empty())
|
||||
{
|
||||
serialized.clear();
|
||||
serialized.push_str("Truncated tool output:");
|
||||
for text in blocks {
|
||||
serialized.push('\n');
|
||||
serialized.push_str(text);
|
||||
}
|
||||
}
|
||||
|
||||
let mut preview_budget = max_bytes;
|
||||
loop {
|
||||
let preview = if preview_budget == 0 {
|
||||
|
||||
Reference in New Issue
Block a user