Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,11 @@ def _release_lock() -> None:
return compressed, new_system_prompt


def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
def try_shrink_image_parts_in_messages(
api_messages: list,
*,
max_dimension: int = 8000,
) -> bool:
"""Re-encode all native image parts at a smaller size to recover from
image-too-large errors (Anthropic 5 MB, unknown other providers).

Expand All @@ -642,7 +646,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
``data:image/...;base64,...`` payload. For each one whose encoded
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
ceiling with header overhead), write the base64 to a tempfile, call
ceiling with header overhead) or whose longest side exceeds
``max_dimension``, write the base64 to a tempfile, call
``vision_tools._resize_image_for_vision`` to produce a smaller data
URL, and substitute it in place.

Expand All @@ -664,10 +669,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
# Anthropic enforces an 8000px per-side dimension cap independently of
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
# even when the byte budget is fine.
max_dimension = 8000
# the 5 MB byte cap. In many-image requests, the provider can report a
# lower cap (observed: 2000px). The caller passes that parsed ceiling
# when the rejection includes it.
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
Expand All @@ -684,9 +688,9 @@ def _shrink_data_url(url: str) -> Optional[str]:
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
if not needs_shrink:
# Even if bytes are fine, check pixel dimensions against
# Anthropic's 8000px cap. A tall image can be tiny in bytes
# yet huge in pixels.
# Even if bytes are fine, check pixel dimensions against the
# provider's reported per-side cap. A screenshot can be tiny in
# bytes yet too large in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
Expand Down
35 changes: 34 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,35 @@
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("


def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
parts = []
for value in (
error,
getattr(error, "message", None),
getattr(error, "body", None),
):
if value:
try:
parts.append(str(value))
except Exception:
pass
text = " ".join(parts).lower()
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
return None

match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
if not match:
return None
try:
max_dimension = int(match.group(1))
except ValueError:
return None
if 512 <= max_dimension <= 8000:
return max_dimension
return None


def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
if not getattr(agent, "tools", None):
Expand Down Expand Up @@ -2070,7 +2099,11 @@ def _perform_api_call(next_api_kwargs):
and not _retry.image_shrink_retry_attempted
):
_retry.image_shrink_retry_attempted = True
if agent._try_shrink_image_parts_in_messages(api_messages):
image_max_dimension = _image_error_max_dimension(api_error) or 8000
if agent._try_shrink_image_parts_in_messages(
api_messages,
max_dimension=image_max_dimension,
):
agent._vprint(
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
f"shrank and retrying...",
Expand Down
12 changes: 10 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4553,10 +4553,18 @@ def _tool_result_content_for_active_model(self, tool_name: str, result: Any) ->
)
return summary

def _try_shrink_image_parts_in_messages(self, api_messages: list) -> bool:
def _try_shrink_image_parts_in_messages(
self,
api_messages: list,
*,
max_dimension: int = 8000,
) -> bool:
"""Forwarder — see ``agent.conversation_compression.try_shrink_image_parts_in_messages``."""
from agent.conversation_compression import try_shrink_image_parts_in_messages
return try_shrink_image_parts_in_messages(api_messages)
return try_shrink_image_parts_in_messages(
api_messages,
max_dimension=max_dimension,
)

def _try_strip_image_parts_from_tool_messages(self, api_messages: list) -> bool:
"""Downgrade list-type tool messages to text summaries in-place.
Expand Down
71 changes: 71 additions & 0 deletions tests/run_agent/test_image_shrink_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
from __future__ import annotations

import base64
import sys
from types import SimpleNamespace


from agent.conversation_loop import _image_error_max_dimension
from agent.error_classifier import FailoverReason, classify_api_error


Expand Down Expand Up @@ -79,6 +82,21 @@ def test_regular_context_overflow_unaffected(self):
result = classify_api_error(err, provider="anthropic", model="claude-sonnet-4-6")
assert result.reason == FailoverReason.context_overflow

def test_anthropic_many_image_dimension_limit(self):
"""OpenRouter-wrapped Anthropic many-image limits recover via shrink."""
err = _FakeApiError(
status_code=400,
message=(
"messages.21.content.43.image.source.base64.data: At least one "
"of the image dimensions exceed max allowed size for many-image "
"requests: 2000 pixels"
),
)
result = classify_api_error(err, provider="openrouter", model="anthropic/claude-opus-4.8")
assert result.reason == FailoverReason.image_too_large
assert result.retryable is True
assert _image_error_max_dimension(err) == 2000


# ─── Shrink helper ───────────────────────────────────────────────────────────

Expand All @@ -90,6 +108,27 @@ def _big_png_data_url(size_kb: int) -> str:
return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")


def _install_fake_pillow(monkeypatch, size: tuple[int, int]) -> None:
"""Install the tiny subset of Pillow used by the shrink preflight."""
class _FakeImage:
def __init__(self):
self.size = size

def __enter__(self):
return self

def __exit__(self, *exc):
return False

class _FakeImageModule:
@staticmethod
def open(_data):
return _FakeImage()

monkeypatch.setitem(sys.modules, "PIL", SimpleNamespace(Image=_FakeImageModule))
monkeypatch.setitem(sys.modules, "PIL.Image", _FakeImageModule)


def _make_agent():
"""Build a bare AIAgent for method-level testing, no provider setup."""
from run_agent import AIAgent
Expand Down Expand Up @@ -163,6 +202,38 @@ def _fake_resize(path, mime_type=None, max_base64_bytes=None, max_dimension=None
assert changed is True
assert msgs[0]["content"][1]["image_url"]["url"] == shrunk

def test_many_image_dimension_limit_rewritten(self, monkeypatch):
"""A 2000px many-image rejection must shrink images below 8000px."""
agent = _make_agent()
_install_fake_pillow(monkeypatch, (2501, 100))
oversized_for_many = _big_png_data_url(100)
shrunk = "data:image/jpeg;base64," + "M" * 1000
seen = {}

def _fake_resize(path, mime_type=None, max_base64_bytes=None, max_dimension=None):
seen["max_dimension"] = max_dimension
return shrunk

monkeypatch.setattr(
"tools.vision_tools._resize_image_for_vision",
_fake_resize,
raising=False,
)

msgs = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": oversized_for_many}},
],
}]
changed = agent._try_shrink_image_parts_in_messages(
msgs,
max_dimension=2000,
)
assert changed is True
assert seen["max_dimension"] == 2000
assert msgs[0]["content"][0]["image_url"]["url"] == shrunk

def test_oversized_input_image_string_shape_rewritten(self, monkeypatch):
"""OpenAI Responses shape: {type: input_image, image_url: "data:..."}."""
agent = _make_agent()
Expand Down
Loading