|
187 | 187 | (swap! db* assoc-in [:chats chat-id :messages] pruned-messages)) |
188 | 188 | freed-tokens)) |
189 | 189 |
|
| 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 | + |
190 | 233 | (defn ^:private message-content->chat-content [role message-content content-id] |
191 | 234 | (case role |
192 | 235 | ("user" |
|
985 | 1028 | :model-capabilities model-capabilities |
986 | 1029 | :user-messages user-messages |
987 | 1030 | :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) |
990 | 1032 | :config config |
991 | 1033 | :tools all-tools |
992 | 1034 | :provider-auth provider-auth |
|
1147 | 1189 | (consume-steer-message! chat-id db* chat-ctx add-to-history!) |
1148 | 1190 | (consume-pending-job-notifications! chat-id db* add-to-history!) |
1149 | 1191 | {: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)}))))) |
1152 | 1193 | received-msgs* add-to-history! user-messages) |
1153 | 1194 | :on-reason (fn [{:keys [status id text external-id delta-reasoning? redacted? data]}] |
1154 | 1195 | (lifecycle/assert-chat-not-stopped! chat-ctx) |
|
1328 | 1369 | (prune-tool-results! db* chat-id {}) |
1329 | 1370 | (trigger-auto-compact! chat-ctx all-tools user-messages)) |
1330 | 1371 |
|
| 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 | + |
1331 | 1416 | :else |
1332 | 1417 | (let [partial-text @received-msgs* |
1333 | 1418 | transient-error? (contains? #{:overloaded :premature-stop} error-type) |
|
0 commit comments