mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(agent): keep parallel tool-result messages contiguous on OpenAI Chat (Databricks image fix) (#770)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
@@ -297,10 +297,28 @@ fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec<Value> {
|
|||||||
|
|
||||||
fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
|
fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
|
||||||
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": cfg.system_prompt })];
|
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": cfg.system_prompt })];
|
||||||
|
// Images returned from tool calls ride on a trailing `role:"user"`
|
||||||
|
// message because OpenAI Chat's `role:"tool"` content is text-only. We
|
||||||
|
// batch them across a run of adjacent ToolResult items so that all
|
||||||
|
// `role:"tool"` messages stay contiguous — splitting them with a user
|
||||||
|
// turn breaks OpenAI-Chat-compatible frontends that translate back to
|
||||||
|
// Anthropic `tool_result` (notably Databricks model serving), since
|
||||||
|
// Anthropic requires every `tool_use` in one assistant turn to be
|
||||||
|
// answered by a single immediately-following user message.
|
||||||
|
let mut pending_images: Vec<Value> = Vec::new();
|
||||||
|
let flush_images = |messages: &mut Vec<Value>, pending: &mut Vec<Value>| {
|
||||||
|
if !pending.is_empty() {
|
||||||
|
messages.push(json!({ "role": "user", "content": std::mem::take(pending) }));
|
||||||
|
}
|
||||||
|
};
|
||||||
for item in history {
|
for item in history {
|
||||||
match item {
|
match item {
|
||||||
HistoryItem::User(text) => messages.push(json!({ "role": "user", "content": text })),
|
HistoryItem::User(text) => {
|
||||||
|
flush_images(&mut messages, &mut pending_images);
|
||||||
|
messages.push(json!({ "role": "user", "content": text }));
|
||||||
|
}
|
||||||
HistoryItem::Assistant { text, tool_calls } => {
|
HistoryItem::Assistant { text, tool_calls } => {
|
||||||
|
flush_images(&mut messages, &mut pending_images);
|
||||||
let mut msg = serde_json::Map::new();
|
let mut msg = serde_json::Map::new();
|
||||||
msg.insert("role".into(), json!("assistant"));
|
msg.insert("role".into(), json!("assistant"));
|
||||||
msg.insert("content".into(), json!(text.as_str()));
|
msg.insert("content".into(), json!(text.as_str()));
|
||||||
@@ -323,13 +341,11 @@ fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Valu
|
|||||||
messages.push(json!({
|
messages.push(json!({
|
||||||
"role": "tool", "tool_call_id": r.provider_id,
|
"role": "tool", "tool_call_id": r.provider_id,
|
||||||
"content": openai_tool_text_content(&r.content) }));
|
"content": openai_tool_text_content(&r.content) }));
|
||||||
let image_content = openai_image_user_content(&r.content);
|
pending_images.extend(openai_image_user_content(&r.content));
|
||||||
if !image_content.is_empty() {
|
|
||||||
messages.push(json!({ "role": "user", "content": image_content }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flush_images(&mut messages, &mut pending_images);
|
||||||
let tools_json: Vec<Value> = tools
|
let tools_json: Vec<Value> = tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
@@ -1116,6 +1132,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression for Databricks model serving (and any OpenAI-Chat frontend
|
||||||
|
/// that translates to Anthropic on the way to the model). Parallel tool
|
||||||
|
/// calls where one or more return images previously produced an
|
||||||
|
/// interleaved sequence:
|
||||||
|
/// role:"tool" (A)
|
||||||
|
/// role:"user" (image A)
|
||||||
|
/// role:"tool" (B)
|
||||||
|
/// role:"user" (image B)
|
||||||
|
/// The intervening user message split the run of tool results, so the
|
||||||
|
/// translator could no longer fold them into a single Anthropic
|
||||||
|
/// `tool_result`-bearing user message — Anthropic then rejected the
|
||||||
|
/// request with "tool_use ids were found without tool_result blocks
|
||||||
|
/// immediately after". Fix: every `role:"tool"` for a run of adjacent
|
||||||
|
/// ToolResults emits contiguously, then a single trailing user message
|
||||||
|
/// carries all of the images from the batch.
|
||||||
|
#[test]
|
||||||
|
fn openai_parallel_image_tool_results_stay_contiguous() {
|
||||||
|
let history = vec![
|
||||||
|
HistoryItem::User("describe both images".into()),
|
||||||
|
HistoryItem::Assistant {
|
||||||
|
text: String::new(),
|
||||||
|
tool_calls: vec![
|
||||||
|
ToolCall {
|
||||||
|
provider_id: "toolu_a".into(),
|
||||||
|
name: "dev__view_image".into(),
|
||||||
|
arguments: serde_json::json!({"source": "a.png"}),
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
provider_id: "toolu_b".into(),
|
||||||
|
name: "dev__view_image".into(),
|
||||||
|
arguments: serde_json::json!({"source": "b.png"}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
HistoryItem::ToolResult(ToolResult {
|
||||||
|
provider_id: "toolu_a".into(),
|
||||||
|
content: vec![
|
||||||
|
ToolResultContent::Text("10×10, 70 B (image/png from a.png)".into()),
|
||||||
|
ToolResultContent::Image {
|
||||||
|
data: "aaa".into(),
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
is_error: false,
|
||||||
|
}),
|
||||||
|
HistoryItem::ToolResult(ToolResult {
|
||||||
|
provider_id: "toolu_b".into(),
|
||||||
|
content: vec![
|
||||||
|
ToolResultContent::Text("10×10, 70 B (image/png from b.png)".into()),
|
||||||
|
ToolResultContent::Image {
|
||||||
|
data: "bbb".into(),
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
is_error: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let body = openai_body(&cfg(Provider::OpenAi), &history, &[]);
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
// [0] system, [1] user, [2] assistant(tool_calls), [3] tool A, [4] tool B, [5] user(images)
|
||||||
|
assert_eq!(messages.len(), 6, "messages: {messages:#?}");
|
||||||
|
assert_eq!(messages[3]["role"], "tool");
|
||||||
|
assert_eq!(messages[3]["tool_call_id"], "toolu_a");
|
||||||
|
assert_eq!(
|
||||||
|
messages[4]["role"], "tool",
|
||||||
|
"tool results must stay adjacent; intervening user message breaks Databricks/Anthropic pairing"
|
||||||
|
);
|
||||||
|
assert_eq!(messages[4]["tool_call_id"], "toolu_b");
|
||||||
|
assert_eq!(messages[5]["role"], "user");
|
||||||
|
let imgs = messages[5]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(imgs.len(), 2);
|
||||||
|
assert_eq!(imgs[0]["image_url"]["url"], "data:image/png;base64,aaa");
|
||||||
|
assert_eq!(imgs[1]["image_url"]["url"], "data:image/png;base64,bbb");
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression: a connection that is accepted and then dropped before any
|
/// Regression: a connection that is accepted and then dropped before any
|
||||||
/// HTTP response bytes are written surfaces as a reqwest request-class
|
/// HTTP response bytes are written surfaces as a reqwest request-class
|
||||||
/// error (not `is_connect()`, not `is_timeout()`). The retry predicate
|
/// error (not `is_connect()`, not `is_timeout()`). The retry predicate
|
||||||
|
|||||||
Reference in New Issue
Block a user