Files
agents/docs/prompt-cache-benchmark.md
Danny AvilaandGitHub 136401703c ⚑ feat: Single Tail Prompt-Cache Breakpoint (#245)
* ⚑ feat: Single Tail Prompt-Cache Breakpoint

Replace the rolling "last two user messages" prompt-cache strategy with a
single breakpoint anchored on the conversation tail, mirroring the approach
used by Claude Code. Anthropic/OpenRouter now place exactly one ephemeral
cache_control marker on the last cacheable block of the final non-synthetic
message; Bedrock places a single cachePoint via the new
addBedrockTailCacheControl. Because the marker always rides the true tail,
the whole prefix is written once and read back as history grows append-only,
instead of re-writing large spans every step.

- Add addTailCacheControl / addBedrockTailCacheControl (single tail marker),
  skipping thinking blocks and synthetic skill/meta messages as anchors and
  stripping all stale markers in one pass.
- Wire Graph (Anthropic, OpenRouter, Bedrock), AgentContext system-runnable
  body path, and summarization to the tail strategy by default.
- Keep legacy addCacheControl / addBedrockCacheControl exported for
  compatibility; update affected tests and add cache.tail.test.ts.

* 🩹 fix: Hoist Bedrock cachePoint out of toolResult body for tail breakpoint

The single tail prompt-cache breakpoint frequently anchors on a tool
result, since agent-loop conversations end with a tool turn before the
next model call. addBedrockTailCacheControl writes the cachePoint into
the tool message content, but the Converse converter wrapped the entire
content (cachePoint included) inside toolResult.content.

A cachePoint is a message-level ContentBlock, not a ToolResultContentBlock.
Bedrock does not reject the nested form β€” it silently drops the breakpoint
(verified live: cache_creation/cache_read both stay 0), so the tail
strategy produced ZERO caching for the most common agent-loop shape.

Hoist any cachePoint out of toolResult.content to a message-level sibling
after the toolResult block β€” the only position Bedrock honors. Live
Bedrock Converse now shows the tool-result tail writing the prefix on
turn 1 (cache_creation) and reading it back on turn 2 (cache_read),
matching the Anthropic-direct behavior.

- Hoist cachePoint(s) in convertToolMessageToConverseMessage.
- Add toolResultCachePoint.test.ts (converter hoist + end-to-end).
- Add cache.tail.test.ts case for a trailing string tool-result tail.

* 🩹 fix: Keep tail cache breakpoint on a block that survives conversion

Two edge cases dropped the single tail breakpoint before the model call,
silently regressing to zero message caching (legacy marked human messages,
which avoided both paths):

1. Foreign reasoning tail (Anthropic/OpenRouter): isTailCacheableBlock only
   excluded native `thinking`/`redacted_thinking`, so on a cross-provider
   handoff the marker could anchor on a `reasoning_content`/`reasoning`/
   `think` block β€” which _convertMessagesToAnthropicPayload drops on
   assistant turns. The only breakpoint vanished. Now exclude foreign
   reasoning types from tail anchoring so the marker lands on a surviving
   text/tool block.

2. Thinking-fold ordering: the tail marker was placed before
   ensureThinkingBlockInMessages, which folds a trailing non-thinking AI→Tool
   chain into a `[Previous agent context]` HumanMessage whose builder copies
   text but not cache_control/cachePoint. Move the provider-specific tail
   cache insertion (Anthropic, Bedrock, OpenRouter) to run LAST β€” after
   thinking normalization and orphan sanitization β€” so it anchors on the
   final message list.

Verified by inspecting the final _convertMessagesToAnthropicPayload output:
the breakpoint now survives in both cases (and a guard test asserts the old
mark-before-fold order loses it).

- Exclude reasoning_content/reasoning/think in isTailCacheableBlock.
- Reorder tail cache insertion after ensureThinkingBlock/sanitizeOrphan in Graph.
- Add tailCacheConversion.test.ts and foreign-reasoning cases in cache.tail.test.ts.

* 🩹 fix: Harden tail prompt-cache anchor against dropped/stripped tails

Three more cases where the single tail breakpoint failed to reach the model;
all stem from anchoring on a volatile tail that a later stage drops/rewrites.

1. input_json_delta anchor (Anthropic/OpenRouter): persisted partial tool-input
   deltas are dropped by _convertMessagesToAnthropicPayload (input is restored
   onto the tool_use block). Anchoring the marker there lost it. Excluded
   input_json_delta from tail anchoring (joins the reasoning types), renaming
   the set to NON_ANCHORABLE_BLOCK_TYPES.

2. toolOutputReferences annotation (functional regression): prompt caching
   rewrites a string ToolMessage tail into a text-block array to host its
   marker; annotateMessagesForLLM only applied the live `[ref: …]` annotation
   to STRING tool content, so the common tool-result tail silently lost its
   reference marker once cached. annotateMessagesForLLM now projects the live
   ref (and unresolved warning) onto array tool content too.

3. assistant-prefill strip (Claude 4.6+): stripUnsupportedAssistantPrefill pops
   a trailing assistant prefill right before the API call; if the only tail
   breakpoint rode it, message caching was lost. It now re-anchors the
   breakpoint onto the new tail (only when one was actually removed, so
   caching-off requests stay untouched), reusing addTailCacheControl to honor
   the same exclusions.

Tests: stripPrefillCache.test.ts (re-anchor); array live-ref cases in
annotateMessagesForLLM.test.ts; input_json_delta is covered by the
NON_ANCHORABLE_BLOCK_TYPES exclusion. tsc + lint clean.

* 🩹 fix: Hoist Anthropic tool_result cache_control onto the top-level block

The single tail breakpoint frequently anchors on a tool result. For a string
ToolMessage tail, addTailCacheControl rewrites it to a text-block array carrying
cache_control, and _ensureMessageContents nests that block inside
tool_result.content. The Anthropic API currently honors that nested marker β€”
verified live with an isolated, system-prompt-free large tool result (control
no-marker => cache_creation 0; nested marker => 10232 written then read) β€” so it
is not broken today. But Anthropic documents the top-level messages.content
block as the cacheable position and does not document sub-content caching, so
relying on the nested form is fragile.

Hoist any cache_control off the inner tool-result content onto the generated
tool_result block itself (mirrors the Bedrock cachePoint hoist). Live-verified
end to end: control no-marker => cache_creation 0; hoisted marker => 12354
written on turn 1, read on turn 2.

- Add hoistToolResultCacheControl; apply it in _ensureMessageContents.
- tailCacheConversion.test.ts now asserts the marker lands on the tool_result
  block, not nested.

* 🩹 fix: Keep orphan sanitization enabled for prompt-cached sends

Moving the tail cache marker to run after sanitizeOrphanToolBlocks (so the
marker survives the thinking fold) had a side effect: the marker no longer
reassigns finalMessages before the `needsOrphanSanitize` gate is evaluated. For
a prompt-cached Anthropic/Bedrock send whose pruner returned the context
unchanged (finalMessages === messagesToUse), the gate went false and orphaned
AI/tool pairs from persisted history could reach the provider and fail
structural validation β€” whereas the pre-move code always reassigned first.

Compute the prompt-cache strategy up front and add `willAddTailCache` to the
sanitize gate, so cached sends are cleaned before the marker is applied
(restoring the pre-move guarantee). Collapses the cache-insertion branch to the
same up-front booleans.

* 🩹 fix: Orphan-sanitize system-runnable prompt-cached sends too

The previous gate used "this node will add the marker" (which excludes the
system-runnable path via !systemRunnable). But when a system runnable owns the
system prompt, AgentContext still adds the body cache marker β€” so those are
cached sends that must be orphan-sanitized as well. With prompt caching +
system runnable + a pruner that returned the context unchanged, orphaned
AI/tool pairs from persisted history could still reach the provider.

Track two separate facts: `providerPromptCacheEnabled` (caching is on for the
provider at all β€” drives orphan cleanup, system-runnable included) vs. the
node-adds-the-marker condition (Anthropic/OpenRouter minus systemRunnable, or
Bedrock β€” drives the insertion). The sanitize gate now uses the former.

* 🩹 fix: Break import cycle from the prefill re-anchor

The P3-1 re-anchor imported addTailCacheControl from @/messages/cache into the
Anthropic converter, closing a cycle:
  messages/format.ts -> llm/anthropic/utils/message_inputs.ts
    -> messages/cache.ts -> messages/format.ts
which the bundler's circular-dependency check (npm run build:dev) flags.

Replace the cross-module reuse with a small local re-anchor that operates on the
already-converted Anthropic payload. This is also more correct: at that stage
the converter has already dropped foreign-reasoning / input_json_delta blocks,
so only native thinking blocks need excluding, and the post-strip tail is always
a user message. Live-reverified: turn1 cache_creation=6264, turn2 read=6264.

* πŸ“Š test: Live reproducible prompt-cache benchmark (tail vs legacy)

Add a committed, live benchmark that empirically justifies the single tail
breakpoint over the legacy "last two user messages" strategy, plus a doc with
representative results.

bench-prompt-cache.ts replays three realistic harness shapes (agent tool loop,
multi-turn chat, realistic agent) under BOTH strategies over the same
conversations in separate cache namespaces, against a real provider, and reports
per-call cache token breakdowns. `fresh` (uncached, full-price input) is derived
provider-agnostically from total_tokens-output_tokens minus the cache buckets,
since Anthropic folds cache tokens into input_tokens while Bedrock reports them
separately.

Result (live, claude-sonnet-4-5): the tail strategy is cheaper in every scenario
on both Anthropic and Bedrock. Legacy reprocesses tens of thousands of
full-price tokens in any tool-bearing conversation (its lone user-message marker
leaves the growing transcript uncached); tail reduces that to ~0 and reads the
prefix back. Effective cost βˆ’30..βˆ’38% (Anthropic), βˆ’9..βˆ’15% (Bedrock); even
legacy's best case (frequent user messages) ties-or-wins.

- src/scripts/bench-prompt-cache.ts (excluded from build/CI; real paid calls)
- npm run bench:cache [-- --provider bedrock|anthropic --rounds N --model id]
- docs/prompt-cache-benchmark.md

* πŸ“Š test: Add post-compaction scenario to the prompt-cache benchmark

Covers the two transcript-mutating harness behaviors raised in review:

- Tool truncation: a non-issue for caching β€” applied once at tool-exec with a
  model-fixed (turn-invariant) cap by the already-tested, deterministic
  truncateToolResultContent, so a truncated result is a stable prefix block.
  Documented; no separate scenario needed (existing tool-loop already exercises
  tool results in the cached prefix).
- Compaction (summarization): add a post-compaction scenario β€” a few pre-
  compaction tool rounds, a head→summary swap (one-time cache miss for any
  strategy), then continued tool rounds. Confirms the tail strategy
  re-establishes append-only caching on the new summary-headed prefix.

Live result (claude-sonnet-4-5): tail wins 4/4 scenarios on BOTH Anthropic and
Bedrock. Post-compaction is among the largest wins (Anthropic effective βˆ’41%,
read +76%) because after compaction the summary is the only user message, so
legacy re-sends all continued tool work uncached (fresh 63k β†’ 42).

docs/prompt-cache-benchmark.md updated with the 4-scenario tables and a
truncation/compaction section.
2026-06-17 13:45:15 -04:00

7.2 KiB
Raw Permalink Blame History

Prompt-cache strategy benchmark

This documents why the default prompt-cache strategy is a single tail breakpoint anchored on the conversation tail (the Claude Code approach), rather than the legacy "last two user messages" markers β€” and how to reproduce the comparison live against a real provider.

Why the tail strategy wins

A prompt-cache breakpoint caches everything before it; the provider then reads back the longest matching cached prefix on the next call, automatically, regardless of where that call's own breakpoints sit.

  • Legacy pins markers on the last two user messages. In an agent tool loop there is often only one user message for many turns, so the only breakpoint sits at the top of the conversation. Every assistant/tool turn appended afterwards falls outside the cached prefix and is re-sent uncached (full price) on every subsequent call. Cache write/fresh ≫ read.
  • Tail rides the true tail, so the transcript is written to cache once and read back as history grows append-only. Freshly appended turns enter the cached prefix on the next call instead of being reprocessed.

This is the dominant agent shape (one request β†’ many tool calls), which is exactly where the legacy approach degrades hardest.

Truncation and compaction

Two harness behaviours mutate the transcript rather than append to it, so they deserve explicit treatment:

  • Tool-output truncation is applied once, at tool-execution time (ToolNode via truncateToolResultContent) with a cap derived from the model's fixed context window, and the truncated string is what's persisted. It is a pure, deterministic function (covered by truncation.test.ts) and the cap does not vary turn to turn, so a truncated result is a stable block in the prefix β€” it never re-truncates differently and so never busts the cache.
  • Compaction (summarization) replaces the head with a durable summary (AgentContext.summaryText, re-injected identically each turn). The compaction event is a one-time cache miss for any strategy β€” the cached prefix genuinely changed. Afterwards the summary is the new stable head and the tail strategy re-establishes append-only caching over the continued transcript. The benchmark's post-compaction scenario exercises exactly this transition, and it is one of the largest wins (after compaction the summary is the only user message, so legacy re-sends all continued tool work uncached).

Metric

For each model call the provider reports a token breakdown. Summed per scenario:

  • read β€” tokens served from cache (higher is better)
  • write β€” tokens written to cache (cache_creation)
  • fresh β€” uncached input processed at full price; this balloons when caching fails to cover the transcript (lower is better)
  • effective β€” a cost proxy in input-token-equivalents using the published multipliers: read Γ—0.1 + write Γ—1.25 + fresh Γ—1.0 (lower is better)

fresh is computed provider-agnostically as (total_tokens βˆ’ output_tokens) βˆ’ cache_read βˆ’ cache_creation. This matters because the two providers report input_tokens differently: Anthropic folds the cached tokens into input_tokens, while Bedrock reports input_tokens as the fresh delta only with the cache buckets separate. Deriving fresh from total_tokens is correct on both.

Representative results

Live, claude-sonnet-4-5, rounds=6 (exact counts vary run to run; the direction is stable). effective is the headline β€” lower is cheaper.

Anthropic

Scenario strategy read write fresh effective
Agent tool loop (1 user turn, N tool rounds) legacy 92,348 23,087 44,705 82,799
tail 129,823 30,284 33 50,870 (βˆ’39%)
Multi-turn chat (frequent user messages) legacy 90,478 23,662 21,595 60,220
tail 118,765 25,004 18 43,150 (βˆ’28%)
Realistic agent (user turns + tool rounds) legacy 498,344 39,514 50,635 149,862
tail 545,327 43,202 90 108,625 (βˆ’28%)
Post-compaction (summary head + tool loop) legacy 69,852 40,538 63,346 121,004
tail 123,118 47,576 42 71,824 (βˆ’41%)

Bedrock (Converse)

Scenario strategy read write fresh effective
Agent tool loop (1 user turn, N tool rounds) legacy 122,940 24,588 21,633 64,662
tail 121,518 28,623 33 47,964 (βˆ’26%)
Multi-turn chat (frequent user messages) legacy 119,560 25,163 18 43,428
tail 104,555 22,162 18 38,176 (βˆ’12%)
Realistic agent (user turns + tool rounds) legacy 495,826 38,003 27,538 124,624
tail 545,327 43,202 90 108,625 (βˆ’13%)
Post-compaction (summary head + tool loop) legacy 96,139 35,287 22,808 76,531
tail 123,118 47,576 42 71,824 (βˆ’6%)

The tail strategy is cheaper (lower effective) in every scenario on both providers (4/4 each). The clearest signal is fresh: the legacy approach reprocesses tens of thousands of full-price tokens in any tool-bearing conversation, which the tail strategy reduces to near zero. Even the legacy strong case (frequent user messages, no tools) is a tie-or-win for the tail strategy.

Reproduce

Requires real credentials in .env (or point BENCH_ENV_FILE at one): ANTHROPIC_API_KEY for Anthropic, BEDROCK_AWS_ACCESS_KEY_ID / BEDROCK_AWS_SECRET_ACCESS_KEY (and a region) for Bedrock. It makes real, paid API calls and is not a unit test (CI never runs it).

npm run bench:cache                          # Anthropic (default)
npm run bench:cache -- --provider bedrock     # Bedrock Converse
npm run bench:cache -- --rounds 10 --model claude-sonnet-4-5

Each scenario runs the same conversation under both strategies in separate cache namespaces (unique per run), then prints the per-strategy totals and the delta.