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
TypeScript
/**
* 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;