UNPKG

agentfootprint

Version:

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

472 lines (471 loc) • 19.7 kB
/** * The answer account — the typed shape `accountForAnswer` returns. * * ONE public name, `AnswerAccount`. Its family (a sentence, a pointer into the * record, a chip, a row …) is reached by indexed access — * `AnswerAccount['rows'][number]['lines'][number]` — rather than as a dozen more * exported names: every export is a page on the API site, and the site's export * file-count budget has cost a release before. The names below are exported from * this MODULE for the library's own files and tests; the barrel exports only * `AnswerAccount`, `AnswerAccountDeclarations` and `AnswerAccountShownLeaf`. * * Read the folder README for the laws (weakest voucher, "not recorded", leaves * only, own run only). */ /** * Who vouches for a fact or a sentence. Ranked for the weakest-input rule, * strongest first: `person` (the question only) > `library` (recorded by the * library itself) > `tool:<name>` (a tool's recorded declaration) > `model` (the * model's recorded declaration) > `app` (the app's declaration handed in when the * report is made — NOT on the record). */ export type AccountSource = 'person' | 'library' | `tool:${string}` | 'model' | 'app'; /** `recorded` ⇒ at least one pointer resolves into the record. */ export type FactStatus = 'recorded' | 'not-recorded' | 'not-applicable'; /** Why a fact the account looks for is not on the record. */ export type MissingReason = /** The event that would carry it is not in this recording. */ 'no-event' /** The library does not record this yet (standing, routing confidence). */ | 'not-built' /** The tool or app did not declare it (no short, no kind, no coverage, no shape). */ | 'not-declared' /** It happened in an earlier leg of a resumed answer. */ | 'before-pause' /** Present, but not in a shape the account reads (counted in `unread`). */ | 'unreadable' /** * It travelled as DATA beside the answer, not as text in it — a typed * answer's limits (`turn_end.answerCoverage`, read as `limitsData`). */ | 'as-data' /** * The record keeps it out: a redaction policy (an agent's `redact`) left its * placeholder where the value was. */ | 'redacted'; /** * Where "show me" lands. Always a LEAF: `path` names one value, never a whole * payload. `path` is an RFC 6901 JSON pointer into the event's PAYLOAD, into the * state key's value, or into the history message — except the two derived * forms: `#emptiness` (a `{ rows, at }` count the account computed, never the * rows themselves) and `#meta/runId` (the event's own * run identity). */ export type RecordPointer = { readonly kind: 'event'; readonly index: number; readonly type: string; readonly path: string; readonly runtimeStageId?: string; } | { readonly kind: 'state'; readonly key: string; readonly path: string; } | { readonly kind: 'history'; readonly index: number; readonly path: string; readonly toolCallId?: string; } | { readonly kind: 'declaration'; readonly owner: 'app'; readonly field: string; readonly id?: string; readonly version?: string; }; /** One fact the account read (or looked for and did not find). */ export interface AccountFact<T> { readonly value: T | null; readonly source: AccountSource; readonly status: FactStatus; readonly pointers: readonly RecordPointer[]; readonly missing?: MissingReason; /** The value was cut to bound the account's size; the rest is in the record. */ readonly clipped?: true; } /** A value a sentence was filled with. `from` resolves to `value` (clipped values: to its start). */ export interface SentenceVar { readonly value: string | number; readonly from?: RecordPointer; readonly source: AccountSource; /** The value was longer than 2,000 characters and was cut; the rest is in the record. */ readonly clipped?: true; } /** The sentence's text split for styling — typed text, never HTML. */ export type SentencePart = { readonly text: string; } /** A tool or skill id, verbatim. */ | { readonly code: string; } /** Words quoted from the record (the question as recorded). */ | { readonly quote: string; readonly source: AccountSource; } /** A declared label — drawn strong, with its own voucher. */ | { readonly label: string; readonly source: AccountSource; }; /** Which template filled a sentence, and which version of its words. */ export interface TemplateRef { readonly id: string; readonly version: number; } /** One line of the account. */ export interface Sentence { /** The whole sentence, plain. */ readonly text: string; readonly parts: readonly SentencePart[]; readonly template: TemplateRef; readonly vars: Readonly<Record<string, SentenceVar>>; /** The weakest of the truth-deciding inputs' sources (a label part is presentation, not truth). */ readonly source: AccountSource; readonly status: FactStatus; readonly pointers: readonly RecordPointer[]; readonly missing?: MissingReason; /** Chips that belong to THIS line (a kind, "not recorded", "decided = delivered"). */ readonly chips?: readonly Chip[]; /** A bulleted item under the nearest line above it that is not an item (declared coverage, flagged values). */ readonly item?: true; } export type ChipMark = 'said-by' | 'declared' | 'not-declared' | 'undeclared-empty' | 'decided-delivered' | 'decided-not-delivered' | 'not-recorded' | 'signals' | 'none-found' | 'before-pause' | 'kind'; export interface Chip { readonly mark: ChipMark; readonly text: string; readonly tone: 'plain' | 'ok' | 'warn' | 'bad'; readonly template: TemplateRef; } export type RowId = 'asked' | 'understood' | 'checked' | 'not-checked' | 'found' | 'how-sure' | 'anything-wrong'; /** One of the seven rows, always present, in `RowId` order. */ export interface Row { readonly id: RowId; readonly heading: Sentence; /** The row's lines, in order. A line with `item: true` is a bullet under the line before it. */ readonly lines: readonly Sentence[]; /** "…and n more tool calls" when the calls were folded (the omitted ones are in `show me`). */ readonly more?: Sentence; readonly chips: readonly Chip[]; readonly status: FactStatus; } export type SignalId = 'decided-not-delivered' | 'existence-not-checked' | 'undeclared-empty-used' | 'undeclared-empty-in-view'; /** The three checks the account runs. */ export type CheckId = 'decided-delivered' | 'existence' | 'empty-results'; export interface Signal { readonly id: SignalId; readonly check: CheckId; readonly sentence: Sentence; readonly tone: 'warn' | 'bad'; } /** A check that applies to this answer but could not be run on this record. */ export interface Unreachable { readonly check: CheckId; readonly missing: MissingReason; readonly sentence: Sentence; } /** How one tool call of THIS run went. */ export type CallOutcome = 'ran' | 'failed' /** A rule refused it BEFORE the tool ran. */ | 'refused' /** A person was asked and declined. */ | 'declined' | 'not-dispatched' /** A start with no recorded end — the record does not say how it ended. */ | 'unknown'; /** * Whether the result the MODEL read was empty, and who says so — the one * emptiness reader's vocabulary (`core/agent/emptiness.ts` · * `readEmptiness`), shared with the standing fold. `clarify` (a described * result that handed back a question and no data) joined it with that reader; * readers of this union skip a value they do not know. */ import type { Emptiness } from '../core/agent/emptiness.js'; export type { Emptiness }; export interface ToolCallFact { /** Cut at 200 characters (a model-chosen id is data, and the account is bounded). */ readonly toolCallId: string; /** Cut at 200 characters; `''` when no event names the tool (then `unnamed`). */ readonly toolName: string; /** No event of the call names its tool: the call is counted, judged and said to be unnamed. */ readonly unnamed?: true; readonly outcome: CallOutcome; /** The rule that refused a `refused` call (a middleware, a permission rule id). */ readonly refusedBy?: string; /** An AFTER-tool deny: the tool ran, the model read the rule's refusal. */ readonly withheldBy?: string; readonly emptiness: Emptiness; /** Rows counted in the model's view (a counted rowset only). */ readonly rows?: number; /** `app` when the emptiness rests on the app's declared `rowsAt`. */ readonly emptinessSource?: 'library' | 'app'; /** * Where in the value the model read the count was taken — a JSON pointer * (`/rows`; `/result/rows` through a declared boundary; `/result/datasets/rows/rows` * when the rows went to the artifact store and the dataset ticket left in their * place counts them). "Show me" points there. */ readonly countedAt?: string; /** * A described result's data, counted per kind from the envelope the record * keeps (the `tools.semantics_declared` event) — the model read its * projection, which carries no marker. */ readonly described?: { readonly facts?: number; readonly series?: number; readonly edges?: number; }; /** The result was read through a declared `coverage()` boundary. */ readonly bounded?: true; /** Whose view the emptiness was read from. */ readonly view?: 'result' | 'model-result' | 'model-result-record-only'; readonly coverage?: { readonly kind: 'absent' | 'coverage'; /** The tool's own words, cut at 200 characters (the "It found" line prints up to 2,000). */ readonly lookedFor?: string; readonly checked: number; readonly notChecked: number; readonly cannotCover: number; readonly kinds: number; readonly tryInsteadTool?: string; }; readonly expectation?: { readonly basis: string; readonly expect?: string; }; readonly pointers: readonly RecordPointer[]; } /** * A call answered BEFORE a pause, on a resumed leg — read from the committed * state this record holds, never from that part's events (which the record * does not hold): what came back, from its message in `history`, and what its * tool declared, from its `coverageDeclared` rows. Whether it failed or was * refused by a rule is in those events, so it carries no outcome. */ export interface BeforePauseFact { readonly toolName: string; readonly toolCallId: string; /** What came back, as the model read it — the tool's own bytes, by the one emptiness reader. */ readonly emptiness?: Emptiness; readonly rows?: number; readonly emptinessSource?: 'library' | 'app'; /** * Where in the value the model read before the pause the count was taken — a JSON pointer * (`/rows`; `/result/rows` through a declared boundary; `/result/datasets/rows/rows` * when the rows went to the artifact store and the dataset ticket left in their * place counts them). "Show me" points there. */ readonly countedAt?: string; /** What its tool declared, from the committed `coverageDeclared` rows; absent when it declared nothing. */ readonly coverage?: { readonly kind: 'absent' | 'coverage'; readonly lookedFor?: string; readonly checked: number; readonly notChecked: number; readonly cannotCover: number; readonly kinds: number; }; readonly pointers?: readonly RecordPointer[]; } /** An earlier answer's tool result that was in front of the model when it answered. */ export interface InViewFact { readonly toolName: string; readonly toolCallId: string; /** Messages a person wrote (`isSaidByPerson`) after the result, up to and including the current one. Always >= 1. */ readonly distance: number; /** A window strategy was configured, so the distance is a floor. */ readonly windowed: boolean; readonly emptiness: Emptiness; readonly rows?: number; readonly emptinessSource?: 'library' | 'app'; /** * Where in the value the model read the count was taken — a JSON pointer * (`/rows`; `/result/rows` through a declared boundary; `/result/datasets/rows/rows` * when the rows went to the artifact store and the dataset ticket left in their * place counts them). "Show me" points there. */ readonly countedAt?: string; readonly pointers: readonly RecordPointer[]; } export interface RoutingFacts { readonly configured: AccountFact<{ readonly routing?: string; readonly continuity?: string; readonly scorer?: string; } | 'none'>; readonly verdict: AccountFact<{ readonly by: string; readonly from?: string; readonly to?: string; readonly decisive?: boolean; readonly scorer?: string; }>; readonly scores: AccountFact<{ readonly top: number; readonly next: number; readonly nextId: string; readonly allOthersEqual: boolean; }>; readonly confidence: AccountFact<number>; readonly appDecision: AccountFact<{ readonly skillId: string; }>; readonly delivered: AccountFact<readonly string[]>; /** ≤ 12, ids and rule names cut at 200 (the model's `read_skill` argument is model-chosen). */ readonly refusals: readonly { readonly requestedId: string; readonly by: string; readonly kind: 'rule' | 'person'; }[]; readonly refusalsOmitted?: number; } export interface EvidenceFact { readonly posture: string; readonly candidates: number; readonly lookedUp?: number; /** The values not found, each cut at 200 characters. */ readonly unsupported: readonly string[]; /** `unsupported` is at the event's cap (12), so the true count may be higher. */ readonly capped: boolean; readonly action: string; readonly afterRevision: boolean; readonly truncated?: boolean; } /** A typed answer's limits as the account reads them: how many items each list holds. */ export interface LimitsDataFact { readonly checked: number; readonly notChecked: number; readonly cannotCover: number; /** Values a tool's rule assumed this turn (the inputs layer). */ readonly assumed: number; } /** The typed facts every sentence is filled from. */ export interface AnswerFacts { readonly routing: RoutingFacts; readonly calls: readonly ToolCallFact[]; /** Calls past the 50-call cap, counted, not listed. */ readonly callsOmitted?: number; /** * On a resumed leg: the calls answered before the pause, read from the * committed state this record holds (≤ 50, names cut at 200) — what came * back (history) and what each tool declared (`coverageDeclared`). */ readonly beforePause: readonly BeforePauseFact[]; readonly beforePauseOmitted?: number; /** ≤ 50, newest first; every one of them is judged by the checks. */ readonly inView: readonly InViewFact[]; readonly inViewOmitted?: number; readonly evidence: AccountFact<EvidenceFact>; /** * The answer's standing, folded from the run's committed state by the one * fold (`core/agent/assessment/assess.ts` · `assessAnswer`) — the owner's * words: `known` · `consistent` (checks ran, none fired — never "verified") · * `not-sure` · `ask` (the run paused on a question for a person) · * `not-assessed` (nothing on the record could be checked). `not-recorded`, * with no word, when the recording carries no committed state, or shows no * answer (no `turn_end`) and no pause — there is no answer to rate. */ readonly standing: AccountFact<'known' | 'consistent' | 'not-sure' | 'ask' | 'not-assessed'>; /** * The library-appended section of a PROSE answer, when the framework added * one: the limits block (`.limitsTravelWithTheAnswer()`), the "Assumed" * block (the inputs layer) and the standing line (`.answerLayer({ * standingLine: true })`), in the order they were appended — split off the * answer at the separator, so `answer` is the model's own text. A TYPED * answer carries no such text: its limits travel as data, so this reads * `not-recorded` with `missing: 'as-data'` and `limitsData` holds them. */ readonly limitsBlock: AccountFact<string>; /** * A TYPED answer's limits, as the run recorded them beside it * (`turn_end.answerCoverage` — `.limitsTravelWithTheAnswer()` with an output * schema): how many items each list holds, with a pointer to every item. * `not-applicable` when the answer carried no limits as data (a prose * answer carries them in `limitsBlock`). */ readonly limitsData: AccountFact<LimitsDataFact>; readonly errors: { readonly failed: number; readonly refused: number; readonly declined: number; readonly notDispatched: number; readonly withheld: number; }; readonly checks: { readonly reachable: readonly CheckId[]; readonly unreachable: readonly CheckId[]; readonly notApplicable: readonly CheckId[]; }; } /** Facts about the run the account read. */ export interface RunFact { readonly runId: string; readonly turnNumber?: number; readonly model?: string; readonly resumedLeg: boolean; } /** * One answer's account: facts, each with who vouches for it and where it lives * in the record, and the fixed sentences rendered from them. */ export interface AnswerAccount { readonly kind: 'agentfootprint/answer-account'; readonly shape: 1; readonly templates: { readonly set: 'answer-account'; readonly version: number; }; readonly run: AccountFact<RunFact>; readonly question: AccountFact<string>; readonly answer: AccountFact<string>; readonly facts: AnswerFacts; /** Always the seven, in `RowId` order. */ readonly rows: readonly Row[]; /** Priority order. */ readonly signals: readonly Signal[]; readonly unreachable: readonly Unreachable[]; readonly summary: { readonly sentence: Sentence; readonly tone: 'ok' | 'warn' | 'bad' | 'unknown'; }; /** Rows passed over as unreadable, including a sentence whose fill failed. */ readonly unread: number; /** Events of another run, never read. */ readonly foreign: number; /** `unfiltered` only when no run id is known — the account then reads every event. */ readonly scope: 'own-run' | 'unfiltered'; } /** * Data the APP declares when the report is made — not on the record. Everything * filled from it is vouched `app`. */ export interface AnswerAccountDeclarations { readonly id?: string; readonly version?: string; /** Plain labels for skills whose runs carry no declared title. The record wins when it has one. */ readonly skills?: Readonly<Record<string, { readonly label?: string; }>>; /** Where the rows of an OBJECT-shaped result live. Top-level key only. */ readonly tools?: Readonly<Record<string, { readonly rowsAt?: string; }>>; /** The app decides entries itself, outside the library's routing. */ readonly routing?: { readonly appDecides?: boolean; }; } /** * One "show me" leaf as it leaves the server: a JSON leaf, a DERIVED emptiness * count (never the rows), or why it is withheld. */ export type AnswerAccountShownLeaf = { readonly value: string | number | boolean | null; } | { readonly rows: number; readonly at: string; } | { readonly withheld: 'not-shown-here' | 'too-large' | 'not-found' | 'foreign'; };