UNPKG

agentfootprint

Version:

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

395 lines (394 loc) • 19.7 kB
/** * pause — runner-level pause/resume primitives. * * Pattern: Control-flow exception (PauseRequest) + typed outcome (RunnerPauseOutcome). * Role: core/ layer. Bridges footprintjs.s pause signal into the agentfootprint * Runner contract: tools call `pauseHere(data)` to raise a pause * intent; runners detect the paused executor result and return a * `RunnerPauseOutcome` instead of `TOut`. Consumers call * `runner.resume(checkpoint, input)` to continue. * Emits: N/A (types + helpers only). Event emission happens in RunnerBase. * * Why a control-flow "throw": tool.execute(args, ctx) doesn't receive the * typed scope, so it cannot call `scope.$pause()` directly. A thrown * `PauseRequest` is caught inside the Agent's tool-call stage, which then * forwards the pause into the flowchart via `scope.$pause()`. This keeps * the tool API clean (tools are pure-ish) while still supporting pause. */ import type { FlowchartCheckpoint } from 'footprintjs'; import type { AskComponent } from '../tools/askComponent.js'; import type { CheckInRequest } from './checkin.js'; import type { AwaitingInput, InputRequestDeclaration } from '../tools/inputRequest.js'; /** * Outcome returned by `runner.run()` / `runner.resume()` when execution * has paused mid-flow. The shape mirrors footprintjs's `PausedResult` but * surfaces `pauseData` as a first-class field for consumers who don't * want to reach into the checkpoint. */ export interface RunnerPauseOutcome { readonly paused: true; /** Serializable checkpoint — store anywhere (Redis, Postgres, localStorage). */ readonly checkpoint: FlowchartCheckpoint; /** Data passed to `scope.$pause()` / `pauseHere()`. Consumer-typed. */ readonly pauseData: unknown; /** Typed data collection; answers are values, never approval decisions. */ readonly awaitingInput?: AwaitingInput; /** * Present ONLY when this pause is an evidence-carrying check-in (a tool * declared `checkIn`). Carries the typed ask + evidence pack. Absent for * plain `askHuman` / `pauseHere` pauses — that's the clean discriminant * between the two pause kinds. Resume with a `CheckInDecision` built from * it (`checkInApproved({ request: outcome.checkIn, by })` / * `checkInDeclined(...)`) — `checkIn.id` names the request it answers. */ readonly checkIn?: CheckInRequest; /** * Present ONLY when a `toolMiddleware` answered `ask` — the question it put * to a person, plus the middleware that asked. Absent for every other pause, * which is the discriminant. * * Resume with a `CheckInDecision` built from it * (`checkInApproved({ request: outcome.ask, by })` / `checkInDeclined(...)`). * That is deliberate rather than a second decision type: a person approving * is a person approving, whether the gate was a tool's `checkIn` or a * middleware's `ask`, and one word for one thing beats a synonym. * * The answer is a DECISION, not a result. Approve and the chain resumes from * the next middleware and the REAL tool runs; decline and the model receives * a denial it can adapt to. Nobody — not the middleware, not the person — * gets to write the tool's answer. */ readonly ask?: MiddlewareAsk; } /** The question a `toolMiddleware` put to a person, as it rides the checkpoint. */ export interface MiddlewareAsk { /** * Names this request: the id a decision quotes * (`checkInApproved({ request: outcome.ask, by })`). Minted when the run * paused, unique to this pause, and bound to the exact call that runs on * approval — see `core/consentRequest.ts`. */ readonly id: string; /** The question, in the middleware author's own words. */ readonly question: string; /** Anything else the answering UI should render. Never interpreted here. */ readonly detail?: unknown; /** * Which REGISTERED screen component collects the answer (9.24.0) — the * typed half of the question, carried from `ask({ question, component })`. * Absent means what it always meant: render the prose. The answer comes * back through the same `CheckInDecision` either way. */ readonly component?: AskComponent; /** `name` of the middleware that asked. */ readonly middleware: string; } /** Type guard — discriminates `RunnerPauseOutcome` from a normal `TOut`. */ export declare function isPaused<T>(result: T | RunnerPauseOutcome): result is RunnerPauseOutcome; /** A missing-input pause, distinct from every consent gate. */ export declare function isInputPause(result: unknown): result is RunnerPauseOutcome & { readonly awaitingInput: AwaitingInput; }; /** * Collect declared fields using a dedicated tool. The query itself runs afterwards. * * When a lookup does raise about its own miss (the dedicated collection tool * stays the documented shape), it still declares what it looked at * (9.114.0): pass the envelope `absent()` returns as `absence`. The dispatch * door files it at the raise — the rows a returned `absent(…)` files — and it * never rides `awaitingInput`. A value the absence recognizer cannot read * throws `InputRequestError` here (`absence: null` is the field omitted); an * envelope it reads whose lists cannot be copied (a hand-built * `checked: [null]`) errors the call at the door instead of pausing, as the * same value returned would. */ export declare function requestInput(declaration: InputRequestDeclaration): never; /** * Type guard — is this a check-in pause (evidence-carrying human consent), * as opposed to a plain `askHuman` pause? Narrows `checkIn` to present. * * @example * const out = await agent.run({ message }); * if (isCheckInPause(out)) { * showToHuman(out.checkIn.evidence); // the receipts * const decision = checkInApproved({ request: out.checkIn, by: 'alice' }); * await agent.resume(out.checkpoint, decision); * } */ export declare function isCheckInPause(result: unknown): result is RunnerPauseOutcome & { readonly checkIn: CheckInRequest; }; /** * Type guard — is this a middleware-ask pause (a `toolMiddleware` answered * `ask`), as opposed to a check-in or a plain `askHuman` pause? Narrows `ask` * to present. * * @example * const out = await agent.run({ message }); * if (isAskPause(out)) { * const yes = await showToHuman(out.ask.question); // asked by out.ask.middleware * await agent.resume(out.checkpoint, yes * ? checkInApproved({ request: out.ask, by: 'alice' }) * : checkInDeclined({ request: out.ask, by: 'alice', note: 'not this one' })); * } */ export declare function isAskPause(result: unknown): result is RunnerPauseOutcome & { readonly ask: MiddlewareAsk; }; /** * The two pause kinds whose answer is a DECISION rather than a value. * * A **`'checkIn'`** pause is the tool's own consent demand, and its identity is * the evidence pack. A **`'ask'`** pause is a `toolMiddleware`'s own question. * Both are answered with `checkInApproved()` / `checkInDeclined()`, built from * the paused request so the decision names it (`ResumeDecisionError`). * * Everything else — `pauseHere()` / `askHuman()`, and the 3LO credential-consent * pause — is NOT a consent gate in this sense: there the human's answer either * IS the tool's result or is ignored entirely, and any value is accepted. */ export type ConsentGateKind = 'checkIn' | 'ask'; /** What {@link pauseDemandsDecision} reports about a pause that is a consent gate. */ export interface ConsentGate { readonly kind: ConsentGateKind; /** The tool the gate is about, when the pause payload named one. */ readonly toolName?: string; /** `'ask'` only — the `name` of the middleware that asked. */ readonly middleware?: string; } /** * Read a pause payload and say whether answering it requires a * `CheckInDecision` — and if so, which gate is outstanding. * * THE ONE reader of that shape. `RunnerBase.detectPause` builds * `outcome.checkIn` / `outcome.ask` from this, and `Agent.resume` refuses a * mis-shaped answer from this, so the surface a consumer is told about and the * surface the library enforces cannot drift apart. * * Keyed on the PAUSE, never on the input: a plain `askHuman` answer is a string * and must stay one, so "is this the right answer?" can only be decided by * knowing what was asked. * * @returns the gate, or `undefined` when this pause takes any value. */ /** * Refuse an answer chosen against different bytes than the question was asked * against. See {@link StaleDecisionError}. * * Silent in every case but one, deliberately: the ask has to have pinned a ref * AND the answer has to name one. Either side absent means nobody claimed the * two were about the same artifact, and inventing that claim here would refuse * answers that are perfectly good. */ export declare function assertDecisionIsNotStale(pauseData: unknown, input: unknown): void; export declare function pauseDemandsDecision(pauseData: unknown): ConsentGate | undefined; /** * Raised by `agent.resume()` when a run paused on a CONSENT GATE and the resume * input is not a `CheckInDecision`. * * Before 8.13.0 that resume silently DECLINED, attributed to `by: 'unknown'` — * a `checkin.decision` record naming a person who was never asked. A governance * layer that invents a decision is worse than one that drops it: the run reads * as consented-and-refused when nobody consented to anything. * * **Nothing was executed and the checkpoint is unchanged** — this refuses at the * API boundary, before the engine is handed the checkpoint. Answer the gate and * resume the same checkpoint again. * * A plain `askHuman()` / `pauseHere()` pause never raises this: there the * human's answer IS the tool's result, so any value is accepted. The 3LO * credential-consent pause never raises it either — it re-asks the provider and * ignores the input by design. */ /** * The person answered about something the ask was not about. * * A typed ask can pin the data the answer is to be chosen FROM — the * `propsRef` on its {@link AskComponent}, a 200-row picker's options living in * the artifact store rather than in every stored session envelope. A * {@link DecisionValue} can say which artifact the choice was made AGAINST. * When both are present and they disagree, the answer is about different bytes * than the question. * * That happens for an ordinary reason: time passes between asking and * answering. A refresh lands, a filter moves, rows re-sort, the ask is re-raised * with new options — and "the third row" now names something else. Accepting it * would resume the run with a value the person never chose, and there is no * later signal that anything went wrong: the id is well-formed, the type checks, * the loop continues. * * So it refuses, and the refusal is the feature. A person asked again is mildly * annoyed; a person whose gesture was silently reinterpreted has been * misrepresented, and in a consent record that is the one failure nothing * downstream can recover from. * * Only ever raised when the ask pinned a ref AND the answer named one. An * approve/decline, a plain `askHuman` value, or an answer that names no * artifact are all byte-identical to every earlier release. */ export declare class StaleDecisionError extends Error { readonly code: "ERR_STALE_DECISION"; /** The artifact the QUESTION was asked against. */ readonly asked: string; /** The artifact the ANSWER was chosen against. */ readonly answered: string; constructor(asked: string, answered: string); } export declare class DecisionRequiredError extends Error { readonly code: "ERR_DECISION_REQUIRED"; /** Which gate is outstanding. */ readonly gate: ConsentGateKind; /** The tool the gate is about, when the pause named one. */ readonly toolName?: string; /** `'ask'` only — the middleware that asked. */ readonly middleware?: string; /** * What arrived instead, as a TYPE NAME only (`'a string'`, `'nothing'`, …). * * Never the value. A resume payload is caller data that may carry anything a * person typed, and an error message is copied into logs, tickets and crash * reporters by default. */ readonly received: string; constructor(gate: ConsentGate, input: unknown); } /** * Why a decision could not answer the request a run is paused on — the * {@link ResumeDecisionError} `reason`: * * - `'no-request'` — the decision names no request (`requestId` missing): * one built by an older release, or by hand. * - `'request-not-recorded'` — the PAUSE recorded no request to answer: a * checkpoint written before requests were named, or one whose record was * removed. * - `'other-request'` — the decision names a different request: another * conversation's, an earlier pause of this run, a parallel sibling's. * - `'call-changed'` — the decision names this request, but the paused call * is no longer the one the person was asked about (the checkpoint's tool, * arguments, call id, served party, or — for an ask — the middleware * position or the rules recorded after it changed). * - `'chain-changed'` — an ask only: the decision names this request and * the checkpoint is intact, but the agent resuming it would continue the * middleware chain under other rules than the ones the person was asked * under — the link that asked, or a link after it, was removed, added, * renamed or moved (a redeploy). An approval holds for the rules it was * given under, so a rule it was given under is never skipped. * - `'already-answered'` — a resume already used a decision for this * request, approved or declined. Whether that resume finished — and so * whether the call ran — is not known to the one refused. * - `'ledger-changed'` — the decision names this request and the call is * intact, but the agent resuming it spends decisions in another ledger * than the one the request was asked under, while that one can prove * whether the call ran (a shared ledger with a `storeId`, or this * process's own default) — a redeploy or a ledger migration. Spent * elsewhere, the call could run where that ledger never saw it; the * person is asked again instead. * - `'closed-by-host'` — a host that kept the pause closed it, because no * answer could reach it any more, and proved its call never ran: it spent * the request in the ledger the request was minted under, so no decision * can run it. The call did not run, and nothing ran now. */ export type ResumeDecisionRefusal = 'no-request' | 'request-not-recorded' | 'other-request' | 'call-changed' | 'chain-changed' | 'already-answered' | 'ledger-changed' | 'closed-by-host'; /** * Raised by a resume when the decision cannot answer the request the run is * paused on. **The tool never runs**: the refusal comes before it, and a * decision is never re-read as a decline or as anyone else's answer. * * A consent gate (a tool's `checkIn`, a middleware's `ask`) mints a request id * when it pauses (`CheckInRequest.id`, `MiddlewareAsk.id`). The id is unique to * that pause and bound to the exact call that runs on approval — the tool, its * arguments, the call id, the served party and, for an ask, where the chain * resumes and the names of the rules from there on. A decision names it * (`checkInApproved({ request, by })`), and a resume holds the decision to it: * the same request, the same call, once. * * Every refusal but `'already-answered'` is decided before anything moves on * an agent's own `resume()`; the checkpoint is unchanged and can be answered * with the right decision. `'already-answered'` — and every refusal of a * composition's nested agent — is decided where the decision is used, inside * the resumed run and still before the tool. See {@link ResumeDecisionRefusal}. * * **It names no request id** — not in its message, not on a field — and a * host sends its message as the refusal's body. A refusal that named the * request the run is paused on would hand out the very id it exists to * require: a client that retried with it would approve a request nobody was * shown. The paused request is the server's to show; it is in the stored * pause (`PendingAsk.checkIn` / `.ask`), never in an error. */ export declare class ResumeDecisionError extends Error { readonly code: "ERR_RESUME_DECISION"; /** Which rule refused the decision. */ readonly reason: ResumeDecisionRefusal; /** Which kind of consent gate the run is paused on. */ readonly gate: ConsentGateKind; constructor(reason: ResumeDecisionRefusal, gate: ConsentGateKind); } /** * Raised when an `askHuman()` / `pauseHere()` pause is resumed with NO answer. * * That pause exists to collect a value, and the value it collects becomes the * paused tool's result — the model reads it as what the tool said. Resuming * with nothing has two live readings, "the person gave no answer" and "just * carry on", and they produce different conversations. So the caller says * which; the library does not choose. * * Until 8.18.0 it chose, and chose invisibly: the missing answer became a * `role: 'tool'` message with `content: undefined`, and the run died on the * next turn inside the messages slot with a `TypeError` that named neither the * tool nor the resume. * * Nothing ran before this raised and the checkpoint is unchanged — answer it * and resume the same checkpoint again. */ export declare class PauseAnswerRequiredError extends Error { readonly code: "ERR_PAUSE_ANSWER_REQUIRED"; /** The tool whose `execute()` paused. */ readonly toolName: string; /** The tool call id the pause is filed under. */ readonly toolCallId: string; constructor(ctx: { toolName: string; toolCallId: string; }); } /** * Control-flow error raised by `pauseHere()` inside a tool's `execute()`. * Caught by the Agent's tool-call stage, which forwards to `scope.$pause()`. * Never propagates to the consumer. */ export declare class PauseRequest extends Error { readonly data: unknown; constructor(data: unknown); } /** * Called from inside a tool's `execute()` to request a pause. Throws a * `PauseRequest` that the Agent catches and forwards to the flowchart. * * @example * const approveTool: Tool<{ action: string }, string> = { * schema: { name: 'approve', description: 'Ask human', inputSchema: {...} }, * execute: async (args) => { * pauseHere({ question: `Approve ${args.action}?`, risk: 'high' }); * return ''; // unreachable — pauseHere always throws * }, * }; */ export declare function pauseHere(data: unknown): never; /** Type guard for a thrown `PauseRequest`. */ export declare function isPauseRequest(err: unknown): err is PauseRequest; /** * Ergonomic alias for `pauseHere(data)` — the human-in-the-loop name. * * `pauseHere` describes the mechanism (control-flow throw); `askHuman` * describes the intent (ask a person to decide). Both work identically. * * @example * const approveRefund: Tool<{ amount: number }, string> = { * schema: { name: 'approve_refund', description: '...', inputSchema: {...} }, * execute: async ({ amount }) => { * if (amount > 1000) askHuman({ question: `Approve $${amount}?` }); * return 'auto-approved'; * }, * }; */ export declare const askHuman: typeof pauseHere;