UNPKG

agentfootprint

Version:

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

183 lines (182 loc) • 10.2 kB
/** * findings/ledger — where the model's standings become record. * * Pattern: one writer for one committed key (the twin of * `core/agent/middleware/ledger.ts · recordDecisions`). * Role: core/ layer leaf. The dispatch loop and the route decider build * rows with `basisRowFrom` / `standingRowsFrom`; `recordFindings` is * the ONLY thing that writes `AgentState.findingsLedger` and emits * the matching events, so the two call sites cannot record the same * declaration two different ways. `foldLedger` is the read: the * current standing per result and the conflicts among what is * stood on, recomputed from the rows every time. * Emits: `agentfootprint.findings.declared` (one per basis row), * `agentfootprint.findings.standing` (one per standing row), * since 9.104.0 `agentfootprint.findings.judged` / * `agentfootprint.findings.judge_failed` (one per judgment / * judgment-error row, filed by `judge.ts`), and since 9.110.0 * `agentfootprint.findings.contingent` (one per contingent row, * filed by the two moments `contingent.ts` serves: the route * decider's answer and the dispatch loop's call) — identities, * enums and numbers only. Assertion values, `settles`, `line`, the * judged state, the contingent VALUE and — on an * `unsettled-by-absence` row — a TOOL's own words (its envelope's * `notChecked`, `cannotCover` and `tryInstead`) live in the * committed key under whatever redaction the run configured; an * event stream fans out to sinks we do not control. That row * (9.113.0, filed by the same two moments through * `unsettled.ts · withUnsettledRows`) emits nothing of its own, as * a conflict row emits nothing of its own. * Observe-only negative prose rows emit * `agentfootprint.findings.negative_claim`: bounded locations, * checker codes and native references, never the prose or values. * * ## Append only, last wins, conflicts are a fold * * A row is never replaced. A second standing on the same result is a second * row and the fold takes the LAST — the earlier one is quotable history, and * two rows that disagree are NOT a conflict (the algebra sees assertions, * never standings). A conflict row is written ONCE per key, at the write * whose stood-on readings first disagreed, from `conflictsOf`'s output and * never by hand; a later `ruled-out` retires a witness in the FOLD without * touching the row, so `foldLedger(...).conflicts` is the current set and the * conflict rows are the history of when each was first seen. * * ## Why the writer emits by literal * * The event names are the registry's `EVENT_NAMES.findings.declared` and * `.standing`; `typedEmit` binds the literal to `AgentfootprintEventMap`, so * a drift between this file and the registry is a compile error, exactly as * `recordDecisions` spells `agentfootprint.middleware.decision`. */ import { type FindingsRequest } from './admission.js'; import { type Conflict } from '../../../integrity/assertion/conflicts.js'; import { type Assertion } from '../../../integrity/assertion/types.js'; import { type BasisRow, type ConflictRow, type DeclaredOn, type FindingsDeclaration, type FindingsLedger, type FindingsRow, type JudgmentRow, type StandingRow, type UnsettledByAbsenceRow } from './types.js'; /** The scope surface this file needs. Structurally a `TypedScope<AgentState>`. */ export interface FindingsScope { findingsLedger?: FindingsLedger; $emit(name: string, payload?: unknown): void; } /** * One result a standing may name: an entry of the previous batch as * `AgentState.toolResults` holds it, or a served `role: 'tool'` message read * as one (`offer.ts · knownResults`). `toolName` is absent only for a served * message that carries none — never invented. */ export interface PreviousResult { readonly toolName?: string; readonly result: string; readonly toolCallId: string; } /** What `foldLedger` answers about a ledger. */ export interface LedgerFold { /** The LAST standing row per result id — the MODEL's current reading of each. */ readonly standingOf: ReadonlyMap<string, StandingRow>; /** * The LAST judgment row per result id — the JUDGE's reading (9.104.0), a * second source kept apart: nothing here folds it into `standingOf`, and * `serve.ts` reads `standingOf` alone (policy A — the model's own standing * is what is served; docs/design/2026-09-findings-ledger.md § Judge). */ readonly judgments: ReadonlyMap<string, JudgmentRow>; /** The assertions of the current `fact` rows, in fold order. */ readonly asserted: readonly Assertion[]; /** `conflictsOf(asserted)` — the current conflict set, not the rows' history. */ readonly conflicts: readonly Conflict[]; /** Whether any result has a standing at all. */ readonly hasStanding: boolean; /** * The ruled-out standings that rest on an absence (9.113.0): the LAST * `unsettled-by-absence` row per result, kept only while that result's * CURRENT standing (`standingOf`) is still `ruled-out` — a later `fact`, * `open` or `noise` moved the model's word, and a row beside the earlier * word describes a standing that no longer stands. In the order each * result first got a row. The piece's `unsettled by absence` section reads * this and nothing else (`serve.ts · findingsLedgerPiece`). */ readonly unsettled: ReadonlyMap<string, UnsettledByAbsenceRow>; } /** * Fold a ledger to its current reading. Pure; safe on a spread of the * committed key (a TypedScope array read is a live proxy view — spread it * into a plain array before calling anything here). */ export declare function foldLedger(rows: readonly FindingsRow[]): LedgerFold; /** * The turn stamp (honesty layers, adopted Q6): while an honesty layer is * armed, every row the one writer files carries the conversation turn it was * filed in. Absent → rows are filed exactly as they always were. */ export interface TurnStamp { readonly turn: number; } /** What `appendRows` answers: the merged ledger and what the merge filed on its own. */ export interface AppendedRows { /** The ledger to commit — `prev`, the new rows, then any new conflict rows. */ readonly ledger: FindingsRow[]; /** The conflict rows this merge filed (a key whose readings first disagreed here). */ readonly newConflicts: readonly ConflictRow[]; /** The judge's current reading per result, for the standing event's `agrees`. */ readonly judgments: ReadonlyMap<string, JudgmentRow>; } /** * THE PURE HALF of the one writer: merge `rows` onto `prev`, plus a conflict * row for every key that first disagreed at this merge. No scope, no event — * so a subflow's output mapper can merge rows its own stages already emitted * for (the inputs layer, `honesty/mounts.ts`) with the same fold the in-stage * callers use. Rows other than `fact` standings never enter `conflictsOf`, so * a merge of argument rows files no conflict. * * @example * ```ts * const { ledger } = appendRows(scope.findingsLedger ?? [], rows); * ``` */ export declare function appendRows(prev: readonly FindingsRow[], rows: readonly FindingsRow[], stamp?: TurnStamp): AppendedRows; /** * Append rows to the run's findings ledger, file a conflict row for every * key that first disagreed at this write, and emit one event per basis or * standing row. No-op on an empty list, so an armed agent whose model * declared nothing never writes the key — its commit log is the one it * always had. Callers pass basis and standing rows — each standing followed * by the `unsettled-by-absence` row the rule files beside it, when it files * one (`unsettled.ts · withUnsettledRows`); conflict rows are this function's * to write. Both halves of the one writer: `appendRows` (the merge) and * `emitRow` (the events). `stamp` is the honesty layers' turn stamp — passed * only while a layer is armed. */ export declare function recordFindings(scope: FindingsScope, rows: readonly FindingsRow[], stamp?: TurnStamp): void; /** * THE EMIT HALF of the one writer: one event per row (identities, enums, * counts — never a value). Exported for the inputs layer's Record stage, * which emits inside its subflow and leaves the merge to the mount's output * mapper (`appendRows`). */ export declare function emitRow(scope: FindingsScope, row: FindingsRow, newConflicts?: readonly ConflictRow[], judgments?: ReadonlyMap<string, JudgmentRow>): void; /** * One standing row per `previous[]` entry of a declaration. Identity comes * from `known` by `toolCallId` — the results the run can identify, which the * two call sites (the dispatch loop, the route decider) build with * `offer.ts · knownResults`: the served history's tool messages plus the * previous batch, the SAME history the offer was read from, so every id the * offer listed resolves. An id `known` does not hold is filed with * `unknownId: true` and no `toolName` — never resolved by position or by * name. Assertions follow the stratum rule: `fact` → asserted, * `open` / `ruled-out` → quoted, `noise` → none; the provenance is * `tool:<toolCallId>`, or `artifact:<ref>` when the result was placed; * `epoch` is never set. */ export declare function standingRowsFrom(known: readonly PreviousResult[], declaration: FindingsDeclaration, declaredOn: DeclaredOn, iteration: number, request?: { readonly witness: FindingsRequest | undefined; }): StandingRow[]; /** * The basis row for one tool call. The declaration must carry a `basis` — * the dispatch loop files a row only when the model declared one, and a * caller that reaches here without one has broken that contract, which is * reported loudly rather than defaulted (never infer a basis). */ export declare function basisRowFrom(tc: { readonly id: string; readonly name: string; }, declaration: FindingsDeclaration, iteration: number, malformed?: number): BasisRow;