agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
206 lines • 10.3 kB
JavaScript
/**
* arguments/subflow — the four stages of the inputs layer's subflow
* (`sf-inputs`): Declare → Verify → Record → Resolve.
*
* Pattern: thin stage bodies over the pure steps in `resolve.ts`. Each reads
* what the stage before it staged on the subflow's own (isolated)
* scope and stages its own output; the mount's output mapper
* (`honesty/mounts.ts`) carries the rows and the resolutions out.
* Role: core/ layer, the inputs layer (honesty layer 2). Loaded through
* `import()` by the mount's stage wrappers — never on a plain agent's
* graph. Everything that is not this folder's arrives as `deps`
* (the dispatch resolver, Route's predicate, the ledger's emit half),
* so `arguments/` stays a leaf that never imports `findings/` or
* `stages/`.
* Emits: `agentfootprint.findings.argument` — one per row, from the Record
* stage, through the ledger's emit half (`deps.emitRows`).
*
* ## Only a batch that will dispatch
*
* Declare asks Route's own predicate (`deps.willDispatch`) first: a reply with
* no tool call, a run out of iterations, a halting cost budget — the batch is
* going to the final branch, so nothing is planned and every later stage finds
* an empty plan. The layer never files a row about a call Route will not run.
*
* ## Declared sources (`.inputsLayer({ argumentSources: true })`, `.findings({ argumentSources: true })`)
*
* Present only under the arm: `deps.sources` reads each call's `from` entries
* through the ONE reader of `_findings` (`findings/reserved.ts` ·
* `readDeclaration`, handed in as a closure, so this folder still never
* imports `findings/`), and Verify builds the corpora the checks read from the
* raw pieces the mount handed in (`sourceInputs`) — loaded on first use, never
* on a plain agent's graph. Every stage re-reads the entries rather than
* staging a second copy of the model's quotes.
*/
import { turnWindowsOf } from '../../../time/bind.js';
import { callWindowRowsOf, declareBatch, resolutionsOf, rowsOf, timeDecisionsOf, verifyPlan, } from './resolve.js';
/** The calls' declared sources, by call id — `undefined` when the arm is off. */
function declaredOf(deps, calls) {
if (deps.sources === undefined)
return undefined;
return new Map(deps.sources.declaredOf(calls).map((d) => [d.toolCallId, d]));
}
/**
* The arm as the pure steps take it — the entries, and (from Verify on) the
* corpora and whether a tool in reach can hide arguments (the agent's
* build-time fact: a registered tool with an argument view, or a ToolProvider).
*/
function armOf(declared, corpus, argumentViews) {
if (declared === undefined)
return undefined;
return {
declared,
...(corpus !== undefined && { corpus }),
...(argumentViews === true && { argumentViews }),
};
}
/**
* This turn's kept answers as plain data — read only by a stage whose batch
* left an `ask` argument out, the one case a kept answer can fill.
*/
function keptOf(scope) {
const kept = scope.argumentAnswersKept;
return kept === undefined ? undefined : [...kept].map((a) => ({ ...a }));
}
/**
* The time arm as the pure steps take it — `undefined` when `.time()` is off,
* or the mount handed no inputs (no call, or a turn with no clock). Read only
* under the arm: a tracked read of a key a run never writes is a phantom source.
*/
function timeArmOf(scope, deps) {
if (deps.time === undefined)
return undefined;
const inputs = scope.timeInputs;
if (inputs === undefined)
return undefined;
// The rows are plain JSON (checked at the checkpoint door); a frozen input read is a live
// proxy view, which `structuredClone` refuses — so the copy is a JSON round-trip.
const plain = JSON.parse(JSON.stringify({
clock: inputs.clock,
readings: [...inputs.readings],
answers: [...(inputs.answers ?? [])],
}));
return {
turn: turnWindowsOf(plain.readings, plain.clock, plain.answers),
now: plain.clock.now,
zone: plain.clock.zone,
...(deps.time.appZone !== undefined && { appZone: deps.time.appZone }),
};
}
/** Whether a planned batch left an `ask`-ruled argument out — the one case a kept answer can fill. */
const leavesAskOut = (plan) => plan.some((p) => p.ruled.some((r) => r.rule === 'ask' && r.missing));
/** Whether the checked batch fills a kept answer anywhere. */
const fillsKept = (checked) => checked.some((c) => c.filled === true && c.source === 'answered' && c.window === undefined);
/** The batch as plain data — a frozen input read is a live view, spread it once. */
function callsOf(scope) {
return [...(scope.calls ?? [])].map((c) => ({
id: c.id,
name: c.name,
args: { ...(c.args ?? {}) },
...(c.argumentsFailure !== undefined && { argumentsFailure: { ...c.argumentsFailure } }),
}));
}
/**
* DECLARE — which calls of a DISPATCHING batch carry rules, read off the
* implementation that will answer each name, and which ruled values are
* missing. Stages `argumentPlan` (identities and a flag — no value); an empty
* plan when the batch will not dispatch or no call is ruled.
*/
export function declareArgumentsStage(scope, deps) {
const calls = callsOf(scope);
// Read under Route's own short-circuit: the policy only once a budget was hit
// (the key is handed in only when the agent configured one).
const costBudgetHit = scope.costBudgetHit;
const costBudgetOnExceed = costBudgetHit === true ? scope.costBudgetOnExceed : undefined;
const dispatches = deps.willDispatch({
callCount: calls.length,
iteration: scope.iteration,
maxIterations: scope.maxIterations,
...(costBudgetHit !== undefined && { costBudgetHit }),
...(costBudgetOnExceed !== undefined && { costBudgetOnExceed }),
...(deps.toolsOffAfterRevision === true && scope.toolsOff === true && { toolsOff: true }),
});
scope.argumentPlan = dispatches
? declareBatch(calls, deps.toolOf, armOf(declaredOf(deps, calls)))
: [];
}
/**
* VERIFY — where each planned ruled value came from (`resolve.ts` ·
* `verifyPlan`); under declared sources, each present value checked against
* the source the model declared (`checks.ts` · `checkSource`) over corpora
* built from the mount's `sourceInputs`. Stages `argumentChecks` (identities,
* enums and the checks' verdicts — no value, no quote).
*/
export async function verifyArgumentsStage(scope, deps) {
const plan = [...(scope.argumentPlan ?? [])];
if (plan.length === 0) {
scope.argumentChecks = [];
return;
}
const calls = callsOf(scope);
const declared = declaredOf(deps, calls);
// Read only under the arm: a tracked read of a key a run never writes is a phantom source.
const inputs = deps.sources !== undefined ? scope.sourceInputs : undefined;
const corpus = declared !== undefined && deps.sources !== undefined && inputs !== undefined
? await deps.sources.corpusOf(detached(inputs), calls, scope.turnNumber)
: undefined;
scope.argumentChecks = verifyPlan(plan, calls, deps.toolOf, leavesAskOut(plan) ? keptOf(scope) : undefined, armOf(declared, corpus, deps.sources?.argumentViews), timeArmOf(scope, deps));
}
/** The mount's frozen inputs as plain data — a frozen input read is a live view. */
function detached(inputs) {
return structuredClone({
history: [...(inputs.history ?? [])],
...(inputs.systemPromptInjections !== undefined && {
systemPromptInjections: [...inputs.systemPromptInjections],
}),
...(inputs.ledger !== undefined && { ledger: [...inputs.ledger] }),
...(inputs.previousBatch !== undefined && { previousBatch: [...inputs.previousBatch] }),
...(inputs.composed === true && { composed: true }),
});
}
/**
* RECORD — one row per checked argument, in the tool's own argument view,
* stamped with the conversation turn; one event per row, fired HERE (inside the
* subflow, before the call dispatches). Stages `argumentRows`; the mount's
* output mapper merges them into the ledger in ONE write.
*/
export function recordArgumentsStage(scope, deps) {
const checked = [...(scope.argumentChecks ?? [])];
const stamp = { turn: scope.turnNumber, iteration: scope.iteration };
// Under `.time()`: the windows decide fills, so the decisions are read before the rows.
const time = timeArmOf(scope, deps);
const plan = time === undefined
? []
: [...(scope.argumentPlan ?? [])];
const calls = checked.length === 0 && plan.length === 0 ? [] : callsOf(scope);
const decisions = timeDecisionsOf(plan, calls, deps.toolOf, time, armOf(declaredOf(deps, calls)));
const rows = checked.length === 0
? []
: rowsOf(checked, calls, deps.toolOf, stamp, fillsKept(checked) ? keptOf(scope) : undefined, armOf(declaredOf(deps, calls)), decisions);
scope.argumentRows = rows;
if (rows.length > 0)
deps.emitRows(scope, rows);
if (time !== undefined)
scope.argumentWindowRows = callWindowRowsOf(decisions, calls, stamp);
}
/**
* RESOLVE — the fills and refusals ToolCalls applies (`resolve.ts` ·
* `resolutionsOf`). Stages `argumentResolutions`, each entry stamped with the
* batch's iteration.
*/
export function resolveArgumentsStage(scope, deps) {
const plan = [...(scope.argumentPlan ?? [])];
const checked = [...(scope.argumentChecks ?? [])];
if (plan.length === 0) {
scope.argumentResolutions = [];
return;
}
// The calls are read only under an arm — a reading's quote rides the ask; a window fills.
const time = timeArmOf(scope, deps);
const calls = deps.sources !== undefined || time !== undefined ? callsOf(scope) : undefined;
const sources = calls !== undefined ? armOf(declaredOf(deps, calls)) : undefined;
scope.argumentResolutions = resolutionsOf(plan, checked, deps.toolOf, scope.iteration, fillsKept(checked) ? keptOf(scope) : undefined, sources, time !== undefined && calls !== undefined
? timeDecisionsOf(plan, calls, deps.toolOf, time, sources)
: undefined);
}
//# sourceMappingURL=subflow.js.map