Skip to content

Commit f2a97a4

Browse files
authored
Add cadence-aware prompt-cache TTL policy (1h caching for sparse sessions) (#169)
1 parent 189ebbf commit f2a97a4

12 files changed

Lines changed: 1082 additions & 16 deletions

File tree

docs/config.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ from any working directory:
4040
| `agent.cron_model` | string | `claude-sonnet-4-6` | Model for cron jobs (cheaper) |
4141
| `agent.max_turns` | int | `50` | Max agentic turns per request |
4242
| `agent.max_concurrent` | int | `4` | Max concurrent agent sessions |
43+
| `agent.cache_ttl` | string | `"5m"` | Prompt-cache write TTL policy: `5m` (status quo), `1h` (always request the 1-hour TTL), or `auto` (per session at client-build time: sparse-cadence sessions — persistent crons, wakeup loops, spaced chats — get `1h`; dense sessions stay on `5m`). Per-cron-job override via `cache_ttl` in jobs.yaml. See `nerve/agent/cache_policy.py` |
44+
| `agent.cache_ttl_excluded_models` | list | `[]` | Model-name substrings that never request the 1h TTL |
4345
| `agent.prompt_rewrite.enabled` | bool | `true` | Offer the first-prompt rewrite feature in the web UI (per-user toggle lives in the composer) |
4446
| `agent.prompt_rewrite.model` | string | `""` | Model for prompt rewriting (empty = `agent.model`, the chat model) |
4547
| `agent.prompt_rewrite.max_tokens` | int | `1024` | Max tokens for the rewritten prompt |

docs/cron.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ prompt definition.
8282
| `prompt_file` | string | yes* | Path to a file containing the prompt (relative to the YAML's directory). Read fresh each run; shareable between jobs. *One of `prompt`/`prompt_file` is required |
8383
| `description` | string | no | Human-readable description |
8484
| `model` | string | no | Override model (default: `agent.cron_model`) |
85+
| `cache_ttl` | string | no | Prompt-cache TTL override for this job's sessions: `5m`, `1h`, or `auto` (default: `agent.cache_ttl`). Sparse-schedule persistent jobs benefit from `1h` — see `nerve/agent/cache_policy.py` |
8586
| `target` | string | no | Delivery channel (default: `telegram`) |
8687
| `session_mode` | string | no | `isolated` (new session per run), `persistent` (reuse context), or `main` |
8788
| `context_rotate_hours` | int | no | Hours before a persistent job rotates to a fresh chat (default: 24, 0 = never). The old chat is preserved |

nerve/agent/cache_policy.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Cadence-aware prompt-cache TTL policy.
2+
3+
Anthropic prompt caching has two write TTLs: 5 minutes (1.25x base input)
4+
and 1 hour (2.0x base input); reads are 0.1x. A cache write is therefore
5+
~12.5x the price of a read of the same tokens, so sessions whose turn
6+
cadence exceeds 5 minutes by design (persistent crons, ScheduleWakeup
7+
monitoring loops, spaced web conversations) re-buy their entire context
8+
on every turn under the default TTL.
9+
10+
This module decides, per SDK-client build, which TTL a session should
11+
request. The Claude Code CLI has native support via env vars:
12+
13+
- ``ENABLE_PROMPT_CACHING_1H=1`` — request the 1h TTL (API-key auth;
14+
the CLI adds the ``extended-cache-ttl-2025-04-11`` beta itself).
15+
- ``ENABLE_PROMPT_CACHING_1H_BEDROCK=1`` — same, for Bedrock.
16+
- ``FORCE_PROMPT_CACHING_5M=1`` — upstream kill switch (not set here).
17+
18+
Policy modes (``agent.cache_ttl`` in config, or a per-session override
19+
in session metadata):
20+
21+
- ``"5m"`` — status quo, never request the beta.
22+
- ``"1h"`` — always request it (minus excluded models).
23+
- ``"auto"`` — per session at client-build time:
24+
1. observed cadence wins: median of the session's recent turn gaps
25+
in (5min, 1h] → 1h; any other observed cadence → 5m (gaps beyond
26+
the TTL expire either way, so the 2x write premium buys nothing);
27+
2. no history: wakeup-driven turns and persistent-mode cron sessions
28+
→ 1h (the canonical sparse-cadence cases), everything else → 5m.
29+
30+
The 1h TTL only pays off if the prompt bytes are identical across turns
31+
— which in Nerve holds *within* an SDK-client lifetime, and across
32+
client rebuilds only if the system prompt is byte-stable (see the
33+
``Current date`` + frozen-recall changes in prompts.py / engine.py).
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import logging
39+
import statistics
40+
from typing import Any, Iterable
41+
42+
from nerve.db.usage import _get_pricing
43+
44+
logger = logging.getLogger(__name__)
45+
46+
FIVE_MIN_S = 300.0
47+
ONE_HOUR_S = 3600.0
48+
49+
# How many recent turn gaps to consider for the cadence heuristic.
50+
CADENCE_WINDOW = 12
51+
52+
VALID_TTL_MODES = ("5m", "1h", "auto")
53+
54+
55+
def cache_ttl_env(ttl: str, is_bedrock: bool = False) -> dict[str, str]:
56+
"""Env vars for the CLI subprocess implementing the resolved TTL.
57+
58+
``"5m"`` returns an empty dict — the CLI default is already 5m and we
59+
deliberately do NOT set ``FORCE_PROMPT_CACHING_5M`` (that would also
60+
override a claude.ai-subscriber allowlist upstream).
61+
"""
62+
if ttl != "1h":
63+
return {}
64+
env = {"ENABLE_PROMPT_CACHING_1H": "1"}
65+
if is_bedrock:
66+
env["ENABLE_PROMPT_CACHING_1H_BEDROCK"] = "1"
67+
return env
68+
69+
70+
async def resolve_cache_ttl(
71+
agent_config: Any,
72+
db: Any,
73+
session_id: str,
74+
source: str,
75+
model: str | None,
76+
session_meta: dict | None = None,
77+
is_claude_model: bool = True,
78+
) -> str:
79+
"""Resolve the cache TTL ("5m" | "1h") for a session's next client.
80+
81+
``session_meta`` is the parsed session metadata dict; recognised keys:
82+
83+
- ``cache_ttl_override`` — per-session mode override (e.g. from a
84+
cron job's ``cache_ttl`` in jobs.yaml). Same values as the config.
85+
- ``cron_session_mode`` — "persistent" | "isolated", written by the
86+
cron runners; used as the no-history prior for cron sessions.
87+
"""
88+
if not is_claude_model:
89+
return "5m" # Ollama/OpenAI-translated models have no Anthropic cache
90+
91+
meta = session_meta or {}
92+
mode = meta.get("cache_ttl_override") or getattr(
93+
agent_config, "cache_ttl", "5m",
94+
)
95+
if mode not in VALID_TTL_MODES:
96+
logger.warning(
97+
"Unknown cache_ttl mode %r for session %s — falling back to 5m",
98+
mode, session_id,
99+
)
100+
return "5m"
101+
if mode == "5m":
102+
return "5m"
103+
104+
# Model exclusion applies to both "1h" and "auto".
105+
resolved = (model or getattr(agent_config, "model", "") or "").lower()
106+
excluded = getattr(agent_config, "cache_ttl_excluded_models", []) or []
107+
if any(tok and tok.lower() in resolved for tok in excluded):
108+
return "5m"
109+
110+
if mode == "1h":
111+
return "1h"
112+
113+
# --- auto: observed cadence first, source priors on no data
114+
gaps: list[float] = []
115+
try:
116+
gaps = await get_recent_turn_gaps(db, session_id, CADENCE_WINDOW)
117+
except Exception as e: # never fail a client build over the heuristic
118+
logger.warning(
119+
"cache_ttl cadence query failed for %s: %s", session_id, e,
120+
)
121+
122+
if gaps:
123+
med = statistics.median(gaps)
124+
return "1h" if FIVE_MIN_S < med <= ONE_HOUR_S else "5m"
125+
126+
if source == "wakeup":
127+
return "1h"
128+
if source == "cron" and meta.get("cron_session_mode") == "persistent":
129+
return "1h"
130+
return "5m"
131+
132+
133+
async def get_recent_turn_gaps(
134+
db: Any, session_id: str, window: int = CADENCE_WINDOW,
135+
) -> list[float]:
136+
"""Seconds between the session's most recent turns (indexed query)."""
137+
async with db.db.execute(
138+
"""
139+
SELECT CAST(strftime('%s', created_at) AS REAL)
140+
FROM session_usage WHERE session_id = ?
141+
ORDER BY id DESC LIMIT ?
142+
""",
143+
(session_id, window + 1),
144+
) as cursor:
145+
ts = [row[0] async for row in cursor if row[0] is not None]
146+
ts.reverse() # chronological
147+
return [b - a for a, b in zip(ts, ts[1:])]
148+
149+
150+
# ---------------------------------------------------------------------------
151+
# Live counterfactual: what would the observed traffic have cost on 5m?
152+
# ---------------------------------------------------------------------------
153+
154+
def estimate_live_ttl_delta(
155+
turns: Iterable[tuple],
156+
ttl_threshold: float = ONE_HOUR_S,
157+
) -> dict:
158+
"""Estimate savings of observed (possibly 1h-cached) traffic vs a
159+
pure-5m baseline.
160+
161+
``turns`` are chronological rows for ONE session:
162+
``(ts_epoch, model, input_tokens, cache_read, write_5m, write_1h)``.
163+
164+
Model: a turn whose gap from the previous turn is in (5min, 1h] and
165+
whose predecessor wrote 1h-TTL cache benefited from the extended TTL
166+
— under 5m its first-iteration prefix read would have been a re-write
167+
at 1.25x. The warm-prefix size is tracked from creation tokens
168+
(reads multi-count across agentic-loop iterations, creations don't).
169+
Turns are also charged the 1h-vs-5m write premium they actually paid.
170+
171+
Returns ``{"actual": $, "baseline_5m": $, "savings": $}`` where
172+
positive savings mean the 1h TTL is paying off.
173+
"""
174+
actual = 0.0
175+
baseline = 0.0
176+
warm_prefix = 0
177+
prev_ts: float | None = None
178+
prev_model: str | None = None
179+
prev_wrote_1h = False
180+
181+
for ts, model, inp, reads, w5m, w1h in turns:
182+
p_in, _o, p_read, p_c5m, p_c1h, _w = _get_pricing(model)
183+
creation = (w5m or 0) + (w1h or 0)
184+
gap = None if prev_ts is None else ts - prev_ts
185+
cold_boundary = (
186+
gap is None or model != prev_model or gap > ttl_threshold
187+
)
188+
189+
actual += (
190+
inp * p_in + reads * p_read + w5m * p_c5m + w1h * p_c1h
191+
) / 1_000_000
192+
193+
# Baseline: same turn under a pure-5m policy.
194+
converted = 0
195+
if (
196+
not cold_boundary
197+
and gap is not None
198+
and gap > FIVE_MIN_S
199+
and prev_wrote_1h
200+
):
201+
# This read survived only thanks to the 1h TTL; under 5m the
202+
# prefix would have been re-written once at the 5m rate.
203+
converted = min(reads, warm_prefix)
204+
baseline += (
205+
inp * p_in
206+
+ (reads - converted) * p_read
207+
+ converted * p_c5m
208+
+ (w5m + w1h) * p_c5m
209+
) / 1_000_000
210+
211+
# Warm-prefix bookkeeping (observed world).
212+
if cold_boundary or (gap is not None and gap > FIVE_MIN_S and not prev_wrote_1h):
213+
warm_prefix = creation
214+
else:
215+
warm_prefix += creation
216+
prev_ts = ts
217+
prev_model = model
218+
prev_wrote_1h = w1h > 0
219+
220+
return {
221+
"actual": round(actual, 4),
222+
"baseline_5m": round(baseline, 4),
223+
"savings": round(baseline - actual, 4),
224+
}
225+
226+
227+
def build_ttl_report(rows: list[tuple]) -> dict:
228+
"""Aggregate the live 1h-vs-5m estimate per source for diagnostics.
229+
230+
``rows`` come from ``UsageStore.get_cache_ttl_turn_rows``:
231+
``(session_id, source, ts, model, input_tokens, reads, w5m, w1h)``,
232+
ordered by session then time.
233+
234+
Guardrail: a source whose 1h traffic is estimated to cost *more* than
235+
the 5m baseline (the auto policy misclassified its cadence) lands in
236+
``regressions`` and is logged at WARNING — manual revert is one
237+
config line (``agent.cache_ttl``) or a per-job ``cache_ttl: "5m"``.
238+
"""
239+
by_session: dict[str, tuple[str, list[tuple]]] = {}
240+
for sid, source, ts, model, inp, reads, w5m, w1h in rows:
241+
if ts is None:
242+
continue
243+
by_session.setdefault(sid, (source, []))[1].append(
244+
(ts, model, inp or 0, reads or 0, w5m or 0, w1h or 0),
245+
)
246+
247+
per_source: dict[str, dict] = {}
248+
total = {"actual": 0.0, "baseline_5m": 0.0, "savings": 0.0}
249+
for _sid, (source, turns) in by_session.items():
250+
est = estimate_live_ttl_delta(turns)
251+
agg = per_source.setdefault(
252+
source, {"actual": 0.0, "baseline_5m": 0.0, "savings": 0.0},
253+
)
254+
for k in agg:
255+
agg[k] = round(agg[k] + est[k], 4)
256+
total[k] = round(total[k] + est[k], 4)
257+
258+
regressions = [
259+
src for src, agg in per_source.items() if agg["savings"] < -0.5
260+
]
261+
for src in regressions:
262+
logger.warning(
263+
"cache_ttl guardrail: 1h caching for source %r cost "
264+
"$%.2f MORE than the 5m baseline over the window — the auto "
265+
"policy may be misclassifying its cadence (revert via "
266+
"agent.cache_ttl or a per-job cache_ttl override)",
267+
src, -per_source[src]["savings"],
268+
)
269+
270+
return {
271+
"by_source": per_source,
272+
"total": total,
273+
"regressions": regressions,
274+
}

0 commit comments

Comments
 (0)