mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
8be08101b3
commit
e59ee4827f
@ -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:
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user