mirror of
https://github.com/openai/codex.git
synced 2026-11-11 22:33:20 +00:00
Preserve MCP tool output as content items (#40737)
## What changed - Convert unstructured MCP results into typed function-call output items instead of serializing the entire content array as a text string. - Keep structured MCP results as serialized text and preserve media, encrypted content, and unknown content through their existing item conversions. - Drop empty text items during output truncation so they do not consume API array slots. ## Testing - Cover text-only, mixed unstructured, structured, and image-sanitized MCP results. - Verify empty text items are discarded under byte- and token-based truncation policies. GitOrigin-RevId: fa1c5b7dee6f003a094265379dcabdd060386a48
This commit is contained in:
1 parent
62aacbb2c9
commit
75cb7c903d
12 files changed
+288
-104
No files matched your search
@@ -2230,13 +2230,12 @@ impl CallToolResult {
|
||||
|
||||
pub fn as_function_call_output_payload(&self) -> FunctionCallOutputPayload {
|
||||
let content_items = convert_mcp_content_to_items(&self.content);
|
||||
if content_items.as_ref().is_some_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| matches!(item, FunctionCallOutputContentItem::EncryptedContent { .. }))
|
||||
}) {
|
||||
if content_items
|
||||
.iter()
|
||||
.any(|item| matches!(item, FunctionCallOutputContentItem::EncryptedContent { .. }))
|
||||
{
|
||||
return FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(content_items.unwrap_or_default()),
|
||||
body: FunctionCallOutputBody::ContentItems(content_items),
|
||||
success: Some(self.success()),
|
||||
};
|
||||
}
|
||||
@@ -2260,23 +2259,8 @@ impl CallToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
let serialized_content = match serde_json::to_string(&self.content) {
|
||||
Ok(serialized_content) => serialized_content,
|
||||
Err(err) => {
|
||||
return FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(err.to_string()),
|
||||
success: Some(false),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let body = match content_items {
|
||||
Some(content_items) => FunctionCallOutputBody::ContentItems(content_items),
|
||||
None => FunctionCallOutputBody::Text(serialized_content),
|
||||
};
|
||||
|
||||
FunctionCallOutputPayload {
|
||||
body,
|
||||
body: FunctionCallOutputBody::ContentItems(content_items),
|
||||
success: Some(self.success()),
|
||||
}
|
||||
}
|
||||
@@ -2288,7 +2272,7 @@ impl CallToolResult {
|
||||
|
||||
fn convert_mcp_content_to_items(
|
||||
contents: &[serde_json::Value],
|
||||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||||
) -> Vec<FunctionCallOutputContentItem> {
|
||||
const CODEX_ENCRYPTED_CONTENT_META_KEY: &str = "codex/encryptedContent";
|
||||
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
|
||||
|
||||
@@ -2321,7 +2305,6 @@ fn convert_mcp_content_to_items(
|
||||
Unknown,
|
||||
}
|
||||
|
||||
let mut saw_content_item = false;
|
||||
let mut items = Vec::with_capacity(contents.len());
|
||||
|
||||
for content in contents {
|
||||
@@ -2333,7 +2316,6 @@ fn convert_mcp_content_to_items(
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(true)
|
||||
{
|
||||
saw_content_item = true;
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: text,
|
||||
}
|
||||
@@ -2346,7 +2328,6 @@ fn convert_mcp_content_to_items(
|
||||
mime_type,
|
||||
meta,
|
||||
}) => {
|
||||
saw_content_item = true;
|
||||
let image_url = if data.starts_with("data:") {
|
||||
data
|
||||
} else {
|
||||
@@ -2373,7 +2354,6 @@ fn convert_mcp_content_to_items(
|
||||
Ok(McpContent::Audio {
|
||||
data, mime_type, ..
|
||||
}) => {
|
||||
saw_content_item = true;
|
||||
let audio_url = if data.starts_with("data:") {
|
||||
data
|
||||
} else {
|
||||
@@ -2389,7 +2369,7 @@ fn convert_mcp_content_to_items(
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
if saw_content_item { Some(items) } else { None }
|
||||
items
|
||||
}
|
||||
|
||||
// Implement Display so callers can treat the payload like a plain string when logging or doing
|
||||
@@ -2679,7 +2659,7 @@ mod tests {
|
||||
"mimeType": "image/png",
|
||||
})];
|
||||
|
||||
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
|
||||
let items = convert_mcp_content_to_items(&contents);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
@@ -3011,7 +2991,7 @@ mod tests {
|
||||
"mimeType": "image/png",
|
||||
})];
|
||||
|
||||
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
|
||||
let items = convert_mcp_content_to_items(&contents);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![FunctionCallOutputContentItem::InputImage {
|
||||
@@ -3039,25 +3019,30 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
convert_mcp_content_to_items(&contents),
|
||||
Some(vec![
|
||||
vec![
|
||||
FunctionCallOutputContentItem::InputAudio {
|
||||
audio_url: "data:audio/wav;base64,Zm9v".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputAudio {
|
||||
audio_url: "data:audio/ogg;base64,YmFy".to_string(),
|
||||
},
|
||||
])
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_mcp_content_to_items_returns_none_without_media() {
|
||||
fn convert_mcp_content_to_items_converts_text_without_media() {
|
||||
let contents = vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
})];
|
||||
|
||||
assert_eq!(convert_mcp_content_to_items(&contents), None);
|
||||
assert_eq!(
|
||||
convert_mcp_content_to_items(&contents),
|
||||
vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "hello".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3302,6 +3287,69 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_unstructured_mcp_content_to_items() {
|
||||
let content = vec![
|
||||
serde_json::json!({"type":"text","text":"caption"}),
|
||||
serde_json::json!({
|
||||
"type": "resource_link",
|
||||
"uri": "file:///notes.txt",
|
||||
"name": "notes",
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "audio",
|
||||
"mimeType": "audio/wav",
|
||||
}),
|
||||
];
|
||||
let call_tool_result = CallToolResult {
|
||||
content: content.clone(),
|
||||
structured_content: Some(serde_json::Value::Null),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let resource_link = serde_json::to_string(&content[1]).expect("serialize resource link");
|
||||
let malformed_audio =
|
||||
serde_json::to_string(&content[2]).expect("serialize malformed audio");
|
||||
assert_eq!(
|
||||
call_tool_result.as_function_call_output_payload(),
|
||||
FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "caption".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: resource_link,
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: malformed_audio,
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_structured_mcp_content() {
|
||||
let call_tool_result = CallToolResult {
|
||||
content: vec![serde_json::json!({"type":"text","text":"ignored"})],
|
||||
structured_content: Some(serde_json::json!({"result":"structured"})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
call_tool_result.as_function_call_output_payload(),
|
||||
FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(
|
||||
serde_json::json!({"result":"structured"}).to_string(),
|
||||
),
|
||||
success: Some(true),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_image_outputs_as_array() -> Result<()> {
|
||||
let call_tool_result = CallToolResult {
|
||||
|
||||
Reference in new issue
Block a user