Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .docket.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ change_types: [chore, docs, feat, fix, refactor, perf]
# Pins apply per agent and per field; a line you leave out keeps its built-in value. A `default:`
# block applies to every harness that has no pin of its own for that agent. Model IDs and effort
# tokens pass through unchecked; `effort: auto` writes no effort line, so the harness picks.
# An entry may also set `cache_ttl`, how long Claude Code keeps that agent's prompt cache
# (`1h`, `5m`). The built-in table sets `1h` on claude's implement-next only: it waits on builds
# and tests longer than the 5-minute default, and the longer TTL roughly halves its cost.
# `cache_ttl: auto` removes the pin. Cursor, Codex, and opencode have no such setting, so install
# leaves it out of their agent files and warns.
# Re-run `docket install` after editing. Set pins only in the global config: an `agents:` pin in
# .docket.yml or .docket.local.yml, even one that repeats the built-in value, makes docket refuse
# to change the repository until you move it to the global config.
Expand All @@ -286,7 +291,7 @@ change_types: [chore, docs, feat, fix, refactor, perf]
# build-premium: { model: claude-opus-5, effort: medium }
# build-max: { model: claude-opus-5, effort: high }
# finalize-change: { model: claude-opus-5, effort: low }
# implement-next: { model: claude-opus-5, effort: medium }
# implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h }
# integration-repair: { model: claude-opus-5, effort: medium }
# plan-writer: { model: claude-opus-5, effort: high }
# rebase-resolver: { model: claude-opus-5, effort: medium }
Expand Down
11 changes: 9 additions & 2 deletions agents/harness-defaults.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort table.
# agents/harness-defaults.yml — docket's shipped per-harness agent model/effort/cache-TTL table.
#
# This is program data, not a user configuration file. The docket binary compiles the same table
# into its built-in agent defaults (internal/config/defaults.go), and a test keeps the two equal.
Expand All @@ -12,6 +12,8 @@
# because one model ID never fits every harness's roster;
# - keys are wrapper short names (build-economy, not docket-build-economy);
# - every listed entry supplies both model and effort;
# - an entry may add an optional `cache_ttl` (a prompt-cache TTL; only Claude Code has the
# setting). It ships on claude's implement-next alone; see the comment on that row;
# - every shipped harness (claude, cursor, codex, opencode) carries a complete 17-agent block.
#
# Model IDs and effort tokens are opaque passthrough values (ADR-0015). Docket keeps no vendor
Expand All @@ -32,7 +34,12 @@ agents:
build-premium: { model: claude-opus-5, effort: medium }
build-max: { model: claude-opus-5, effort: high }
finalize-change: { model: claude-opus-5, effort: low }
implement-next: { model: claude-opus-5, effort: medium }
# implement-next waits on build workers and the test suite for longer than Claude Code's
# 5-minute subagent prompt cache lasts, so most of its cost went to writing its whole context
# into the cache again after each wait. A 1-hour TTL roughly halves its cost per run. The
# other agents rarely wait five minutes between turns, and a 1-hour cache write costs more
# than a 5-minute one, so they keep Claude Code's default.
implement-next: { model: claude-opus-5, effort: medium, cache_ttl: 1h }
integration-repair: { model: claude-opus-5, effort: medium }
plan-writer: { model: claude-opus-5, effort: high }
rebase-resolver: { model: claude-opus-5, effort: medium }
Expand Down
10 changes: 10 additions & 0 deletions docs/install/models-and-effort.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ pointing it at another harness's model, where Claude Code's effort tiers do not
auto`, which drops the effort line entirely so the agent inherits the model default. Omitting the
`effort:` key instead *keeps* the built-in effort, so `auto` is the explicit way to drop it.

**Prompt-cache TTL (Claude Code).** An agent entry may also set `cache_ttl`, how long Claude Code
keeps that agent's prompt cache. docket ships `cache_ttl: 1h` on Claude Code's `implement-next`
only. It waits on builds and tests longer than the 5-minute default cache lasts, so it kept paying
to write its whole context into the cache again, and the 1-hour TTL roughly halves its cost per
run. The other agents rarely wait that long, and a 1-hour cache write costs more, so they keep the
default. `cache_ttl: auto` removes a pin. Cursor, Codex, and opencode have no such setting: install
leaves the field out of their wrappers and warns. The [agent layer
reference](../../skills/docket-convention/references/agent-layer.md#prompt-cache-ttl) covers the
measurements, Claude Code's machine-wide alternatives, and which one wins.

**Finding model IDs.** A `model:` value is passed to the harness verbatim — docket never validates
it — so use exactly the IDs your harness reports:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Per-agent prompt-cache TTL pin — design

Change 0546 — Per-agent prompt-cache TTL pin, shipped 1h for implement-next

## Summary

Add an optional `cache_ttl` field to docket's per-agent pin table, next to `model` and `effort`.
The Claude Code adapter renders it as `experimental: { cacheTtl: <value> }` in the generated
wrapper's frontmatter. Docket ships `cache_ttl: 1h` for the claude `implement-next` entry only,
because the cost data below shows that agent losing about half its spend to cache expiry, while
every other agent would get more expensive under a 1-hour TTL.

## Research findings (2026-10-09)

Source: every subagent transcript under `~/.claude/projects/-Users-homer-dev-docket/*/subagents/`
(the `usage` block of each assistant message, deduplicated by message id). Costs count only
`claude-opus-5-5` turns, at Opus 5.5 prices: input $4/M, 5-minute cache write $5/M, 1-hour cache
write $8/M, cache read $0.20/M, output $20/M.

### Where the money goes

| Agent (pinned effort) | Runs | Turns/run | Avg peak context | Opus $/run | Total | Cache writes that are expiry rewrites |
|---|---|---|---|---|---|---|
| implement-next (low) | 95 | 65 | 173k | $5.35 | **$508** | **87%** (461 rewrites) |
| build-standard (medium) | 427 | 24 | 97k | $0.96 | $410 | 5% |
| plan-writer (high) | 67 | 56 | 245k | $3.96 | $265 | 24% |
| build-premium (high) | 103 | 43 | 155k | $2.23 | $230 | 12% |

A "rewrite" is a turn that wrote more than 50k cache tokens while reading fewer than it wrote:
the cached prefix had expired and the whole context was written again. All recorded cache writes
used the 5-minute TTL; no 1-hour writes appear in any subagent transcript.

### implement-next per run

| Token type | Tokens/run | Cost/run | Share |
|---|---|---|---|
| Cache reads | 8.19M | $1.64 | ~31% |
| Cache writes (5-minute TTL) | 0.70M, of which ~0.60M are expiry rewrites | $3.48 | ~65% |
| Output (visible text and tool input, estimated) | ~11k | ~$0.22 | ~4% |

Measurement note: transcripts record `output_tokens` while the response is still streaming, so the
logged count undercounts. The output figure above is estimated from visible text and tool-input
characters (÷4). Thinking tokens aren't recorded at all, so every output figure is a lower bound.

implement-next is an orchestrator. It runs commands and dispatches workers, and it sits idle while
a build worker or the test suite runs. Those waits regularly outlast five minutes, so the cache
expires and the next turn writes the full ~120k context again.

### Estimated per-run cost under each option

| Option | implement-next | build-standard | build-premium | plan-writer |
|---|---|---|---|---|
| Opus 5.5, as today | $5.35 | $0.96 | $2.23 | $3.96 |
| Opus 5.5, 1-hour TTL | **$2.60** | $1.18 ↑ | $2.55 ↑ | $4.27 ↑ |
| Sonnet 5.5 (same effort) | $3.49 | $0.68 | $1.71 | $2.98 |
| Sonnet 5.5, 1-hour TTL | $2.11 | $0.79 | $1.87 | $3.13 |

The 1-hour TTL only pays where expiry rewrites dominate. A 1-hour write costs 2× input; a
5-minute write costs 1.25×. The workers rarely expire, so for them the 1-hour TTL is pure surcharge.
That is why the pin is per agent and why the shipped default covers implement-next only.

### Effort and model are weaker levers for implement-next

- Effort mostly changes output and thinking tokens, which are ~4% of implement-next's cost. The
low → medium price gap on public benchmarks comes from output-heavy tasks and doesn't carry over
to this agent. For the workers, visible output is 11–13% of cost plus unrecorded thinking, so
effort is a modest lever there.
- Sonnet 5.5 cache reads cost the same $0.20/M as Opus 5.5, so Sonnet saves only on writes and
output: about 35% for implement-next and 24–29% for the workers.
- Haiku 5.5 would be cheapest even at its >100k-token rate ($0.50/M input; 79% of implement-next
turns are over 100k). It isn't recommended for implement-next: the agent must follow a ~110 KB
skill contract exactly over ~65 turns, and a single intelligence score doesn't measure that.

## Claude Code's cache-TTL controls

Source: Claude Code docs, `code.claude.com/docs/en/prompt-caching.md` ("Choose the TTL yourself")
and `code.claude.com/docs/en/sub-agents.md` (supported frontmatter fields). Requires Claude Code
v2.1.248+ for the frontmatter field and v2.1.242+ for the env var and setting.

**Used: per-agent frontmatter.**

```yaml
experimental:
cacheTtl: 1h
```

It sits under the `experimental` map, never at the top level. It's the only control scoped to one
agent, which is what the data calls for.

**Documented, deliberately not used:**

| Control | Scope | Why not |
|---|---|---|
| `CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h` (env var) | every subagent, plus workflows, teammates, forks, compaction, and session titles | Too broad: it would make the workers 10–23% more expensive. It's also process environment, which docket doesn't own. |
| `subagentPromptCacheTtl: "1h"` (settings key) | same bucket as the env var | Same breadth problem. It's a user settings file, not a generated docket surface. |
| `ENABLE_PROMPT_CACHING_1H=1` | main conversation and subagents | Broadest of all. A reported bug says it didn't reach subagents on Bedrock. |
| `FORCE_PROMPT_CACHING_5M=1` | everything | The opposite lever. It overrides all of the above, frontmatter included. |

Precedence, highest first: `FORCE_PROMPT_CACHING_5M` > subagent env var > subagent setting >
frontmatter `cacheTtl` > `ENABLE_PROMPT_CACHING_1H` > default. A user who sets the broader
controls overrides docket's pin, which is the user's call.

**Caveats the docs record:**

- On a Claude subscription, frontmatter `cacheTtl: 1h` is ignored while usage is drawn from usage
credits. The env var and setting aren't ignored, but 1-hour writes cost more on credits. API-key
and cloud-provider billing default to 5 minutes for subagents.
- Claude Code documents no keep-warm, ping, or background-task way to hold a cache open. The TTL
is the only documented fix.
- An open Claude Code issue reports conflicting evidence about whether subagent writes land in the
1-hour bucket, with a maintainer saying the effective TTL is decided downstream. Verification
(below) therefore checks the transcript, not the file.

## Design

### Config surface

`AgentSetting` (`internal/config/config.go`) gains a third field next to `Model` and `Effort`:
`CacheTTL Value[string]`, JSON/YAML key `cache_ttl`. It follows the established machinery
unchanged:

- It's honoured from the built-in table and the global config only, per field, exactly like
`model` and `effort`. A repository-layer agent pin still blocks writes, as today.
- Values are opaque passthrough (ADR-0015): docket keeps no vendor allowlist, so `1h`, `5m`, or a
future token all pass through to the harness verbatim.
- Unset means no `experimental.cacheTtl` is emitted, so wrappers without it are the same as today.

### Shipped default

`agents/harness-defaults.yml` (ADR-0064) gets `cache_ttl: 1h` on the claude `implement-next`
entry only. The compiled defaults in `internal/config/defaults.go` follow, and the existing test
that keeps the sidecar and the compiled table equal covers the new field. The sidecar's shape rule
"every listed entry supplies both model and effort" stays true; `cache_ttl` is an optional third
field and the header comment says so. The global-config starter that the first install scaffolds
mirrors the shipped claude block (ADR-0039; `internal/config/example_correspondence_test.go`
guards the mirror), so it shows the new field on implement-next and the otherwise-invisible
default stays visible and tunable.

### Rendering

The Claude adapter's `renderAgent` (`internal/harness/claude/claude.go`) writes, after
`model`/`effort`:

```yaml
experimental:
cacheTtl: 1h
```

`verifyRoundTrip` extends to read `experimental.cacheTtl` back and refuses a mismatch, as it does
for model and effort.

### Non-Claude harnesses

Cursor, Codex, and OpenCode have no cache-TTL setting. A resolved `cache_ttl` on one of their
agents is left out of the generated files, with a warning that names the harness and agent and says
the harness has no prompt-cache TTL setting. This reuses the ADR-0077 warn-and-drop posture for an
effort docket can't attribute (trace the existing effort-drop warning in the install path and
extend it rather than adding a parallel one): the configuration never blocks, and the drop is loud rather than
silent. The shipped sidecar carries no `cache_ttl` for those harnesses, so a default install prints
no warning.

### Documentation

The agent-layer reference (`docket-convention/references/agent-layer.md`) and the README's agent
configuration section document:

- the `cache_ttl` key, its layering, and the shipped implement-next default;
- why it's scoped to one agent (the per-agent cost table, condensed);
- the global Claude Code controls above as alternatives docket deliberately doesn't use, with
their precedence over the frontmatter pin;
- the usage-credits caveat.

Docs describe current behaviour only, with no change or PR citations.

## Testing

- Config: `cache_ttl` resolves built-in → global per field; a repository-layer `cache_ttl` is
refused like other repository agent pins; the sidecar/compiled-table equality test covers it.
- Claude render: implement-next's generated wrapper carries `experimental.cacheTtl: 1h`; an agent
without the field renders byte-identical to today; a round-trip mismatch is refused. Mutation
check: strip the renderer's write and watch the wrapper test fail.
- Non-Claude harnesses: a global `cache_ttl` on a cursor, codex, or opencode agent leaves it out
of that harness's file and prints the warning; the shipped default prints none.

## Verification after merge

Rebuild and reinstall per the repo's after-merge rule, restart the Claude Code session (the agent
registry loads at process start), and run one `docket-implement-next`. In that subagent's
transcript, confirm cache writes land in `usage.cache_creation.ephemeral_1h_input_tokens`, that
large expiry rewrites (over 50k written, fewer read) mostly disappear, and that cost per run moves
toward ~$2.60. If writes still land in the 5-minute bucket, record that in the results file as a
Claude Code-side limitation (see the open-issue caveat). Leave the pin in place: it's harmless
when ignored.

## Recommendations recorded for separate changes

Not built here; listed for human triage.

1. **Sonnet 5.5 for build-standard.** It has the most runs, so the model switch saves the most:
~$0.28/run, about $120 over this history. Trial a few runs and compare build outcomes against
the Opus history before changing the shipped default.
2. **A context diet for plan-writer.** It has the largest context of any agent (245k average peak,
80% of turns over 100k) at high effort. Trimming what it loads likely beats a model switch.
3. **Measure finalize-change** the same way before giving it the 1-hour TTL. It also waits on test
suites, but its transcripts weren't analysed.
4. **A Sonnet 5.5 medium trial for implement-next**, after this change lands and is measured. Use
medium, not low: effort barely moves this agent's cost. Judge it by `run.verdict` outcomes and
gate stops, since retries can wipe out the ~$0.50/run saving left once the TTL fix is in.
21 changes: 18 additions & 3 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,9 @@ func effectiveLines(eff *config.Effective) []effectiveLine {
}

// agentLines renders the harness × agent table in sorted order, pairing each
// row's model with its effort pin. The bracket names the model's winning
// layer, and adds the effort's own only when the two disagree.
// row's model with its effort pin and, where one is set, its cache TTL pin.
// The bracket names the model's winning layer, and adds the effort's or the
// cache TTL's own only when it disagrees with the model's.
func agentLines(agents config.AgentsTable) []effectiveLine {
harnesses := make([]string, 0, len(agents))
for harness := range agents {
Expand All @@ -307,9 +308,23 @@ func agentLines(agents config.AgentsTable) []effectiveLine {
if setting.Effort.Provenance != setting.Model.Provenance {
layer += ", effort: " + layerLabel(setting.Effort.Provenance)
}
value := fmt.Sprintf("%s / %s", setting.Model.Value, effort)
// The cache TTL segment appears only when a pin resolves or a layer
// declared one (`cache_ttl: auto` clears it): most agents carry no
// pin, and a "(no pin)" on every row would bury the ones that do.
if ttl := setting.CacheTTL; ttl.Value != "" || ttl.Explicit {
shown := ttl.Value
if shown == "" {
shown = "(no pin)"
}
value += " / ttl " + shown
if ttl.Provenance != setting.Model.Provenance {
layer += ", cache_ttl: " + layerLabel(ttl.Provenance)
}
}
out = append(out, effectiveLine{
path: fmt.Sprintf("agents.%s.%s", harness, name),
value: fmt.Sprintf("%s / %s", setting.Model.Value, effort),
value: value,
layer: layer,
})
}
Expand Down
36 changes: 36 additions & 0 deletions internal/app/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -857,3 +857,39 @@ func TestConfigDiagnosticsOpenArtifactsSurface(t *testing.T) {
}
}
}

// TestConfigDiagnosticsAgentCacheTTLSurface pins that the hand-maintained
// `effective (winning layer)` human block (agentLines) shows each agent's
// prompt-cache TTL pin beside its model and effort, and names the TTL's layer
// when it differs from the model's: the shipped `1h` on claude implement-next,
// a global override, a global `auto` that clears the pin, and no segment at
// all for an agent that carries no pin.
func TestConfigDiagnosticsAgentCacheTTLSurface(t *testing.T) {
def := DiagnosticConfig(sparseSources(), mainCtx(), false).HumanText()
for _, want := range []string{
"agents.claude.implement-next = claude-opus-5 / medium / ttl 1h [built-in]\n",
"agents.claude.adr = claude-opus-5 / low [built-in]\n",
} {
if !strings.Contains(def, want) {
t.Errorf("default human block lacks %q:\n%s", want, def)
}
}

global := func(body string) string {
return DiagnosticConfig([]config.Source{{
Layer: config.LayerGlobal, Name: "/xdg/docket/config.yml", Data: []byte(body),
}}, mainCtx(), false).HumanText()
}
for _, tc := range []struct{ name, body, want string }{
{"override", "agents:\n claude:\n implement-next:\n cache_ttl: 5m\n",
"agents.claude.implement-next = claude-opus-5 / medium / ttl 5m [built-in, cache_ttl: global /xdg/docket/config.yml:4]\n"},
{"auto", "agents:\n claude:\n implement-next:\n cache_ttl: auto\n",
"agents.claude.implement-next = claude-opus-5 / medium / ttl (no pin) [built-in, cache_ttl: global /xdg/docket/config.yml:4]\n"},
{"new pin", "agents:\n claude:\n adr:\n cache_ttl: 1h\n",
"agents.claude.adr = claude-opus-5 / low / ttl 1h [built-in, cache_ttl: global /xdg/docket/config.yml:4]\n"},
} {
if h := global(tc.body); !strings.Contains(h, tc.want) {
t.Errorf("%s: human block lacks %q:\n%s", tc.name, tc.want, h)
}
}
}
Loading
Loading