UNPKG

agentfootprint

Version:

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

348 lines • 14.2 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 { frozenView, warnFrozenWrite } from '../lib/frozenCopy.js'; /** * 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 function checkInApproved(input) { return decide(true, input, 'checkInApproved'); } /** * 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 function checkInDeclined(input) { return decide(false, input, 'checkInDeclined'); } function decide(approved, input, door) { const requestId = input?.request?.id; if (typeof requestId !== 'string' || requestId.length === 0) { throw new TypeError(`${door}: \`request\` must be the paused request this decision answers — ` + `outcome.checkIn or outcome.ask (PendingAsk.checkIn / .ask on a hosted door). A ` + `decision names its request, so it can answer that request once and no other.`); } return { requestId, approved, by: input.by, at: Date.now(), ...(input.note && { note: input.note }), // Carried on a DECLINE too: "none of these" is an answer with coverage — // what they searched and did not find — not the absence of one. ...(input.value && { value: input.value }), }; } /** * 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 function isCheckInDecision(x) { return (looksLikeDecision(x) && typeof x.requestId === 'string' && x.requestId.length > 0); } /** * 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 function looksLikeDecision(x) { return (typeof x === 'object' && x !== null && typeof x.approved === 'boolean' && typeof x.by === 'string'); } /** * 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 function declinedResult(note) { return note ? `declined by human: ${note}` : 'declined by human'; } /** * 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 function shouldCheckIn(demand, args, ctx) { if (demand === undefined) return false; if (demand === 'always') return true; const own = frozenView(args); if (own === undefined) return true; try { return demand(own, ctx) === true; } catch (err) { warnFrozenWrite('checkIn predicate', err, 'the gate tripped, so the call asks a person'); // A buggy predicate must not let a consequential action slip through // unreviewed — fail toward asking the human. return true; } } const WORD_RE = /[a-z0-9]+/g; const STOP = new Set([ 'the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'this', 'that', 'it', 'as', 'at', 'by', 'be', 'you', 'your', 'i', ]); function tokenize(text) { const out = new Set(); const lower = text.toLowerCase(); let m; while ((m = WORD_RE.exec(lower))) { if (m[0].length > 1 && !STOP.has(m[0])) out.add(m[0]); } WORD_RE.lastIndex = 0; return out; } /** * 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 const lexicalDriverScorer = ({ tool, units }) => { const toolTokens = tokenize(tool.text); const scored = units.map((u) => { const unitTokens = tokenize(u.text); let shared = 0; for (const t of unitTokens) if (toolTokens.has(t)) shared++; const union = toolTokens.size + unitTokens.size - shared; const score = union === 0 ? 0 : shared / union; return { id: u.id, channel: u.channel, text: u.text, score }; }); // Stable descending sort — Array.prototype.sort is stable in modern engines. return scored.sort((a, b) => b.score - a.score); }; const MAX_SUMMARY = 160; function truncate(s, max = MAX_SUMMARY) { const flat = s.replace(/\s+/g, ' ').trim(); return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; } /** Render args as a compact `k=v, k=v` string (values truncated, clone-safe). */ function renderArgs(args) { const parts = Object.entries(args).map(([k, v]) => { const val = typeof v === 'string' ? v : v === null || v === undefined ? String(v) : JSON.stringify(v); return `${k}=${truncate(val, 60)}`; }); return parts.join(', '); } /** Plain-words "what will happen" claim. */ function buildWillDo(tool, args) { const desc = tool.description.trim() || tool.name; const rendered = renderArgs(args); return rendered ? `${desc} — with ${rendered}` : desc; } /** * 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 function unitsFromHistory(history) { const units = []; let sysN = 0; let taskN = 0; let resN = 0; for (const m of history) { const content = (m.content ?? '').trim(); if (!content) continue; if (m.role === 'system') { // Split the system prompt into rule-sized units so a single rule can be // cited. Sentence-ish split; keep non-empty pieces. for (const piece of content.split(/(?<=[.!?])\s+|\n+/)) { const t = piece.trim(); if (t) units.push({ id: `system-${++sysN}`, channel: 'system', text: t }); } } else if (m.role === 'user') { units.push({ id: `task-${++taskN}`, channel: 'task', text: content }); } else if (m.role === 'tool') { units.push({ id: `result-${++resN}`, channel: 'result', text: m.toolName ? `${m.toolName}: ${content}` : content, }); } } return units; } function framesFromUnits(units) { return units.map((u) => ({ channel: u.channel, summary: truncate(u.text) })); } function buildTrail(history, iteration) { const toolCalls = []; for (const m of history) { // A call the batch settlement answered was never dispatched (9.113.0) — // not a completed call, so not in the receipts at all. Read off the // marker, never the sentence. Its sentence stays in `read`: the model // did read it (`unitsFromHistory`). if (m.role === 'tool' && m.toolName && m.notDispatched === undefined) { // A tool result whose content advertises an error is marked not-ok. The // synthetic strings the loop writes ("[permission denied…]", "declined by // human…", "credential error…") all read as failures here. const c = (m.content ?? '').toLowerCase(); const ok = !(c.startsWith('[permission denied') || c.startsWith('declined by human') || c.startsWith('credential error') || c.startsWith('authorization required')); toolCalls.push({ name: m.toolName, ok }); } } const summary = toolCalls.length === 0 ? `no tools run yet (iteration ${iteration})` : `${toolCalls.length} tool${toolCalls.length === 1 ? '' : 's'} run over ${iteration} iteration${iteration === 1 ? '' : 's'}`; return { iteration, toolCalls, summary }; } /** * 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 const standardEvidenceAssembler = async (input) => { const { tool, args, iteration, history, scorer, signal } = input; const units = unitsFromHistory(history); const willDo = buildWillDo(tool, args); const read = framesFromUnits(units); const trail = buildTrail(history, iteration); let drivers = []; if (units.length > 0) { const toolText = `${tool.name} ${tool.description} ${renderArgs(args)}`.trim(); drivers = await scorer({ tool: { name: tool.name, text: toolText }, units, ...(signal && { signal }), }); } return { willDo, read, drivers, trail }; }; /** The `'minimal'` assembler — only `willDo`. Zero cost; no scorer call. */ export const minimalEvidenceAssembler = (input) => ({ willDo: buildWillDo(input.tool, input.args), }); /** * 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 function resolveCheckInConfig(opts) { // 8.13.0 — a scorer that will never be called is refused, not resolved. // `minimalEvidenceAssembler` builds only `willDo`; the scorer's whole job is // ranking `drivers`, which the minimal pack does not have. Configured and // inert looks exactly like configured and working. // // Only the literal `'minimal'` PRESET is refused. A custom assembler is handed // the scorer in its input and may legitimately call it, so the library cannot // judge that pairing — and refusing what it cannot judge would be a guess. if (opts?.scorer !== undefined && opts.evidence === 'minimal') { throw new Error("AgentBuilder.checkIn: `scorer` has no effect with `evidence: 'minimal'`. The scorer " + 'ranks the `drivers` field — which context drove this tool choice — and the minimal ' + 'assembler builds only `willDo`, so the scorer is resolved and then never called. Use ' + "`evidence: 'standard'` to get the drivers, or drop the `scorer`."); } const scorer = opts?.scorer ?? lexicalDriverScorer; const evidence = opts?.evidence ?? 'standard'; const assembler = evidence === 'standard' ? standardEvidenceAssembler : evidence === 'minimal' ? minimalEvidenceAssembler : evidence; return { assembler, scorer }; } //# sourceMappingURL=checkin.js.map