agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
132 lines (131 loc) • 6.54 kB
TypeScript
/**
* 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<T>`,
* 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 '../provider-contract/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<CacheMetrics>;
/** Dollar estimates, `unknown` on any call whose metrics were not known. */
readonly dollarsSpent: Claim<number>;
readonly dollarsSavedVsNoCache: Claim<number>;
}
/**
* 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<number>;
readonly cacheWriteTokensTotal: Claim<number>;
readonly freshInputTokensTotal: Claim<number>;
/** cacheRead / (cacheRead + cacheWrite + fresh), over MEASURED calls only. */
readonly hitRate: Claim<number>;
readonly estimatedDollarsSpent: Claim<number>;
readonly estimatedDollarsSavedVsNoCache: Claim<number>;
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;