agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
219 lines • 10.5 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.spendKeyFor = exports.spendLedger = exports.turnsPerHour = void 0;
/** 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 }),
* });
*/
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.`
: ''),
};
},
};
}
exports.turnsPerHour = turnsPerHour;
/** How many distinct callers one process's ledger holds before evicting. */
const DEFAULT_MAX_CALLERS = 10_000;
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;
},
};
}
exports.spendLedger = 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.
*/
function spendKeyFor(identity) {
return identity === undefined ? '#anonymous' : `user:${identity.userId}`;
}
exports.spendKeyFor = spendKeyFor;
//# sourceMappingURL=admission.js.map