From da67b214ebe4728e441447826385dfbda9c3a749 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:08:49 -0700 Subject: [PATCH] Port from block/buzz#5318: recover text-only endpoint 'not a multimodal model' rejections Text-only serving endpoints (Crusoe serverless, vLLM text-only checkpoints) reject any request whose history contains an image with HTTP 400 ' is not a multimodal model'. That phrasing was missing from _IMAGE_REJECTION_PHRASES, so the images stayed in history, every retry failed identically, and the session was permanently poisoned. block/buzz hit this exact failure live (block/buzz#5318: 8 wedged benchmark trials, 12.7h aggregate idle time) and widened their classifier; this ports the same phrase coverage to our strip-images-and-retry fallback. --- agent/conversation_loop.py | 11 ++++++++ .../test_image_rejection_fallback.py | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index aeb7bf10ca7b..8eb0a8de0084 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -4098,6 +4098,17 @@ def _perform_api_call(next_api_kwargs): # every subsequent message queued behind the stuck turn — # the P1 in issue #21160. The 404 passes the 4xx gate below. "no endpoints found that support image input", + # Text-only serving endpoints (Crusoe serverless, vLLM + # deployments of text-only checkpoints, various + # OpenAI-compatible hosts) reject any request whose + # history contains an image with HTTP 400 + # " is not a multimodal model". Without this + # phrase the image stays in history, every retry fails + # identically, and the session is poisoned until manual + # intervention. Observed live by block/buzz on + # crusoeai/GLM-5.2-NVFP4 (block/buzz#5318: 8 wedged + # trials, 12.7h aggregate idle-after-poison). + "not a multimodal model", ) _err_lower = _err_body.lower() _looks_like_image_rejection = any( diff --git a/tests/run_agent/test_image_rejection_fallback.py b/tests/run_agent/test_image_rejection_fallback.py index cd254ee6aabb..be5706b05b2c 100644 --- a/tests/run_agent/test_image_rejection_fallback.py +++ b/tests/run_agent/test_image_rejection_fallback.py @@ -139,6 +139,7 @@ class TestImageRejectionPhraseIsolation: "model does not support image", "image_url'. expected", "no endpoints found that support image input", + "not a multimodal model", ) def _matches(self, body: str) -> bool: @@ -158,6 +159,33 @@ def test_anthropic_image_too_large_does_not_trip(self): for body in bodies: assert self._matches(body) is False, f"false positive on: {body}" + def test_text_only_endpoint_multimodal_rejection_trips(self): + """Crusoe/vLLM text-only endpoints reject imaged history with + " is not a multimodal model" (HTTP 400). This must route + to the strip-images fallback or the session poisons permanently — + observed live by block/buzz on crusoeai/GLM-5.2-NVFP4 + (block/buzz#5318: 8 wedged benchmark trials). + """ + bodies = [ + "400: crusoeai/GLM-5.2-NVFP4 is not a multimodal model", + "Error: my-org/text-model-v2 is not a multimodal model.", + ] + for body in bodies: + assert self._matches(body) is True, f"missed rejection: {body}" + + def test_multimodal_tool_content_shapes_do_not_trip(self): + # From agent/error_classifier.py _MULTIMODAL_TOOL_CONTENT_PATTERNS — + # these mean "tool message content must be a string", recovered by + # downgrading tool content to text, NOT by stripping all images + # session-wide. + bodies = [ + "tool message content must be a string", + "expected string, got list", + "text is not set", + ] + for body in bodies: + assert self._matches(body) is False, f"false positive on: {body}" +