mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-09 04:20:34 +00:00
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:
parent
8924c5844a
commit
5b1bee3d5a
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user