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