UNPKG

agentfootprint

Version:

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

321 lines (320 loc) • 17.6 kB
/** * 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. */ import type { LLMMessage } from '../../../provider-contract/types.js'; import type { InjectionRecord } from '../../../recorders/core/types.js'; import type { ResolvedEvidenceGate } from './types.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`). */ export declare const MAX_INDEX_TOKENS = 200000; /** * 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. */ export declare const MAX_CARRIERS = 8; /** * Which of THIS turn's tool results carried one indexed form (9.110.0) — * the `toolCallId` of each `role: 'tool'` message that served it, in wire * order, each id once. The other half of the provenance the walk already * had: `values` says WHEN a value was last read, this says FROM WHERE. */ export interface ValueCarriers { readonly toolCallIds: readonly string[]; /** * Set when more than `MAX_CARRIERS` results carried the form: the list is * the first `MAX_CARRIERS` and a reader must not judge "every carrier" * from it. */ readonly truncated?: true; } /** A finished index plus the honesty flag that says whether it is complete. */ export interface EvidenceCorpus { /** * Every indexed form, stamped with the LATEST turn whose tool result * carried it. * * A Map rather than a Set plus a second structure: the walk already visits * each leaf once, so the turn is an attribute of the entry it was going to * write anyway. `.has()` reads exactly as it did when this was a Set, which * is all the grounding decision ever needed; `.get()` is the new half. * * LATEST, not first, and the direction is the whole point: the question is * "could this turn's own results have supplied this value?", so a form * served in turn 2 and again in turn 7 is turn 7's. */ readonly values: ReadonlyMap<string, number>; /** * Every indexed form THIS turn's tool results carried, with the ids of * the results that carried it (9.110.0) — `value → carriers`, beside * `value → turn`, written by the same walk at the same leaf. Only the * current turn's results are listed: the carriers of an earlier turn are * dropped at each turn boundary, so on a continued conversation a value * read four turns ago has a turn stamp here and no carrier. Bounded per * form by `MAX_CARRIERS` (the list is cut and marked `truncated`), and by * the same token budget as `values` — a form the budget refused has no * entry here either. Read by the contingent check * (`findings/contingent.ts`), which asks whether every carrier of a value * the model USED was one the model itself declared open, noise or * ruled-out. */ readonly carriers: ReadonlyMap<string, ValueCarriers>; /** * True when the ceiling was hit and the index is INCOMPLETE. The gate * downgrades itself to record-only when this is set: a partial corpus can * call a grounded value fabricated, and an accusation from a half-read * corpus is worse than no accusation at all. */ readonly truncated: boolean; /** * The turn in progress — the ordinal every stamp above is compared against. * `0` means the history carries no user turn at all, so there is no * boundary and nothing can be attributed to an earlier one. */ readonly currentTurn: number; /** * How many `role: 'tool'` results this turn served. `0` is the sharp case: * a turn that fetched nothing sourced every value in its answer from * history, by construction and without needing the index to prove it. */ readonly toolResultsThisTurn: number; } /** * How ONE result reads: its JSON walked (`parsed`) and any text beside it read * as text (`tail`: a note after the JSON, or the whole words of a string the * JSON was cut inside), or — when it opens with no JSON the grammar can read — * its text. */ export type ResultReading = { readonly parsed: unknown; readonly tail?: string; } | { readonly text: string; }; /** * 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. */ export declare function readResult(content: string): ResultReading; /** * 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. */ export declare function evidenceFromHistory(history: readonly LLMMessage[], /** * 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?: Pick<ResolvedEvidenceGate, 'decimalRounding' | 'shapes'>): EvidenceCorpus; /** * 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 * ``` */ export declare function exemptLineageOf(span: readonly LLMMessage[]): readonly string[]; /** * 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. */ export declare function exemptFromRun(args: { readonly userMessage?: string; readonly history: readonly LLMMessage[]; readonly systemPromptInjections?: readonly InjectionRecord[]; /** * The declared `assume` values of every (tool, argument) that filed a * `default` row this turn (the inputs layer, honesty layer 2) — the APP's own * declaration, read from the tool's rule and never from the row, exempt like * a value in the system prompt. Absent → the corpus it always was. */ readonly declaredDefaults?: readonly string[]; /** * The values the PERSON gave this turn by answering the inputs layer's * batch ask (honesty layer 2, step 4) — read from this turn's `answered` * rows. The library's ask writes no message, so this is the one door the * answer reaches the gate through. Absent → the corpus it always was. */ readonly answeredValues?: readonly string[]; /** * Under `.time()` (step T7): the SAID spellings of this turn's recorded * windows (`time/forms.ts` · `timeFormsOf`) — the parts the person * wrote, confirmed, typed or set, at the grain they gave them. Absent → * the corpus it always was. */ readonly timeSaid?: readonly string[]; }): ReadonlySet<string>; /** * 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 * ``` */ export declare function derivedFormsOf(spellings: readonly string[]): ReadonlySet<string>;