From e59ee4827f9009bc21a152fbc950ba0e76d81f18 Mon Sep 17 00:00:00 2001 From: Baldwinzc <56501736+Baldwinzc@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:53:21 +0800 Subject: [PATCH] fix(middleware): target the latest user message on first-turn fallback injection (#4667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(middleware): target the latest user message on first-turn fallback injection When an earlier turn ends without any dynamic-context reminder — e.g. the async abefore_agent degraded path times out and skips injection (issue #3402's guard) — the next turn enters the first-injection branch (last_date is None) on a history that already holds several turns. That branch scanned from the start and attached the ID-swap to the FIRST user message. The swap's {id}__user copy is appended by add_messages, so the stale first prompt moved to the tail of history, ahead of the current question — and the model answered the old prompt as if it were the current turn. Scan from the end instead (matching the midnight-crossing branch) so the reminder attaches to the latest user message and history order is preserved. Genuine first turns are unaffected: they have exactly one message, which is both first and last. The pre-existing test_injects_only_into_first_human_message_not_later_ones case encoded the buggy target selection and is updated to the corrected contract. * refactor: rename first_idx to target_idx after reversed scan The branch now scans from the end, so the local holds the LAST user injection target; first_idx read misleadingly. Match the midnight-crossing branch's naming convention and clarify the log line accordingly. No behavior change. --- .../middlewares/dynamic_context_middleware.py | 30 +++++++-- .../tests/test_dynamic_context_middleware.py | 63 ++++++++++++++++--- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py index c31e0c717..e28e6c3be 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py @@ -203,6 +203,16 @@ class DynamicContextMiddleware(AgentMiddleware): session — its content never changes again, so the prefix cache can hit on every subsequent turn. + Fallback (missed earlier injection) + ----------------------------------- + If an earlier turn ended without any reminder (e.g. the async ``abefore_agent`` + degraded path skipped injection on a timeout), the first-injection branch runs + on a history that already holds several turns. The reminder then attaches to + the **last** user message instead: the ID-swap's ``{id}__user`` copy is + appended by ``add_messages``, so attaching to an earlier message would move + that stale prompt ahead of the current question and the model would answer + the old prompt as the current turn. + Midnight crossing ----------------- If the conversation spans midnight, the current date differs from the date that @@ -317,16 +327,26 @@ class DynamicContextMiddleware(AgentMiddleware): if last_date is None: # ── First turn: inject full reminder as a SystemMessage ───── - first_idx = next((i for i, m in enumerate(messages) if _is_user_injection_target(m)), None) - if first_idx is None: + # + # Scan from the end so the reminder attaches to the LAST user + # injection target. Normally that is also the only message. But + # when an earlier turn ended without any reminder — e.g. the async + # ``abefore_agent`` degraded path skipped injection on a timeout — + # history already holds multiple turns and the ID-swap's + # ``{id}__user`` copy is APPENDED by ``add_messages``; choosing an + # earlier message here would move the old first user prompt to the + # tail, ahead of the latest question, and the model would answer + # the stale first message as if it were the current turn. + target_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None) + if target_idx is None: return None date_reminder, memory_block = self._build_full_reminder(runtime) logger.info( - "DynamicContextMiddleware: injecting full reminder (has_memory=%s) into first HumanMessage id=%r", + "DynamicContextMiddleware: injecting full reminder (has_memory=%s) into last HumanMessage id=%r", memory_block is not None, - messages[first_idx].id, + messages[target_idx].id, ) - result_msgs = self._make_reminder_and_user_messages(messages[first_idx], date_reminder, memory_block, reminder_date=current_date) + result_msgs = self._make_reminder_and_user_messages(messages[target_idx], date_reminder, memory_block, reminder_date=current_date) return {"messages": result_msgs} if last_date == current_date: diff --git a/backend/tests/test_dynamic_context_middleware.py b/backend/tests/test_dynamic_context_middleware.py index 6ca9f39d2..945063b3e 100644 --- a/backend/tests/test_dynamic_context_middleware.py +++ b/backend/tests/test_dynamic_context_middleware.py @@ -397,8 +397,18 @@ def test_legacy_systemmessage_reminder_without_key_detected(): assert result is None # same day detected from content → no re-injection -def test_injects_only_into_first_human_message_not_later_ones(): - """Reminder targets the first HumanMessage; subsequent messages are not touched.""" +def test_first_turn_fallback_targets_the_latest_user_message(): + """Fallback injection (history without any reminder) attaches to the LAST user target. + + A history that reaches the ``last_date is None`` branch while already + holding multiple turns only exists because an earlier injection never + happened (e.g. the async degraded path skipped it) — the genuinely first + turn has exactly one message. The reminder must therefore attach to the + latest user message: the ID-swap's ``{id}__user`` copy is appended by + ``add_messages``, so attaching to an earlier message would move that stale + prompt to the tail, ahead of the current question, and the model would + answer it as the current turn. + """ mw = _make_middleware() state = { "messages": [ @@ -414,15 +424,15 @@ def test_injects_only_into_first_human_message_not_later_ones(): assert result is not None msgs = result["messages"] - # Only the two injected messages are returned (reminder + original first query) + # Only the two injected messages are returned (reminder + latest user query) assert len(msgs) == 2 - assert msgs[0].id == "msg-1" # reminder takes first message's ID + assert msgs[0].id == "msg-2" # reminder takes the latest message's ID assert msgs[0].additional_kwargs.get(_DYNAMIC_CONTEXT_REMINDER_KEY) is True assert _SYSTEM_REMINDER_TAG in msgs[0].content - assert msgs[1].id == "msg-1__user" # original content with derived ID - assert msgs[1].content == "First" - # "Second" (msg-2) is not in the returned update — it is left unchanged - assert all(m.id != "msg-2" for m in msgs) + assert msgs[1].id == "msg-2__user" # latest user content with derived ID + assert msgs[1].content == "Second" + # "First" (msg-1) is not in the returned update — it is left unchanged + assert all(m.id != "msg-1" for m in msgs) # --------------------------------------------------------------------------- @@ -514,6 +524,43 @@ def test_user_message_containing_system_reminder_tag_does_not_prevent_injection( assert result["messages"][0].additional_kwargs.get(_DYNAMIC_CONTEXT_REMINDER_KEY) is True +def test_first_turn_injection_with_unguarded_history_targets_last_user_message(): + """First-injection fallback after an un-reminded earlier turn must not reorder history. + + When the earlier first-turn injection never happened (e.g. the async + ``abefore_agent`` degraded path timed out, so ``last_date is None`` at the + start of a *later* turn), the reminder must attach to the LAST user + injection target. The ID-swap's ``{id}__user`` copy is appended by + ``add_messages``; picking the first message instead would move the stale + first user prompt to the tail, ahead of the current question — and the + model would answer the old prompt as the current turn. + """ + from langgraph.graph.message import add_messages + + mw = _make_middleware() + state = { + "messages": [ + HumanMessage(content="first question", id="u1"), + AIMessage(content="first answer", id="a1"), + HumanMessage(content="second question", id="u2"), + ] + } + + with mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value=""), mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday" + result = mw.before_agent(state, _fake_runtime()) + + assert result is not None + assert [m.id for m in result["messages"]] == ["u2", "u2__user"] + + # Fold the update through the real reducer — this is where the reorder + # would actually manifest for the model. + merged = add_messages(state["messages"], result["messages"]) + last_visible = [m for m in merged if isinstance(m, HumanMessage)][-1] + assert last_visible.content == "second question" + assert [m.id for m in merged].index("u1") < [m.id for m in merged].index("u2__user") + + # --------------------------------------------------------------------------- # Midnight crossing # ---------------------------------------------------------------------------