Context Injection Scan Direction: The deer-flow #4667 Pitfall, Dissected and Cross-Reviewed Across Six Agent Systems
Seven turns into a session, the user says "remove the subtitles" — and the agent cheerfully starts answering turn one's question, "make me a product intro video." No error, no stack trace, no awareness on the model's part that it is answering the wrong turn. That is not a capability problem; it is a context-injection bug. On August 16, 2026, PR #4667 landed on bytedance/deer-flow's main branch and nailed this pitfall into the contract.
This post does three things: dissects the complete four-link chain behind the bug (it runs deeper than "scanned the wrong direction"); cross-reviews six mainstream agent systems' injection and compaction defenses against first-source code; and distills six invariants you can paste into your next code review. The thesis up front: where dynamic context attaches in the message history determines what the model believes "now" is — and the first version of almost every injection mechanism attaches it wrong.
1. Anatomy of the Bug: Four Links, All Required
deer-flow's DynamicContextMiddleware injects dynamic context (current-date reminder plus a memory block) before each turn. Its technique is an ID-swap: a reminder message is constructed that steals the ID of the target user message, and the original content is re-emitted under a derived ID, {id}__user. LangGraph's add_messages reducer merges by ID — same ID replaces in place, new IDs append to the tail.
Every step in isolation is sound. The disaster lives in the composition (I covered deer-flow 2.x's overall architecture in an earlier teardown; this post zooms into the injection chain):
- An injection gets skipped. The async
abefore_agentdegraded path gives up on injection when a cold tiktoken download times out (a guard added earlier for #3402). - The next turn enters the "first injection" branch. The middleware finds no reminder anywhere in history (
last_date is None) and concludes this is the conversation's first turn — while six turns already sit in history. - The first-injection branch scans from the head.
next(i for i, m in enumerate(messages) if _is_user_injection_target(m))— the first-instinct formulation — attaches to the first user message. - The ID-swap triggers an implicit reorder. The first user message's ID is taken by the reminder; its content, now carrying
msg-1__user, gets appended to the tail byadd_messages— right after the current question.
The folded message list (buggy path):
[reminder(id=msg-1)] [a1] [u2] [a2] ... [u7 "remove the subtitles"] [msg-1__user "make me a product intro video"]
↑ current turn ↑ the stale prompt, teleported to the tail
The last user-authored content the model sees is the first prompt from six turns ago. It answers the wrong question because, from its vantage point, that is the current question.
The fix differs by one word — reversed(range(len(messages))) — scanning from the tail and attaching to the latest user message. A genuine first turn has exactly one message (first equals last), so behavior there is unchanged; on the fallback path, the appended msg-7__user copy coincides with the current question and order falls out correct.
The PR shipped labeled risk:high and needs-validation, into milestone 2.1.0, with a diff of barely twenty lines. But the semantic trap it exposes deserves to be memorized by anyone building on LangGraph — or any runtime that merges message lists by ID:
# LangGraph add_messages contract (source docstring, main branch, 2026-08):
# msgs1 = [HumanMessage(content="Hello", id="1")]
# msgs2 = [HumanMessage(content="Hello again", id="1")]
# → [HumanMessage(content='Hello again', id='1')] # same ID: replace in place
# new IDs always append to the tail
Rewriting a historical message's ID + the reducer's append semantics = implicit history reordering. You never wrote a "move" — the message was teleported anyway.
The testing lesson: the old test encoded the bug into the contract
Before #4667, the test was named test_injects_only_into_first_human_message_not_later_ones — faithfully asserting the wrong behavior: "the reminder attaches to the first message." The test stayed green; the bug stayed alive. The renamed successor, test_first_turn_fallback_targets_the_latest_user_message, also does something the old one never did: folds the middleware's return value through the real add_messages reducer and asserts the current question is still the last user message.
That is the most theft-worthy part of the fix. Tests that only assert the shape of an intermediate artifact ("returned two messages with IDs X and Y") cannot verify the net effect of injection through the full pipeline — and the reorder happens in the reducer, not the middleware.
2. Why This Is Not deer-flow-Specific
Three forces make this bug class generic:
- "Find the first user message" is the first instinct of injection code. The mental model "system reminders belong at the top of the conversation" makes head-scanning the default formulation.
- ID-swap is an elegant but dangerous technique. Its payoff is prompt-cache-friendly injection (deer-flow's comments say so explicitly: reminder content never changes, so the prefix cache hits every subsequent turn). Its price is that any "attach to an old message" mistake is amplified by the reducer into a history reorder.
- Degraded paths manufacture impossible states. Under the normal flow, "multi-turn history with zero injections" cannot exist — which is why the first-injection branch could assume "history holds exactly one message." The moment some timeout guard skips one injection, the impossible state becomes real and the assumption collapses. Defensive code (#3402's guard) manufactured the trigger for the new bug — a recurring theme in agent systems.
3. Six Systems, Cross-Reviewed: The Defenses
I pulled source or official docs for five other systems to see how they answer two questions: where does dynamic context go, and where does compaction cut. All as of the main branches on 2026-08-17.
Codex (OpenAI, Rust): injection position as a type
codex-rs/core/src/compact.rs contains a textbook enum:
pub(crate) enum InitialContextInjection {
BeforeLastUserMessage { world_state: Arc<WorldState>, step_context: Arc<StepContext> },
DoNotInject,
}
The comments spell out the rule: pre-turn/manual compaction uses DoNotInject — after history is replaced by a summary, the next regular turn naturally re-injects initial context; mid-turn compaction must use BeforeLastUserMessage because "the model is trained to see the compaction summary as the last item in history after mid-turn compaction," so initial context goes immediately before the last real user message.
The placement function, insert_initial_context_before_last_real_user_or_summary, iterates with .rev() — backwards — through a priority chain: last real user message → last summary-like message (keeping the summary last) → last compaction item → else append. Identical philosophy to #4667's fix — scan from the tail, anchor the latest — except Codex encodes it as a compile-time-checkable type with documented invariants, not a lone reversed inside a loop.
The compaction product itself is heavily engineered: each compacted history window carries window_number and AutoCompactWindowIds (monotonically increasing, traceable to which compaction); the summarization prompt is a four-bullet handoff format (progress and key decisions / constraints and preferences / remaining steps / critical data needed to continue), prefixed with the fixed "Another language model started to solve this problem..." — the successor's perspective, hardcoded. Single compaction input caps at 20,000 tokens.
Gemini CLI (Google): two generations of compaction in one repo
The repository reads like a geological cross-section.
The 2025 layer (chatCompressionService.ts): threshold 0.5 triggers; the newest 30% of history is preserved. The split-point function findCompressSplitPoint only cuts at real user-message boundaries (role === 'user', no functionResponse parts) — never mid-turn — sharing the post-#4667 philosophy: boundary-aware cuts. The gem is the Reverse Token Budget: scanning newest-to-oldest, recent tool outputs are preserved in full; once a 50,000-token budget is exceeded, older large outputs are truncated to their last 30 lines with the full text saved to a temp file behind a reference. Freshness tiering — the closer to the current turn, the higher the fidelity.
The 2026 layer (context/processors/rollingSummaryProcessor.ts): with context now a graph, compaction becomes rolling summary nodes. Three strategies (incremental / freeNTokens / max) decide how much to fold; the summary node's ID derives from the consumed nodes' IDs (deriveStableId(consumedIds)) — deterministic derivation, same input yields the same ID — replay-safe.
Claude Code (Anthropic): minimal surface, documented
The official costs documentation (fetched 2026-08) confirms the auto-compact window: "the threshold where Claude Code summarizes older history to free space," paired with the advice to clear sessions between unrelated tasks. Details stay closed, but the direction matches the industry: threshold trigger + summary replacing old history + prompt caching for cost. Notably, the docs list "long sessions never cleared" as the top cause of runaway spend — compaction is a backstop, not a free lunch.
Hermes Agent (Nous Research): two-tier compaction + mechanical anchors
Hermes' context_compressor.py (local checkout v0.20.0) is an extreme specimen of the determinism-first school (its cross-session memory recall was covered in an earlier source walkthrough; this post focuses on the compaction layer):
- Micro-compaction and batch compaction are tiered, marked with different metadata keys (
MICRO_COMPACT_MARKER_KEY); the comments explain why the distinction is load-bearing: a batch marker's content is not contained in the rolling summary, so deleting or rewriting one destroys history. - Image anchors: anchor on the last image-bearing user message; earlier messages get their image parts replaced with placeholders — "anchor is the last one" again, the same directional judgment as #4667's fix, Codex's insertion point, and our fold cut.
- Tool output is pruned before LLM summarization (a cheap pre-pass); summaries use an auxiliary small model.
- The most telling line is in the repo's AGENTS.md: "Per-conversation prompt caching is sacred ... the one exception is context compression." Compaction is a controlled, carefully-bounded exception, not a casual tool. The test suite covers busy-retry (unsummarizable exchanges don't spin), locks, and trajectory compression.
Letta: don't compact the conversation at all
Letta (MemGPT lineage) takes the other road: externalize memory out of the context window. Its Context Constitution (github.com/letta-ai/context-constitution) governs what information enters the context, in what order, at what detail level, for how long; beyond the conversation, MemFS is a git-versioned memory filesystem, and sleep-time subagents organize memory in the background. The compaction problem is transformed into a retrieval problem — at the cost of a longer recall chain, and the gain of never touching history reordering at all.
| System | Dynamic context position | Compaction cut | Summary product | Reorder-risk defense |
|---|---|---|---|---|
| deer-flow | ID-swap onto user messages | — (middleware doesn't compact) | — | Post-fix tail scan; tests fold through the real reducer |
| Codex | Typed enum: before last real user message / no injection | Window boundaries | Handoff summary + window numbers | Compile-time types + reverse-scan priority chain |
| Gemini CLI | Preserves newest 30% at compaction | Only at user-turn boundaries | LLM summary (two generations: threshold / rolling) | Boundary-aware split + freshness-tiered budget |
| Claude Code | Not public | Auto-compact threshold | Summary replaces old history | Prompt caching + session-hygiene advice |
| Hermes | Image anchor = last image-bearing message | Micro (tool output) + batch (middle turns) | Auxiliary-model summary with persistence markers | Two-tier markers against mis-deletion; byte stability first |
| Letta | Nothing injected into history; memory externalized | No conversation compaction | MemFS + sleep-time organization | Problem converted to retrieval; reordering bypassed |
My verdict: the six systems converge on the same rule for injection position — anchor the latest user message, or don't touch history at all — and differ only in the strength of expression: deer-flow with one reversed, Codex with the type system, Gemini with boundary-aware cut points, Hermes with anchors and tiered markers, Letta with an architectural bypass. The stronger the expression, the harder the bug class is to reintroduce.
4. Production Field Notes: Three Injection Shapes and a De-compaction Refactor
Beyond the cross-review, a production perspective. My team maintains a node-canvas workflow copilot (video creation, a multi-turn agent in the LangGraph style). We audited every context-injection path — and when #4667 merged, I re-verified our code line by line. Conclusion: this bug class is structurally absent from our architecture, for reasons worth spelling out:
Shape one: stateful context is rebuilt from the database every turn. The canvas node/edge index (what file trees or dates are to general agents) never lands in conversation history; it is rebuilt each turn in the system prompt from the latest DB-confirmed snapshot. The action "scan history for an attach point" does not exist — dynamic state never enters history, so reordering has nothing to grab. This goes further than "scan from the tail": the attach-point concept itself is deleted. When a snapshot read fails, a degraded header declares "what follows may be stale; do not conclude a node is missing" — defending the opposite failure (the model treating a stale empty canvas as permission to rebuild).
Shape two: event context attaches at creation time. Attachments and mentions on a user message are serialized into that message's envelope as it is being written. The attach point is the latest message by construction. On replay, envelopes are stripped and only body text returns — history is never re-processed.
Shape three: corrective context updates in place. The one path that writes back to history (late-arriving attachment metadata) updates database rows in place — same ID, same position, changed content. No message ID is ever rewritten or re-appended. That is the safe form of "editing history": content changes, structure does not.
On the compaction side we recently made the reverse decision: we deleted the per-turn, LLM-authored four-part summary. Its pathology is the same family as #4667's — two records (what the user read vs. what the next turn reads) can drift apart with nobody noticing, and the loss is one-directional. After the refactor, "the reply is the record": each turn's reply is simultaneously what the person reads and the entire basis for later replay — one string cannot drift from itself. The one remaining compactor is a threshold-triggered fold: the cut point walks backwards to the most recent turn boundary (never mid-turn), and the folded record is a deterministic per-turn concatenation (user messages clipped at 300 chars, action records at 600) — deliberately no LLM call, because deterministic concatenation keeps the compaction product byte-stable and the prompt cache re-hits immediately after folding. The repo comment draws the line: "the day it genuinely needs to be shorter is the day an LLM call earns its place."
5. Six Invariants
Everything above, collected into portable rules, ordered by defensive strength:
- Dynamic state does not enter history. Canvas state, file trees, dates — "current state" information is rebuilt each turn from the source of truth (at system-prompt level), not injected into a historical message. The attach-point problem is architecturally dissolved.
- If history must change, update in place — never rewrite IDs. Same-ID replacement or row updates are safe; any "old message re-emitted under a new ID" (ID-swap) borrows the reducer's hand to reorder history. If you use ID-swap (for cache prefixes), you accept the constraint: the attach point may only be the latest user message.
- Always scan from the tail. When hunting an attach point, anchor, or insertion point in history, iterate backwards and anchor the latest — deer-flow's fix, Codex's insertion function, Hermes' image anchor, and our fold cut all point the same way. The only legitimate head scan is "verify no X exists anywhere in history."
- Compaction cuts must land on turn boundaries. Never split a turn: Gemini's user-boundary splits, Codex's windows, our backwards walk to the turn boundary — one discipline.
- Compaction products persist once; replay reuses the stored bytes. An LLM summary regenerated on every reload reorders the prefix once per process and torches the cache. Persist the summary; replay reads the stored artifact. Deterministic concatenation adds a further layer of byte stability over LLM summaries.
- Tests must fold through the real reducer. Assert the net effect of injection/compaction through the complete pipeline (message order, current question still last, fact recall), not the shape of intermediates. The old deer-flow test stood green for years, testing only shapes.
6. The Decision Matrix: What to Use When
- Injecting "state" (canvas, filesystem, dates, permissions) → rebuild from the DB each turn into the system prompt. Don't touch history.
- Injecting "events" (attachments, mentions, one-off supplements) → attach at creation to the message being written. Latest by construction.
- Correcting historical data (late metadata, field backfill) → in-place row updates. Content changes, structure doesn't.
- History over budget, in-session continuity needed → threshold-triggered boundary fold, deterministic concatenation first; only after measured recall proves it insufficient, upgrade to an LLM summary (Codex's four-bullet handoff is a ready-made prompt template), persisted.
- Mid-turn overflow (tool loops filling the window) → Codex-style BeforeLastUserMessage injection, or Gemini-style freshness-tiered budgets (recent in full, older truncated with external references).
- Cross-session continuity → externalized memory (Letta-style MemFS / a retrieval layer), converting compaction into retrieval.
Conclusion
The immediate lesson of #4667 is "fallback injection scans from the tail," but what it actually reveals runs deeper on three axes: rewriting a historical message's ID is an implicit reorder — a semantic landmine shared by every runtime that merges messages by ID (LangGraph is merely one); degraded paths manufacture impossible states, collapsing assumptions that are safe under the normal flow ("first injection equals first conversation"); shape-only tests stand guard for the bug. The six-system review shows the industry has converged on injection position (anchor the latest, or don't touch history) and diverges on strength of expression — one reversed, an enum, a boundary-aware cut function, a tiered-marker scheme, or an architecture swap. If your agent project contains code that says "find the first user message and attach something," now is the time to grep for it.
References
- Baldwinzc — fix(middleware): target the latest user message on first-turn fallback injection, deer-flow PR #4667 (merged 2026-08-16, milestone 2.1.0)
- LangChain — langgraph/graph/message.py, the add_messages reducer (main branch, 2026-08)
- OpenAI — codex-rs/core/src/compact.rs and prompts/templates/compact/prompt.md (main branch, 2026-08)
- Google — gemini-cli chatCompressionService.ts (© 2025) and rollingSummaryProcessor.ts (© 2026)
- Anthropic — Claude Code costs documentation (auto-compact window) (fetched 2026-08-17)
- Nous Research — hermes-agent, agent/context_compressor.py (local checkout v0.20.0, 2026-08-17)
- Letta — Context Constitution and Letta documentation (fetched 2026-08-17)