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