From 5b1bee3d5a1f9006d358bf783779b4923fc5cb47 Mon Sep 17 00:00:00 2001 From: yetuge <182123929+yetuge@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:43:06 +0800 Subject: [PATCH] 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 --- .../backends/deermem/deermem/core/prompt.py | 51 ++++++++++++------- backend/tests/test_memory_updater.py | 41 +++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py index 54569acb75..ddab2bbb1e 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py @@ -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"[\s\S]*?\n*", "", str(content)).strip() - if not content: - continue + content = re.sub(r"[\s\S]*?\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. diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 72480ea413..2366281abc 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -1093,6 +1093,47 @@ class TestFormatConversationForUpdate: assert "Tom & Jerry" in result assert "a < 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