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