fix: preserve Bedrock prompt-cache hits across turns (deterministic ordering + byte-stable compaction)#697
Merged
Conversation
…k prompt-cache hits Tool-content truncation previously ran on every session restore behind a sliding protected-turns window, so each new turn re-mutated the turn that just aged past the window. Bedrock prompt caching requires an exact prefix match, so this forced a full prefix cache re-write (~$2.5/MTok on a 35k-150k prefix) nearly every turn — costing far more than the read tokens truncation saved (evidence: prod session aecd387d, inter-turn prefix shrinkages of -382/-1035/-1513 with cacheRead=0 inside the cache TTL). Redesign: truncation is now driven only by a persisted truncation_anchor in the compaction state (sessions-metadata `compaction` attribute): - The anchor moves when the checkpoint advances (update_after_turn), where the slice already pays the single cache re-write — one mutation per compaction event instead of one per turn. - It also advances opportunistically at restore when more than cache_ttl_seconds (default 300s, AGENTCORE_MEMORY_COMPACTION_CACHE_TTL_SECONDS) have passed since the previous turn: the cache entry has expired anyway, so pending truncations are applied for free. - Legacy state records without the field default the anchor to the checkpoint, so retained history stops being mutated immediately on upgrade. - The compaction-failure path in initialize() now resets _compaction_state_loaded so update_after_turn re-loads persisted state instead of overwriting checkpoint/anchor with defaults. Removes the now-dead _find_protected_indices sliding-window helper and adds tests/agents/main_agent/session/test_compaction_stability.py asserting agent.messages is byte-identical across consecutive restores whenever no compaction-state change occurs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cache Skill records reached the AgentSkills <available_skills> system-prompt block in nondeterministic order, changing the prompt between turns of the same session and invalidating the Bedrock prompt cache (exact-prefix match) — forcing full cache re-writes on turns well inside the TTL. Two sources, fixed at three layers: - batch_get_skills returned raw DynamoDB batch_get_item response order; now sorted by skill_id. - resolve_user_permissions built grant unions as sets and returned list(set) — iteration order varies per process via hash randomization; tools/models/skills now sorted. - build_skills_runtime sorts records before constructing AgentSkills as defense in depth at the injection point. Regression tests force a descending batch_get_item response order and a reversed fetch order, both verified to fail without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ad the stable prefix CacheConfig(strategy="auto") places exactly one message-level cachePoint; when its lookup misses, nothing is read and the whole prefix re-writes at the cache-write premium. One proven miss mode is structural: Anthropic's cache lookback checks only ~20 content blocks behind the breakpoint, so a wide parallel tool fan-out (18 parallel calls = ~38 new blocks) pushes the previous checkpoint out of range — prod session aecd387d observed cacheRead=0 / cacheWrite=134k mid-turn (~$0.34). Now the request carries 3 of Bedrock's max-4 cachePoints: - toolConfig tail via cache_tools="default" - system tail via SystemContentBlock list with trailing cachePoint (built in AgentFactory; the cache_prompt config key is deprecated) - last-user-message point via the existing auto strategy (which strips only message-level points, never system/tools ones) Both new points are gated on ModelConfig.bedrock_cache_points_supported() (mirrors Strands' _cache_strategy predicate) because unlike auto mode they would be sent verbatim to non-Anthropic models and rejected. Verified live on global.anthropic.claude-sonnet-5 via ConverseStream: simulated message-level miss reads the 6.5k-token system+tools prefix from cache (cacheRead=6497, cacheWrite=83) instead of re-writing it. CountTokens accepts the cachePoint-bearing request, so context attribution is unaffected. Position/budget test asserts exactly 3 cachePoints. Upstream: strands-agents/harness-sdk#3348 proposes auto mode keep a rolling pair of message cachePoints so fan-outs stay within the lookback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… call
A $1.60 prod conversation audit (session aecd387d) showed 75% of spend was
avoidable Bedrock prompt-cache re-writes, and diagnosing the causes took
hours of manual forensics against raw DynamoDB rows. This makes the whole
class measurable end to end:
- PrefixFingerprintHook (BeforeModelCallEvent) hashes the three cacheable
prefix components per model call — toolConfig (order-sensitive canonical
JSON), effective system prompt (captured after AgentSkills injection),
and message history excluding the newest message. The stream coordinator
persists entry N on the turn's Nth assistant-message cost row, so a miss
is diagnosable with a column diff instead of row forensics.
- Write-time cacheStatus per cost row (first_write | hit | miss_ttl_expired
| miss_avoidable | uncached) derived from the session's previous C# row
(one GSI read), plus wastedUsd for avoidable misses priced at the
cache-write premium over cache-read from the row's own pricingSnapshot.
Turn rows now write sequentially (was parallel) so each call classifies
against its true predecessor.
- Session-row rollups next to totalCost: totalCacheReadTokens,
totalCacheWriteTokens, avoidableMissCount, wastedUsd — cache-efficiency
ratio for lists/admin without scanning cost rows.
- Admin cost anatomy: GET /admin/costs/sessions/{sessionId}/calls
(require_admin) returns chronological per-call rows with token splits,
cost, cacheStatus, and fingerprints, plus session-level cache summary.
- CloudWatch EMF per call (CacheReadTokens / CacheWriteTokens /
AvoidableMiss / WastedUsd) via a raw-JSON stdout logger — dashboard and
alarm on fleet cache-write share with no SDK calls or extra IAM.
- CI determinism guard: builds the chat-agent surface twice from shuffled
skill/role/tool record orders (RBAC merge -> skills runtime -> Agent ->
AgentSkills injection) and asserts identical system-prompt and toolConfig
fingerprints; verified to fail when the skill-ordering sort is removed.
Complements the sorting + byte-stability fixes from
feature/prompt-cache-stability (merged in).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PROMPT_CACHE_OBSERVABILITY_ENABLED=false disables the fingerprint hook, per-call cacheStatus derivation (and its GSI read), and EMF emission — default ON per house convention; empty string stays enabled. Raw cacheRead/cacheWrite token rollups are unaffected (usage passthrough, not derived). CLAUDE.md now encodes the determinism + byte-stability contract and the fingerprint-based debugging recipe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 20, 2026
philmerrell
added a commit
that referenced
this pull request
Jul 20, 2026
…he-followup-breadcrumbs chore: PR #697 follow-up breadcrumbs + token-cost-effectiveness tenet
This was referenced Jul 20, 2026
Merged
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.
Problem
A cost audit of a $1.60 prod conversation (Chief of Staff agent + Memory Space, session
aecd387d…1166) found 75% of spend was Bedrock prompt-cache re-writes ($1.19 of $1.60). 6 of 7 user turns started withcacheRead=0and a full prefix re-write at $2.50/MTok — four of them well inside the 5-minute cache TTL. Three distinct miss mechanisms were identified:batch_get_skillsreturns DynamoDBbatch_get_itemorder (arbitrary), and RBAC effective permissions are materialized from unorderedsets. These lists feed the<available_skills>system-prompt block andtoolConfig— an order flip between turns fails Bedrock's exact-prefix match. Confirmed in prod: persistedagent_skills.last_injected_xmlflip-flopped between turns, correlating with sub-TTL misses._apply_compactiontruncated tool results on every restore with a sliding protected window, mutating older history every turn (observed inter-turn prefix shrinkages of −382/−1,035/−1,513 tokens). Truncation economics are backwards at this scale: clamping ~1k tokens saves ~$0.0002/call in reads but costs $0.08–$0.35 per forced re-write.Fix (4 commits)
1. Deterministic ordering —
batch_get_skillssorts byskill_id;build_skills_runtimere-sorts defensively;AppRoleServicesortstools/models/skillsin effective permissions.2. Byte-stable compaction (truncation anchor) — new persisted
truncation_anchor: truncation applies only below the anchor, making restored history a pure function of (stored history, persisted state). The anchor advances only at checkpoint advances (the slice already pays one re-write) or when the prompt cache has already expired (cache_ttl_seconds, default 300s — the re-write is free then). Legacy sessions default the anchor to their checkpoint.3. Layered cachePoints —
cache_tools="default"plus a system-prompt cachePoint (viaSystemContentBlocklist; 3 of Bedrock's 4 allowed points, position-asserted by test). A message-level miss now reads the stable ~28k-token tools+system prefix at $0.20/MTok instead of re-writing it — this is the floor protection for miss mode 3, worth ~$0.30 in the audited session. Gated onbedrock_cache_points_supported()(Anthropic-on-Bedrock only) so non-Claude models don't receive verbatim cachePoints they'd reject.4. Prompt-cache observability — makes this whole bug class self-diagnosing:
PrefixFingerprintHook(BeforeModelCallEvent) hashes the three cacheable prefix segments per model call —toolConfigHash,systemPromptHash,historyHash— persisted on eachC#cost row. A miss now attributes itself: system hash changed → ordering/injection drift; toolConfig changed → tool list drift; history changed → mutation; all stable → TTL or lookback.cacheStatusper call (first_write | hit | miss_ttl_expired | miss_avoidable) +cacheGapSeconds+wastedUsd, computed at metadata-write time against the session's previous cost row.totalCacheReadTokens/totalCacheWriteTokens/avoidableMissCount/wastedUsdalongsidetotalCost.GET /admin/costs/sessions/{session_id}/calls(require_admin).AgentCoreStack/PromptCache: CacheReadTokens, CacheWriteTokens, AvoidableMiss) emitted as raw-JSON stdout lines — no agent, no SDK calls.Impact
On the audited conversation, working turn-to-turn caching converts
287k tokens from cache-write to cache-read: **$0.66 saved on a $1.60 conversation (~40%)**, and the ordering bug alone was taxing every multi-turn conversation with >1 skill granted. Layered cachePoints additionally cap the blast radius of any residual message-level miss.Verification
test_compaction_stability.py): same stored history + same persisted state ⇒ byte-identicalagent.messagesacross restores; anchor advance rules; legacy migrationtest_bedrock_cache_points.py), fingerprint hook tests, cacheStatus derivation tests, EMF format tests, determinism guard — 61 new testscacheRead > 0, and any miss row should carry a fingerprint diff explaining itselfFollow-ups (deliberately not in this PR)
backend.ymldeploy ships it)🤖 Generated with Claude Code