fix(inference): one session is one conversation, whatever agent runs a turn - #750
Merged
Conversation
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>
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #741.
The bug
An
@-mention turn and the plain turns around it ran on two different cachedAgentinstances, 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 Dogfoodanswered a question, the next plain turn repliedNOT IN HISTORYwhen 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
historyHashwas 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_keyincludes the system prompt, tools, model and skills hashes. That is correct — those genuinely need differentAgentobjects. But the conversation is not configuration, and nothing said so.initialize()restores history → looks fineinitialize()never re-runs → in-memory list still ends before the mention → stale list wins, silentlyNot 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_conversationpoints a newly built agent at the message list its session is already using.That holds because every site that rebinds
agent.messages— document stripping, content-block sanitizing, compaction slicing, pairing repair — lives insideTurnBasedSessionManager.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, andtest_second_cache_key_for_a_session_shares_the_conversationis 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.mdforbids ($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
extra_toolsearly return — an uncached agent still takes a turn in the thread and must not fork it.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
TurnBasedSessionManager, so two instances for one session means twocompaction_stateobjects persisting to the same DynamoDB row. Same root cause, separate blast radius; filed separately rather than widening this PR.miss_avoidablewaste 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).Deploy
backend.yml— inference-api image → AgentCore Runtime. No CDK.🤖 Generated with Claude Code