UNPKG

agentfootprint

Version:

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

817 lines • 36 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 { canonicalJson } from '../lib/canonicalJson.js'; import { isJsonFaithful } from '../lib/jsonFaithful.js'; import { sha256Hex } from '../lib/sha256.js'; import { looksLikeDecision } from './checkin.js'; import { DecisionRequiredError, ResumeDecisionError, } from './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. */ export 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; } /** * 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. */ export function registerConsentRules(agent, rules) { processState().rules.set(agent, rules); } /** * 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 function consentRulesOf(runner) { if (typeof runner !== 'object' || runner === null) return undefined; return processState().rules.get(runner)?.(); } /** * 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 function processDecisionLedger() { return processState().ledger; } 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. */ export function registerConsentLedger(agent, ledger) { proofState().ledgers.set(agent, ledger ?? processDecisionLedger()); } /** * 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 function consentLedgerOf(runner) { if (typeof runner !== 'object' || runner === null) return undefined; return proofState().ledgers.get(runner); } /** 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. */ export 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; } /** * 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 function closedProvenKey(requestId) { return `closed-proven:${requestId}`; } /** * 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. */ export 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; } /** * 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 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; } 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. */ export 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, }; } /** * 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 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'), }; } /** * 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 function heldDecision(input, gate, recorded, call, rules, ledger) { const decision = decisionCopyOf(input); if (decision === undefined) throw new 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 ResumeDecisionError('request-not-recorded', gate.kind); if (requestId === undefined) throw new ResumeDecisionError('no-request', gate.kind); if (requestId !== pending.id) throw new ResumeDecisionError('other-request', gate.kind); const refused = requestRefusal(recorded, call, rules, ledger); if (refused !== undefined) throw new ResumeDecisionError(refused, gate.kind); return decision; } /** * 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). */ export 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); } /** * 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 function runsAsAsked(recorded, call, wouldRun) { const pending = readPendingRequest(recorded); return (pending !== undefined && requestIdOf(pending.nonce, { ...call, args: wouldRun }) === pending.id); } /** * 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 function checkDecisionAgainst(checkpoint, gate, input, rules, ledger) { const state = pausedStateOf(checkpoint); if (state === undefined) { if (decisionCopyOf(input) === undefined) throw new DecisionRequiredError(gate, input); return; } const read = (key) => state[key]; heldDecision(input, gate, read('pausedRequest'), gatedCallOf(read, gate.kind), rules, ledger); } /** * 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 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 ResumeDecisionError((await closeRecorded(ledger, decision.requestId)) ? 'closed-by-host' : 'already-answered', gate); } refuseIfAborted(signal); } /** * 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 function runEndedBy(signal) { if (signal === undefined || !signal.aborted) return undefined; const reason = signal.reason; return reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')); } /** 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. */ export function noteDecisionTakenUp(requestId) { if (requestId === undefined) return; for (const watch of processState().watches.get(requestId) ?? []) watch.takenUp = true; } /** * 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 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); }, }; } /** 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`. */ export function recordedRequestId(recorded) { return readPendingRequest(recorded)?.id; } /** * The id of the call a checkpoint's paused stage records as paused * (`pausedToolCallId`), or `undefined` — never a throw, whatever the * checkpoint holds. */ export function pausedCallIdOf(checkpoint) { try { const id = pausedStateOf(checkpoint)?.pausedToolCallId; return typeof id === 'string' ? id : undefined; } catch { return 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 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; } } /** * The id of the request a checkpoint's paused stage records, or `undefined` * — never a throw, whatever the checkpoint holds. */ export function pausedRequestIdOf(checkpoint) { try { const state = pausedStateOf(checkpoint); return state === undefined ? undefined : recordedRequestId(state.pausedRequest); } catch { return undefined; } } /** 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 (!isJsonFaithful(body)) return undefined; return sha256Hex(`${DOMAIN}\u001f${nonce}\u001f${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 (!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