agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
844 lines • 37.8 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.pausedRequestIdOf = exports.pausedRequestLedgerOf = exports.pausedCallIdOf = exports.recordedRequestId = exports.watchDecisionUse = exports.noteDecisionTakenUp = exports.runEndedBy = exports.spendDecision = exports.checkDecisionAgainst = exports.runsAsAsked = exports.unanswerableReason = exports.heldDecision = exports.gatedCallOf = exports.mintConsentRequest = exports.closedByAHost = exports.proveNeverRan = exports.closedProvenKey = exports.ledgerIdentityOf = exports.consentLedgerOf = exports.registerConsentLedger = exports.processDecisionLedger = exports.consentRulesOf = exports.registerConsentRules = exports.inMemoryDecisionLedger = void 0;
const canonicalJson_js_1 = require("../lib/canonicalJson.js");
const jsonFaithful_js_1 = require("../lib/jsonFaithful.js");
const sha256_js_1 = require("../lib/sha256.js");
const checkin_js_1 = require("./checkin.js");
const pause_js_1 = require("./pause.js");
/**
* 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.
*/
function inMemoryDecisionLedger() {
const spent = new Set();
const ledger = {
spend(requestId) {
if (spent.has(requestId))
return false;
spent.add(requestId);
return true;
},
isSpent(requestId) {
return spent.has(requestId);
},
};
return ledger;
}
exports.inMemoryDecisionLedger = inMemoryDecisionLedger;
/**
* The key the process state is kept under on `globalThis`. `Symbol.for`, so
* every copy of this package in the process — its CJS and ESM builds, loaded
* side by side — finds the same state; versioned, so a later release that
* changes its shape keeps its own.
*/
const PROCESS_STATE = Symbol.for('agentfootprint.consentRequest.processState.v1');
/**
* This process's consent state, made by whichever copy of this package asks
* first. Two copies with two ledgers would answer one decision twice (a
* replay across them), and a door watching through one copy would never see
* a spend made through the other.
*/
function processState() {
const holder = globalThis;
const existing = holder[PROCESS_STATE];
if (existing !== undefined)
return existing;
const made = {
ledger: inMemoryDecisionLedger(),
watches: new Map(),
rules: new WeakMap(),
};
Object.defineProperty(globalThis, PROCESS_STATE, { value: made });
return made;
}
/**
* 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.
*/
function registerConsentRules(agent, rules) {
processState().rules.set(agent, rules);
}
exports.registerConsentRules = registerConsentRules;
/**
* 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.
*/
function consentRulesOf(runner) {
if (typeof runner !== 'object' || runner === null)
return undefined;
return processState().rules.get(runner)?.();
}
exports.consentRulesOf = consentRulesOf;
/**
* 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.
*/
function processDecisionLedger() {
return processState().ledger;
}
exports.processDecisionLedger = processDecisionLedger;
const PROOF_STATE = Symbol.for('agentfootprint.consentRequest.proofState.v2');
function proofState() {
const holder = globalThis;
const existing = holder[PROOF_STATE];
if (existing !== undefined)
return existing;
const made = { processLedger: `process:${randomHex(16)}`, ledgers: new WeakMap() };
Object.defineProperty(globalThis, PROOF_STATE, { value: made });
return made;
}
/**
* 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.
*/
function registerConsentLedger(agent, ledger) {
proofState().ledgers.set(agent, ledger ?? processDecisionLedger());
}
exports.registerConsentLedger = registerConsentLedger;
/**
* 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).
*/
function consentLedgerOf(runner) {
if (typeof runner !== 'object' || runner === null)
return undefined;
return proofState().ledgers.get(runner);
}
exports.consentLedgerOf = consentLedgerOf;
/** The longest `DecisionLedger.storeId` a pause records. */
const MAX_LEDGER_ID = 200;
/**
* 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.
*/
function ledgerIdentityOf(ledger) {
if (ledger === processState().ledger)
return proofState().processLedger;
let storeId;
try {
storeId = ledger.storeId;
}
catch {
return undefined;
}
return typeof storeId === 'string' && storeId.length > 0 && storeId.length <= MAX_LEDGER_ID
? `store:${storeId}`
: undefined;
}
exports.ledgerIdentityOf = ledgerIdentityOf;
/**
* 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.
*/
function closedProvenKey(requestId) {
return `closed-proven:${requestId}`;
}
exports.closedProvenKey = closedProvenKey;
/**
* The ledger that may prove a closed pause's call never ran, or `undefined`
* when none can: the guards {@link proveNeverRan} and {@link closedByAHost}
* share.
*/
function provingLedger(question) {
const { requestIds, mintedUnder, runner } = question;
if (requestIds.length === 0 || mintedUnder === undefined)
return undefined;
if (typeof runner !== 'object' || runner === null)
return undefined;
const proof = proofState();
const ledger = proof.ledgers.get(runner);
if (ledger === undefined || ledgerIdentityOf(ledger) !== mintedUnder)
return undefined;
if (mintedUnder === proof.processLedger && !question.storeIsProcessLocal)
return undefined;
return ledger;
}
/** Whether `ledger` says a host recorded closing `requestId` — `false` when it cannot say. */
async function closeRecorded(ledger, requestId) {
if (typeof ledger.isSpent !== 'function')
return false;
try {
return (await ledger.isSpent(closedProvenKey(requestId))) === true;
}
catch {
return false;
}
}
/**
* 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.
*/
async function proveNeverRan(question) {
const ledger = provingLedger(question);
if (ledger === undefined)
return false;
for (const id of question.requestIds) {
let won;
try {
won = await ledger.spend(id);
}
catch {
return false;
}
if (won === true) {
// Recorded for a retry and for a late answer; the proof is the win
// above, so a marker that does not land costs only that.
try {
await ledger.spend(closedProvenKey(id));
}
catch {
// a retry will read "not proven" — the safe direction
}
}
else if (!(await closeRecorded(ledger, id))) {
return false;
}
}
return true;
}
exports.proveNeverRan = proveNeverRan;
/**
* 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.
*/
async function closedByAHost(question) {
const ledger = provingLedger(question);
if (ledger === undefined)
return false;
for (const id of question.requestIds) {
if (!(await closeRecorded(ledger, id)))
return false;
}
return true;
}
exports.closedByAHost = closedByAHost;
const DOMAIN = 'agentfootprint/consent-request/v1';
const HEX_ID = /^[0-9a-f]{64}$/;
const HEX_NONCE = /^[0-9a-f]{32}$/;
/**
* 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.
*/
function mintConsentRequest(call, ledger) {
let args;
try {
args = structuredClone(call.args);
}
catch {
return undefined;
}
if (typeof args !== 'object' || args === null || Array.isArray(args))
return undefined;
const nonce = randomHex(16);
const id = requestIdOf(nonce, { ...call, args });
if (id === undefined)
return undefined;
const mintedUnder = ledger === undefined ? undefined : ledgerIdentityOf(ledger);
return {
request: { id, nonce, ...(mintedUnder !== undefined && { ledger: mintedUnder }) },
args: args,
};
}
exports.mintConsentRequest = mintConsentRequest;
/**
* 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.
*/
function gatedCallOf(read, gate) {
return gate === 'ask'
? {
gate,
tool: read('pausedToolName'),
toolCallId: read('pausedToolCallId'),
party: read('pausedToolParty'),
args: read('pausedAskArgs'),
middleware: read('pausedAskMiddleware'),
askIndex: read('pausedAskIndex'),
chain: read('pausedAskChain'),
}
: {
gate,
tool: read('pausedToolName'),
toolCallId: read('pausedToolCallId'),
party: read('pausedToolParty'),
args: read('pausedCheckInArgs'),
};
}
exports.gatedCallOf = gatedCallOf;
/**
* 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.
*/
function heldDecision(input, gate, recorded, call, rules, ledger) {
const decision = decisionCopyOf(input);
if (decision === undefined)
throw new pause_js_1.DecisionRequiredError(gate, input);
const requestId = typeof decision.requestId === 'string' && decision.requestId.length > 0
? decision.requestId
: undefined;
// A pause that recorded no request can be answered by nothing — said first,
// because no decision, however it is built, would change that. No refusal
// carries an id (`ResumeDecisionError`): it would hand out the one it
// requires.
const pending = readPendingRequest(recorded);
if (pending === undefined)
throw new pause_js_1.ResumeDecisionError('request-not-recorded', gate.kind);
if (requestId === undefined)
throw new pause_js_1.ResumeDecisionError('no-request', gate.kind);
if (requestId !== pending.id)
throw new pause_js_1.ResumeDecisionError('other-request', gate.kind);
const refused = requestRefusal(recorded, call, rules, ledger);
if (refused !== undefined)
throw new pause_js_1.ResumeDecisionError(refused, gate.kind);
return decision;
}
exports.heldDecision = heldDecision;
/**
* The refusals about the paused request itself, whatever the decision says —
* the one rule {@link heldDecision} and {@link unanswerableReason} share.
*
* @param rules the resuming agent's chain by link name, or `undefined` when it
* is not known — an ask's chain is then not checked.
* @param ledger the resuming agent's ledger, or `undefined` when it is not
* known — the ledger binding is then not checked.
*/
function requestRefusal(recorded, call, rules, ledger) {
const pending = readPendingRequest(recorded);
if (pending === undefined)
return 'request-not-recorded';
if (requestIdOf(pending.nonce, call) !== pending.id)
return 'call-changed';
// The checkpoint is the one the person was asked over; the agent resuming it
// must still be one that runs the same rules from the asking link on.
if (call.gate === 'ask' && rules !== undefined && !sameChain(call.chain, rules, call.askIndex)) {
return 'chain-changed';
}
if (ledger !== undefined && spentElsewhere(recorded, ledger))
return 'ledger-changed';
return undefined;
}
/**
* Whether a decision for `recorded` would be spent in a ledger other than the
* one it was minted under, while that one can PROVE — a shared ledger with a
* `storeId`, or this process's own default ledger. A host closes a pause by
* spending its request in the minting ledger ({@link proveNeverRan}); a
* decision spent anywhere else would run the call where that ledger never saw
* it, and the close would record "did not run" about a call that ran. So such
* a resume is refused before anything runs, and the person is asked again.
* A request minted under a ledger that cannot prove — no identity, or an
* earlier process's default ledger, gone with it — keeps being spent wherever
* the resuming agent spends: no close can ever prove it either way.
*/
function spentElsewhere(recorded, ledger) {
const mintedUnder = typeof recorded === 'object' && recorded !== null
? recorded.ledger
: undefined;
if (typeof mintedUnder !== 'string' || mintedUnder.length === 0)
return false;
if (mintedUnder.startsWith('process:') && mintedUnder !== proofState().processLedger) {
return false;
}
return ledgerIdentityOf(ledger) !== mintedUnder;
}
/**
* 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).
*/
function unanswerableReason(checkpoint, gate, rules, ledger) {
let state;
try {
state = pausedStateOf(checkpoint);
}
catch {
return undefined;
}
if (state === undefined)
return undefined;
const read = (key) => state?.[key];
return requestRefusal(read('pausedRequest'), gatedCallOf(read, gate), rules, ledger);
}
exports.unanswerableReason = unanswerableReason;
/**
* 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`.
*/
function runsAsAsked(recorded, call, wouldRun) {
const pending = readPendingRequest(recorded);
return (pending !== undefined && requestIdOf(pending.nonce, { ...call, args: wouldRun }) === pending.id);
}
exports.runsAsAsked = runsAsAsked;
/**
* 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.
*/
function checkDecisionAgainst(checkpoint, gate, input, rules, ledger) {
const state = pausedStateOf(checkpoint);
if (state === undefined) {
if (decisionCopyOf(input) === undefined)
throw new pause_js_1.DecisionRequiredError(gate, input);
return;
}
const read = (key) => state[key];
heldDecision(input, gate, read('pausedRequest'), gatedCallOf(read, gate.kind), rules, ledger);
}
exports.checkDecisionAgainst = checkDecisionAgainst;
/**
* 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`).
*/
async function spendDecision(ledger, decision, gate, signal) {
refuseIfAborted(signal);
const attempts = attemptsOn(decision.requestId, ledger, decision.approved === false);
let fresh;
try {
fresh = await ledger.spend(decision.requestId);
}
catch (err) {
for (const attempt of attempts)
attempt.outcome = 'failed';
throw err;
}
for (const attempt of attempts)
attempt.outcome = fresh === true ? 'spent' : 'refused';
if (fresh !== true) {
// A host that closed the request recorded it (`proveNeverRan`): the
// person is told so — the call did not run — not "already answered".
throw new pause_js_1.ResumeDecisionError((await closeRecorded(ledger, decision.requestId)) ? 'closed-by-host' : 'already-answered', gate);
}
refuseIfAborted(signal);
}
exports.spendDecision = spendDecision;
/**
* 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.
*/
function runEndedBy(signal) {
if (signal === undefined || !signal.aborted)
return undefined;
const reason = signal.reason;
return reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'));
}
exports.runEndedBy = runEndedBy;
/** Throw what an aborted run ended with ({@link runEndedBy}). */
function refuseIfAborted(signal) {
const ended = runEndedBy(signal);
if (ended !== undefined)
throw ended;
}
/** Record an attempt to spend `requestId` in `ledger` on every watch open for it. */
function attemptsOn(requestId, ledger, declined) {
const attempts = [];
for (const watch of processState().watches.get(requestId) ?? []) {
const attempt = { ledger, declined, outcome: 'unanswered' };
watch.attempts.push(attempt);
attempts.push(attempt);
}
return attempts;
}
/**
* 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.
*/
function noteDecisionTakenUp(requestId) {
if (requestId === undefined)
return;
for (const watch of processState().watches.get(requestId) ?? [])
watch.takenUp = true;
}
exports.noteDecisionTakenUp = noteDecisionTakenUp;
/**
* 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.
*/
function watchDecisionUse(requestId) {
const watches = processState().watches;
const watched = { takenUp: false, attempts: [] };
const open = watches.get(requestId) ?? new Set();
open.add(watched);
watches.set(requestId, open);
return {
async mayHaveBeenUsed() {
if (watched.attempts.length === 0)
return !watched.takenUp;
for (const attempt of [...watched.attempts]) {
if (attempt.outcome !== 'failed')
return true;
if (!(await neverLanded(attempt.ledger, requestId)))
return true;
}
return false;
},
spentAsDecline() {
const { attempts } = watched;
return (attempts.some((attempt) => attempt.declined && attempt.outcome === 'spent') &&
attempts.every((attempt) => attempt.declined));
},
release() {
open.delete(watched);
if (open.size === 0 && watches.get(requestId) === open)
watches.delete(requestId);
},
};
}
exports.watchDecisionUse = watchDecisionUse;
/** Whether `ledger` says, when asked, that its failed spend of `requestId` never landed. */
async function neverLanded(ledger, requestId) {
if (typeof ledger.isSpent !== 'function')
return false;
try {
return (await ledger.isSpent(requestId)) === false;
}
catch {
return false;
}
}
/** The id of the request a paused run's state records (`pausedRequest`), or `undefined`. */
function recordedRequestId(recorded) {
return readPendingRequest(recorded)?.id;
}
exports.recordedRequestId = recordedRequestId;
/**
* The id of the call a checkpoint's paused stage records as paused
* (`pausedToolCallId`), or `undefined` — never a throw, whatever the
* checkpoint holds.
*/
function pausedCallIdOf(checkpoint) {
try {
const id = pausedStateOf(checkpoint)?.pausedToolCallId;
return typeof id === 'string' ? id : undefined;
}
catch {
return undefined;
}
}
exports.pausedCallIdOf = pausedCallIdOf;
/**
* 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.
*/
function pausedRequestLedgerOf(checkpoint) {
try {
const recorded = pausedStateOf(checkpoint)?.pausedRequest;
const ledger = typeof recorded === 'object' && recorded !== null
? recorded.ledger
: undefined;
return typeof ledger === 'string' && ledger.length > 0 ? ledger : undefined;
}
catch {
return undefined;
}
}
exports.pausedRequestLedgerOf = pausedRequestLedgerOf;
/**
* The id of the request a checkpoint's paused stage records, or `undefined`
* — never a throw, whatever the checkpoint holds.
*/
function pausedRequestIdOf(checkpoint) {
try {
const state = pausedStateOf(checkpoint);
return state === undefined ? undefined : recordedRequestId(state.pausedRequest);
}
catch {
return undefined;
}
}
exports.pausedRequestIdOf = pausedRequestIdOf;
/** The request's id for `call` under `nonce`, or `undefined` when the call is not faithful JSON. */
function requestIdOf(nonce, call) {
const body = {
gate: call.gate,
tool: call.tool,
toolCallId: call.toolCallId,
party: call.party,
args: call.args,
middleware: call.middleware,
askIndex: call.askIndex,
chain: call.chain,
};
if (!(0, jsonFaithful_js_1.isJsonFaithful)(body))
return undefined;
return (0, sha256_js_1.sha256Hex)(`${DOMAIN}\u001f${nonce}\u001f${(0, canonicalJson_js_1.canonicalJson)(body)}`);
}
/**
* Whether `rules` (an agent's whole chain, by link name) runs `recorded` (the
* names an ask bound, from its own link on) from `askIndex` — the same links,
* by name, in the same order, and no others after them.
*/
function sameChain(recorded, rules, askIndex) {
if (!Array.isArray(recorded) || typeof askIndex !== 'number' || !Number.isInteger(askIndex)) {
return false;
}
if (askIndex < 0)
return false;
const live = rules.slice(askIndex);
return live.length === recorded.length && live.every((name, at) => name === recorded[at]);
}
function readPendingRequest(recorded) {
if (typeof recorded !== 'object' || recorded === null)
return undefined;
const { id, nonce } = recorded;
return typeof id === 'string' &&
HEX_ID.test(id) &&
typeof nonce === 'string' &&
HEX_NONCE.test(nonce)
? { id, nonce }
: undefined;
}
/**
* A copy of the input's decision fields, each read ONCE and then judged — the
* input is caller data, and a getter or a proxy must not answer the check one
* way and the use another. `undefined` when the copy is not decision-shaped,
* or the input cannot be read at all.
*/
function decisionCopyOf(input) {
if (typeof input !== 'object' || input === null)
return undefined;
let copy;
try {
const source = input;
copy = {
requestId: source.requestId,
approved: source.approved,
by: source.by,
at: source.at,
note: source.note,
value: source.value,
};
}
catch {
return undefined;
}
if (!(0, checkin_js_1.looksLikeDecision)(copy))
return undefined;
const { note, value, ...fields } = copy;
return {
...fields,
...(note !== undefined && { note: note }),
...(value !== undefined && { value: value }),
};
}
function pausedStateOf(checkpoint) {
const path = checkpoint.subflowPath;
if (!Array.isArray(path) || path.length === 0)
return objectOrUndefined(checkpoint.sharedState);
const innermost = path[path.length - 1];
return typeof innermost === 'string'
? objectOrUndefined(checkpoint.subflowStates?.[innermost])
: undefined;
}
function objectOrUndefined(value) {
return typeof value === 'object' && value !== null
? value
: undefined;
}
function randomHex(bytes) {
const buffer = globalThis.crypto.getRandomValues(new Uint8Array(bytes));
let out = '';
for (const byte of buffer)
out += byte.toString(16).padStart(2, '0');
return out;
}
//# sourceMappingURL=consentRequest.js.map