Skip to content

Commit 3acd292

Browse files
ddupont808teknium1
authored andcommitted
fix(computer-use): unwrap _multimodal tool results to content list for non-Anthropic providers
Tool handlers (e.g. computer_use capture) return a _multimodal envelope dict when a screenshot is attached. The tool-message builder was passing this raw dict as the `content` field of role:tool messages, which is an illegal format — OpenAI-compatible APIs expect a string or a content-parts list, not a plain Python dict, and would reject it with a 400/422 error. Fix: unwrap _multimodal results to their `content` list ([{type:text,...},{type:image_url,...}]) in both the parallel and sequential tool-call paths. The Anthropic adapter already handles content lists natively; vision-capable OpenAI-compatible servers (mlx-vlm, GPT-4o, etc.) accept image_url parts in tool messages directly. Also add a _vision_supported adaptive fallback: on first image-rejection error ("Only 'text' content type is supported." etc.) the agent strips all image parts from the message history and retries with text only, so text-only endpoints degrade gracefully without crashing the session.
1 parent aa90415 commit 3acd292

1 file changed

Lines changed: 99 additions & 3 deletions

File tree

run_agent.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
809844
def _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

Comments
 (0)