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
JavaScript
/**
* 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