Skip to content

fix(inference): one session is one conversation, whatever agent runs a turn - #750

Merged
philmerrell merged 2 commits into
developfrom
fix/agent-mention-history-fork
Jul 25, 2026
Merged

fix(inference): one session is one conversation, whatever agent runs a turn#750
philmerrell merged 2 commits into
developfrom
fix/agent-mention-history-fork

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Fixes #741.

The bug

An @-mention turn and the plain turns around it ran on two different cached Agent instances, and neither saw the other's messages. The user saw one continuous thread — the SPA renders from the persisted store — but the model was amnesiac in both directions.

Reproduced on dev (e5e8b259-1780-4179-8ebe-38c57d3709a5): after @Docx Dogfood answered a question, the next plain turn replied NOT IN HISTORY when asked to quote it back. A second mention likewise could not see the plain turns in between.

The prompt-cache fingerprints show it without any prompting: the swap-back turn's historyHash was byte-identical to the mention turn's, and it wrote 71 tokens — impossible if the mention exchange were in the prompt.

Root cause

_create_cache_key includes the system prompt, tools, model and skills hashes. That is correct — those genuinely need different Agent objects. But the conversation is not configuration, and nothing said so.

  • mention turn → different key → cache miss → new agent → initialize() restores history → looks fine
  • next plain turn → key reverts → cache hitinitialize() never re-runs → in-memory list still ends before the mention → stale list wins, silently

Not mention-specific in principle; any mid-thread cache-key change forks the same way. It stayed latent because the SPA blocks mid-thread model switches. The @-mention is simply the first path that changes agent identity inside a live thread.

The fix

_adopt_session_conversation points a newly built agent at the message list its session is already using.

⚠️ Shared by reference, deliberately — this is the part to review. Copying would fix only the direction that already works (the miss). The turn that goes stale is a cache hit, where nothing runs and there is nothing to copy. Aliasing is what makes the mention turn's appends visible to the instance the next turn will hit.

That holds because every site that rebinds agent.messages — document stripping, content-block sanitizing, compaction slicing, pairing repair — lives inside TurnBasedSessionManager.initialize() and therefore runs before adoption. After construction the list is only appended to. A future compaction that rebinds mid-life would break the alias, and test_second_cache_key_for_a_session_shares_the_conversation is what catches that.

Why not re-restore on a stale hit

It was the obvious alternative and it is worse for a specific reason: restored history passes through the sanitizers and pairing repair, while accumulated in-memory history does not. So the same conversation can serialize differently depending on the path that produced it — a prefix byte change on an arbitrary turn, which is exactly what the prompt-cache contract in CLAUDE.md forbids ($2.50/MTok on a re-write). Aliasing re-serializes nothing, so the cached prefix is untouched. It also costs no extra Memory read per turn.

Details worth a look

  • Adoption runs before the extra_tools early return — an uncached agent still takes a turn in the thread and must not fork it.
  • A length guard keeps a live instance that trails Memory from dragging a newer restored history backwards. ⚠️ The reverse comparison would be a bug: compaction legitimately makes a restored list shorter than the live one, so "restored is shorter" is normal and must still adopt.
  • Concurrency is covered by the single-flight session lease (one turn per session at a time). Separate replicas share no cache, so cross-process behavior is unchanged either way.

Tests

  • test_second_cache_key_for_a_session_shares_the_conversation — the repro, committed first as a strict xfail (ffee261b) so the bug was pinned executably before the fix, then flipped. It asserts on the conversation, not instance identity, so it stays valid if the mechanism is ever changed.
  • test_adoption_keeps_the_longer_history_when_the_live_instance_trails — the guard.
  • test_adoption_does_not_reach_across_sessions — scoping.

Full backend suite: 5230 passed, 3 skipped.

Follow-ups, not in this PR

  • The same fork exists in compaction state — each agent builds its own TurnBasedSessionManager, so two instances for one session means two compaction_state objects persisting to the same DynamoDB row. Same root cause, separate blast radius; filed separately rather than widening this PR.
  • The cost dashboard still books deliberate mention swaps as miss_avoidable waste with no dimension marking them (comment on @-mention forks conversation history: the mention turn and the plain turns run on two different cached agents #741).
  • The spec's "two prompt-cache re-writes ≈ $0.25 per mention" should be re-measured after this lands — the cheap swap-back measured earlier was partly an artifact of this bug.

Deploy

backend.yml — inference-api image → AgentCore Runtime. No CDK.

🤖 Generated with Claude Code

philmerrell and others added 2 commits July 25, 2026 15:25
An `@`-mention turn and the plain turns around it run on two different cached
`Agent` instances, and neither sees the other's messages. The user sees one
continuous thread — the SPA renders from the persisted store — but the model
answers "NOT IN HISTORY" about a turn on screen. Measured on dev session
e5e8b259-1780-4179-8ebe-38c57d3709a5.

The test models the round trip that produces it: plain → mention → plain, with a
`store` standing in for AgentCore Memory. The distinction it has to preserve is
why the bug is invisible in production logs — the mention turn is a cache MISS,
so it restores and legitimately sees prior history; the plain turn after it is a
cache HIT, restores nothing, and is stale. A first draft asserted the mention
turn was empty, which fails for the wrong reason and would have passed once
anyone made a fresh agent restore.

Asserts on the conversation rather than instance identity, so it stays valid
whichever fix wins: reuse one instance, hand the message list between instances,
or re-restore on a stale hit.

`strict=True` so it fails loudly the moment the fix makes it pass.

Refs #741

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a turn

The agent cache keys on *configuration* — system prompt, tools, model, skills —
which is right: those need different `Agent` objects. The conversation is not
configuration, and nothing enforced that.

So an `@`-mention forked the thread. The mention turn missed the cache, built a
second agent, restored history and looked fine; the next plain turn reverted the
key and cache-*hit* the original instance, whose in-memory list still ended
before the mention. `initialize()` never re-runs on a hit, so the stale list won
silently: the model answered "NOT IN HISTORY" about a turn the user could see on
screen, because the SPA renders from the persisted store. Symmetric, too — a
second mention could not see the plain turns in between.

`_adopt_session_conversation` points a newly built agent at the list its session
is already using.

⚠️ Shared by reference, deliberately. Copying would fix only the direction that
already works — the miss. The turn that goes stale is a cache *hit*, where
nothing runs and there is nothing to copy. Aliasing is what makes the mention
turn's appends visible to the instance the next turn hits. Safe because every
site that rebinds `agent.messages` (document stripping, content-block sanitizing,
compaction slicing, pairing repair) lives inside
`TurnBasedSessionManager.initialize()` and runs before adoption; after
construction the list is only appended to.

Re-restoring on a stale hit was the alternative and is worse: restored history
passes through the sanitizers and pairing repair while accumulated history does
not, so one conversation can serialize two ways depending on the path — a prefix
byte change on an arbitrary turn, which is what the prompt-cache contract
forbids. Aliasing re-serializes nothing, so the cached prefix is untouched.

Adoption runs before the `extra_tools` early return: an uncached agent still
takes a turn in the thread and must not fork it. A length guard keeps a live
instance that trails Memory from dragging a newer restored history backwards;
the reverse comparison would be wrong, since compaction legitimately shortens a
restored list.

Concurrency is covered by the single-flight session lease — one turn per session
at a time. Separate replicas share no cache, so cross-process divergence is
unchanged by this either way.

Fixes #741

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant