UNPKG

agentfootprint

Version:

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

238 lines • 11.2 kB
/** * assessment/sources — what each reason rests on * (`AnswerAssessment.reasons[].source`): specific calls, the answer itself, or * the run as a whole. * * Pattern: a projection inside the one Fold (`assess.ts` · `assessAnswer`). * The KIND is declared per reason (`reasons.ts` · `REASONS`); for a * `calls` reason this module reads the calls off the very rows the * reason's witnesses point at — a history result carries its call id, * a committed row names its call — and the call's tool name off the * same row, or off the run's `history` for a row that names only an id. * It reads the committed record only, and adds nothing to it. * Role: core/ layer, pure. Asked once per fired reason by the fold; every * reader takes the source from the fold — the answer layer's line * (`compose.ts` · `standingLineOf`) and the answer account's "How * sure" counts (`answer-account/facts/howSure.ts`) — and never * re-derives it. * Emits: N/A. * * ## Names and ids only * * A source holds call ids and registered tool names, never a value, an * argument or a result: the rows it reads hold more, and it copies two strings * out of each. * * ## Which calls a row names * * - a tool result in `history` — its own call; * - a coverage, argument, period or middleware row — its own call (the row's * `toolCallId`, with the row's `toolName`); * - a conflict row — the calls of the readings that disagree (`witnesses`); * - a contingent row — the results the value came from (`carriers`), and an * argument row behind `value-contingent` — the result it names (`result`): * the reason is that a value came from results the model itself set aside; * - an answer check that names its tool and not its call * (`declaration-overclaimed`, the report row's `tools` — kept by the one * projection onto the report, `negativeClaims/delivery.ts` · * `reportWithNegatives`) — every call of that tool this turn: it may * over-report, it never hides (the fold's rule for "rests on"). * * A row that names no call, a call whose tool's name the record does not * hold, and every entry of a list (a conflict's readings, a contingent value's * carriers, an answer check's tools) that names no call — beside the entries * that do — is COUNTED in `unnamed`, never dropped and never guessed. Only a * known call id is said once. * * ## A list is read by index, never through its own code * * Every list on a row — a conflict's readings, a contingent value's carriers, * an answer check's tools, a request's `toolCalls` — is read through ONE * reader, `rowList` (`lib/boundedJson.ts` · `readOwnList`, the bounded-copy * convention as a reader): a plain array only, its own `length`, the own data * property at each index, at most `MAX_ROW_LIST_ENTRIES`. A record handed to * `assessAnswer` may be host-built, so a list can be sparse, carry its own * iterator or put a getter at an index: the iterator and the getter are never * called. A hole or a getter's entry names no call; the entries past the bound * are counted, never read; a value that is not a plain array is not trusted as * a list — one call the record cannot name. Each is counted in `unnamed`. */ import { readOwnList } from '../../../lib/boundedJson.js'; const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); const str = (value) => typeof value === 'string' && value.length > 0 ? value : undefined; /** The most entries the fold reads off one list on a row; the rest are counted, never read. */ export const MAX_ROW_LIST_ENTRIES = 1_000; /** * A list on a row, as the fold reads it — THE one reader, shared by the * projection here and the fold's placement (`assess.ts` · `conflictOfThisTurn`, * `callsNamedBy`): `readOwnList` at `MAX_ROW_LIST_ENTRIES`. `undefined` when * the value is not a plain array. */ export function rowList(value) { return readOwnList(value, MAX_ROW_LIST_ENTRIES); } /** The entries of a list past the bound, as one counted anonymous reference — or none. */ const unreadRefs = (list) => (list.unread > 0 ? [{ count: list.unread }] : []); /** The row an RFC 6901 pointer's first segment indexes, in an array-valued key. */ function rowAt(state, key, path) { const rows = state[key]; const index = Number(path.split('/')[1]); return Array.isArray(rows) && Number.isInteger(index) ? rows[index] : undefined; } /** * One reference per entry of a list of `{ toolCallId }` entries, in order. An * entry that names no call is an ANONYMOUS reference — counted in `unnamed`, * never dropped: a conflict whose one reading names its call and whose other * does not still rests on both. A hole, a getter's entry and the entries past * the bound are such entries (`rowList`); a value that is not a plain array * gives no reference, which the caller counts as one call it cannot name. */ function refsIn(value) { const list = rowList(value); if (list === undefined) return []; const refs = list.entries.map((entry) => { const id = isRecord(entry) ? str(entry.toolCallId) : undefined; return id === undefined ? {} : { id }; }); return [...refs, ...unreadRefs(list)]; } /** The calls one committed row names, read for `reason`. */ function callsOfRow(reason, row) { if (!isRecord(row)) return []; if (row.kind === 'conflict') return refsIn(row.witnesses); if (row.kind === 'contingent') return refsIn(row.carriers); if (reason === 'value-contingent' && row.kind === 'argument') { const result = str(row.result); return result === undefined ? [] : [{ id: result }]; } const id = str(row.toolCallId); const name = str(row.toolName); return id === undefined && name === undefined ? [] : [{ ...(id !== undefined && { id }), ...(name !== undefined && { name }) }]; } /** * An answer check that names the TOOLS it is about (`tools`) and no call — * every call of each named tool among this turn's results. A tool no call of * this turn ran, and an entry that is not a tool name (a hole or a getter's * entry included, `rowList`), is an ANONYMOUS reference each: counted, never * dropped beside the tools that matched — and so are the entries past the bound. */ function callsOfCheck(check, read) { const list = isRecord(check) ? rowList(check.tools) : undefined; if (list === undefined) return []; const tools = list.entries; const names = [...new Set(tools.flatMap((t) => (str(t) === undefined ? [] : [t])))]; const malformed = tools.filter((t) => str(t) === undefined).map(() => ({})); const matched = names.flatMap((name) => { const calls = read.turnResults.filter((r) => r.toolName === name); return calls.length === 0 ? [{ name }] : calls.map((r) => ({ ...(r.toolCallId !== undefined && { id: r.toolCallId }), name })); }); return [...matched, ...malformed, ...unreadRefs(list)]; } /** The calls one witness names. */ function callsOfWitness(reason, w, read) { if (w.kind === 'history') { const message = Array.isArray(read.state.history) ? read.state.history[w.index] : undefined; const name = isRecord(message) ? str(message.toolName) : undefined; return w.toolCallId === undefined && name === undefined ? [] : [ { ...(w.toolCallId !== undefined && { id: w.toolCallId }), ...(name !== undefined && { name }), }, ]; } if (w.key === 'answerValidation') { // `/checks/<index>` — the check row the reason fired on. const [, list, at] = w.path.split('/'); const report = read.state.answerValidation; const checks = isRecord(report) && list === 'checks' ? report.checks : undefined; return Array.isArray(checks) ? callsOfCheck(checks[Number(at)], read) : []; } return callsOfRow(reason, rowAt(read.state, w.key, w.path)); } /** * Every call id → its tool's registered name, as `history` holds it (a result, * else the call). `history` is read by index over its own length — never * through its iterator, like every reader of it in the fold — and a request's * `toolCalls` through `rowList`. */ function toolNamesOf(state) { const names = new Map(); const history = Array.isArray(state.history) ? state.history : []; for (let index = 0; index < history.length; index++) { const m = history[index]; if (!isRecord(m)) continue; const id = str(m.toolCallId); const name = str(m.toolName); if (m.role === 'tool' && id !== undefined && name !== undefined) names.set(id, name); for (const call of rowList(m.toolCalls)?.entries ?? []) { const callId = isRecord(call) ? str(call.id) : undefined; const callName = isRecord(call) ? str(call.name) : undefined; if (callId !== undefined && callName !== undefined && !names.has(callId)) { names.set(callId, callName); } } } return names; } /** * What one fired reason rests on — its declared `kind`, and for `calls` the * calls its witnesses' rows name, each once, in witness order. * * @example * ```ts * reasonSourceOf('declared-absent', 'calls', [historyPointer], read); * // { kind: 'calls', calls: [{ toolCallId: 'c1', toolName: 'load_attachment' }] } * reasonSourceOf('stopped-early', 'run', [statePointer('stoppedEarly')], read); * // { kind: 'run' } * ``` */ export function reasonSourceOf(reason, kind, witness, read) { if (kind !== 'calls') return { kind }; let names; const nameOf = (id) => (names ??= toolNamesOf(read.state)).get(id); const calls = []; const listed = new Set(); // A call the record names by id but not by tool — counted once per id. const nameless = new Set(); // A reference that names no call — a row that names none, or a list entry with no // call id beside entries that have one — counted once each, never merged (the // entries of a list past the bound, `count` of them). let anonymous = 0; for (const w of witness) { const refs = callsOfWitness(reason, w, read); if (refs.length === 0) anonymous += 1; for (const ref of refs) { if (ref.id === undefined) { anonymous += ref.count ?? 1; continue; } if (listed.has(ref.id) || nameless.has(ref.id)) continue; const toolName = ref.name ?? nameOf(ref.id); if (toolName === undefined) { nameless.add(ref.id); continue; } listed.add(ref.id); calls.push({ toolCallId: ref.id, toolName }); } } const unnamed = nameless.size + anonymous; return { kind: 'calls', calls, ...(unnamed > 0 && { unnamed }) }; } //# sourceMappingURL=sources.js.map