UNPKG

agentfootprint

Version:

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

244 lines • 11.1 kB
/** * arguments/dispatch — the inputs layer's half of ToolCalls, in the ONE module * a plain agent never loads. * * Pattern: small helpers over the batch loop's own values — the layer's entry * for a call (`argumentResolutions`), the arguments the call ran * with, the rewrites a before-tool chain made — each returning the * value the loop uses next. * Role: core/ layer leaf of the inputs layer (honesty layer 2). ToolCalls * (`stages/toolCalls.ts`) loads this module through `import()` only * when the layer is armed, or when a call's tool declares rules on an * agent built without it — the optional-family law of docs-next's * site budget (`scripts/check-site-budget.mjs`, the `findings/peel.ts` * precedent). Imports nothing from `findings/` or `stages/`. * Emits: N/A (one console warning per tool name on the fail-closed path). * * @example * ```ts * const inputs = deps.inputsLayer === true ? await import('../arguments/dispatch.js') : undefined; * const runArgs = inputs !== undefined ? inputs.withFills(args, resolution) : args; * ``` */ import { changedArgKeys } from '../../toolShownArgs.js'; import { isMissing, rulesOf } from '../../../tools/declare.js'; import { keptThisTurn, withKept, withoutUsed } from './kept.js'; import { filledNote, hidesArgument, keptAnswersNote, secondPauseRefusal, unmountedRulesRefusal, } from './serve.js'; /** * The refusal a call reads when the batch's one human question was the * layer's ask and the call needed a person again (`serve.ts` · * `secondPauseRefusal`) — re-exported so ToolCalls reaches it through this * module's one `import()`. */ export { secondPauseRefusal }; /** * This batch's entries of `argumentResolutions`, by call id — only the entries * resolved for THIS iteration (`resolve.ts` stamps each with its batch's). */ export function resolutionsFor(scope, iteration) { // Spread first: a TypedScope array read is a live deep-proxy view. const entries = [...(scope.argumentResolutions ?? [])]; const byId = new Map(); for (const entry of entries) { if (entry.iteration === iteration) byId.set(entry.toolCallId, entry); } return byId; } /** `args` with the entry's fills — a FRESH object; `args` itself when there are none. */ export function withFills(args, resolution) { const fills = resolution?.fills; if (fills === undefined || fills.length === 0) return args; const filled = { ...args }; for (const fill of fills) filled[fill.argument] = fill.value; return filled; } /** * KEPT ANSWERS, USED (honesty layer 2, step 4 — `kept.ts`): the * (tool, argument) pairs this batch's calls fill from an answer the turn kept * are dropped from `argumentAnswersKept` — a kept answer is used once. Called * before the batch ask merges its own answers, so a fill that is `answered` * here came from a kept answer and nothing else; a batch that fills none never * reads the key. A re-run after an interrupt finds them already dropped and * writes nothing. */ export function dropUsedKept(scope, resolutions, calls) { const nameOf = new Map(calls.map((c) => [c.id, c.name])); const used = []; for (const entry of resolutions.values()) { const toolName = nameOf.get(entry.toolCallId); if (toolName === undefined) continue; for (const fill of entry.fills ?? []) { if (fill.source === 'answered') used.push({ toolName, argument: fill.argument }); } } if (used.length === 0) return; const turn = scope.turnNumber; const before = scope.$getValue('argumentAnswersKept'); const left = withoutUsed(before, turn, used); if ((left?.length ?? 0) === keptThisTurn(before, turn).length) return; scope.argumentAnswersKept = left; } /** * KEEP a refused call's answers (honesty layer 2, step 4 — `kept.ts`): in a * batch whose one human question was the inputs layer's ask, a call that * needed a person again was just refused — so the answered values it carried * (the batch ask's, or a kept one it filled from) are kept for the call the * model proposes next, which would otherwise leave the argument out and be * asked the same question again. Returns the argument names kept, for the * model's sentence (`keptNote`); nothing kept, nothing written. */ export function keepAnswers(scope, toolName, resolution) { const answered = (resolution?.fills ?? []).filter((f) => // An `object` form's window is not kept: a kept answer is one flat value (`kept.ts`). f.source === 'answered' && (typeof f.value !== 'object' || f.value === null)); if (answered.length === 0) return []; scope.argumentAnswersKept = withKept(scope.$getValue('argumentAnswersKept'), scope.turnNumber, toolName, answered); return answered.map((f) => f.argument); } /** * KEEP a refused call's answers and return the model's sentence for them * (`serve.ts` · `keptAnswersNote` over `keepAnswers`) — `''` when the call * carried no answer. */ export function keptNote(scope, toolName, resolution) { return keptAnswersNote(toolName, keepAnswers(scope, toolName, resolution)); } /** * The entry's fills the call RAN with — each whose value is still the value on * the arguments the call ran with (`callArgs`, after the before-tool chain). A * middleware that rewrote a filled argument ran the call on ITS value, so a * clause naming the fill would tell the model the call ran with a value it did * not run with — a sentence a later path broke (honesty law 7). That clause is * omitted, never denied: the rewrite is on `middlewareDecisions` * (`changedKeys`), which the answer's standing and the "Assumed" block read. * Strict equality on purpose: the note prints the value, so it must be the * value that ran, byte for byte. */ export function fillsThatRan(resolution, ranWith) { const fills = resolution?.fills ?? []; return fills.filter((f) => ranWith[f.argument] === f.value); } /** The value the model's call carried for `argument` — `undefined` when it left it out. */ function carriedValue(proposed, argument) { if (proposed === undefined || isMissing(proposed, argument)) return undefined; const value = proposed[argument]; return typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) ? value : undefined; } /** * The past-tense note for a call that RAN on filled values (`serve.ts` · * `filledNote`) — one clause per fill it really ran with (`fillsThatRan`), a * value the tool's view hides never printed. `proposed` is the call's own * arguments before the fills: an answered fill that REPLACED a value the call * carried (declared sources ask about an untraced value) says what the call * had carried; under declared sources (`options.sources`) an answered clause * also says a later call may cite the answer (`serve.ts` · * `ANSWERED_SOURCE_CLAUSE`). `''` when no fill ran. */ export function noteFor(toolName, tool, resolution, ranWith, proposed, options) { const ran = fillsThatRan(resolution, ranWith); if (ran.length === 0) return ''; return filledNote(toolName, ran.map((f) => { // A value the library wrote from the window the person CHOSE when asked (the lazy // word-driven ask) is a window fill: its clause says whose window, and whether it reads wider. if (f.source === 'answered' && f.window === true) { return { argument: f.argument, value: f.value, hidden: hidesArgument(tool, f.argument, f.value), source: 'window', from: 'answered', ...(f.wider !== undefined && { wider: f.wider }), }; } if (f.source === 'window') { return { argument: f.argument, value: f.value, hidden: hidesArgument(tool, f.argument, f.value), source: 'window', from: f.from, ...(f.wider !== undefined && { wider: f.wider }), }; } const carried = f.source === 'answered' ? carriedValue(proposed, f.argument) : undefined; return { argument: f.argument, value: f.value, hidden: hidesArgument(tool, f.argument, f.value), ...(f.source === 'answered' && { source: 'answered' }), ...(carried !== undefined && { carried }), }; }), options); } /** * The before-tool rows of one call's chain, each rewrite stamped with the * NAMES of the arguments it changed (`changedArgKeys` over its own * before/after — names only, never values). */ export function withChangedKeys(rows) { return rows.map((row) => { if (!row.changed || row.moment !== 'before-tool') return row; const before = row.before; const after = row.after; if (before === null || typeof before !== 'object' || after === null || typeof after !== 'object') { return row; } const keys = changedArgKeys(before, after); return keys.length > 0 ? { ...row, changedKeys: keys } : row; }); } /** * The rows a call's before-tool chain files: a RULED tool's rewrites carry * `changedKeys` (`withChangedKeys`) — the answer's standing reads a rewritten * ruled argument with no declared origin as assumed; every other tool's rows * as the chain wrote them. */ export function decisionsToRecord(tool, rows) { return rulesOf(tool) !== undefined ? withChangedKeys(rows) : rows; } const warnedUnmounted = new Set(); const MAX_WARNED_UNMOUNTED = 500; /** * FAIL CLOSED (adopted Q14): the sentence a call reads when its tool declares * argument rules and the agent was built without the inputs layer — and, once * per tool name, unconditionally (a refused call is not a style note), a * warning naming `.inputsLayer()`. */ export function unmountedRefusal(toolName) { if (!warnedUnmounted.has(toolName)) { if (warnedUnmounted.size < MAX_WARNED_UNMOUNTED) warnedUnmounted.add(toolName); // eslint-disable-next-line no-console console.warn(`[agentfootprint] tool '${toolName}' declares argument rules (askOrAssume / period), and ` + `this agent was built without the inputs layer — the tool reached the run through a ` + `ToolProvider, which the build cannot see. Its calls are REFUSED rather than run ` + `unruled. Build the agent with .inputsLayer() to apply the rules.`); } return unmountedRulesRefusal(toolName); } /** * Forget every unmounted-rules warning issued so far. * @internal test seam — the ledger is process-wide and warn-once. */ export function _resetUnmountedRulesWarnings() { warnedUnmounted.clear(); } //# sourceMappingURL=dispatch.js.map