@@ -806,6 +806,41 @@ def _sanitize_tools_non_ascii(tools: list) -> bool:
806806 return _sanitize_structure_non_ascii(tools)
807807
808808
809+ def _strip_images_from_messages(messages: list) -> bool:
810+ """Remove image_url content parts from all messages in-place.
811+
812+ Called when a server signals it does not support images (e.g.
813+ "Only 'text' content type is supported."). Mutates messages so the
814+ next API call sends text only.
815+
816+ Returns True if any image parts were removed.
817+ """
818+ found = False
819+ to_delete = []
820+ for i, msg in enumerate(messages):
821+ if not isinstance(msg, dict):
822+ continue
823+ content = msg.get("content")
824+ if not isinstance(content, list):
825+ continue
826+ new_parts = []
827+ for part in content:
828+ if isinstance(part, dict) and part.get("type") in ("image_url", "image", "input_image"):
829+ found = True
830+ else:
831+ new_parts.append(part)
832+ if len(new_parts) < len(content):
833+ if new_parts:
834+ msg["content"] = new_parts
835+ else:
836+ # Entire message was images — drop it (user messages added for
837+ # image delivery only, e.g. the deferred injection messages).
838+ to_delete.append(i)
839+ for i in reversed(to_delete):
840+ del messages[i]
841+ return found
842+
843+
809844def _sanitize_structure_non_ascii(payload: Any) -> bool:
810845 """Strip non-ASCII characters from nested dict/list payloads in-place."""
811846 found = False
@@ -9161,9 +9196,22 @@ def _run_tool(index, tool_call, function_name, function_args):
91619196 else:
91629197 function_result += subdir_hints
91639198
9199+ # Unwrap _multimodal dicts to an OpenAI-style content list so any
9200+ # vision-capable provider receives [{type:text},{type:image_url}]
9201+ # rather than a raw Python dict. The Anthropic adapter already
9202+ # accepts content lists; vision-capable OpenAI-compatible servers
9203+ # (mlx-vlm, GPT-4o, …) accept image_url in tool messages natively.
9204+ # Text-only servers that reject images are handled by the adaptive
9205+ # _vision_supported recovery in the API retry loop.
9206+ # String results pass through unchanged.
9207+ _tool_content = (
9208+ function_result["content"]
9209+ if _is_multimodal_tool_result(function_result)
9210+ else function_result
9211+ )
91649212 tool_msg = {
91659213 "role": "tool",
9166- "content": function_result ,
9214+ "content": _tool_content ,
91679215 "tool_call_id": tc.id,
91689216 }
91699217 messages.append(tool_msg)
@@ -9535,9 +9583,16 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
95359583 else:
95369584 function_result += subdir_hints
95379585
9586+ # Unwrap _multimodal dicts to an OpenAI-style content list
9587+ # (see parallel path for rationale). String results pass through.
9588+ _tool_content = (
9589+ function_result["content"]
9590+ if _is_multimodal_tool_result(function_result)
9591+ else function_result
9592+ )
95389593 tool_msg = {
95399594 "role": "tool",
9540- "content": function_result ,
9595+ "content": _tool_content ,
95419596 "tool_call_id": tool_call.id
95429597 }
95439598 messages.append(tool_msg)
@@ -9585,7 +9640,6 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
95859640 self._apply_pending_steer_to_tool_results(messages, num_tools_seq)
95869641
95879642
9588-
95899643 def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
95909644 """Request a summary when max iterations are reached. Returns the final response text."""
95919645 print(f"⚠️ Reached maximum iterations ({self.max_iterations}). Requesting summary...")
@@ -9825,6 +9879,11 @@ def run_conversation(
98259879 self._last_content_tools_all_housekeeping = False
98269880 self._mute_post_response = False
98279881 self._unicode_sanitization_passes = 0
9882+ # True until the server rejects an image_url content part with an error
9883+ # like "Only 'text' content type is supported." Set to False on first
9884+ # rejection and kept False for the rest of the session so we never re-send
9885+ # images to a text-only endpoint. Scoped per `_run()` call, not per instance.
9886+ self._vision_supported = True
98289887
98299888 # Pre-turn connection health check: detect and clean up dead TCP
98309889 # connections left over from provider outages or dropped streams.
@@ -11305,6 +11364,43 @@ def _stop_spinner():
1130511364 )
1130611365 continue
1130711366
11367+ # ── Image-rejection recovery ──────────────────────────────
11368+ # Some providers (mlx-lm, text-only endpoints) reject any
11369+ # message that contains image_url content with an error like
11370+ # "Only 'text' content type is supported." On first hit,
11371+ # strip all images from the message list, mark the session
11372+ # as vision-unsupported, and retry with text only.
11373+ _err_body = ""
11374+ try:
11375+ _err_body = str(getattr(api_error, "body", None) or
11376+ getattr(api_error, "message", None) or
11377+ str(api_error))
11378+ except Exception:
11379+ pass
11380+ _IMAGE_REJECTION_PHRASES = (
11381+ "only 'text' content type is supported",
11382+ "only text content type is supported",
11383+ "image_url is not supported",
11384+ "multimodal is not supported",
11385+ "vision is not supported",
11386+ "does not support images",
11387+ )
11388+ if (
11389+ getattr(self, "_vision_supported", True)
11390+ and any(p in _err_body.lower() for p in _IMAGE_REJECTION_PHRASES)
11391+ ):
11392+ self._vision_supported = False
11393+ _imgs_removed = _strip_images_from_messages(messages)
11394+ if isinstance(api_messages, list):
11395+ _strip_images_from_messages(api_messages)
11396+ self._vprint(
11397+ f"{self.log_prefix}⚠️ Server rejected image content — "
11398+ f"switching to text-only mode for this session"
11399+ + (". Stripped images from history and retrying." if _imgs_removed else "."),
11400+ force=True,
11401+ )
11402+ continue
11403+
1130811404 status_code = getattr(api_error, "status_code", None)
1130911405 error_context = self._extract_api_error_context(api_error)
1131011406
0 commit comments