Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions crates/buzz-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@ use crate::wire::{self, WireSender};
const ERROR_REFLECTION_SUFFIX: &str =
"\n\n[Reflect] Before retrying, identify the cause and change your approach.";

const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support image input. The image was removed from conversation history so this turn can continue. Use a text-based inspection tool or ask the user for a textual description instead.";

/// Remove image blocks that the provider has explicitly rejected while keeping
/// their surrounding tool result (and therefore the tool-call/result pairing)
/// intact. Returns the number of images removed; zero means the provider error
/// cannot be safely recovered by mutating history.
fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize {
let mut replaced = 0;
for item in history {
let HistoryItem::ToolResult(result) = item else {
continue;
};
let before = result.content.len();
result
.content
.retain(|content| !matches!(content, ToolResultContent::Image { .. }));
let removed = before - result.content.len();
if removed > 0 {
replaced += removed;
result.is_error = true;
result.content.push(ToolResultContent::Text(
UNSUPPORTED_IMAGE_TOOL_MESSAGE.to_string(),
));
}
}
replaced
}

/// Maximum reply reminders emitted per prompt when `require_reply` is on.
///
/// After this many, the turn is allowed to end whether or not anything was
Expand Down Expand Up @@ -249,10 +277,10 @@ impl RunCtx<'_> {
tools.push(builtin::load_skill_def());
}
round = round.saturating_add(1);
let response = tokio::select! {
let response_result = tokio::select! {
biased;
_ = self.cancel.changed() => return Ok(StopReason::Cancelled),
r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r?,
r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r,
_ = async {
// Keepalive ticker: emit a lightweight session update every 30s
// while waiting on the LLM provider. This resets the ACP harness
Expand All @@ -275,6 +303,22 @@ impl RunCtx<'_> {
}
} => unreachable!(),
};
let response = match response_result {
Ok(response) => response,
Err(AgentError::UnsupportedImageInput(detail)) => {
let removed = replace_unsupported_images(self.history);
if removed == 0 {
return Err(AgentError::UnsupportedImageInput(detail));
}
tracing::warn!(
model = self.effective_model,
removed_images = removed,
"provider rejected image input; removed images from history and continuing turn"
);
continue;
}
Err(error) => return Err(error),
};

// Record provider-reported input usage so the next loop iteration's
// handoff gate can compare it against the token budget. We capture
Expand Down Expand Up @@ -1075,6 +1119,47 @@ mod tests {
assert!(total_after <= max_bytes);
}

#[test]
fn unsupported_images_become_recoverable_tool_errors() {
let mut history = vec![
HistoryItem::Assistant {
text: String::new(),
tool_calls: vec![ToolCall {
provider_id: "call-image".into(),
name: "dev__view_image".into(),
arguments: json!({ "source": "spec.png" }),
provider_extra: Default::default(),
}],
reasoning_details: None,
},
HistoryItem::ToolResult(ToolResult {
provider_id: "call-image".into(),
content: vec![
ToolResultContent::Text("10x10 image from spec.png".into()),
ToolResultContent::Image {
data: "aW1n".into(),
mime_type: "image/png".into(),
},
],
is_error: false,
}),
];

assert_eq!(replace_unsupported_images(&mut history), 1);
let HistoryItem::ToolResult(result) = &history[1] else {
panic!("tool result must stay paired with the assistant tool call");
};
assert_eq!(result.provider_id, "call-image");
assert!(result.is_error);
assert!(result
.content
.iter()
.all(|content| !matches!(content, ToolResultContent::Image { .. })));
assert!(result.text().contains("does not support image input"));
assert!(result.text().contains("10x10 image from spec.png"));
assert_eq!(replace_unsupported_images(&mut history), 0);
}

#[test]
fn truncate_history_noop_when_under_budget() {
let mut history = vec![
Expand Down
45 changes: 43 additions & 2 deletions crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,11 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || e.is_request()
}

fn is_unsupported_image_input_error(body: &str) -> bool {
body.to_ascii_lowercase()
.contains("no endpoints found that support image input")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems too fragile?
Maybe break it into a few different errors
contains("not support image") // for open ai models
|| contains("no endpoints found that support image input") // for deepseek models
|| contains("image inputs are not supported") // for claude models

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ollama outputs: "not support multi-modal inputs"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct — the matcher is deliberately narrow, but we probed the suggestion before taking it, and the phrase list turns out not to be the binding constraint: the status gate is. is_unsupported_image_input_error only runs inside the status == 404 arms (both here and in openrouter_post). We measured all four cases against the real post path:

phrase status result
OpenAI-style "not support image" 400 AgentError::Llm — never reaches the matcher
Claude-style "image inputs are not supported" 400 AgentError::Llm — never reaches the matcher
Claude-style phrase 404 LlmModelNotFound — matcher ran, phrase absent
DeepSeek/OpenRouter phrase 404 UnsupportedImageInput — works today

So adding the OpenAI/Claude phrases as written would close the comment without changing behavior for either provider — their rejections arrive as 400s and are swallowed upstream of the matcher. Real coverage means hoisting the check above the status dispatch, which is a structurally different patch.

Two more reasons we're holding to the narrow matcher in this PR:

  1. We can't verify the other phrases. The DeepSeek phrase is in this PR because a live trial produced it verbatim; nobody in this effort has a captured 400 body from OpenAI or Anthropic rejecting an image. Matching a guessed phrase fails silently the moment the real wording differs.
  2. A false positive here is not benign. contains("not support image") is broad, and misclassification strips images out of history on a turn where images were fine. The neighboring test openrouter_post_404_unknown_model_stays_model_not_found exists precisely because 404-classification mistakes send users to the wrong fix.

Filed as a follow-up: broaden coverage (hoist above the status dispatch + per-provider phrases) once we have real captured rejection bodies to match against. The PR body now states the scope guarantee explicitly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on the measurement above — you were right about Ollama, and I have the captured body now.

Ollama is reachable through our openai-compat path with no credential, so I pulled a text-only model and drove the real buzz-agent binary against it at this PR's head. The rejection is real, close to your guess but not identical:

HTTP 400
{"error":{"message":"{\"error\":{\"code\":400,\"message\":\"Multimodal data provided, but model does not support multimodal requests.\",\"type\":\"invalid_request_error\"}}","type":"invalid_request_error","param":null,"code":null}}

You guessed "not support multi-modal inputs"; the actual wording is does not support multimodal requests — no hyphen, "requests" not "inputs". That gap is exactly why I wanted a captured body rather than a guessed phrase: a matcher built on the guess would have silently missed this. Also worth flagging for whoever implements it — the body is doubly encoded, so the real sentence sits in a JSON string nested inside error.message.

At this PR's head the failure is unchanged for Ollama — three consecutive turns die on the same 400, history stays poisoned. I also ran a proxy that rewrote only that 400 into the OpenRouter 404 + phrase: recovery fires and all three turns end end_turn. Same binary, same rig, sole variable is the status and phrase. So the recovery machinery here is right; it just isn't reached.

Two more measurements that constrain the fix, and they argue against the simple version:

  • Ollama returns 404 for model not found (text-only request, no image).
  • Ollama returns 404 for model not found even when the request does carry an image.

So we can't just add the phrase to the existing 404 arm or loosen that arm — on this provider 404 already means something else. Real coverage needs the check hoisted above the status dispatch, plus a test pinning that Ollama's model-not-found 404 keeps LlmModelNotFound.

Full evidence, controls, and a reproduction recipe are on #4899. OpenAI and Anthropic bodies are still uncaptured, so I'd keep those phrases out until someone has them verbatim.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: your instinct on the phrase list was better than my first reply gave it credit for. We stood up a credential-free live rig against Ollama and captured a real image rejection — and one of your guessed phrases is essentially real:

HTTP 400
{"error":{"message":"{\"error\":{\"code\":400,\"message\":\"Multimodal data provided, but model does not support multimodal requests.\",\"type\":\"invalid_request_error\"}}", ...}}

Two things the capture pins down:

  1. It arrives as a 400, which confirms the structural point from my earlier reply — adding phrases to the current 404-gated matcher would not have caught it. The check has to hoist above the status dispatch. And Ollama's own 404 (model 'x' not found) means model-not-found even when the request carries an image, so the 404 classifications must stay as-is.
  2. The body is doubly-encoded (the sentence is a JSON string nested inside error.message), which any structured matcher needs to know about.

The live rig also confirmed the recovery machinery in this PR works end-to-end against a real provider (a proxy rewriting that 400 to the OpenRouter-shaped 404 produces clean end_turn recovery on the same binary).

Sequencing: this PR lands as-is (correct for OpenRouter/DeepSeek, live-verified recovery), and the hoist + captured Ollama phrase + ordering tests come as a stacked PR tracked in #4899. OpenAI/Anthropic phrases stay out until someone captures real bodies from them.

}

/// Build the terminal `AgentError::Llm` for a `post()` exit that has given up
/// retrying — persistent retryable status, transport failure, or a body-read
/// break. `detail` carries the specific cause (status/body, or the transport
Expand Down Expand Up @@ -1864,9 +1869,14 @@ where
// upstream capacity — no retry was attempted, so cumulative duration
// would be misleading.
if status == 404 {
let error_body = read_error_body(resp).await;
if is_unsupported_image_input_error(&error_body) {
return Err(PostError::Agent(AgentError::UnsupportedImageInput(
error_body,
)));
}
return Err(PostError::Agent(AgentError::LlmModelNotFound(format!(
"{status}: {}",
read_error_body(resp).await
"{status}: {error_body}"
))));
}
if !status.is_success() {
Expand Down Expand Up @@ -2117,6 +2127,9 @@ async fn openrouter_post(
// about the model, and reporting a parameter problem as
// `LlmModelNotFound` (or vice versa) sends the user to the wrong fix.
let error_body = read_error_body(resp).await;
if is_unsupported_image_input_error(&error_body) {
return Err(AgentError::UnsupportedImageInput(error_body));
}
if error_body.contains("No endpoints found that can handle the requested parameters") {
return Err(openrouter_parameter_routing_error(&error_body));
}
Expand Down Expand Up @@ -6217,6 +6230,34 @@ mod tests {
);
}

/// A provider's explicit image-capability rejection is a recoverable typed
/// error, not a missing model. The agent loop uses this signal to remove the
/// image from history before retrying the next LLM round.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn openrouter_post_404_unsupported_image_is_typed_and_not_retried() {
let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
404,
r#"{"error":{"message":"No endpoints found that support image input"}}"#,
)])
.await;
let http = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
.await
.unwrap_err();
assert!(
matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")),
"image rejection must reach the history-recovery path: got {err:?}"
);
assert_eq!(
attempts.load(std::sync::atomic::Ordering::SeqCst),
1,
"a deterministic capability rejection must not be retried"
);
}

/// Every other 404 still maps to `LlmModelNotFound`, including one that
/// shares the `No endpoints found` prefix but is about the model rather than
/// the parameters — the discriminator is narrow enough that a genuinely
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-agent/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ pub enum AgentError {
Llm(String),
LlmAuth(String),
LlmModelNotFound(String),
/// The provider explicitly rejected image content for the selected model.
/// Kept distinct so the agent loop can remove the unsupported image from
/// replayed history and give the model a recoverable tool error.
UnsupportedImageInput(String),
Mcp(String),
Cancelled,
}
Expand All @@ -399,6 +403,7 @@ impl std::fmt::Display for AgentError {
Self::Llm(s) => write!(f, "llm: {s}"),
Self::LlmAuth(s) => write!(f, "llm auth: {s}"),
Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"),
Self::UnsupportedImageInput(s) => write!(f, "llm image input unsupported: {s}"),
Self::Mcp(s) => write!(f, "mcp: {s}"),
Self::Cancelled => write!(f, "cancelled"),
}
Expand Down
11 changes: 10 additions & 1 deletion crates/buzz-agent/tests/bin/fake_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! (use a large value, e.g. 999, to simulate hang)
//! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result
//! (default: the literal "ok"); grows history
//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block
//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup
//! (for tests that want to verify the child died)
//! FAKE_MCP_SPAWN_GRANDCHILD=1
Expand Down Expand Up @@ -300,10 +301,18 @@ fn main() {
} else {
"ok".to_owned()
};
let content = if env_flag("FAKE_MCP_IMAGE_RESULT") {
json!([
{ "type": "text", "text": result_text },
{ "type": "image", "data": "aW1n", "mimeType": "image/png" },
])
} else {
json!([{ "type": "text", "text": result_text }])
};
write_response(
id,
json!({
"content": [{ "type": "text", "text": result_text }],
"content": content,
"isError": false,
}),
);
Expand Down
Loading