* β‘ 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.
7.2 KiB
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.
- Tail strategy:
addTailCacheControl/addBedrockTailCacheControl - Legacy strategy:
addCacheControl/addBedrockCacheControl(still exported for back-compat) - Benchmark:
src/scripts/bench-prompt-cache.ts
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
(
ToolNodeviatruncateToolResultContent) 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 bytruncation.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.