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