mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-14 23:19:55 +00:00
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:
parent
7a7f5c9689
commit
b261201985
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user