UNPKG

agentfootprint

Version:

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

270 lines • 12 kB
/** * variable-recall — footprintjs's VARIABLE slices, joined to agent vocabulary. * * footprintjs answers "what happened to this variable?" in the address space it * owns: commit indices, runtimeStageIds, state keys (`keyTimeline` / * `forwardSliceForKey`, `foottrace`). An agent debugger asks the same * question in a DIFFERENT vocabulary: which LOOP was that, which injected fact * or tool result is that write, and what would REMOVING it cost. This module is * the join between the two — and nothing more: * * - **Pure assembly.** Every fact here was already recorded. No new capture, * no new scorer, no embedder, no LLM. `joinVariableSlice` re-labels; it * never measures. * - **Loop lifting.** Each moment gets the `loopIndex` of the frame that * contains its `runtimeStageId` (the walk's own `buildWriterFrameIndex` — * one resolver, not two). * - **Agent identity.** A WRITE whose value the trajectory carries is run * through the SAME `defaultSuspectClassifier` the localizer uses, so a * write joins 1:1 with a `Suspect` (`injectionId` / `toolName`) and gets * the `AblationSpec` that would remove it. Writers the classifier cannot * name get NO hook — an honest absence, never a fabricated one. * - **Honesty rides along verbatim.** footprintjs's `HonestyNote`s, the * missing-reason, and `readsCoverage` are copied, not re-worded. * * The one JUDGEMENT this module makes is {@link DataflowCoverage} — see the * long comment on `dataflowCoverage()`. It is what lets `walkToRoot` replace a * text-similarity guess with a recorded dataflow edge, and it is deliberately * strict. * * @beta Beta feature (RFC-003 Part B). The API works and is tested, but may * change before GA. */ import { forwardSliceForKey, keyTimeline, keysReadFromExecutionTree, keysReadFromMap, } from 'foottrace'; import { ablationForSuspect } from './ablation.js'; import { defaultSuspectClassifier, } from './localize.js'; import { assembleTrajectory } from './trajectory.js'; import { buildWriterFrameIndex } from './walk-to-root.js'; // ─── Coverage ──────────────────────────────────────────────────────── /** Walk a forward slice's DAG once, collecting the distinct edge bases. */ function fedBases(root) { const bases = new Set(); if (root === undefined) return bases; const seen = new Set(); const queue = [root]; while (queue.length > 0) { const node = queue.shift(); if (seen.has(node)) continue; seen.add(node); for (const edge of node.fedEdges) { bases.add(edge.basis); queue.push(edge.child); } } return bases; } /** * The coverage verdict (see {@link DataflowCoverage} for the three values). * * WHY the vacuous case is demoted: `'exact'` requires POSITIVE evidence — at * least one `'per-write'` edge — not merely the absence of a conservative one. * A key with no recorded reader (the agent's `lastToolResult`: written by * tool-calls, never read back by `call-llm`) has an EMPTY edge set, so "no * conservative edges" is vacuously true; scoring that as exact would hand the * walk its most confident hop on its least-evidenced key. Absence of dataflow * is `'unknown'`, never exactness. * * WHY this is stricter than the single hop needs: the hop the walk actually * takes rests on `KeyMoment.fromWriteIdx` — read→value attribution, which the * commit log records INDEPENDENTLY of the write-provenance dial and is exact on * its own. Requiring per-write fed-edge exactness on top of that is a * conservative CHOICE, made so the deterministic narrow ships behind the * strongest available evidence; a later wave can relax it with measurements * rather than having to re-derive why it was strict. */ function dataflowCoverage(forward) { if (forward === undefined) return 'unknown'; if (forward.notes.some((n) => n.code === 'reads-not-recorded')) return 'conservative'; const bases = fedBases(forward.root); if (bases.size === 0) return 'unknown'; // vacuous — see above return bases.has('stage') ? 'conservative' : 'exact'; } /** Identity of one committed value the trajectory carries: (state key, writer). */ function valueKey(key, writerId) { return `${key}${writerId}`; } /** * Every committed value the trajectory materialized, indexed by (key, writer). * These are `commitValueAt` results — already redaction-scrubbed, already * verb-folded — so classification needs no second pass over the commit log. */ function buildValueIndex(trajectory) { const values = new Map(); for (const frame of trajectory.frames) { for (const src of frame.contextSources) { if (src.writerId === undefined) continue; const id = valueKey(src.key, src.writerId); if (!values.has(id)) values.set(id, { value: src.value, basis: src.basis ?? [] }); } // The WALK-ONLY proximate tool source carries a value `call-llm` never read // (`lastToolResult`) — including it here is what gives TOOL writes their // ablation hook. It adds nothing to L3: this module never scores. const prox = frame.proximateToolSource; if (prox?.writerId !== undefined) { const id = valueKey(prox.stateKey, prox.writerId); if (!values.has(id)) values.set(id, { value: prox.value, basis: prox.basis ?? [] }); } } return values; } /** Classify one write's committed value with the localizer's own classifier. */ function classifyWrite(key, { value, basis }, classify) { const ctx = { // The classifier reads only `incompleteSources` off the node; a variable // slice has no CausalNode (same shape-cast loop-recall uses). node: { incompleteSources: undefined }, keysWritten: [key], valueOf: (k) => (k === key ? value : undefined), basisOf: (k) => (k === key ? basis : []), }; const seeds = classify(ctx) ?? []; const out = []; const seen = new Set(); for (const seed of seeds) { const suspectId = seed.detail?.injectionId ?? seed.detail?.toolName; if (suspectId === undefined) continue; // 'stage' / 'arg' — no removable identity if (seen.has(suspectId)) continue; seen.add(suspectId); const spec = ablationForSuspect({ source: suspectId, stageName: suspectId, kind: seed.kind, detail: seed.detail, score: 0, structuralScore: 0, hasContentEvidence: false, edgePath: [], }); if (spec !== undefined) out.push({ suspectId, kind: seed.kind, spec }); } return out; } /** Merge both doors' notes without duplicating a code+detail pair. */ function mergeNotes(...lists) { const out = []; const seen = new Set(); for (const list of lists ?? []) { for (const note of list ?? []) { const id = `${note.code}${note.detail}`; if (seen.has(id)) continue; seen.add(id); out.push(note); } } return out; } /** * Join a footprintjs variable slice to the agent's loops and sources. * * @param slice A `keyTimeline(...)` result — the flat, JSON-safe life of one * key. (Chosen over the backward `sliceForKey` deliberately: the walk needs * moments in commit order, and a timeline cannot be mistaken for a DAG.) * @param trajectory `assembleTrajectory(artifacts)` — supplies loop frames AND * the committed values used for classification. * @param opts `forward` (the exactness carrier) and `classify`. * * Nothing here is measured: every field is a re-label of something already * recorded. See the module doc. */ export function joinVariableSlice(slice, trajectory, opts = {}) { const classify = opts.classify ?? defaultSuspectClassifier; const writerFrame = buildWriterFrameIndex(trajectory); const values = buildValueIndex(trajectory); const loopOf = (runtimeStageId) => { const frameIdx = writerFrame.get(runtimeStageId); return frameIdx === undefined ? undefined : trajectory.frames[frameIdx].loopIndex; }; const ablations = []; const seenHook = new Set(); const moments = (slice.moments ?? []).map((m) => { const loopIndex = loopOf(m.runtimeStageId); if (m.kind !== 'write') { return { kind: m.kind, commitIdx: m.commitIdx, runtimeStageId: m.runtimeStageId, stageId: m.stageId, stageName: m.stageName, ...(m.fromWriteIdx !== undefined && { fromWriteIdx: m.fromWriteIdx }), ...(loopIndex !== undefined && { loopIndex }), }; } const committed = values.get(valueKey(slice.key, m.runtimeStageId)); const sources = committed === undefined || committed.value === undefined ? [] : classifyWrite(slice.key, committed, classify); for (const s of sources) { const id = `${m.runtimeStageId}${s.suspectId}`; if (seenHook.has(id)) continue; seenHook.add(id); ablations.push({ writerId: m.runtimeStageId, suspectId: s.suspectId, kind: s.kind, spec: s.spec, }); } return { kind: 'write', commitIdx: m.commitIdx, runtimeStageId: m.runtimeStageId, stageId: m.stageId, stageName: m.stageName, ...(m.verb !== undefined && { verb: m.verb }), ...(loopIndex !== undefined && { loopIndex }), ...(sources.length > 0 && { suspectKind: sources[0].kind, suspectId: sources[0].suspectId }), }; }); return { key: slice.key, moments, ...(slice.missing !== undefined && { missing: slice.missing }), coverage: dataflowCoverage(opts.forward), ablations, notes: mergeNotes(slice.notes, opts.forward?.notes), keysReadKind: slice.keysReadKind, ...(slice.readsCoverage !== undefined && { readsCoverage: slice.readsCoverage }), }; } /** * One call, from a recorded run to a variable's joined life: `keyTimeline` + * `forwardSliceForKey` + `assembleTrajectory` + {@link joinVariableSlice}. * * Reads come from the snapshot's execution tree (zero setup, `readTracking` ≠ * `'off'`); exactness comes from the run's `writeProvenance` dial. Both facts * ride out on the result (`readsCoverage`, `coverage`) rather than being * assumed. * * @example * ```ts * const life = traceVariable({ snapshot }, 'systemPromptInjections'); * life.coverage; // 'exact' | 'conservative' | 'unknown' * life.moments.filter((m) => m.kind === 'write').map((m) => m.loopIndex); * ``` */ export function traceVariable(artifacts, key, opts = {}) { const commitLog = (artifacts.snapshot.commitLog ?? []); const tree = artifacts.snapshot.executionTree; // No execution tree ⇒ no reads to resolve; the empty map is honest (it // reports zero coverage) where a fabricated lookup would not be. const reads = tree !== undefined ? keysReadFromExecutionTree(tree) : keysReadFromMap({}); const bound = opts.before !== undefined ? { before: opts.before } : undefined; return joinVariableSlice(keyTimeline(commitLog, key, reads, bound), opts.trajectory ?? assembleTrajectory(artifacts), { forward: opts.forward ?? forwardSliceForKey(commitLog, key, reads, bound), ...(opts.classify !== undefined && { classify: opts.classify }), }); } //# sourceMappingURL=variable-recall.js.map