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