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