UNPKG

agentfootprint

Version:

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

289 lines • 13.8 kB
"use strict"; /** * keyedFold — the value of ONE state key at one commit, folded from the run's * own fold base. * * Role: Fold. It reads a finished run's commit log plus the base that log * was recorded against, and answers "what was `key` at commit `idx`?" * It executes nothing, records nothing and stores nothing beside the * log. Its answers are DETACHED — deep-frozen before they are cached, * because a memo that hands out live references is a fold that lets a * reader rewrite the run (`keyedFold.ts` · `freezeDeep`). * Reads: a `FoldSource` — a snapshot (`commitLog` + `initialState`) or a * subflow subtree (`history` + `initialState`), through footprintjs's * own `stateAt` (for the base) and `applySmartMerge` (for the replay). * Emits: N/A. * * ── WHY THIS FILE EXISTS ─────────────────────────────────────────────────── * A commit log stores DIFFS. `commitValueAt` folds them for one key and says * so in its own docstring: a value that came from the run's INITIAL state and * was never `set` again folds to ABSENT, because the log alone cannot see the * pre-run base. * * That is not an edge case here. A RESUMED agent run is a fresh executor * seeded from `checkpoint.sharedState`, so the entire pre-pause world — * `systemPromptInjections`, `history`, `dynamicToolSchemas`, every build-time * constant `seed` wrote before the pause — is base, not log. Folding the * resumed run with `commitValueAt` returns an empty system prompt and a * truncated conversation and declares no gap: a confident falsehood about the * one question this family exists to answer. footprintjs 9.17 shipped * `RuntimeSnapshot.initialState` and `stateAt` for exactly this, and every * read in `epochs.ts` goes through here so it lands on that base. * * ── WHY NOT JUST CALL `stateAt` PER READ ─────────────────────────────────── * `stateAt` folds the WHOLE state, and it clones that state once per bundle * (`applySmartMerge` opens with a `structuredClone` of its base). One fold is * therefore O(commits × state size) — measured on a real 100-turn agent * (1,719 commits, 101 epochs): 323 ms for a single fold, and 11.7 s to fold * once per epoch, against 78 ms for the whole per-epoch scrub before this * release. A per-epoch reader cannot pay that. * * So `stateAt` is used for the ONE thing only it can give — the fold base and * the honest `basis` verdict, at `commitIdx: -1`, where it folds nothing and * clones the base once — and the forward replay is done per key, through * footprintjs's own `applySmartMerge`. There is deliberately no second * implementation of the trace verbs here: every `set` / `append` / `merge` / * `delete` is applied by the same function the live commit uses. * `keyed-fold-equivalence.test.ts` pins the result against `stateAt` itself on * real runs, including a resumed one and a merge with no `set` anchor — the * one shape that cannot be folded at all without the base. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.keyedFold = void 0; const foottrace_1 = require("foottrace"); /** * The delimiter footprintjs joins nested trace paths with — the engine's own * `DELIM`. Mirrored rather than imported because it is not on any public * barrel. * * Today's scope facade records a nested write (`scope.profile.city = …`) as a * MERGE on the top-level key rather than as a delimited path, so this is * DEFENCE and not a hot path. It is still load-bearing: a delimited path must * be attributed to its ROOT key, or a write to `history<DELIM>0<DELIM>content` * would be filed under a key nobody asks for and dropped from every fold. */ const PATH_DELIM = '\u001F'; /** The top-level state key a trace path belongs to. */ function rootKeyOf(path) { const cut = path.indexOf(PATH_DELIM); return cut < 0 ? path : path.slice(0, cut); } /** * Deep-freeze in place, skipping subtrees that are frozen already. * * ── A FOLD RESULT IS DETACHED, OR IT IS NOT A FOLD ───────────────────────── * This module memoizes: `valueAt` hands the SAME object back on every call for * the same question, and the forward cursor seeds the replay of every LATER * index from that very object. Handing out a live reference therefore does not * merely risk a surprise — a consumer that edits what it was handed silently * rewrites what every later epoch reports was served, which is the one thing * this family exists to be trusted about. * * It was not a hazard in the reader this module replaced. footprintjs's * `commitValueAt` clones per call, so the same edit was harmless there; the * memo is what introduced it, so the memo is where it is closed. * * FREEZING RATHER THAN CLONING PER READ, for two reasons. Cloning per read is * exactly the cost the forward cursor exists to avoid — it puts an O(value) * copy back on every question, on the hot path of a scrub. And freezing is the * answer footprintjs itself already gives: `stateAt` returns * `freezeDeep(structuredClone(out))`, so a caller that moves between the two * folds meets one contract instead of two. * * The forward replay is unaffected: `applySmartMerge` opens with a * `structuredClone` of its base, and a clone of a frozen value is not frozen. * * MEASURED, so the choice is not a guess, and reported because it is not free. * On a 601-epoch run (10,219 commits) the whole per-epoch scrub — every * epoch's system pieces, conversation and tool list, folded and rebuilt — * costs 1,141 ms with the freeze and 930 ms without it: +211 ms, +23%, on a * scrub that cost 20.8 s two fixes ago. The batch form moves 919 ms to * 1,116 ms. The alternative is DEARER, not cheaper: on the same structure * (1,200 messages) `structuredClone` costs 3.6x what this walk does — 0.94 ms * against 0.26 ms — and a copy would have to run once per READ where the * freeze runs once per memoized answer. A mutable answer is not worth 23%, and * buying safety by copying would cost more than 23%. */ function freezeDeep(value) { if (value === null || typeof value !== 'object') return value; Object.freeze(value); for (const key of Object.getOwnPropertyNames(value)) { const inner = value[key]; if (inner !== null && typeof inner === 'object' && !Object.isFrozen(inner)) freezeDeep(inner); } return value; } /** The bundles, from whichever field this source spells them in. */ function commitsOf(source) { if (!source) return []; if (Array.isArray(source.commitLog)) return source.commitLog; if (Array.isArray(source.history)) return source.history; return []; } /** Build the per-key touch index in ONE pass over the log. */ function indexTouches(log) { const index = new Map(); for (let at = 0; at < log.length; at++) { const bundle = log[at]; if (!bundle) continue; for (const entry of bundle.trace ?? []) { const path = entry.path; if (typeof path !== 'string') continue; const key = rootKeyOf(path); const verb = entry.verb; const touch = { at, bundle, entry, anchors: path === key && (verb === 'set' || verb === 'delete'), }; const list = index.get(key); if (list === undefined) index.set(key, [touch]); else list.push(touch); } } return index; } /** The last position in `touches` whose bundle index is `<= idx`, or `-1`. */ function lastTouchAt(touches, idx) { let lo = 0; let hi = touches.length - 1; let found = -1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (touches[mid].at <= idx) { found = mid; lo = mid + 1; } else { hi = mid - 1; } } return found; } /** Memoized folds, one per source object. */ const FOLDS = new WeakMap(); /** * The keyed fold for one source, memoized on the source object. * * Memoized because a per-epoch reader asks the same source for the same keys * at many different commits, and the index is the expensive half: building it * is one pass over the log, and answering a read afterwards is a binary search * plus a replay from the nearest full-value write (normally exactly one). * * @example * ```ts * import { keyedFold } from 'agentfootprint'; * * const fold = keyedFold(agent.getSnapshot()); * fold.basis; // 'initial+log' — the base travelled * fold.valueAt('history', 12); // the conversation after the 13th commit * fold.valueAt('history', -1); // …and what the run STARTED from * ``` */ function keyedFold(source) { if (source !== null && typeof source === 'object') { const memo = FOLDS.get(source); if (memo !== undefined) return memo; } const log = commitsOf(source); // `stateAt` at -1 folds NOTHING: it clones the base once and reports whether // there was one. That is the whole reason to call the engine's own fold here // rather than read `initialState` directly — the `basis` verdict is its // answer to give, and a recording that travelled without its base must be // able to say so. const seed = (0, foottrace_1.stateAt)(source, -1); const index = indexTouches(log); const answers = new Map(); /** Per key, the furthest replay position reached and the value there — see * the forward-cursor note in `compute`. */ const cursor = new Map(); /** One touch applied, through the ENGINE's verb switch, over a state * projected to this key alone. `applySmartMerge` navigates from the root, * so a delimited path lands exactly where it did in the real fold. */ const step = (key, value, touch) => (0, foottrace_1.applySmartMerge)({ [key]: value }, touch.bundle.updates, touch.bundle.overwrite, [ touch.entry, ])[key]; const compute = (key, idx) => { const touches = index.get(key); const last = touches === undefined ? -1 : lastTouchAt(touches, idx); // The base's own value. `stateAt` already deep-freezes what it returns, so // this is a no-op today — asserted rather than assumed, because the law // this module states about its answers must not rest on another module // continuing to keep it. if (touches === undefined || last < 0) return freezeDeep(seed.state[key]); // Anchor at the latest write that fully determines the key on its own; // everything before it — the base included — cannot be read back through // it. With no anchor the base IS the starting value, which is the whole // fix: `commitValueAt` starts from `undefined` here and folds a fiction. let start = 0; let anchored = false; for (let i = last; i >= 0; i--) { if (touches[i].anchors) { start = i; anchored = true; break; } } let from = start; let value = anchored ? undefined : seed.state[key]; // THE FORWARD CURSOR, and why this module would be slower than the reader // it replaces without it. An agent's `history` is `set` ONCE and appended // to every turn, so a query at turn k has to replay k appends from the // anchor — and a per-epoch scrub asks E times, which is O(E²) replays of a // growing array. Resuming from the furthest position already reached makes // the whole scrub O(E) replays instead, because a scrub walks forward. // (Measured on a 96-turn run: 201 ms per scrub without this, 40 ms with.) // A backward or out-of-order query simply falls back to the anchor, which // is correct and no worse than never having cached anything. const held = cursor.get(key); if (held !== undefined && held.position >= start && held.position <= last) { from = held.position + 1; value = held.value; } for (let i = from; i <= last; i++) value = step(key, value, touches[i]); // Frozen HERE, before anything else can hold it: the cursor keeps this very // object as the seed for every later index, and `valueAt` memoizes it. One // freeze covers both — `keyedFold.ts` · `freezeDeep`. freezeDeep(value); cursor.set(key, { position: last, value }); return value; }; const fold = { basis: seed.basis, length: log.length, valueAt(key, idx) { const at = Math.min(Math.floor(idx), log.length - 1); let perKey = answers.get(key); if (perKey === undefined) { perKey = new Map(); answers.set(key, perKey); } if (perKey.has(at)) return perKey.get(at); const value = compute(key, at); perKey.set(at, value); return value; }, }; // The fold OBJECT is memoized too — the same one every caller of `keyedFold` // gets for this source — so it is frozen for the same reason its answers are. Object.freeze(fold); if (source !== null && typeof source === 'object') FOLDS.set(source, fold); return fold; } exports.keyedFold = keyedFold; //# sourceMappingURL=keyedFold.js.map