fix(middleware): target the latest user message on first-turn fallback injection (#4667)

* 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.
This commit is contained in:
Baldwinzc 2026-08-16 11:53:21 +08:00 committed by GitHub
parent 8be08101b3
commit e59ee4827f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 80 additions and 13 deletions

View File

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

View File

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