agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
484 lines (483 loc) • 23.6 kB
TypeScript
/**
* coverage/types — the shared vocabulary of the two coverage primitives.
*
* Pattern: one value type, two consumers — `absent()` says "I looked HERE and
* found nothing", `coverage()` says "my answer covers THIS and not
* THAT". Both are statements about the same thing: the ground a
* result stands on. Spelling them with one vocabulary is what lets
* the answer-level block merge an absence's boundary with a
* verdict's without translating between two grammars.
* Role: core/ layer, pure data. No behavior; TYPE-only imports (they erase
* at runtime): the one period shape (`period.ts`) and the one
* provenance shape `describedResult()` already declares
* (`semantics/types.ts`), so an absence says its source and time
* in the same words a described result does.
* Emits: N/A.
*/
import type { DescribedResultDeclaration, SemanticProvenance } from '../types.js';
import type { DeclaredPeriod, PeriodOnWire } from './period.js';
import type { PagingDeclaration, ResolvedDeclaration } from './search/resultFacts.js';
/**
* One piece of ground, and (optionally) why it is where it is.
*
* `what` is prose the TOOL AUTHOR wrote — a source, a window, a filter, a
* fleet ("the fcns database on shq-fab-a", "the last 24h", "all four
* arrays"). It is never composed from the model's arguments by this library,
* because the library does not know which of them are real.
*/
export interface CoverageItem {
/** The source, window, filter or population. Non-empty. */
readonly what: string;
/**
* Why it sits where it does. REQUIRED on `cannotCover` (a permanent blind
* spot is a claim about capability, and a claim with no reason cannot be
* acted on or disproved); optional on `checked` and `notChecked`, where
* "we did" and "we did not need to" are often the whole story.
*/
readonly why?: string;
/**
* A short plain form of `what`, for a person reading a report — "every VM
* disk in the RVTools export of 2026-09-19" for a `what` that names the two
* tables it read. Optional; at most 80 characters, one line, and never
* longer than `what`.
*
* RECORD-ONLY: it rides the events (`tools.absent`,
* `tools.coverage_declared`), the tracked `coverageDeclared` rows and the
* answer account, and is REMOVED from what the model is served
* (`read.ts` · `servedToModel`). It is also withheld from the evidence
* corpus (`evidence.ts`), because it is the field an author is most tempted
* to personalise: NEVER interpolate the caller's arguments into it — a true
* short form restates `what`, so no value should live only here.
*/
readonly short?: string;
/**
* What kind of ground a `notChecked` or `cannotCover` item is — a CLOSED
* set, read by the answer account's existence check:
*
* • `'existence'` — whether the thing asked about exists at all, or is
* the kind of thing the question assumes ("whether that name is a
* storage array").
* • `'scope'` — a population, family or source outside what this tool
* reaches ("hosts that are not VMware").
*
* Refused on `checked`. RECORD-ONLY, like `short`. Absent = not declared:
* a reader never infers a kind from `what`. Library code never switches
* EXHAUSTIVELY over this union (every switch has a `default` arm), so a
* later member is an additive change for producers.
*/
readonly kind?: 'existence' | 'scope';
}
/** What an author may write in a coverage list: bare prose, or prose + why. */
export type CoverageInput = string | CoverageItem;
/**
* One thing the tool READ and found still RUNNING — a backup in progress, a
* replication session still synchronizing — whose outcome is therefore not
* known yet: neither a success nor a failure (`coverage()`'s `inProgress`).
*
* The TOOL decides which of its vendor's states are in flight — this library
* never reads a state name, and the vendor's own verdict field, when there is
* one, is the tool's to weigh. `what` is the author's prose, as for every
* coverage item; `short` is the same RECORD-ONLY short form. `count` says how
* many things this one entry stands for ("96 sessions still synchronizing" is
* one entry with `count: 96`); omitted, the entry is one thing. It is served —
* the model reads the number as the tool's own.
*
* No `kind`: that names ground a call did NOT reach, and an item in progress
* is ground it reached.
*/
export interface InProgressItem {
/** What is still running. Non-empty. */
readonly what: string;
/** Since when, or what the vendor reported — optional. */
readonly why?: string;
/** RECORD-ONLY short form, the {@link CoverageItem.short} rules. */
readonly short?: string;
/** How many things this entry stands for — a positive whole number; omitted = one. */
readonly count?: number;
}
/** What an author may write in `inProgress`: bare prose, or an {@link InProgressItem}. */
export type InProgressInput = string | InProgressItem;
/**
* The three lists, normalized. This is the shape everything downstream reads —
* the renderer, the event payload, the answer-level block.
*
* The three are NOT interchangeable, and the difference is the whole point:
*
* • `checked` — ground this result actually stands on. A clean verdict here
* means VERIFIED.
* • `notChecked` — ground this run could have covered and did not (budget,
* a timeout, a scope the caller chose). A clean verdict says nothing
* about it. Re-asking with a wider scope can move it to `checked`.
* • `cannotCover` — ground no call to this tool will ever reach (no
* collector, no permission, no such telemetry). A clean verdict says
* nothing about it, and no retry ever will. This is the list that turns
* "everything looks fine" into "everything I can see looks fine".
*/
export interface Coverage {
readonly checked: readonly CoverageItem[];
readonly notChecked: readonly CoverageItem[];
readonly cannotCover: readonly CoverageItem[];
}
/** What a tool author passes to {@link import('./ledger.js').coverage}. */
export interface CoverageDeclaration {
readonly checked?: readonly CoverageInput[];
readonly notChecked?: readonly CoverageInput[];
readonly cannotCover?: readonly CoverageInput[];
}
/**
* The other TOOL a suggestion points at, typed (9.113.0) — `tryInsteadTool`
* on the declaration, `try_instead_tool` on the envelope.
*
* The `tryInstead` sentence puts a tool's name inside prose ("…or check
* cluster_inventory for the collected names."), and a reader that wants to
* know WHICH tool was suggested could only find out by parsing that sentence.
* This library never parses a tool name out of prose, so the name rides here
* as data — BESIDE the sentence, never in its place: `try_instead` stays a
* string, so every reader of it reads what it always read.
*
* `tool` is NOT looked up. The tool may be served by a provider that resolves
* per iteration, or be offered on a later turn; `absent()` requires a
* non-empty name and records it as declared. Which names a provider accepts is
* `lib/toolName.ts` · `assertValidToolName`'s question, and the library only
* warns about it (dev mode), here as at `defineTool`.
*/
export interface TryInsteadTool {
/** The tool's registered name, as a call would name it. */
readonly tool: string;
/** Why that tool, in the author's words ("it lists the collected names"). */
readonly why?: string;
}
/** What a tool author passes to {@link import('./absent.js').absent}. */
export interface AbsenceDeclaration {
/**
* What was looked for, in the author's own words ("FLOGI entries on
* fc1/3"). Required: an absence that cannot say what it did not find is
* indistinguishable from a tool that returned nothing by accident.
*/
readonly what: string;
/**
* The coverage of the search — REQUIRED and non-empty. An absence that
* names no coverage is a `null` with extra steps: the reader still cannot
* tell "I looked and there is nothing" from "I could not look", which is
* the entire failure this primitive exists to prevent.
*/
readonly checked: readonly CoverageInput[];
/** Ground the search did not reach this time — an absence here proves
* nothing about it. */
readonly notChecked?: readonly CoverageInput[];
/** Ground no search by this tool can reach. Each needs a `why`. */
readonly cannotCover?: readonly CoverageInput[];
/**
* Where to go INSTEAD, in one sentence ("widen the window with
* `window: '7d'`, or ask for a different interface"). Optional, and the
* highest-value optional field in the shape: the loop this primitive stops
* is a model with nowhere else to go.
*
* Prose for the model. Nothing in this library reads a tool name out of it:
* when the sentence points at another tool, name that tool in
* {@link AbsenceDeclaration.tryInsteadTool} as well.
*/
readonly tryInstead?: string;
/**
* The other TOOL the suggestion points at, as data (9.113.0) —
* `{ tool, why? }`. Beside the sentence, not instead of it: the sentence is
* what the model is told, this is what a reader reads without parsing
* prose. Either may be given without the other; when both are given they
* must name the same tool, and the library cannot check that they do — it
* would have to read a tool name out of the sentence. ONE tool; a list is
* refused (see `absent.ts` · `readToolSuggestion` for why).
*/
readonly tryInsteadTool?: TryInsteadTool;
/**
* Where the search looked and how old that source is — the SAME shape
* `describedResult()` declares (`measuredAt`, `source`, and optionally
* `ageSeconds`, `sourceExportDate`), respelled to the same snake_case wire
* (honesty step 7b). `measuredAt` and `source` are both required once it is
* present. So the found branch and the not-found branch of one `execute`
* carry their source and time in one shape: "searched the 02:00 export —
* nothing" as data, not prose in `checked`. `measuredAt` is the tool's own
* words, never parsed.
*/
readonly provenance?: NonNullable<DescribedResultDeclaration['provenance']>;
/**
* What the search's READ covered in time — the instants it asked for, and
* what the store holds (or `'unknown'`, said out loud) — honesty step 7b.
* Every value is an ISO 8601 instant with a zone; a malformed period is
* refused here. The results layer files its verdict (covered · partly held ·
* not held · unknown), and `.limitsTravelWithTheAnswer()` prints it.
*/
readonly period?: DeclaredPeriod;
/**
* The entity each population value RESOLVED to — `[{ argument, id, name?,
* aliases? }]` — the tool's own claim, said out loud. Read by a tool that
* declares its `search`: it binds the value the call was SENT for and no
* other entity. Refused here when malformed.
*/
readonly resolved?: readonly ResolvedDeclaration[];
/**
* Whether there is a next page — `{ next: null }` says there is none. A tool
* with a `page` argument whose result declares no `paging` is never complete.
*/
readonly paging?: PagingDeclaration;
}
/**
* An absence whose coverage the tool's `search` DECLARES —
* `absent({ what, checked: 'declared' })`. The author writes the search once;
* the dispatch funnel composes `checked` (and the gaps) from the declaration
* and the arguments the call ran with. A tool with no `search` that returns
* one has its call refused, naming the field to add.
*/
export type DeclaredAbsenceDeclaration = Omit<AbsenceDeclaration, 'checked'> & {
readonly checked: 'declared';
};
/**
* The list a missed name was looked up in — `absent({ what, among })`.
*
* A lookup that checks a name against a known list IS a search, and its miss
* says only that the name is not on THAT list. `among` declares the list, and
* the mint composes the rest: the names facet the tool searched, the gap the
* author cannot remove ("names outside <source>" — and, for a `partial` read,
* "not every name in <source> was read"), and the absence's `provenance` from
* `source` and `measuredAt`. The gap holds where `among` is declared; a plain
* `absent({ what })` or a thrown refusal never passes through it.
*
* @example a name lookup that found nothing in the inventory export
* if (!array) return absent({
* what: array_id,
* among: { argument: 'array_id', source: inventory.source, measuredAt: inventory.readAt,
* count: inventory.names.length, names: inventory.names },
* });
*/
export interface AmongDeclaration {
/** The input argument whose value was looked up ('array_id'). */
readonly argument: string;
/** The list the names were read from, in the author's words ('the PowerStore inventory export'). One line. */
readonly source: string;
/**
* When that list was read — the tool's own words (the `provenance` rule).
* When it is an ISO 8601 instant this library can read, the names facet
* also carries a dated note, "names as of <measuredAt>" — no judgement of
* whether that is old.
*/
readonly measuredAt: string;
/** How many names the list holds — a whole number. The tool's claim; served on the facet. */
readonly count: number;
/**
* The names the list holds, read ONLY to check `count`: when given,
* `names.length` must equal it (`names-count-mismatch`). No name is served
* to the model or written to an event or a record.
*/
readonly names?: readonly string[];
/**
* `true` when the read did not see every name in the source — a truncated
* or paged inventory. The mint then composes a completeness gap, "not every
* name in <source> was read" (`bears: 'completeness'`), beside "names
* outside <source>", and `authoritative` never removes it.
*/
readonly partial?: boolean;
/**
* The ONE opt-out of the "names outside <source>" gap: the author says the
* source is every name there is, and why — the tool's claim, not verified.
* Every use is listed for review by `check:semantics` (`authoritative-names`).
*/
readonly authoritative?: {
readonly why: string;
};
}
/**
* `absent()` given an {@link AmongDeclaration}: `checked` becomes optional (the
* mint composes the names facet first) and `provenance` is refused — `among`
* composes it from its own `source` and `measuredAt`.
*/
export interface AbsenceAmongDeclaration extends Omit<AbsenceDeclaration, 'checked' | 'provenance'> {
/** The list the missed name was looked up in. */
readonly among: AmongDeclaration;
/** Further ground the search covered, after the composed names facet. */
readonly checked?: readonly CoverageInput[];
}
/**
* The names facet an `among` miss composes — the first `checked` item. A
* {@link CoverageItem} whose `what` the library writes from the declaration
* (never from the looked-for value).
*/
export interface NamesFacetItem extends CoverageItem {
readonly facet: 'names';
/** The input argument whose value was looked up. */
readonly argument: string;
/** How many names the list holds. */
readonly count: number;
/** The list, as declared. */
readonly source: string;
/** The names came from the tool's own read. */
readonly from: 'result';
/** Present only when the author declared the list authoritative. */
readonly authoritative?: {
readonly why: string;
};
}
/**
* The gap an `among` miss adds and its author cannot remove except by
* `authoritative` — the first `not_checked` item: names outside the source
* the tool read. It bears on the source facet: a claim that leaves the source
* open (a flat "X does not exist") rests on ground this search never covered.
* `kind` is record-only, like every item's.
*/
export interface SourceGapItem extends CoverageItem {
readonly kind: 'scope';
readonly relevance: 'bears';
readonly bears: 'source';
}
/**
* The gap a `partial` among read adds, which nothing removes: the names in
* the source the read did not see. It bears on completeness — a claim that
* the name is not in the source rests on a read that did not see all of it.
*/
export interface CompletenessGapItem extends CoverageItem {
readonly kind: 'scope';
readonly relevance: 'bears';
readonly bears: 'completeness';
}
/**
* What a tool author passes to {@link import('./ledger.js').coverage}: the
* three lists, and the period the verdict's read covered (honesty step 7b).
* `coverage()` takes no `provenance` — a value that comes as rows from a
* system of record has `describedResult()`.
*
* @inline
*/
export type CoverageLedgerDeclaration = CoverageDeclaration & {
/**
* What the read behind this value covered in time — the instants it asked
* for, and what the store holds (or `'unknown'`). Refused when malformed.
*/
readonly period?: DeclaredPeriod;
/**
* What the read found still RUNNING — its outcome not known yet (a backup in
* progress, a session still synchronizing). The tool decides what is in
* flight; the library never reads a vendor's state name. Needs `checked`
* beside it (an item in progress is ground the call read). An empty list
* says nothing and is omitted, like every coverage list. Served as
* `in_progress`, with one static clause after the note; recorded on the
* `coverageDeclared` row and `tools.coverage_declared`; printed under
* `.limitsTravelWithTheAnswer()`. It never changes the answer's standing.
*/
readonly inProgress?: readonly InProgressInput[];
};
/**
* The rendered absence — the exact object a tool hands back and the model
* reads. Field names are snake_case and English on purpose: this value is
* read by a language model far more often than by code, and `af_absent` is
* the only field here that exists for the machine.
*
* The `af_absent` key is RESERVED vocabulary on the tool-result wire (the
* `propose-transition` / `require-instruction` precedent): a plain object
* carrying it is an absence, and nothing else in this library will treat any
* other shape as one.
*/
export interface ToolAbsence {
readonly af_absent: true;
/** The plain-English handle. Present so a model that skims one key still
* reads the outcome rather than inferring it from a missing field. */
readonly outcome: 'nothing_found';
readonly looked_for: string;
readonly checked: readonly CoverageItem[];
readonly not_checked?: readonly CoverageItem[];
readonly cannot_cover?: readonly CoverageItem[];
/** Stated as data as well as prose — the note can be skimmed past, a
* `true` in a field named for the question cannot. */
readonly retry_returns_the_same: true;
/** The author's sentence. A string, always — the typed tool rides its own
* key, so a reader of this one never has to narrow it. */
readonly try_instead?: string;
/** The typed tool the suggestion points at (9.113.0), as declared — the
* author's words, `{ tool, why? }`. The model reads it as written. */
readonly try_instead_tool?: TryInsteadTool;
/** Where the search looked and when that source was measured — the wire
* `describedResult()` mints (`measured_at`, `source`, …), honesty step 7b. */
readonly provenance?: SemanticProvenance;
/** What the search's read covered in time — `queried`, `held` (or
* `'unknown'`), `read_at` — honesty step 7b. */
readonly period?: PeriodOnWire;
/** The entity each population value resolved to, as declared. */
readonly resolved?: readonly ResolvedDeclaration[];
/** Whether there is a next page, as declared. */
readonly paging?: PagingDeclaration;
/** The static sentence. Never interpolated — see `absent.ts`. */
readonly note: string;
}
/**
* The envelope `absent({ checked: 'declared' })` mints, before the dispatch
* funnel composes its coverage. `readAbsence` does not read it (its `checked`
* is not a list), so every reader that predates it sees data;
* `recognize.ts` · `readDeclaredAbsence` is its one recognizer.
*/
export type DeclaredToolAbsence = Omit<ToolAbsence, 'checked'> & {
readonly checked: 'declared';
};
/**
* A ledger whose `checked` the tool's `search` DECLARES —
* `coverage(rows, { checked: 'declared', … })`, the twin of
* {@link DeclaredAbsenceDeclaration} for a value that came back with rows.
*/
export type DeclaredLedgerDeclaration = Omit<CoverageLedgerDeclaration, 'checked'> & {
readonly checked: 'declared';
};
/**
* The envelope `coverage(rows, { checked: 'declared' })` mints, before the
* dispatch funnel composes it. `readCoverageLedger` does not read it, so a
* reader that predates it sees data; `recognize.ts` · `readDeclaredLedger` is
* its one recognizer.
*/
export interface DeclaredCoveredResult<T = unknown> {
readonly af_coverage: Omit<CoveredResult['af_coverage'], 'checked'> & {
readonly checked: 'declared';
};
readonly result: T;
}
/** The rendered coverage ledger, wrapped around the result it bounds. */
export interface CoveredResult<T = unknown> {
readonly af_coverage: {
readonly checked?: readonly CoverageItem[];
readonly not_checked?: readonly CoverageItem[];
readonly cannot_cover?: readonly CoverageItem[];
/** What the read found still running — its outcome not known yet.
* Serialized before `result`, like the lists. */
readonly in_progress?: readonly InProgressItem[];
/** What the read behind the value covered in time — honesty step 7b.
* Serialized before `result`, like the lists. */
readonly period?: PeriodOnWire;
readonly note: string;
};
/** The tool's own answer, untouched. */
readonly result: T;
}
/**
* One coverage statement as the RUN recorded it — what the event carries and
* what accumulates in `AgentState.coverageDeclared`.
*
* `kind` is kept because the two read differently at the answer boundary: a
* `'ledger'` bounds a verdict the answer is probably built on, an
* `'absence'` bounds a search that found nothing.
*/
export interface DeclaredCoverage extends Coverage {
readonly kind: 'absence' | 'ledger';
readonly toolName: string;
readonly toolCallId?: string;
readonly iteration: number;
/** Present for `'absence'` only — what the search was for. */
readonly lookedFor?: string;
/**
* The period the declaration said its read covered, in the record's
* camelCase form (honesty step 7b) — present only when the envelope declared
* a well-formed one. A described result with a period and no coverage lists
* files a `'ledger'` row whose three lists are empty.
*/
readonly period?: DeclaredPeriod;
/**
* What the call found still running — its outcome not known yet — as the
* `coverage()` declared it (copied item by item; `short` and `count` when
* valid). Present only on a `'ledger'` row whose envelope declared a
* well-formed, non-empty `in_progress`.
*/
readonly inProgress?: readonly InProgressItem[];
}