@yeaft/webchat-agent 0.1.1023 → 0.1.1024

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.1023",
3
+ "version": "0.1.1024",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -54,8 +54,62 @@ const DEFAULTS = {
54
54
  // block is injected). Hand-edited values are NOT clamped — we let
55
55
  // power users opt into larger docs at their own context-window risk.
56
56
  projectDocMaxBytes: 32 * 1024,
57
+ // ─── LLM retry policy ──────────────────────────────────────
58
+ // How the engine reacts when adapter.stream()/call() throws a
59
+ // retryable error (429 / 529 / 5xx / transport failure). Each field
60
+ // is overridable from ~/.yeaft/config.json under "llmRetry": {…}.
61
+ // • maxRetries: cap on CONSECUTIVE retryable failures per turn.
62
+ // 0 disables retry entirely (caller falls straight through to
63
+ // fallbackModel / error). Default 3.
64
+ // • baseDelayMs / maxDelayMs: exponential backoff bounds used when
65
+ // the server didn't send a Retry-After header.
66
+ // • jitterRatio: ± random fraction applied to backoff; 0 disables.
67
+ llmRetry: {
68
+ maxRetries: 3,
69
+ baseDelayMs: 1_000,
70
+ maxDelayMs: 30_000,
71
+ jitterRatio: 0.25,
72
+ },
57
73
  };
58
74
 
75
+ // ─── llmRetry normalizer ────────────────────────────────────────
76
+
77
+ /**
78
+ * Merge user-supplied retry overrides with DEFAULTS.llmRetry, clamping
79
+ * each field to its safe range. The output is always a fully-populated
80
+ * object so the engine never has to deal with `undefined`. Unknown keys
81
+ * in the input are dropped.
82
+ *
83
+ * Precedence: overrides > fileConfig > DEFAULTS.
84
+ *
85
+ * @param {object | null | undefined} fileConfig
86
+ * @param {object | null | undefined} overrides
87
+ * @returns {{ maxRetries: number, baseDelayMs: number, maxDelayMs: number, jitterRatio: number }}
88
+ */
89
+ export function normalizeLlmRetry(fileConfig, overrides) {
90
+ const base = DEFAULTS.llmRetry;
91
+ const out = { ...base };
92
+ const apply = (src) => {
93
+ if (!src || typeof src !== 'object') return;
94
+ if (Number.isFinite(src.maxRetries) && src.maxRetries >= 0) {
95
+ out.maxRetries = Math.min(20, Math.floor(src.maxRetries));
96
+ }
97
+ if (Number.isFinite(src.baseDelayMs) && src.baseDelayMs >= 0) {
98
+ out.baseDelayMs = Math.min(60_000, Math.floor(src.baseDelayMs));
99
+ }
100
+ if (Number.isFinite(src.maxDelayMs) && src.maxDelayMs >= 0) {
101
+ out.maxDelayMs = Math.min(600_000, Math.floor(src.maxDelayMs));
102
+ }
103
+ if (Number.isFinite(src.jitterRatio) && src.jitterRatio >= 0) {
104
+ out.jitterRatio = Math.min(1, src.jitterRatio);
105
+ }
106
+ };
107
+ apply(fileConfig);
108
+ apply(overrides);
109
+ if (out.maxDelayMs < out.baseDelayMs) out.maxDelayMs = out.baseDelayMs;
110
+ return out;
111
+ }
112
+
59
113
  // ─── config.json reader ─────────────────────────────────────────
60
114
 
61
115
  /**
@@ -220,6 +274,7 @@ function loadLegacyConfig(dir, overrides) {
220
274
  const config = {
221
275
  model: overrides.model || env.YEAFT_MODEL || fileConfig.model || 'claude-sonnet-4-20250514',
222
276
  fallbackModel: overrides.fallbackModel || env.YEAFT_FALLBACK_MODEL || fileConfig.fallbackModel || null,
277
+ llmRetry: normalizeLlmRetry(fileConfig.llmRetry, overrides.llmRetry),
223
278
  language: overrides.language || env.YEAFT_LANGUAGE || fileConfig.language || DEFAULTS.language,
224
279
  apiKey: overrides.apiKey || env.YEAFT_API_KEY || fileConfig.apiKey || null,
225
280
  openaiApiKey: overrides.openaiApiKey || env.YEAFT_OPENAI_API_KEY || fileConfig.openaiApiKey || null,
@@ -341,6 +396,7 @@ export function loadConfig(overrides = {}) {
341
396
  fastModel: overrides.fastModel || fastModel,
342
397
  fastModelId: fastModelId,
343
398
  fallbackModel: jsonConfig.fallbackModel || null,
399
+ llmRetry: normalizeLlmRetry(jsonConfig.llmRetry, overrides.llmRetry),
344
400
  modelInfo: modelInfo || null,
345
401
 
346
402
  // Providers
package/yeaft/engine.js CHANGED
@@ -22,7 +22,7 @@ import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
24
24
  import { getRuntimePlatformInfo } from './runtime-platform.js';
25
- import { LLMContextError, LLMAbortError } from './llm/adapter.js';
25
+ import { LLMContextError, LLMAbortError, LLMRateLimitError, LLMServerError } from './llm/adapter.js';
26
26
  import { runMemoryPreflow, buildRelevantScopes } from './sessions/pre-flow.js';
27
27
  import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './sessions/project-doc.js';
28
28
  import { partitionMessages } from './compact/partition.js';
@@ -76,6 +76,80 @@ import {
76
76
  /** Maximum auto-continue turns when stopReason is 'max_tokens'. */
77
77
  const MAX_CONTINUE_TURNS = 3;
78
78
 
79
+ // ─── LLM retry policy defaults ──────────────────────────────────
80
+ // Hard-coded floor / ceiling for retry behaviour. The engine reads the
81
+ // effective policy from `config.llmRetry` so users can dial these via
82
+ // ~/.yeaft/config.json without touching code; the constants here are the
83
+ // fallback when the config is missing or partial.
84
+ // • maxRetries: how many times we re-issue the same turn on a
85
+ // retryable failure before giving up / falling back
86
+ // • baseDelayMs: starting backoff for transient (5xx / network) errors
87
+ // • maxDelayMs: ceiling we never exceed regardless of backoff growth
88
+ // • jitterRatio: ± random fraction of the delay (0.25 = ±25 %)
89
+ // Rate-limit waits prefer the server's Retry-After header; we only fall
90
+ // back to exponential backoff when the header is missing.
91
+ const RETRY_DEFAULTS = Object.freeze({
92
+ maxRetries: 3,
93
+ baseDelayMs: 1_000,
94
+ maxDelayMs: 30_000,
95
+ jitterRatio: 0.25,
96
+ });
97
+
98
+ function resolveRetryPolicy(config) {
99
+ const raw = config?.llmRetry || {};
100
+ const num = (v, d) => (Number.isFinite(v) && v >= 0 ? v : d);
101
+ return {
102
+ maxRetries: Math.max(0, Math.floor(num(raw.maxRetries, RETRY_DEFAULTS.maxRetries))),
103
+ baseDelayMs: Math.max(0, Math.floor(num(raw.baseDelayMs, RETRY_DEFAULTS.baseDelayMs))),
104
+ maxDelayMs: Math.max(0, Math.floor(num(raw.maxDelayMs, RETRY_DEFAULTS.maxDelayMs))),
105
+ jitterRatio: Math.min(1, Math.max(0, num(raw.jitterRatio, RETRY_DEFAULTS.jitterRatio))),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Compute the next backoff delay (ms) for a transient error.
111
+ * Exponential growth (base * 2^attempt) capped at maxDelayMs, with optional
112
+ * +/- jitter to avoid synchronized retry stampedes.
113
+ *
114
+ * @param {{ baseDelayMs: number, maxDelayMs: number, jitterRatio: number }} policy
115
+ * @param {number} attempt - 0-indexed retry attempt (0 = first retry)
116
+ * @returns {number} milliseconds to sleep
117
+ */
118
+ export function computeBackoffDelay(policy, attempt) {
119
+ const safeAttempt = Math.max(0, Math.floor(attempt));
120
+ const grown = policy.baseDelayMs * Math.pow(2, safeAttempt);
121
+ const capped = Math.min(policy.maxDelayMs, grown);
122
+ if (policy.jitterRatio <= 0) return capped;
123
+ const jitter = capped * policy.jitterRatio;
124
+ const offset = (Math.random() * 2 - 1) * jitter; // [-jitter, +jitter]
125
+ return Math.max(0, Math.round(capped + offset));
126
+ }
127
+
128
+ /**
129
+ * Promise-based sleep that resolves early if the caller's AbortSignal fires.
130
+ * Returns true when the sleep completed normally, false when aborted.
131
+ *
132
+ * @param {number} ms
133
+ * @param {AbortSignal | undefined} signal
134
+ * @returns {Promise<boolean>}
135
+ */
136
+ function sleepWithAbort(ms, signal) {
137
+ if (ms <= 0) return Promise.resolve(true);
138
+ if (signal?.aborted) return Promise.resolve(false);
139
+ return new Promise((resolve) => {
140
+ const timer = setTimeout(() => {
141
+ if (signal) signal.removeEventListener('abort', onAbort);
142
+ resolve(true);
143
+ }, ms);
144
+ const onAbort = () => {
145
+ clearTimeout(timer);
146
+ if (signal) signal.removeEventListener('abort', onAbort);
147
+ resolve(false);
148
+ };
149
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
150
+ });
151
+ }
152
+
79
153
  /**
80
154
  * task-331 — Map a conversationMessages entry into the snapshot shape used
81
155
  * by `debug_turn.messages`. Preserves the function-calling metadata that
@@ -226,8 +300,9 @@ export function shouldAllowGroupReflection({
226
300
  * @typedef {{ type: 'consolidate', archivedCount: number, extractedCount: number }} ConsolidateEvent
227
301
  * @typedef {{ type: 'recall', entryCount: number, cached: boolean }} RecallEvent
228
302
  * @typedef {{ type: 'fallback', from: string, to: string, reason: string }} FallbackEvent
303
+ * @typedef {{ type: 'llm_retry', attempt: number, maxRetries: number, delayMs: number, reason: 'rate_limit_retry_after'|'rate_limit_backoff'|'transient_backoff', errorName: string, statusCode: number|null, message: string }} LlmRetryEvent
229
304
  *
230
- * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent | ConsolidateEvent | RecallEvent | FallbackEvent} EngineEvent
305
+ * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent | ConsolidateEvent | RecallEvent | FallbackEvent | LlmRetryEvent} EngineEvent
231
306
  */
232
307
 
233
308
  // ─── Engine ──────────────────────────────────────────────────────
@@ -1760,6 +1835,16 @@ export class Engine {
1760
1835
  // outer-loop iteration so the flag never carries across turns.
1761
1836
  let endTurnRequested = null;
1762
1837
 
1838
+ // LLM retry bookkeeping (rate-limit / 5xx / transient network errors).
1839
+ // Counts CONSECUTIVE retryable failures on the same turn — reset to 0
1840
+ // on any successful stream() iteration, and also on a fallback-model
1841
+ // switch (the new model gets a fresh budget). Reaching maxRetries
1842
+ // gives up: we either fall back to a backup model or surface the
1843
+ // error to the user. LLMContextError has its own compact-retry path
1844
+ // and does NOT count against this budget.
1845
+ const retryPolicy = resolveRetryPolicy(this.#config);
1846
+ let consecutiveRetryableErrors = 0;
1847
+
1763
1848
  while (true) {
1764
1849
  turnNumber++;
1765
1850
 
@@ -2011,6 +2096,11 @@ export class Engine {
2011
2096
  break;
2012
2097
  }
2013
2098
  }
2099
+ // Stream completed without throwing — reset the retry counter so
2100
+ // the next turn starts with a clean budget. Note: this includes
2101
+ // stream() that emitted an in-band `error` event (server didn't
2102
+ // throw), since those are policy-specific and not transport-level.
2103
+ consecutiveRetryableErrors = 0;
2014
2104
  } catch (err) {
2015
2105
  const latencyMs = Date.now() - startTime;
2016
2106
  this.#trace.endTurn(turnId, {
@@ -2087,12 +2177,64 @@ export class Engine {
2087
2177
  }
2088
2178
  }
2089
2179
 
2180
+ // ─── Rate-limit / transient retry ─────────────────
2181
+ // Honour server-supplied Retry-After for 429/529; fall back to
2182
+ // exponential backoff for 5xx and transport failures wrapped as
2183
+ // LLMServerError. Counts against retryPolicy.maxRetries; on
2184
+ // exhaustion we fall through to the fallback-model path (and
2185
+ // ultimately the error event) without further waiting.
2186
+ const isRateLimit = err instanceof LLMRateLimitError;
2187
+ const isTransient = err instanceof LLMServerError;
2188
+ if (isRateLimit || isTransient) {
2189
+ if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
2190
+ consecutiveRetryableErrors += 1;
2191
+ let delayMs;
2192
+ let reason;
2193
+ if (isRateLimit && Number.isFinite(err.retryAfterMs) && err.retryAfterMs > 0) {
2194
+ // Server told us exactly when to come back. Respect it,
2195
+ // but still cap to maxDelayMs so a misconfigured upstream
2196
+ // can't make us hang forever on one turn.
2197
+ delayMs = Math.min(retryPolicy.maxDelayMs, err.retryAfterMs);
2198
+ reason = 'rate_limit_retry_after';
2199
+ } else if (isRateLimit) {
2200
+ // No header — use backoff but start one step higher so the
2201
+ // first retry isn't immediate (rate-limit windows are
2202
+ // usually >= 1s wide).
2203
+ delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors);
2204
+ reason = 'rate_limit_backoff';
2205
+ } else {
2206
+ delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors - 1);
2207
+ reason = 'transient_backoff';
2208
+ }
2209
+ yield {
2210
+ type: 'llm_retry',
2211
+ attempt: consecutiveRetryableErrors,
2212
+ maxRetries: retryPolicy.maxRetries,
2213
+ delayMs,
2214
+ reason,
2215
+ errorName: err.name,
2216
+ statusCode: err.statusCode ?? null,
2217
+ message: String(err.message || '').slice(0, 300),
2218
+ };
2219
+ const slept = await sleepWithAbort(delayMs, signal);
2220
+ if (!slept || signal?.aborted) {
2221
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2222
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2223
+ break;
2224
+ }
2225
+ yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
2226
+ continue; // retry the same turn with the same model
2227
+ }
2228
+ // Exhausted — fall through to fallback-model / error paths.
2229
+ }
2230
+
2090
2231
  // ─── Fallback model ──────────────────────────────
2091
2232
  const fallbackModel = this.#config.fallbackModel;
2092
2233
  if (fallbackModel && fallbackModel !== currentModel &&
2093
2234
  (err.name === 'LLMRateLimitError' || err.name === 'LLMServerError')) {
2094
2235
  yield { type: 'fallback', from: currentModel, to: fallbackModel, reason: err.message };
2095
2236
  currentModel = fallbackModel;
2237
+ consecutiveRetryableErrors = 0; // new model, fresh retry budget
2096
2238
  yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry', threadId };
2097
2239
  continue; // retry with fallback model
2098
2240
  }
@@ -108,6 +108,112 @@ export class LLMAbortError extends Error {
108
108
  }
109
109
  }
110
110
 
111
+ // ─── Retry helpers ─────────────────────────────────────────────
112
+
113
+ /**
114
+ * Parse an HTTP `Retry-After` header value into milliseconds.
115
+ *
116
+ * The header may be either:
117
+ * • An integer number of seconds (delta-seconds) — `"30"`
118
+ * • An HTTP-date — `"Fri, 31 Dec 1999 23:59:59 GMT"`
119
+ *
120
+ * Returns null when the header is missing, malformed, or yields a non-positive
121
+ * delay. Callers should treat null as "no server hint" and fall back to their
122
+ * own backoff schedule.
123
+ *
124
+ * @param {string | null | undefined} headerValue
125
+ * @returns {number | null} milliseconds to wait, or null
126
+ */
127
+ export function parseRetryAfterMs(headerValue) {
128
+ if (!headerValue) return null;
129
+ const trimmed = String(headerValue).trim();
130
+ if (!trimmed) return null;
131
+ // Integer seconds path
132
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
133
+ const seconds = Number(trimmed);
134
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
135
+ return Math.round(seconds * 1000);
136
+ }
137
+ // HTTP-date path
138
+ const ts = Date.parse(trimmed);
139
+ if (!Number.isFinite(ts)) return null;
140
+ const delta = ts - Date.now();
141
+ return delta > 0 ? delta : null;
142
+ }
143
+
144
+ /**
145
+ * Read the `retry-after` header from a fetch Response in a case-insensitive
146
+ * way that tolerates both real `Headers` instances and the plain object
147
+ * stand-ins our tests sometimes hand us.
148
+ *
149
+ * @param {Response | { headers?: Record<string, string> | Headers } | null | undefined} response
150
+ * @returns {number | null} milliseconds, or null when not present
151
+ */
152
+ export function retryAfterFromResponse(response) {
153
+ const headers = response?.headers;
154
+ if (!headers) return null;
155
+ let raw = null;
156
+ if (typeof headers.get === 'function') {
157
+ raw = headers.get('retry-after');
158
+ } else {
159
+ for (const key of Object.keys(headers)) {
160
+ if (key.toLowerCase() === 'retry-after') {
161
+ raw = headers[key];
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ return parseRetryAfterMs(raw);
167
+ }
168
+
169
+ /**
170
+ * Classify a raw error thrown by `fetch()` (or while reading a streaming
171
+ * body) into one of the unified LLM error types when it represents a
172
+ * retryable transport-level failure (DNS, ECONN*, socket reset, fetch
173
+ * `TypeError`, undici `UND_ERR_*`, …).
174
+ *
175
+ * Returns:
176
+ * • LLMAbortError — caller-initiated abort; engine short-circuits.
177
+ * • LLMServerError — transient transport failure; engine should retry.
178
+ * • the original error — anything that doesn't match a known transient
179
+ * pattern. We never invent retryability we can't
180
+ * prove from the error shape.
181
+ *
182
+ * @param {unknown} err
183
+ * @param {{ providerLabel?: string }} [opts]
184
+ * @returns {Error}
185
+ */
186
+ export function classifyFetchError(err, opts = {}) {
187
+ if (!(err instanceof Error)) return err instanceof Object ? err : new Error(String(err));
188
+ if (err.name === 'AbortError' || err.name === 'LLMAbortError') return new LLMAbortError();
189
+ // Anything already classified: keep as-is.
190
+ if (err instanceof LLMRateLimitError
191
+ || err instanceof LLMAuthError
192
+ || err instanceof LLMContextError
193
+ || err instanceof LLMServerError
194
+ || err instanceof LLMAbortError) {
195
+ return err;
196
+ }
197
+ const label = opts.providerLabel ? `${opts.providerLabel}: ` : '';
198
+ const code = err.cause?.code || err.code || null;
199
+ const transientCodes = new Set([
200
+ 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ETIMEDOUT',
201
+ 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH',
202
+ 'UND_ERR_SOCKET', 'UND_ERR_CLOSED', 'UND_ERR_BODY_TIMEOUT',
203
+ 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_CONNECT_TIMEOUT',
204
+ ]);
205
+ if (code && transientCodes.has(code)) {
206
+ return new LLMServerError(`${label}network error (${code}): ${err.message}`, 0);
207
+ }
208
+ // Node 20+ fetch surfaces network problems as a plain TypeError
209
+ // with `cause` set to the underlying undici error. We treat any
210
+ // such TypeError as transient — abort already returned above.
211
+ if (err.name === 'TypeError' && /fetch failed|terminated|network|socket/i.test(err.message || '')) {
212
+ return new LLMServerError(`${label}fetch failed: ${err.message}`, 0);
213
+ }
214
+ return err;
215
+ }
216
+
111
217
  // ─── Raw payload redaction helper ──────────────────────────────
112
218
 
113
219
  /**
@@ -9,6 +9,8 @@
9
9
  import {
10
10
  LLMAdapter,
11
11
  LLMRateLimitError,
12
+ classifyFetchError,
13
+ retryAfterFromResponse,
12
14
  LLMAuthError,
13
15
  LLMContextError,
14
16
  LLMServerError,
@@ -175,18 +177,22 @@ export class AnthropicAdapter extends LLMAdapter {
175
177
  * Classify HTTP errors into our typed errors.
176
178
  * @param {number} status
177
179
  * @param {string} body
180
+ * @param {Response | { headers?: Record<string, string> } | null} [response] -
181
+ * When provided, reads the `retry-after` header so the engine can honor
182
+ * the server hint instead of guessing a backoff delay.
178
183
  */
179
- #classifyError(status, body) {
184
+ #classifyError(status, body, response = null) {
180
185
  const authHint = `auth=${this.#authHeaderMode}`;
181
186
  if (status === 401 || status === 403) {
182
187
  return new LLMAuthError(`Anthropic auth error (${authHint}): ${body}`, status);
183
188
  }
184
189
  if (status === 429) {
185
- const retryAfter = null; // Could parse retry-after header
190
+ const retryAfter = retryAfterFromResponse(response);
186
191
  return new LLMRateLimitError(`Anthropic rate limit (${authHint}): ${body}`, status, retryAfter);
187
192
  }
188
193
  if (status === 529) {
189
- return new LLMRateLimitError(`Anthropic overloaded (${authHint}): ${body}`, status);
194
+ const retryAfter = retryAfterFromResponse(response);
195
+ return new LLMRateLimitError(`Anthropic overloaded (${authHint}): ${body}`, status, retryAfter);
190
196
  }
191
197
  if (body.includes('prompt is too long') || body.includes('max_tokens')) {
192
198
  return new LLMContextError(`Anthropic context error (${authHint}): ${body}`);
@@ -231,12 +237,17 @@ export class AnthropicAdapter extends LLMAdapter {
231
237
  // exactly what we POST to the LLM.
232
238
  const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
233
239
 
234
- const response = await fetch(url, {
235
- method: 'POST',
236
- headers,
237
- body: JSON.stringify(body),
238
- signal,
239
- });
240
+ let response;
241
+ try {
242
+ response = await fetch(url, {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify(body),
246
+ signal,
247
+ });
248
+ } catch (err) {
249
+ throw classifyFetchError(err, { providerLabel: 'Anthropic' });
250
+ }
240
251
 
241
252
  if (!response.ok) {
242
253
  const errorBody = await response.text();
@@ -253,7 +264,7 @@ export class AnthropicAdapter extends LLMAdapter {
253
264
  });
254
265
  } catch { /* ignore */ }
255
266
  }
256
- throw this.#classifyError(response.status, errorBody);
267
+ throw this.#classifyError(response.status, errorBody, response);
257
268
  }
258
269
 
259
270
  // Parse SSE stream
@@ -469,16 +480,21 @@ export class AnthropicAdapter extends LLMAdapter {
469
480
  applyAnthropicThinking(body, model, normEffort);
470
481
  }
471
482
 
472
- const response = await fetch(`${this.#baseUrl}/v1/messages`, {
473
- method: 'POST',
474
- headers: this.#headers(),
475
- body: JSON.stringify(body),
476
- signal,
477
- });
483
+ let response;
484
+ try {
485
+ response = await fetch(`${this.#baseUrl}/v1/messages`, {
486
+ method: 'POST',
487
+ headers: this.#headers(),
488
+ body: JSON.stringify(body),
489
+ signal,
490
+ });
491
+ } catch (err) {
492
+ throw classifyFetchError(err, { providerLabel: 'Anthropic' });
493
+ }
478
494
 
479
495
  if (!response.ok) {
480
496
  const errorBody = await response.text();
481
- throw this.#classifyError(response.status, errorBody);
497
+ throw this.#classifyError(response.status, errorBody, response);
482
498
  }
483
499
 
484
500
  const result = await response.json();
@@ -32,6 +32,8 @@ import {
32
32
  LLMContextError,
33
33
  LLMServerError,
34
34
  LLMAbortError,
35
+ classifyFetchError,
36
+ retryAfterFromResponse,
35
37
  redactRawRequest,
36
38
  safeHeaders,
37
39
  } from './adapter.js';
@@ -187,15 +189,17 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
187
189
 
188
190
  // ─── Error classification ───────────────────────────────
189
191
 
190
- #classifyError(status, body) {
192
+ #classifyError(status, body, response = null) {
191
193
  if (status === 401 || status === 403) {
192
194
  return new LLMAuthError(`Auth error: ${body}`, status);
193
195
  }
194
196
  if (status === 429) {
195
- return new LLMRateLimitError(`Rate limit: ${body}`, status);
197
+ const retryAfter = retryAfterFromResponse(response);
198
+ return new LLMRateLimitError(`Rate limit: ${body}`, status, retryAfter);
196
199
  }
197
200
  if (status === 529) {
198
- return new LLMRateLimitError(`Overloaded: ${body}`, status);
201
+ const retryAfter = retryAfterFromResponse(response);
202
+ return new LLMRateLimitError(`Overloaded: ${body}`, status, retryAfter);
199
203
  }
200
204
  if (status === 413 || body.includes('context_length_exceeded') || body.includes('maximum context length')) {
201
205
  return new LLMContextError(`Context too long: ${body}`);
@@ -286,7 +290,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
286
290
  });
287
291
  } catch (err) {
288
292
  if (err.name === 'AbortError') throw new LLMAbortError();
289
- throw err;
293
+ throw classifyFetchError(err, { providerLabel: 'OpenAI' });
290
294
  }
291
295
 
292
296
  if (!response.ok) {
@@ -304,7 +308,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
304
308
  });
305
309
  } catch { /* ignore */ }
306
310
  }
307
- throw this.#classifyError(response.status, errorBody);
311
+ throw this.#classifyError(response.status, errorBody, response);
308
312
  }
309
313
 
310
314
  const reader = response.body.getReader();
@@ -512,12 +516,12 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
512
516
  });
513
517
  } catch (err) {
514
518
  if (err.name === 'AbortError') throw new LLMAbortError();
515
- throw err;
519
+ throw classifyFetchError(err, { providerLabel: 'OpenAI' });
516
520
  }
517
521
 
518
522
  if (!response.ok) {
519
523
  const errorBody = await response.text();
520
- throw this.#classifyError(response.status, errorBody);
524
+ throw this.#classifyError(response.status, errorBody, response);
521
525
  }
522
526
 
523
527
  const result = await response.json();
@@ -2264,6 +2264,23 @@ function handleEngineEvent(event, hctx) {
2264
2264
  }, envelope);
2265
2265
  break;
2266
2266
 
2267
+ case 'llm_retry':
2268
+ // Engine paused before re-issuing the same turn because the LLM
2269
+ // returned a retryable error (rate limit / 5xx / transient network).
2270
+ // Surface to the client so the UI can show "retrying in Xs (1/3)"
2271
+ // instead of looking frozen mid-turn.
2272
+ sendSessionEvent({
2273
+ type: 'llm_retry',
2274
+ attempt: event.attempt,
2275
+ maxRetries: event.maxRetries,
2276
+ delayMs: event.delayMs,
2277
+ reason: event.reason,
2278
+ errorName: event.errorName,
2279
+ statusCode: event.statusCode,
2280
+ message: event.message,
2281
+ }, envelope);
2282
+ break;
2283
+
2267
2284
  case 'reflection':
2268
2285
  sendSessionEvent({
2269
2286
  type: 'reflection',