UNPKG

agentfootprint

Version:

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

420 lines • 21 kB
"use strict"; /** * answer — how a ledger survives into the final answer. * * Pattern: composed by the FRAMEWORK, not requested from the model. Pure * function of the run's declarations; the stage that calls it is a * two-line wrapper (see `stages/prepareFinal.ts`). * Role: core/ layer, pure. * Emits: N/A. * * ## Why this is an append and not a check * * A ledger the model can drop is worthless, and every mechanism that ASKS the * model to carry it can be dropped: a note in the tool result is advice, a * system-prompt rule is advice, and a judge that reads the answer back and * asks "did it state its limits?" needs a second model to decide what * counts — which would put a language judgement in the one place this library * refuses to put one (see `evidence/README.md`: the guard may not need a * bigger model than the thing it guards). * * So the limits are APPENDED. The model does not write them, so the model * cannot drop them, and the mechanism is a string concatenation over data the * tools declared — deterministic, auditable, and cheap. It changes the bytes * of the answer, which is why it is opt-in * (`.limitsTravelWithTheAnswer()`): an agent that never asks for it is * byte-identical. * * ## Why a TYPED answer gets the same fold as data, never the block * * An answer with an output schema is JSON, and JSON followed by prose is not * JSON — appending the block made `runTyped()` throw on every answer that had * limits. So when an output schema is configured the answer string stays the * model's own, and the same fold travels BESIDE it: `coverageOfAnswer` below, * committed as `AgentState.answerCoverage`. The model still cannot drop it — * the framework composes it from what the tools declared — and a person reads * it wherever the app draws it, not wherever the prose happened to end. * * ## Why an absence contributes too * * `absent()` and `coverage()` make the same kind of statement about the same * run. A search that found nothing in the fcns database really did cover the * fcns database, and a search that could not reach the archive really is a * limit on the answer. Folding both into one block is the only way the reader * gets ONE boundary instead of a boundary per tool — and duplicates are * dropped, so five tools naming the same missing collector say it once. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.composeAnswerWithCoverage = exports.copyAnswerCoverage = exports.copyCoverage = exports.answerCoverageOf = exports.coverageOfAnswer = exports.CLOCKS_SECTION_LABEL = exports.PERIOD_SECTION_LABEL = exports.ASSUMED_BLOCK_HEADING = exports.COVERAGE_BLOCK_HEADING = void 0; const inProgress_js_1 = require("./inProgress.js"); const items_js_1 = require("./items.js"); const period_js_1 = require("./period.js"); const linearText_js_1 = require("../../lib/linearText.js"); const displayName_js_1 = require("../../lib/displayName.js"); /** * THE FOLD both answer forms share: each section merged ONCE across the run's * declarations — declaration order, duplicates dropped (`mergeItems`). The * block renders these lists (capped for the reader) and the data copies them * (uncapped), so the two cannot disagree about which items the answer's limits * hold: one rule, one place, two callers. */ function foldSections(declared) { return { checked: (0, items_js_1.mergeItems)(declared.map((d) => d.checked)), notChecked: (0, items_js_1.mergeItems)(declared.map((d) => d.notChecked)), cannotCover: (0, items_js_1.mergeItems)(declared.map((d) => d.cannotCover)), }; } /** The block's opening line. Stable — tests and readers match on it. */ exports.COVERAGE_BLOCK_HEADING = 'Coverage of this answer'; /** * The inputs layer's "Assumed" block's opening line (`arguments/serve.ts` · * `assumedBlock` composes the block). Owned here, beside the limits block's * heading: this module's `composeAnswerWithCoverage` is the one composer of * the section the framework appends after an answer, and a reader of the * answer's text (`answer-account/account.ts` · `readAnswer`) finds every * block of it through this one module. Stable — tests and readers match on it. */ exports.ASSUMED_BLOCK_HEADING = "Assumed (a tool's rule, not your words):"; /** * Entries per section before the block folds. A boundary nobody reads is not * a boundary; the run record keeps every entry either way (the * `tools.coverage_declared` / `tools.absent` events), so folding costs * nothing but the reader's patience. */ const MAX_ENTRIES_PER_SECTION = 12; const SECTIONS = [ ['checked', 'Checked'], ['notChecked', 'Not checked'], ['cannotCover', 'Cannot cover'], ]; function renderSection(label, items, sourceOf = () => undefined) { return renderLines(label, items.map((i) => { const source = sourceOf(i); return `${i.what}${i.why !== undefined ? ` — ${i.why}` : ''}${source !== undefined ? ` (${source})` : ''}`; })); } /** * The calls that declared `item` in one section, named in the app's words — * the standing line's own naming (`lib/displayName.ts` · `namedCalls`: the * declared names, the rest counted). One call that filed two rows (a * `coverage(absent(…))`) is one call; a row that names no call is a call of * its own. `undefined` when none of them declares a name: the item's line is * then the bytes it always was. */ function itemSource(declared, key, item, displayNameOf) { const calls = []; const seen = new Set(); for (const row of declared) { if (!row[key].some((i) => (0, items_js_1.sameItem)(i, item))) continue; if (row.toolCallId !== undefined) { if (seen.has(row.toolCallId)) continue; seen.add(row.toolCallId); } calls.push({ toolName: row.toolName }); } return (0, displayName_js_1.namedCalls)(calls, 0, displayNameOf); } /** One labelled section of `- line` bullets, folded after the cap like every section. */ function renderLines(label, texts) { const shown = texts.slice(0, MAX_ENTRIES_PER_SECTION); const lines = shown.map((text) => `- ${text}`); if (texts.length > shown.length) { lines.push(`- … and ${texts.length - shown.length} more (in the run record)`); } return `${label}:\n${lines.join('\n')}`; } /** The section heading the periods print under (honesty step 7b). Stable — readers match on it. */ exports.PERIOD_SECTION_LABEL = 'Period'; /** The section heading the wall-clock sources print under (the time layer, step T8). Stable. */ exports.CLOCKS_SECTION_LABEL = 'Clocks'; /** * The periods the run's declarations carried, one per DECLARING call and * distinct period, in declaration order — a call whose `coverage()` and inner * `absent()` declared the same period says it once. Fresh plain objects. */ function periodsOf(declared) { const out = []; const seen = new Set(); for (const row of declared) { if (row.period === undefined) continue; const key = JSON.stringify([row.toolCallId ?? '', row.toolName, row.period]); if (seen.has(key)) continue; seen.add(key); out.push({ toolName: row.toolName, ...(row.toolCallId !== undefined && { toolCallId: row.toolCallId }), ...(0, period_js_1.copyPeriod)(row.period), }); } return out; } /** * The in-progress items the run's declarations carried, one entry per * DECLARING call, in declaration order. Fresh plain objects. */ function inProgressOf(declared) { const out = []; for (const row of declared) { if (row.inProgress === undefined || row.inProgress.length === 0) continue; out.push({ toolName: row.toolName, ...(row.toolCallId !== undefined && { toolCallId: row.toolCallId }), items: row.inProgress.map(inProgress_js_1.copyInProgressItem), }); } return out; } // reads: scope.coverageDeclared ← read by ../../core/agent/stages/answerCoverage.ts · withAnswerCoverage, on the Route decider's // terminal decision, and only when the answer is TYPED — the data twin of `composeAnswerWithCoverage`, below. /** * The run's declarations folded into the ANSWER's coverage — the three lists * `composeAnswerWithCoverage` renders, as data. * * `.limitsTravelWithTheAnswer()` on a typed answer (`.outputSchema()`): the * answer string has to stay the model's JSON for `runTyped()` to parse it, so * the limits travel beside it instead of inside it — this value, committed as * `AgentState.answerCoverage`, projected onto `turn_end.answerCoverage` and * returned by `agent.answerCoverage()`. * * Folded by the block's own fold (`foldSections`, the one place each list is * merged — declaration order, duplicates dropped), so every entry is one the * block would print and none is added. It keeps EVERY entry: the block's cap * (twelve per section, then "… and N more") is a reading aid for prose, not a * limit on the data. * * Fresh plain objects, copied field by field (`what`, `why`, and the * record-only `short` / `kind` when declared), never a reference into the rows * it was folded from — so the value can be committed, emitted and handed to a * caller as detached data. `undefined` when nothing was declared: the identity * case, the one every run whose tools declare nothing takes. */ function coverageOfAnswer(declared) { if (declared.length === 0) return undefined; // The periods the calls declared (honesty step 7b) — the data twin of the // block's `Period:` lines; the key only when one was declared, so a run whose // tools declared none commits the value it always did. const periods = periodsOf(declared); // What the calls found still running — the data twin of the block's // "In progress" lines; the key only when a call declared some. const inProgress = inProgressOf(declared); const sections = copyCoverage(foldSections(declared)); const folded = { ...sections, ...(periods.length > 0 && { periods }), ...(inProgress.length > 0 && { inProgress }), }; // The composer's second identity case, for the same reason: a hand-built row // that says nothing must not become a boundary that looks like one. const entries = folded.checked.length + folded.notChecked.length + folded.cannotCover.length + periods.length + inProgress.length; return entries > 0 ? folded : undefined; } exports.coverageOfAnswer = coverageOfAnswer; /** * A typed answer's limits as ONE value: the folded coverage (or three empty * lists) and the values assumed this turn, `assumed` present only when one * was. `undefined` when there is neither — the identity case, so a run whose * tools declared nothing and assumed nothing commits exactly the keys it * always did. */ function answerCoverageOf(folded, assumed) { if (folded === undefined && assumed.length === 0) return undefined; const base = folded ?? { checked: [], notChecked: [], cannotCover: [] }; return assumed.length > 0 ? { ...base, assumed: assumed.map(copyAssumed) } : base; } exports.answerCoverageOf = answerCoverageOf; /** A coverage value as detached plain data — the same three lists, every item copied. */ function copyCoverage(value) { return { checked: value.checked.map(copyItem), notChecked: value.notChecked.map(copyItem), cannotCover: value.cannotCover.map(copyItem), }; } exports.copyCoverage = copyCoverage; /** A typed answer's limits as detached plain data — the lists and any assumed values, copied. */ function copyAnswerCoverage(value) { return { ...copyCoverage(value), ...(value.periods !== undefined && { periods: value.periods.map(copyAnswerPeriod) }), ...(value.inProgress !== undefined && { inProgress: value.inProgress.map((entry) => ({ toolName: entry.toolName, ...(entry.toolCallId !== undefined && { toolCallId: entry.toolCallId }), items: entry.items.map(inProgress_js_1.copyInProgressItem), })), }), ...(value.assumed !== undefined && { assumed: value.assumed.map(copyAssumed) }), }; } exports.copyAnswerCoverage = copyAnswerCoverage; /** One declared period as the answer carries it, as detached plain data. */ function copyAnswerPeriod(p) { return { toolName: p.toolName, ...(p.toolCallId !== undefined && { toolCallId: p.toolCallId }), ...(0, period_js_1.copyPeriod)(p), }; } /** One assumed value as detached plain data. */ function copyAssumed(line) { return { toolName: line.toolName, argument: line.argument, value: line.value, hidden: line.hidden, }; } /** One entry as detached plain data: the fields a coverage item declares, nothing else. */ function copyItem(item) { return { what: item.what, ...(item.why !== undefined && { why: item.why }), ...(item.short !== undefined && { short: item.short }), ...(item.kind !== undefined && { kind: item.kind }), }; } // LENS · injected-turn · persistent-history // reads: scope.coverageDeclared ← read once by ../../core/agent/stages/prepareFinal.ts · captureTurnPayload — the ONE reader that COMPOSES from it, // folded and capped (a typed answer never reaches it: its data twin is `coverageOfAnswer`, above). Not the only read // of the key: ../../core/agent/stages/toolCalls.ts · declareCoverage reads it to append to it, // and ../../core/agent/findings/unsettled.ts · withUnsettledRows (9.113.0) reads one call's rows for their `kind` — the witness // that the tool returned an absence — and composes nothing from them (its row's words come from the served result). // law: may omit, never deny; every clause anchored to the call it was composed on. /** * Fold the run's declarations into one block and append it to the answer. * * Returns the answer UNCHANGED when nothing was declared — the identity case * matters, because it is the one every agent that never returns a coverage * shape takes. * * `standing` (the answer layer, honesty layer 4, under * `.answerLayer({ standingLine: true })`) is the answer's standing as one * already-composed line (`assessment/compose.ts` · `standingLineOf`); it opens * the section, and while it is on the caller passes no `assumed` block — the * line names the assumed values itself (one composer for one fact). * * `assumed` (the inputs layer, honesty layer 2) is a second, already-composed * block — the values a tool's `assume` rule filled this turn * (`arguments/serve.ts` · `assumedBlock`) — appended after the coverage block * under the same separator. With neither declarations nor an assumed block the * answer is unchanged, byte for byte. * * `presentation` (the time layer, only under `.time()`) is the run's clock * zone: each `Period:` line renders its instants there, the zone named * (`period.ts` · `periodLine`). Absent → the lines are the declared instants * verbatim, as they always were. * * `displayNameOf` (a tool's `displayName`, the app's words — given only on an * agent where a tool can declare one) names each item's declaring calls the * way the standing line names a reason's calls (`lib/displayName.ts` · * `namedCalls`): `- PDF attachments (loading the attached data)`, the * declared names named and the rest counted; and a `Period:` or "In progress" * line names its call by the declared words instead of the registered name. * An item no declaring call names stays the bytes it always was. * * `scripts` (`.scriptTravelsWithTheAnswer()`) is a third, already-composed * block — the programs this turn's calls SUBMITTED to a declared code-running * tool, each in the library's own fence under a label line read from its * execution record (`core/agent/scripts/attachment.ts` · `scriptsBlock`) — * appended LAST, after the standing line and the limits block, under the same * separator. Absent → the bytes it always was. Given as a function, it is * handed the exact text it will follow — the model's answer, the separator and * every block above — so the attachment can set a rendering boundary first * (`core/agent/scripts/attachment.ts` · `scriptsBlockAfter`): whatever block * that text leaves open must not take the programs in. */ function composeAnswerWithCoverage(answer, declared, assumed = '', standing = '', presentation, time, displayNameOf, scripts = '') { const blocks = []; // The answer layer's standing line (honesty layer 4, its own opt-in arm) // opens the section: it is the headline a person reads first. When it is // on, the caller passes no "Assumed" block — the line owns that sentence // (one composer for one fact). if (standing !== '') blocks.push(standing); const coverage = coverageBlock(declared, presentation, time, displayNameOf); if (coverage !== '') blocks.push(coverage); if (assumed !== '') blocks.push(assumed); // The model's answer: trimmed in one pass, never `/\s+$/` (lib/linearText.ts). const body = (0, linearText_js_1.trimTrailing)(answer, linearText_js_1.isRegExpWhitespace); // The scripts the library attached from the record, after everything else — // composed knowing the exact text they follow. const tail = typeof scripts === 'function' ? scripts(textBefore(body, blocks)) : scripts; if (tail !== '') blocks.push(tail); if (blocks.length === 0) return answer; const joined = blocks.join('\n\n'); return body.length > 0 ? `${body}\n\n---\n\n${joined}` : joined; } exports.composeAnswerWithCoverage = composeAnswerWithCoverage; /** The text a block appended after `blocks` follows, as the composer writes it. */ function textBefore(body, blocks) { const joined = blocks.join('\n\n'); if (body.length === 0) return joined; return blocks.length === 0 ? `${body}\n\n---` : `${body}\n\n---\n\n${joined}`; } /** The coverage block alone — `''` when the declarations say nothing. */ function coverageBlock(declared, presentation, time, displayNameOf) { const timeLines = (time?.period.length ?? 0) + (time?.clocks.length ?? 0); if (declared.length === 0 && timeLines === 0) return ''; const folded = foldSections(declared); // A call is named by the app's words only; with none declared, by its registered name, as always. const callName = (toolName) => displayNameOf?.(toolName) ?? toolName; const sections = []; for (const [key, label] of SECTIONS) { const items = folded[key]; if (items.length === 0) continue; sections.push(renderSection(label, items, displayNameOf === undefined ? undefined : (item) => itemSource(declared, key, item, displayNameOf))); } // One `Period:` line per declaring call (honesty step 7b) — the period AS // THE TOOL DECLARED IT (`period.ts` · `periodLine`). No period declared → // no section, and the block is the bytes it always was. // Under `.time()` (step T8), the time layer's result checks follow the // declared periods in the same section, and the wall-clock sources get // their own. Not armed → nothing is added, byte for byte. const periods = periodsOf(declared); const periodLines = [ ...periods.map((p) => (0, period_js_1.periodLine)(callName(p.toolName), p, presentation)), ...(time?.period ?? []), ]; if (periodLines.length > 0) sections.push(renderLines(exports.PERIOD_SECTION_LABEL, periodLines)); if (time !== undefined && time.clocks.length > 0) { sections.push(renderLines(exports.CLOCKS_SECTION_LABEL, time.clocks)); } // What the calls found still running — one line per item, tool first, as // declared (`inProgress.ts` · `inProgressLine`). None declared → no section, // and the block is the bytes it always was. const inProgress = inProgressOf(declared); if (inProgress.length > 0) { sections.push(renderLines(inProgress_js_1.IN_PROGRESS_SECTION_LABEL, inProgress.flatMap((entry) => entry.items.map((i) => (0, inProgress_js_1.inProgressLine)(callName(entry.toolName), i))))); } // Every declaration was empty in all three lists — impossible through the // two doors (both refuse a declaration that says nothing), but a hand-built // shape could arrive here, and appending an empty heading would be noise // pretending to be a boundary. if (sections.length === 0) return ''; return (`${exports.COVERAGE_BLOCK_HEADING} — declared by the tools that produced it, not by the model:` + `\n\n${sections.join('\n\n')}`); } //# sourceMappingURL=answer.js.map