UNPKG

agentfootprint

Version:

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

413 lines (412 loc) • 19.5 kB
/** * checkin — the evidence-carrying human-consent primitive. * * "OpenWorker-class agents check in; agentfootprint checks in WITH THE * RECEIPTS." A tool declares `checkIn` (see `Tool.checkIn` in `tools/tools.ts`); when * it trips, the tool-dispatch loop pauses BEFORE executing and surfaces a * {@link CheckInRequest} — a typed ask that carries an EVIDENCE PACK * ({@link CheckInEvidence}): what the tool will do, what context the run * consumed, which context drove the choice, and a compact run-so-far trail. * A human answers with {@link checkInApproved} / {@link checkInDeclined}; * the {@link CheckInDecision} lands as a typed record. * * This is the LEAF that owns every check-in TYPE + the pure assembly logic. * It has one runtime import, the `lib/frozenCopy.ts` leaf (which reads only * footprintjs's dev-mode flag); the rest are type-only imports of the message * shape + the influence-core attribution unit — so it stays cheap to import * and trivial to unit-test. The pause plumbing lives in * `core/agent/stages/toolCalls.ts`; the events in `events/`; the store in * `recorders/core/CheckInRecorder.ts`. * * NOT a policy engine (the `PermissionChecker` in `provider-contract/types.ts` is * untouched, and runs BEFORE this gate) and NOT UI. This asks a person to * CONSENT, with the receipts attached. * * Pattern: Strategy (pluggable evidence assembler + driver scorer) + pure * value types. Every value here survives `structuredClone` + JSON * (checkpoint discipline) — no functions, no class instances. */ import type { ArtifactRef } from '../artifacts/types.js'; import type { LLMMessage } from '../provider-contract/types.js'; import type { AskComponent } from '../tools/askComponent.js'; import type { AttributionUnit } from '../influence-core/types.js'; /** * The typed pause payload for one check-in. Rides the existing pause * machinery: it becomes the checkpoint's `pauseData` and is surfaced on * surfaced on `RunnerPauseOutcome.checkIn` (`core/pause.ts`). JSON/clone-safe. */ export interface CheckInRequest { /** * Names this request: the id a decision quotes * (`checkInApproved({ request: outcome.checkIn, by })`). Minted when the run * paused, unique to this pause, and bound to the exact call that runs on * approval — the tool, {@link args}, the call id and the served party. A * decision for any other request, or for a call that changed after the ask, * is refused (`ResumeDecisionError`). An opaque fingerprint: it carries no * value from the run. */ readonly id: string; /** The tool the agent wants to run (its name). */ readonly tool: string; /** * The arguments the call will run with: the model's proposal, with any value * the inputs layer filled (a tool's `askOrAssume` `assume` rule) and any * before-tool middleware rewrite applied — what a person approves is what * runs. */ readonly args: Readonly<Record<string, unknown>>; /** * The model's stated reasoning for THIS call, when the assistant turn * carried text alongside the tool call. Omitted when the turn was a bare * tool call with no content. */ readonly intent?: string; /** The receipts riding the ask. */ readonly evidence: CheckInEvidence; /** * Which REGISTERED screen component collects the decision (9.24.0) — the * tool's own declaration (`defineTool({ checkIn, checkInComponent })`), * carried onto the ask. Absent means what it always meant: the screen * renders the evidence pack as prose. The answer is a `CheckInDecision` * either way — the component changes how the question is asked, never * what the answer is. */ readonly component?: AskComponent; } /** * The evidence pack — the "receipts". The `'minimal'` assembler fills only * {@link willDo} (zero cost); the `'standard'` assembler fills all four. */ export interface CheckInEvidence { /** Plain-words claim of what will happen: the tool description + the * rendered arguments. Always present. */ readonly willDo: string; /** What context this run consumed so far — one frame per context piece * (system rules, the user task, prior tool results). Absent under the * `'minimal'` assembler. */ readonly read?: readonly CheckInContextFrame[]; /** Which context drove THIS choice, ranked most-to-least. Produced by the * configured {@link CheckInScorer} (default: a zero-LLM lexical scorer). * Absent under the `'minimal'` assembler. */ readonly drivers?: readonly CheckInDriver[]; /** A compact grouped summary of the run so far. Absent under `'minimal'`. */ readonly trail?: CheckInTrail; } /** One piece of context the run consumed — role/channel + a compact summary. */ export interface CheckInContextFrame { /** Origin group: `'system' | 'task' | 'result'`. */ readonly channel: string; /** A short, truncated summary of the piece (never the full payload). */ readonly summary: string; } /** One ranked driver — a context unit and how strongly it aligns with the pick. */ export interface CheckInDriver { /** The unit id (the citation, e.g. `'system-1'`). */ readonly id: string; /** Origin group: `'system' | 'task' | 'result'`. */ readonly channel: string; /** The unit text (quotable). */ readonly text: string; /** Alignment score — higher means it drove the pick more. Scorer-defined * units; compare within one request, not across scorers. */ readonly score: number; } /** A compact grouped summary of the run so far. */ export interface CheckInTrail { /** Which ReAct iteration this check-in fired on. */ readonly iteration: number; /** The tool calls already completed this run, oldest first. */ readonly toolCalls: readonly { readonly name: string; readonly ok: boolean; }[]; /** One-line human summary, e.g. `"3 tools run over 2 iterations"`. */ readonly summary: string; } /** * The human's answer to a check-in — the record that lands. Produced by * {@link checkInApproved} / {@link checkInDeclined} and passed to * `agent.resume(checkpoint, decision)`. JSON/clone-safe. */ /** * What a person CHOSE, when the answer is a value rather than a yes or a no. * * The ask got its typed half in 9.24.0 — {@link AskComponent} names a registered * screen component and the props it renders with. The ANSWER never did: a * decision was `approved` plus a free-text `note`, so a picked row, a brushed * date range or a chosen option had to travel as PROSE for the model to parse * back out. That is the exact failure the typed ask exists to prevent, surviving * on the return leg. * * Deliberately shaped like the ask, for the same reasons: * • `kind` is CONSUMER vocabulary — meaningful to whoever registered the * component, opaque here. Never a type name from this library. * • `value` is small inline JSON, because it rides the resume call and lands * in the run's history. Anything large belongs behind {@link from}. * • `from` is the artifact the choice was made AGAINST. A row id means * nothing on its own; it means something in a dataset. * * ── Why `coverage` is here, and it is the field people skip ───────────────── * A person who filtered 5,000 rows to 3 and picked one has not chosen from * 5,000. Without this field that pick is indistinguishable from an informed * choice over the whole set, and the difference is the whole value of a human * in the loop. It is the same distinction `coverage()` draws for a tool — * what was checked, and what a clean answer does not rule out — applied to the * person, because on this turn the person IS the tool. * * Every field must survive `structuredClone`: a decision rides the checkpoint. */ export interface DecisionValue { /** Consumer vocabulary for what this value IS — e.g. `'row-choice'`. */ readonly kind: string; /** The chosen value itself. JSON, clone-safe, small. */ readonly value: unknown; /** The artifact the choice was made against, when there was one. */ readonly from?: ArtifactRef; /** * What the person could actually see when they chose. * * `seen` of `total`, and the filter that narrowed it. A pick made over a * filtered view is a different fact from a pick made over everything, and * only the screen knows which happened. */ readonly coverage?: { readonly seen: number; readonly total: number; readonly filter?: string; }; } export interface CheckInDecision { /** * The request this decision answers — the paused request's `id` * (`CheckInRequest.id`, `MiddlewareAsk.id`). A resume refuses a decision for * any other request, and answers each request once (`ResumeDecisionError`). */ readonly requestId: string; /** True to run the tool, false to decline it. */ readonly approved: boolean; /** * Who decided (an operator id, an email, a queue name — your call). Recorded * as given: a claim, not proof. Authenticate the person before you build the * decision; the library binds WHICH request and WHAT call, never who. */ readonly by: string; /** Optional note. On decline it is surfaced to the model so it can adapt. */ readonly note?: string; /** When the decision was made (ms since epoch). */ readonly at: number; /** * What they chose, when the answer was a value and not just a yes. * * Optional everywhere: a decision without one is byte-identical to every * earlier release, and a screen that only approves or declines never sets it. */ readonly value?: DecisionValue; } /** Options for {@link checkInApproved} / {@link checkInDeclined}. */ export interface CheckInDecisionInput { /** * The paused request being answered — the one the person was shown: * `outcome.checkIn` or `outcome.ask` (`PendingAsk.checkIn` / `.ask` on a * hosted door). Only its `id` is read; the decision answers that request and * no other. */ readonly request: { readonly id: string; }; /** Who decided. Recorded as given — see {@link CheckInDecision.by}. */ readonly by: string; /** Optional free-text note. */ readonly note?: string; /** What was chosen, for an ask that wanted a value rather than a yes. */ readonly value?: DecisionValue; } /** * Approve a pending check-in — the paused tool executes normally on resume. * * The decision names the request it answers (`request.id`), so it answers that * request once and no other: a resume refuses it on a different pause, on a * call that changed after the ask, or a second time (`ResumeDecisionError`). * * @throws TypeError when `request` is not a paused request (it has no `id`). * * @example * const decision = checkInApproved({ * request: outcome.checkIn, * by: 'alice@ops', * note: 'verified with customer', * }); * const final = await agent.resume(outcome.checkpoint, decision); */ export declare function checkInApproved(input: CheckInDecisionInput): CheckInDecision; /** * Decline a pending check-in — the tool is NOT executed; the model receives * a `"declined by human: <note>"` tool result and adapts in-loop. * * A decline is an answer too: it names its request and spends it, so an * approval presented for the same request afterwards is refused. * * @throws TypeError when `request` is not a paused request (it has no `id`). * * @example * const decision = checkInDeclined({ * request: outcome.checkIn, * by: 'alice@ops', * note: 'amount too high', * }); * const final = await agent.resume(outcome.checkpoint, decision); */ export declare function checkInDeclined(input: CheckInDecisionInput): CheckInDecision; /** * Type guard — is this resume input a {@link CheckInDecision}? Distinguishes * a check-in answer from a plain `askHuman` tool-result value. A decision * names its request: an object without a `requestId` is not one. */ export declare function isCheckInDecision(x: unknown): x is CheckInDecision; /** * The SHAPE of a decision before it names a request — `approved` and `by`. * What tells "a decision that names no request" (refused by name, with the * migration) apart from "not a decision at all" (`DecisionRequiredError`). * * @internal */ export declare function looksLikeDecision(x: unknown): x is Omit<CheckInDecision, 'requestId'>; /** * What the model reads for a call a person declined: the result the dispatch * loop records, and the host's notice for a declined call a failed run left * open (`hosting/answeredPause.ts`). Either way the call did not run. * * @internal */ export declare function declinedResult(note?: string): string; /** * What a tool declares to demand a check-in. `'always'` trips on every call; * a predicate trips selectively (e.g. only high-value refunds). A predicate * that throws trips (fail toward asking the human — consequential actions * should not silently proceed on a buggy predicate). */ export type CheckInDemand<TArgs = Readonly<Record<string, unknown>>> = 'always' | ((args: TArgs, ctx: CheckInPredicateContext) => boolean); /** Context handed to a {@link CheckInDemand} predicate. */ export interface CheckInPredicateContext { /** The current ReAct iteration. */ readonly iteration: number; /** This tool invocation's id. */ readonly toolCallId: string; /** The conversation so far (system, user, prior tool results). */ readonly history: readonly LLMMessage[]; } /** * Decide whether a tool's declared demand trips for this call. Returns false * when the tool declared no `checkIn` (backward-compatible: byte-identical * behavior for tools without the field). * * The ONE place a predicate is asked, at every door (the loop's gate, an * approved ask's resume). It is handed its OWN frozen copy of the arguments * (`lib/frozenCopy.ts`), never the object the tool runs with: a predicate * that writes into its argument — after a person approved the call, or after * answering "no check-in needed" for it — must not change what runs. The * write throws in strict code (and a predicate that throws trips) or is * ignored in sloppy code (in dev mode, one warning names the cause). A copy * the caller already shares with the call's other callbacks is handed on as * it is (`frozenView`). Arguments no copy can be made of trip the gate. */ export declare function shouldCheckIn(demand: CheckInDemand<Readonly<Record<string, unknown>>> | undefined, args: Readonly<Record<string, unknown>>, ctx: CheckInPredicateContext): boolean; /** Input to a {@link CheckInScorer}. Mirrors `influence-core`'s attribution * shape so an embedding-backed scorer (wrapping `explainChoice`) drops in. */ export interface CheckInScoreInput { /** The chosen tool. `text` is what gets scored (name + description + args). */ readonly tool: { readonly name: string; readonly text: string; }; /** The context units to rank against the tool. */ readonly units: readonly AttributionUnit[]; /** Abort signal for network-backed scorers. */ readonly signal?: AbortSignal; } /** * Ranks context units by how strongly each drove one tool choice. The * DEFAULT is {@link lexicalDriverScorer} — deterministic, zero LLM, zero * network. Swap in a richer one via `.checkIn({ scorer })`, e.g. wrapping * `explainChoice` from `agentfootprint` with your own embedder. * * @example a semantic scorer * const scorer: CheckInScorer = async ({ tool, units, signal }) => { * const ex = await explainChoice({ tool, units, embedder, signal }); * return ex.units.map((u) => ({ id: u.id, channel: u.channel, text: u.text, score: u.score })); * }; */ export type CheckInScorer = (input: CheckInScoreInput) => readonly CheckInDriver[] | Promise<readonly CheckInDriver[]>; /** * The default drivers scorer: deterministic Jaccard token overlap between * the tool text and each context unit. Zero LLM, zero network, structuredClone * -safe output. Ties keep input order (stable sort). */ export declare const lexicalDriverScorer: CheckInScorer; /** Everything the assembler needs to build one evidence pack. */ export interface CheckInAssemblerInput { /** The chosen tool — `name` for citations, `description` for `willDo`. */ readonly tool: { readonly name: string; readonly description: string; }; /** The proposed arguments. */ readonly args: Readonly<Record<string, unknown>>; /** The model's stated reasoning, if any (assistant-turn text). */ readonly intent?: string; /** The ReAct iteration this check-in fired on. */ readonly iteration: number; /** The conversation so far — the raw material for `read`, `drivers`, `trail`. */ readonly history: readonly LLMMessage[]; /** The scorer to rank `drivers` with. */ readonly scorer: CheckInScorer; /** Abort signal threaded to the scorer. */ readonly signal?: AbortSignal; } /** * Builds the evidence pack for one check-in. Two are built in * ({@link standardEvidenceAssembler}, {@link minimalEvidenceAssembler}); * pass your own to `.checkIn({ evidence })` for full control. */ export type CheckInAssembler = (input: CheckInAssemblerInput) => CheckInEvidence | Promise<CheckInEvidence>; /** Which built-in assembler to use, by name. */ export type EvidencePreset = 'minimal' | 'standard'; /** * Derive attribution units from the run-so-far conversation: system rules * (split into sentences), the user task, and prior tool results. These feed * BOTH `read` (as frames) and `drivers` (scored). Ids are unique + stable. */ export declare function unitsFromHistory(history: readonly LLMMessage[]): AttributionUnit[]; /** * The `'standard'` assembler — fills all four evidence fields. The `drivers` * ranking runs the configured scorer over the run-so-far context units; the * default scorer is deterministic and makes zero LLM/network calls. */ export declare const standardEvidenceAssembler: CheckInAssembler; /** The `'minimal'` assembler — only `willDo`. Zero cost; no scorer call. */ export declare const minimalEvidenceAssembler: CheckInAssembler; /** What `.checkIn({...})` accepts on the Agent builder. */ export interface CheckInBuilderOptions { /** * How much evidence rides the ask. `'standard'` (default) fills all four * fields; `'minimal'` fills only `willDo` (zero cost); or pass your own * {@link CheckInAssembler}. */ readonly evidence?: EvidencePreset | CheckInAssembler; /** * The scorer that ranks `drivers`. Default {@link lexicalDriverScorer} * (deterministic, zero LLM). Only consulted by the `'standard'` assembler. */ readonly scorer?: CheckInScorer; } /** The resolved config threaded into the tool-dispatch handler. */ export interface ResolvedCheckInConfig { readonly assembler: CheckInAssembler; readonly scorer: CheckInScorer; } /** * Resolve builder options (or nothing) into a runtime config. The default — * `standard` evidence + `lexicalDriverScorer` — makes a tool that declares * `checkIn` work even when the builder never called `.checkIn()`. */ export declare function resolveCheckInConfig(opts?: CheckInBuilderOptions): ResolvedCheckInConfig;