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:
yong 2026-08-22 16:47:11 +08:00 committed by GitHub
parent 556a178771
commit 0a3c04ebcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 148 additions and 71 deletions

View File

@ -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)

View File

@ -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