agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
102 lines (101 loc) • 5.66 kB
TypeScript
/**
* 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 type { CombinedRecorder } from 'footprintjs';
import type { AgentRunCheckpoint } from '../core/runCheckpoint.js';
import type { DurabilityMode } from './types.js';
/** What the writer needs from the composer around it. */
export interface DurableWriterOptions {
/** `'async'` or `'sync'`. `'exit'` never builds a writer at all. */
readonly mode: Exclude<DurabilityMode, 'exit'>;
/**
* The session this run belongs to, asked at every commit. `undefined` means
* "nothing to write to" — an anonymous request, or no run of ours in flight —
* and the commit is ignored.
*/
readonly session: () => string | undefined;
/** The run id to stamp on the conversation, for correlating back to the run. */
readonly runId: () => string | undefined;
/**
* The run's committed root state, live (`core/runnerLive.ts` · `RunnerLive`). The
* conversation is READ here, at the commit that moved it — never off the
* commit event, whose values are the RECORD's form: under an agent's
* `redact` footprintjs serves a selected key as a placeholder there, and a
* store that resumed from those would continue a conversation of
* placeholders.
*/
readonly state: () => Readonly<Record<string, unknown>> | undefined;
/** Where the conversation goes. */
readonly write: (sessionId: string, conversation: AgentRunCheckpoint) => Promise<void>;
}
/** The composer's handle on its own durability. */
export interface DurableWriter {
/** Attach this to the agent to start observing commits. */
readonly recorder: CombinedRecorder;
/** Install the tool-dispatch barrier. Returns the uninstall function. */
install(agent: object): () => void;
/**
* Everything outstanding has landed.
*
* The composer awaits this before writing a run's FINAL envelope, and that
* ordering is load-bearing rather than tidy: an `'async'` conversation write
* still in flight would otherwise land AFTER the terminal envelope and
* overwrite it — turning a stored pause back into a plain conversation and
* losing the question a person was asked.
*
* **Rejects when the newest write did not land.** Fail-closed on purpose: a
* store that refused the run's progress has not made it durable, and both
* things waiting on this — the next tool call under `'sync'`, and the reply —
* would otherwise proceed on a promise nobody kept.
*/
settle(): Promise<void>;
}
/**
* 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 declare function durableWriter(options: DurableWriterOptions): DurableWriter;