@crewhaus/recovery-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/recovery-engine",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pure recovery taxonomy: classify Anthropic API errors into a RecoveryAction (compact/retry/continue/tombstone/fail)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/recovery-engine"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/recovery-engine#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,98 @@
1
+ [
2
+ {
3
+ "label": "real Anthropic 529 overloaded",
4
+ "shape": {
5
+ "name": "APIError",
6
+ "status": 529,
7
+ "error": { "type": "overloaded_error", "message": "Overloaded" },
8
+ "message": "529 Overloaded"
9
+ },
10
+ "expectedKind": "retry"
11
+ },
12
+ {
13
+ "label": "real Anthropic 503 service unavailable",
14
+ "shape": {
15
+ "name": "APIError",
16
+ "status": 503,
17
+ "error": { "type": "api_error", "message": "Service unavailable" },
18
+ "message": "503 Service Unavailable"
19
+ },
20
+ "expectedKind": "retry"
21
+ },
22
+ {
23
+ "label": "real Anthropic 500 internal error",
24
+ "shape": {
25
+ "name": "APIError",
26
+ "status": 500,
27
+ "error": { "type": "api_error", "message": "Internal server error" },
28
+ "message": "500 Internal Server Error"
29
+ },
30
+ "expectedKind": "retry"
31
+ },
32
+ {
33
+ "label": "real Anthropic prompt too long (400 + invalid_request_error w/ message hint)",
34
+ "shape": {
35
+ "name": "BadRequestError",
36
+ "status": 400,
37
+ "error": {
38
+ "type": "invalid_request_error",
39
+ "message": "Prompt is too long: 220000 tokens > 200000 maximum"
40
+ },
41
+ "message": "400 Prompt is too long: 220000 tokens > 200000 maximum"
42
+ },
43
+ "expectedKind": "compact"
44
+ },
45
+ {
46
+ "label": "real Anthropic generic 400 invalid_request",
47
+ "shape": {
48
+ "name": "BadRequestError",
49
+ "status": 400,
50
+ "error": { "type": "invalid_request_error", "message": "messages.0: invalid block" },
51
+ "message": "400 Bad Request"
52
+ },
53
+ "expectedKind": "tombstone"
54
+ },
55
+ {
56
+ "label": "synthetic max_output_tokens (caller fabricates after stop_reason)",
57
+ "shape": {
58
+ "name": "Error",
59
+ "error": { "type": "max_output_tokens", "message": "stop_reason was max_tokens" },
60
+ "message": "max output tokens reached"
61
+ },
62
+ "expectedKind": "continue"
63
+ },
64
+ {
65
+ "label": "user-aborted via APIUserAbortError",
66
+ "shape": {
67
+ "name": "APIUserAbortError",
68
+ "message": "Request was aborted"
69
+ },
70
+ "expectedKind": "fail"
71
+ },
72
+ {
73
+ "label": "user-aborted via AbortError",
74
+ "shape": {
75
+ "name": "AbortError",
76
+ "message": "The operation was aborted"
77
+ },
78
+ "expectedKind": "fail"
79
+ },
80
+ {
81
+ "label": "unknown TypeError",
82
+ "shape": {
83
+ "name": "TypeError",
84
+ "message": "Cannot read properties of undefined"
85
+ },
86
+ "expectedKind": "fail"
87
+ },
88
+ {
89
+ "label": "real Anthropic 401 unauthorized (treated as unknown → fail)",
90
+ "shape": {
91
+ "name": "AuthenticationError",
92
+ "status": 401,
93
+ "error": { "type": "authentication_error", "message": "invalid x-api-key" },
94
+ "message": "401 Unauthorized"
95
+ },
96
+ "expectedKind": "fail"
97
+ }
98
+ ]
@@ -0,0 +1,289 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ BUDGETS,
6
+ type NamedFailureClass,
7
+ type RecoveryAction,
8
+ advanceState,
9
+ backoffMs,
10
+ classify,
11
+ initialRecoveryState,
12
+ matchNamedFailure,
13
+ recover,
14
+ } from "./index";
15
+
16
+ describe("classify", () => {
17
+ test("531/529/500 → overloaded_or_5xx", () => {
18
+ expect(classify({ status: 503, message: "x" })).toBe("overloaded_or_5xx");
19
+ expect(classify({ status: 529, message: "x" })).toBe("overloaded_or_5xx");
20
+ expect(classify({ status: 500, message: "x" })).toBe("overloaded_or_5xx");
21
+ });
22
+
23
+ test("type: overloaded_error wins regardless of status", () => {
24
+ expect(classify({ status: 200, error: { type: "overloaded_error" }, message: "x" })).toBe(
25
+ "overloaded_or_5xx",
26
+ );
27
+ });
28
+
29
+ test("400 invalid_request → invalid_request", () => {
30
+ expect(
31
+ classify({
32
+ status: 400,
33
+ error: { type: "invalid_request_error", message: "bad" },
34
+ message: "bad",
35
+ }),
36
+ ).toBe("invalid_request");
37
+ });
38
+
39
+ test('"Prompt is too long" inside a 400 → prompt_too_long, not invalid_request', () => {
40
+ expect(
41
+ classify({
42
+ status: 400,
43
+ error: { type: "invalid_request_error", message: "Prompt is too long: 220k > 200k" },
44
+ message: "Prompt is too long",
45
+ }),
46
+ ).toBe("prompt_too_long");
47
+ });
48
+
49
+ test("synthetic max_output_tokens → max_output_tokens", () => {
50
+ expect(classify({ error: { type: "max_output_tokens" }, message: "stopped" })).toBe(
51
+ "max_output_tokens",
52
+ );
53
+ });
54
+
55
+ test("APIUserAbortError / AbortError → user_aborted", () => {
56
+ expect(classify({ name: "APIUserAbortError", message: "aborted" })).toBe("user_aborted");
57
+ expect(classify({ name: "AbortError", message: "aborted" })).toBe("user_aborted");
58
+ });
59
+
60
+ test("unknown shapes → unknown", () => {
61
+ expect(classify(null)).toBe("unknown");
62
+ expect(classify(undefined)).toBe("unknown");
63
+ expect(classify({})).toBe("unknown");
64
+ expect(classify({ status: 401, message: "auth" })).toBe("unknown");
65
+ });
66
+ });
67
+
68
+ describe("recover — happy paths", () => {
69
+ test("prompt_too_long → compact", () => {
70
+ const action = recover(
71
+ {
72
+ status: 400,
73
+ error: { type: "invalid_request_error", message: "Prompt is too long" },
74
+ message: "Prompt is too long",
75
+ },
76
+ initialRecoveryState,
77
+ );
78
+ expect(action).toEqual({ kind: "compact" });
79
+ });
80
+
81
+ test("max_output_tokens → continue", () => {
82
+ const action = recover(
83
+ { error: { type: "max_output_tokens" }, message: "stopped" },
84
+ initialRecoveryState,
85
+ );
86
+ expect(action).toEqual({ kind: "continue" });
87
+ });
88
+
89
+ test("5xx → retry with first-attempt backoff in [1000, 1250]", () => {
90
+ const action = recover({ status: 503, message: "boom" }, initialRecoveryState);
91
+ expect(action.kind).toBe("retry");
92
+ if (action.kind !== "retry") return;
93
+ expect(action.attempt).toBe(1);
94
+ expect(action.delayMs).toBeGreaterThanOrEqual(1000);
95
+ expect(action.delayMs).toBeLessThanOrEqual(1250);
96
+ });
97
+
98
+ test("invalid_request → tombstone", () => {
99
+ const action = recover(
100
+ {
101
+ status: 400,
102
+ error: { type: "invalid_request_error", message: "bad block" },
103
+ message: "bad block",
104
+ },
105
+ initialRecoveryState,
106
+ );
107
+ expect(action).toEqual({ kind: "tombstone" });
108
+ });
109
+
110
+ test("user_aborted → fail with reason 'user_aborted'", () => {
111
+ const action = recover({ name: "APIUserAbortError", message: "aborted" }, initialRecoveryState);
112
+ expect(action).toEqual({ kind: "fail", reason: "user_aborted" });
113
+ });
114
+
115
+ test("unknown → fail with the original message", () => {
116
+ const action = recover({ name: "TypeError", message: "boom" }, initialRecoveryState);
117
+ expect(action).toEqual({ kind: "fail", reason: "boom" });
118
+ });
119
+ });
120
+
121
+ describe("recover — budget exhaustion", () => {
122
+ test("retry exhausts after MAX_RETRIES", () => {
123
+ const state = { ...initialRecoveryState, retryCount: BUDGETS.MAX_RETRIES };
124
+ const action = recover({ status: 503, message: "boom" }, state);
125
+ expect(action.kind).toBe("fail");
126
+ });
127
+
128
+ test("compact exhausts after MAX_COMPACTS", () => {
129
+ const state = { ...initialRecoveryState, compactCount: BUDGETS.MAX_COMPACTS };
130
+ const action = recover(
131
+ {
132
+ status: 400,
133
+ error: { type: "invalid_request_error", message: "Prompt is too long" },
134
+ message: "Prompt is too long",
135
+ },
136
+ state,
137
+ );
138
+ expect(action.kind).toBe("fail");
139
+ });
140
+
141
+ test("continue exhausts after MAX_CONTINUES", () => {
142
+ const state = { ...initialRecoveryState, continueCount: BUDGETS.MAX_CONTINUES };
143
+ const action = recover({ error: { type: "max_output_tokens" }, message: "x" }, state);
144
+ expect(action.kind).toBe("fail");
145
+ });
146
+
147
+ test("tombstone exhausts after MAX_TOMBSTONES", () => {
148
+ const state = { ...initialRecoveryState, tombstoneCount: BUDGETS.MAX_TOMBSTONES };
149
+ const action = recover(
150
+ {
151
+ status: 400,
152
+ error: { type: "invalid_request_error", message: "bad" },
153
+ message: "bad",
154
+ },
155
+ state,
156
+ );
157
+ expect(action.kind).toBe("fail");
158
+ });
159
+ });
160
+
161
+ describe("backoffMs", () => {
162
+ test("monotonic non-decreasing across attempts (with jitter=0)", () => {
163
+ const zero = () => 0;
164
+ const a0 = backoffMs(0, zero);
165
+ const a1 = backoffMs(1, zero);
166
+ const a2 = backoffMs(2, zero);
167
+ const a3 = backoffMs(3, zero);
168
+ expect(a0).toBeLessThanOrEqual(a1);
169
+ expect(a1).toBeLessThanOrEqual(a2);
170
+ expect(a2).toBeLessThanOrEqual(a3);
171
+ });
172
+
173
+ test("jitter stays within [0, 250]", () => {
174
+ for (let i = 0; i < 100; i++) {
175
+ const ms = backoffMs(0); // attempt 0 → exp = 1000
176
+ expect(ms).toBeGreaterThanOrEqual(1000);
177
+ expect(ms).toBeLessThanOrEqual(1250);
178
+ }
179
+ });
180
+
181
+ test("caps at MAX_BACKOFF_MS + jitter", () => {
182
+ const ms = backoffMs(20, () => 1); // 1000 * 2^20 → capped to 30_000, plus jitter 250
183
+ expect(ms).toBeLessThanOrEqual(BUDGETS.MAX_BACKOFF_MS + BUDGETS.MAX_JITTER_MS);
184
+ });
185
+ });
186
+
187
+ describe("advanceState", () => {
188
+ test("retry increments retryCount", () => {
189
+ const after = advanceState(initialRecoveryState, {
190
+ kind: "retry",
191
+ delayMs: 1000,
192
+ attempt: 1,
193
+ });
194
+ expect(after.retryCount).toBe(1);
195
+ });
196
+
197
+ test("compact/continue/tombstone increment their respective counters", () => {
198
+ const a = advanceState(initialRecoveryState, { kind: "compact" });
199
+ expect(a.compactCount).toBe(1);
200
+ const b = advanceState(initialRecoveryState, { kind: "continue" });
201
+ expect(b.continueCount).toBe(1);
202
+ const c = advanceState(initialRecoveryState, { kind: "tombstone" });
203
+ expect(c.tombstoneCount).toBe(1);
204
+ });
205
+
206
+ test("fail leaves state unchanged", () => {
207
+ const after = advanceState(initialRecoveryState, { kind: "fail", reason: "boom" });
208
+ expect(after).toEqual(initialRecoveryState);
209
+ });
210
+ });
211
+
212
+ // T4: replay over fixture file. Each entry has an expected action kind; we
213
+ // confirm classify() + recover() agree against an initial state.
214
+ describe("T4 — fixture replay", () => {
215
+ type Fixture = {
216
+ label: string;
217
+ shape: unknown;
218
+ expectedKind: RecoveryAction["kind"];
219
+ };
220
+ // `tsc -b` also compiles this file into `dist/`; resolve fixtures from the
221
+ // source tree so both the src and dist test copies find errors.json.
222
+ const SRC_DIR = import.meta.dir.replace(/([/\\])dist$/, "$1src");
223
+ const fixturesPath = join(SRC_DIR, "__fixtures__", "errors.json");
224
+ const fixtures = JSON.parse(readFileSync(fixturesPath, "utf-8")) as Fixture[];
225
+
226
+ for (const f of fixtures) {
227
+ test(`${f.label} → action.kind === "${f.expectedKind}"`, () => {
228
+ const action = recover(f.shape, initialRecoveryState);
229
+ expect(action.kind).toBe(f.expectedKind);
230
+ });
231
+ }
232
+ });
233
+
234
+ // Section 55 (Track A) — named failure taxonomy. The recovery engine
235
+ // consults user-declared classes before falling back to the built-in
236
+ // taxonomy. Source: NLAH (arxiv 2603.25723).
237
+ describe("Track A — named failure taxonomy", () => {
238
+ const taxonomy: NamedFailureClass[] = [
239
+ { class: "missing_artifact", pattern: "ENOENT", recovery: "tombstone" },
240
+ { class: "verifier_failure", pattern: "verification failed", recovery: "continue" },
241
+ { class: "rate_limit_429", pattern: "/^429\\b/", recovery: "retry" },
242
+ ];
243
+
244
+ test("substring pattern matches a named class", () => {
245
+ const matched = matchNamedFailure({ message: "ENOENT: no such file or directory" }, taxonomy);
246
+ expect(matched?.class).toBe("missing_artifact");
247
+ });
248
+
249
+ test("regex pattern matches a named class", () => {
250
+ const matched = matchNamedFailure({ message: "429 too many requests" }, taxonomy);
251
+ expect(matched?.class).toBe("rate_limit_429");
252
+ });
253
+
254
+ test("case-insensitive substring match", () => {
255
+ const matched = matchNamedFailure({ message: "Verification Failed: bad output" }, taxonomy);
256
+ expect(matched?.class).toBe("verifier_failure");
257
+ });
258
+
259
+ test("returns undefined when no entry matches", () => {
260
+ expect(matchNamedFailure({ message: "completely unrelated" }, taxonomy)).toBeUndefined();
261
+ });
262
+
263
+ test("recover() uses named-class recovery when matched", () => {
264
+ const action = recover({ message: "ENOENT bad path" }, initialRecoveryState, taxonomy);
265
+ expect(action.kind).toBe("tombstone");
266
+ });
267
+
268
+ test("recover() falls through to built-in classify when no named match", () => {
269
+ const action = recover({ status: 503, message: "overloaded" }, initialRecoveryState, taxonomy);
270
+ expect(action.kind).toBe("retry");
271
+ });
272
+
273
+ test("named-class recovery respects per-turn budgets", () => {
274
+ const exhausted = { ...initialRecoveryState, tombstoneCount: BUDGETS.MAX_TOMBSTONES };
275
+ const action = recover({ message: "ENOENT bad path" }, exhausted, taxonomy);
276
+ expect(action.kind).toBe("fail");
277
+ if (action.kind === "fail") {
278
+ expect(action.reason).toContain("missing_artifact");
279
+ }
280
+ });
281
+
282
+ test("malformed regex pattern is skipped without throwing", () => {
283
+ const badTaxonomy: NamedFailureClass[] = [
284
+ { class: "bad", pattern: "/[unclosed/", recovery: "fail" },
285
+ ];
286
+ expect(() => matchNamedFailure({ message: "anything" }, badTaxonomy)).not.toThrow();
287
+ expect(matchNamedFailure({ message: "anything" }, badTaxonomy)).toBeUndefined();
288
+ });
289
+ });
package/src/index.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Catalog R1 `recovery-engine` — pure decision function that maps an
3
+ * Anthropic API error + per-turn recovery state to a `RecoveryAction`.
4
+ * No I/O. The orchestrator (runtime-core) is responsible for *executing*
5
+ * the chosen action (sleeping, recompacting, injecting messages).
6
+ *
7
+ * Taxonomy:
8
+ * prompt_too_long → compact (max once per turn → fail)
9
+ * max_output_tokens → continue (max 3 per turn → fail)
10
+ * overloaded / 5xx → retry (exponential backoff, max 5 → fail)
11
+ * invalid_request 400 → tombstone (max once per turn → fail)
12
+ * user-aborted → fail("user_aborted") — orchestrator handles abort separately
13
+ * anything else → fail(message)
14
+ *
15
+ * References: claude-code/query.ts recovery branches; agent-framework
16
+ * _runner.py retry; AI-Harness-Systems §recovery.
17
+ */
18
+ import { CrewhausError } from "@crewhaus/errors";
19
+
20
+ export type RecoveryAction =
21
+ | { readonly kind: "compact" }
22
+ | { readonly kind: "retry"; readonly delayMs: number; readonly attempt: number }
23
+ | { readonly kind: "continue" }
24
+ | { readonly kind: "tombstone"; readonly messageId?: string }
25
+ | { readonly kind: "fail"; readonly reason: string };
26
+
27
+ export type RecoveryErrorClass =
28
+ | "prompt_too_long"
29
+ | "max_output_tokens"
30
+ | "overloaded_or_5xx"
31
+ | "invalid_request"
32
+ | "user_aborted"
33
+ | "unknown";
34
+
35
+ export type RecoveryState = {
36
+ /** Number of `retry` actions already chosen in this turn. */
37
+ readonly retryCount: number;
38
+ /** Number of `compact` actions already chosen in this turn. */
39
+ readonly compactCount: number;
40
+ /** Number of `continue` actions already chosen in this turn. */
41
+ readonly continueCount: number;
42
+ /** Number of `tombstone` actions already chosen in this turn. */
43
+ readonly tombstoneCount: number;
44
+ };
45
+
46
+ export const initialRecoveryState: RecoveryState = {
47
+ retryCount: 0,
48
+ compactCount: 0,
49
+ continueCount: 0,
50
+ tombstoneCount: 0,
51
+ };
52
+
53
+ export class RecoveryEngineError extends CrewhausError {
54
+ override readonly name = "RecoveryEngineError";
55
+ constructor(message: string, cause?: unknown) {
56
+ super("runtime", message, cause);
57
+ }
58
+ }
59
+
60
+ const MAX_RETRIES = 5;
61
+ const MAX_COMPACTS = 1;
62
+ const MAX_CONTINUES = 3;
63
+ const MAX_TOMBSTONES = 1;
64
+
65
+ const BASE_BACKOFF_MS = 1_000;
66
+ const MAX_BACKOFF_MS = 30_000;
67
+ const MAX_JITTER_MS = 250;
68
+
69
+ /**
70
+ * Compute exponential-backoff delay for the Nth retry (0-indexed): 1 s, 2 s,
71
+ * 4 s, ..., capped at 30 s, plus 0–250 ms of jitter.
72
+ */
73
+ export function backoffMs(attempt: number, jitterFn: () => number = Math.random): number {
74
+ const exp = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
75
+ // Clamp into [0, MAX_JITTER_MS] so the contract holds even if a test injects
76
+ // a jitterFn that returns exactly 1.0 (Math.random itself returns [0, 1)).
77
+ const jitter = Math.min(Math.floor(jitterFn() * (MAX_JITTER_MS + 1)), MAX_JITTER_MS);
78
+ return exp + jitter;
79
+ }
80
+
81
+ /**
82
+ * Classify an unknown error value into a recovery taxonomy bucket. Duck-typed
83
+ * against the Anthropic SDK error shape: `.status`, `.error.type`, `.message`,
84
+ * plus the SDK's `name === "APIUserAbortError"` or `"AbortError"` for aborts.
85
+ *
86
+ * Synthetic max-output-tokens errors should carry `error.type === "max_output_tokens"`
87
+ * (the SDK doesn't throw on stop_reason — the caller fabricates an Error with
88
+ * that shape after seeing `stop_reason: "max_tokens"`).
89
+ */
90
+ export function classify(error: unknown): RecoveryErrorClass {
91
+ if (error === null || error === undefined) return "unknown";
92
+
93
+ const errObj = error as {
94
+ name?: unknown;
95
+ status?: unknown;
96
+ message?: unknown;
97
+ error?: { type?: unknown; message?: unknown };
98
+ };
99
+
100
+ // Aborts come through with name === "APIUserAbortError" or "AbortError".
101
+ if (typeof errObj.name === "string") {
102
+ if (errObj.name === "APIUserAbortError" || errObj.name === "AbortError") {
103
+ return "user_aborted";
104
+ }
105
+ }
106
+
107
+ const innerType = typeof errObj.error?.type === "string" ? errObj.error.type : undefined;
108
+ const message = typeof errObj.message === "string" ? errObj.message : "";
109
+ const status = typeof errObj.status === "number" ? errObj.status : undefined;
110
+
111
+ if (innerType === "max_output_tokens") return "max_output_tokens";
112
+
113
+ // Anthropic returns "Prompt is too long" inside invalid_request_error responses;
114
+ // distinguish from generic invalid_request by sniffing the message.
115
+ if (innerType === "invalid_request_error" && /prompt is too long|input length/i.test(message)) {
116
+ return "prompt_too_long";
117
+ }
118
+
119
+ if (innerType === "overloaded_error") return "overloaded_or_5xx";
120
+ if (status !== undefined && status >= 500 && status < 600) return "overloaded_or_5xx";
121
+ if (status === 529) return "overloaded_or_5xx";
122
+
123
+ if (innerType === "invalid_request_error" || status === 400) return "invalid_request";
124
+
125
+ return "unknown";
126
+ }
127
+
128
+ /**
129
+ * Section 55 (Track A) — a single entry from a spec's `failure_taxonomy`,
130
+ * carried verbatim from the IR. The recovery engine consults the taxonomy
131
+ * BEFORE falling back to its built-in classify+recover logic so user-
132
+ * named classes take precedence. Cited paper: NLAH (arxiv 2603.25723).
133
+ */
134
+ export type NamedFailureClass = {
135
+ readonly class: string;
136
+ readonly pattern: string;
137
+ readonly recovery: "retry" | "compact" | "continue" | "tombstone" | "fail";
138
+ readonly hint?: string;
139
+ };
140
+
141
+ /**
142
+ * Match an unknown error against a taxonomy entry's `pattern`. The pattern
143
+ * is either a `/regex/flags` literal or a case-insensitive substring of
144
+ * the error.message. Returns the first matching entry, or undefined.
145
+ */
146
+ export function matchNamedFailure(
147
+ error: unknown,
148
+ taxonomy: ReadonlyArray<NamedFailureClass>,
149
+ ): NamedFailureClass | undefined {
150
+ const message =
151
+ typeof (error as { message?: unknown })?.message === "string"
152
+ ? (error as { message: string }).message
153
+ : "";
154
+ if (message.length === 0) return undefined;
155
+ for (const entry of taxonomy) {
156
+ const p = entry.pattern;
157
+ if (p.length > 2 && p.startsWith("/") && p.lastIndexOf("/") > 0) {
158
+ const lastSlash = p.lastIndexOf("/");
159
+ const body = p.slice(1, lastSlash);
160
+ const flags = p.slice(lastSlash + 1);
161
+ let re: RegExp;
162
+ try {
163
+ re = new RegExp(body, flags);
164
+ } catch {
165
+ continue;
166
+ }
167
+ if (re.test(message)) return entry;
168
+ } else {
169
+ if (message.toLowerCase().includes(p.toLowerCase())) return entry;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /**
176
+ * Decide what to do about `error` given the per-turn recovery state. Pure.
177
+ * Each call returns a single action; the caller advances state.
178
+ *
179
+ * When `taxonomy` is provided, named classes take precedence over the
180
+ * built-in classify+recover taxonomy. A named match returns its declared
181
+ * `recovery` action directly (subject to the same per-turn budgets as
182
+ * built-in classes). Unmatched errors fall through to the built-in flow.
183
+ */
184
+ export function recover(
185
+ error: unknown,
186
+ state: RecoveryState,
187
+ taxonomy?: ReadonlyArray<NamedFailureClass>,
188
+ ): RecoveryAction {
189
+ if (taxonomy !== undefined && taxonomy.length > 0) {
190
+ const named = matchNamedFailure(error, taxonomy);
191
+ if (named !== undefined) {
192
+ return recoverNamed(named, state);
193
+ }
194
+ }
195
+ const klass = classify(error);
196
+ const message = (error as { message?: unknown })?.message;
197
+ const reasonStr = typeof message === "string" && message.length > 0 ? message : klass;
198
+
199
+ switch (klass) {
200
+ case "prompt_too_long":
201
+ if (state.compactCount >= MAX_COMPACTS) {
202
+ return { kind: "fail", reason: `compact budget exhausted: ${reasonStr}` };
203
+ }
204
+ return { kind: "compact" };
205
+
206
+ case "max_output_tokens":
207
+ if (state.continueCount >= MAX_CONTINUES) {
208
+ return { kind: "fail", reason: `continue budget exhausted: ${reasonStr}` };
209
+ }
210
+ return { kind: "continue" };
211
+
212
+ case "overloaded_or_5xx":
213
+ if (state.retryCount >= MAX_RETRIES) {
214
+ return { kind: "fail", reason: `retry budget exhausted: ${reasonStr}` };
215
+ }
216
+ return {
217
+ kind: "retry",
218
+ delayMs: backoffMs(state.retryCount),
219
+ attempt: state.retryCount + 1,
220
+ };
221
+
222
+ case "invalid_request":
223
+ if (state.tombstoneCount >= MAX_TOMBSTONES) {
224
+ return { kind: "fail", reason: `tombstone budget exhausted: ${reasonStr}` };
225
+ }
226
+ return { kind: "tombstone" };
227
+
228
+ case "user_aborted":
229
+ return { kind: "fail", reason: "user_aborted" };
230
+
231
+ case "unknown":
232
+ return { kind: "fail", reason: reasonStr };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Convert a matched named-failure entry into the corresponding
238
+ * RecoveryAction, respecting the same per-turn budgets as built-in
239
+ * classes. Budget exhaustion always returns `fail` so the user can't
240
+ * declare an infinite retry loop via taxonomy.
241
+ */
242
+ function recoverNamed(named: NamedFailureClass, state: RecoveryState): RecoveryAction {
243
+ const reason = `failure_taxonomy: ${named.class}`;
244
+ switch (named.recovery) {
245
+ case "retry":
246
+ if (state.retryCount >= MAX_RETRIES) {
247
+ return { kind: "fail", reason: `retry budget exhausted: ${reason}` };
248
+ }
249
+ return {
250
+ kind: "retry",
251
+ delayMs: backoffMs(state.retryCount),
252
+ attempt: state.retryCount + 1,
253
+ };
254
+ case "compact":
255
+ if (state.compactCount >= MAX_COMPACTS) {
256
+ return { kind: "fail", reason: `compact budget exhausted: ${reason}` };
257
+ }
258
+ return { kind: "compact" };
259
+ case "continue":
260
+ if (state.continueCount >= MAX_CONTINUES) {
261
+ return { kind: "fail", reason: `continue budget exhausted: ${reason}` };
262
+ }
263
+ return { kind: "continue" };
264
+ case "tombstone":
265
+ if (state.tombstoneCount >= MAX_TOMBSTONES) {
266
+ return { kind: "fail", reason: `tombstone budget exhausted: ${reason}` };
267
+ }
268
+ return { kind: "tombstone" };
269
+ case "fail":
270
+ return { kind: "fail", reason };
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Helper for the orchestrator: advance recovery state by one chosen action.
276
+ * Pure; returns a new state.
277
+ */
278
+ export function advanceState(state: RecoveryState, action: RecoveryAction): RecoveryState {
279
+ switch (action.kind) {
280
+ case "retry":
281
+ return { ...state, retryCount: state.retryCount + 1 };
282
+ case "compact":
283
+ return { ...state, compactCount: state.compactCount + 1 };
284
+ case "continue":
285
+ return { ...state, continueCount: state.continueCount + 1 };
286
+ case "tombstone":
287
+ return { ...state, tombstoneCount: state.tombstoneCount + 1 };
288
+ case "fail":
289
+ return state;
290
+ }
291
+ }
292
+
293
+ /** Budget constants exported so the orchestrator and tests stay in sync. */
294
+ export const BUDGETS = {
295
+ MAX_RETRIES,
296
+ MAX_COMPACTS,
297
+ MAX_CONTINUES,
298
+ MAX_TOMBSTONES,
299
+ BASE_BACKOFF_MS,
300
+ MAX_BACKOFF_MS,
301
+ MAX_JITTER_MS,
302
+ } as const;