agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
276 lines • 12.4 kB
TypeScript
/**
* Cache layer — public types.
*
* Three layers, each with one responsibility:
*
* 1. CONSUMER DSL — `CachePolicy` field on every injection factory.
* Declarative, like GraphQL schema input. Says WHAT should be
* cacheable. Examples: `cache: 'always'`, `cache: 'while-active'`.
*
* 2. AGNOSTIC MARKERS — `CacheMarker[]` produced by the
* `CacheDecision` subflow at runtime. Provider-independent
* identification of "cacheable prefix in field X up to index Y".
*
* 3. STRATEGY — one `CacheStrategy` per CAPABILITY, chosen from what the
* provider DECLARES (`LLMProvider.promptCaching`), never from its name:
* explicit breakpoints, automatic prefix caching, or none. Decides
* which markers reach the request AND reads cache metrics off the
* port usage. The adapter owns the wire format.
*
* The interfaces are read-only / immutable by convention. Strategies
* MUST be stateless across runs; per-run state lives in the
* `CacheStrategyContext` passed into `prepareRequest`.
*/
import type { LLMRequest, LLMResponse } from '../provider-contract/types.js';
import type { Claim } from '../lib/claim/claim.js';
/**
* The usage a strategy is actually handed — the PORT shape, exactly as it
* rides `agentfootprint.stream.llm_end`.
*
* Named here rather than inlined because it is the load-bearing half of the
* 9.59.0 meter fix: the interface below used to take `unknown`, so three
* strategies parsed RAW WIRE names (`cache_read_input_tokens`,
* `prompt_tokens_details`) off a value that has never carried them, every
* field read `undefined`, and the meter recorded nothing while reporting a
* 0% hit rate. Adapters normalise the wire ONCE, at the adapter ring
* (`readCacheUsage`); a strategy reads the normalised result.
*
* The absence of `cacheRead` / `cacheWrite` is INFORMATION, not zero: the
* adapter sets them only when the provider reported a number, so
* absent = nobody measured, present-and-0 = measured zero.
*/
export type CacheUsage = LLMResponse['usage'];
/**
* `cache:` field shape on every injection factory.
*
* Defaults per factory (chosen so `cache:` is rarely written explicitly):
* - `defineSteering` → `'always'`
* - `defineFact` → `'always'`
* - `defineSkill` → `'while-active'`
* - `defineInstruction` → `'never'`
* - `defineMemory` → `'while-active'`
*
* Variants:
* - `'always'` — cache whenever this injection is in `activeInjections`.
* Sugar for `{ until: () => false }`. The most aggressive form.
* - `'never'` — never cache. Use for volatile content (rule predicates,
* on-tool-return injections, content with timestamps or per-request IDs).
* Sugar for `{ until: () => true }` — i.e., always-invalidated.
* - `'while-active'` — cache while this injection appears in
* `activeInjections[]` for the current iteration. The cache invalidates
* the moment the injection becomes inactive (predicate returns `false`,
* skill deactivates, fact gets removed). Skill default; intuitive
* meaning regardless of factory.
* - `{ until: ctx => ... }` — conditional invalidation; cached UNTIL
* the predicate returns `true`. The predicate runs every iteration;
* if it flips to `true`, the cache prefix is rebuilt.
*
* **Composition**: the four sentinel forms cover most cases. For
* complex composition (e.g., "cache always EXCEPT after iter 5"), use
* the `{ until: ... }` form directly:
*
* ```ts
* // Stable for the first 5 iters, then flush:
* cache: { until: ctx => ctx.iteration > 5 }
*
* // Cache while iter > 1 (skip caching on the very first call):
* cache: { until: ctx => ctx.iteration <= 1 }
*
* // Invalidate when cumulative spend exceeds budget:
* cache: { until: ctx => ctx.cumulativeInputTokens > 50_000 }
* ```
*
* The predicate is the DSL's escape hatch — Turing-complete by design.
* 80% of consumers stick with the three sentinel strings; power users
* compose freely via `{ until }`.
*/
export type CachePolicy = 'always' | 'never' | 'while-active' | {
readonly until: (ctx: CachePolicyContext) => boolean;
};
/**
* Context passed to a `CachePolicy.until` predicate. Read-only
* snapshot; predicates must be pure.
*
* Mirrors `InjectionContext` but trimmed to the fields a cache
* predicate would meaningfully inspect.
*/
export interface CachePolicyContext {
/** Current ReAct iteration (1-based). */
readonly iteration: number;
/** Number of iterations remaining (= maxIterations - iteration). */
readonly iterationsRemaining: number;
/** The current user message that started this turn. */
readonly userMessage: string;
/** Last tool that returned, if any. */
readonly lastToolName?: string;
/** Cumulative input tokens so far this run. */
readonly cumulativeInputTokens: number;
}
/**
* Provider-independent identification of a cacheable prefix.
*
* The CacheDecision subflow walks `activeInjections` and emits one
* marker per slot whose entries from index 0..boundaryIndex form a
* stable, contiguous, cacheable prefix — and one more on the conversation
* tail when everything before it is cacheable (the moving breakpoint).
*
* `field` is the request field this marker targets. An adapter that declares
* `promptCaching.mode: 'breakpoints'` encodes it on its wire; the Anthropic
* one does:
* - `'system'` → `cache_control` on a system block
* - `'tools'` → `cache_control` on a tools array entry
* - `'messages'` → `cache_control` on the LAST content block of the
* message the index names (Anthropic's positional rule)
*/
export interface CacheMarker {
readonly field: 'system' | 'tools' | 'messages';
/**
* 0-based index of the LAST element in `field` to include in the
* cached prefix. Everything from index 0..boundaryIndex (inclusive)
* is cacheable.
*
* **Provider note for `field: 'messages'`**: the index is a position in
* `LLMRequest.messages`. Anthropic's `cache_control` on `messages` is
* positional — it takes effect on the LAST content block of a message —
* and the adapter translates the index into its own (coalesced) message
* array; consumers and the CacheDecision subflow don't see this.
*/
readonly boundaryIndex: number;
/**
* Suggested TTL for this marker. Strategies map to provider-specific
* values (Anthropic: `'short'` → 5min ephemeral, `'long'` → 1h beta).
*/
readonly ttl: 'short' | 'long';
/**
* Diagnostic string surfaced in cacheRecorder events. Helps consumers
* understand WHY this marker fired. Examples: `'always-on injections'`,
* `'skill body (port-error-triage)'`.
*/
readonly reason: string;
}
/**
* How an adapter's wire caches a repeated prompt prefix — DECLARED by the
* adapter, read by the agent to choose its cache strategy
* (`agentfootprint/cache` · `cacheStrategyFor`).
*
* - `'breakpoints'` — the provider caches a prefix only where the request
* marks it (Anthropic's `cache_control`). The adapter reads
* `LLMRequest.cacheMarkers` and writes them on its wire, at most
* `maxBreakpoints` per request.
* - `'automatic'` — the provider caches repeated prefixes on its own (OpenAI).
* Markers are inert; nothing the request says changes what is cached.
*
* `reportsUsage` says whether the adapter lifts the provider's cache token
* counts onto `LLMResponse.usage.cacheRead` / `cacheWrite`. The cache meter
* reads it: `false` makes every call's cache claim *not applicable*, never a
* zero.
*
* @example
* ```ts
* // A wrapper forwards what it wraps, like every other capability:
* const wrapped: LLMProvider = {
* name: `my-app/${inner.name}`,
* ...(inner.promptCaching !== undefined && { promptCaching: inner.promptCaching }),
* complete: (req, hooks) => inner.complete(req, hooks),
* };
* ```
*/
export type PromptCaching = {
readonly mode: 'breakpoints';
/** Breakpoints the wire honours on one request (Anthropic: 4). */
readonly maxBreakpoints: number;
readonly reportsUsage: boolean;
/** Optional wire-supported fields. Absent means all marker fields;
* models such as Bedrock Nova accept system/messages but not tools. */
readonly fields?: readonly CacheMarker['field'][];
} | {
readonly mode: 'automatic';
readonly reportsUsage: boolean;
};
/**
* The cache strategy an agent runs between request assembly and the provider.
*
* Chosen by CAPABILITY: `cacheStrategyFor(provider)` reads the provider's
* declared `promptCaching` and returns `BreakpointCacheStrategy`,
* `AutomaticCacheStrategy` or `NoOpCacheStrategy`. Pass your own as
* `Agent.create({ cacheStrategy })` to override it.
*
* Strategies MUST be stateless across runs; per-run state arrives in the
* {@link CacheStrategyContext}.
*/
export interface CacheStrategy {
/**
* What the run's receipt records as the strategy that stood between
* assembly and the port (`Receipt.cache.strategy`). The built-ins name the
* capability they serve: `'breakpoints'`, `'automatic'`, `'none'`.
*/
readonly name: string;
/**
* Decide which agnostic markers reach the request.
*
* Async to support handle-based caching (Gemini's `createCachedContent`
* references handles) without another interface change.
*
* Returns the request (with `cacheMarkers` set for the adapter to encode)
* AND the markers actually applied, after clamping. `markersApplied` flows
* into the receipt and the cacheRecorder.
*/
prepareRequest(req: LLMRequest, candidates: readonly CacheMarker[], ctx: CacheStrategyContext): Promise<{
readonly request: LLMRequest;
readonly markersApplied: readonly CacheMarker[];
}>;
/**
* Read cache hit/miss metrics off the PORT usage the framework hands you
* ({@link CacheUsage}) — never off a raw provider payload. The adapter has
* already normalised the wire (Anthropic's `cache_read_input_tokens`,
* OpenAI's `prompt_tokens_details.cached_tokens`, …) onto
* `cacheRead` / `cacheWrite`.
*
* Returns a {@link Claim}, not `CacheMetrics | undefined`, because the
* three answers are genuinely different and the meter must never blur
* them:
* - `known(metrics, …)` — the provider reported cache token counts.
* - `unknown(reason, …)` — nothing measured this call (no usage payload,
* or the provider reported no cache fields). The
* report renders this as *unmeasured*, never 0.
* - `notApplicable(…)` — this provider cannot report cache usage at
* all (it declares no `promptCaching`, or
* declares `reportsUsage: false`).
*/
extractMetrics(usage: CacheUsage | undefined): Claim<CacheMetrics>;
}
/**
* Per-run state passed into `prepareRequest`. Strategies use this to
* make per-iteration decisions (rate of cache invalidation, current
* iteration index, etc.) without leaking state into module scope.
*/
export interface CacheStrategyContext {
readonly iteration: number;
readonly iterationsRemaining: number;
/**
* Hit rate across previous iterations of this run (0..1), when something
* supplied one. Nothing in the library writes it today, so it arrives
* `undefined`; the CacheGate's hit-rate rule reads the same value.
*/
readonly recentHitRate: number | undefined;
/**
* `true` when `Agent.create({ caching: 'off' })` is set OR a
* higher-level kill switch fires. Strategy MUST honor this and
* return the request unchanged.
*/
readonly cachingDisabled: boolean;
}
/**
* Normalized cache metrics extracted from a provider's `usage`
* response. cacheRecorder consumes these for hit-rate tracking,
* cost estimation (via PricingTable), and diagnostic events.
*/
export interface CacheMetrics {
/** Tokens served from cache (10% / 50% / 25% off depending on provider). */
readonly cacheReadTokens: number;
/** Tokens written to cache this call (premium varies by provider). */
readonly cacheWriteTokens: number;
/** New input tokens not from cache — full price. */
readonly freshInputTokens: number;
}
//# sourceMappingURL=types.d.ts.map