mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-28 05:33:59 +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)
|
result.append(msg)
|
||||||
return result if found else None
|
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:
|
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
|
||||||
"""Call chat() and convert unexpected exceptions to error responses."""
|
"""Call chat() and convert unexpected exceptions to error responses."""
|
||||||
try:
|
try:
|
||||||
@ -670,7 +690,12 @@ class LLMProvider(ABC):
|
|||||||
)
|
)
|
||||||
retry_kw = dict(kw)
|
retry_kw = dict(kw)
|
||||||
retry_kw["messages"] = stripped
|
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
|
return response
|
||||||
|
|
||||||
if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT:
|
if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT:
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -152,7 +153,7 @@ async def test_non_transient_error_with_images_retries_without_images() -> None:
|
|||||||
LLMResponse(content="ok, no image"),
|
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 response.content == "ok, no image"
|
||||||
assert provider.calls == 2
|
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"),
|
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 provider.calls == 2
|
||||||
assert response.content == "still failing"
|
assert response.content == "still failing"
|
||||||
@ -202,7 +203,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
|||||||
LLMResponse(content="ok"),
|
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 response.content == "ok"
|
||||||
assert provider.calls == 2
|
assert provider.calls == 2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user