agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
164 lines • 8.03 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.durableWriter = void 0;
const durabilityBarrier_js_1 = require("../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.
*/
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 (0, durabilityBarrier_js_1.installDurabilityBarrier)(agent, () => {
if (inFlight === undefined && queued === undefined && failure === undefined)
return undefined;
return settle();
});
},
settle,
};
}
exports.durableWriter = durableWriter;
//# sourceMappingURL=durability.js.map