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 • 13.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.walkTrajectory = exports.walkToRoot = exports.buildWriterFrameIndex = void 0; const ablation_js_1 = require("./ablation.js"); const localize_js_1 = require("./localize.js"); const loop_recall_js_1 = require("./loop-recall.js"); const trajectory_js_1 = require("./trajectory.js"); /** Margin under which the top-2 narrowed candidates are "unseparated" (mirrors the toBacktrackTrace pattern). */ const UNSEPARATED_MARGIN = 0.05; /** * Resolve every `writerId` to the index of the frame whose `bodyIds` contains it (NOT via * `parseRuntimeStageId` — that yields executionIndex, not loopIndex). A writer not in any frame * (run prelude / root-seeded) maps to `undefined`. Invariant: each id lands in exactly one frame. */ function buildWriterFrameIndex(trajectory) { const map = new Map(); trajectory.frames.forEach((frame, idx) => { for (const id of frame.bodyIds) if (!map.has(id)) map.set(id, idx); }); return map; } exports.buildWriterFrameIndex = buildWriterFrameIndex; /** Map each suspectId present in a frame to its {@link SuspectOrigin}. */ function writtenByOf(frame) { const map = new Map(); for (const src of frame.contextSources) { const v = src.value; if (Array.isArray(v)) { // injection slots: an array of records keyed by sourceId for (const rec of v) { const id = typeof rec.sourceId === 'string' ? rec.sourceId : undefined; if (id !== undefined && !map.has(id)) map.set(id, { writerId: src.writerId, stateKey: src.key }); } } else if (v !== null && typeof v === 'object' && typeof v.toolName === 'string') { // lastToolResult: a { toolName, result } object → the tool suspect const tool = v.toolName; if (!map.has(tool)) map.set(tool, { writerId: src.writerId, stateKey: src.key }); } } // The WALK-ONLY proximate tool source (proposal 008) — the cross-loop descent edge. NOT in // contextSources (L3 never scored it); walkTrajectory adds it as a hop candidate. const tool = frame.proximateToolSource; if (tool && typeof tool.value?.toolName === 'string') { const name = tool.value.toolName; if (!map.has(name)) map.set(name, { writerId: tool.writerId, stateKey: tool.stateKey }); } return map; } /** * The DETERMINISTIC descent: the write that produced the value this loop's * `call-llm` read of `stateKey` — straight off the recorded moments, no * embedding involved. * * Preference order, both commit-log facts: * 1. the read moment BY this loop's call-llm → its `fromWriteIdx` → that * write (exact value identity — footprintjs pins the same rule its forward * walk uses); * 2. otherwise the last write strictly before the call-llm's commit (the key * the call-llm did not read directly — e.g. an inferred proximate). * * Returns `undefined` unless coverage is EXACT: a conservative (stage-level) * edge must never be presented as an exact hop. */ function dataflowWriter(slice, frame) { const coverage = slice?.coverage; if (slice === undefined || coverage !== 'exact') return undefined; // Grouped frames index a subflow's OWN inner log; a run-log slice cannot be // joined to them (see LoopFrame.subflowScope). if (frame.subflowScope !== undefined) return undefined; const writes = slice.moments.filter((m) => m.kind === 'write'); const read = frame.llmCallId !== undefined ? slice.moments.find((m) => m.kind === 'read' && m.runtimeStageId === frame.llmCallId) : undefined; if (read?.fromWriteIdx !== undefined) { return writes.find((w) => w.commitIdx === read.fromWriteIdx); } const llmIdx = frame.llmCallArrayIdx; if (llmIdx === undefined) return undefined; const before = writes.filter((w) => w.commitIdx < llmIdx); return before.length > 0 ? before[before.length - 1] : undefined; } /** A minimal Suspect for ablationForSuspect (only kind + detail identity are read). */ function suspectFor(suspectId, kind) { const detail = kind === 'tool' ? { toolName: suspectId } : { injectionId: suspectId }; return { source: suspectId, stageName: suspectId, kind, detail, score: 0, structuralScore: 0, hasContentEvidence: false, edgePath: [], }; } /** * Walk backward from the symptom to the root context source — from a recorded run. * Thin wrapper: `assembleTrajectory` then {@link walkTrajectory}. See module docs for the honesty model. */ async function walkToRoot(artifacts, opts) { return walkTrajectory((0, trajectory_js_1.assembleTrajectory)(artifacts), opts); } exports.walkToRoot = walkToRoot; /** * The walk itself, over an already-assembled {@link Trajectory} (composable + directly testable). * FLAT charts only for the cross-loop hop; grouped charts degrade (within-loop) with a flag. */ async function walkTrajectory(trajectory, opts) { const beamK = opts.beamK ?? 2; const maxHops = opts.maxHops ?? 8; const maxAblations = opts.maxAblations ?? 24; const classify = opts.classifier ?? localize_js_1.defaultSuspectClassifier; const honestyFlags = [...trajectory.honestyFlags]; const grouped = trajectory.frames.some((f) => f.subflowScope !== undefined); if (grouped) { honestyFlags.push({ flag: 'untracked-sources', note: 'cross-loop hop unavailable for the grouped chart — loop frames are scope-isolated; the walk degrades to within one loop.', }); } const writerFrame = buildWriterFrameIndex(trajectory); const variables = new Map((opts.variables ?? []).map((v) => [v.key, v])); const variableOf = (key) => variables.get(key); // Baseline stability (the un-ablated scenario must reproduce for any ablation verdict to be trusted). let baselineStable = true; let ablationsUsed = 0; if (opts.rerun) { const baseline = await (0, ablation_js_1.runAblationProbe)({ embedder: opts.embedder, rerun: opts.rerun }, []); ablationsUsed++; baselineStable = !(0, ablation_js_1.probeFlipped)(baseline); } const hops = []; const visited = new Set(); let frameIdx = trajectory.frames.length - 1; // start at the symptom (final loop) let byHops = false; let byAblations = false; while (frameIdx >= 0) { if (hops.length >= maxHops) { byHops = true; break; } const frame = trajectory.frames[frameIdx]; // NARROW: rank THIS loop's suspects by per-loop influence (single-frame sub-trajectory). const sub = { frames: [frame], prelude: [], honestyFlags: [] }; const shortlist = await (0, loop_recall_js_1.shortlistEarlyCulprits)(sub, { embedder: opts.embedder, ...(opts.recencyDecay !== undefined ? { recencyDecay: opts.recencyDecay } : {}), ...(opts.k !== undefined ? { k: opts.k } : {}), ...(opts.signal ? { signal: opts.signal } : {}), classifier: classify, }); const beam = shortlist.candidates.slice(0, beamK); if (beam.length === 0) break; const writtenBy = writtenByOf(frame); const unseparated = beam.length >= 2 && Math.abs(beam[0].recallScore - beam[1].recallScore) < UNSEPARATED_MARGIN; // ISOLATE: run-wide ablation of each NARROWED injection candidate; ablation is the only // discriminator (a wrong-branch narrow that doesn't flip is never the root). let chosen = beam[0]; let chosenVerdict; let convicted = false; if (opts.rerun) { for (const cand of beam) { if (ablationsUsed >= maxAblations) { byAblations = true; break; } const spec = (0, ablation_js_1.ablationForSuspect)(suspectFor(cand.suspectId, cand.kind)); if (spec === undefined) continue; const stats = await (0, ablation_js_1.runAblationProbe)({ embedder: opts.embedder, rerun: opts.rerun }, [ spec, ]); ablationsUsed++; if (baselineStable && (0, ablation_js_1.probeFlipped)(stats)) { chosen = cand; chosenVerdict = (0, ablation_js_1.verdictFor)(`ablate ${cand.suspectId}`, stats, baselineStable); convicted = true; break; // ablation picked this loop's root } } } // NARROW → HOP. The beam picked WHO; this picks WHERE. When the narrowed // suspect rode in on a state key whose dataflow is per-write EXACT, WHERE is // a commit-log fact — the write that produced the value THIS loop read — // instead of a provenance scrape or an inferred proximate. Absent // `variables`, `exactWriter` is always undefined and nothing below changes. const beamOrigin = writtenBy.get(chosen.suspectId); const exactWrite = beamOrigin?.stateKey !== undefined ? dataflowWriter(variableOf(beamOrigin.stateKey), frame) : undefined; const exactFrameIdx = exactWrite !== undefined ? writerFrame.get(exactWrite.runtimeStageId) : undefined; const exactWriter = exactWrite !== undefined && exactFrameIdx !== undefined && exactFrameIdx < frameIdx ? exactWrite.runtimeStageId : undefined; // strictly backward, or it is no descent at all // The proximate tool source (walk-only, proposal 008) is the cross-loop DESCENT edge. When this // loop did NOT convict, follow it back to the loop that PRODUCED the tool output the decision was // conditioned on — that's where a buried root (e.g. the misdirecting instruction) scores + convicts. // It is an INFERRED proximate (`call-llm` read `history`, not this key), so a RECORDED exact edge // for the narrowed suspect outranks it: better evidence wins, and the hop says which it used. const toolName = !convicted && exactWriter === undefined && frame.proximateToolSource && typeof frame.proximateToolSource.value?.toolName === 'string' ? frame.proximateToolSource.value.toolName : undefined; if (toolName !== undefined) chosen = { suspectId: toolName, kind: 'tool' }; const narrowedBy = exactWriter !== undefined ? 'dataflow' : 'text-similarity'; const writer = exactWriter ?? writtenBy.get(chosen.suspectId)?.writerId; const nextFrameIdx = writer !== undefined ? writerFrame.get(writer) : undefined; const descendIdx = nextFrameIdx !== undefined && nextFrameIdx < frameIdx ? nextFrameIdx : undefined; // strictly backward // Per-hop honest note: untracked-origin (structural — no provenance writer to descend) is set // regardless of a rerun; unseparated-siblings flags a narrow that couldn't separate the top-2. const inlineNote = writer === undefined ? 'untracked-origin' : unseparated && !convicted ? 'unseparated-siblings' : undefined; hops.push({ loopIndex: frame.loopIndex, suspectId: chosen.suspectId, kind: chosen.kind, narrowedBy, ...(chosenVerdict ? { verdict: chosenVerdict } : {}), ...(writer !== undefined ? { writtenBy: writer } : {}), ...(descendIdx !== undefined ? { cameFrom: trajectory.frames[descendIdx].loopIndex } : {}), ...(inlineNote ? { note: inlineNote } : {}), }); if (convicted) break; // root found at this loop (injections are same-loop-seeded — terminal) const visitKey = `${chosen.suspectId}@${frame.loopIndex}`; if (descendIdx === undefined || visited.has(visitKey)) break; // dead end / cycle visited.add(visitKey); frameIdx = descendIdx; } // root = the ablation-convicted hop (the walk breaks there). let root; for (const hop of hops) if (hop.verdict?.verdict === 'confirmed') root = hop; // Terminal honesty note: a rerun ran but the walk ended without a causal root, AND the last hop // had a descendable provenance writer (so it's "could-not-convict", not "untracked-origin"). if (opts.rerun && root === undefined && hops.length > 0) { const last = hops[hops.length - 1]; if (last.note === undefined && last.writtenBy !== undefined) { hops[hops.length - 1] = { ...last, note: 'overdetermined-or-incomplete' }; } } return { hops, ...(root ? { root } : {}), honestyFlags, ...(byHops || byAblations ? { truncated: { byHops, byAblations } } : {}), }; } exports.walkTrajectory = walkTrajectory; //# sourceMappingURL=walk-to-root.js.map