agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
216 lines • 10.1 kB
JavaScript
/**
* llmEdgeWeigher — influence-scored `EdgeWeigher` for LLM-call slice edges
* (RFC-003 Part B, block D7).
*
* The gap this fills: footprintjs's causal slice treats every parent of an
* LLM call equally — a 12-parent hairball (system prompt, history, tool
* results, cache markers, counters …) gives a debugger 12 equally-plausible
* leads. D7 turns the hairball into a RANKED shortlist by weighing each
* parent edge with influence-core's composite (D6): the parent's WRITTEN
* content vs the LLM call's OUTPUT.
*
* Pattern: two-pass adapter over footprintjs's synchronous `EdgeWeigher`
* hook (A3). Embedding is async, the hook is sync — so the handle
* PRIMES first (walk an unweighted slice, embed every needed text
* in deduplicated batches, memoize composites), and `weigh` then
* answers synchronously from the primed map. `localizeContextBug`
* drives the two passes; standalone consumers call
* `prime(dag)` themselves and re-run `causalChain({ weigh })`.
* Role: `src/context-bisect/` leaf. The engine stays zero-dep — the
* weigher is consumer-injected exactly as A3 intended.
*
* ## Honest claim (§B2)
*
* Weights are CORRELATIONAL proxies: deterministic embedding geometry
* between texts the run already committed — never model internals, never
* causal attribution (ablation is the causal tier). A weight of 0.93 means
* "this parent's content is semantically close to what the LLM produced",
* not "the LLM used it".
*
* ## Determinism
*
* Same artifacts + same (deterministic) embedder → same weights and same
* ranking, run after run: texts are built from the commit log in commit
* order, embedded via influence-core's deduplicated batch, and ties in
* `rankedParents` keep first-seen (slice BFS) order. Wrap the embedder in
* `embeddingCache(...)` to also make repeat localizations embed nothing.
*
* ## What is weighed
*
* DATA edges whose CHILD is an LLM call (`llmCallIds`). Everything else —
* non-LLM children, control edges (a routing decision's influence is not a
* semantic-content question) — returns `undefined`, which footprintjs
* stamps as the default 1.0.
*
* ## Redaction posture
*
* Texts come exclusively from the COMMIT LOG, which footprintjs scrubs at
* commit time — a redacted key's committed value IS the placeholder, so
* the embedder never sees the raw secret.
*/
import { commitValueAt, flattenCausalDAG } from 'foottrace';
import { scoreInfluence, } from '../influence-core/index.js';
import { safeStringify } from '../trace-toolpack/bounded.js';
import { CONTEXT_BISECT_DEFAULTS } from './types.js';
// The one committed key that is a fingerprint, not prose — see NOT_OUTPUT_KEYS.
import { RECEIPT_KEY } from '../lib/conventions.js';
// ─── Text builders (commit-log-only — redaction-respecting) ──────────
/** Last commit-log index of a step (steps may commit more than once). */
function buildLastIdxMap(commitLog) {
const map = new Map();
for (let i = 0; i < commitLog.length; i++)
map.set(commitLog[i].runtimeStageId, i);
return map;
}
function cap(text, maxChars) {
return text.length > maxChars ? text.slice(0, maxChars) : text;
}
/**
* Keys a step commits that are BOOKKEEPING ABOUT the step rather than output
* FROM it. They are skipped by {@link stepOutputText} — see the note there.
*/
const NOT_OUTPUT_KEYS = new Set([RECEIPT_KEY]);
/**
* Default child text: everything the step committed, `key=value` in trace
* order. For an agent's LLM call this carries the assistant content +
* tool-call intents — the step's observable OUTPUT.
*
* WITH ONE EXCLUSION (9.88.0). The receipt an LLM call commits is a
* FINGERPRINT of the request, not something the step said: run-salted hex
* digests, which are by construction semantically empty. Left in, they are
* hundreds of characters of noise inside a `maxChars` budget that then has
* less room for the assistant's actual words — measured, that alone reordered
* the localizer's suspects and demoted a planted fact below a tool. A text
* corpus scored by an embedder must contain only text somebody wrote.
*/
export function stepOutputText(commitLog, lastIdxOf, runtimeStageId, maxChars) {
const idx = lastIdxOf.get(runtimeStageId);
if (idx === undefined)
return undefined;
const paths = new Set();
for (const bundle of commitLog) {
if (bundle.runtimeStageId !== runtimeStageId)
continue;
for (const entry of bundle.trace) {
if (NOT_OUTPUT_KEYS.has(entry.path))
continue;
paths.add(entry.path);
}
}
if (paths.size === 0)
return undefined;
const parts = [];
for (const path of paths) {
parts.push(`${path}=${safeStringify(commitValueAt(commitLog, idx, path))}`);
}
const text = parts.join('\n');
return text.length === 0 ? undefined : cap(text, maxChars);
}
/** Default parent text: the value the parent committed for the edge key. */
function edgeKeyText(commitLog, lastIdxOf, runtimeStageId, key, maxChars) {
const idx = lastIdxOf.get(runtimeStageId);
if (idx === undefined)
return undefined;
const value = commitValueAt(commitLog, idx, key);
if (value === undefined)
return undefined;
return cap(safeStringify(value), maxChars);
}
// ─── The factory ─────────────────────────────────────────────────────
/**
* Build the D7 weigher. See module docs for the two-pass contract, the
* determinism guarantee, and the §B2 honest claim (weights = proxies).
*/
export function llmEdgeWeigher(options) {
const llmCallIds = new Set(options.llmCallIds);
const maxTextChars = options.maxTextChars ?? CONTEXT_BISECT_DEFAULTS.maxTextChars;
const lastIdxOf = buildLastIdxMap(options.commitLog);
/** `${childId}→${parentId}@${key}` → clamped composite. */
const weightOf = new Map();
/** First-seen order of edges per LLM call (slice BFS — the tiebreaker). */
const edgeOrder = new Map();
const embedded = [];
const edgeKey = (childId, parentId, key) => `${childId}