/** * cacheRecorder() — the cache layer's meter. * * Subscribes to: * - `FlowRecorder.onDecision` — CacheGate routing decisions * (apply-markers / no-markers + the rule that fired + evidence from * `decide()`). * - `agentfootprint.stream.llm_end` — reads that event's `usage` (the PORT * shape: `{ input, output, cacheRead?, cacheWrite? }`) and asks the * agent's `CacheStrategy.extractMetrics` what is known about it. * * Produces: a per-turn report via `recorder.report()` — token tallies, hit * rate and dollar estimates, each carried as a `Claim` so an UNMEASURED turn * can never render as a zero one. * * ── Why every number here is a Claim (9.59.0) ───────────────────────── * The shipped 9.58.0 meter reported `hitRate: 0` for a 20-call turn that hit * cache on every call. Two faults, and the second is why the first went * unnoticed for so long: (1) the strategies parsed RAW WIRE field names off a * value that carries the PORT shape, so every field read `undefined`; (2) the * report typed its aggregates as bare `number`, so "nobody measured" and * "measured, and it was zero" rendered identically. Fixing (1) alone would * have left a meter that still cannot say "unmeasured" — hence `Claim`, * plus `measuredIterations` / `unmeasuredIterations` so a rate computed from * 3 of 20 calls states its own denominator. * * Read the number through `isKnown(report.hitRate)` (or `describeClaim` for a * one-line render). There is deliberately no door that hands you a bare * number without your having branched. * * ── And an unknown carries ITS OWN reason (9.59.1) ──────────────────── * 9.59.0 enforced that law per call and then broke it in `report()`, which * hardcoded "the provider reported no cache fields" for the whole turn no * matter what the rows said. With no strategy passed and a provider that DID * report cache fields, the row said "nothing read the usage" and the summary * blamed the provider — pointing the reader away from their actual mistake. * The summary now carries the rows' own reasons, and says so plainly when the * rows disagree rather than picking one. * * ── What this recorder does NOT do ──────────────────────────────────── * It emits no events. (Earlier prose here promised per-iteration * `agentfootprint.cache.applied` / `agentfootprint.cache.metrics` events; * neither name has ever existed in the event registry and no `typedEmit` has * ever been in this file. The prose was the bug.) * * It does not write `scope.recentHitRate` back into agent state either, so * CacheGate's hit-rate-floor rule never fires on its own — the loop is * severed at both ends (the key is seeded `undefined` and written by * nothing). Recorders do not write to chart scope, so closing that loop needs * an agent-side accessor convention; it is separable work from measuring the * number, which is what this file now does. */ import type { CombinedRecorder } from 'footprintjs'; import type { CacheMetrics, CacheStrategy } from './types.js'; import type { PricingTable } from '../adapters/types.js'; import { type Claim } from '../lib/claim/claim.js'; /** One LLM call's row on the record. */ export interface PerIterEntry { readonly iteration: number; readonly branch: 'apply-markers' | 'no-markers'; readonly rule?: string; /** * What the strategy could say about this call's cache traffic. `known` = * the provider reported counts; `unknown` = nothing was measured; * `not-applicable` = this adapter cannot report cache usage at all. */ readonly metrics: Claim; /** Dollar estimates, `unknown` on any call whose metrics were not known. */ readonly dollarsSpent: Claim; readonly dollarsSavedVsNoCache: Claim; } /** * The turn's tally. Every quantity derived from provider usage is a * {@link Claim}: unmeasured is a first-class answer, never a zero. */ export interface CacheReportSummary { /** LLM calls seen. Always known — the recorder counted them itself. */ readonly totalIterations: number; readonly applyMarkersIterations: number; readonly noMarkersIterations: number; /** * Calls whose cache traffic the provider actually reported. The DENOMINATOR * every claim below is computed over — a rate from 3 of 20 calls is not the * turn's rate, and this is how a reader can tell. */ readonly measuredIterations: number; /** Calls that reported nothing (no usage, no cache fields, or an adapter that cannot). */ readonly unmeasuredIterations: number; readonly cacheReadTokensTotal: Claim; readonly cacheWriteTokensTotal: Claim; readonly freshInputTokensTotal: Claim; /** cacheRead / (cacheRead + cacheWrite + fresh), over MEASURED calls only. */ readonly hitRate: Claim; readonly estimatedDollarsSpent: Claim; readonly estimatedDollarsSavedVsNoCache: Claim; readonly perIter: readonly PerIterEntry[]; } export interface CacheRecorderOptions { /** * The agent's CacheStrategy — the thing that reads the port usage. Without * one every row's metrics are `unknown` (with that as the stated reason), * which is the honest answer: nothing read the usage. */ readonly strategy?: CacheStrategy; /** * PricingTable for dollar estimates. Falls back to token-count-only * reporting when omitted. Looks up `'input'` / `'cacheRead'` / * `'cacheWrite'` token kinds (PricingTable already supports these * as of v2.5). */ readonly pricing?: PricingTable; /** * Model id for pricing lookup. Defaults to a placeholder; set to * the actual model the agent is using for accurate dollar math. */ readonly model?: string; } export interface CacheRecorderHandle extends CombinedRecorder { /** * Build a per-turn report. Call after `agent.run()` completes. * Returns a frozen snapshot — recorder keeps accumulating but the * report you held is stable. */ report(): CacheReportSummary; /** * Reset accumulated state. Call between turns if you want * per-turn rather than per-session reporting. */ reset(): void; } export declare function cacheRecorder(options?: CacheRecorderOptions): CacheRecorderHandle;