Prompt caching
Stable context — steering, facts, an active skill — is cached for you on providers that support it, per a policy you can override on each injection. On by default; one flag turns it off.
An agent loop sends the same system prompt and tool descriptions on every iteration. On providers with prompt caching, those repeated bytes can cost a fraction of normal input tokens — but only if the request marks which blocks are stable. agentfootprint does that marking for you — the conversation so far included.
It is on by default. Most agents never touch it.
Turning it off
import { Agent } from 'agentfootprint';
import { anthropic } from 'agentfootprint/providers';
const agent = Agent.create({
provider: anthropic({ defaultModel: 'claude-sonnet-4-5' }),
caching: 'off', // the only accepted value; leave unset to keep caching on
})
.system('…')
.build();Turn it off when:
- running tests against
mock()— there is no real provider, so caching is moot; - debugging context assembly and you want the request exactly as built, every iteration;
- running a token-cost comparison and you need an uncached baseline.
In production, leave it on: a built-in gate (below) already stands caching down on the turns where it would cost more than it saves.
What gets cached — a policy per injection
Each piece of context says how long it stays stable. The defaults follow how each kind behaves:
| Declared with | Default cache | Why |
|---|---|---|
defineSteering | 'always' | Always-on rules are the most stable text in the loop |
defineFact | 'always' | Static reference data |
defineSkill | 'while-active' | Stable while the skill is active; released when it deactivates |
defineInstruction | 'never' | Rule-based text flips turn to turn, so caching it would cost more than it saves |
Override on any injection:
import { defineSkill } from 'agentfootprint/context';
const triage = defineSkill({
id: 'port-error-triage',
description: 'Diagnose port errors on a storage switch.',
body: '…',
cache: 'always', // keep the body cached even while the skill is inactive
});The values are 'always', 'while-active', 'never', or
{ until: (ctx) => boolean } — cached until the predicate returns true.
The predicate sees the iteration, the iterations remaining, the user message,
the last tool called and the input tokens used so far:
cache: { until: (ctx) => ctx.cumulativeInputTokens > 50_000 }When caching stands itself down
Every iteration, before the model is called, a gate decides whether to place cache markers at all. It skips them when:
- caching is off for the agent;
- a supplied hit rate is below 0.3 (the agent computes none yet);
- three or more different skills were active in the last five iterations — each switch invalidates the cache, so marking would only add write cost.
Under .findings(), stable tool definitions and immutable system instructions
can be cached. The changing advisory ID list and ledger declarations are late
request-only DATA, outside the cache breakpoints. See the layout and limits.
Which providers, and how
Each provider declares how its wire caches (promptCaching, a PromptCaching
from agentfootprint/cache); the agent picks its strategy from that, never
from the provider's name:
| Provider | Declares | Cache token counts |
|---|---|---|
anthropic(), browserAnthropic() | mode: 'breakpoints' — up to 4 markers, placed for you | reported |
openai(), Azure OpenAI, Foundry | mode: 'automatic' — the provider caches on its own | not reported |
bedrock() | model-aware mode: 'breakpoints' for supported model IDs; unknown models do not cache | reported when present |
ollama(), gemini(), mock() | nothing — no-op | — |
Bedrock support comes from the adapter's model table. A cache point below the model's minimum size does not cache.
A wrapper keeps caching when it forwards the field (withRetry,
withCircuitBreaker and withFallback do):
const routed: LLMProvider = {
name: `my-app/${inner.name}`,
...(inner.promptCaching !== undefined && { promptCaching: inner.promptCaching }),
complete: (req, hooks) => inner.complete(req, hooks),
};Without it the wrapper runs uncached, and its receipts say 'none'.
Did it pay off?
cacheRecorder() (also on agentfootprint/cache) reports what caching cost and
saved on a turn — and says "not measured" rather than zero when a provider
reported nothing. See The cache meter.
Gate or stuff? Both work now
Without caching, the only sensible shape for a skill-heavy agent is to gate: put only the active skill's text in the prompt. With caching, stuffing every skill into the prompt and letting the cache carry it becomes reasonable too. Measured on a 10-skill, 18-tool agent against the live Anthropic API (input tokens for one task):
| Shape | Caching off | Caching on |
|---|---|---|
| Gate — base prompt + the one active skill | 28,404 | 6,535 (−77%) |
| Stuff — base prompt + all 10 skills | ~140,000 | 7,640 (−95%) |
Gating still uses fewer tokens and has a faster first call (no large cache write), but stuffing is now a matter of taste rather than cost.
Findings uses a stable prefix
With .findings(), library-owned _findings tool definitions stay fixed as
result IDs and standings change. The fixed findings instruction can be cached;
mutable declarations and the current advisory IDs are served in a final
request-only DATA frame, after the last cache breakpoint. This frame is quoted
model/record data, not a new human request or verified facts. Judge and argument
rows are not added to it.
// The example advertises breakpoint support to test placement. In an app,// use the chosen adapter's own model-aware capability declaration.const provider = { name: inner.name, complete: inner.complete.bind(inner), promptCaching: { mode: 'breakpoints' as const, maxBreakpoints: 4, reportsUsage: true },};const agent = Agent.create({ provider, model: 'mock', maxIterations: 5 }) .system('Use the synthetic fixture to answer.') .tool( defineTool({ name: 'read_fixture', description: 'Read a local fixture.', inputSchema: { type: 'object', properties: {} }, execute: () => ({ status: 'ready' }), }), ) .findings({ maxContextBytes: 4096 }) .build();await agent.run({ message: input });// Mutable findings DATA is request-only, after the committed history and// its cache markers. Citation admission still checks actual served results.maxContextBytes is a host-owned UTF-8 ceiling: default 16,384, a positive whole
number between 1,024 and 262,144. Whole rows or IDs that do not fit are omitted,
with per-bucket counts; IDs are never shortened. A source that cannot be read as
bounded plain JSON is unknown as a whole, including its row count. Missing
entries must not be interpreted as a healthy result or proof of absence.
The advisory list is not a citation grant. The existing admission check still uses the actual unique executed results shown in the request producing the accepted answer. IDs found only in DATA or in the model's own statements cannot authorize a tool. Providers and wrappers must still report any private request rewrite through the existing hooks.
Global caching: 'off', host cache restrictions and unsupported models remain
authoritative. Tool activation, window changes and collapsing judged historical
results can still change a prefix and miss the cache. Vendor minimum sizes and
pricing still apply. The offline example verifies placement and bytes only;
it reports no cache-hit or money-saving measurement.
servedAt rebuilds DATA from the call's recorded layout and byte limit, even
when a checkpoint is resumed by an agent with a different limit. Records without
a layout discriminator use the legacy layout; unknown or masked layout evidence
produces a findings-layout gap. Redaction can therefore withhold reconstruction
rather than silently rebuilding private model data.
