UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

213 lines • 10.3 kB
/** * 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. */ /** One hour, in ms — the window this policy's name promises. */ const HOUR_MS = 3_600_000; /** * 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 function turnsPerHour(options) { const { limit } = options; if (!Number.isInteger(limit) || limit < 1) { throw new TypeError(`[hosting] turnsPerHour({ limit: ${String(limit)} }) is not a ceiling. It is how many ` + `turns one caller may start per hour, so it has to be a positive integer — a limit of ` + `zero would refuse everything, which is a closed door rather than a rate limit.`); } const anonymousLimit = options.anonymousLimit ?? limit; if (!Number.isInteger(anonymousLimit) || anonymousLimit < 0) { throw new TypeError(`[hosting] turnsPerHour({ anonymousLimit: ${String(anonymousLimit)} }) is not a ceiling. ` + `Use a non-negative integer — 0 refuses anonymous callers outright, which is a ` + `legitimate posture and should be spelled that way.`); } return { decide({ identity, recentSpend }) { const ceiling = identity === undefined ? anonymousLimit : limit; if (recentSpend.turns < ceiling) return 'allow'; const who = identity === undefined ? 'Anonymous callers share' : 'You have'; return { refuse: `${who} a limit of ${ceiling} turns per hour and ${identity === undefined ? 'that bucket is' : 'you are'} at ${recentSpend.turns}. ` + `The window is rolling, so the next turn becomes available as the oldest one ages ` + `past an hour — no reset to wait for at a fixed time.` + (identity === undefined ? ` Signing in gets you your own bucket rather than the shared anonymous one.` : ''), }; }, }; } /** How many distinct callers one process's ledger holds before evicting. */ const DEFAULT_MAX_CALLERS = 10_000; export function spendLedger(options = {}) { const windowMs = options.windowMs ?? HOUR_MS; const maxCallers = options.maxCallers ?? DEFAULT_MAX_CALLERS; const now = options._now ?? Date.now; const startedAt = now(); /** Insertion-ordered, so the FIRST key is the least recently touched — a * `Map` re-inserted on touch is an LRU with no second structure. */ const byCaller = new Map(); const touch = (key) => { const held = byCaller.get(key); if (held !== undefined) { byCaller.delete(key); byCaller.set(key, held); return held; } while (byCaller.size >= maxCallers) { const oldest = byCaller.keys().next(); if (oldest.done === true) break; byCaller.delete(oldest.value); } const fresh = []; byCaller.set(key, fresh); return fresh; }; const prune = (entries, at) => { const cutoff = at - windowMs; let drop = 0; // Entries are appended in time order, so the ones to drop are a PREFIX — // one splice rather than a filter that reallocates every window read. for (const entry of entries) { if (entry.at > cutoff) break; drop += 1; } if (drop > 0) entries.splice(0, drop); return entries; }; return { admit(key) { const at = now(); const entries = prune(touch(key), at); entries.push({ at, inputTokens: 0, outputTokens: 0, usd: 0, priced: false }); }, add(key, usage) { const entries = byCaller.get(key); // Usage with no admitted turn behind it is dropped rather than opening a // turn of its own: the turn count is what a policy bounds, and inventing // one from a stray token event would bill a caller for a turn nobody // admitted. const entry = entries?.[entries.length - 1]; if (entry === undefined) return; entry.inputTokens += usage.inputTokens ?? 0; entry.outputTokens += usage.outputTokens ?? 0; if (usage.usd !== undefined) { entry.usd += usage.usd; entry.priced = true; } }, read(key) { const at = now(); const entries = prune(byCaller.get(key) ?? [], at); let inputTokens = 0; let outputTokens = 0; let usd = 0; let priced = false; for (const entry of entries) { inputTokens += entry.inputTokens; outputTokens += entry.outputTokens; usd += entry.usd; priced ||= entry.priced; } return { turns: entries.length, inputTokens, outputTokens, // Absent, not zero, when nothing priced this window — see RecentSpend. ...(priced && { usd }), windowMs, complete: at - startedAt >= windowMs, }; }, get size() { return byCaller.size; }, }; } /** * 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 function spendKeyFor(identity) { return identity === undefined ? '#anonymous' : `user:${identity.userId}`; } //# sourceMappingURL=admission.js.map