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
JavaScript
;
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