UNPKG

agentfootprint

Version:

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

504 lines • 23.5 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 { referenceObservation } from './admission.js'; import { isPlacedToolResult } from '../../../artifacts/placement.js'; import { conflictsOf } from '../../../integrity/assertion/conflicts.js'; import { assertionKey } from '../../../integrity/assertion/types.js'; import { typedEmit } from '../../../recorders/core/typedEmit.js'; import { HIDDEN_VALUE } from '../arguments/rows.js'; import { isTimeRowKind } from '../../../time/rows.js'; import { PROPOSITION_CHARS, } from './types.js'; // ─── The fold ────────────────────────────────────────────────────────── /** * 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 function foldLedger(rows) { const standingOf = new Map(); const judgments = new Map(); const lastUnsettled = new Map(); for (const row of rows) { if (row.kind === 'standing') standingOf.set(row.toolCallId, row); else if (row.kind === 'judgment') judgments.set(row.toolCallId, row); else if (row.kind === 'unsettled-by-absence') lastUnsettled.set(row.toolCallId, row); } const asserted = []; for (const row of standingOf.values()) { if (row.standing === 'fact') asserted.push(...row.assertions); } const unsettled = new Map(); for (const [toolCallId, row] of lastUnsettled) { if (standingOf.get(toolCallId)?.standing === 'ruled-out') unsettled.set(toolCallId, row); } return { standingOf, judgments, asserted, conflicts: conflictsOf(asserted), hasStanding: standingOf.size > 0, unsettled, }; } /** * The witnesses of one conflict, identities only. Resolved by KEY over the * current fact rows rather than by reference: `conflictsOf` hands back the * objects it was given, but an element read off a TypedScope array is not * reference-stable, and a key is. */ function witnessesOf(conflict, standingOf) { const witnesses = []; for (const row of standingOf.values()) { if (row.standing !== 'fact') continue; for (const a of row.assertions) { if (assertionKey(a) !== conflict.key) continue; witnesses.push({ toolCallId: row.toolCallId, subject: { kind: a.subject.kind, id: a.subject.id }, predicate: a.predicate, }); } } return witnesses; } /** `rows` with `turn` set on every row that carries none — the same rows when unstamped. */ function stamped(rows, stamp) { if (stamp === undefined) return rows; return rows.map((row) => (row.turn === undefined ? { ...row, turn: stamp.turn } : row)); } /** * 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 function appendRows(prev, rows, stamp) { const filed = stamped(rows, stamp); const merged = [...prev, ...filed]; const fold = foldLedger(merged); const alreadyFiled = new Set(); for (const row of merged) { if (row.kind === 'conflict') alreadyFiled.add(row.key); } const iteration = filed.length > 0 ? filed[filed.length - 1].iteration : 0; const newConflicts = fold.conflicts .filter((c) => !alreadyFiled.has(c.key)) .map((c) => ({ kind: 'conflict', key: c.key, witnesses: witnessesOf(c, fold.standingOf), iteration, ...(stamp !== undefined && { turn: stamp.turn }), })); return { ledger: [...merged, ...newConflicts], newConflicts, judgments: fold.judgments }; } /** * 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 function recordFindings(scope, rows, stamp) { if (rows.length === 0) return; // Spread into a plain local array first: a TypedScope array read is a live // deep-proxy view, and both the commit and the event payloads below must be // detached plain data (RFC-001 'clone' capture under deferred delivery). const prev = [...(scope.findingsLedger ?? [])]; const filed = stamped(rows, stamp); const { ledger, newConflicts, judgments } = appendRows(prev, filed, stamp); scope.findingsLedger = ledger; for (const row of filed) emitRow(scope, row, newConflicts, judgments); } /** * 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 function emitRow(scope, row, newConflicts = [], judgments = new Map()) { if (row.kind === 'basis') { typedEmit(scope, 'agentfootprint.findings.declared', { toolName: row.toolName, toolCallId: row.toolCallId, iteration: row.iteration, basis: row.basis, ...(row.expect !== undefined && { expect: row.expect }), // A FLAG, never the text: the proposition is model prose and stays in // the committed key under the run's redaction (the payload law above). ...(row.proposition !== undefined && { hasProposition: true }), ...(row.malformed !== undefined && { malformed: row.malformed }), }); return; } if (row.kind === 'judgment') { // No `agrees` here: the judge files BEFORE the next model call, so the // model's standing for this result cannot exist yet — the comparison is // made on the STANDING event below, where both readings exist. typedEmit(scope, 'agentfootprint.findings.judged', { toolCallId: row.toolCallId, toolName: row.toolName, iteration: row.iteration, against: row.against, standing: row.standing, confidence: row.confidence, latencyMs: row.latencyMs, ...(row.usage !== undefined && { inputTokens: row.usage.inputTokens, outputTokens: row.usage.outputTokens, }), }); return; } if (row.kind === 'judgment-error') { typedEmit(scope, 'agentfootprint.findings.judge_failed', { toolCallId: row.toolCallId, toolName: row.toolName, iteration: row.iteration, ...(row.status !== undefined && { status: row.status }), latencyMs: row.latencyMs, }); return; } if (row.kind === 'contingent') { // The VALUE stays on the row (the payload law): a sink learns which // moment used a value of what size, how many results carried it and // which standings they hold — never the token itself. const standings = []; for (const c of row.carriers) if (!standings.includes(c.standing)) standings.push(c.standing); typedEmit(scope, 'agentfootprint.findings.contingent', { iteration: row.iteration, declaredOn: row.declaredOn === 'answer' ? 'answer' : 'tool-call', ...(row.declaredOn !== 'answer' && { toolCallId: row.declaredOn.toolCallId }), carriers: row.carriers.length, standings, valueChars: row.value.length, }); return; } // The row beside a ruled-out standing that rests on an absence (9.113.0) // emits nothing of its own — the conflict row's precedent: it is on the // committed key, the served piece is its reader, and no event field ships // without a reader in the same release. if (row.kind === 'unsettled-by-absence') return; // The answer layer's witness rows (honesty layer 4) emit nothing of their // own either: the verdict each one commits fired its event beside it, from // the same stage (`agent.evidence_checked`, `skill.steps_unfinished`). if (row.kind === 'grounded' || row.kind === 'steps-unfinished') return; // The time layer's rows (`time/rows.ts`) emit nothing either: the // clock, a resume's passed time and a call's dispatch moment are VALUES, // which live in the rows; no event field ships without a reader. if (isTimeRowKind(row.kind)) return; if (row.kind === 'argument') { emitArgumentRow(scope, row); return; } // The results layer's period verdict (honesty layer 3, step 7b): the tool, // the call, the stamps and the verdict word — never an instant. if (row.kind === 'period') { // The time layer's checks (step T8): their NAMES, never a range. const timeChecks = [ ...(row.differs !== undefined ? ['period-differs-from-asked'] : []), ...(row.shifted !== undefined ? ['period-shifted'] : []), ...(row.beyondRetention === true ? ['period-beyond-retention'] : []), ...(row.partlyBeyondRetention === true ? ['partly-beyond-retention'] : []), ]; typedEmit(scope, 'agentfootprint.findings.period', { toolCallId: row.toolCallId, toolName: row.toolName, iteration: row.iteration, turn: row.turn, verdict: row.verdict, ...(timeChecks.length > 0 && { timeChecks }), }); return; } if (row.kind === 'negative-claim' || row.kind === 'negative-claim-note') { emitNegativeProseRow(scope, row); return; } if (row.kind !== 'standing') return; const conflictKeys = newConflicts .filter((c) => c.witnesses.some((w) => w.toolCallId === row.toolCallId)) .map((c) => c.key); // The model's standing beside the judge's CURRENT judgment of the same // result (9.104.0) — `agrees` is a comparison made for the sink at the // one moment both readings exist (the judgment always lands first, before // the model call that declares), never written to a row: the two sources // stay two rows on the record (the second-source law). Absent when no // judgment exists — an unarmed run, a failed call, an unknown id. const judged = judgments.get(row.toolCallId); typedEmit(scope, 'agentfootprint.findings.standing', { toolCallId: row.toolCallId, ...(row.toolName !== undefined && { toolName: row.toolName }), iteration: row.iteration, standing: row.standing, declaredOn: row.declaredOn === 'answer' ? 'answer' : 'tool-call', assertionCount: row.assertions.length, ...(conflictKeys.length > 0 && { conflictKeys }), ...(row.unknownId === true && { unknownId: true }), ...(judged !== undefined && { agrees: judged.standing === row.standing }), }); } /** A bounded projection, not another checker: the filed row remains the authority. */ function emitNegativeProseRow(scope, row) { let truncated = false; const text = (value, limit) => { if (value.length <= limit) return value; truncated = true; return value.slice(0, limit); }; const references = (values, chars) => { if (values.length > 32) truncated = true; // Never shorten an identity into a different, apparently valid identity. return values.slice(0, 32).filter((value) => { if (value.length <= chars) return true; truncated = true; return false; }); }; const location = { iteration: row.iteration, ...(row.turn !== undefined && { turn: row.turn }), field: text(row.field, 4096), span: { start: row.span.start, end: row.span.end }, packVersion: text(row.packVersion, 128), ...(row.phraseId !== undefined && { phraseId: text(row.phraseId, 128) }), }; const payload = row.kind === 'negative-claim' ? { ...location, kind: row.kind, claim: row.claim, notes: references(row.notes, 128), results: references(row.results, 256), } : { ...location, kind: row.kind, code: text(row.code, 128) }; typedEmit(scope, 'agentfootprint.findings.negative_claim', { ...payload, ...(truncated ? { truncated: true } : {}), }); } /** * The inputs layer's row event (`agentfootprint.findings.argument`) — names, * enums and counts only. Never the value, the quote or the proposal (they live * on the committed row, in the tool's own argument view), and no `valueChars` * when that view hides the argument: the length of a hidden value is itself a * leak. */ function emitArgumentRow(scope, row) { const hidden = row.value === HIDDEN_VALUE; typedEmit(scope, 'agentfootprint.findings.argument', { toolCallId: row.toolCallId, toolName: row.toolName, iteration: row.iteration, turn: row.turn, argument: row.argument, ...(row.rule !== undefined && { rule: row.rule }), ...(row.period === true && { period: true }), ...(row.source !== undefined && { source: row.source }), ...(row.asked !== undefined && { asked: row.asked }), ...(row.claimed !== undefined && { claimed: row.claimed }), ...(row.matched !== undefined && { matched: row.matched }), ...(row.reading === true && { reading: true }), ...(row.earlier === true && { earlier: true }), ...(row.setAside !== undefined && { setAside: row.setAside }), ...(row.argumentsFrom !== undefined && { argumentsFrom: row.argumentsFrom }), ...(row.coincides !== undefined && { coincides: row.coincides }), ...(row.free === true && { free: true }), ...(row.failed !== undefined && { failed: row.failed }), ...(row.malformed !== undefined && { malformed: row.malformed }), ...(row.value !== undefined && !hidden && { valueChars: row.value.length }), }); } // ─── Row builders ────────────────────────────────────────────────────── /** * The ref a placed result lives at, or nothing. A guarded parse of a string * the library itself minted (`artifacts/placement.ts · placedToolResult`), * judged by `isPlacedToolResult` — a predicate, not inference. */ function placedRefOf(result) { if (typeof result !== 'string' || result.trimStart()[0] !== '{') return undefined; try { const parsed = JSON.parse(result); return isPlacedToolResult(parsed) ? parsed.ref : undefined; } catch { return undefined; } } /** * 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 function standingRowsFrom(known, declaration, declaredOn, iteration, request) { const on = declaredOn === 'answer' ? 'answer' : { toolCallId: declaredOn.toolCallId }; return (declaration.previous ?? []).map((entry) => { const knownResult = request === undefined ? known.find((r) => r.toolCallId === entry.toolCallId) : undefined; const reference = request === undefined ? undefined : referenceObservation(request.witness, iteration, entry.toolCallId); const identified = reference === undefined ? knownResult !== undefined : reference.identified && reference.execution === 'result'; const toolName = identified ? reference?.toolName ?? knownResult?.toolName : undefined; const ref = reference === undefined ? knownResult === undefined ? undefined : placedRefOf(knownResult.result) : reference.ref; const provenance = ref !== undefined ? `artifact:${ref}` : `tool:${entry.toolCallId}`; const stratum = entry.standing === 'fact' ? 'asserted' : 'quoted'; const assertions = entry.standing === 'noise' ? [] : (entry.assertions ?? []).map((a) => ({ subject: { kind: a.subject.kind, id: a.subject.id }, predicate: a.predicate, value: a.value, stratum, provenance, })); return { kind: 'standing', toolCallId: entry.toolCallId, ...(toolName !== undefined && { toolName }), ...(ref !== undefined && { ref }), standing: entry.standing, ...(entry.sought !== undefined && { sought: entry.sought }), ...(entry.settles !== undefined && { settles: entry.settles }), ...(entry.line !== undefined && { line: entry.line }), assertions, declaredOn: on, iteration, ...(!identified && { unknownId: true }), }; }); } /** * 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 function basisRowFrom(tc, declaration, iteration, malformed) { if (declaration.basis === undefined) { throw new TypeError(`basisRowFrom: the declaration on '${tc.name}' (${tc.id}) carries no basis — file a ` + 'basis row only when the model declared one.'); } return { kind: 'basis', toolCallId: tc.id, toolName: tc.name, iteration, basis: declaration.basis, ...(declaration.expect !== undefined && { expect: declaration.expect }), ...(declaration.proposition !== undefined && { proposition: clipText(declaration.proposition), }), ...(declaration.predicts !== undefined && { predicts: clipText(declaration.predicts) }), ...(malformed !== undefined && malformed > 0 && { malformed }), }; } /** * The one cut a basis row's text gets: at most `PROPOSITION_CHARS`, the * overflow STATED in the text (`…[clipped N chars]`), the same statement * `serve.ts · clip` makes on a served line. Nothing is folded here — the row * keeps the model's newlines; the piece folds them when it quotes the line. */ function clipText(text) { if (text.length <= PROPOSITION_CHARS) return text; return `${text.slice(0, PROPOSITION_CHARS)} …[clipped ${text.length - PROPOSITION_CHARS} chars]`; } //# sourceMappingURL=ledger.js.map