mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
fix(middleware): Fix the issue where summarization compressed away the user message of the current request (#4882)
多轮对话中,summarization 会压缩掉当前请求的用户消息,同时让上一次 请求的 ID-swap peer 残留在活跃上下文,导致模型答旧请求。改为只救援 带标记的 reminder 与最新真实用户消息,让陈旧的历史请求正常压缩。
This commit is contained in:
parent
556a178771
commit
0a3c04ebcc
@ -18,6 +18,7 @@ from langgraph.runtime import Runtime
|
||||
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.utils.messages import is_real_user_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SUMMARY_TRIGGER_MESSAGE_NAME = "summary"
|
||||
@ -524,8 +525,19 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
if cutoff_index <= 0:
|
||||
return None
|
||||
|
||||
# The latest real user message (the current request) must survive: peer
|
||||
# rescue no longer covers it (see _preserve_dynamic_context_reminders), so
|
||||
# lock its id here and rescue by exact id. This keeps the current request
|
||||
# without "moving cutoff" — which would also retain early AI/Tool turns and
|
||||
# never compress a first-turn long analysis.
|
||||
latest_user_id: str | None = None
|
||||
for msg in reversed(messages):
|
||||
if is_real_user_message(msg):
|
||||
latest_user_id = msg.id
|
||||
break
|
||||
|
||||
messages_to_summarize, preserved_messages = self._partition_messages(messages, cutoff_index)
|
||||
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
|
||||
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages, latest_user_id=latest_user_id)
|
||||
if not messages_to_summarize:
|
||||
return None
|
||||
return messages_to_summarize, preserved_messages, previous_summary, total_tokens
|
||||
@ -628,51 +640,24 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
self,
|
||||
messages_to_summarize: list[AnyMessage],
|
||||
preserved_messages: list[AnyMessage],
|
||||
*,
|
||||
latest_user_id: str | None = None,
|
||||
) -> tuple[list[AnyMessage], list[AnyMessage]]:
|
||||
"""Keep hidden dynamic-context reminders and their ID-swap peers out of summary compression.
|
||||
"""Keep tagged dynamic-context reminders and the current user request out of compression.
|
||||
|
||||
These reminders carry the current date and optional memory. If summarization
|
||||
removes them, DynamicContextMiddleware can lose the already-injected reminder
|
||||
and inject a replacement into the wrong point of the conversation.
|
||||
|
||||
The ID-swap triplet produced by ``_make_reminder_and_user_messages`` contains
|
||||
three messages: ``SystemMessage(id=X)`` and ``HumanMessage(id=X__memory)`` are
|
||||
both tagged with ``dynamic_context_reminder=True``, but ``HumanMessage(id=X__user)``
|
||||
carries the original user content and is **not** tagged. Without peer rescue,
|
||||
``__user`` would stay in ``to_summarize`` and be compressed into prose — orphaning
|
||||
the tagged messages and losing the user question from the model's direct context.
|
||||
|
||||
This method rescues tagged reminders and also rescues any untagged messages whose
|
||||
``id`` shares the same ``stable_id`` prefix (i.e. ``X__user``, ``X__memory``).
|
||||
Only tagged reminders (date ``SystemMessage`` + optional ``__memory`` peer,
|
||||
both carrying ``dynamic_context_reminder=True``) and the latest real user
|
||||
message are rescued. The untagged ``__user`` peer is deliberately NOT
|
||||
rescued by ID-swap prefix: it is a stale historical request that must be
|
||||
allowed to compress — the source of cross-turn prompt contamination. The
|
||||
*current* request is instead identified by ``latest_user_id``, so a
|
||||
first-turn long analysis keeps its ``__user`` request while its early
|
||||
AI/Tool turns still compress.
|
||||
"""
|
||||
reminders = [msg for msg in messages_to_summarize if is_dynamic_context_reminder(msg)]
|
||||
if not reminders:
|
||||
return messages_to_summarize, preserved_messages
|
||||
|
||||
# Collect the base IDs (the stable_id prefix) from tagged reminders.
|
||||
# For a reminder with id="ctx-001__memory", the base is "ctx-001".
|
||||
# For a reminder with id="ctx-001" (SystemMessage), the base is "ctx-001".
|
||||
# removesuffix is suffix-only — it won't strip a "__" that sits in the
|
||||
# middle of a stable_id (e.g. "ctx__001" stays intact, unlike rsplit
|
||||
# which would mis-derive "ctx"). Only known ID-swap suffixes (__memory,
|
||||
# __user) are stripped; __user is not tagged so won't appear in reminders,
|
||||
# but is included defensively.
|
||||
reminder_base_ids: set[str] = set()
|
||||
for msg in reminders:
|
||||
if msg.id:
|
||||
base = msg.id.removesuffix("__memory").removesuffix("__user")
|
||||
reminder_base_ids.add(base)
|
||||
|
||||
# Single-pass partition: walk messages_to_summarize in chronological order
|
||||
# and rescue both tagged reminders and untagged ID-swap peers (whose id
|
||||
# starts with a known base + "__"). This preserves the original message
|
||||
# order within rescued — critical when multiple triplets land in one
|
||||
# summarization window — and eliminates the need for id(m)-based dedup
|
||||
# that the previous reminders+peers concatenation required.
|
||||
rescued: list[AnyMessage] = []
|
||||
remaining: list[AnyMessage] = []
|
||||
for msg in messages_to_summarize:
|
||||
if is_dynamic_context_reminder(msg) or (msg.id and any(msg.id.startswith(b + "__") for b in reminder_base_ids)):
|
||||
if is_dynamic_context_reminder(msg) or (latest_user_id is not None and msg.id == latest_user_id):
|
||||
rescued.append(msg)
|
||||
else:
|
||||
remaining.append(msg)
|
||||
|
||||
@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langgraph.constants import TAG_NOSTREAM
|
||||
|
||||
@ -426,14 +426,12 @@ def test_memory_flush_hook_passes_runtime_user_id(monkeypatch: pytest.MonkeyPatc
|
||||
assert manager.add_nowait.call_args.kwargs["user_id"] == "alice"
|
||||
|
||||
|
||||
def test_id_swap_user_peer_is_preserved_across_summarization() -> None:
|
||||
"""__user (untagged) must be rescued alongside its tagged ID-swap peers.
|
||||
def test_stale_user_peer_is_compressed_not_rescued() -> None:
|
||||
"""A stale untagged ``__user`` peer is no longer rescued — only the latest user message is.
|
||||
|
||||
The ID-swap triplet from _make_reminder_and_user_messages is:
|
||||
[SystemMessage(id=X, reminder=True), HumanMessage(id=X__memory, reminder=True),
|
||||
HumanMessage(id=X__user)] — only the first two are tagged. Without peer
|
||||
rescue, __user stays in to_summarize and is compressed into prose, orphaning
|
||||
the tagged messages and losing the user question from direct model context.
|
||||
A historical ``__user`` peer now compresses like any other history; only tagged
|
||||
reminders (date SystemMessage + ``__memory``) and the latest real user message
|
||||
are rescued.
|
||||
"""
|
||||
captured: list[SummarizationEvent] = []
|
||||
middleware = _middleware(before_summarization=[captured.append])
|
||||
@ -470,28 +468,30 @@ def test_id_swap_user_peer_is_preserved_across_summarization() -> None:
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
# The __user message should NOT be in messages_to_summarize
|
||||
# The __user peer is no longer the current request (user-2 is) — it compresses.
|
||||
summarized_contents = [m.content for m in captured[0].messages_to_summarize]
|
||||
assert "What is the weather in Tokyo?" not in summarized_contents
|
||||
assert "What is the weather in Tokyo?" in summarized_contents
|
||||
|
||||
# All three triplet members should be in preserved_messages
|
||||
# tagged reminder + memory survive, but the __user peer is no longer rescued.
|
||||
preserved_ids = [m.id for m in captured[0].preserved_messages]
|
||||
assert stable_id in preserved_ids
|
||||
assert f"{stable_id}__memory" in preserved_ids
|
||||
assert f"{stable_id}__user" in preserved_ids
|
||||
assert f"{stable_id}__user" not in preserved_ids
|
||||
# The latest user message (user-2) survives.
|
||||
assert any(m.content == "user-2" for m in captured[0].preserved_messages)
|
||||
|
||||
# The emitted state includes all three triplet members
|
||||
# The emitted state likewise no longer contains the stale __user peer.
|
||||
emitted = result["messages"]
|
||||
assert isinstance(emitted[0], RemoveMessage)
|
||||
# Find the triplet members in the emitted messages
|
||||
emitted_ids = [m.id for m in emitted[1:]] # Skip RemoveMessage
|
||||
assert stable_id in emitted_ids
|
||||
assert f"{stable_id}__memory" in emitted_ids
|
||||
assert f"{stable_id}__user" in emitted_ids
|
||||
assert f"{stable_id}__user" not in emitted_ids
|
||||
|
||||
|
||||
def test_id_swap_user_peer_preserved_without_memory() -> None:
|
||||
"""When there's no __memory in the triplet, __user is still rescued."""
|
||||
def test_stale_user_peer_compressed_without_memory() -> None:
|
||||
"""Without ``__memory``, the stale ``__user`` peer still compresses; only the reminder survives."""
|
||||
captured: list[SummarizationEvent] = []
|
||||
middleware = _middleware(before_summarization=[captured.append])
|
||||
|
||||
@ -521,11 +521,11 @@ def test_id_swap_user_peer_preserved_without_memory() -> None:
|
||||
|
||||
assert len(captured) == 1
|
||||
summarized_contents = [m.content for m in captured[0].messages_to_summarize]
|
||||
assert "How are you?" not in summarized_contents
|
||||
assert "How are you?" in summarized_contents
|
||||
|
||||
preserved_ids = [m.id for m in captured[0].preserved_messages]
|
||||
assert stable_id in preserved_ids
|
||||
assert f"{stable_id}__user" in preserved_ids
|
||||
assert f"{stable_id}__user" not in preserved_ids
|
||||
|
||||
|
||||
def test_non_reminder_messages_with_double_underscore_id_not_rescued() -> None:
|
||||
@ -558,15 +558,11 @@ def test_non_reminder_messages_with_double_underscore_id_not_rescued() -> None:
|
||||
assert "standalone-reminder" in preserved_ids
|
||||
|
||||
|
||||
def test_multiple_id_swap_triplets_preserve_chronological_order() -> None:
|
||||
"""When multiple ID-swap triplets sit in one summarization window, rescued
|
||||
messages must retain their original chronological order — not be scrambled
|
||||
by separating tagged reminders from untagged peers.
|
||||
def test_multiple_id_swap_triplets_preserve_tagged_order() -> None:
|
||||
"""Multiple ID-swap triplets in one window: tagged reminders keep chronological order, user peers compress.
|
||||
|
||||
Regression: the previous reminders+peers concatenation rescued as
|
||||
[Sys(base1), Sys(base2), Mem(base1), Mem(base2), User(base1), User(base2)],
|
||||
detaching each user question from its AI answer. The single-pass partition
|
||||
preserves [Sys(base1), Mem(base1), User(base1), Sys(base2), Mem(base2), User(base2)].
|
||||
Peer rescue is gone: only tagged reminders (date SystemMessage + ``__memory``)
|
||||
survive, while untagged ``__user`` peers compress with history.
|
||||
"""
|
||||
captured: list[SummarizationEvent] = []
|
||||
middleware = _middleware(before_summarization=[captured.append])
|
||||
@ -619,18 +615,22 @@ def test_multiple_id_swap_triplets_preserve_chronological_order() -> None:
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
# Rescued messages must appear in their original chronological order:
|
||||
# each triplet stays contiguous, not re-grouped by role.
|
||||
# tagged reminders + memory keep chronological order (base1 before base2),
|
||||
# but untagged user peers are no longer rescued — they fall into to_summarize.
|
||||
preserved = captured[0].preserved_messages
|
||||
rescued_ids = [m.id for m in preserved if m.id and (is_dynamic_context_reminder(m) or m.id in (f"{base1}__user", f"{base2}__user"))]
|
||||
rescued_ids = [m.id for m in preserved if is_dynamic_context_reminder(m)]
|
||||
assert rescued_ids == [
|
||||
base1,
|
||||
f"{base1}__memory",
|
||||
f"{base1}__user",
|
||||
base2,
|
||||
f"{base2}__memory",
|
||||
f"{base2}__user",
|
||||
]
|
||||
preserved_ids = [m.id for m in preserved]
|
||||
assert f"{base1}__user" not in preserved_ids
|
||||
assert f"{base2}__user" not in preserved_ids
|
||||
summarized_contents = [m.content for m in captured[0].messages_to_summarize]
|
||||
assert "What is the weather?" in summarized_contents
|
||||
assert "How are you?" in summarized_contents
|
||||
|
||||
|
||||
def test_factory_attaches_memory_flush_hook_by_default(monkeypatch):
|
||||
@ -1206,3 +1206,95 @@ async def test_text_extraction_failure_falls_back_to_run_model_async(monkeypatch
|
||||
assert built == ["run-model"]
|
||||
assert result is not None
|
||||
assert result["summary_text"] == "from-run-model"
|
||||
|
||||
|
||||
def test_current_request_survives_and_stale_peer_compresses() -> None:
|
||||
"""Regression: with a stale peer and a current request in one window, the current request survives and the stale peer compresses.
|
||||
|
||||
Mirrors the production incident: a first-turn request is ID-swapped into
|
||||
reminder(X) + X__user (stale), while a later turn's request is a plain
|
||||
HumanMessage. On summarization the stale X__user must NOT be rescued into
|
||||
preserved, the current request must stay preserved, and messages_to_summarize
|
||||
must be non-empty (guards the no-op regression).
|
||||
"""
|
||||
captured: list[SummarizationEvent] = []
|
||||
middleware = _middleware(before_summarization=[captured.append], keep=("messages", 10))
|
||||
|
||||
stable_id = "60fe6f08-83cf-48d6-a2e6-2042830011d6"
|
||||
reminder = SystemMessage(
|
||||
content="<system-reminder>\n<current_date>2026-08-18, Tuesday</current_date>\n</system-reminder>",
|
||||
id=stable_id,
|
||||
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True, "reminder_date": "2026-08-18, Tuesday"},
|
||||
)
|
||||
old_user = HumanMessage(
|
||||
content="definition request for metric A",
|
||||
id=f"{stable_id}__user",
|
||||
)
|
||||
new_user = HumanMessage(
|
||||
content="single-metric analysis for metric B",
|
||||
id="c75368cd-d46e-4f22-8202-29a06e4929a3",
|
||||
)
|
||||
|
||||
messages = [
|
||||
reminder,
|
||||
old_user,
|
||||
new_user,
|
||||
AIMessage(content="run2 ai", id="ai-1", tool_calls=[{"id": "tc-1", "name": "queryMetrics", "args": {}}]),
|
||||
ToolMessage(content="{}", tool_call_id="tc-1", id="tool-1"),
|
||||
AIMessage(content="run2 ai2", id="ai-2"),
|
||||
ToolMessage(content="{}", tool_call_id="tc-2", id="tool-2"),
|
||||
ToolMessage(content="{}", tool_call_id="tc-3", id="tool-3"),
|
||||
ToolMessage(content="{}", tool_call_id="tc-4", id="tool-4"),
|
||||
AIMessage(content="run2 ai3", id="ai-3"),
|
||||
ToolMessage(content="{}", tool_call_id="tc-5", id="tool-5"),
|
||||
AIMessage(content="run2 ai4", id="ai-4"),
|
||||
ToolMessage(content="{}", tool_call_id="tc-6", id="tool-6"),
|
||||
ToolMessage(content="{}", tool_call_id="tc-7", id="tool-7"),
|
||||
]
|
||||
|
||||
middleware.before_model({"messages": messages}, _runtime())
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
preserved_ids = [m.id for m in ev.preserved_messages]
|
||||
# The current request survives.
|
||||
assert new_user.id in preserved_ids
|
||||
# The stale peer no longer escapes compression — it lands in to_summarize.
|
||||
assert old_user.id not in preserved_ids
|
||||
summarized_contents = [m.content for m in ev.messages_to_summarize]
|
||||
assert any("metric A" in c for c in summarized_contents)
|
||||
# Compression is non-empty (guards the no-op regression).
|
||||
assert len(ev.messages_to_summarize) > 0
|
||||
|
||||
|
||||
def test_first_turn_long_analysis_preserves_current_request() -> None:
|
||||
"""First-turn long analysis: the current request (X__user peer) stays preserved even outside the keep window, and early AI/Tool turns still compress."""
|
||||
captured: list[SummarizationEvent] = []
|
||||
middleware = _middleware(before_summarization=[captured.append], keep=("messages", 10))
|
||||
|
||||
stable_id = "ctx-001"
|
||||
reminder = SystemMessage(
|
||||
content="<system-reminder>\n<current_date>2026-05-08, Friday</current_date>\n</system-reminder>",
|
||||
id=stable_id,
|
||||
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
|
||||
)
|
||||
user = HumanMessage(content="first-turn long request", id=f"{stable_id}__user")
|
||||
|
||||
messages = [reminder, user]
|
||||
for k in range(1, 13):
|
||||
messages.append(AIMessage(content=f"ai{k}", id=f"ai{k}", tool_calls=[{"id": f"tc{k}", "name": "t", "args": {}}]))
|
||||
messages.append(ToolMessage(content=f"r{k}", tool_call_id=f"tc{k}", id=f"tool{k}"))
|
||||
|
||||
middleware.before_model({"messages": messages}, _runtime())
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
preserved_ids = [m.id for m in ev.preserved_messages]
|
||||
# The current request (the only real user message) survives.
|
||||
assert user.id in preserved_ids
|
||||
# The tagged reminder survives.
|
||||
assert stable_id in preserved_ids
|
||||
# Early AI/Tool turns still compress — non-empty.
|
||||
summarized_ids = [m.id for m in ev.messages_to_summarize]
|
||||
assert len(summarized_ids) > 0
|
||||
assert "ai1" in summarized_ids
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user