agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
206 lines (205 loc) • 10.2 kB
TypeScript
/**
* hosting/admission — decide whether a request runs, before it costs anything.
*
* A standing agent's cheapest refusal is the one made before the first model
* call. Everything else the composer does about load is about ORDER —
* `onConcurrentInvoke` decides whether a session's second turn waits or is
* refused, the pool decides which instance answers — and none of it can say
* "this person has had twenty turns in the last hour and that is enough".
*
* That question needs two things the composer already has and never joined: WHO
* is asking (verified, since 9.26) and what that person has spent (the token
* and cost events every run already emits). This module is the join, plus the
* seam where an operator's own rule goes.
*
* **Status, split rather than averaged, because the two halves were not earned
* equally:**
*
* • **The `admission: { decide }` seam is field-validated — an independent
* field trial, 2026-08-13.** Running against a real hosted door (a live
* `nodeHost` in a live Google Cloud project, sessions in a real Firestore),
* the trial had one caller's first two turns run and the third answer **429
* before the provider or the session store was invoked** — the
* cheapest-refusal claim, measured rather than asserted.
*
* • **`turnsPerHour` itself is contract-shaped and tested.** The finding
* records the refusal and restates this helper's per-process bound; it does
* not record the shipped helper as the policy under test, and a run of a few
* minutes cannot have crossed an HOUR boundary in any case. So the window's
* own arithmetic — the roll-off, the reset a caller is told about — is
* proven here by tests and not by a field run.
*
* The honest bound the trial restated is the one already on {@link RecentSpend}:
* `turnsPerHour` is an in-process reference policy, its counters are not shared
* across instances and do not survive a restart, so a fleet-wide or billing
* control needs a `decide` that reads a transactional store of your own.
*
* The refusal itself is invisible to `auditExport()` — it happens before a run
* exists. `standingAgent({ onIngressDecision })` is where it lands instead.
*
* ── One decision, three answers, no fourth ───────────────────────────────────
* `'allow'` · `{ queue: true }` · `{ refuse: '<sentence>' }`. Deliberately not
* "delay by N ms" (a sleep is a thread the caller is paying for), not "degrade
* to a cheaper model" (that is the run's business, and silently answering from
* a different brain is the accepted-and-silently-wrong failure), and not a
* numeric priority (a priority means a scheduler, and this is a door).
*
* ── The refusal is the operator's sentence, and that is deliberate ───────────
* The policy WRITES the words. A limit and its reset are facts only the
* operator has, and a library-authored "rate limit exceeded" is a support
* ticket rather than something a caller can act on. `turnsPerHour` shows the
* shape: name the limit, name when it resets, name what to do.
*
* ── What "spend" honestly means here ─────────────────────────────────────────
* PER PROCESS. This ledger counts what THIS process served, in a rolling
* window, in memory. Two replicas keep two windows; a restart forgets. That is
* not a defect to be apologised for — it is what an in-process accountant can
* truthfully claim — and it is stated on {@link RecentSpend} so nobody builds a
* billing control on it by accident. A deployment that needs one number across
* a fleet writes a `decide` that reads its own store; the seam is the same.
*/
import type { VerifiedIdentity } from './identityVerification.js';
/**
* What one caller has spent inside the rolling window, as THIS PROCESS saw it.
*
* Every field is a measurement, never an estimate — with one stated exception
* noted on `usd`. A number this ledger cannot know is absent rather than zero:
* zero is a claim, and "we did not measure that" is a different fact.
*/
export interface RecentSpend {
/** Turns admitted for this caller inside the window. */
readonly turns: number;
/** Prompt tokens billed to this caller's runs, summed from `stream.llm_end`. */
readonly inputTokens: number;
/** Completion tokens, from the same source. */
readonly outputTokens: number;
/**
* Estimated USD, summed from `cost.tick`.
*
* **Absent unless a `pricingTable` is configured on the agent** — money is
* something only a pricing table can turn tokens into, and a zero here would
* read as "this caller has spent nothing" when the truth is "nobody is
* counting". Estimated, because the pricing table is a local table and the
* invoice is the vendor's.
*/
readonly usd?: number;
/** How far back this window looks, in milliseconds. */
readonly windowMs: number;
/**
* Whether the window is complete for this caller — `false` when the process
* has been up for LESS than `windowMs`, so the numbers cover a shorter
* period than they claim to.
*
* A policy that refuses on a partial window refuses on less evidence than it
* thinks it has; one that ignores this is choosing to, which is fine, but it
* should be a choice.
*/
readonly complete: boolean;
}
/** What an admission decision is handed. */
export interface AdmissionContext {
/**
* WHO is asking, when the door verified anybody. `undefined` for an
* anonymous request and for a door with no verifier — never a claimed-but-
* unproven user, because a policy that budgeted an unverified name would
* budget whatever name the next request invented.
*/
readonly identity?: VerifiedIdentity;
/** The conversation this request belongs to, when it named one. */
readonly sessionId?: string;
/** This caller's rolling-window spend. See {@link RecentSpend}. */
readonly recentSpend: RecentSpend;
}
/**
* What a policy answers.
*
* - `'allow'` — run it now (the shape of every request before this option
* existed).
* - `{ queue: true }` — run it, but behind whatever this session already has
* in flight, even where the host would otherwise refuse a second concurrent
* turn. A back-pressure valve that costs latency instead of a failure.
* - `{ refuse }` — do not run it, and answer with this sentence.
*/
export type AdmissionVerdict = 'allow' | {
readonly queue: true;
} | {
readonly refuse: string;
};
/** The seam. One method, so a policy is a function with a name. */
export interface AdmissionPolicy {
decide(context: AdmissionContext): AdmissionVerdict | Promise<AdmissionVerdict>;
}
export interface TurnsPerHourOptions {
/** How many turns one caller may start per hour. */
readonly limit: number;
/**
* What an ANONYMOUS caller may start per hour. Default: the same `limit`.
*
* Worth setting separately at a door built with `allowAnonymous`, where
* every anonymous request shares one bucket: the shared bucket makes a
* per-user limit into a global one for that lane, which is a fact rather
* than a bug, and this is the dial for it.
*/
readonly anonymousLimit?: number;
}
/**
* The reference policy: N turns per caller per hour, refused with the limit and
* its reset in the sentence.
*
* It is deliberately the SIMPLEST honest rule — a count, a window, a sentence —
* because its job is to show the shape rather than to be everybody's policy.
* Read it and write yours: a real deployment's rule is usually "this plan, this
* endpoint, this time of day", which is exactly the knowledge a library does
* not have.
*
* @example
* await standingAgent({
* agent, sessions, host,
* identity: { verify }, // required for a PER-USER bound
* admission: turnsPerHour({ limit: 60 }),
* });
*/
export declare function turnsPerHour(options: TurnsPerHourOptions): AdmissionPolicy;
/**
* The rolling-window ledger the composer feeds and a policy reads.
*
* Bounded twice, because an unbounded map keyed by caller-supplied strings is a
* memory leak with an attacker's finger on it: entries older than the window
* are dropped on every read, and the number of distinct callers held is capped
* (the least-recently-touched is evicted). Evicting a caller costs that caller
* a forgotten window — they are admitted where they might have been refused,
* which is the safe direction for a bound whose alternative is falling over.
*/
export interface SpendLedger {
/** Note that a turn was admitted for this caller. */
admit(key: string): void;
/** Add measured usage to this caller's most recent turn. */
add(key: string, usage: {
inputTokens?: number;
outputTokens?: number;
usd?: number;
}): void;
/** This caller's window, as of now. */
read(key: string): RecentSpend;
/** How many distinct callers are currently held (diagnostic). */
readonly size: number;
}
export interface SpendLedgerOptions {
/** Window length in ms. Default one hour — what `turnsPerHour` promises. */
readonly windowMs?: number;
/** Distinct callers held before the least-recently-touched is evicted. */
readonly maxCallers?: number;
/** @internal Test seam — the clock. */
readonly _now?: () => number;
}
export declare function spendLedger(options?: SpendLedgerOptions): SpendLedger;
/**
* The ledger key for one caller.
*
* A verified user is keyed by the id the TOKEN proved. Everyone else shares one
* anonymous bucket — and sharing is the honest answer rather than a weakness:
* there is no other fact to key on. A session id would let a caller mint a
* fresh budget by asking for a new conversation, and an IP address is neither
* available to this port nor a person.
*/
export declare function spendKeyFor(identity: VerifiedIdentity | undefined): string;