UNPKG

agentfootprint

Version:

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

1,021 lines • 47 kB
"use strict"; /** * assess — the answer's standing, folded from the committed record. * * Pattern: one pure Fold over the Trace. It reads COMMITTED state only — the * run's `sharedState` (the snapshot's, or a paused run's checkpoint's) * — and never an event, so the running agent and every later reader * fold the same bytes. No clock, network, model or randomness; the * same inputs give the same bytes; it never writes into the run. * Role: core/ layer, pure. The one owner of the answer's standing: the * `/observe` door publishes it (`assessAnswer`), `Agent.assessment` * reads the last run through it, and the answer account's "How sure" * row renders it (`answer-account/facts/howSure.ts` · * `readHowSure`). * Emits: N/A. * * ## The law * * The standing comes from what the record can check, never from how sure the * model sounds — and never "known" from silence. A reason that fires makes it * `unknown`; only a TIE check (a passed enforce `.answerValidation()` report for * these bytes) SUPPORTS `known`; checks that ran and fired nothing make it * `unrefuted` ("consistent with the record"); a record on which nothing could be * checked is `not-applicable` ("not assessed"). A membership pass — a value * found, a result that came back non-empty — keeps a reason from firing and * never supports anything. * * ## This turn only * * The turn begins after the last message a person said * (`lib/saidByPerson.ts` · `isSaidByPerson`, the one owner of that question): a * continued conversation carries earlier turns' results in `history`, and a * library frame (a correction, a nudge) is not a person's turn. The fold reads * the tool results after that message, every coverage row of the run * (`coverageDeclared` is never carried into a next turn), the conflict rows of * this turn — one that names one of those calls, or one stamped with this turn * whose readings the record does not all place in earlier turns * (`conflictOfThisTurn`; the ledger IS carried, so rows about earlier turns' * calls are left out), and the * answer's own rows (`unsupportedValues`, * `stoppedEarly`, `answerValidation`). "Rests on" is every call of the turn: it * may over-report, it never hides. * * ## How the turn ended * * A turn that ended in a PAUSE has no answer yet: a person's reply is what it * waits on. The fold reads that from the committed state every pause leaves — * `pausedToolCallId`, written by each pause the dispatch loop raises and * cleared by each resume — so a snapshot, a checkpoint and a saved recording * all say it, and every pause kind reads `ask`. A turn that ended in an ERROR * has no answer either, and no committed row says so in this version: the * readers settle that before they fold (`Agent.assessment` returns * `undefined`; the answer account says the record shows no answer). */ Object.defineProperty(exports, "__esModule", { value: true }); exports.foldState = exports.assessLive = exports.assessAnswer = void 0; const saidByPerson_js_1 = require("../../../lib/saidByPerson.js"); const toolBytes_js_1 = require("../../../lib/toolBytes.js"); const emptiness_js_1 = require("../emptiness.js"); const rewrites_js_1 = require("../middleware/rewrites.js"); const reasons_js_1 = require("./reasons.js"); const sources_js_1 = require("./sources.js"); const marker_js_1 = require("../../../redaction/marker.js"); const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); const str = (value) => (typeof value === 'string' ? value : undefined); function fail(message) { throw new TypeError(`assessAnswer: ${message}`); } /** A caller error throws; everything the RECORD holds is read, never refused. */ function checkInputs(record, declarations) { if (!isRecord(record)) { fail('the record must be an object — a recording, { snapshot }, or { checkpoint }'); } if (declarations === undefined) return; if (!isRecord(declarations)) fail('declarations must be an object'); const tools = declarations.tools; if (tools === undefined) return; if (!isRecord(tools)) fail('declarations.tools must be an object keyed by tool name'); for (const [tool, entry] of Object.entries(tools)) { if (!isRecord(entry)) fail(`declarations.tools.${tool} must be an object`); if (entry.rowsAt === undefined) continue; const problem = (0, emptiness_js_1.rowsAtProblem)(entry.rowsAt); if (problem === 'empty') fail(`declarations.tools.${tool}.rowsAt must be a non-empty key`); if (problem === 'nested') { fail(`declarations.tools.${tool}.rowsAt must be a top-level key (no "/" or ".")`); } } } /** The committed state: the snapshot's, else the paused run's checkpoint's. */ function stateOf(record) { const fromSnapshot = isRecord(record.snapshot) ? record.snapshot.sharedState : undefined; if (isRecord(fromSnapshot)) return fromSnapshot; const fromCheckpoint = isRecord(record.checkpoint) ? record.checkpoint.sharedState : undefined; return isRecord(fromCheckpoint) ? fromCheckpoint : {}; } /** * `state` as the fold may read it. A key the record keeps out (an agent's * `redact` left its placeholder there) reads as ABSENT — the fold never takes * the placeholder for a value (a `pausedToolCallId` of `'REDACTED'` is not a * pause) — and every such key the fold asks for is noted in `asked`, so * `assessAnswer` can refuse to give a verdict over it. A record with no * placeholder is handed through untouched. */ function keptOutGuard(state) { const keptOut = new Set(Object.keys(state).filter((key) => (0, marker_js_1.isPlaceholder)(state[key]))); const asked = new Set(); if (keptOut.size === 0) return { state, asked }; // A plain copy without the kept-out keys: a served snapshot may be frozen, // and a proxy may not answer `undefined` for a frozen key that holds a value. const held = Object.fromEntries(Object.entries(state).filter(([key]) => !keptOut.has(key))); const note = (key) => { if (typeof key === 'string' && keptOut.has(key)) asked.add(key); }; const guarded = new Proxy(held, { get: (target, key, receiver) => (note(key), Reflect.get(target, key, receiver)), has: (target, key) => (note(key), Reflect.has(target, key)), }); return { state: guarded, asked }; } /** * The results after the last message a person said — or all of them, said so, * when none is — each read as the tool's own bytes; and where that message * sits (`begins`, `-1` when there is none). */ function turnResults(history) { let start = -1; for (let i = history.length - 1; i >= 0; i--) { const m = history[i]; if (isRecord(m) && typeof m.content === 'string' && typeof m.role === 'string') { const said = (0, saidByPerson_js_1.isSaidByPerson)({ role: m.role, content: m.content, ...(m.injectedBy !== undefined && { injectedBy: m.injectedBy }), }); if (said) { start = i; break; } } } const results = []; for (let i = start + 1; i < history.length; i++) { const m = history[i]; if (!isRecord(m) || m.role !== 'tool') continue; const toolCallId = str(m.toolCallId); const toolName = str(m.toolName); results.push({ index: i, ...(toolCallId !== undefined && { toolCallId }), ...(toolName !== undefined && { toolName }), content: (0, toolBytes_js_1.toolBytesOf)({ content: m.content, toolChars: m.toolChars }), }); } return { results, from: start >= 0 ? 'person' : 'whole-history', begins: start }; } /** * The calls the record places in an EARLIER turn: each call id `history` holds * before this turn began (`begins`) — a tool result's, or an id an assistant * message asked for (`toolCalls`, through the one row-list reader). Only a * placement the record shows: an id it does not hold anywhere is not in it. */ function earlierCalls(history, begins) { const ids = new Set(); const add = (id) => { if (typeof id === 'string' && id.length > 0) ids.add(id); }; for (let i = 0; i < begins; i++) { const m = history[i]; if (!isRecord(m)) continue; if (m.role === 'tool') add(m.toolCallId); for (const call of (0, sources_js_1.rowList)(m.toolCalls)?.entries ?? []) { if (isRecord(call)) add(call.id); } } return ids; } const listed = (value) => Array.isArray(value) && value.length > 0; function coverageRows(state) { const rows = state.coverageDeclared; if (!Array.isArray(rows)) return []; const out = []; rows.forEach((row, index) => { if (!isRecord(row) || (row.kind !== 'absence' && row.kind !== 'ledger')) return; const toolCallId = str(row.toolCallId); out.push({ index, kind: row.kind, ...(toolCallId !== undefined && { toolCallId }), gap: listed(row.notChecked) || listed(row.cannotCover), }); }); return out; } const statePointer = (key, ...path) => ({ kind: 'state', key, path: path.map((p) => `/${String(p).replace(/~/g, '~0').replace(/\//g, '~1')}`).join(''), }); const historyPointer = (result) => ({ kind: 'history', index: result.index, path: result.toolCallId !== undefined ? '/toolCallId' : '/role', ...(result.toolCallId !== undefined && { toolCallId: result.toolCallId }), }); function fire(g, reason, witness) { const list = g.fired.get(reason) ?? []; list.push(witness); g.fired.set(reason, list); } /** * Every layer: the turn ENDED in a pause still waiting for a person — read from * the committed state the pause leaves (`AgentState.pausedToolCallId`: the * paused call, written by every pause the dispatch loop raises, cleared to `''` * by every resume), never from the pause event or the checkpoint's * `pauseData`. Every kind is the one `asked` reason: a typed input * (`requestInput`), a question (`askHuman` / `pauseHere`), a consent gate (a * tool's `checkIn`, a middleware's `ask`) and a credential consent all wait on * a person's reply, and the answer does not exist until it comes. */ function readPause(state, g) { const paused = state.pausedToolCallId; if (typeof paused === 'string' && paused.length > 0) { fire(g, 'asked', statePointer('pausedToolCallId')); } } /** * This turn's CURRENT argument rows (honesty layer 2): the ledger's `argument` * rows whose `turn` is the run's `turnNumber` — the ledger crosses turns on a * continued conversation, and a row from an earlier turn is about a call this * answer does not rest on — keyed by call, then argument, the LAST row * winning. A record with no `turnNumber` reads every argument row (it may * over-report; it never hides). */ function argumentRows(state) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; const byCall = new Map(); ledger.forEach((row, index) => { if (!isRecord(row) || row.kind !== 'argument') return; if (turn !== undefined && row.turn !== turn) return; const toolCallId = str(row.toolCallId); const argument = str(row.argument); if (toolCallId === undefined || argument === undefined) return; const source = str(row.source); const read = { index, toolCallId, argument, ...(source !== undefined && { source }), asked: typeof row.asked === 'string' && source === undefined, ruled: row.rule !== undefined || row.period === true, failed: row.failed !== undefined, ...(typeof row.claimed === 'string' && { sourced: row.failed === 'uncheckable' ? 'uncheckable' : 'verdict', }), reading: source === 'said' && row.reading === true, setAside: source === 'result' && typeof row.setAside === 'string', }; const forCall = byCall.get(toolCallId) ?? new Map(); forCall.set(argument, read); byCall.set(toolCallId, forCall); }); return [...byCall.values()].flatMap((forCall) => [...forCall.values()]); } /** * Layer 2, the inputs layer's verdicts. For each ruled argument of this * turn's calls, the value the call RAN with decides: * * - rewritten by a before-tool middleware AFTER the layer checked it — the * rewrite is what ran: ASSUMED unless the middleware declared the value the * person's or the app's (`allow(args, why, { from })`); a middleware default * never earns more standing than the same default declared as `assume`; * - otherwise the layer's row: `default` fires `argument-assumed`; `model` on a * ruled argument, or a failed declared-source check on ANY argument (the * model misstated the record), fires `argument-unverified`; `said` with * `reading` (the model's reading of the person's words) fires * `argument-read`; `result` with `setAside` (a result the model set aside) * fires `value-contingent`. * * A traced source — `said` via the quote or a declared phrase, `answered`, * `result`, `app` — fires nothing, and SUPPORTS nothing: a membership pass * only keeps a reason from firing, so no row here ever makes an answer * "known". Files the `argument-rules` check when the layer filed any verdict * this turn, and the `argument-sources` check when the declared-sources check * judged any. */ function readArgumentVerdicts(state, rows, g) { if (rows.length === 0) return; // The before-tool rewrites (`middlewareDecisions` · `changedKeys`) — the ONE reading the // answer's "Assumed" block takes too (`middleware/rewrites.ts` · `argumentRewritesOf`). const rewrites = (0, rewrites_js_1.argumentRewritesOf)(state.middlewareDecisions); const witness = []; for (const row of rows) { const at = statePointer('findingsLedger', row.index, 'argument'); witness.push(at); const rewrite = rewrites.get(row.toolCallId)?.get(row.argument); if (rewrite !== undefined) { if (rewrite.origin !== 'person' && rewrite.origin !== 'app') { fire(g, 'argument-assumed', statePointer('middlewareDecisions', rewrite.index, 'changedKeys')); } continue; } if (row.source === 'default') fire(g, 'argument-assumed', at); else if ((row.source === 'model' && row.ruled) || row.failed) { fire(g, 'argument-unverified', at); } else if (row.reading) fire(g, 'argument-read', at); else if (row.setAside) fire(g, 'value-contingent', at); } g.checked.push({ layer: 2, check: 'argument-rules', ran: rows.length, of: rows.length, witness, }); // The declared-sources check (either door: `.inputsLayer` or `.findings`) — the rows it // judged carry `claimed`; `uncheckable` is a check that reached no verdict. const judged = rows.filter((r) => r.sourced !== undefined); if (judged.length > 0) { const reached = judged.filter((r) => r.sourced === 'verdict'); g.checked.push({ layer: 2, check: 'argument-sources', ran: reached.length, of: judged.length, witness: reached.map((r) => statePointer('findingsLedger', r.index, 'claimed')), }); } } /** The call ids one ledger row names — its own, where it was declared, its carriers, its witnesses. */ function callsNamedBy(row) { const named = []; const id = str(row.toolCallId); if (id !== undefined) named.push(id); const on = isRecord(row.declaredOn) ? str(row.declaredOn.toolCallId) : undefined; if (on !== undefined) named.push(on); // Read through the one row-list reader (`sources.ts` · `rowList`): by index, never the list's code. for (const list of [row.carriers, row.witnesses]) { for (const entry of (0, sources_js_1.rowList)(list)?.entries ?? []) { const at = isRecord(entry) ? str(entry.toolCallId) : undefined; if (at !== undefined) named.push(at); } } return named; } /** * Where THIS turn's rows begin on the ledger, as far as the ledger itself can * show it: the first row stamped with this turn, or — unstamped — the first * that names a call of this turn. A row cannot name a call before the call * exists, and the one writer appends in order, so every row from there on was * filed this turn. `-1` when no row shows it — nothing on the ledger is shown * to be this turn's. A provider that reuses call ids across turns breaks the * first premise: an earlier turn's row naming the reused id is found first, * and the fold over-reports from there (named in the README, "Not covered"). */ function firstRowOfTurn(ledger, calls, turn) { return ledger.findIndex((row) => { if (!isRecord(row)) return false; if (typeof row.turn === 'number') return row.turn === turn; return callsNamedBy(row).some((id) => calls.has(id)); }); } /** * Layer 2, the contingent law's rows: a value a call of this turn — or the * answer — used that only results the model itself set aside carried * (`findings/contingent.ts`, filed under `.findings()` beside the evidence * gate). A row the one writer stamped with its `turn` is this turn's only when * the stamp says so; an unstamped row declared on a call is this turn's when * the call is. An unstamped row declared on the ANSWER names no call of its * own, and the ledger crosses turns on a continued conversation: on a first * turn (or a record with no `turnNumber`) it is this turn's; on a later turn * only when it follows — or is — a row the ledger shows to be this turn's * (`firstRowOfTurn`). Otherwise the record cannot say which turn filed it, * and it is not read: an earlier turn's answer must never make this one "not * sure". The rows of an agent that arms the inputs layer carry their turn. */ function readContingentRows(state, calls, g) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; const since = turn !== undefined && turn > 1 ? firstRowOfTurn(ledger, calls, turn) : 0; ledger.forEach((row, index) => { if (!isRecord(row) || row.kind !== 'contingent') return; if (typeof row.turn === 'number' && turn !== undefined) { if (row.turn !== turn) return; } else if (row.declaredOn !== 'answer') { const on = isRecord(row.declaredOn) ? str(row.declaredOn.toolCallId) : undefined; if (on === undefined || !calls.has(on)) return; } else if (since < 0 || index < since) { return; } fire(g, 'value-contingent', statePointer('findingsLedger', index, 'value')); }); } /** * Layer 2, the inputs layer's own ask: the turn ENDED with the batch ask still * waiting on the person — read from the committed state the ask leaves * (`AgentState.argumentAsk`, whose `waiting` is written before the pause and * cleared when the answer settles it), never from the pause event or the * checkpoint's `pauseData`. The witnesses are that marker and this turn's * current `asked` rows — the values the answer waits on. Nothing in the batch * has run. An ask that settled (answered, or refused after three answers that * did not fit) leaves no marker and fires nothing here. */ function readArgumentAsk(state, rows, g) { const ask = state.argumentAsk; if (!isRecord(ask) || !isRecord(ask.waiting)) return; fire(g, 'argument-asked', statePointer('argumentAsk', 'waiting', 'requestId')); for (const row of rows) { if (row.asked) fire(g, 'argument-asked', statePointer('findingsLedger', row.index, 'asked')); } } /** * This turn's period verdicts (honesty layer 3): EVERY `period` row whose * `turn` is the run's `turnNumber`, in ledger order. A record with no * `turnNumber` reads every period row (it may over-report; it never hides). * * Never collapsed by call id. The layer files one row per judged call — each * batch judged once (`honesty/mounts.ts` · `batchToJudge`) — so a second row * under an id is ANOTHER call: a provider's synthetic counter restarts with * each provider instance, so a resumed leg repeats the ids of the leg that * failed (whose rows ride the checkpoint, under the same turn), and a provider * may reuse an id across batches. "The last per call" would let a later * `covered` hide an earlier `not-held` the answer can still rest on. */ function periodRows(state) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; const rows = []; ledger.forEach((row, index) => { if (!isRecord(row) || row.kind !== 'period') return; if (turn !== undefined && row.turn !== turn) return; const toolCallId = str(row.toolCallId); const verdict = str(row.verdict); if (toolCallId === undefined || verdict === undefined) return; const argument = str(row.argument); rows.push({ index, toolCallId, verdict, ...(argument !== undefined && { argument }), differs: isRecord(row.differs), beyondRetention: row.beyondRetention === true, }); }); return rows; } /** The reason each period verdict fires — `covered` fires none. */ const PERIOD_REASONS = { 'not-held': 'period-not-held', 'partly-held': 'period-partly-held', unknown: 'period-unknown', undeclared: 'period-undeclared', }; /** * Layer 3, the results layer's verdicts: each period row of this turn — the * store did not hold the period the read asked for (`not-held`), held only * part of it (`partly-held`), could not say (`unknown`, adopted Q33: "not * sure" by default, on a non-empty result too), or the result said nothing * about its period though its tool declares a period argument (`undeclared`). * `covered` fires nothing — and supports nothing: a period the store held * keeps a reason from firing, never more. * * THE JOIN (results.md § 3.7): the inputs layer owns WHO chose the period * (the call's `argument` row for the argument its `ToolPeriod` names), this * layer owns WHAT the read covered. A period reason's witnesses are both rows * — joined by the call id and the argument name, and neither parses the * other's words — so a reader can say "the hour you asked about" or "the 2 * hours the tool's rule assumed". Files the `result-period` check when the * layer filed any verdict this turn. */ function readPeriodVerdicts(rows, argumentVerdicts, g) { if (rows.length === 0) return; const witness = []; for (const row of rows) { const at = statePointer('findingsLedger', row.index, 'verdict'); witness.push(at); const chosenBy = argumentVerdicts.find((a) => a.toolCallId === row.toolCallId && a.argument === row.argument); const fireJoined = (reason, pointer) => { fire(g, reason, pointer); if (row.argument !== undefined && chosenBy !== undefined) { fire(g, reason, statePointer('findingsLedger', chosenBy.index, 'argument')); } }; // The time layer's result checks (step T8), under `.time()` only: the // read is not what was asked (TQ8 — `extra` alone too), or the window was // older than the source keeps. if (row.differs) { fireJoined('period-differs-from-asked', statePointer('findingsLedger', row.index, 'differs')); } if (row.beyondRetention) { fireJoined('period-beyond-retention', statePointer('findingsLedger', row.index, 'beyondRetention')); } const reason = PERIOD_REASONS[row.verdict]; if (reason === undefined) continue; fireJoined(reason, at); } g.checked.push({ layer: 3, check: 'result-period', ran: rows.length, of: rows.length, witness }); } /** Layer 3, the tools' own declarations: every absence and every declared gap. */ function readCoverageRows(coverage, g) { for (const row of coverage) { const at = statePointer('coverageDeclared', row.index, 'kind'); if (row.kind === 'absence') fire(g, 'declared-absent', at); if (row.gap) fire(g, 'coverage-gap', at); } } function readResults(results, coverage) { const rowsByCall = new Map(); for (const row of coverage) { if (row.toolCallId === undefined) continue; rowsByCall.set(row.toolCallId, [...(rowsByCall.get(row.toolCallId) ?? []), row]); } return results.map((result) => { const rows = result.toolCallId !== undefined ? rowsByCall.get(result.toolCallId) ?? [] : []; const own = rows.length === 0 ? (0, emptiness_js_1.declaredByValue)(result.content) : undefined; return { result, at: historyPointer(result), rows, ...(own !== undefined && { own }) }; }); } /** * Layer 3, what came back: every result of the turn through the ONE emptiness * reader. A call with committed coverage rows is read through the door those * rows are; a call with none is handed NO door, so the reader reads the * envelope in the bytes by the rule the run's recognizer files rows — and a gap * that envelope lists is a reason too. */ function readTurnResults(reads, declarations, g) { const readable = []; for (const { result, at, rows, own } of reads) { const absenceRow = rows.some((c) => c.kind === 'absence'); const door = rows.length > 0 ? { absent: absenceRow, bounded: rows.some((c) => c.kind === 'ledger') } : undefined; const rowsAt = result.toolName !== undefined ? declarations?.tools?.[result.toolName]?.rowsAt : undefined; const reading = (0, emptiness_js_1.readEmptiness)(result.content, { ...(door !== undefined && { door }), ...(rowsAt !== undefined && { rowsAt }), }); if (reading.emptiness !== 'unknown') readable.push(at); // An empty rowset inside a declared boundary, or an absence no row holds; an absence row fired for itself. if (reading.emptiness === 'declared-absent' && !absenceRow) fire(g, 'declared-absent', at); if (reading.emptiness === 'undeclared-empty' && rows.length === 0) { fire(g, 'empty-undeclared', at); } // A gap the result's own envelope lists, when no row holds it. if (own?.gap === true) fire(g, 'coverage-gap', at); } if (reads.length > 0) { g.checked.push({ layer: 3, check: 'result-shape', ran: readable.length, of: reads.length, witness: readable, }); } } /** * The calls of this turn: every result's call and every coverage row's call. * A row or a result that names no call is counted on its own — never merged * into another call. A call DECLARED what it covered when the record holds a * coverage row for it, or — with no row — its result's own envelope. Files the * `tool-coverage` check and returns the ids. */ function readTurnCalls(reads, coverage, g) { const calls = new Set(); for (const { result } of reads) if (result.toolCallId !== undefined) calls.add(result.toolCallId); for (const c of coverage) if (c.toolCallId !== undefined) calls.add(c.toolCallId); const declared = new Set(coverage.flatMap((c) => (c.toolCallId ? [c.toolCallId] : []))); const witness = coverage.map((c) => statePointer('coverageDeclared', c.index, 'kind')); let unnamedDeclared = 0; for (const { result, at, own } of reads) { if (own === undefined) continue; witness.push(at); if (result.toolCallId !== undefined) declared.add(result.toolCallId); else unnamedDeclared += 1; } const unjoinedRows = coverage.filter((c) => c.toolCallId === undefined).length; const unnamedResults = reads.filter((r) => r.result.toolCallId === undefined).length; const of = calls.size + unjoinedRows + unnamedResults; if (of > 0) { g.checked.push({ layer: 3, check: 'tool-coverage', ran: declared.size + unjoinedRows + unnamedDeclared, of, witness, }); } return calls; } /** * Layer 3, the model's readings: a conflict row of THIS turn * (`conflictOfThisTurn`). A row the one writer stamped with its `turn` (the * honesty layers' turn stamp, filed while a layer is armed) is this turn's * only when the stamp says so — a provider that reuses call ids across turns * no longer makes an earlier turn's conflict read as this one's. */ function readConflicts(state, places, g) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; ledger.forEach((row, index) => { if (!isRecord(row) || row.kind !== 'conflict') return; if (conflictOfThisTurn(row, turn, places)) { fire(g, 'sources-conflict', statePointer('findingsLedger', index, 'kind')); } }); } /** * Whether a conflict row is this turn's — THE INCOMPLETE-RECORD POLICY. Each * reading (`witnesses`) is placed by the call it names, as the record shows * it: CURRENT (a call of this turn), EARLIER (a call `history` places before * this turn began — its result, or the request that asked for it) or * UNRESOLVED (no call id, or one the record does not place: a hole, a getter's * entry, an id whose result the kept history no longer holds). * * - a row stamped with ANOTHER turn is never this turn's; * - a CURRENT reading places the row here, stamped or not (the call-id rule); * - a row stamped with THIS turn stays this turn's unless EVERY reading is * EARLIER — a contrary placement the record establishes. An unresolved * reading establishes none: the stamp keeps its doubt, and the source * counts the reading in `unnamed` (`sources.ts`). No readings at all, or a * list the fold cannot read whole (not a plain array, or longer than it * reads — `sources.ts` · `rowList`), establish none either; * - an UNSTAMPED row is never placed by a reading that is not current: an * earlier turn's conflict whose ids are gone, or unresolved, stays out of * this turn. Nor is any row of a record with no `turnNumber`, where no stamp * can be read against the turn. */ function conflictOfThisTurn(row, turn, places) { const stamped = typeof row.turn === 'number' && turn !== undefined; if (stamped && row.turn !== turn) return false; const witnesses = (0, sources_js_1.rowList)(row.witnesses); const ids = (witnesses?.entries ?? []).map((w) => (isRecord(w) ? w.toolCallId : undefined)); if (ids.some((id) => typeof id === 'string' && places.current.has(id))) return true; if (!stamped) return false; const placedEarlier = (id) => typeof id === 'string' && places.earlier.has(id); const readWhole = witnesses !== undefined && witnesses.unread === 0; return !(readWhole && ids.length > 0 && ids.every(placedEarlier)); } /** * This turn's witness rows (honesty layer 4, `assessment/witness.ts`): the * ledger's `grounded` and `steps-unfinished` rows whose `turn` is the run's * `turnNumber` — the ledger crosses turns on a continued conversation, and an * earlier turn's verdict is about an answer this fold is not reading. A record * with no `turnNumber` reads every witness row (it may over-report; it never * hides). Filed only while the answer layer is armed, so an unarmed record * holds none and this reads nothing. */ function witnessRows(state) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; const out = []; ledger.forEach((row, index) => { if (!isRecord(row) || (row.kind !== 'grounded' && row.kind !== 'steps-unfinished')) return; if (turn !== undefined && row.turn !== turn) return; out.push({ index, kind: row.kind, ...(typeof row.lookedUp === 'number' && { lookedUp: row.lookedUp }), }); }); return out; } /** * Layer 4, the answer's own rows: the evidence gate's verdict (a flag — * `unsupportedValues` — or, while the answer layer is armed, its clean pass, * the `grounded` witness row), a cut-short turn, an answer given before its * declared steps finished (the `steps-unfinished` witness row), the app's * checks. A clean pass is a MEMBERSHIP pass — every value found somewhere in * the tools' results — so it files the check as having run and supports * nothing: "known" needs a tie check. */ function readAnswerRows(state, g) { const witnesses = witnessRows(state); const unsupported = state.unsupportedValues; if (isRecord(unsupported)) { const at = statePointer('unsupportedValues', 'candidates'); fire(g, unsupported.revised === true ? 'value-survived-revision' : 'value-unsupported', at); g.checked.push({ layer: 4, check: 'names-and-numbers', ran: 1, of: 1, witness: [at] }); } else { // The gate's clean pass on this turn's answer — the LAST one, the verdict // on the answer that stands (an earlier draft's verdict never reaches a // row: a draft sent back for revision files `evidenceUnsupported`, not a // witness, and the Route decider files a `steps-unfinished` row only once // its answer has stood every later judge). A pass that looked NOTHING up // (the answer stated no name or number, or every one was exempt) did not // apply: it is absent from `checked`, never a check that "ran" on an // answer it could not read. const grounded = witnesses.filter((w) => w.kind === 'grounded').at(-1); if (grounded !== undefined && (grounded.lookedUp ?? 0) > 0) { g.checked.push({ layer: 4, check: 'names-and-numbers', ran: 1, of: 1, witness: [statePointer('findingsLedger', grounded.index, 'kind')], }); } } if (isRecord(state.stoppedEarly)) { fire(g, 'stopped-early', statePointer('stoppedEarly', 'iteration')); } for (const w of witnesses) { if (w.kind === 'steps-unfinished') { fire(g, 'steps-unfinished', statePointer('findingsLedger', w.index, 'kind')); } } readTimeDerived(state, g); readScriptsShown(state, g); const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; ledger.forEach((row, index) => { if (!isRecord(row) || (row.kind !== 'negative-claim' && row.kind !== 'negative-claim-note')) return; if (typeof state.turnNumber === 'number' && row.turn !== state.turnNumber) return; fire(g, 'negative-prose-unverified', statePointer('findingsLedger', index, 'kind')); }); const report = state.answerValidation; if (!isRecord(report) || typeof report.status !== 'string') return; const at = statePointer('answerValidation', 'status'); g.checked.push({ layer: 4, check: 'answer-checks', ran: 1, of: 1, witness: [at] }); // By index over the list's own length — never its iterator or its own `entries`. const checks = Array.isArray(report.checks) ? report.checks : []; for (let index = 0; index < checks.length; index++) { const check = checks[index]; if (!isRecord(check) || typeof check.id !== 'string') continue; if (!check.id.startsWith('agent:negative-claims:')) continue; if (check.disposition === 'checked-fail') fire(g, 'claim-exceeds-search', statePointer('answerValidation', 'checks', index)); if (check.reason === 'declaration-overclaimed') fire(g, 'declaration-overclaimed', statePointer('answerValidation', 'checks', index)); } if (report.status === 'failed') fire(g, 'answer-check-failed', at); if (report.status === 'unverified') fire(g, 'check-unreachable', at); // A failed report can also hold checks that could not reach a verdict — a // contradiction found beside unreachable rows, or beside a validator that // did not finish (`reason`): both are true, so both are said. else if (report.status === 'failed') { if (typeof report.unreachable === 'number' && report.unreachable > 0) { fire(g, 'check-unreachable', statePointer('answerValidation', 'unreachable')); } else if (typeof report.reason === 'string') { fire(g, 'check-unreachable', statePointer('answerValidation', 'reason')); } } const digest = str(report.candidateDigest); // The ONE tie check this version can read: a passed ENFORCE report that names its bytes. if (report.status === 'passed' && report.mode === 'enforce' && digest !== undefined) { g.support = { kind: 'answer-validation', reportDigest: digest }; } } /** * The answer's time values the library itself spelled from a reading of this * turn (time design § 9.5, step T7): a `time-derived` row of this turn fires * `derived-from-reading` — folded like `argument-assumed`, "not sure" at most, * never "known". Filed only under `.time()` beside the evidence gate, so an * unarmed record holds none and this reads nothing. A record with no * `turnNumber` reads every such row (it may over-report; it never hides). */ function readTimeDerived(state, g) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; ledger.forEach((row, index) => { if (!isRecord(row) || row.kind !== 'time-derived') return; if (turn !== undefined && row.turn !== turn) return; fire(g, 'derived-from-reading', statePointer('findingsLedger', index, 'values')); }); } /** * The script match's verdicts on the blocks THIS turn's answer showed * (`.namesAndNumbersFromEvidence({ code: 'match' })`, the `script-shown` * witness rows): each block reached a verdict, so the `script-match` check ran * on every one; an `unmatched` block fires `script-unmatched` — "not sure", * its own reason (a script can miss while every number in the answer is in a * result). A MATCHED block fires nothing and supports nothing: it says which * call submitted the program, never that a value is right. Filed only under * the arm while the answer layer is armed, so an unarmed record reads nothing. * A record with no `turnNumber` reads every such row (it may over-report; it * never hides). */ function readScriptsShown(state, g) { const ledger = Array.isArray(state.findingsLedger) ? state.findingsLedger : []; const turn = typeof state.turnNumber === 'number' ? state.turnNumber : undefined; const witness = []; ledger.forEach((row, index) => { if (!isRecord(row) || row.kind !== 'script-shown') return; if (turn !== undefined && row.turn !== turn) return; const at = statePointer('findingsLedger', index, 'label'); witness.push(at); if (row.label === 'unmatched') fire(g, 'script-unmatched', at); }); if (witness.length === 0) return; g.checked.push({ layer: 4, check: 'script-match', ran: witness.length, of: witness.length, witness, }); } /** * The reasons in `REASONS` order, each with what it rests on (its DECLARED * kind, and for `calls` the calls its rows name — `sources.ts`), and the value * by precedence: a reason > support > a check ran > nothing. The source never * moves the value: it is read after the reason fired, from the same rows. */ function valueOf(g, read) { const reasons = reasons_js_1.REASONS.flatMap((entry) => { const witness = g.fired.get(entry.reason); if (witness === undefined) return []; return [ { reason: entry.reason, ...(entry.layer !== undefined && { layer: entry.layer }), witness, source: (0, sources_js_1.reasonSourceOf)(entry.reason, entry.source, witness, read), }, ]; }); const assessment = reasons.length > 0 ? 'unknown' : g.support !== undefined ? 'known' : g.checked.some((c) => c.ran > 0) ? 'unrefuted' : 'not-applicable'; return { assessment, reasons }; } /** * Fold one answer's standing from its committed record. * * The record of a turn that ended in an ANSWER or in a PAUSE (a pause reads * `ask`, from the committed state the pause leaves — no checkpoint needed). A * turn that ended in an error has no answer, and its committed state does not * say so in this version: do not fold it (`agent.assessment()` returns * `undefined` for one; the answer account says the record shows no answer). * * @param record a recording (`recordRun`), `{ snapshot }`, or a paused * run's `{ checkpoint }` — its `sharedState` is read when * no snapshot is given. * @param declarations what the APP declares — any `AnswerAccountDeclarations` * is accepted as it is: the fold reads only * `tools[name].rowsAt`, where an object result keeps its * rows. * @throws TypeError only on a caller error (a record that is not an object, a * malformed `rowsAt`); a record it cannot read yields * `not-applicable`, never a throw. * * A REDACTED record (an agent's `redact`): when the fold would read a state key * the record keeps out, it gives no verdict — `standing: 'not-assessed'`, with * the keys it needed in `keptOut`. A verdict over the placeholder would read a * kept-out pause as a pause and a kept-out history as an empty one. * * @example * ```ts * import { assessAnswer, recordRun } from 'agentfootprint/observe'; * * const recorder = recordRun(agent); * await agent.run({ message: 'Which ports on switch A are down?' }); * const a = assessAnswer(recorder.toRecording()); * a.standing; // 'not-sure' * a.reasons; // [{ reason: 'empty-undeclared', layer: 3, witness: [...] }] * ``` */ function assessAnswer(record, declarations) { checkInputs(record, declarations); // A placeholder is a kept-out value only in a snapshot served under a policy // (`redaction/marker.ts`); a checkpoint is never served. return foldState(stateOf(record), declarations, (0, marker_js_1.servedUnderPolicy)(record.snapshot)); } exports.assessAnswer = assessAnswer; /** * `assessAnswer` over a runner's LIVE snapshot (`Agent.assessment()`): the * state is the run's own heap, never served, so a placeholder string in it is * a value. The snapshot's `recorders` row says a policy covered the run — true * of its commit log, never of its live state — so it is not consulted here. * * @internal — `Agent · assessment`'s. */ function assessLive(snapshot, declarations) { checkInputs({ snapshot }, declarations); return foldState(stateOf({ snapshot }), declarations, false); } exports.assessLive = assessLive; /** * The one fold, over a committed state already in hand. `served`: the state * comes from a record served under a redaction policy, so a key holding the * placeholder is a value the record keeps out (`keptOutGuard`). * * @internal — `assessAnswer`'s, and the answer account's, which holds the * state and the marker apart. */ function foldState(recorded, declarations, served) { const { state, asked } = served ? keptOutGuard(recorded) : { state: recorded, asked: new Set() }; const g = { fired: new Map(), checked: [] }; const history = Array.isArray(state.history) ? state.history : []; const { results, from, begins } = turnResults(history); const coverage = coverageRows(state); const reads = readResults(results, coverage); readPause(state, g); const argumentVerdicts = argumentRows(state); readArgumentAsk(state, argumentVerdicts, g); readArgumentVerdicts(state, argumentVerdicts, g); readCoverageRows(coverage, g); const calls = readTurnCalls(reads, coverage, g); readContingentRows(state, calls, g); readTurnResults(reads, declarations, g); readPeriodVerdicts(periodRows(state), argumentVerdicts, g); readConflicts(state, { current: calls, earlier: earlierCalls(history, begins) }, g); readAnswerRows(state, g); if (asked.size > 0) { return { assessment: 'not-applicable', standing: 'not-assessed', reasons: [], checked: [], turnFrom: from, keptOut: [...asked].sort(), }; } const { assessment, reasons } = valueOf(g, { state, turnResults: results }); return { assessment, standing: standingOf(assessment, reasons), reasons, ...(assessment === 'known' && g.support !== undefined && { support: g.support }), checked: g.checked, turnFrom: from, }; } exports.foldState = foldState; /** The owner's words for a value: ask > not sure > known > consistent > not assessed. */ function standingOf(assessment, reasons) { switch (assessment) { case 'unknown': return reasons.some((r) => (0, reasons_js_1.reasonEntry)(r.reason).class === 'ask') ? 'ask' : 'not-sure'; case 'known': return 'known'; case 'unrefuted': return 'consistent'; case 'not-applicable': return 'not-assessed'; } } //# sourceMappingURL=assess.js.map