agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
699 lines • 38.2 kB
JavaScript
;
/**
* 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).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.classifyFailurePhase = exports.validateCheckpoint = exports.buildCheckpoint = exports.checkpointReplayHistory = exports.retainReplayRetirements = exports.assertContinuable = exports.ConversationMismatchError = exports.canResume = exports.RunCheckpointError = void 0;
const negativeProse_js_1 = require("./agent/findings/negativeProse.js");
const replayHistory_js_1 = require("../thinking/replayHistory.js");
const contextWindow_js_1 = require("../provider-contract/contextWindow.js");
const rows_js_1 = require("./agent/arguments/rows.js");
const refusal_js_1 = require("./agent/findings/refusal.js");
const period_js_1 = require("../semantics/coverage/period.js");
const witness_js_1 = require("./agent/assessment/witness.js");
const rows_js_2 = require("./agent/scripts/rows.js");
const rows_js_3 = require("../time/rows.js");
const types_js_1 = require("./agent/findings/types.js");
const recovery_js_1 = require("./agent/evidence/recovery.js");
const verificationRow_js_1 = require("./agent/findings/verificationRow.js");
const verificationCheckpoint_js_1 = require("./agent/findings/verificationCheckpoint.js");
const checkpoint_js_1 = require("./agent/negativeClaims/checkpoint.js");
const scope_js_1 = require("../integrity/claim-binding/scope.js");
/**
* 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
* }
* }
* ```
*/
class RunCheckpointError extends Error {
code = 'ERR_RUN_CHECKPOINT';
/** The error that triggered the checkpoint. Inspect for retry
* decisions ("if cause is CircuitOpenError, wait for cooldown
* before resuming"). */
cause;
/** The last-known-good checkpoint. Persist + pass back to
* `agent.resumeOnError(checkpoint)` to continue from here. */
checkpoint;
constructor(cause, checkpoint) {
super(`[agent run] failed at iteration ${checkpoint.failurePoint?.iteration ?? '?'} ` +
`${describeFailurePoint(checkpoint.failurePoint)}. ` +
`Last-good checkpoint captured at iteration ${checkpoint.lastCompletedIteration}. ` +
`${resumeAdvice(cause)} ` +
`Underlying error: ${cause.message}`);
this.name = 'RunCheckpointError';
this.cause = cause;
this.checkpoint = checkpoint;
}
}
exports.RunCheckpointError = RunCheckpointError;
// ─── Can this failure be resumed at all? (9.6.0) ─────────────────────
/**
* Why `resumeOnError` cannot help — one clause per class, or `undefined`
* when the failure really is worth retrying.
*
* `resumeOnError(checkpoint)` re-sends the checkpointed conversation to the
* same provider with the same credentials. That is the right move for a
* transient failure (a 503, a timeout, a circuit that has since closed) and a
* DETERMINISTIC LOOP for a failure the request itself causes: measured in the
* field, a context-length refusal resumed into the identical 400, forever.
*
* The list is deliberately short. Anything not named here keeps today's
* advice, because wrongly telling a caller not to resume costs them a run
* that would have succeeded.
*/
function notResumableBecause(cause) {
// The typed error when an adapter caught it, and the vendor's own sentence
// when it came from somewhere else — a custom `LLMProvider`, a gateway
// wrapper, a proxy. The advice is the same either way, and it is the advice
// the field case needed: that error never passed through an adapter and
// would otherwise have fallen into the generic "malformed request" arm.
if ((0, contextWindow_js_1.isContextWindowExceeded)(cause) || (0, contextWindow_js_1.looksLikeContextWindowExceeded)(cause)) {
return (`Resume cannot help: agent.resumeOnError(checkpoint) re-sends this same ` +
`conversation, and the conversation is what did not fit — the identical ` +
`request fails identically. Make the next request smaller instead: cap ` +
`oversized tool results where they are produced, add ` +
`.window(slidingWindow({ keepRecentTurns: 2 })) to drop older rounds, and ` +
`note that .compaction() cannot fold a span bigger than the window (its ` +
`summarizer call sends that span). The checkpoint still carries the full ` +
`history — keep it for the post-mortem (checkpoint.history is what was ` +
`about to be sent), not for a retry.`);
}
const status = statusOf(cause);
const name = cause instanceof Error ? cause.name : '';
const text = cause instanceof Error ? cause.message : '';
if (status === 401 || status === 403 || AUTH_NAMES.test(name) || AUTH_PHRASES.test(text)) {
return (`Resume cannot help: the provider rejected the CREDENTIALS, and resume ` +
`replays the run with the same ones. Fix the key / role / region and start ` +
`a fresh run — the checkpoint keeps the conversation meanwhile, so nothing ` +
`said so far is lost (checkpoint.history).`);
}
if (status === 400 || status === 404 || status === 422) {
return (`Resume cannot help: the provider refused the REQUEST itself (HTTP ` +
`${status}), and resume re-sends the same one. Fix what it named — an ` +
`unknown model id, a malformed tool schema, an unsupported parameter — ` +
`then start a fresh run. The checkpoint keeps the conversation for the ` +
`post-mortem (checkpoint.history).`);
}
return undefined;
}
/**
* Auth failures that arrive without a status — matched on the vendor's
* EXCEPTION NAME (AWS SDK errors carry theirs there) rather than on loose
* prose, so a tool that happens to report "unauthorized" from some downstream
* system is not mistaken for the agent's own credentials being wrong.
*/
const AUTH_NAMES = /^(UnrecognizedClientException|ExpiredTokenException|InvalidSignatureException|IncompleteSignatureException|MissingAuthenticationTokenException|InvalidClientTokenId|AccessDenied\w*|AuthFailure|CredentialsError|UnauthorizedException|AuthenticationError|PermissionDeniedError)$/i;
/** The two phrasings every LLM vendor uses for "that key is not a key". */
const AUTH_PHRASES = /(invalid[_ -]?api[_ -]?key|incorrect api key|no api key provided)/i;
function statusOf(err) {
const record = err;
for (const value of [record?.status, record?.statusCode, record?.$metadata?.httpStatusCode]) {
if (typeof value === 'number' && Number.isFinite(value))
return value;
}
return undefined;
}
/**
* 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
* }
* }
* ```
*/
function canResume(error) {
return notResumableBecause(error) === undefined;
}
exports.canResume = canResume;
/** Today's hint, verbatim, unless the failure is one resume cannot fix. */
function resumeAdvice(cause) {
return notResumableBecause(cause) ?? 'Pass to agent.resumeOnError(checkpoint) to continue.';
}
/**
* 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.
*/
class ConversationMismatchError extends Error {
code = 'ERR_CONVERSATION_MISMATCH';
/** The id stamped on the stored conversation. */
storedAgentId;
/** The id of the agent it was handed to. */
agentId;
constructor(door, storedAgentId, agentId) {
super(`${door}: this conversation was recorded by agent '${storedAgentId}' and was handed to ` +
`agent '${agentId}'. Both agents named themselves with Agent.create({ id }), and the ` +
`names differ — so this is one agent answering another one's conversation, not a ` +
`deploy that changed. Continue it on the agent whose id is '${storedAgentId}', or, if ` +
`handing conversations between these two really is intended, give them the same id. ` +
`(An agent that merely gained a tool, changed its prompt or moved model since the ` +
`conversation was stored keeps the same id and is never refused here.)`);
this.name = 'ConversationMismatchError';
this.storedAgentId = storedAgentId;
this.agentId = agentId;
}
}
exports.ConversationMismatchError = ConversationMismatchError;
/**
* 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
*/
function assertContinuable(checkpoint, agentId, door) {
const storedId = checkpoint.agent?.id;
if (storedId === undefined || agentId === undefined)
return;
if (storedId === agentId)
return;
throw new ConversationMismatchError(door, storedId, agentId);
}
exports.assertContinuable = assertContinuable;
/**
* The human half of `failurePoint`, as one clause.
*
* `"failed at iteration 3 (unknown)"` was what a real production report came
* back with — a WebKit `fetch` failure is `TypeError: Load failed`, which
* matches none of {@link classifyFailurePhase}'s patterns, while the run
* itself knew perfectly well that the LLM call was open at the time. Naming
* the phase and the stage turns that shrug into a starting point.
*/
function describeFailurePoint(fp) {
if (!fp)
return '(phase unknown — nothing was open when it threw)';
const where = fp.phase === 'llm'
? 'during the LLM call'
: fp.phase === 'tool'
? 'during a tool call'
: fp.phase === 'iteration'
? 'between iteration boundaries'
: 'at an unidentified point';
return fp.stage !== undefined ? `${where} (stage: ${fp.stage})` : where;
}
/** A transport preparation is durable even if its request subsequently fails. */
function retainReplayRetirements(tracker, preparation) {
const edits = (0, replayHistory_js_1.replayRetirements)(preparation);
if (edits.length === 0)
return;
tracker.replayRetirements = [...(tracker.replayRetirements ?? []), ...structuredClone(edits)];
}
exports.retainReplayRetirements = retainReplayRetirements;
/** Read only approved replay edits onto the history each checkpoint already owns. */
function checkpointReplayHistory(history, tracker) {
return (0, replayHistory_js_1.applyReplayRetirements)(history, tracker?.replayRetirements ?? []);
}
exports.checkpointReplayHistory = checkpointReplayHistory;
/**
* Build a JSON-serializable checkpoint from a tracker + failure
* info. Pure function — no side effects.
*
* @internal
*/
function buildCheckpoint(tracker, 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,
/**
* 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,
/**
* 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, evidenceRecovery,
/**
* 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,
/**
* 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,
/** Private evidence beside the same committed ledger; absent unless explicitly opted in. */
claimVerification, negativeClaims) {
const verification = claimVerification === undefined
? undefined
: (0, verificationCheckpoint_js_1.verificationCheckpointOf)({ claimVerification, findingsLedger: findingsLedger ?? [] });
return {
version: 1,
runId: tracker.runId,
history: checkpointReplayHistory(tracker.history, tracker),
lastCompletedIteration: tracker.lastCompletedIteration,
originalInput: tracker.originalInput,
checkpointedAt: Date.now(),
...(failurePoint && { failurePoint }),
...(folded !== undefined && folded.length > 0 && { folded }),
...(owner?.identity !== undefined && { identity: owner.identity }),
...(owner?.agentId !== undefined && { agent: { id: owner.agentId } }),
...(skillCursor !== undefined && { skillCursor }),
...(evidenceRecovery !== undefined && { evidenceRecovery }),
...(findingsLedger !== undefined && findingsLedger.length > 0 && { findingsLedger }),
...(turnNumber !== undefined && { turnNumber }),
...(verification !== undefined && { claimVerification: verification }),
...(negativeClaims === undefined ? {} : (0, checkpoint_js_1.negativeCheckpointOf)(negativeClaims)),
};
}
exports.buildCheckpoint = buildCheckpoint;
/**
* One ledger row is well-formed when it carries the fields its kind's reader
* consumes (`findings/ledger.ts · foldLedger` and the row builders). Shape
* only — a standing's `assertions` must be an array, never "an array of
* assertions whose values look right" — because the continued run re-seeds
* the row verbatim and the fold reads exactly these fields. Every value
* vocabulary is the one `findings/types.ts` declares.
*/
function ledgerRowIsWellFormed(row) {
if (row === null || typeof row !== 'object' || Array.isArray(row))
return false;
// Admit the new closed protocol before legacy field reads. Its reader refuses
// accessors and unsafe values, and never runs a checker while restoring.
const kind = Object.getOwnPropertyDescriptor(row, 'kind');
if (kind !== undefined && !('value' in kind))
return false;
if (kind?.value === 'negative-claim' || kind?.value === 'negative-claim-note')
return (0, negativeProse_js_1.negativeProseRowIsWellFormed)(row);
if (kind?.value === 'claim-verification')
return (0, verificationRow_js_1.isClaimVerificationRow)(row);
if (kind?.value === 'reference-refusal')
return (0, refusal_js_1.referenceRefusalRowIsWellFormed)(row);
const r = row;
const isIn = (value, vocabulary) => typeof value === 'string' && vocabulary.includes(value);
const isIdentity = (value) => value !== null &&
typeof value === 'object' &&
typeof value.toolCallId === 'string';
// The honesty layers' turn stamp may ride EVERY kind (the one writer stamps
// each row it files while a layer is armed); readers compare it as a number,
// so a stamp of any other type would quietly drop its row from its own turn.
if (r.turn !== undefined && typeof r.turn !== 'number')
return false;
// The time layer's rows (`.time()`): the turn's clock stamp, a resume's
// differing `time`, each call's dispatch moment, and each time mention the
// armed reader found (`time-reading`). The arm ships in the
// SAME change as the kinds; one owner of their kinds and shape
// (`time/rows.ts`). An older runtime refuses a checkpoint carrying one.
if ((0, rows_js_3.isTimeRowKind)(r.kind))
return (0, rows_js_3.timeRowIsWellFormed)(r);
switch (r.kind) {
case 'basis':
return (typeof r.toolCallId === 'string' &&
typeof r.toolName === 'string' &&
typeof r.iteration === 'number' &&
isIn(r.basis, types_js_1.BASIS_VALUES) &&
(r.expect === undefined || isIn(r.expect, types_js_1.EXPECT_VALUES)));
case 'standing':
return (typeof r.toolCallId === 'string' &&
isIn(r.standing, types_js_1.STANDING_VALUES) &&
Array.isArray(r.assertions) &&
typeof r.iteration === 'number' &&
(r.declaredOn === 'answer' || isIdentity(r.declaredOn)));
case 'conflict':
return (typeof r.key === 'string' && Array.isArray(r.witnesses) && typeof r.iteration === 'number');
// The judge's two rows (9.104.0) — the door had no arm for them until
// 9.110.0, so a checkpoint of a judged run was refused on resume. The
// fold reads `toolCallId` and `standing` off a judgment row and nothing
// off an error row but its kind.
case 'judgment':
return (typeof r.toolCallId === 'string' &&
isIn(r.standing, types_js_1.STANDING_VALUES) &&
typeof r.iteration === 'number');
case 'judgment-error':
return typeof r.toolCallId === 'string' && typeof r.iteration === 'number';
// The towers (9.110.0): the served piece reads `declaredOn`, `value` and
// every carrier's `toolCallId` and `standing`. The rule that writes the
// row never files an empty carrier list and never a `fact` carrier (one
// fact carrier means the value stands), so the door refuses both — a
// row with either is not one `recordFindings` wrote.
case 'contingent':
return ((r.declaredOn === 'answer' || isIdentity(r.declaredOn)) &&
typeof r.value === 'string' &&
Array.isArray(r.carriers) &&
r.carriers.length >= 1 &&
r.carriers.every((c) => c !== null &&
typeof c === 'object' &&
typeof c.toolCallId === 'string' &&
isIn(c.standing, ['open', 'noise', 'ruled-out'])) &&
typeof r.iteration === 'number');
// The row beside a ruled-out standing that rests on an absence
// (9.113.0): the fold reads `toolCallId`, the piece reads each list's
// items and `tryInstead`. The rule writes a list only when it holds an
// item with a string `what`, and `tryInstead` only as a non-empty
// string, so the door refuses anything else — a row with an empty list
// is not one `recordFindings` wrote.
case 'unsettled-by-absence':
return (typeof r.toolCallId === 'string' &&
(r.notChecked === undefined || isCoverageList(r.notChecked)) &&
(r.cannotCover === undefined || isCoverageList(r.cannotCover)) &&
(r.tryInstead === undefined || (typeof r.tryInstead === 'string' && r.tryInstead !== '')) &&
typeof r.iteration === 'number');
// The inputs layer's verdict on one ruled argument (honesty layer 2) —
// the arm ships in the SAME change as the row kind (the judge rows were
// refused until 9.110.0 because their arm came later). One owner of the
// row's shape: `arguments/rows.ts`. An older runtime refuses a checkpoint
// that carries this kind.
case 'argument':
return (0, rows_js_1.argumentRowIsWellFormed)(r);
// The results layer's verdict on one call's period (honesty layer 3, step
// 7b) — the arm ships in the SAME change as the row kind. One owner of the
// row's shape: `agent/coverage/period.ts`. An older runtime refuses a
// checkpoint that carries this kind.
case 'period':
return (0, period_js_1.periodRowIsWellFormed)(r);
// The answer layer's witness rows (honesty layer 4): the evidence gate's
// clean verdict, an answer given before its declared steps finished, and
// the script match's verdict on a shown block (`script-shown`). The arms
// ship in the SAME change as the kinds; one owner of their shape
// (`assessment/witness.ts`). An older runtime refuses a checkpoint that
// carries any of them.
case 'grounded':
case 'steps-unfinished':
case 'script-shown':
return (0, witness_js_1.witnessRowIsWellFormed)(r);
// The script record (`core/agent/scripts/`): one row per call of a
// declared code-running tool. The arm ships in the SAME change as the
// kind; one owner of its shape (`scripts/rows.ts`). An older runtime
// refuses a checkpoint that carries it.
case 'script':
return (0, rows_js_2.scriptRowIsWellFormed)(r);
default:
return false;
}
}
/** A conversation turn: a positive whole number (the `AgentState.turnNumber` seed writes). */
function isTurnNumber(value) {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
}
/** A non-empty list of `{ what, why? }` coverage items, both strings — the shape the rule files. */
function isCoverageList(value) {
return (Array.isArray(value) &&
value.length >= 1 &&
value.every((item) => item !== null &&
typeof item === 'object' &&
typeof item.what === 'string' &&
(item.why === undefined ||
typeof item.why === 'string')));
}
/**
* 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.
*/
function validateCheckpoint(value) {
if (!value || typeof value !== 'object') {
throw new TypeError('[resumeOnError] checkpoint is not an object.');
}
const c = value;
if (c.version !== 1) {
throw new TypeError(`[resumeOnError] unsupported checkpoint version: ${c.version}. ` +
`This runtime supports version 1; persisted checkpoints from a future ` +
`agentfootprint version need a matching runtime to resume.`);
}
if (typeof c.runId !== 'string' || !Array.isArray(c.history)) {
throw new TypeError('[resumeOnError] checkpoint missing required fields (runId, history).');
}
if (typeof c.lastCompletedIteration !== 'number') {
throw new TypeError('[resumeOnError] checkpoint missing required field: lastCompletedIteration.');
}
if (!c.originalInput || typeof c.originalInput.message !== 'string') {
throw new TypeError('[resumeOnError] checkpoint missing required field: originalInput.message.');
}
// Optional fields are validated only when PRESENT — absence is the ordinary
// pre-SG-C checkpoint and must keep deserializing byte-identically.
if (c.skillCursor !== undefined && typeof c.skillCursor !== 'string') {
throw new TypeError(`[resumeOnError] checkpoint \`skillCursor\` must be a skill id string when present ` +
`(got ${typeof c.skillCursor}). It is written by a graph with ` +
`continuity: 'conversation' and names the node the stored turn ended on.`);
}
if (c.evidenceRecovery !== undefined) {
const recovery = c.evidenceRecovery;
const pending = recovery?.pending;
if (recovery === null ||
typeof recovery !== 'object' ||
Array.isArray(recovery) ||
recovery.revisionSpent !== true ||
Object.keys(recovery).some((key) => key !== 'revisionSpent' && key !== 'pending') ||
(pending !== undefined &&
(pending === null ||
typeof pending !== 'object' ||
Array.isArray(pending) ||
Object.keys(pending).some((key) => key !== 'instruction' && key !== 'conclusion') ||
(pending.conclusion !== undefined &&
(typeof pending.conclusion !== 'string' ||
pending.conclusion.length === 0 ||
pending.conclusion.length > recovery_js_1.MAX_EVIDENCE_RECOVERY_CHARS)) ||
typeof pending.instruction !== 'string' ||
pending.instruction.length === 0 ||
pending.instruction.length > recovery_js_1.MAX_EVIDENCE_RECOVERY_CHARS))) {
throw new TypeError('[resumeOnError] checkpoint evidenceRecovery must carry a spent revision and optional bounded instruction.');
}
}
// The findings ledger (9.101.0), by the same present-only rule: a checkpoint
// without the key is every pre-ledger conversation and deserializes
// byte-identically. When present it must be the shape `recordFindings`
// writes, because a continued run re-seeds it VERBATIM into
// `AgentState.findingsLedger` and the next `foldLedger` reads the fields
// below without looking: a row that names a known `kind` but lacks the
// fields that kind's reader consumes (`standing` without `assertions`,
// say) would fail deep inside that fold. So the door checks the fields the
// fold consumes, per kind — never the values the model wrote.
if (c.findingsLedger !== undefined) {
let ledger;
try {
ledger = (0, scope_js_1.ownConfigArray)(c.findingsLedger, Number.MAX_SAFE_INTEGER);
}
catch {
// A saved array cannot run a getter/iterator to choose the rows admitted.
}
if (ledger === undefined || !ledger.every(ledgerRowIsWellFormed)) {
throw new TypeError('[resumeOnError] checkpoint `findingsLedger` must be an array of ledger rows when ' +
"present — each a plain object whose `kind` is 'basis' (with toolCallId, toolName, " +
"iteration, basis), 'standing' (with toolCallId, standing, assertions[], declaredOn, " +
"iteration), 'reference-refusal' (with declaredOn, iteration, a refusal reason and optional bounded diagnostic), 'conflict' (with key, witnesses[], iteration), 'judgment' (with " +
"toolCallId, standing, iteration), 'judgment-error' (with toolCallId, iteration) or " +
"'contingent' (with declaredOn, value, at least one carrier { toolCallId, standing: " +
"open | noise | ruled-out }, iteration) or 'unsettled-by-absence' (with toolCallId, " +
'iteration, and optional non-empty notChecked / cannotCover lists of { what, why? } ' +
"and a non-empty tryInstead string) or 'argument' (with toolCallId, toolName, " +
'argument, iteration, turn, and a source or an asked in its vocabulary) or ' +
"'period' (with toolCallId, toolName, iteration, turn, and a verdict of covered, " +
'partly-held, not-held, unknown or undeclared) or ' +
"'grounded' (with turn, iteration, posture, candidates, lookedUp) or " +
"'steps-unfinished' (with turn, iteration, skillId, remaining[] of { index, tool }, " +
'total, action: accepted | cut-short) or ' +
"'script-shown' (with turn, iteration, block, a script label or 'unmatched' with a " +
'reason) or ' +
"'script' (with toolCallId, tool, argument, bytes, lines, a script outcome, turn, " +
'iteration, and text only as long as bytes says, within the bound) or ' +
"'clock' (with turn, iteration, now, nowSource, zone, zoneSource) or " +
"'clock-on-resume' (with turn, iteration, passed, kept) or " +
"'call' (with turn, iteration, toolCallId, toolName, dispatchedAt) or " +
"'time-reading' (with turn, iteration, reader, tzdata, mentions and, per mention, " +
'mention with quote, parses, candidates and choice, or refused) or ' +
"'time-answer' (with turn, iteration, mention, from, to, zone, how: confirmed | edited); " +
"or 'claim-verification' (the closed scoped-comparison protocol); " +
"or 'negative-claim' / 'negative-claim-note' (bounded observe-only prose findings); " +
'a row of any kind may carry a numeric turn. ' +
'It is written by an agent with `.findings()`, an honesty layer or `.time()` armed, and ' +
're-seeded verbatim on continuation.');
}
}
// The two checkpoint carriers use one admission rule. Only a present opted-in
// carrier is copied/validated; every old checkpoint keeps its return identity.
let claimVerification;
try {
claimVerification = (0, verificationCheckpoint_js_1.verificationCheckpointOf)(c);
}
catch {
throw new TypeError('[resumeOnError] checkpoint claimVerification is malformed.');
}
// The turn the stored history ends on (honesty layers), by the same
// present-only rule: a checkpoint without the key is every conversation
// stored without a layer armed. When present it is a turn — a positive
// whole number — because the next run's turn stamp starts from it.
if (c.turnNumber !== undefined && !isTurnNumber(c.turnNumber)) {
throw new TypeError(`[resumeOnError] checkpoint \`turnNumber\` must be a positive whole number when present ` +
`(got ${JSON.stringify(c.turnNumber) ?? typeof c.turnNumber}). It is the conversation ` +
`turn the stored history ends on, written while an honesty layer is armed.`);
}
// The conversation itself, message by message (8.18.0). `Array.isArray` was
// the whole check, one field away from `originalInput.message` — which HAS
// been string-checked since this function was written. A checkpoint that
// round-tripped through a store, a hand edit, or an older writer could carry
// a turn with no `content`, deserialize cleanly, and take the run down on
// its first composition with a TypeError naming neither the checkpoint nor
// the turn. A persisted artifact is checked at the door it comes in.
for (const [i, msg] of c.history.entries()) {
if (!msg || typeof msg !== 'object') {
throw new TypeError(`[resumeOnError] checkpoint history[${i}] is not a message (got ${msg === null ? 'null' : typeof msg}). Each entry must be { role, content }.`);
}
const m = msg;
if (typeof m.role !== 'string' || typeof m.content !== 'string') {
throw new TypeError(`[resumeOnError] checkpoint history[${i}] is missing text: \`role\` is ${typeof m.role}, ` +
`\`content\` is ${m.content === undefined ? 'missing' : typeof m.content}. Every turn in a stored conversation must carry { role: string, content: string } — ` +
`a turn without content cannot be composed into the next request, and the failure ` +
`would otherwise surface deep inside the run rather than here.`);
}
}
(0, checkpoint_js_1.negativeCheckpointOf)(c);
const hasVerificationRows = c.findingsLedger?.some((row) => row.kind === 'claim-verification');
return claimVerification === undefined && !hasVerificationRows
? c
: {
...c,
...(claimVerification !== undefined && { claimVerification }),
...(hasVerificationRows && {
findingsLedger: c.findingsLedger?.map((row) => row.kind === 'claim-verification' ? (0, verificationRow_js_1.readClaimVerificationRow)(row) : row),
}),
};
}
exports.validateCheckpoint = validateCheckpoint;
/**
* 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.
*/
function classifyFailurePhase(err) {
const name = err.name;
const code = err.code ?? '';
const msg = err.message ?? '';
// LLM provider failures: known codes + name patterns.
if (code === 'ERR_CIRCUIT_OPEN' || // our own circuit breaker
name === 'AnthropicError' ||
name === 'OpenAIError' ||
name === 'BedrockError' ||
/\b(LLM|provider|anthropic|openai|bedrock)\b/i.test(msg)) {
return 'llm';
}
if (/\b(tool|tool_call)\b/i.test(name) || /\bTool\b/.test(msg)) {
return 'tool';
}
if (/iteration/i.test(msg))
return 'iteration';
return 'unknown';
}
exports.classifyFailurePhase = classifyFailurePhase;
//# sourceMappingURL=runCheckpoint.js.map