fix(retry): strip images in-place to prevent repeated error-retry cycles

When a non-transient LLM error occurs with image content, the retry
mechanism strips images from a copy but never updates the original
conversation history. Subsequent iterations rebuild context from the
unmodified history, causing the same error-retry cycle to repeat
every iteration until max_iterations is reached.

Add _strip_image_content_inplace() that mutates the original message
content lists in-place after a successful no-image retry, so callers
sharing those references (e.g. the runner's conversation history)
also see the stripped version.
This commit is contained in:
yanghan-cyber 2026-04-12 14:20:14 +08:00 committed by Xubin Ren
parent 7a7f5c9689
commit b261201985
2 changed files with 30 additions and 4 deletions

View File

@ -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:

View File

@ -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