anthropics / anthropics/claude-code

[BUG] Turn-boundary prompt-cache misses on Fable 5.1 / Claude 5: 7% → 29% between 2.1.224 and 2.1.252, mechanism captured, fixed in 2.1.259 — with two residuals and one default worth changing

Aperta
#91,707 1 commento 0 reazioni 0 assegnatari Vedi su GitHub
area:core area:cost area:hooks area:plugins bug has repro platform:macos
Lingua principale
Python
Stelle
145k
Fork
23.1k
Metriche di merge delle PR
Metriche PR in attesa

Descrizione

Connecting #83913, #87966, #87215, #87227 and #91514, which I believe are one regression. I captured full `/v1/messages` bodies on both sides of every turn boundary plus the server's `cache_miss_reason` (via the `cache-diagnosis-2026-04-07` beta) on 2.1.252 and again on 2.1.259.

Environment: macOS, OAuth (Max), `claude-fable-5-1[1m]` and `claude-fable-5`, one large agentic monorepo (~30 hooks, background subagents, teammate messages), Aug 20 – Sep 3.

## Symptom, from ~5,300 turn boundaries

| client | turn-boundary misses (<5 min idle) |
|---|---|
| 2.1.224 | 7.2% (n = 4,757) |
| 2.1.252 | **29.2%** (n = 538) |

Mid-turn tool loops: ~0.7% on every version. Misses tier by what survives (`cache_read` at the miss): **T1** read ≈ system+tools only, **T2** read ≈ system+tools+early history. Teammate/subagent-completion-opened turns missed at **10.6×** the typed-turn rate — that is #87215 and the "subagent wake" reports in #87966 / #91514. On Fable 5.1 across two days: 206M cache-write tokens, 96% from 484 such misses.

## Mechanism: hook `additionalContext` is dropped on the next history rebuild (= #83913)

3-arm control, haiku, `--strict-mcp-config`, identical 8-turn spawn-heavy workload, everything else byte-identical:

| arm | misses | cache_creation |
|---|---|---|
| PreToolUse hook emitting `additionalContext`, text changes each call | 7 (1×T1, 6×T2) | 210,497 |
| PreToolUse hook emitting `additionalContext`, text **constant** | 7 (1×T1, 6×T2) | 171,457 |
| no hook | **0** | 85,110 |

Byte diff, request N → N+1, at the user message carrying the spawn tool_results:

```
N : blocks = [tool_result ×5, text ×6]
…"type":"tool_result"},{"text":"\nPreToolUse:Agent hook additional context: …\n","type":"text"}, …
N+1 : blocks = [tool_result ×5]
…\n\n\nThe task tools haven't been used recently… ← re-inlined INTO the last tool_result
```

Every `` block carrying hook context is removed from that message and the between-turn reminder is folded into the last tool_result's text. `system`, `tools`, params, headers: identical. Divergence is always at the previous turn boundary; T1 vs T2 is only *where* in history that message sits. Teammate-opened turns are worst because the completion arrives as a between-turn notification and the rebuild runs against the message most likely to carry hook context from the spawn.

Refuted along the way so nobody re-chases them: string↔block content-shape flip (canonicalized server-side; controlled A/B = full hit), `tools` churn from `ListAgents`/`SendMessage` (byte-identical across a teammate lifecycle), output-style block, per-turn effort statement, MCP tool-list deltas.

## 2.1.259 fixes it

Same project, Fable 5.1, real hooks (one PreToolUse hook still emits `additionalContext`), background subagents + SendMessage + teammate-completion turns:

| session | requests | boundary misses |
|---|---|---|
| A | 20 | 0 — `cache_read` monotonic 98k → 123k |
| B | 37 | 1 (first typed turn); all 6 teammate-opened turns full hits |
| C (real work, 720 req) | 720 | 7: one pasted image (#91705), five `cd`-triggered system-prompt rewrites + one memory-index edit (#91706) |

On 2.1.259 the hook context is rendered as a `role:"system"` turn and survives every rebuild byte-for-byte. Suggest closing #87227 / #87215 / #91514 as duplicates of #83913 fixed in 2.1.259. Workaround for anyone pinned below: don't emit `additionalContext` from PreToolUse hooks; write it to a file the model reads.

## Residual 1 — a pasted image is re-rendered as a file pointer on the next request

Sent as base64 on the turn it's pasted; on the next request the same message becomes `[Image: source: ~/.claude/image-cache/…/1.png]`. Prefix breaks there: on a 232k-token session, read fell to 84k and 159k was rewritten for a 136 KB PNG — and the model no longer has the image (the pointer is text; it doesn't re-read the file). Keeping the block byte-stable would cost ~1.5k *cached* tokens/turn. Filed separately with the receipt: #91705. Workaround: deliver images as a path the model `Read`s (tool results are replayed byte-for-byte and the image stays in context); I built a menu bar app that makes the native screenshot shortcut do that, https://github.com/ArshansGithub/shotpath.

## Residual 2 — a persisting `cd` in Bash rewrites the system prompt and drops the entire cache

The most expensive thing left, and it took a recorder in front of the session to see. Claude Code tracks the shell cwd across Bash calls; on the next system-prompt rebuild it re-resolves project context from the new cwd, and in a monorepo with nested `.claude/` trees the agent-memory root flips and the first system block changes. Captured five times in one session at ~580k tokens each; two `cd` round-trips cost ~$24. Byte diff, mechanism, and the client-side fixes that would close it are in #91706. Worked around with a hook that denies a persisting `cd` and by consolidating the nested trees: zero collapses since.

(Earlier drafts of this issue blamed async notifications and the mid-conversation `role:"system"` fallback; both were timing coincidences and are withdrawn.)

## A second default worth revisiting: `security-guidance` ships enabled and runs an Opus 4.7 call at the end of every turn

Unrelated to caching, found while attributing account usage. The official `security-guidance` plugin is enabled by default with the marketplace. Its Stop hook sends every turn's diff to `claude-opus-4-7` for an LLM review, and its commit hook runs an SDK agent with Read/Grep/Glob on every `git commit`, all on the user's own OAuth token. On an agentic setup (orchestrator + subagents, so many turn-ends per minute) over the last 7 days:

| | |
|---|---|
| Stop-hook runs | 3,943 |
| Opus 4.7 review calls | 3,070 (avg 88k input tokens each, 5m cache tier) |
| findings returned to the model | 0 |
| findings that blocked anything | 0 |
| list-price equivalent | ~$1,390 (~$200/day) |

That was about the same magnitude as the entire Fable 5.1 cache waste above, on the account that then hit its weekly window. The regex layer is cheap and fine; the per-turn LLM review deserves to be opt-in, or at least to default to a small model and skip subagent turns.

## Result

2.1.259 + `promptCacheTtl: "1h"` + hooks not emitting `additionalContext`, measured on a 540-request real session: write share of context 8.7% → 1.7%, input cost per context token 2.2× lower, every thinking block replayed. Three quarters of the remaining writes were the four events above.

Guida per i contributori

Nessuna guida per i contributori indicizzata per questo repository

Direzione di ricerca

Start by comparing captured /v1/messages bodies and cache_miss_reason on 2.1.252 versus 2.1.259, using a PreToolUse hook that emits additionalContext and the described spawn-heavy workload. Check the separate reports #91705 and #91706 for the two residuals. Done means the reported boundary miss mechanism is reproducible or confirmed fixed, with remaining image and cwd rewrites clearly separated.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
bash, git
Ambito
cli, developer-experience, performance
Tipo di issue
Bug
Difficoltà
5/5
Tempo stimato
Più di una settimana
Stato di attività
Ferma
Chiarezza
Da chiarire
Idoneità per principianti
15/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.