UNPKG

agentfootprint

Version:

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

221 lines • 10.4 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.llmEdgeWeigher = exports.stepOutputText = void 0; const foottrace_1 = require("foottrace"); const index_js_1 = require("../influence-core/index.js"); const bounded_js_1 = require("../trace-toolpack/bounded.js"); const types_js_1 = require("./types.js"); // The one committed key that is a fingerprint, not prose — see NOT_OUTPUT_KEYS. const conventions_js_1 = require("../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([conventions_js_1.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. */ 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}=${(0, bounded_js_1.safeStringify)((0, foottrace_1.commitValueAt)(commitLog, idx, path))}`); } const text = parts.join('\n'); return text.length === 0 ? undefined : cap(text, maxChars); } exports.stepOutputText = stepOutputText; /** 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 = (0, foottrace_1.commitValueAt)(commitLog, idx, key); if (value === undefined) return undefined; return cap((0, bounded_js_1.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). */ function llmEdgeWeigher(options) { const llmCallIds = new Set(options.llmCallIds); const maxTextChars = options.maxTextChars ?? types_js_1.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}${parentId}${key}`; const childText = (id) => options.childTextOf ? options.childTextOf(id) : stepOutputText(options.commitLog, lastIdxOf, id, maxTextChars); const parentText = (id, key) => options.parentTextOf ? options.parentTextOf(id, key) : edgeKeyText(options.commitLog, lastIdxOf, id, key, maxTextChars); async function prime(root) { // Group the LLM-edge work per child so each child scores against ITS // own output in one influence-core call (one deduplicated embed batch // per child; the cache dedups across children). for (const node of (0, foottrace_1.flattenCausalDAG)(root)) { if (!llmCallIds.has(node.runtimeStageId)) continue; const output = childText(node.runtimeStageId); if (output === undefined) continue; const evidence = []; const evidenceMeta = []; for (const edge of node.parentEdges) { if (edge.kind !== 'data' || edge.key === undefined) continue; const id = edgeKey(node.runtimeStageId, edge.parent.runtimeStageId, edge.key); if (weightOf.has(id)) continue; // already primed (idempotent) const text = parentText(edge.parent.runtimeStageId, edge.key); if (text === undefined) continue; evidence.push({ id, text, ancestorTexts: [] }); evidenceMeta.push({ parentId: edge.parent.runtimeStageId, stageName: edge.parent.stageName, key: edge.key, }); } if (evidence.length === 0) continue; embedded.push(output, ...evidence.map((item) => item.text)); const scores = await (0, index_js_1.scoreInfluence)({ evidence, finalAnswerText: output, embedder: options.embedder, ...(options.weights !== undefined ? { weights: options.weights } : {}), }); const scoreById = new Map(scores.map((item) => [item.id, item.score])); const order = edgeOrder.get(node.runtimeStageId) ?? []; for (let i = 0; i < evidence.length; i++) { const composite = scoreById.get(evidence[i].id) ?? 0; // Clamp to [0, 1]: negative cosine alignment is "no influence // signal", not "negative influence" — path products stay monotone. weightOf.set(evidence[i].id, Math.max(0, Math.min(1, composite))); order.push(evidenceMeta[i]); } edgeOrder.set(node.runtimeStageId, order); } } const weigh = (child, parent, key, kind) => { if (kind !== 'data' || key === undefined) return undefined; if (!llmCallIds.has(child.runtimeStageId)) return undefined; return weightOf.get(edgeKey(child.runtimeStageId, parent.runtimeStageId, key)); }; function rankedParents(llmCallId) { const order = edgeOrder.get(llmCallId); if (!order) return []; const ranked = order.map((meta) => ({ parentId: meta.parentId, stageName: meta.stageName, key: meta.key, weight: weightOf.get(edgeKey(llmCallId, meta.parentId, meta.key)) ?? 0, })); // Stable sort: ties keep first-seen order — deterministic across runs. return ranked.sort((a, b) => b.weight - a.weight); } return { prime, weigh, rankedParents, embeddedTexts: () => [...embedded], }; } exports.llmEdgeWeigher = llmEdgeWeigher; //# sourceMappingURL=llmEdgeWeigher.js.map