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