UNPKG

agentfootprint

Version:

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

160 lines • 7.86 kB
/** * hosting/durability — how often a run's progress becomes crash-survivable. * * A standing agent that only writes at the end of a turn is one restart away * from losing everything the turn had done: the tool calls it made, the results * it read, the iterations it spent. This is the dial that decides how much of * that survives, and what it costs. * * ── Where a mid-run write can honestly come from ───────────────────────────── * From the COMMIT boundary, and nowhere else. footprintjs commits a stage's * writes after the stage function returns, and committed state is * immutable-after-swap — so `ScopeRecorder.onCommit` is the one moment where * "what the run has agreed on so far" is a real, complete, consistent thing. * Reading the agent's live state at any other moment would be reading a stage's * work in progress. * * There is no mid-run engine checkpoint to store: footprintjs builds a * `FlowchartCheckpoint` only at a pause (`getCheckpoint()` is documented as "the * most recent PAUSED execution"). So what a mid-run write can carry is a * CONVERSATION — the same `AgentRunCheckpoint` every finished turn stores — and * that is exactly enough, because that is what the next turn resumes from. * * ── Why it writes on SOME commits and not all of them ──────────────────────── * A two-iteration turn commits about forty times. Exactly two of those commits * change the conversation: `Initialize` (the user's message lands) and * `ToolCalls` (an iteration's assistant turn and its tool results land). Every * other commit would store bytes identical to the last write. So the trigger is * "this commit wrote `history`", which is not an optimisation but the honest * reading of the question: the conversation moved iff `history` moved. * * ── What a commit boundary actually guarantees, and what it does not ───────── * It guarantees the whole stage. The agent dispatches ALL of one iteration's * tool calls inside one stage body, so a crash part-way through that body stores * nothing from it and a replay re-runs that iteration's tools. That is the * shipped idempotency requirement, unchanged — mutating tools must be * idempotent, keyed on stable call content rather than `ctx.toolCallId`. What * `'sync'` adds is a BOUND on it: iteration N's tools do not start until * iteration N-1's write has landed, so the replay is the current iteration and * never an earlier one. * * Pattern: an observer (`CombinedRecorder`) for the snapshot, a serialiser for * the writes, and — for `'sync'` only — a barrier the tool dispatch waits on. * Role: internal to `standingAgent`. Deliberately not exported: it is how the * composer keeps its promise, not a second way to write to a store. */ import { installDurabilityBarrier } from '../core/durabilityBarrier.js'; /** * Build the writer. * * Under `'sync'` it also answers the tool-dispatch barrier, which is what turns * "we write often" into a bound on how much can re-run. */ export function durableWriter(options) { const { mode, session, runId, state, write } = options; /** The write currently on the wire, or `undefined` when nothing is. */ let inFlight; /** * The newest snapshot that has not been started yet. At most ONE, and a newer * one replaces it: the conversation only grows, so a superseded snapshot is a * prefix of the one replacing it and writing it first would buy nothing. */ let queued; /** * Why the NEWEST write did not land, or `undefined` when it did. Cleared by a * write that succeeds — a store that failed once and then took the newer * state has made that state durable, and reporting the older failure would be * describing a problem that no longer exists. */ let failure; function pump() { if (inFlight !== undefined || queued === undefined) return; const next = queued; queued = undefined; inFlight = write(next.sessionId, next.conversation) .then(() => { failure = undefined; }, (err) => { failure = new Error(`[hosting] the session store did not accept this run's progress, so nothing ` + `after this point may proceed as if it had` + (mode === 'sync' ? ` — the next tool call was not allowed to run` : '') + `. Underlying error: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); }) .then(() => { inFlight = undefined; pump(); }); } function enqueue(sessionId, history, userMessage, iteration) { queued = { sessionId, conversation: { version: 1, runId: runId() ?? 'unknown', // Read from the run's live committed state. Clone on the way to a // store so nothing a persistence layer does can reach back into the // run's own state. history: structuredClone(history), lastCompletedIteration: iteration, originalInput: { message: userMessage }, checkpointedAt: Date.now(), }, }; pump(); } const recorder = { id: 'af-hosting-durability', // INLINE, always. A write delivered one beat behind is a write that can be // lost by the very crash it exists to survive — and under `'sync'` the // barrier would be waiting on a snapshot the queue had not handed over yet. // The causal-evidence bridge and the compaction meter are inline for the // same class of reason. delivery: 'inline', // The conversation moved iff this commit wrote `history`. The commit event // says WHICH keys moved — a key's NAME is never redacted — and the run's // live state says what they now hold: real values, the conversation the // next turn resumes from. The event's own values are the record's form, // which an agent's `redact` may have turned into placeholders. onCommit(event) { if (!event.mutations.some((mutation) => mutation.key === 'history')) return; const sessionId = session(); if (sessionId === undefined) return; const live = state(); const history = live?.['history']; if (!Array.isArray(history)) return; const userMessage = live?.['userMessage']; const iteration = live?.['iteration']; enqueue(sessionId, history, typeof userMessage === 'string' ? userMessage : '', typeof iteration === 'number' ? iteration : 0); }, }; const settle = async () => { // Loop rather than await once: a write that completes may release a queued // successor, and "settled" has to mean nothing is left. while (inFlight !== undefined || queued !== undefined) { pump(); await inFlight; } if (failure) throw failure; }; return { recorder, install(agent) { // `'async'` deliberately takes NO barrier: its whole promise is that the // run does not wait, and a barrier would be that promise broken quietly. if (mode !== 'sync') return () => undefined; return installDurabilityBarrier(agent, () => { if (inFlight === undefined && queued === undefined && failure === undefined) return undefined; return settle(); }); }, settle, }; } //# sourceMappingURL=durability.js.map