UNPKG

agentfootprint

Version:

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

238 lines • 11.5 kB
/** * results/subflow — the four stages of the results layer's subflow * (`sf-results`): Declare → Verify → Record → Resolve (honesty layer 3). * * Pattern: thin stage bodies over three pure steps (`planPeriods`, * `checkPeriods`, `periodRowsOf`). Each stage reads what the one * before it staged on the subflow's own (isolated) scope and stages * its own output; the mount's output mapping (`honesty/mounts.ts` · * `mountResultsLayer`) merges the rows into the ONE ledger in one * write. * Role: core/ layer, the results layer. Loaded through `import()` by the * mount's stage wrappers — never on a plain agent's graph. Its rules * stay with their data: the period's shape, its rule set and the * verdict are `coverage/period.ts`'s; the ledger's merge and emit * halves are `findings/ledger.ts`'s, handed in as `deps`. * Emits: `agentfootprint.findings.period` — one per row, from the Record * stage, through the ledger's emit half (`deps.emitRows`). * * ## Where it runs, and what it reads * * At the LOOP HEAD — ToolCalls' `loopTo` lands on it — so it runs right after * every batch and before the next model call, and before any window strategy * folds the batch away. It reads the batch ToolCalls just ran (`toolResults`, * which still holds it at the loop head: identities only, never a result's * bytes) and the periods those calls' results declared (their committed * `coverageDeclared` rows of the batch's iteration). * * ## Each batch once — told apart by its iteration, never by a call id * * The schema re-ask, the step nudge, the evidence recheck and the wrap-up run * no tool and loop back here with the batch still in place. The layer tells * the first visit from those by the iteration that DISPATCHED the batch, * which ToolCalls stamps beside it (`AgentState.toolResultsIteration`, under * the arm): ToolCalls advances the iteration by one and loops straight here, * and every other way back advances it again — so the batch is new exactly * when the loop head's iteration is the stamp plus one, and the mount hands * the batch only then (`honesty/mounts.ts` · `batchToJudge`). A * call id cannot say it: a provider's synthetic counter restarts with each * provider instance (a resumed leg repeats the ids of the leg that failed, * whose rows the conversation checkpoint carries), and nothing stops a * provider reusing an id across batches of one run. If that invariant ever * broke, a batch would be judged twice — an over-report, never a hidden one. * On the first iteration there is no batch, and every stage finds nothing to * do. * * ## One verdict per call * * A call gets a row when its result declared a period, or its tool declares a * `ToolPeriod` (the inputs layer's declaration: which argument sets the * period). The verdict is `coverage/period.ts` · `periodVerdict` over the * declared period — the LEAST held, when the result declared more than one * (`leastHeld`) — or `undeclared` when the tool declares a period argument and * this result said nothing about what its read covered: declared silence, * recorded as silence. The row names the `ToolPeriod`'s argument, the join * key to the inputs layer's row for the same call. */ import { copyPeriod, leastHeld, periodVerdict, } from '../../../semantics/coverage/period.js'; import { periodTimeCheck, } from '../../../time/check.js'; // ─── The three pure steps ─────────────────────────────────────────────── /** * DECLARE, pure: the calls to judge, in batch order — each call whose result * declared a period or whose tool declares a `ToolPeriod`. A call id met twice * in ONE batch is judged once, over every period declared under it (the least * held wins, so merging can only over-report). */ export function planPeriods(calls, periods, periodArgumentOf, time) { const done = new Set(); const plan = []; for (const call of calls) { if (done.has(call.toolCallId)) continue; done.add(call.toolCallId); const declared = periods.filter((p) => p.toolCallId === call.toolCallId).map((p) => p.period); const argument = periodArgumentOf(call.toolName); if (declared.length === 0 && argument === undefined) continue; const rows = time?.times.find((t) => t.toolCallId === call.toolCallId); const facts = rows === undefined ? undefined : time?.periodFactsOf?.(call.toolName); const forms = rows === undefined ? undefined : time?.periodFormsOf?.(call.toolName); plan.push({ toolCallId: call.toolCallId, toolName: call.toolName, declared, ...(argument !== undefined && { argument }), ...(rows !== undefined && time !== undefined && { time: { now: time.now, ...(rows.window !== undefined && { window: rows.window }), ...(rows.drift !== undefined && { drift: rows.drift }), ...(facts !== undefined && { facts }), ...(forms !== undefined && forms.length > 0 && { forms: [...forms] }), }, }), }); } return plan; } /** * Under `.time()` (take 3): the held span of the declared period a `not-held` / `partly-held` verdict * came from — the first declared period whose own verdict is the call's — * or `undefined` for any other verdict. Fresh plain data. * * Here, in the results layer that files it, and not in `coverage/period.ts`: * this file loads through `import()` under the arm, so a plain agent never * carries it (the docs site's demo budget). * * @example * ```ts * heldSpanOf([{ queried: { from: '2026-09-30T13:00:00Z', to: '2026-09-30T18:59:50Z' }, * held: { from: '2026-10-01T16:22:44Z', to: '2026-10-01T17:02:44Z' } }], 'not-held'); * // { queried: { from: '2026-09-30T13:00:00Z', … }, held: { from: '2026-10-01T16:22:44Z', … } } * ``` */ export function heldSpanOf(declared, verdict) { if (verdict !== 'not-held' && verdict !== 'partly-held') return undefined; const period = declared.find((d) => periodVerdict(d) === verdict); if (period === undefined || period.held === 'unknown') return undefined; return { queried: { from: period.queried.from, to: period.queried.to }, held: { from: period.held.from, to: period.held.to }, }; } /** * VERIFY, pure: one verdict per planned call — the least held of its declared * periods, or `undeclared` when it declared none (its tool declares a * `ToolPeriod`, or it would not be planned). */ export function checkPeriods(plan) { return plan.map((p) => { // Under `.time()` (step T8): the time layer's result checks — `undefined` // when none holds, so the row gains nothing. const time = p.time === undefined ? undefined : periodTimeCheck({ ...p.time, declared: p.declared.map((d) => d.queried) }); const verdict = p.declared.length === 0 ? 'undeclared' : // Non-empty, so `leastHeld` answers. leastHeld(p.declared.map(periodVerdict)); // Under `.time()`: the span the source holds when it holds none or only part of the asked // time — the served time line names it in the person's zone (`coverage/timeLimits.ts` · `heldLine`). const held = p.time === undefined ? undefined : heldSpanOf(p.declared, verdict); return { toolCallId: p.toolCallId, toolName: p.toolName, verdict, ...(p.argument !== undefined && { argument: p.argument }), ...(time !== undefined && { time }), ...(held !== undefined && { held }), }; }); } /** RECORD, pure: one `period` row per judged call, stamped with the turn and the batch's iteration. */ export function periodRowsOf(checked, stamp) { return checked.map((c) => ({ kind: 'period', turn: stamp.turn, toolCallId: c.toolCallId, toolName: c.toolName, iteration: stamp.iteration, verdict: c.verdict, ...(c.argument !== undefined && { argument: c.argument }), ...c.time, ...(c.held !== undefined && { held: c.held }), })); } // ─── The four stages ──────────────────────────────────────────────────── /** A frozen input read is a live view — copied into plain data once. */ function callsOf(scope) { return [...(scope.calls ?? [])].map((c) => ({ toolCallId: c.toolCallId, toolName: c.toolName, })); } /** * DECLARE — which calls of the batch just run are the layer's: a declared * period, or a tool that declares a `ToolPeriod`. Stages `periodPlan` * (identities, the declared periods, the argument name). */ export function declareResultsStage(scope, deps) { const calls = callsOf(scope); if (calls.length === 0) { scope.periodPlan = []; return; } const periods = [...(scope.periods ?? [])].map((p) => ({ toolCallId: p.toolCallId, period: copyPeriod(p.period), })); const times = scope.times; const now = scope.now; scope.periodPlan = planPeriods(calls, periods, deps.periodArgumentOf, times === undefined || now === undefined ? undefined : { times: JSON.parse(JSON.stringify(times)), // a frozen input is a live view — plain data once now, ...(deps.periodFactsOf !== undefined && { periodFactsOf: deps.periodFactsOf }), ...(deps.periodFormsOf !== undefined && { periodFormsOf: deps.periodFormsOf }), }); } /** VERIFY — the verdict on each planned call's period. Stages `periodChecks` (identities and a word). */ export function verifyResultsStage(scope) { const plan = [...(scope.periodPlan ?? [])]; scope.periodChecks = plan.length === 0 ? [] : checkPeriods(plan); } /** * RECORD — one `period` row per judged call, stamped with the conversation * turn and the batch's iteration; one event per row, fired HERE (inside the * subflow). Stages `periodRows`; the mount's output mapping merges them into * the ledger in ONE write. */ export function recordResultsStage(scope, deps) { const checked = [...(scope.periodChecks ?? [])]; const rows = checked.length === 0 ? [] : periodRowsOf(checked, { turn: scope.turnNumber, iteration: scope.batchIteration, }); scope.periodRows = rows; if (rows.length > 0) deps.emitRows(scope, rows); } /** * RESOLVE — FLAG, the one verb a result that has already run admits (the * layer contract's clause 4: an ask and an assumption need a call that has * not run yet, a refusal one that can still be stopped). The rows Record filed * ARE the flags: the answer's standing folds them, and the mount's output * mapping returns them. So in this version the stage decides nothing and * writes nothing — it is the seat the later reading checks and outcome rows * (honesty step 8) resolve in. */ export function resolveResultsStage() { // Deliberately empty — see above. } //# sourceMappingURL=subflow.js.map