Skip to content

fix: preserve Bedrock prompt-cache hits across turns (deterministic ordering + byte-stable compaction)#697

Merged
philmerrell merged 5 commits into
developfrom
feature/prompt-cache-stability
Jul 20, 2026
Merged

fix: preserve Bedrock prompt-cache hits across turns (deterministic ordering + byte-stable compaction)#697
philmerrell merged 5 commits into
developfrom
feature/prompt-cache-stability

Conversation

@philmerrell

@philmerrell philmerrell commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

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 with cacheRead=0 and 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:

  1. Nondeterministic skill/tool ordering. batch_get_skills returns DynamoDB batch_get_item order (arbitrary), and RBAC effective permissions are materialized from unordered sets. These lists feed the <available_skills> system-prompt block and toolConfig — an order flip between turns fails Bedrock's exact-prefix match. Confirmed in prod: persisted agent_skills.last_injected_xml flip-flopped between turns, correlating with sub-TTL misses.
  2. Per-turn tool-content truncation. _apply_compaction truncated 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.
  3. Structural lookback miss on wide tool fan-outs. Strands' auto cache strategy keeps a single message cachePoint (moved every call, all others stripped), and Anthropic's cache lookup checks only ~20 content blocks behind a breakpoint. An 18-way parallel tool batch appended ~38 blocks in one hop → previous checkpoint out of range → full 134k re-write mid-turn, with nothing read because no system/tools checkpoint existed.

Fix (4 commits)

1. Deterministic orderingbatch_get_skills sorts by skill_id; build_skills_runtime re-sorts defensively; AppRoleService sorts tools/models/skills in 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 cachePointscache_tools="default" plus a system-prompt cachePoint (via SystemContentBlock list; 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 on bedrock_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 each C# 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.
  • Derived cacheStatus per call (first_write | hit | miss_ttl_expired | miss_avoidable) + cacheGapSeconds + wastedUsd, computed at metadata-write time against the session's previous cost row.
  • Session rollups: totalCacheReadTokens / totalCacheWriteTokens / avoidableMissCount / wastedUsd alongside totalCost.
  • Admin cost-anatomy endpoint: GET /admin/costs/sessions/{session_id}/calls (require_admin).
  • CloudWatch EMF metrics (AgentCoreStack/PromptCache: CacheReadTokens, CacheWriteTokens, AvoidableMiss) emitted as raw-JSON stdout lines — no agent, no SDK calls.
  • CI determinism guard: builds the agent twice from shuffled skill/tool inputs and asserts identical system-prompt and toolConfig hashes.

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

  • Byte-stability contract tests (test_compaction_stability.py): same stored history + same persisted state ⇒ byte-identical agent.messages across restores; anchor advance rules; legacy migration
  • cachePoint position tests (test_bedrock_cache_points.py), fingerprint hook tests, cacheStatus derivation tests, EMF format tests, determinism guard — 61 new tests
  • Ordering regression tests (shuffled fetch order ⇒ stable output)
  • Session/skills/repository suites: 314 passed; import-boundary tests pass
  • Acceptance after deploy: re-run the audited scenario; every turn-start call within 5 min of the prior turn should show cacheRead > 0, and any miss row should carry a fingerprint diff explaining itself

Follow-ups (deliberately not in this PR)

  • SPA admin page consuming the cost-anatomy endpoint
  • CDK CloudWatch dashboard/alarm on the EMF metrics (keeps this PR backend-only — one backend.yml deploy ships it)
  • Upstream Strands issue: auto cache strategy should keep a rolling pair of message cachePoints so wide parallel tool batches don't outrun the ~20-block lookback
  • ContextOffloader adoption (S3 storage) to shrink tool results at ingestion; MCP server payload trims

🤖 Generated with Claude Code

philmerrell and others added 5 commits July 19, 2026 19:49
…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>
@philmerrell
philmerrell merged commit e7d1a8b into develop Jul 20, 2026
4 checks passed
@philmerrell
philmerrell deleted the feature/prompt-cache-stability branch July 20, 2026 04:00
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
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