UNPKG

agentfootprint

Version:

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

441 lines • 22.4 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.askHuman = exports.isPauseRequest = exports.pauseHere = exports.PauseRequest = exports.PauseAnswerRequiredError = exports.ResumeDecisionError = exports.DecisionRequiredError = exports.StaleDecisionError = exports.pauseDemandsDecision = exports.assertDecisionIsNotStale = exports.isAskPause = exports.isCheckInPause = exports.requestInput = exports.isInputPause = exports.isPaused = void 0; const askComponent_js_1 = require("../tools/askComponent.js"); const inputRequest_js_1 = require("../tools/inputRequest.js"); /** Type guard — discriminates `RunnerPauseOutcome` from a normal `TOut`. */ function isPaused(result) { return (typeof result === 'object' && result !== null && result.paused === true); } exports.isPaused = isPaused; /** A missing-input pause, distinct from every consent gate. */ function isInputPause(result) { return isPaused(result) && result.awaitingInput !== undefined; } exports.isInputPause = isInputPause; /** * 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. */ function requestInput(declaration) { const inputRequest = (0, inputRequest_js_1.validateInputDeclaration)(declaration); if (!inputRequest.fields.some((field) => field.required !== false && !Object.prototype.hasOwnProperty.call(inputRequest.supplied ?? {}, field.id))) { throw new inputRequest_js_1.InputRequestError('no required fields are missing; continue without requesting input'); } return pauseHere({ question: inputRequest.question, inputRequest }); } exports.requestInput = requestInput; /** * 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); * } */ function isCheckInPause(result) { return isPaused(result) && result.checkIn !== undefined; } exports.isCheckInPause = isCheckInPause; /** * 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' })); * } */ function isAskPause(result) { return isPaused(result) && result.ask !== undefined; } exports.isAskPause = isAskPause; /** * 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. */ function assertDecisionIsNotStale(pauseData, input) { const asked = (0, askComponent_js_1.readAskComponent)(pauseData)?.propsRef; if (asked === undefined) return; const answered = input?.value?.from; if (answered === undefined || answered === asked) return; throw new StaleDecisionError(asked, answered); } exports.assertDecisionIsNotStale = assertDecisionIsNotStale; function pauseDemandsDecision(pauseData) { if (typeof pauseData !== 'object' || pauseData === null) return undefined; const bag = pauseData; const toolName = typeof bag.toolName === 'string' ? bag.toolName : undefined; if (typeof bag.checkIn === 'object' && bag.checkIn !== null) { return { kind: 'checkIn', ...(toolName !== undefined && { toolName }) }; } if (typeof bag.ask === 'object' && bag.ask !== null) { const middleware = bag.ask.middleware; return { kind: 'ask', ...(toolName !== undefined && { toolName }), ...(typeof middleware === 'string' && { middleware }), }; } return undefined; } exports.pauseDemandsDecision = pauseDemandsDecision; /** * 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. */ class StaleDecisionError extends Error { code = 'ERR_STALE_DECISION'; /** The artifact the QUESTION was asked against. */ asked; /** The artifact the ANSWER was chosen against. */ answered; constructor(asked, answered) { super(`[resume] this answer was chosen against artifact '${answered}', and the question was ` + `asked against '${asked}'. The data moved between asking and answering, so the choice ` + `does not name what the person saw. Nothing was resumed — raise the ask again with the ` + `current data.`); this.name = 'StaleDecisionError'; this.asked = asked; this.answered = answered; } } exports.StaleDecisionError = StaleDecisionError; class DecisionRequiredError extends Error { code = 'ERR_DECISION_REQUIRED'; /** Which gate is outstanding. */ gate; /** The tool the gate is about, when the pause named one. */ toolName; /** `'ask'` only — the middleware that asked. */ middleware; /** * 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. */ received; constructor(gate, input) { const who = gate.kind === 'checkIn' ? "a tool's checkIn" : `a toolMiddleware's ask${gate.middleware ? ` (asked by '${gate.middleware}')` : ''}`; const received = describeResumeInput(input); super(`[resume] this run paused on a consent gate — ${who}${gate.toolName ? ` on tool '${gate.toolName}'` : ''} — and a consent gate is answered with a DECISION, not a value. Got ${received}. ` + `Resume with checkInApproved({ request, by }) or checkInDeclined({ request, by, note }) ` + `from 'agentfootprint'; \`request\` is the paused request (outcome.checkIn or ` + `outcome.ask) and \`by\` is who decided, and both are what the record says. Nothing was ` + `executed and the checkpoint is unchanged — answer it and resume again. (An askHuman() ` + `/ pauseHere() pause is different: there the human's answer IS the tool's result, and ` + `any value is accepted.)`); this.name = 'DecisionRequiredError'; this.gate = gate.kind; if (gate.toolName !== undefined) this.toolName = gate.toolName; if (gate.middleware !== undefined) this.middleware = gate.middleware; this.received = received; } } exports.DecisionRequiredError = DecisionRequiredError; /** * 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. */ class ResumeDecisionError extends Error { code = 'ERR_RESUME_DECISION'; /** Which rule refused the decision. */ reason; /** Which kind of consent gate the run is paused on. */ gate; constructor(reason, gate) { super(resumeDecisionMessage(reason)); this.name = 'ResumeDecisionError'; this.reason = reason; this.gate = gate; } } exports.ResumeDecisionError = ResumeDecisionError; /** * What a host that keeps the pause does with one no decision can answer * (`hosting/answeredPause.ts`): it closes it rather than hold the session. */ const CLOSED_BY_A_HOST = 'A host that keeps the pause (standingAgent, streamHandler) closes it: the next message ' + 'continues the conversation, the call recorded as not run only when proven, otherwise as ' + 'not known.'; function resumeDecisionMessage(reason) { switch (reason) { case 'no-request': return (`[resume] this decision names no request. A person's decision answers ONE paused ` + `request: build it with checkInApproved({ request, by }) or checkInDeclined({ request, ` + `by }), passing the request the person was shown — outcome.checkIn or outcome.ask ` + `(PendingAsk.checkIn / .ask on a hosted door). A decision without \`requestId\` cannot ` + `say which request it answers, so it answers none. Nothing was executed.`); case 'request-not-recorded': return (`[resume] this run paused on a consent gate without a record of the request it asked — ` + `a checkpoint written before requests were named, or one whose record was removed. No ` + `decision can answer it. Nothing was executed; abandon this pause and ask again in a ` + `new run. ${CLOSED_BY_A_HOST}`); case 'other-request': return (`[resume] this decision answers a different request than the one this run is paused ` + `on. A decision answers only the request it names — not a later pause of the same run, ` + `not a parallel sibling's, not another conversation's. Nothing was executed; show the ` + `person the paused request and build the decision from what they were shown.`); case 'call-changed': return (`[resume] this decision names the paused request, but the paused call is no longer the ` + `call the person was asked about: the checkpoint's tool, arguments, call id, served ` + `party or middleware position changed after the ask. An approval binds to the exact ` + `call it was shown. Nothing was executed; treat the checkpoint as altered — no ` + `decision can answer it. ${CLOSED_BY_A_HOST}`); case 'chain-changed': return (`[resume] this decision names the paused request, but this agent's toolMiddleware ` + `chain is not the one the request was asked under: the link that asked, or a link ` + `after it, was removed, added, renamed or moved since (a redeploy). The rest of the ` + `chain would not be the rules the approval was given under, so the approval is not ` + `used. Nothing was executed; resume on an agent built with the same chain, or abandon ` + `the pause and ask again in a new run. ${CLOSED_BY_A_HOST}`); case 'already-answered': return (`[resume] the request this decision names was already answered by an earlier resume — ` + `one decision runs one call, and a request is spent the moment a resume uses its ` + `decision, approved or declined. This resume ran nothing. Whether the earlier resume ` + `finished, and so whether the call ran, is not known here: check the call's own ` + `record (its system, the conversation) before the person is asked again.`); case 'ledger-changed': return (`[resume] this decision names the paused request, but the agent resuming it records ` + `decisions in a different ledger than the one the request was asked under (a redeploy ` + `or a ledger migration). An approval is spent only in the ledger its request was asked ` + `under, so that ledger can say whether the call ran. Nothing was executed; the person ` + `has to be asked again. ${CLOSED_BY_A_HOST}`); case 'closed-by-host': return (`[resume] the request this decision names was closed by the host that kept the ` + `pause: no answer could reach it any more, so the host closed it and recorded that ` + `its call did not run — and no decision can run it now. This resume ran nothing. The ` + `next message continues the conversation.`); } } /** * 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. */ class PauseAnswerRequiredError extends Error { code = 'ERR_PAUSE_ANSWER_REQUIRED'; /** The tool whose `execute()` paused. */ toolName; /** The tool call id the pause is filed under. */ toolCallId; constructor(ctx) { super(`[resume] this run paused inside tool '${ctx.toolName}' (askHuman/pauseHere), and that ` + `pause is answered with a VALUE — the answer becomes the tool's result. Got nothing. ` + `Say which you meant: resume(checkpoint, 'the answer') to hand the tool an answer, or ` + `resume(checkpoint, '(no answer)') to tell the model nobody answered. Nothing was ` + `executed and the checkpoint is unchanged.`); this.name = 'PauseAnswerRequiredError'; this.toolName = ctx.toolName; this.toolCallId = ctx.toolCallId; } } exports.PauseAnswerRequiredError = PauseAnswerRequiredError; /** Name the SHAPE of a resume input for the refusal — never its contents. */ function describeResumeInput(input) { if (input === undefined) return 'nothing'; if (input === null) return 'null'; if (Array.isArray(input)) return 'an array'; const t = typeof input; return t === 'object' ? 'an object that is not a CheckInDecision' : `a ${t}`; } /** * 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. */ class PauseRequest extends Error { data; constructor(data) { super('PauseRequest'); this.name = 'PauseRequest'; this.data = data; // Not a real error — stack has no diagnostic value for consumers. this.stack = ''; } } exports.PauseRequest = PauseRequest; /** * 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 * }, * }; */ function pauseHere(data) { throw new PauseRequest(data); } exports.pauseHere = pauseHere; /** Type guard for a thrown `PauseRequest`. */ function isPauseRequest(err) { return (err instanceof PauseRequest || (err instanceof Error && err.name === 'PauseRequest' && Object.prototype.hasOwnProperty.call(err, 'data'))); } exports.isPauseRequest = isPauseRequest; /** * 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'; * }, * }; */ exports.askHuman = pauseHere; //# sourceMappingURL=pause.js.map