fix: keep tool results consecutive when OpenAI-compatible tool images are emitted (#12233)
Some checks are pending
Canary / Prepare Version (push) Waiting to run
Canary / build-cli-linux (push) Blocked by required conditions
Canary / Upload Install Script (push) Waiting to run
Canary / bundle-macos-arm64 (push) Blocked by required conditions
Canary / bundle-macos-x64 (push) Blocked by required conditions
Canary / bundle-windows (push) Blocked by required conditions
Canary / bundle-windows-cuda (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / goose ACP npm packages (push) Blocked by required conditions
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Lean ACP Binary (push) Blocked by required conditions
CI / Check and Test GDK Agent Crates on wasm32 (push) Blocked by required conditions
CI / Check goose-sdk UniFFI (push) Blocked by required conditions
CI / Test Kotlin GDK package (push) Blocked by required conditions
CI / Build and Test TLS Backend (native-tls) (push) Blocked by required conditions
CI / Build and Test TLS Backend (rustls-tls) (push) Blocked by required conditions
CI / Build and Test Roaming Feature (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Check MSRV (push) Blocked by required conditions
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Check GDK Release Configuration (push) Waiting to run
CI / Check GDK API Reference is Up-to-Date (push) Waiting to run
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
CI / Test Desktop Updater Install (ubuntu-latest) (push) Blocked by required conditions
CI / Test Desktop Updater Install (windows-latest) (push) Blocked by required conditions
Prepare GDK release / Create or update GDK release PR (push) Waiting to run
MCP Conformance / Build Conformance Binaries (push) Waiting to run
MCP Conformance / Conformance 2025-11-25 / 0.1.16 (push) Blocked by required conditions
MCP Conformance / Conformance 2025-11-25 / 0.2.0-alpha.10 (push) Blocked by required conditions
MCP Conformance / Conformance 2026-07-28 / 0.2.0-alpha.10 (push) Blocked by required conditions
Create Minor Release PR / release (push) Blocked by required conditions
Create Minor Release PR / check-version-bump-pr (push) Waiting to run
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

This commit is contained in:
Jeremy Schoemaker 2026-09-29 18:09:51 +00:00 • committed by GitHub
parent c82e7517f3
commit 6d5eba9b14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -253,6 +253,10 @@ pub fn format_messages_with_options(
});
let mut output = Vec::new();
// Deferred to the end of the message so every tool result in a batch stays
// consecutive; a strict OpenAI-compatible API rejects a request where a
// synthetic user image message splits one assistant tool_calls batch.
let mut pending_image_messages = Vec::new();
let mut content_array = Vec::new();
let mut has_non_text_content = false;
let mut reasoning_text = String::new();
@ -355,7 +359,6 @@ pub fn format_messages_with_options(
Ok(result) => {
// Process all content, replacing images with placeholder text
let mut tool_content = Vec::new();
let mut image_messages = Vec::new();
for content in result.content.iter() {
match content {
@ -365,7 +368,7 @@ pub fn format_messages_with_options(
tool_content.push(ContentBlock::text("This tool result included an image that is uploaded in the next message."));
// Create a separate image message
image_messages.push(json!({
pending_image_messages.push(json!({
"role": "user",
"content": [convert_image(&image.clone(), image_format)]
}));
@ -392,14 +395,11 @@ pub fn format_messages_with_options(
.collect::<Vec<String>>()
.join(" "));
// First add the tool response with all content
output.push(json!({
"role": "tool",
"content": tool_response_content,
"tool_call_id": response.id
}));
// Then add any image messages that need to follow
output.extend(image_messages);
}
Err(e) => {
// A tool result error is shown as output so the model can interpret the error message
@ -450,6 +450,8 @@ pub fn format_messages_with_options(
}
}
output.append(&mut pending_image_messages);
if !content_array.is_empty() {
if has_non_text_content {
converted["content"] = json!(content_array);
@ -2655,6 +2657,99 @@ mod tests {
Ok(())
}
#[test]
fn test_parallel_tool_responses_with_images_are_consecutive() {
// #11893: a synthetic user image message between the tool results of one
// tool_calls batch makes strict OpenAI-compatible APIs reject the request.
let messages = vec![
Message::assistant()
.with_tool_request("call_a", Ok(CallToolRequestParams::new("read_image")))
.with_tool_request("call_b", Ok(CallToolRequestParams::new("read_image"))),
Message::user()
.with_tool_response(
"call_a",
Ok(CallToolResult::success(vec![ContentBlock::image(
"aW1hZ2VkYXRhYQ==",
"image/png",
)])),
)
.with_tool_response(
"call_b",
Ok(CallToolResult::success(vec![ContentBlock::image(
"aW1hZ2VkYXRhYg==",
"image/png",
)])),
),
];
let spec = format_messages_with_options(
&messages,
&ImageFormat::OpenAi,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: true,
..Default::default()
},
);
let roles: Vec<&str> = spec.iter().map(|m| m["role"].as_str().unwrap()).collect();
assert_eq!(roles, vec!["assistant", "tool", "tool", "user", "user"]);
assert_eq!(spec[1]["tool_call_id"], "call_a");
assert_eq!(spec[2]["tool_call_id"], "call_b");
}
#[test]
fn test_mixed_tool_responses_image_and_text_ordering() {
// A text-only result between two image results must not let the image
// messages split the batch either.
let messages = vec![
Message::assistant()
.with_tool_request("call_a", Ok(CallToolRequestParams::new("read_image")))
.with_tool_request("call_b", Ok(CallToolRequestParams::new("shell")))
.with_tool_request("call_c", Ok(CallToolRequestParams::new("read_image"))),
Message::user()
.with_tool_response(
"call_a",
Ok(CallToolResult::success(vec![ContentBlock::image(
"aW1hZ2VkYXRhYQ==",
"image/png",
)])),
)
.with_tool_response(
"call_b",
Ok(CallToolResult::success(vec![ContentBlock::text(
"text result",
)])),
)
.with_tool_response(
"call_c",
Ok(CallToolResult::success(vec![ContentBlock::image(
"aW1hZ2VkYXRhYw==",
"image/png",
)])),
),
];
let spec = format_messages_with_options(
&messages,
&ImageFormat::OpenAi,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: true,
..Default::default()
},
);
let roles: Vec<&str> = spec.iter().map(|m| m["role"].as_str().unwrap()).collect();
assert_eq!(
roles,
vec!["assistant", "tool", "tool", "tool", "user", "user"]
);
assert_eq!(spec[1]["tool_call_id"], "call_a");
assert_eq!(spec[2]["tool_call_id"], "call_b");
assert_eq!(spec[3]["tool_call_id"], "call_c");
}
#[test]
fn test_format_messages_with_text_and_image_preserves_order() {
// Text before image: order should be [text, image]