UNPKG

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
"use strict"; /** * 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