fix(memory): skip text-less turns when formatting the memory prompt (#7985)

* fix(memory): skip text-less turns when formatting the memory prompt

A content-less message rendered as the literal "None", and a block list
without text blocks rendered as its Python repr, including any base64
data URL. Normalize through the same rules as the sibling content-to-text
helpers and skip turns that carry no text.

* test(memory): cover whitespace-only assistant turns

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yetuge 2026-11-08 13:43:06 +08:00 committed by GitHub
parent 8924c5844a
commit 5b1bee3d5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 75 additions and 17 deletions

View File

@ -759,6 +759,33 @@ def format_memory_for_injection(
return result
def _content_to_text(content: Any) -> str:
"""Normalize LangChain message content for the memory prompt.
Mirrors ``deerflow/utils/messages.py:message_content_to_text``, the mem0
``message_filtering`` copy, and ``message_processing.extract_message_text``:
a content-less message is empty rather than the truthy literal ``"None"``,
and a block list without text blocks contributes no text. ``str(content)``
on such a list would embed its Python repr in the extraction prompt,
including a ``data:`` URL's base64 payload for an image-only turn.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for p in content:
if isinstance(p, str):
text_parts.append(p)
elif isinstance(p, dict):
text_val = p.get("text")
if isinstance(text_val, str):
text_parts.append(text_val)
return " ".join(text_parts)
return str(content)
def format_conversation_for_update(messages: list[Any]) -> str:
"""Format conversation messages for memory update prompt.
@ -771,27 +798,17 @@ def format_conversation_for_update(messages: list[Any]) -> str:
lines = []
for msg in messages:
role = getattr(msg, "type", "unknown")
content = getattr(msg, "content", str(msg))
# Handle content that might be a list (multimodal)
if isinstance(content, list):
text_parts = []
for p in content:
if isinstance(p, str):
text_parts.append(p)
elif isinstance(p, dict):
text_val = p.get("text")
if isinstance(text_val, str):
text_parts.append(text_val)
content = " ".join(text_parts) if text_parts else str(content)
content = _content_to_text(getattr(msg, "content", str(msg)))
# Strip the upload-context tag from human messages to avoid persisting
# ephemeral file path info into long-term memory. Skip the turn entirely
# when nothing remains after stripping (upload-only message).
# when nothing remains after stripping (upload-only message). Turns with
# no text at all (content-less AI message, image-only blocks) are skipped
# the same way instead of rendering a bare "Assistant: " line.
if role == "human":
content = re.sub(r"<current_uploads>[\s\S]*?</current_uploads>\n*", "", str(content)).strip()
if not content:
continue
content = re.sub(r"<current_uploads>[\s\S]*?</current_uploads>\n*", "", content).strip()
if not content.strip():
continue
# Truncate very long messages: keep the head (topic / opening) and the
# tail (conclusion / "remember X" instruction), dropping the middle.

View File

@ -1093,6 +1093,47 @@ class TestFormatConversationForUpdate:
assert "Tom &amp; Jerry" in result
assert "a &lt; b" in result
def test_content_less_message_is_skipped_instead_of_rendering_none(self):
"""A content-less message must not become the literal "None" turn.
``str(None)`` is truthy, so the previous fallback fed ``Assistant: None``
to the extraction prompt as if the assistant had said it. Sibling helpers
(``utils/messages.py:message_content_to_text``, the mem0 copy, and
``message_processing.extract_message_text``) all return "" here.
"""
msg = MagicMock()
msg.type = "ai"
msg.content = None
assert format_conversation_for_update([msg]) == ""
def test_whitespace_only_assistant_turn_is_skipped(self):
msg = MagicMock()
msg.type = "ai"
msg.content = " "
assert format_conversation_for_update([msg]) == ""
def test_image_only_blocks_do_not_leak_the_block_repr(self):
"""An image-only turn contributes no text, not a base64 Python repr."""
payload = "data:image/png;base64," + "A" * 2000
msg = MagicMock()
msg.type = "human"
msg.content = [{"type": "image_url", "image_url": {"url": payload}}]
assert format_conversation_for_update([msg]) == ""
def test_text_blocks_render_when_an_image_block_is_also_present(self):
msg = MagicMock()
msg.type = "human"
msg.content = [
{"type": "text", "text": "what is in this screenshot?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
result = format_conversation_for_update([msg])
assert result == "User: what is in this screenshot?"
# ---------------------------------------------------------------------------
# update_memory - structured LLM response handling