diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index d2a0727fb..8ce2b9a7a 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -419,6 +419,26 @@ class LLMProvider(ABC): result.append(msg) return result if found else None + @staticmethod + def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool: + """Replace image_url blocks with text placeholder *in-place*. + + Mutates the content lists of the original message dicts so that + callers holding references to those dicts also see the stripped + version. + """ + found = False + for msg in messages: + content = msg.get("content") + if isinstance(content, list): + for i, b in enumerate(content): + if isinstance(b, dict) and b.get("type") == "image_url": + path = (b.get("_meta") or {}).get("path", "") + placeholder = image_placeholder_text(path, empty="[image omitted]") + content[i] = {"type": "text", "text": placeholder} + found = True + return found + async def _safe_chat(self, **kwargs: Any) -> LLMResponse: """Call chat() and convert unexpected exceptions to error responses.""" try: @@ -670,7 +690,12 @@ class LLMProvider(ABC): ) retry_kw = dict(kw) retry_kw["messages"] = stripped - return await call(**retry_kw) + result = await call(**retry_kw) + # Permanently strip images from the original messages so + # subsequent iterations do not repeat the error-retry cycle. + if result.finish_reason != "error": + self._strip_image_content_inplace(original_messages) + return result return response if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT: diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index 78c2a791e..c64e2a0f8 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -1,4 +1,5 @@ import asyncio +import copy import pytest @@ -152,7 +153,7 @@ async def test_non_transient_error_with_images_retries_without_images() -> None: LLMResponse(content="ok, no image"), ]) - response = await provider.chat_with_retry(messages=_IMAGE_MSG) + response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG)) assert response.content == "ok, no image" assert provider.calls == 2 @@ -187,7 +188,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None: LLMResponse(content="still failing", finish_reason="error"), ]) - response = await provider.chat_with_retry(messages=_IMAGE_MSG) + response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG)) assert provider.calls == 2 assert response.content == "still failing" @@ -202,7 +203,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None: LLMResponse(content="ok"), ]) - response = await provider.chat_with_retry(messages=_IMAGE_MSG_NO_META) + response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG_NO_META)) assert response.content == "ok" assert provider.calls == 2