UNPKG

agentfootprint

Version:

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

604 lines • 29.4 kB
"use strict"; /** * evidenceIndex — what the run can PROVE it read, as a lookup set. * * Pattern: build once per judgement, ask many times (the same "one pass, then * query" shape the slice layer uses). * Role: core/ layer. The other half of `namesAndNumbersFromEvidence`. * Emits: N/A. * * ## Why the walk is STRUCTURAL * * The obvious implementation is `allToolResults.includes(value)`. It is wrong * in the direction that matters: a fabricated FCID `0xef0101` "appears" in a * blob that contains `0xef01011` or `naa.0xef0101ab`, so the invented value * reads as grounded and the check quietly passes everything. So a tool result * that parses as JSON is WALKED — every key, every leaf — and each leaf is * indexed both whole and tokenized; so is the JSON a result OPENS with when a * framework note follows it (`readResult`, over `servedJson.ts`). A result * that is not JSON is tokenized as text. Either way the comparison is * token-exact, never substring. Under `decimalRounding` a whole token of * text in scientific notation also files the exact decimal it states * (`1.12e-02` → `0.0112`, {@link addDecimalOf}); the spelling stays. * * ## What counts as evidence, and what deliberately does not * * • **Tool results** — the `role: 'tool'` turns in `scope.history` AS IT * STANDS AT JUDGEMENT. A value the model read from a tool is grounded. * * Say that precisely, because "the whole conversation" is only true for an * agent with no window strategy. `.window()` / `.compaction()` / * `tokenBudget` REWRITE `scope.history` in place (window.ts, `scope.history * = result.window`), so on those agents this corpus is the LIVE WINDOW and * a result the window has dropped is not in it — the same value then reads * as ungrounded. `keepLastToolResults` (default 2) is what keeps the most * recent observations pinned; nothing pins the rest. Anything computed from * this index therefore describes what the run can still SEE, never * everything it ever read. * • **Object KEYS count.** A map keyed by WWN puts real identifiers in key * position, and the model saw them exactly as it saw the values. * • **Tool call ARGUMENTS do not count.** The model typed those. Grounding a * value because the model passed it to a tool would let any invention * launder itself through one failed lookup. * • **The model's own earlier answers do not count**, for the same reason. * • **An `absent(…)` result grounds everything EXCEPT `looked_for`.** Its * coverage lists, its `try_instead`, its note and any extra key the tool * attached (a `known_shares` list, say) are the tool's own words about the * world, and an answer that follows the absence's advice must not be * called ungrounded for doing so. `looked_for` alone quotes the arguments * the model passed, so indexing it would ground an invented identifier * through the one operation that proves nothing about it: a lookup that * found nothing. See `../../../semantics/coverage/evidence.ts`. * * The EXEMPT index is built from a different corpus with the same machinery: * the user's own message, the conversation's user/system turns, the values * the person typed into a typed ask (`requestInput` — its `input_received` * result is a `role: 'tool'` message carrying THEIR words), and the * system-prompt content this turn was built from — each text with the other * spellings of the dates and clock times in it (`time/forms.ts` · * `timeFormsOf`'s `said` list: `8 Am` is also `8:00`, `2026-10-09` also * `2026`) — and, under `.time()`, the said spellings of the turn's recorded * windows (`timeSaid`). A compaction summary is a * user-role turn a MODEL wrote, so its text is not in it — the summary * carries the forms its folded person/app turns exempted instead * (`LLMMessage.foldedExempt`, {@link exemptLineageOf}). A value the user supplied is * not a fabrication — the user gave it — and neither is one the app's own * prompt or skill body put in front of the model. * * ## WHEN a value was read, and why the corpus is still conversation-wide * * The corpus above answers "did the run read this?" and says nothing about * WHEN. That gap has a measured cost: an agent answered a fresh question with * ZERO tool calls, and the gate approved it because all seven values appeared * in an inventory result fetched four turns earlier for a different question. * Grounded, and four turns out of date. * * The fix is NOT to narrow the corpus to this turn. "And what about that * disk?" legitimately leans on the previous turn's rows, and flagging those * would make the gate cry wolf until somebody switched it off. So the corpus * stays whole and every indexed form carries the TURN it was last served in * — one number, stamped during the walk that was happening anyway. * * A TURN starts at each `role: 'user'` message the library did NOT author. * `isLibraryAuthoredTurn` (frames.ts) is a statement about AUTHORSHIP, not * about time — it exists to keep the exempt corpus from swallowing the gate's * own corrections — and it is reused here for a different reason that happens * to need the same predicate: a frame the library wrote is not a person asking * something new. Excluding them is load-bearing rather than tidy. The evidence * recheck appends a `role: 'user'` turn MID-RUN, so counting it as a boundary * would push this turn's own tool results into the "earlier" bucket and file a * notice against every revised answer. * * Turn 1 is the first user message still in the window; anything indexed * before one (a history with no user turn at all) is turn 0, which is also * `currentTurn`, so nothing can read as earlier and no notice can be filed * from a conversation with no boundary in it. * * **The ordinals are WINDOW-RELATIVE**, and that is the honest bound: they * count the user turns the run can still see, so under a window strategy * "turn 2 of 4" may be the conversation's turn 9 of 13. The BOUNDARY is * exact regardless — the current request is un-droppable by every window * strategy (`'current-request'`, window/currentRequest.ts) — so "this turn * versus earlier" holds; only the DISTANCE is a floor. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.derivedFormsOf = exports.exemptFromRun = exports.exemptLineageOf = exports.evidenceFromHistory = exports.readResult = exports.MAX_CARRIERS = exports.MAX_INDEX_TOKENS = void 0; const toolBytes_js_1 = require("../../../lib/toolBytes.js"); const index_js_1 = require("../../../semantics/coverage/index.js"); const toolResultCap_js_1 = require("../toolResultCap.js"); const saidByPerson_js_1 = require("../../../lib/saidByPerson.js"); const frames_js_1 = require("./frames.js"); const textForms_js_1 = require("../../../time/textForms.js"); const normalize_js_1 = require("../../../lib/normalize.js"); const numberPrecision_js_1 = require("./numberPrecision.js"); const servedJson_js_1 = require("./servedJson.js"); /** * Ceiling on indexed tokens. Generous — a 200 000-token corpus is roughly a * 5 MB tool result — because the cost of hitting it is not "slower", it is * "the gate stops accusing" (see {@link EvidenceCorpus.truncated}). The * declared-sources check reads a haystack to the same ceiling and says * `uncheckable` past it (`evidence/resultCarries.ts` · `resultReader`). */ exports.MAX_INDEX_TOKENS = 200_000; /** * How many results one indexed form remembers as its CARRIERS (9.110.0) * before the list is cut and marked. Eight, because the question the list * answers — "did EVERY result that carried this value get judged non-fact?" * (`findings/contingent.ts`) — needs the whole list or none of it: a value * nine results carried is a common value, not a tower, and the honest answer * for it is "not judged" rather than a verdict read off a prefix. */ exports.MAX_CARRIERS = 8; /** * File the result being walked as a carrier of `form` — once per result, * in wire order, cut and marked at `MAX_CARRIERS`. Costs no index entry: a * carrier is an attribute of an entry the walk was going to write anyway. */ function carry(sink, form) { const id = sink.toolCallId; if (id === undefined) return; const entry = sink.carriers.get(form); if (entry === undefined) { sink.carriers.set(form, { toolCallIds: [id] }); return; } if (entry.toolCallIds.includes(id)) return; if (entry.toolCallIds.length >= exports.MAX_CARRIERS) { entry.truncated = true; return; } entry.toolCallIds.push(id); } function add(sink, raw) { const norm = (0, normalize_js_1.normalizeToken)(raw); if (norm === '') return; for (const form of (0, normalize_js_1.lookupForms)(norm)) { // Re-stamping a form the index already holds costs no entry, so it is // done before the budget is consulted: a truncated index should still // hold the freshest turn for everything it did manage to index. if (sink.values.has(form)) { sink.values.set(form, sink.turn); carry(sink, form); continue; } if (sink.budget <= 0) return; sink.values.set(form, sink.turn); carry(sink, form); sink.budget -= 1; } } function addText(sink, text) { for (const token of (0, normalize_js_1.tokenize)(text)) { if (sink.budget <= 0) return; add(sink, token); if (sink.decimalShapes !== undefined) addDecimalOf(sink, token, sink.decimalShapes); } } /** * Under `decimalRounding`: a standalone scientific token (`1.12e-02`) also * files the exact decimal it states (`0.0112`, `numberPrecision.ts` · * `scientificDecimalOf`) — beside its spelling, never instead of it, and * from the same result, so it carries the same turn and the same carrier. * A token that matches a declared ID shape is an identifier, not a * measurement, and files nothing more. The decimal takes one entry of the * same budget as any form. */ function addDecimalOf(sink, token, shapes) { const decimal = (0, numberPrecision_js_1.scientificDecimalOf)(token); if (decimal === undefined || shapes.some((shape) => shape.match.test(token))) return; add(sink, decimal.exact); } /** Walk a parsed JSON value, indexing keys and leaves. */ function walk(node, sink) { if (sink.budget <= 0 || node === null || node === undefined) return; if (typeof node === 'string') { // Whole value first (a leaf may contain spaces and still be one value), // then its tokens (`"fc1/3 is down"` carries `fc1/3`). add(sink, node); addText(sink, node); return; } if (typeof node === 'number' || typeof node === 'boolean' || typeof node === 'bigint') { add(sink, String(node)); return; } if (Array.isArray(node)) { for (const el of node) walk(el, sink); return; } if (typeof node === 'object') { for (const [key, value] of Object.entries(node)) { add(sink, key); walk(value, sink); } } } /** * One parsed value as evidence. An `absent(…)` frame grounds everything the * TOOL authored and drops the one field that quotes the REQUEST * (`looked_for`) — indexing that would ground every identifier a model * invented as long as it handed the invention to one tool that found nothing. * See `../../../semantics/coverage/evidence.ts` — it is the frames.ts argument on the tool * side of the conversation. A capped result (`../toolResultCap.ts`) is walked * as served AND its `head` — the tool's first characters, verbatim, usually * JSON cut mid-value — is read as a result of its own, so a number or a * boolean in it is found. Every other value keeps the walk it always had. */ function asEvidence(value) { const projection = (0, index_js_1.absenceEvidenceProjection)(value); if (projection !== undefined) return projection; if (!(0, toolResultCap_js_1.isTruncatedToolResult)(value) || typeof value.head !== 'string') return value; const head = readResult(value.head); if (!('parsed' in head)) return value; return head.tail === undefined ? [value, head.parsed] : [value, head.parsed, head.tail]; } /** * How ONE tool result is read — the one reading both the index * (`indexResult`) and the per-result question (`resultCarries.ts` · * `resultReader`) take: the parsed JSON when it is JSON (`asEvidence`: an * absence's `looked_for` projected away, a capped result's head read too), its * text otherwise. * * A result that is NOT one JSON value but OPENS with one — the tool's JSON * with a framework note joined after it (a step banner, an effect note, the * repeated-call note), or several JSON blocks (an MCP text result's content * blocks) — reads as those leading values, walked, and the text after them as * `tail`; one cut short before it closes (a tool that truncated its own * output) reads as the leaves the grammar can read, and — cut inside a * string — that string's whole words as `tail` (`servedJson.ts` · * `jsonPrefixOf`: never the word the cut may have split). Read whole as text, * `{"id":4417}` tokenises to `:4417`, and every number and boolean the tool * returned would read as absent. Text that opens with a brace and is not JSON * (a log line) stays text. */ function readResult(content) { const trimmed = content.trim(); if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return { text: content }; try { return { parsed: asEvidence(JSON.parse(trimmed)) }; } catch { // Not ONE JSON value: read what the grammar can, below. } const { values, rest } = (0, servedJson_js_1.leadingJsonValues)(trimmed); if (values.length === 0) { const cut = (0, servedJson_js_1.jsonPrefixOf)(trimmed); if (cut === undefined) return { text: content }; return { parsed: cut.leaves, ...(cut.words !== undefined && { tail: cut.words }) }; } // After the values: a further block cut short (its leaves, and the words of a cut string), or text. const cut = rest === '' ? undefined : (0, servedJson_js_1.jsonPrefixOf)(rest); const walked = values.map(asEvidence); const all = cut === undefined ? walked : [...walked, cut.leaves]; const tail = cut === undefined ? (rest === '' ? undefined : rest) : cut.words; return { parsed: all.length === 1 ? all[0] : all, ...(tail !== undefined && { tail }), }; } exports.readResult = readResult; /** Index one tool result: structurally where it is JSON, as text where it is not. */ function indexResult(content, sink) { const read = readResult(content); if ('text' in read) { addText(sink, read.text); return; } walk(read.parsed, sink); if (read.tail !== undefined) addText(sink, read.tail); } // FOLD · the one owner of the corpus of values this run can prove it read from a tool result // consumers read this and never re-derive it: gate.ts · checkAnswer, which is HANDED the corpus rather than building one, and findings/contingent.ts · contingentRowsOf, handed its `carriers`; built at two moments by one fold — stages/route.ts · `judgeEvidence` (the answer) and stages/toolCalls.ts · `towersFor` (dispatch, under `.findings()` beside the gate only) // detached: yes — never stored; rebuilt per judgement, and the verdict is committed as plain data. /** * Build the evidence corpus from the history AS IT STANDS: every `role: 'tool'` * turn still in it, each indexed form stamped with the turn that served it. * * In a single-turn run these are exactly this turn's tool results. In a * continued conversation the earlier turns' results are in here too, and that * is deliberate: the model really did read them, and calling a value from turn * one a fabrication in turn two would be false. Which earlier turns are still * here is the WINDOW's decision, not this function's — see the header. */ function evidenceFromHistory(history, /** * The gate this corpus is judged by. Under its `decimalRounding`, a * standalone scientific token in a result's text also files the exact * decimal it states, unless it matches one of its declared `shapes`. * Absent, or the dial off → the corpus it always was. */ gate) { const sink = { values: new Map(), carriers: new Map(), budget: exports.MAX_INDEX_TOKENS, turn: 0, toolCallId: undefined, ...(gate?.decimalRounding === true && { decimalShapes: gate.shapes }), }; let toolResultsThisTurn = 0; for (const msg of history) { // A turn boundary — the person asked something new. The library's own // corrections are `role: 'user'` too and are NOT boundaries; see the // header for the bug that distinction prevents. if (msg.role === 'user' && !(0, frames_js_1.isLibraryAuthoredTurn)(msg.content)) { sink.turn += 1; toolResultsThisTurn = 0; // The carriers are THIS turn's: an earlier turn's results keep their // turn stamp in `values` and lose their place here. sink.carriers.clear(); continue; } if (msg.role !== 'tool') continue; toolResultsThisTurn += 1; sink.toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined; // The tool's OWN bytes (`lib/toolBytes.ts` · `toolBytesOf`, the one owner of the // boundary): the inputs layer's note carries a value the call ran with ("2h", the // value the tool's rule assumes), and indexing it would let an assumption ground an // answer as if the tool had said it. indexResult((0, toolBytes_js_1.toolBytesOf)(msg), sink); sink.toolCallId = undefined; } return { values: sink.values, carriers: sink.carriers, truncated: sink.budget <= 0, currentTurn: sink.turn, toolResultsThisTurn, }; } exports.evidenceFromHistory = evidenceFromHistory; /** * Index one piece of EXEMPT text — something the person or the app said — * with the other spellings of every date and clock time in it — the SAID * list of `time/textForms.ts` · `timeFormsOfText` (the text rule of * `time/forms.ts` · `timeFormsOf`), the one owner of which * spellings of a time are the person's (time design § 9.5). The exempt * corpus's one text door: the tool-evidence index reads `addText` and never * gains these forms. */ function addExempt(sink, text) { addText(sink, text); for (const form of (0, textForms_js_1.timeFormsOfText)(text).said) add(sink, form); } /** * The values the PERSON typed into a typed ask (`requestInput`), read off the * `input_received` result their answer landed as — `undefined` for every * other message. Only a field whose origin is `'response'` is theirs: a * `'declaration'` value is one the asking tool already knew, and stays the * tool's words (it is in the evidence corpus, where it always was). Read * from the message AS IT STANDS — the tool-result rules (redaction first) * ran on the answer before it landed, so a hidden field reads as its * placeholder and exempts nothing, exactly as a redacted person turn does. * A result with no `origins` exempts nothing: no field can be proven theirs. */ function typedAskAnswerOf(msg) { if (msg.role !== 'tool' || typeof msg.content !== 'string') return undefined; // Every other tool result is read by the evidence index already; this // corpus parses only the ones that can be an answer. if (!msg.content.includes('"input_received"')) return undefined; const read = readResult((0, toolBytes_js_1.toolBytesOf)(msg)); if (!('parsed' in read)) return undefined; const landed = read.parsed; if (!isRecord(landed) || landed.status !== 'input_received') return undefined; const { values, origins } = landed; if (!isRecord(values) || !isRecord(origins)) return undefined; const given = []; for (const [field, value] of Object.entries(values)) { if (origins[field] !== 'response') continue; if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') given.push(String(value)); } return given; } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** Insert an already-normalized lookup form (a fold's carried lineage). */ function addForm(sink, form) { if (form === '' || sink.values.has(form)) return; if (sink.budget <= 0) return; sink.values.set(form, sink.turn); sink.budget -= 1; } /** * The history half of the exempt corpus — ONE rule, asked by the corpus * ({@link exemptFromRun}) and by the fold that records its lineage * ({@link exemptLineageOf}), so what a message exempted before a fold and * what its summary carries after one cannot disagree. */ function addHistoryExempt(sink, history) { for (const msg of history) { // The person's answer to a typed ask (`requestInput`) lands as a // `role: 'tool'` message, but it is their words, not an observation: // exempt like a value in their message — and, being read here, carried // by a fold's lineage exactly as their message is. const answered = typedAskAnswerOf(msg); if (answered !== undefined) { for (const value of answered) { add(sink, value); addExempt(sink, value); } continue; } if (msg.role !== 'user' && msg.role !== 'system') continue; // A compaction summary's TEXT is a model's claim about the conversation, // not something the person or the app supplied — indexing it exempted // every value the summarizer wrote, invented ones included, from the // gate and the contingent check. It contributes the LINEAGE it was folded // with instead: the forms the folded person/app turns exempted, taken // from those original messages at fold time. A frame without one exempts // nothing. See the "Compaction" section of this folder's README. if ((0, saidByPerson_js_1.isCompactedSummary)(msg)) { for (const form of msg.foldedExempt ?? []) addForm(sink, form); continue; } // …except the corrections this library wrote. They are `role: 'user'` // turns that QUOTE the flagged values back to the model, so indexing one // would exempt exactly what it challenged — the gate laundering its own // accusation. See frames.ts. if ((0, frames_js_1.isLibraryAuthoredTurn)(msg.content)) continue; addExempt(sink, msg.content); } } // FOLD · the one owner of what a compaction summary carries forward for the exempt corpus // consumers read this and never re-derive it: window/strategies/summarizeOldest.ts (stamps it on the frame via buildSummaryMessage), read back by exemptFromRun // detached: yes — a fresh array of strings, computed from the folded ORIGINALS, never from the summary text. /** * The exemption lineage of a span about to be folded: every lookup form the * exempt corpus holds for these messages, in first-seen order — the person's * and the app's turns by the corpus's own rule, a nested summary by the * lineage IT carried, and a typed-ask answer by the values the person typed * into it (its `input_received` result is their words). Every other tool * result contributes nothing here: a summarized tool value keeps its tool * source and is judged against the tool results still in the window, exactly * like a value whose result a drop removed. * * @example * ```ts * const lineage = exemptLineageOf([{ role: 'user', content: 'Check array ARR-2291.' }]); * lineage.includes(normalizeToken('ARR-2291')); // true — the person's value * exemptLineageOf([{ role: 'tool', content: 'ARR-7', toolCallId: 't1' }]); // [] — a tool's * ``` */ function exemptLineageOf(span) { const sink = { values: new Map(), carriers: new Map(), budget: exports.MAX_INDEX_TOKENS, turn: 0, toolCallId: undefined, }; addHistoryExempt(sink, span); return [...sink.values.keys()]; } exports.exemptLineageOf = exemptLineageOf; /** * Build the exempt corpus: everything the RUN put in front of the model that * the model did not invent — the user's message, the conversation's user and * system turns, and the composed system prompt (base prompt, skill bodies, * facts, retrieved passages). * * `rawContent` is optional on an {@link InjectionRecord} — a record that was * summarised or redacted contributes what it has. That direction is the safe * one: a missing exemption can only cost a false flag on a value the app * supplied, and the caller can name it in `exempt`. * * STATED REACH, because `systemPromptInjections` is broader than "the app's * own prompt": it also carries `.memory()` recall and RAG passages. A value * that reached the model through memory or retrieval is therefore EXEMPT from * grounding — never flagged, and never counted as tool evidence of any turn, * so it is invisible to the recency read too. That is the correct call for the * fabrication question (the run really did put those words in front of the * model) and a real blind spot for the recency one, and it is deliberately NOT * fixed here: recall is its own seam, with its own record * (`retrievalEvidence_<id>`, `agentfootprint.memory.attached`), and giving a * recalled passage a turn stamp means deciding whose turn it belongs to — the * one it was written in or the one it was recalled in — which is a question * this file has no standing to answer. */ function exemptFromRun(args) { // The same accumulator, walked with no turn boundaries: an exemption is a // fact about WHO supplied a value, and the turn it arrived in changes // nothing about that. The keys are lifted into a Set at the end so the // grounding decision keeps the exact membership type it always took. // No `toolCallId` is ever set, so nothing here is filed as a carrier: an // exemption names no result. const sink = { values: new Map(), carriers: new Map(), budget: exports.MAX_INDEX_TOKENS, turn: 0, toolCallId: undefined, }; if (args.userMessage) addExempt(sink, args.userMessage); addHistoryExempt(sink, args.history); for (const rec of args.systemPromptInjections ?? []) { if (rec.rawContent) addExempt(sink, rec.rawContent); // The summary is what a redacted record has instead. Indexing it cannot // create a false exemption for a value nobody supplied — the summary is // built from the content itself. else if (rec.contentSummary) addExempt(sink, rec.contentSummary); } // A tool's declared default is the app's declaration (honesty layer 2): the // value itself and its tokens, exactly as a prompt's text is indexed. for (const value of args.declaredDefaults ?? []) { add(sink, value); addExempt(sink, value); } // The person's answer to the library's ask: their own words, exempt like a // value in their message (honesty layer 2, step 4). for (const value of args.answeredValues ?? []) { add(sink, value); addExempt(sink, value); } // The person's time parts at grain (time design § 9.5): spellings, each one // value — indexed whole, never tokenized further. for (const form of args.timeSaid ?? []) add(sink, form); return new Set(sink.values.keys()); } exports.exemptFromRun = exemptFromRun; // FOLD · the one owner of the lookup set of the library's own time spellings (the `derived` lineage) // consumers read this and never re-derive it: stages/route.ts · judgeEvidence (checkAnswer's `derived`) // detached: yes — a fresh Set per call. /** * The lookup forms of the spellings the library DERIVED from this turn's * time readings (`time/forms.ts` · `timeFormsOf`'s `derived` list, the * values the library filled from a window, the served time line) — each * value indexed whole and by its tokens, exactly as the exempt corpus * indexes a text. A value the answer took only from here is filed * `derived-from-reading` (time design § 9.5): never invented, never known. * * @example * ```ts * derivedFormsOf(['2026-10-09T15:00:00Z', 'PDT']).has('pdt'); // true * ``` */ function derivedFormsOf(spellings) { const sink = { values: new Map(), carriers: new Map(), budget: exports.MAX_INDEX_TOKENS, turn: 0, toolCallId: undefined, }; for (const spelling of spellings) { add(sink, spelling); addText(sink, spelling); } return new Set(sink.values.keys()); } exports.derivedFormsOf = derivedFormsOf; //# sourceMappingURL=evidenceIndex.js.map