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