Skip to content
Open
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
58 changes: 58 additions & 0 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,18 @@ async def stream_callback(chunk: str, full: str):
PrintStyle(font_color="red", padding=True).print(msg["message"])
self.context.log.log(type="warning", content=msg["message"])
except Exception as e:
# Check if this is a provider rejection caused by raw image content
if "BadRequestError" in type(e).__name__ or "400" in str(e):
stripped = self._strip_raw_images_from_history()
if stripped:
self.context.log.log(
type="warning",
content=f"Stripped {stripped} raw image(s) from history after provider rejection (inner loop)",
)
PrintStyle(font_color="orange", padding=True).print(
f"Stripped {stripped} raw image(s) from history after provider rejection, retrying..."
)
continue
# Retry critical exceptions before failing
error_retries = await self.retry_critical_exception(
e, error_retries
Expand All @@ -520,6 +532,18 @@ async def stream_callback(chunk: str, full: str):
error_retries = 0 # reset retry counter on user intervention
pass # just start over
except Exception as e:
# Check if this is a provider rejection caused by raw image content
if "BadRequestError" in type(e).__name__ or "400" in str(e):
stripped = self._strip_raw_images_from_history()
if stripped:
self.context.log.log(
type="warning",
content=f"Stripped {stripped} raw image(s) from history after provider rejection (outer loop)",
)
PrintStyle(font_color="orange", padding=True).print(
f"Stripped {stripped} raw image(s) from history after provider rejection, retrying..."
)
continue
# Retry critical exceptions before failing
error_retries = await self.retry_critical_exception(
e, error_retries
Expand Down Expand Up @@ -581,6 +605,40 @@ async def prepare_prompt(self, loop_data: LoopData) -> list[BaseMessage]:

return full_prompt

def _strip_raw_images_from_history(self) -> int:
"""Strip raw image messages from history after provider rejection.

When a provider returns 400 BadRequestError for image content,
this replaces raw image messages with text previews to prevent
permanent crash loops where every subsequent message triggers
the same 400 error.

Returns the count of stripped images.
"""
stripped = 0
# Check current topic messages
for msg in self.history.current.messages:
if history._is_raw_message(msg.content):
preview = (
msg.content.get("preview", "<image content removed after provider error>")
if isinstance(msg.content, dict)
else "<image content removed after provider error>"
)
msg.set_summary(preview)
stripped += 1
# Check past topics
for topic in self.history.topics:
for msg in topic.messages:
if history._is_raw_message(msg.content):
preview = (
msg.content.get("preview", "<image content removed after provider error>")
if isinstance(msg.content, dict)
else "<image content removed after provider error>"
)
msg.set_summary(preview)
stripped += 1
return stripped

async def retry_critical_exception(
self, e: Exception, error_retries: int, delay: int = 3, max_retries: int = 1
) -> int:
Expand Down
23 changes: 21 additions & 2 deletions python/helpers/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,20 @@ async def compress_attention(self) -> bool:
return False

async def summarize_messages(self, messages: list[Message]):
# FIXME: vision bytes are sent to utility LLM, send summary instead
msg_txt = [m.output_text() for m in messages]
# Explicitly replace raw message content (e.g., base64 images) with
# text previews to avoid sending binary data to the utility model
msg_txt = []
for m in messages:
if _is_raw_message(m.content):
preview = (
m.content.get("preview", "<non-text content>")
if isinstance(m.content, dict)
else "<non-text content>"
)
label = "ai" if m.ai else "user"
msg_txt.append(f"{label}: {preview}")
else:
msg_txt.append(m.output_text())
summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
Expand Down Expand Up @@ -536,6 +548,13 @@ def output_text(messages: list[OutputMessage], ai_label="ai", human_label="human


def _merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent:
# Guard: convert raw messages to text previews before merging
# to prevent invalid mixed-format message structures
if _is_raw_message(a):
a = _stringify_content(a)
if _is_raw_message(b):
b = _stringify_content(b)

if isinstance(a, str) and isinstance(b, str):
return a + "\n" + b

Expand Down
26 changes: 21 additions & 5 deletions python/tools/vision_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,27 @@ async def after_execution(self, response: Response, **kwargs):
"text": "Error processing image " + path,
}
)
# append as raw message content for LLMs with vision tokens estimate
msg = history.RawMessage(raw_content=content, preview="<Base64 encoded image data>")
self.agent.hist_add_message(
False, content=msg, tokens=TOKENS_ESTIMATE * len(content)
)
try:
# append as raw message content for LLMs with vision tokens estimate
msg = history.RawMessage(
raw_content=content,
preview=f"<Base64 encoded image data ({len(content)} image(s))>",
)
self.agent.hist_add_message(
False, content=msg, tokens=TOKENS_ESTIMATE * len(content)
)
except Exception as e:
# Graceful degradation: add text description instead of raw image
# to prevent corrupted image data from persisting in chat history
PrintStyle().error(f"Error adding image to history: {e}")
self.agent.context.log.log(
"warning", f"Error adding image to history: {e}"
)
fallback = (
f"{len(self.images_dict)} image(s) loaded but could not be "
f"added to context: {e}"
)
self.agent.hist_add_tool_result(self.name, fallback)
else:
self.agent.hist_add_tool_result(self.name, "No images processed")

Expand Down