UNPKG

agentfootprint

Version:

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

489 lines (488 loc) • 28.5 kB
/** * consentRequest — the request a consent gate puts to a person: how it is * named, what it is bound to, and how a decision is held to it, once. * * Role: the ONE owner of that law. A consent gate (a tool's `checkIn`, a * middleware's `ask`) pauses the run; `mintConsentRequest` mints the request * at the pause, and `heldDecision` + `spendDecision` hold a resume's * decision to it. The dispatch loop (`agent/stages/toolCalls.ts`) is * the only writer and the only place a decision is used; `Agent.resume` * asks the same pure question earlier, so a mismatch is refused before * anything moves. * Reads: the call about to pause; at a resume, the paused run's own state * (`gatedCallOf`) and the decision. * Writes: nothing — the dispatch loop writes what `mintConsentRequest` returns. * * ── WHY THE ID IS A COMMITMENT, NOT ONLY A NAME ───────────────────────────── * `id = sha256(domain ‖ nonce ‖ canonicalJson(call))`, where the call is * everything that runs on approval: the gate, the tool, the call id, the * served party, the arguments and — for an ask — the middleware, where the * chain resumes and the names of the rules from the asking one on (a resume * refuses an agent whose chain no longer runs those rules, and a rule after * the ask that hands back other arguments than the ones approved — the tool * does not run). The nonce is 128 random bits kept in the paused run's state. * * - A random nonce makes every pause's id unique and unguessable, so a * decision for one pause can never answer another — even two pauses of * the same tool, with the same model-chosen call id and the same * arguments. * - Re-deriving the id from the state at resume binds the decision to the * exact call: a checkpoint whose arguments, tool, call id, party or chain * position changed after the person was asked derives a different id, and * no nonce makes it derive the old one (that would be a SHA-256 preimage). * The person's decision quotes the id they were shown, so a checkpoint * changed after the ask cannot be made to match it. * - What it does NOT stop is a hostile writer of the checkpoint store: one * who rewrites the call, the nonce and the request shown together BEFORE * the person decides (they then approve what they were shown), or who * switches the pause into a kind whose resume takes no decision (a * credential-consent pause). The binding detects drift and replay, not * that writer; a host that does not trust its store signs checkpoints or * keeps them server-side. * - The id carries no run value. The nonce does unlock it — whoever holds * the nonce can confirm a guess of the arguments — so the state key that * carries it (`pausedRequest`) is named with the arguments in the * conversation redaction vocabulary (`redaction/conversation.ts`). * * ── THE DOMAIN ───────────────────────────────────────────────────────────── * The call is fingerprinted as JSON, because JSON is what a person is shown and * what a stored checkpoint holds. A call JSON cannot write down faithfully (a * `Date`, a `Map`, `Infinity` — `lib/jsonFaithful.ts`) cannot be bound, and * `mintConsentRequest` says so instead of fingerprinting a value two different calls * would share. JSON's own equivalences hold: a property whose value is * `undefined` is the property absent, and `-0` is `0`. * * ── ONCE ─────────────────────────────────────────────────────────────────── * A request is spent the moment a resume uses a decision for it, approved or * declined (`DecisionLedger`), and "once" holds PER LEDGER. The default is one * ledger in this process's memory, shared by every agent that names none — a * pool that rebuilds an evicted instance, a host that builds a runner per * request, a composition's nested agents all spend in it. It is gone after a * restart and no other process sees it: where more than one process (or a * restart) can resume the same paused run, every agent that can resume it is * given one ledger in a store they all reach (`Agent.create({ decisionLedger })`). * The process's ledger and everything below that is process-wide live on * `globalThis` under one versioned key, so an app that loads both builds of * this package (CJS and ESM) still has ONE (`processState`). * * ── WAS IT USED? ─────────────────────────────────────────────────────────── * A host that keeps the pause asks this after a resume of it failed: the call * may have run only if the decision reached a ledger. `spendDecision` is the * one place a decision does, so it reports every attempt to whoever watches * that request (`watchDecisionUse`), and the ledger it went to is asked when * the attempt's own answer was lost. A resume this library took up and never * took to a ledger used nothing; one it never took up cannot be told apart * from a use (`hosting/answeredPause.ts`). * * ── DID IT NEVER RUN? ────────────────────────────────────────────────────── * A watch sees one resume. A host closing a pause no answer can reach asks a * stronger question — can ANY decision for it have been used, by any resume, * before this process started included — and says "did not run" only on * proof (`proveNeverRan`): the ledger the request was MINTED under (its * identity rides the pause, `PendingRequest.ledger`) spends the request * itself, atomically. `true` means no decision had reached it, and none ever * can now — the spend is a tombstone, not a read that a resume elsewhere * could overtake. Anything else is not proof. */ import type { FlowchartCheckpoint } from 'footprintjs'; import { type CheckInDecision } from './checkin.js'; import { type ConsentGate, type ConsentGateKind } from './pause.js'; /** * The call a consent gate asked a person about — what runs on approval. Read * loosely (`unknown`) because at a resume it comes from a checkpoint: whatever * the state holds is what would run, and that is what gets fingerprinted. */ export interface GatedCall { readonly gate: ConsentGateKind; /** The tool's name. */ readonly tool: unknown; /** The model's id for the call. */ readonly toolCallId: unknown; /** The party the tool resolves against (`AgentState.pausedToolParty`), when one was recorded. */ readonly party?: unknown; /** The arguments the call runs with. */ readonly args: unknown; /** `'ask'` only — the middleware that asked. */ readonly middleware?: unknown; /** `'ask'` only — the asking link's index; the chain resumes after it. */ readonly askIndex?: unknown; /** * `'ask'` only — the names of the asking link and of every link after it, * in chain order: the rules the call still passes once the person answers. * A position alone would let a redeploy that removed or moved a later rule * resume past it; the names make that a refusal (`'chain-changed'`). */ readonly chain?: unknown; } /** A consent gate's request as the paused run's state carries it (`AgentState.pausedRequest`). */ export interface PendingRequest { /** The id a decision quotes — 64 hex characters. */ readonly id: string; /** 128 random bits, hex — the commitment's key. Never served on the request. */ readonly nonce: string; /** * The identity of the ledger the request was minted under — the agent's * `decisionLedger` (`store:` + its `storeId`) or this process's default * (`process:` + a per-process random id). Only that ledger can prove the * paused call never ran ({@link proveNeverRan}). Absent when the ledger has * no stable identity, and on a pause stored before it was recorded: such a * pause can never be proven. */ readonly ledger?: string; } /** What {@link mintConsentRequest} hands the dispatch loop. */ export interface BoundRequest { readonly request: PendingRequest; /** * The detached copy of the arguments the request is bound to. The call * carries THIS copy from here on — on the request the person is shown, in * the paused run's state, and into the tool on approval — so nothing that * still holds the original can change what was approved. */ readonly args: Readonly<Record<string, unknown>>; } /** * Where the consent requests that have been answered are recorded, so one * decision runs one call — once per LEDGER. * * By default every agent that names none spends in one ledger in this * process's memory: "once" holds across this process's agents — a pool that * rebuilds an evicted instance, a server that builds an agent per request — * and only there. A restart forgets it, and another process never saw it. * When more than one process can resume the same paused run, or a restart * must not forget, give every agent that can resume it one ledger backed by a * store they all reach (`Agent.create({ decisionLedger })`). */ export interface DecisionLedger { /** * OPTIONAL — the identity of the store this ledger records into. A host that * closes a pause no answer can reach asks the ledger the pause was minted * under — and only that one — to prove its call never ran * ({@link DecisionLedger.spend}); a ledger without a `storeId` never proves, * and the call is recorded as possibly run. * * The contract this library cannot check, and that the proof rests on: * * - unique per BACKING STORE: one value for one table (or collection, or * key space) and every process that writes it — never copied from * staging to production, never shared by per-tenant tables; * - permanent: the same for the life of that store; * - changed on any migration to a new store — a request spent in the old * store is not in the new one, and must not be read as never spent there. * Pending approvals are then asked again: a decision is spent only in the * ledger its request was asked under (`'ledger-changed'`). * * Spends in it are permanent too (no expiry) — see `spend`. */ readonly storeId?: string; /** * Record `requestId` as answered. Resolve `true` when THIS call recorded it * and `false` when it was already recorded — atomically per id: of two * concurrent calls for one id, exactly one may see `true`. Anything other * than `true` refuses the decision; a throw fails the resume. Both fail * closed: the tool does not run. * * A recorded id stays recorded: for a ledger that is to prove a paused call * never ran (one with a {@link DecisionLedger.storeId}), the result is * permanent — no expiry. A host closing a pause spends its request here as * the proof (`true`: no decision had reached it, and none ever can), then * `closed-proven:<requestId>` to record that IT closed it; a key that expired * would let a spent request read as never spent. */ spend(requestId: string): boolean | Promise<boolean>; /** * OPTIONAL — whether `requestId` is recorded as answered, without recording * it. Asked only after a {@link DecisionLedger.spend} for it failed, so a * host can tell a spend that never landed (the call did not run; the pause * stays answerable) from one that did. A ledger without it, or one that * throws or answers anything but `false`, counts as spent: the host stops * offering the pause. Asked, too, whether a host recorded closing a request * (`closed-proven:<requestId>`) — so a retried close keeps the verdict the * first one proved, and a person whose answer arrives after the close is * told the host closed it. Never asked whether the REQUEST is unspent as a * proof that its call did not run: a read can be overtaken by a resume * elsewhere; only `spend` proves. */ isSpent?(requestId: string): boolean | Promise<boolean>; } /** * A NEW {@link DecisionLedger} in this process's memory. Grows by one id per * answered request. * * Agents that name no ledger already share one of these per process, so this * is for a ledger of their own: agents given different ledgers spend * separately, and a decision spent in one is not known to the others. */ export declare function inMemoryDecisionLedger(): DecisionLedger; /** * Register the rules `agent` holds an ask's approval to — its `toolMiddleware` * chain by link name, read each time it is asked — for a host that keeps its * pauses ({@link consentRulesOf}). Called by every `Agent` it builds. */ export declare function registerConsentRules(agent: object, rules: () => readonly string[]): void; /** * The rules `runner` holds an ask's approval to, when it is an agent this * library built — `undefined` for any other runner (a composition, an app's * own), whose nested agents' rules a host cannot see. */ export declare function consentRulesOf(runner: unknown): readonly string[] | undefined; /** * This process's ledger — where an agent that names no `decisionLedger` * spends. One per process, so a decision runs once across every agent * instance this process builds (and every copy of this package it loaded), * not once per instance. It lives as long as the process and grows by one id * per answered request; request ids are unique per pause, so sharing it never * makes one agent's answer collide with another's. */ export declare function processDecisionLedger(): DecisionLedger; /** * Register the ledger `agent` spends its decisions in — `undefined` for the * process's ({@link processDecisionLedger}) — so a host that closes one of its * pauses can ask it for the proof ({@link proveNeverRan}). Called by every * `Agent` it builds. */ export declare function registerConsentLedger(agent: object, ledger: DecisionLedger | undefined): void; /** * The ledger `runner` spends decisions in, when it is an agent this library * built — `undefined` for any other runner (a composition, an app's own). */ export declare function consentLedgerOf(runner: unknown): DecisionLedger | undefined; /** * The identity a pause records for the ledger its request is minted under * ({@link PendingRequest.ledger}), or `undefined` for one that has none: * * - this process's default ledger: `process:` + an id drawn when the process * first asked. A restart draws another, so it never answers for a pause an * earlier process stored — that process took its ledger with it; * - a ledger naming its store (`DecisionLedger.storeId`): `store:` + that id; * - any other — an app's own `inMemoryDecisionLedger()`, a ledger without a * `storeId` — none: it may be one agent's alone, and nothing tells it * apart from another. */ export declare function ledgerIdentityOf(ledger: DecisionLedger): string | undefined; /** What {@link proveNeverRan} needs to know about a pause being closed, and the door closing it. */ export interface NeverRanQuestion { /** Every id the paused request is known by — the state's and the question's. */ readonly requestIds: readonly string[]; /** The identity of the ledger the request was minted under ({@link PendingRequest.ledger}). */ readonly mintedUnder: string | undefined; /** The runner serving the session — an agent this library built names its ledger. */ readonly runner: unknown; /** * The door keeps its pauses in this process's memory, so no other process * can have answered one. Asked only of this process's default ledger. */ readonly storeIsProcessLocal: boolean; } /** * The key a closer spends, after it won a request, to record that IT closed it * ({@link proveNeverRan}). Request ids are 64 hex characters, so no request id * is ever one of these. */ export declare function closedProvenKey(requestId: string): string; /** * Prove that no decision for a paused request can ever run its call — or say * that it cannot be proven. Spending is the one way a decision reaches a call * (`spendDecision` runs before the tool, on every door), so the proof is the * ledger the request was MINTED under spending the request itself: `true` * there means no decision had reached it and — in the same atomic step — that * none ever can (a later resume is refused). A read (`isSpent`) of the request * would not do: a resume on another replica could spend and run the call * between the read and the close, and a ledger may expire what it reads. * * IDEMPOTENT. Having won the request, the closer spends a second key, * {@link closedProvenKey}, to record that it closed it. The closed pause is * stored only afterwards, and that can fail (the store's write, the run that * goes on from it); a retried close finds the request spent and reads the * marker (`isSpent`) — present: the verdict the first close proved; absent * (a crash between the two spends, a ledger without `isSpent`): not proven, the * safe direction. A request a DECISION spent never has the marker. * * CONSUMES the request: asked only for a pause being closed. * * Fails closed — `false` — whenever the proof cannot be had: * * - the pause names no request, or no minting ledger (one stored before * either was recorded, or minted under a ledger with no identity); * - `runner` is not an agent this library built (a composition, an app's * own), so its ledger is unknown; * - its ledger is not the one the request was minted under: a redeploy that * switched ledgers, or a restart (this process's ledger is a new one) — * that ledger never saw the spends; * - the request was minted under this process's default ledger and the * door's session store is not this process's memory: another replica, on * its own default ledger, may have spent it and run the call; * - a spend throws, or answers anything but `true` with no close recorded. */ export declare function proveNeverRan(question: NeverRanQuestion): Promise<boolean>; /** * Whether a host already closed this paused request with the proof that its * call never ran — read, never spent: the ledger it was minted under holds the * close marker for every id ({@link closedProvenKey}). Asked by a door whose * answer to the pause failed because the request was already spent, so a pause * closed elsewhere (whose closed conversation was never stored) is closed as * not run rather than marked as possibly run. */ export declare function closedByAHost(question: NeverRanQuestion): Promise<boolean>; /** * Mint the request for a call about to pause, over a detached copy of its * arguments. `undefined` when the call cannot be bound — its arguments cannot * be copied, are not an object, or are not values JSON writes down faithfully * — and the gate must refuse the call instead of pausing. * * @param ledger the ledger a decision for it will be spent in — its identity * rides the request ({@link PendingRequest.ledger}), so only that ledger can * later prove the call never ran. */ export declare function mintConsentRequest(call: GatedCall, ledger?: DecisionLedger): BoundRequest | undefined; /** * The paused call as the paused run's state records it — the one reading both * the resume's early check and the dispatch loop take, so they cannot disagree * about what the request was bound to. */ export declare function gatedCallOf(read: (key: string) => unknown, gate: ConsentGateKind): GatedCall; /** * Hold a resume's input to the request the run is paused on. Pure: it spends * nothing (see {@link spendDecision}). * * @param recorded the paused run's `pausedRequest`, as read — never trusted. * @param call the paused call, as read ({@link gatedCallOf}). * @param rules the resuming agent's own `toolMiddleware` chain, by link * name — an ask's approval holds only for the rules it was asked under. * @param ledger the ledger the resuming agent spends decisions in — a * request minted under a ledger that can prove is spent only there. * @returns a detached copy of the decision. * @throws DecisionRequiredError when the input is not a decision at all. * @throws ResumeDecisionError (`'no-request'`, `'request-not-recorded'`, * `'other-request'`, `'call-changed'`, `'chain-changed'`, * `'ledger-changed'`) when it cannot answer this request. */ export declare function heldDecision(input: unknown, gate: ConsentGate, recorded: unknown, call: GatedCall, rules: readonly string[], ledger?: DecisionLedger): CheckInDecision; /** * The refusals no decision can change — a paused request that can never be * answered by the agent whose rules are `rules`. See {@link unanswerableReason}. */ export type UnanswerableReason = 'request-not-recorded' | 'call-changed' | 'chain-changed' | 'ledger-changed'; /** * Why the request a checkpoint's paused stage records can never be answered — * by any decision — or `undefined` when it can, or when that cannot be told * from the checkpoint (its paused state is not where it should be). * * Read by a host that keeps the pause, so a session is not held by a question * nobody can answer any more: one paused before requests were named * (`'request-not-recorded'`), one whose call changed after the ask * (`'call-changed'`), an ask made under rules the resuming agent no longer * runs (`'chain-changed'` — told only when `rules` are known), or a request * minted under a proving ledger the resuming agent no longer spends in * (`'ledger-changed'` — told only when `ledger` is known). */ export declare function unanswerableReason(checkpoint: FlowchartCheckpoint, gate: ConsentGateKind, rules: readonly string[] | undefined, ledger?: DecisionLedger): UnanswerableReason | undefined; /** * Whether `wouldRun` are the arguments the paused request was minted over — * decided by re-deriving the request's id with them in place of the recorded * ones: the call that runs must be the call the id the person was shown * commits to. Key order and JSON's own equivalences do not count; a value * JSON cannot write down faithfully never matches. * * Held to the REQUEST, never to a copy: an ask's resume hands the rest of the * chain a copy of the approved arguments, and a link that edited that copy in * place would make any comparison against it agree with itself. * * @param recorded the paused run's `pausedRequest`, as read. * @param call the paused call, as read ({@link gatedCallOf}); its own * `args` are replaced by `wouldRun`. */ export declare function runsAsAsked(recorded: unknown, call: GatedCall, wouldRun: unknown): boolean; /** * The same question as {@link heldDecision}, asked of a checkpoint before a * resume starts — so a mismatch is refused while nothing has moved. Reads the * paused stage's own state (the root's, or the innermost subflow's on the * pause path). A state it cannot find is left to the dispatch loop, which asks * again where the decision is used. */ export declare function checkDecisionAgainst(checkpoint: FlowchartCheckpoint, gate: ConsentGate, input: unknown, rules: readonly string[], ledger?: DecisionLedger): void; /** * Spend the decision's request in `ledger` — the one place a decision reaches * a ledger, so every attempt is reported to whoever watches the request * ({@link watchDecisionUse}). * * @param signal the resumed run's signal. The engine stops waiting on a stage * when its run is aborted, but the stage goes on running: a resume whose run * was aborted takes nothing to a ledger, and one aborted while its ledger * answered is refused after the spend — spent, and its call never run. * @throws ResumeDecisionError `'already-answered'` when a resume already spent * it, or `'closed-by-host'` when a host closed it and recorded that its call * never ran (`closedProvenKey`). */ export declare function spendDecision(ledger: DecisionLedger, decision: CheckInDecision, gate: ConsentGateKind, signal?: AbortSignal): Promise<void>; /** * What an aborted run ended with — the signal's own reason, as the engine * reports it — or `undefined` while the run goes on. Asked before the spend, * after it, and by the dispatch loop once more at the last moment before an * approved call runs (`toolCalls.ts` · `resolveCredentialAndExecute`): the * engine stops waiting on an aborted run's stage, but the stage goes on, and * nothing may run for a caller who was already told the run ended. */ export declare function runEndedBy(signal: AbortSignal | undefined): Error | undefined; /** * A resume of this library has taken up the request `requestId` names: from * here every use of its decision passes through {@link spendDecision}, where a * watch sees it. Called by every runner's resume (`RunnerBase` · * `emitPauseResume`, and `Agent.resume` before its first check) and by the * dispatch loop where it takes the decision up. */ export declare function noteDecisionTakenUp(requestId: string | undefined): void; /** What a door that keeps a pause learns, for one resume, about its request ({@link watchDecisionUse}). */ export interface DecisionUseWatch { /** * Whether the request's decision may have been used while watched — by * this resume, or by any other in this process: * * - taken to a ledger that spent it, refused it (an earlier resume had) * or has not answered yet → `true`; * - taken to a ledger whose spend failed → asked again of that ledger * (`DecisionLedger.isSpent`): `false` there means the spend never landed; * no answer, a throw, or a ledger that cannot be asked → `true`; * - never taken to a ledger, by a resume this library took up → `false`; * - never taken up by this library at all → `true`: that cannot be told * apart from a use. */ mayHaveBeenUsed(): Promise<boolean>; /** * Whether a ledger SPENT the request for a decline while watched, and no * approval of it was taken to a ledger meanwhile: the decline won the * ledger, so no approval can have run the call there. Only then does a door * record the paused call as declined. A decline the ledger refused (an * earlier resume — an approval, perhaps in another process — had spent the * request) is not one: that call may have run. */ spentAsDecline(): boolean; /** Stop watching. A door releases its watch once it has decided. */ release(): void; } /** * Watch the request `requestId` names for the length of one resume — opened * by a door that keeps the pause, before it hands the decision over, and * released once it has decided what a failure means for the stored pause. * Keyed by the request, never by the decision object a door may copy or wrap. */ export declare function watchDecisionUse(requestId: string): DecisionUseWatch; /** The id of the request a paused run's state records (`pausedRequest`), or `undefined`. */ export declare function recordedRequestId(recorded: unknown): string | undefined; /** * The id of the call a checkpoint's paused stage records as paused * (`pausedToolCallId`), or `undefined` — never a throw, whatever the * checkpoint holds. */ export declare function pausedCallIdOf(checkpoint: FlowchartCheckpoint): string | undefined; /** * The identity of the ledger the request a checkpoint's paused stage records * was minted under ({@link PendingRequest.ledger}), or `undefined` — never a * throw, whatever the checkpoint holds. */ export declare function pausedRequestLedgerOf(checkpoint: FlowchartCheckpoint): string | undefined; /** * The id of the request a checkpoint's paused stage records, or `undefined` * — never a throw, whatever the checkpoint holds. */ export declare function pausedRequestIdOf(checkpoint: FlowchartCheckpoint): string | undefined;