fix(summarization): keep a Human Input Card reply as the current request (#5416)

Summarization rescues the latest user message by id so the current
request survives compaction (#4882). It picked that message with
is_real_user_message, which rejects every hide_from_ui HumanMessage,
including Human Input Card replies. When a run starts from a card
reply, the older visible request was kept and the user's answer was
summarized away.

Use is_genuine_user_message, which accepts hidden messages that carry a
valid human_input_response and still skips other hidden injections.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
alanhuangyoo 2026-09-14 10:45:31 +08:00 committed by GitHub
parent 96d6ff3aca
commit f5cf25a8b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 49 additions and 3 deletions

View File

@ -18,12 +18,12 @@ from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
from deerflow.agents.middlewares.message_utils import is_genuine_user_message
from deerflow.config.app_config import get_app_config
from deerflow.config.summarization_config import DEFAULT_KEEP
from deerflow.config.task_continuity_config import TaskContinuityConfig
from deerflow.extensions.notify import notify_context_compacted
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"
@ -578,10 +578,11 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# 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.
# never compress a first-turn long analysis. A Human Input Card reply is
# hidden from the UI but is still the user's current request.
latest_user_id: str | None = None
for msg in reversed(messages):
if is_real_user_message(msg):
if is_genuine_user_message(msg):
latest_user_id = msg.id
break

View File

@ -1461,6 +1461,51 @@ def test_current_request_survives_and_stale_peer_compresses() -> None:
assert len(ev.messages_to_summarize) > 0
def test_human_input_card_reply_survives_as_current_request() -> None:
"""A Human Input Card reply is the current request, so it survives compaction.
The frontend sends the answer as a hidden HumanMessage carrying
``human_input_response``. It must be rescued like a visible request instead
of the older request that triggered the clarification.
"""
captured: list[SummarizationEvent] = []
middleware = _middleware(before_summarization=[captured.append], keep=("messages", 6))
request = HumanMessage(content="Research topic X and write a report", id="request")
reply = HumanMessage(
content="Only Europe, and only 2025 data",
id="card-reply",
additional_kwargs={
"hide_from_ui": True,
"human_input_response": {
"version": 1,
"kind": "human_input_response",
"source": "ask_clarification",
"request_id": "clarify-1",
"response_kind": "text",
"value": "Only Europe, and only 2025 data",
},
},
)
messages = [
request,
AIMessage(content="", id="ai-clarify", tool_calls=[{"id": "clarify-1", "name": "ask_clarification", "args": {"question": "Which region?"}}]),
ToolMessage(content="Which region?", tool_call_id="clarify-1", id="tool-clarify"),
reply,
]
for k in range(1, 6):
messages.append(AIMessage(content=f"ai{k}", id=f"ai{k}", tool_calls=[{"id": f"tc{k}", "name": "web_search", "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]
assert reply.id in [m.id for m in ev.preserved_messages]
assert reply.id not in [m.id for m in ev.messages_to_summarize]
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] = []