Skip to content

Commit acc6807

Browse files
committed
Recover from provider rejecting images instead of poisoning the chat
Providers like xAI reject requests carrying images below a minimum size (e.g. tiny images from MCP tool results), which failed every subsequent request in the chat. Classify these 400s as :invalid-image, retry once and keep omitting images at send time while the same model is selected.
1 parent 82ca0a3 commit acc6807

9 files changed

Lines changed: 496 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Recover when the provider rejects an image in the request (e.g. xAI's 512px minimum on MCP tool images): retry without images instead of failing every subsequent prompt.
6+
57
## 0.148.1
68

79
- Fix OpenRouter models failing every prompt with a context-window error: ignore catalog output limits >= context window and use limits from the provider's `/models` endpoint.

integration-test/entrypoint.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
integration.chat.hooks-test
2121
integration.chat.commands-test
2222
integration.chat.mcp-remote-test
23+
integration.chat.invalid-image-test
2324
integration.chat.background-jobs-test
2425
integration.chat.subagent-test
2526
integration.chat.list-test
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
(ns integration.chat.invalid-image-test
2+
"Covers recovery from providers rejecting image content: an MCP tool returns
3+
a tiny image, the (mocked) provider rejects any request carrying it with a
4+
400 (mimicking xAI's 512 total-pixels minimum), and ECA must retry without
5+
images instead of poisoning the chat forever."
6+
(:require
7+
[clojure.string :as string]
8+
[clojure.test :refer [deftest is testing]]
9+
[integration.eca :as eca]
10+
[integration.fixture :as fixture]
11+
[llm-mock.mocks :as llm.mocks]
12+
[matcher-combinators.core :as mc]
13+
[matcher-combinators.test :refer [match?]]
14+
[mcp-mock.server :as mcp-mock]))
15+
16+
(eca/clean-after-test)
17+
18+
(def ^:private init-options
19+
(merge fixture/default-init-options
20+
{:mcpServers {"test-mcp" {:url (str "http://localhost:" mcp-mock/port "/mcp")}}}))
21+
22+
(defn ^:private await-content
23+
"Consumes chat/contentReceived notifications until one matches role+content,
24+
failing the test after 40 non-matching notifications."
25+
[chat-id role content]
26+
(loop [remaining 40]
27+
(if (zero? remaining)
28+
(is false (str "No contentReceived matched role=" role " content=" content))
29+
(let [actual (eca/client-awaits-server-notification :chat/contentReceived)]
30+
(when-not (mc/indicates-match?
31+
(mc/match {:chatId chat-id :role role :content content} actual))
32+
(recur (dec remaining)))))))
33+
34+
(deftest invalid-image-recovery
35+
(eca/start-process!)
36+
(mcp-mock/reset-requests!)
37+
(eca/request! (fixture/initialize-request {:initializationOptions init-options}))
38+
(eca/notify! (fixture/initialized-notification))
39+
40+
;; Wait for MCP server to be ready: native, mcp starting, mcp running
41+
(eca/client-awaits-server-notification :tool/serverUpdated)
42+
(eca/client-awaits-server-notification :tool/serverUpdated)
43+
(eca/client-awaits-server-notification :tool/serverUpdated)
44+
45+
(testing "provider rejecting a tool-result image triggers a retry without images"
46+
(llm.mocks/set-case! :invalid-image-0)
47+
(let [resp (eca/request! (fixture/chat-prompt-request
48+
{:model "anthropic/claude-sonnet-4-6"
49+
:message "Call the tiny-image tool"}))
50+
chat-id (:chatId resp)]
51+
52+
(is (match? {:chatId string?
53+
:model "anthropic/claude-sonnet-4-6"
54+
:status "prompting"}
55+
resp))
56+
57+
;; MCP tool runs and returns text + image contents (the image is split
58+
;; into its own content notification).
59+
(await-content chat-id "assistant" {:type "toolCalled"
60+
:origin "mcp"
61+
:name "tiny-image"
62+
:error nil
63+
:outputs [{:type "text" :text "Evaluation result rendered as image:"}]})
64+
(await-content chat-id "assistant" {:type "image"
65+
:mediaType "image/png"
66+
:base64 mcp-mock/tiny-png-base64})
67+
68+
;; The continuation request carries the image -> mocked 400 -> recovery.
69+
(await-content chat-id "system" {:type "text"
70+
:text #(string/includes? % "rejected an image")})
71+
72+
;; Retry (without images) succeeds and the turn completes.
73+
(await-content chat-id "assistant" {:type "text" :text "Recovered without the image"})
74+
(await-content chat-id "system" {:type "progress" :state "finished"})
75+
76+
(testing "MCP mock received exactly one tools/call"
77+
(is (= 1 (count (mcp-mock/get-requests-by-method "tools/call")))))
78+
79+
(testing "retry request replaced the image with a placeholder"
80+
(let [retry-body (llm.mocks/get-req-body :invalid-image-0)
81+
as-str (pr-str retry-body)]
82+
(is (not (string/includes? as-str "\"image\""))
83+
"no image content may be replayed after the provider rejected it")
84+
(is (string/includes? as-str "[image removed: rejected by the LLM provider]")
85+
"the tool result keeps a placeholder where the image was"))))))

integration-test/llm_mock/anthropic.clj

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,64 @@
339339
(sse-send! ch "message_stop" {:type "message_stop"})
340340
(hk/close ch)))))
341341

342+
(def invalid-image-error-body
343+
"Anthropic-shaped 400 mimicking a provider rejecting an image (e.g. xAI's
344+
512 total-pixels minimum relayed through OpenRouter)."
345+
(json/generate-string
346+
{:type "error"
347+
:error {:type "invalid_request_error"
348+
:message "messages.2.content.1.image: Image has 100 total pixels (10x10), which is below the minimum of 512 pixels."}}))
349+
350+
(defn ^:private message-with-image?
351+
"True when any message content block (including nested tool_result content)
352+
is an image block."
353+
[messages]
354+
(some #(and (map? %) (= "image" (:type %)))
355+
(tree-seq coll? seq messages)))
356+
357+
(defn ^:private invalid-image-0 [ch body]
358+
(let [second-stage? (some (fn [{:keys [content]}]
359+
(some #(= "tool_result" (:type %)) content))
360+
(:messages body))]
361+
(if-not second-stage?
362+
(do
363+
(sse-send! ch "content_block_delta"
364+
{:type "content_block_delta"
365+
:index 0
366+
:delta {:type "text_delta" :text "Calling the image tool"}})
367+
(sse-send! ch "content_block_start"
368+
{:type "content_block_start"
369+
:index 1
370+
:content_block {:type "tool_use"
371+
:id "img-tool-1"
372+
:name "testMcp__tiny-image"}})
373+
(sse-send! ch "content_block_delta"
374+
{:type "content_block_delta"
375+
:index 1
376+
:delta {:type "input_json_delta"
377+
:partial_json "{}"}})
378+
(sse-send! ch "message_delta"
379+
{:type "message_delta"
380+
:delta {:stop_reason "tool_use"}
381+
:usage {:input_tokens 10
382+
:output_tokens 20}})
383+
(sse-send! ch "message_stop" {:type "message_stop"})
384+
(hk/close ch))
385+
;; Second stage only reached when the retry no longer carries the image
386+
;; (requests with images get a 400 before the SSE channel opens).
387+
(do
388+
(sse-send! ch "content_block_delta"
389+
{:type "content_block_delta"
390+
:index 0
391+
:delta {:type "text_delta" :text "Recovered without the image"}})
392+
(sse-send! ch "message_delta"
393+
{:type "message_delta"
394+
:delta {:stop_reason "end_turn"}
395+
:usage {:input_tokens 15
396+
:output_tokens 10}})
397+
(sse-send! ch "message_stop" {:type "message_stop"})
398+
(hk/close ch)))))
399+
342400
(defn ^:private compact-0 [ch]
343401
;; LLM calls eca__compact_chat with a summary — no text or reasoning
344402
(sse-send! ch "content_block_start"
@@ -376,9 +434,10 @@
376434
(let [body (some-> (slurp (:body req))
377435
(json/parse-string true))
378436
title-req? (string/includes? (:text (last (:system body))) llm.mocks/chat-title-generator-str)]
379-
(if (and (= :rate-limited-0 llm.mocks/*case*)
380-
(not title-req?)
381-
(zero? @rate-limited-count*))
437+
(cond
438+
(and (= :rate-limited-0 llm.mocks/*case*)
439+
(not title-req?)
440+
(zero? @rate-limited-count*))
382441
(do
383442
(swap! rate-limited-count* inc)
384443
(llm.mocks/set-req-body! llm.mocks/*case* body)
@@ -388,6 +447,18 @@
388447
:body (json/generate-string {:type "error"
389448
:error {:type "rate_limit_error"
390449
:message "Rate limit exceeded"}})})
450+
451+
;; Mimic a provider rejecting any request carrying an image.
452+
(and (= :invalid-image-0 llm.mocks/*case*)
453+
(not title-req?)
454+
(message-with-image? (:messages body)))
455+
(do
456+
(llm.mocks/set-req-body! llm.mocks/*case* body)
457+
{:status 400
458+
:headers {"Content-Type" "application/json"}
459+
:body invalid-image-error-body})
460+
461+
:else
391462
(hk/as-channel
392463
req
393464
{:on-open (fn [ch]
@@ -411,4 +482,5 @@
411482
:mcp-add-tool-0 (mcp-add-tool-0 ch body)
412483
:bg-shell-0 (bg-shell-0 ch body)
413484
:compact-0 (compact-0 ch)
485+
:invalid-image-0 (invalid-image-0 ch body)
414486
:rate-limited-0 (simple-text-0 ch)))))}))))

integration-test/mcp_mock/server.clj

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@
3939
:description "Dynamically registers a new tool on this server"
4040
:inputSchema {:type "object"
4141
:properties {:name {:type "string" :description "Name of the tool to add"}}
42-
:required ["name"]}}])
42+
:required ["name"]}}
43+
{:name "tiny-image"
44+
:description "Returns the evaluation result rendered as a tiny PNG image"
45+
:inputSchema {:type "object"
46+
:properties {}}}])
47+
48+
(def tiny-png-base64
49+
"10x10 8-bit RGBA PNG (100 total pixels), below xAI's minimum of 512."
50+
"iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC")
4351

4452
(def ^:private default-instructions
4553
"This is a test MCP server for integration testing.")
@@ -134,13 +142,18 @@
134142
{:content [{:type "text" :text (str "Unknown tool template: " tool-name)}]
135143
:isError true})))
136144

145+
(defn ^:private call-tiny-image [_arguments]
146+
{:content [{:type "text" :text "Evaluation result rendered as image:"}
147+
{:type "image" :data tiny-png-base64 :mimeType "image/png"}]})
148+
137149
(defn ^:private handle-tool-call [body]
138150
(let [tool-name (get-in body [:params :name])
139151
arguments (get-in body [:params :arguments])
140152
result (case tool-name
141153
"echo" (call-echo arguments)
142154
"add" (call-add arguments)
143155
"add-tool" (call-add-tool arguments)
156+
"tiny-image" (call-tiny-image arguments)
144157
{:content [{:type "text" :text (str "Unknown tool: " tool-name)}]
145158
:isError true})]
146159
{:status 200

src/eca/features/chat.clj

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,49 @@
187187
(swap! db* assoc-in [:chats chat-id :messages] pruned-messages))
188188
freed-tokens))
189189

190+
(def ^:private removed-image-placeholder
191+
{:type :text :text "[image removed: rejected by the LLM provider]"})
192+
193+
(defn ^:private strip-messages-images
194+
"Replaces :image content entries (user attachments and MCP tool result
195+
images) with a text placeholder in the given messages. Returns
196+
{:messages messages :stripped n} where n is the number of images replaced."
197+
[messages]
198+
(let [stripped* (volatile! 0)
199+
strip-contents (fn [contents]
200+
(if (sequential? contents)
201+
(mapv (fn [content]
202+
(if (and (map? content) (= :image (:type content)))
203+
(do (vswap! stripped* inc)
204+
removed-image-placeholder)
205+
content))
206+
contents)
207+
contents))
208+
messages (mapv (fn [{:keys [role] :as msg}]
209+
(case role
210+
"user" (update msg :content strip-contents)
211+
"tool_call_output" (if (sequential? (get-in msg [:content :output :contents]))
212+
(update-in msg [:content :output :contents] strip-contents)
213+
msg)
214+
msg))
215+
messages)]
216+
{:messages messages
217+
:stripped @stripped*}))
218+
219+
(defn ^:private messages-to-send
220+
"History slice sent to the LLM. When this chat's current model previously
221+
rejected an image (`:images-rejected-by-model`, e.g. below xAI's minimum
222+
pixels), image contents are stripped at send time so images (including new
223+
tool results) cannot poison every subsequent request. History itself is
224+
left untouched: clients keep displaying images and switching to an
225+
image-capable model replays them again."
226+
[db chat-id full-model]
227+
(let [messages (shared/messages-after-last-compact-marker
228+
(get-in db [:chats chat-id :messages] []))]
229+
(if (= full-model (get-in db [:chats chat-id :images-rejected-by-model]))
230+
(:messages (strip-messages-images messages))
231+
messages)))
232+
190233
(defn ^:private message-content->chat-content [role message-content content-id]
191234
(case role
192235
("user"
@@ -985,8 +1028,7 @@
9851028
:model-capabilities model-capabilities
9861029
:user-messages user-messages
9871030
:instructions instructions
988-
:past-messages (shared/messages-after-last-compact-marker
989-
(get-in @db* [:chats chat-id :messages] []))
1031+
:past-messages (messages-to-send @db* chat-id full-model)
9901032
:config config
9911033
:tools all-tools
9921034
:provider-auth provider-auth
@@ -1147,8 +1189,7 @@
11471189
(consume-steer-message! chat-id db* chat-ctx add-to-history!)
11481190
(consume-pending-job-notifications! chat-id db* add-to-history!)
11491191
{:tools tc-all-tools
1150-
:new-messages (shared/messages-after-last-compact-marker
1151-
(get-in @db* [:chats chat-id :messages]))})))))
1192+
:new-messages (messages-to-send @db* chat-id full-model)})))))
11521193
received-msgs* add-to-history! user-messages)
11531194
:on-reason (fn [{:keys [status id text external-id delta-reasoning? redacted? data]}]
11541195
(lifecycle/assert-chat-not-stopped! chat-ctx)
@@ -1328,6 +1369,50 @@
13281369
(prune-tool-results! db* chat-id {})
13291370
(trigger-auto-compact! chat-ctx all-tools user-messages))
13301371

1372+
;; Provider rejected an image in the request (e.g. an MCP tool
1373+
;; returned an image below xAI's 512 total-pixels minimum).
1374+
;; Such requests fail deterministically on every replay, which
1375+
;; would otherwise poison the chat forever. Replace images with
1376+
;; text placeholders and retry once.
1377+
(and (= :invalid-image error-type)
1378+
(not compacting?)
1379+
(not (:image-retried? chat-ctx)))
1380+
(let [;; When the failure happened before the first response,
1381+
;; user-messages were never added to history and must be
1382+
;; re-sent (image-stripped) on retry; otherwise just nudge
1383+
;; the model to continue from the sanitized history.
1384+
user-msgs-in-history? (boolean (when-let [user-content-id (:user-content-id chat-ctx)]
1385+
(some #(= user-content-id (:content-id %))
1386+
(get-in @db* [:chats chat-id :messages]))))
1387+
{stripped-user-messages :messages user-stripped :stripped} (strip-messages-images user-messages)
1388+
retry-messages (if user-msgs-in-history?
1389+
[{:role "user"
1390+
:content [{:type :text
1391+
:text "An image in the conversation was rejected by the LLM provider and was replaced with a placeholder. Continue the task with the remaining content, do not redo completed steps."}]}]
1392+
stripped-user-messages)]
1393+
(logger/warn logger-tag "Provider rejected an image, omitting images for this model and retrying"
1394+
{:chat-id chat-id
1395+
:full-model full-model
1396+
:user-messages-stripped user-stripped})
1397+
;; Makes messages-to-send strip images from every
1398+
;; subsequent request while this model is selected, so
1399+
;; new tool-result images cannot re-poison the chat.
1400+
(swap! db* assoc-in [:chats chat-id :images-rejected-by-model] full-model)
1401+
(lifecycle/send-content! chat-ctx :system
1402+
{:type :text
1403+
:text "The LLM provider rejected an image in the conversation. Retrying without images (omitted while this model is selected)..."})
1404+
(swap! db* assoc-in [:chats chat-id :auto-compacting?] true)
1405+
(lifecycle/finish-chat-prompt! :idle
1406+
(assoc chat-ctx
1407+
:on-finished-side-effect
1408+
(fn []
1409+
(swap! db* update-in [:chats chat-id] dissoc :auto-compacting?))
1410+
:on-after-finish!
1411+
(fn []
1412+
(prompt-messages! retry-messages
1413+
:invalid-image-retry
1414+
(assoc chat-ctx :image-retried? true))))))
1415+
13311416
:else
13321417
(let [partial-text @received-msgs*
13331418
transient-error? (contains? #{:overloaded :premature-stop} error-type)

0 commit comments

Comments
 (0)