agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
473 lines (472 loc) • 23.5 kB
TypeScript
/**
* runCheckpoint — fault-tolerant resume primitives.
*
* Today's pause/resume only handles INTENTIONAL pauses (`askHuman`).
* Errors mid-run (LLM 500s, vendor outages, tool throws, container
* restarts) propagate all the way up and the consumer must restart
* from scratch — losing the prior iterations' work.
*
* This module adds the third piece of the Reliability subsystem:
*
* 1. **`AgentRunCheckpoint`** — JSON-serializable snapshot of an
* agent run's progress. Captured automatically at each
* iteration boundary (the natural commit points). Survives
* process restart — persist to Redis / Postgres / S3 / queue.
*
* 2. **`RunCheckpointError`** — wraps the underlying error with
* the last-known-good checkpoint. Throwing this instead of the
* raw error lets consumers catch + persist + resume later
* without losing context.
*
* 3. **`agent.resumeOnError(checkpoint)`** — replays the agent run
* with the checkpointed conversation history restored. The
* next iteration retries the call that originally failed (with
* the latest provider state — circuit breaker may have closed,
* vendor may have recovered, etc.).
*
* Design tradeoff: we use a CONVERSATION-HISTORY checkpoint shape
* rather than a full executor-state checkpoint (which would require
* footprintjs API surface changes for mid-run snapshotting). The
* tradeoff:
*
* ✅ Survives process restart (JSON-serializable, tiny payload)
* ✅ Works with any LLM provider — replay starts from history
* ✅ No footprintjs core changes
* ⚠️ Loses mid-iteration partial state (acceptable — iterations
* are atomic; we resume from the last completed boundary)
* ⚠️ TOOL RE-EXECUTION (idempotency requirement): anything the
* failed iteration did after the last completed boundary —
* including tool side effects — is NOT in the checkpoint. On
* resume the model re-decides from the restored history and may
* re-issue those tool calls; they WILL execute again. There is
* NO built-in toolCallId-based dedup. Mutating tools (payments,
* emails, DB writes) must be idempotent — derive an idempotency
* key from stable call content, not from `ctx.toolCallId` (fresh
* per issued call, so a re-issued call gets a NEW id). Note the
* same requirement exists WITHOUT resume: a tool that performs
* its side effect and then throws reports the error message back
* to the model as the tool result, and the model typically
* retries the call on the next iteration.
*
* Pattern: Memento (GoF) — snapshot of an object's internal state
* for later restoration. Same shape as `FlowchartCheckpoint`
* but at the agent layer (one logical iteration vs. one
* DFS stage).
*/
import type { LLMMessage } from '../provider-contract/types.js';
import type { ReplayPreparation } from '../thinking/replay.js';
import { type ReplayRetirement } from '../thinking/replayHistory.js';
import type { MemoryIdentity } from '../memory/identity/types.js';
import type { FoldedSpan } from './agent/window/types.js';
import { type FindingsLedger } from './agent/findings/types.js';
import { type NegativeCheckpoint } from './agent/negativeClaims/checkpoint.js';
import type { ClaimVerificationState } from './agent/findings/verificationState.js';
/** Internal repair state kept separately from the human conversation.
* Restored only when retrying the same failed request, never on a new turn. */
export interface EvidenceRecoveryCheckpoint {
readonly revisionSpent: true;
/** Present only when the request carrying this instruction did not complete. */
readonly pending?: {
readonly instruction: string;
/** The figures dial's late line, when the pending revision has one. */
readonly conclusion?: string;
};
}
/**
* JSON-serializable checkpoint of an in-progress agent run. Persist
* to ANY durable store (Redis / Postgres / S3 / disk / queue) and
* resume hours / days / deploys later via `agent.resumeOnError(...)`.
*
* **Stable shape** — the `version` field guards forward compat. v1
* → v2 transitions will be supported via a migration helper.
*/
export interface AgentRunCheckpoint {
/** Private admitted negative evidence; keep checkpoints on the trusted host. */
readonly negativeClaimState?: NegativeCheckpoint['negativeClaimState'];
readonly resultIdentities?: NegativeCheckpoint['resultIdentities'];
/** Schema version. v1 = conversation-history-based. */
readonly version: 1;
/** `runId` of the FAILING run — lets the consumer correlate a
* persisted checkpoint back to the original run's observability.
* NOT reused on resume: `resumeOnError` starts a fresh run with a
* fresh `runId` (only the conversation history is restored). */
readonly runId: string;
/** Conversation history at the LAST completed iteration boundary
* (LLM messages). The next iteration retries from here. */
readonly history: readonly LLMMessage[];
/** Index of the last completed iteration in the FAILING run
* (diagnostic — not consumed on resume). The resumed run restores
* this history but re-seeds its own iteration counter at 1 with a
* full `maxIterations` budget. */
readonly lastCompletedIteration: number;
/** Original input message. Surfaces in observability + lets the
* consumer correlate checkpoint to the user's request. */
readonly originalInput: {
readonly message: string;
};
/** Wall-clock when the checkpoint was captured. Diagnostic only. */
readonly checkpointedAt: number;
/**
* The same request's bounded evidence repair. `resumeOnError` restores this
* budget and any unsent feedback; `run({ continueFrom })` adds a new human
* turn and deliberately starts a fresh repair budget. Not conversation text.
* Absent for checkpoints written before request-scoped recovery.
*/
readonly evidenceRecovery?: EvidenceRecoveryCheckpoint;
/**
* Every span this conversation folded into a summary, oldest first — what
* makes a compacted conversation still a provable one after the process
* that compacted it is gone.
*
* Written by `.compaction()`; absent on a conversation that never folded,
* and absent on one stored by a runtime older than 8.2. Under the default
* `retain: 'conversation'` each span carries the folded messages verbatim;
* under `retain: 'discard'` the span is still here, naming what left, and
* only `messages` is absent.
*
* Join a summary in {@link history} to its span with `foldedSpanFor(...)` —
* by content fingerprint, never by index, because a later fold moves every
* index after it.
*
* **Version 1 still, deliberately.** An optional field is not a format
* change: a runtime that has never heard of `folded` reads this checkpoint,
* ignores it, and continues the conversation correctly — the summary is an
* ordinary message in `history` either way. Bumping the version would make
* an older deployment REFUSE a session it can serve perfectly well, which is
* the opposite of what the version field is for.
*/
readonly folded?: readonly FoldedSpan[];
/**
* The model's standings on its tool results (9.101.0) — the committed
* `AgentState.findingsLedger`, carried so a continued conversation keeps
* what the model already called a fact, left open, ruled out or dismissed,
* instead of re-deriving it from results the store no longer serves whole.
*
* Written only when the run's ledger is non-empty; absent on every
* conversation whose agent never called `.findings()` and on any stored by
* a runtime older than 9.101.0 — the {@link folded} rule: an optional field
* is not a format change, and a runtime that has never heard of this key
* reads the checkpoint, ignores it, and continues correctly. The rows are
* plain records and arrays only (the shape `recordFindings` writes), so
* they round-trip through JSON and `structuredClone` unchanged.
*
* **Version 1 still**, by the same documented rule as {@link folded} /
* {@link skillCursor}.
*/
readonly findingsLedger?: FindingsLedger;
/**
* Private, opt-in exact evidence and reservation counters. Unlike historical
* observations, this carrier can support later checks. A newest-version
* continuation must explicitly keep verification enabled, never discard it.
* Storage ownership/authenticity remains the checkpoint caller's obligation.
*/
readonly claimVerification?: ClaimVerificationState;
/**
* The conversation TURN the stored history ends on — the stored run's
* `AgentState.turnNumber` — carried while an honesty layer is armed.
*
* Every ledger row filed while a layer is armed carries its `turn`, and the
* answer's standing reads "this turn" by that stamp, so a stamp must never
* repeat in one conversation. The count of user messages in `history` cannot
* promise that: a window strategy or a compaction trims the stored history,
* and the count repeats and goes backwards — an earlier turn's verdict would
* then be folded as a later turn's. So the next run starts from this number:
* `run({ continueFrom })` is the turn after it, `resumeOnError` the same
* turn again. Written by both carriers (`checkpoint()` and the crash
* checkpoint), and only while a layer is armed — every other checkpoint
* keeps its exact byte shape.
*
* **Version 1 still**, by the same documented rule as {@link folded} /
* {@link findingsLedger}: an optional field is not a format change, and a
* runtime that has never heard of it continues the conversation correctly.
*/
readonly turnNumber?: number;
/**
* WHO this conversation belongs to — the `identity` the stored run was
* given, carried so that continuing it lands in the same namespace it
* started in (9.2.0).
*
* Before this field, continuing a conversation re-seeded identity from the
* resuming run's own id: every continued turn wrote its memory under a
* FRESH `conversationId`, so turn two's facts were stored somewhere turn
* three could not read them. Nothing threw, and the only symptom was an
* agent that kept forgetting — the same class of failure as a store that
* silently forgot everything looking exactly like a new user.
*
* Absent on a conversation stored before 9.2.0, and absent when the run
* never got an explicit identity (the default is derived from a runId, and
* carrying THAT forward would pin a whole conversation to one run's id).
* An explicit `identity` on the continuing call always wins.
*
* **Version 1 still**, on the same reasoning as {@link folded}: an optional
* field is not a format change, and a runtime that has never heard of it
* continues the conversation correctly.
*/
readonly identity?: MemoryIdentity;
/**
* WHERE the conversation's skill graph stood when the stored turn ended —
* the graph cursor, carried so a continued conversation can default its
* next turn's start to it (SG-C, 9.17.0).
*
* Written ONLY when the recording agent's graph declared
* `continuity: 'conversation'`; under the default `'turn'` the field is
* never written and the byte shape of every checkpoint is unchanged. On
* the continuing side it is honored only under the same declaration, and
* the RouteTurn cascade still judges it against the new message — an
* inherited cursor is a sticky DEFAULT, not a lock. An id the continuing
* graph does not know is dropped and recorded
* (`turn_routed.droppedResume`), never silently parked.
*
* **Version 1 still**, by the same documented rule as {@link folded} /
* {@link identity} / {@link agent}: an optional field is not a format
* change — an older runtime ignores it and continues correctly.
*/
readonly skillCursor?: string;
/**
* WHICH agent recorded this conversation — present only when that agent was
* given an explicit `Agent.create({ id })` (9.2.0).
*
* A conversation is a transcript, and a transcript can be replayed on any
* agent. Usually that is the point: a deploy that adds a tool or edits a
* prompt must still be able to continue yesterday's conversations, so the
* runtime cannot refuse on "the agent changed". But replaying the BILLING
* agent's conversation on the SUPPORT agent is a different mistake, and it
* used to be accepted in silence.
*
* The rule is the one the embedder fingerprint already uses: **ids decide
* only when BOTH sides named themselves.** A default id (`'agent'`) is not
* naming yourself, so the majority of callers — who never pass one — are
* never refused. Two sides that both chose a name and chose different ones
* are refused, by name.
*
* Version 1 still, for the same reason as the two fields above.
*/
readonly agent?: {
readonly id: string;
};
/** Where the failure happened. Diagnostic — surfaces in oncall
* triage so you can tell "LLM 500 mid-iteration" from "tool
* threw" from "validation kept failing". */
readonly failurePoint?: {
readonly iteration: number;
readonly phase: 'iteration' | 'tool' | 'llm' | 'unknown';
/**
* What was OPEN when it threw — `'call-llm'` for the model call, or the
* declared name of the tool that was running (8.14.0).
*
* Absent when nothing was open (a failure between brackets), which is the
* honest answer rather than a guess.
*
* **Never a URL, never a credential, never request or response content.**
* Only the literal string `'call-llm'` or a tool name the app itself
* declared. A checkpoint is persisted to Redis / Postgres / S3 and read by
* whoever is on call; nothing that could carry a secret goes in it. Do not
* "improve" this field into carrying the endpoint.
*/
readonly stage?: string;
};
}
/**
* Thrown by `agent.run()` when a fault occurs mid-run. Carries the
* underlying error AND the last-known-good checkpoint. Catch this
* specifically to engage the resume-on-error path; let other errors
* propagate normally.
*
* @example
* ```ts
* import { Agent, RunCheckpointError } from 'agentfootprint';
*
* try {
* const result = await agent.run({ message: 'long task' });
* } catch (err) {
* if (err instanceof RunCheckpointError) {
* await checkpointStore.put(sessionId, err.checkpoint);
* // hours / restart later:
* const checkpoint = await checkpointStore.get(sessionId);
* const result = await agent.resumeOnError(checkpoint);
* } else {
* throw err; // not a recoverable error — propagate
* }
* }
* ```
*/
export declare class RunCheckpointError extends Error {
readonly code: "ERR_RUN_CHECKPOINT";
/** The error that triggered the checkpoint. Inspect for retry
* decisions ("if cause is CircuitOpenError, wait for cooldown
* before resuming"). */
readonly cause: Error;
/** The last-known-good checkpoint. Persist + pass back to
* `agent.resumeOnError(checkpoint)` to continue from here. */
readonly checkpoint: AgentRunCheckpoint;
constructor(cause: Error, checkpoint: AgentRunCheckpoint);
}
/**
* Can `agent.resumeOnError(checkpoint)` plausibly succeed for this failure?
*
* `true` for the transient classes resume exists for; `false` for the ones
* where replaying the same request reproduces the same refusal (a request too
* large for the context window, rejected credentials, a malformed request).
* Branch on this instead of reading the message — a retry loop that resumes a
* non-resumable failure is an expensive infinite loop.
*
* @example
* ```ts
* import { canResume, RunCheckpointError } from 'agentfootprint';
*
* try {
* await agent.run({ message });
* } catch (err) {
* if (err instanceof RunCheckpointError && canResume(err.cause)) {
* await agent.resumeOnError(err.checkpoint);
* } else {
* throw err; // fix the request; the checkpoint is evidence, not a retry handle
* }
* }
* ```
*/
export declare function canResume(error: unknown): boolean;
/**
* Thrown when a stored conversation is handed to an agent that is provably
* not the one that recorded it (9.2.0).
*
* Raised only when BOTH sides named themselves with an explicit
* `Agent.create({ id })` and the two names differ — see
* {@link AgentRunCheckpoint.agent} for why that is the whole of the rule.
* An agent that gained a tool, changed its prompt or moved to a new model
* since the conversation was stored is NOT this error; continuing across a
* deploy is the ordinary case and must keep working.
*/
export declare class ConversationMismatchError extends Error {
readonly code: "ERR_CONVERSATION_MISMATCH";
/** The id stamped on the stored conversation. */
readonly storedAgentId: string;
/** The id of the agent it was handed to. */
readonly agentId: string;
constructor(door: string, storedAgentId: string, agentId: string);
}
/**
* Refuse a stored conversation that provably belongs to a different agent.
*
* Both-sides-named-themselves, or nothing happens. `storedId` is absent on
* every conversation written before 9.2.0 and on every agent that never chose
* an id; `agentId` is undefined for the same reason on the reading side.
*
* @internal
*/
export declare function assertContinuable(checkpoint: AgentRunCheckpoint, agentId: string | undefined, door: string): void;
/**
* Mutable state the Agent maintains during a run for checkpoint
* capture. Keyed by `runId` so multiple in-flight runs don't
* collide. Cleared on `turn_end` (success path).
*
* @internal
*/
export interface RunCheckpointTracker {
readonly runId: string;
readonly originalInput: {
readonly message: string;
};
/** Updated on every `agentfootprint.agent.iteration_end`. */
history: readonly LLMMessage[];
/** Explicit prepared thinking edits only; no failed-iteration tool/output state. */
replayRetirements?: readonly ReplayRetirement[];
/** Updated on every `agentfootprint.agent.iteration_end`. */
lastCompletedIteration: number;
/** Set when an iteration begins (used to attribute the failure
* phase if we throw before the next iteration_end). */
inFlightIteration?: number;
/**
* What is OPEN right now, from the run's own `stream.*` brackets: set on
* `llm_start` / `tool_start`, cleared on `llm_end` / `tool_end` (8.14.0).
*
* OBSERVATION, which is why it exists. {@link classifyFailurePhase} guesses
* from the error's own name and message and is wrong for every failure that
* does not describe itself — a browser fetch failure says only
* `TypeError: Load failed`. This says what the engine was doing, and only
* falls back to the guess when nothing was open.
*
* `stage` holds `'call-llm'` or a declared tool name. Never a URL.
*/
inFlightPhase?: {
readonly phase: 'llm' | 'tool';
readonly stage: string;
};
}
/** A transport preparation is durable even if its request subsequently fails. */
export declare function retainReplayRetirements(tracker: RunCheckpointTracker, preparation: ReplayPreparation): void;
/** Read only approved replay edits onto the history each checkpoint already owns. */
export declare function checkpointReplayHistory(history: readonly LLMMessage[], tracker?: RunCheckpointTracker): readonly LLMMessage[];
/**
* Build a JSON-serializable checkpoint from a tracker + failure
* info. Pure function — no side effects.
*
* @internal
*/
export declare function buildCheckpoint(tracker: RunCheckpointTracker, failurePoint?: NonNullable<AgentRunCheckpoint['failurePoint']>,
/**
* Spans this run folded, read from committed state by the caller. Passed in
* rather than tracked, because the tracker follows `history` through events
* and a fold's span is committed state — and because the same reader then
* serves both checkpoint carriers, so neither can lose what the other keeps.
*/
folded?: readonly FoldedSpan[],
/**
* Who the run was for and which agent ran it — the two fields that make a
* stored conversation continuable rather than merely readable (9.2.0).
* Both are absent unless the caller chose them explicitly; see
* {@link AgentRunCheckpoint.identity} and {@link AgentRunCheckpoint.agent}.
*/
owner?: {
readonly identity?: MemoryIdentity;
readonly agentId?: string;
},
/**
* The graph cursor at the last committed state (SG-C) — read from the same
* snapshot `folded` comes from, by the same one-reader-two-carriers rule:
* `Agent.continuityCursorOf` serves BOTH `checkpoint()` and this crash
* carrier, so a conversation cannot keep its place on one path and silently
* lose it on the other. Passed only when the graph declared
* `continuity: 'conversation'`.
*/
skillCursor?: string, evidenceRecovery?: EvidenceRecoveryCheckpoint,
/**
* The model's standings on its tool results (9.101.0), read from the same
* committed snapshot `folded` comes from, by the same one-reader-two-carriers
* rule — so a crash checkpoint cannot lose a ledger `checkpoint()` keeps.
* Absent unless the run's ledger is non-empty.
*/
findingsLedger?: FindingsLedger,
/**
* The turn the failing run was on (honesty layers), read from the same
* committed snapshot by the same one-reader-two-carriers rule — so a crash
* checkpoint cannot lose the turn `checkpoint()` keeps. Absent unless a
* layer is armed; see {@link AgentRunCheckpoint.turnNumber}.
*/
turnNumber?: number,
/** Private evidence beside the same committed ledger; absent unless explicitly opted in. */
claimVerification?: ClaimVerificationState, negativeClaims?: NegativeCheckpoint): AgentRunCheckpoint;
/**
* Validate a checkpoint at deserialization time. Catches forward-
* incompatible payloads (someone tries to resume a v3 checkpoint on
* a v1 runtime, or a corrupted JSON blob).
*
* Returns the checkpoint typed-narrowed; throws TypeError on
* unknown shape.
*/
export declare function validateCheckpoint(value: unknown): AgentRunCheckpoint;
/**
* Classify a thrown error into one of the failure-point phase
* buckets. Heuristic — uses error name / code / message inspection.
* Fast path returns 'unknown' so unrecognized errors still produce
* a checkpoint (the cause itself is preserved in
* `RunCheckpointError.cause`).
*
* **The FALLBACK since 8.14.0.** It only runs when the tracker observed no
* open bracket, because an error's own text is the weakest available evidence
* about where it happened: `TypeError: Load failed` — WebKit's entire message
* for a failed `fetch` — matches nothing here and produced `'unknown'` on a
* real production report while the run knew the LLM call was open. Observation
* first, this second.
*/
export declare function classifyFailurePhase(err: Error): 'iteration' | 'tool' | 'llm' | 'unknown';