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