fix(memory): flatten multimodal content before provider sync

Multimodal turns carry message content as a list of typed parts
({type: "text"|"image_url", ...}). _sync_external_memory_for_turn
passed that list straight into MemoryManager.sync_all, and providers
feed it to regexes — Honcho's sync_turn calls sanitize_context, where
re.sub raised 'expected string or bytes-like object, got list'. Every
turn with an attached image silently never synced.

Flatten to plain text at the boundary: text parts joined, images noted
as an [N image(s)] marker so the attachment isn't erased from recall.
Fixing here covers all providers instead of patching each plugin.
This commit is contained in:
Erosika
2026-06-11 18:53:26 -04:00
parent 2ee69d0579
commit 705bdb6ffe
4 changed files with 161 additions and 4 deletions
+39
View File
@@ -67,6 +67,45 @@ def sanitize_context(text: str) -> str:
return text
def flatten_message_content(content: Any) -> str:
"""Flatten message content to plain text for memory providers.
Multimodal turns carry content as a list of ``{type: "text"|"image_url",
...}`` parts; providers expect a string and feed it to regexes
(``sanitize_context``) and text APIs, so a list crashes the sync
(``expected string or bytes-like object, got 'list'``). Text parts are
joined, images become a ``[N image(s)]`` marker so the turn isn't
recorded as if the attachment never existed.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_bits: List[str] = []
image_count = 0
for part in content:
if isinstance(part, str):
if part:
text_bits.append(part)
continue
if not isinstance(part, dict):
continue
ptype = str(part.get("type") or "").strip().lower()
if ptype in {"text", "input_text", "output_text"}:
text = part.get("text")
if isinstance(text, str) and text:
text_bits.append(text)
elif ptype in {"image_url", "input_image"}:
image_count += 1
flattened = "\n".join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
flattened = f"{note} {flattened}" if flattened else note
return flattened
return str(content)
class StreamingContextScrubber:
"""Stateful scrubber for streaming text that may contain split memory-context spans.