agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
284 lines • 13 kB
JavaScript
;
/**
* epochs — where one epoch's committed pieces live, in either chart shape.
*
* Role: Fold. It reads a finished run's recording and answers one question:
* "for iteration k, which commit log holds the call, at which index,
* and against which fold base?" It executes nothing, records nothing
* and stores nothing beside the log.
* Reads: a recording (`commitLog`, `initialState`, `subflowResults`), through
* footprintjs's own id parsers and this folder's `keyedFold.ts`.
* Emits: N/A.
*
* ── WHY THIS FILE EXISTS ───────────────────────────────────────────────────
* An agent's iteration — an EPOCH — commits to a different place depending on
* how the chart was built. Under `reactMode: 'dynamic'` the `call-llm` stage is
* a parent-level stage and its bundle is in the run's own log. Under
* `'dynamic-grouped'` each turn runs inside an `sf-llm-call` subflow, which has
* its own isolated runtime and its own log, retained per iteration under
* `subflowResults['sf-llm-call#k'].treeContext.history`.
*
* That fork was written out THREE times — `contextLedger.ts` had a private
* `llmCallMountKeys`, `trajectory.ts` had a byte-twin of it, and a rebuild of
* what the model was served would have been the third. Three copies of "which
* log is this key in?" is three chances to disagree about which iteration a
* value belonged to, on the one question the whole family exists to answer.
* So the fork lives here once, and every reader asks it.
*
* ── WHAT AN EPOCH NUMBER IS ────────────────────────────────────────────────
* The committed `iteration` value in effect at the call — the same number
* `callLLM` put on the receipt's `basis.epoch` and on every `iteration_start`
* event. It starts at 1. It is READ, not counted: a run that pauses and
* resumes, or one whose first turn is not its first stage, still names its
* epochs the way the run itself named them.
*
* ── EVERY READ FOLDS FROM THE BASE ─────────────────────────────────────────
* A location carries the FOLD SOURCE its log was recorded against, not just
* the log, and every read below goes through `keyedFold.ts`. A resumed run is
* a fresh executor seeded from `checkpoint.sharedState`, so most of what the
* call read is base rather than log; a fold that could not see the base
* answered "absent" for the whole pre-pause world and said nothing about it.
* `EpochLocation.basis` is that fold's own verdict, carried out to the reader
* so a recording that travelled without its base declares a gap instead of a
* confident empty view.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.readRunConstant = exports.readAfterCall = exports.readAtCall = exports.epochAt = exports.epochLocations = exports.llmCallMountKeys = exports.recordingOf = void 0;
const foottrace_1 = require("foottrace");
const conventions_js_1 = require("../lib/conventions.js");
const keyedFold_js_1 = require("./keyedFold.js");
/**
* A snapshot from whatever the caller had: a runner (`Agent`, `LLMCall`), or
* the snapshot itself. Duck-typed on purpose — a recording read back from JSON
* is a plain object and must work exactly like a live one.
*/
function recordingOf(source) {
const runner = source;
const accessor = runner?.getLastSnapshot ?? runner?.getSnapshot;
if (typeof accessor === 'function') {
return accessor.call(source);
}
return source;
}
exports.recordingOf = recordingOf;
/**
* The `sf-llm-call` mount keys in `subflowResults`, in loop order.
*
* Non-empty ⇒ the run is GROUPED and every turn's pieces are in its own inner
* log. Empty ⇒ flat, and the run's own log holds everything.
*
* @example
* ```ts
* llmCallMountKeys(snapshot.subflowResults); // ['sf-llm-call#4', 'sf-llm-call#9']
* ```
*/
function llmCallMountKeys(subflowResults) {
if (!subflowResults)
return [];
return Object.keys(subflowResults)
.filter((k) => k.includes('#') && (0, foottrace_1.splitStageId)(k.split('#')[0]).localStageId === conventions_js_1.SUBFLOW_IDS.LLM_CALL)
.sort((a, b) => (0, foottrace_1.parseRuntimeStageId)(a).executionIndex - (0, foottrace_1.parseRuntimeStageId)(b).executionIndex);
}
exports.llmCallMountKeys = llmCallMountKeys;
/**
* One `sf-llm-call` mount's own subtree — its log AND the base that log folds
* against. The subtree, not the array, because the whole point of a location
* is that a read lands on the base as well as the diffs.
*/
function innerSubtreeOf(subflowResults, key) {
const entry = subflowResults[key];
const tree = entry?.treeContext;
return tree !== null && typeof tree === 'object' && Array.isArray(tree.history)
? tree
: undefined;
}
/**
* The FIRST commit of the `call-llm` stage in a log, or `-1`.
*
* First, not last, for the reason `commitStops` anchors there: one stage
* execution can flush more than one bundle under one `runtimeStageId`, and the
* call's own reads happened at the first.
*/
function callIndexIn(log) {
for (let i = 0; i < log.length; i++) {
const stageId = log[i]?.stageId;
if (stageId && (0, foottrace_1.splitStageId)(stageId).localStageId === conventions_js_1.STAGE_IDS.CALL_LLM)
return i;
}
return -1;
}
/** Located epochs, memoized on the recording object. */
const LOCATIONS = new WeakMap();
/**
* Every epoch in a recording, in run order — the ONE owner of the flat /
* grouped fork.
*
* Returns `[]` for a recording with no LLM call in it at all (an empty log, a
* chart with no `call-llm` stage). That is a different fact from "the epoch you
* asked for is not here", which is what {@link epochAt} says with `undefined`.
*
* MEMOIZED on the recording object, because the cost of locating epochs is a
* pass over the log and a per-epoch reader asks for one epoch at a time: before
* this, scrubbing a 600-turn run epoch by epoch relocated every epoch on every
* call and took 20.8 s. Holding the snapshot and asking it repeatedly is the
* shape a reader's UI actually has, so the memo is keyed on exactly that
* object; a caller that hands in a runner and gets a fresh snapshot each time
* pays the pass each time, which is the cost of asking a different question.
*
* FROZEN, because it is memoized: the array and each location on it are the
* very objects the next caller gets, so an edit here would be an edit to
* everybody's answer. The same law `keyedFold.ts` · `freezeDeep` states for a
* folded value. What is NOT frozen is what a location POINTS AT — `log`,
* `source`, `runSource` are the caller's own recording, and this file does not
* get to lock down an object it was merely handed.
*
* @example
* ```ts
* import { epochLocations } from 'agentfootprint';
*
* epochLocations(agent.getSnapshot()!).map((e) => [e.epoch, e.callRuntimeStageId]);
* // dynamic: [[1, 'call-llm#12'], [2, 'call-llm#31']]
* // dynamic-grouped: [[1, 'sf-llm-call/call-llm#9'], [2, 'sf-llm-call/call-llm#28']]
* ```
*/
function epochLocations(source) {
const recording = recordingOf(source);
if (recording !== null && typeof recording === 'object') {
const memo = LOCATIONS.get(recording);
if (memo !== undefined)
return memo;
}
const located = Object.freeze(locate(recording).map((e) => Object.freeze(e)));
if (recording !== null && typeof recording === 'object')
LOCATIONS.set(recording, located);
return located;
}
exports.epochLocations = epochLocations;
/** The uncached pass — one walk of the log, one keyed fold per source. */
function locate(recording) {
const runLog = (recording?.commitLog ?? []);
const runSource = recording ?? {};
const runFold = (0, keyedFold_js_1.keyedFold)(runSource);
const hasRunLog = runLog.length > 0;
const subflowResults = recording?.subflowResults;
const mounts = llmCallMountKeys(subflowResults);
if (mounts.length > 0 && subflowResults) {
const found = [];
for (const [ordinal, key] of mounts.entries()) {
const inner = innerSubtreeOf(subflowResults, key);
const log = (inner?.history ?? []);
const callIdx = callIndexIn(log);
if (inner === undefined || callIdx < 0)
continue;
const fold = (0, keyedFold_js_1.keyedFold)(inner);
const iteration = fold.valueAt('iteration', callIdx);
found.push({
epoch: typeof iteration === 'number' && Number.isFinite(iteration) ? iteration : ordinal + 1,
callRuntimeStageId: log[callIdx].runtimeStageId,
log,
callIdx,
runLog,
subflowScope: key,
source: inner,
runSource,
basis: fold.basis,
runBasis: runFold.basis,
hasRunLog,
});
}
return found;
}
// FLAT: every `call-llm` bundle in the run's own log, at its first commit.
const found = [];
const seen = new Set();
for (let i = 0; i < runLog.length; i++) {
const bundle = runLog[i];
if ((0, foottrace_1.splitStageId)(bundle.stageId).localStageId !== conventions_js_1.STAGE_IDS.CALL_LLM)
continue;
if (seen.has(bundle.runtimeStageId))
continue;
seen.add(bundle.runtimeStageId);
const iteration = runFold.valueAt('iteration', i);
found.push({
epoch: typeof iteration === 'number' && Number.isFinite(iteration) ? iteration : found.length + 1,
callRuntimeStageId: bundle.runtimeStageId,
log: runLog,
callIdx: i,
runLog,
source: runSource,
runSource,
basis: runFold.basis,
runBasis: runFold.basis,
hasRunLog,
});
}
return found;
}
/**
* One epoch by number, or `undefined` when the run has no such iteration.
*
* @example
* ```ts
* epochAt(agent.getSnapshot()!, 2)?.callRuntimeStageId; // 'call-llm#31'
* ```
*/
function epochAt(source, epoch) {
return epochLocations(source).find((e) => e.epoch === epoch);
}
exports.epochAt = epochAt;
/**
* The value of `key` as the epoch's call READ it — folded to just BEFORE the
* call's own bundle, so a key the call itself writes back (`history` gains the
* assistant turn; `receipt` is minted) reads as it was on the way in.
*
* Folded from the run's base, not from the log alone: on a resumed run the
* call read values the log never wrote, and reading those as absent is the
* fiction `keyedFold.ts` exists to end. A call that is its log's FIRST commit
* (a grouped turn, or the first stage after a resume) therefore folds to the
* base rather than to nothing.
*
* @example
* ```ts
* const epoch = epochAt(snapshot, 1)!;
* (readAtCall(epoch, 'history') as unknown[]).length; // the turns that went out
* ```
*/
function readAtCall(location, key) {
if (location.callIdx < 0)
return undefined;
return (0, keyedFold_js_1.keyedFold)(location.source).valueAt(key, location.callIdx - 1);
}
exports.readAtCall = readAtCall;
/**
* The value of `key` the epoch's call itself COMMITTED — folded through the
* call's own bundle. The receipt is read this way, and only the receipt: it is
* the one value the call writes that describes the call.
*/
function readAfterCall(location, key) {
if (location.callIdx < 0)
return undefined;
return (0, keyedFold_js_1.keyedFold)(location.source).valueAt(key, location.callIdx);
}
exports.readAfterCall = readAfterCall;
/**
* A RUN CONSTANT: a build-time fact `seed` put on the record once, read from
* the run's own log.
*
* It is read from the run log rather than the epoch's log because that is where
* `seed` runs in both chart shapes. Under `'dynamic-grouped'` the turn's inner
* log only ever holds what the boundary's `inputMapper` carried in, and a
* constant nobody inside the turn reads is not carried — so asking the inner
* log would answer "absent" for a fact the run plainly recorded.
*
* `undefined` has TWO causes and a caller must not blur them: the run never
* recorded this constant, or this recording carries no run log to read
* (`EpochLocation.hasRunLog` is `false` — a subtree handed in on its own).
* `servedView.ts` branches on that flag and declares a gap for the second.
*/
function readRunConstant(location, key) {
if (!location.hasRunLog)
return undefined;
return (0, keyedFold_js_1.keyedFold)(location.runSource).valueAt(key, location.runLog.length - 1);
}
exports.readRunConstant = readRunConstant;
//# sourceMappingURL=epochs.js.map