UNPKG

agentfootprint

Version:

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

272 lines • 14.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.assembleTrajectory = exports.findLoopHeads = exports.bucketByAnchors = void 0; const foottrace_1 = require("foottrace"); const conventions_js_1 = require("../lib/conventions.js"); // The flat/grouped fork, from its ONE owner (9.88.0) — see time-travel/epochs.ts. const epochs_js_1 = require("../time-travel/epochs.js"); const llmEdgeWeigher_js_1 = require("./llmEdgeWeigher.js"); const types_js_1 = require("./types.js"); /** The state key the proximate tool result is committed under (agent chart convention). */ const PROXIMATE_TOOL_KEY = 'lastToolResult'; function traceKey(log, initialState, lastIdxOf, key, beforeIdx) { const found = (0, foottrace_1.findLastWriterWithBasis)(log, key, beforeIdx); const writerId = found.writer?.runtimeStageId; const writerArrayIdx = writerId !== undefined ? lastIdxOf.get(writerId) : undefined; const read = writerArrayIdx !== undefined ? (0, foottrace_1.commitValueAtWithBasis)(log, writerArrayIdx, key, { initialState }) : undefined; const basis = [...new Set([...found.basis, ...(read?.basis ?? [])])]; return { writerId, writerArrayIdx, value: read?.value, basis }; } /** * Partition a commit log by a list of HEAD runtimeStageIds (taken as data — no agent * knowledge). Each frame is the half-open range `[head[k], head[k+1])`; commits before * the first head are the `prelude`. TOTAL: every commit lands in exactly one frame OR * the prelude. Heads not found in the log are ignored; ordering follows the log, not the * input list (an out-of-order or duplicate head list cannot reorder/duplicate commits). * * A head is anchored at the FIRST commit bearing its runtimeStageId — a single stage * execution can flush MORE THAN ONE commit bundle under one runtimeStageId (parallel * fork merges, multi-flush stages), and those repeats stay INSIDE the frame they open * rather than each spawning a spurious one-commit frame. */ function bucketByAnchors(commitLog, headRuntimeStageIds) { const headSet = new Set(headRuntimeStageIds); const headIdx = []; const anchored = new Set(); for (let i = 0; i < commitLog.length; i++) { const id = commitLog[i].runtimeStageId; if (headSet.has(id) && !anchored.has(id)) { headIdx.push(i); anchored.add(id); // later commits sharing this id belong to the frame it opened } } if (headIdx.length === 0) { return { frames: [], prelude: commitLog.map((b) => b.runtimeStageId) }; } const prelude = commitLog.slice(0, headIdx[0]).map((b) => b.runtimeStageId); const frames = headIdx.map((head, k) => { const end = k + 1 < headIdx.length ? headIdx[k + 1] : commitLog.length; return { headArrayIdx: head, bodyIds: commitLog.slice(head, end).map((b) => b.runtimeStageId) }; }); return { frames, prelude }; } exports.bucketByAnchors = bucketByAnchors; // ─── findLoopHeads — flat-chart loop-head detection ────────────────── /** True when a commit lives inside the injection-engine subflow (the loop head region). */ function inInjectionEngine(bundle) { const { localStageId, subflowPath } = (0, foottrace_1.splitStageId)(bundle.stageId); if (localStageId === conventions_js_1.SUBFLOW_IDS.INJECTION_ENGINE) return true; // the mount commit, if any if (subflowPath === undefined) return false; // a commit nested anywhere under sf-injection-engine (handles nested subflow prefixes) return (subflowPath === conventions_js_1.SUBFLOW_IDS.INJECTION_ENGINE || subflowPath.startsWith(conventions_js_1.SUBFLOW_IDS.INJECTION_ENGINE + '/')); } /** * The flat-chart loop heads: the FIRST commit of each injection-engine ENTRY (one per * ReAct iteration, since the loop is branch-sourced back to the injection engine). A head * is a commit that is in the injection engine while the previous commit was not — so a * multi-commit injection-engine body yields exactly one head per loop. * * Returns the runtimeStageIds to feed `bucketByAnchors`. Empty when the run never enters * the injection engine (e.g. the grouped chart, where the loop lives in sf-llm-call — * the caller degrades with an honesty flag). */ function findLoopHeads(commitLog) { const heads = []; let wasIn = false; for (const bundle of commitLog) { const isIn = inInjectionEngine(bundle); if (isIn && !wasIn) heads.push(bundle.runtimeStageId); wasIn = isIn; } return heads; } exports.findLoopHeads = findLoopHeads; /** Build the per-runtimeStageId read-key map from the snapshot execution tree * (same walk as the localizer's `buildArtifactIndex`). */ function buildReadsOf(executionTree) { const readsOf = new Map(); const visit = (node) => { if (!node) return; const id = node.runtimeStageId; if (id && !readsOf.has(id)) readsOf.set(id, node.stageReads ? Object.keys(node.stageReads) : []); for (const child of node.children ?? []) visit(child); visit(node.next); }; visit(executionTree); return readsOf; } function safeStringify(value) { if (typeof value === 'string') return value; try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } } /** * Project ONE loop frame from a given commit log + readsOf — the shared core used by BOTH * the flat path (over the run commit log) and the grouped path (over a subflow's OWN inner * commit log). `headArrayIdx`/`bodyIds` bound the frame WITHIN `log`; `subflowScope`, when * set, records that all indices are relative to that subflow's inner log. */ function projectFrame(loopIndex, log, /** `log`'s fold base — so a key nothing seeded is not called partial. */ initialState, lastIdxOf, readsOf, headArrayIdx, bodyIds, maxTextChars, subflowScope) { // Locate the call-llm commit WITHIN this frame's body (the LLM-step pointer). let llmCallId; let llmCallArrayIdx; let llmBundle; for (let i = headArrayIdx; i < headArrayIdx + bodyIds.length; i++) { if ((0, foottrace_1.splitStageId)(log[i].stageId).localStageId === conventions_js_1.STAGE_IDS.CALL_LLM) { llmCallId = log[i].runtimeStageId; llmCallArrayIdx = i; llmBundle = log[i]; break; } } const intermediateText = llmCallId !== undefined ? (0, llmEdgeWeigher_js_1.stepOutputText)(log, lastIdxOf, llmCallId, maxTextChars) : undefined; const keys = llmCallId !== undefined ? readsOf.get(llmCallId) ?? [] : []; const contextSources = keys.map((key) => { // EXCLUSIVE beforeIdx — finds the PRIOR writer, never call-llm's own write-back. const traced = llmCallArrayIdx !== undefined ? traceKey(log, initialState, lastIdxOf, key, llmCallArrayIdx) : undefined; const value = traced?.value; const text = value === undefined ? '' : safeStringify(value).slice(0, maxTextChars); return { key, writerId: traced?.writerId, writerArrayIdx: traced?.writerArrayIdx, value, ...(traced !== undefined && traced.basis.length > 0 && { basis: traced.basis }), evidence: { id: `${llmCallId}::${key}`, text, ancestorTexts: [] }, }; }); // Proximate tool source (proposal 008) — the most recent `lastToolResult` committed BEFORE this // loop's call-llm, surfaced WALK-ONLY (NOT in contextSources, so L3's narrow is untouched). Its // writer is the PRODUCING loop's tool-calls stage — the cross-loop provenance edge L4's descent // hops along. FLAT only: in the grouped chart the PRODUCING tool-calls stage lives in the run // log outside the per-scope inner log. Since footprintjs 9.33.0 (nested-row reads) the inner // log does answer `lastToolResult` — from the subflow's input SEED, named by the mount // (9.34.0), basis `['nested-rows']` — but that writer is the copy-in, not the producer, so it // is no cross-loop edge (pinned in trajectory.test.ts). Honesty: the call-llm read `history` // (the aggregate), NOT this key — so it's an INFERRED proximate (`proximate: true`). let proximateToolSource; if (subflowScope === undefined && llmCallArrayIdx !== undefined) { const traced = traceKey(log, initialState, lastIdxOf, PROXIMATE_TOOL_KEY, llmCallArrayIdx); if (traced.writerId !== undefined && traced.value !== undefined) { proximateToolSource = { value: traced.value, writerId: traced.writerId, ...(traced.basis.length > 0 && { basis: traced.basis }), stateKey: PROXIMATE_TOOL_KEY, proximate: true, }; } } const incompleteSources = llmBundle?.untrackedSources; const untrackedReadsPresent = incompleteSources !== undefined && incompleteSources.length > 0; return { loopIndex, llmCallId, llmCallArrayIdx, headArrayIdx, bodyIds, intermediateText, contextSources, ...(proximateToolSource ? { proximateToolSource } : {}), ...(subflowScope !== undefined ? { subflowScope } : {}), ...(untrackedReadsPresent ? { incompleteSources } : {}), untrackedReadsPresent, }; } /** * GROUPED chart projection (`reactMode: 'dynamic-grouped'`). The LLM turn runs inside an * `sf-llm-call` subflow, so its `call-llm` + slot writes live in the subflow's OWN commit log * — retained per-iteration under `subflowResults['sf-llm-call#k']` (footprintjs * subflow-commit-visibility). Each loop is a frame projected PER-SCOPE over its inner log; no * cross-scope merge, so `findLastWriter`/`commitValueAt` run correctly over the isolated log. */ function assembleGroupedTrajectory(artifacts, mountKeys, maxTextChars, maxFrames) { const sr = (artifacts.snapshot.subflowResults ?? {}); const runLog = (artifacts.snapshot.commitLog ?? []); // Run-level prelude: commits before the first sf-llm-call mount (seed / memory-read setup). const firstMountRunIdx = runLog.findIndex((b) => b.runtimeStageId === mountKeys[0]); const prelude = firstMountRunIdx > 0 ? runLog.slice(0, firstMountRunIdx).map((b) => b.runtimeStageId) : []; const kept = maxFrames !== undefined ? mountKeys.slice(0, maxFrames) : mountKeys; const truncated = maxFrames !== undefined && mountKeys.length > maxFrames; const frames = kept.map((key, loopIndex) => { const innerLog = (sr[key]?.treeContext?.history ?? []); const innerReadsOf = buildReadsOf(sr[key]?.treeContext?.stageContexts); const innerLastIdxOf = new Map(); for (let i = 0; i < innerLog.length; i++) innerLastIdxOf.set(innerLog[i].runtimeStageId, i); const bodyIds = innerLog.map((b) => b.runtimeStageId); return projectFrame(loopIndex, innerLog, sr[key]?.treeContext?.initialState, innerLastIdxOf, innerReadsOf, 0, bodyIds, maxTextChars, key); }); return { frames, prelude, honestyFlags: [], ...(truncated ? { truncated: { byFrames: true } } : {}), }; } /** * Slice a recorded agent run into a {@link Trajectory} — one {@link LoopFrame} per ReAct * iteration, each carrying its `call-llm` pointer, the call's output text, and the live * {@link ContextSource}s that fed it (traced via `findLastWriter` + `commitValueAt` from the * SAME commit log — zero new capture). * * Takes the SAME `ContextBugArtifacts` bag the localizer takes — adopter call is just * `assembleTrajectory(artifacts)`. * * Handles BOTH chart shapes: * - FLAT (`reactMode: 'dynamic'`, default): `call-llm` is a parent-level stage; frames are * bucketed over the run commit log by the `sf-injection-engine` loop heads. * - GROUPED (`reactMode: 'dynamic-grouped'`): the LLM turn runs in an `sf-llm-call` subflow; * each loop is projected PER-SCOPE over its own inner commit log (retained per-iteration by * footprintjs subflow-commit-visibility). Such frames carry `subflowScope` and their array * indices are inner-log-relative. * * Standing caveat on every result: contextSources show only sources re-committed to tracked * state; context the model retained internally (carried in its own reasoning, never * re-committed) leaves no read→write edge and is NOT represented. */ function assembleTrajectory(artifacts, opts = {}) { const maxTextChars = opts.maxTextChars ?? types_js_1.CONTEXT_BISECT_DEFAULTS.maxTextChars; // Grouped agent ⟺ the LLM turn is wrapped in sf-llm-call (its mounts appear in subflowResults). const mountKeys = (0, epochs_js_1.llmCallMountKeys)((artifacts.snapshot.subflowResults ?? {})); if (mountKeys.length > 0) { return assembleGroupedTrajectory(artifacts, mountKeys, maxTextChars, opts.maxFrames); } // FLAT path: project over the run commit log, bucketed by injection-engine loop heads. const commitLog = (artifacts.snapshot.commitLog ?? []); const lastIdxOf = new Map(); for (let i = 0; i < commitLog.length; i++) lastIdxOf.set(commitLog[i].runtimeStageId, i); const readsOf = buildReadsOf(artifacts.snapshot.executionTree); const heads = findLoopHeads(commitLog); const { frames: buckets, prelude } = bucketByAnchors(commitLog, heads); const kept = opts.maxFrames !== undefined ? buckets.slice(0, opts.maxFrames) : buckets; const truncated = opts.maxFrames !== undefined && buckets.length > opts.maxFrames; const frames = kept.map((bucket, loopIndex) => projectFrame(loopIndex, commitLog, artifacts.snapshot.initialState, lastIdxOf, readsOf, bucket.headArrayIdx, bucket.bodyIds, maxTextChars)); return { frames, prelude, honestyFlags: [], ...(truncated ? { truncated: { byFrames: true } } : {}), }; } exports.assembleTrajectory = assembleTrajectory; //# sourceMappingURL=trajectory.js.map