@xingjisoft/neutrino 0.1.6 → 0.2.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.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Plan-first turns.
3
+ *
4
+ * A request is answered in two passes. A *planner* agent turns the user's text
5
+ * into an ordered list of independent steps; a *worker* agent is then handed one
6
+ * step at a time and never sees the rest of the plan as work it may start. That
7
+ * split is what makes the later work explicit: every step names one deliverable,
8
+ * so it can be executed, verified and shown on its own.
9
+ *
10
+ * This module is deliberately free of I/O: it parses the planner's answer, turns
11
+ * the plan into prompt text, and lays the plan out as the rows of the info card.
12
+ * The model calls themselves live in the two agent loops.
13
+ */
14
+ import { displayWidth, ellipsize } from "../width.js";
15
+ /**
16
+ * One step is not a plan, it is the answer: such a turn runs as a plain turn.
17
+ * The ceiling keeps the card readable and the turn's cost proportionate.
18
+ */
19
+ export const PLAN_MIN_STEPS = 2;
20
+ export const PLAN_MAX_STEPS = 10;
21
+ export const PLAN_MAX_TITLE = 160;
22
+ export const PLAN_MAX_DETAIL = 400;
23
+ // ------------------------------------------------------------------ parsing
24
+ function cleanText(value, max) {
25
+ if (typeof value !== "string")
26
+ return "";
27
+ return value.replace(/\s+/g, " ").trim().slice(0, max);
28
+ }
29
+ function normalizeStep(value) {
30
+ if (typeof value === "string") {
31
+ const title = cleanText(value, PLAN_MAX_TITLE);
32
+ return title ? { title, status: "pending" } : null;
33
+ }
34
+ if (!value || typeof value !== "object")
35
+ return null;
36
+ const o = value;
37
+ // Models name the same field differently; accept the common spellings rather
38
+ // than rejecting a plan over a key name.
39
+ const title = cleanText(o.title ?? o.step ?? o.task ?? o.action, PLAN_MAX_TITLE);
40
+ if (!title)
41
+ return null;
42
+ const detail = cleanText(o.detail ?? o.description ?? o.how ?? o.acceptance, PLAN_MAX_DETAIL);
43
+ return { title, status: "pending", ...(detail ? { detail } : {}) };
44
+ }
45
+ /** Turn an already-parsed planner answer into a plan, or null when unusable. */
46
+ export function normalizePlan(value) {
47
+ if (!value || typeof value !== "object")
48
+ return null;
49
+ const o = value;
50
+ const rawSteps = Array.isArray(value) ? value : Array.isArray(o.steps) ? o.steps : Array.isArray(o.plan) ? o.plan : null;
51
+ if (!rawSteps)
52
+ return null;
53
+ const steps = [];
54
+ for (const item of rawSteps) {
55
+ const step = normalizeStep(item);
56
+ if (step)
57
+ steps.push(step);
58
+ if (steps.length >= PLAN_MAX_STEPS)
59
+ break;
60
+ }
61
+ if (steps.length < PLAN_MIN_STEPS)
62
+ return null;
63
+ const goal = cleanText(o.goal ?? o.title ?? o.summary ?? o.request, 240);
64
+ return { goal, steps };
65
+ }
66
+ /**
67
+ * Every top-level `{…}` block in the text, in order.
68
+ *
69
+ * A planner is asked for bare JSON but models still wrap it in a fence or add a
70
+ * sentence before it; scanning for a balanced object finds it without guessing
71
+ * where the JSON starts and stops. Strings and escapes are tracked so a brace
72
+ * inside a step title cannot end the scan early.
73
+ */
74
+ function jsonCandidates(text) {
75
+ const out = [];
76
+ let depth = 0;
77
+ let start = -1;
78
+ let inString = false;
79
+ let escaped = false;
80
+ for (let i = 0; i < text.length; i++) {
81
+ const ch = text[i];
82
+ if (inString) {
83
+ if (escaped)
84
+ escaped = false;
85
+ else if (ch === "\\")
86
+ escaped = true;
87
+ else if (ch === '"')
88
+ inString = false;
89
+ continue;
90
+ }
91
+ if (ch === '"')
92
+ inString = true;
93
+ else if (ch === "{") {
94
+ if (depth === 0)
95
+ start = i;
96
+ depth++;
97
+ }
98
+ else if (ch === "}") {
99
+ depth--;
100
+ if (depth === 0 && start >= 0) {
101
+ out.push(text.slice(start, i + 1));
102
+ start = -1;
103
+ }
104
+ if (depth < 0)
105
+ depth = 0;
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+ /** A numbered or bulleted list, for a planner that answered in prose. */
111
+ function planFromList(text) {
112
+ const steps = [];
113
+ for (const line of text.split("\n")) {
114
+ // "1." "1)" "1、" and bullets all show up in practice, with or without a
115
+ // space after the marker.
116
+ const match = /^\s*(?:\d+\s*[.):、]\s*|[-*•]\s+)(.+)$/.exec(line);
117
+ if (!match)
118
+ continue;
119
+ const title = cleanText(match[1], PLAN_MAX_TITLE);
120
+ if (!title)
121
+ continue;
122
+ steps.push({ title, status: "pending" });
123
+ if (steps.length >= PLAN_MAX_STEPS)
124
+ break;
125
+ }
126
+ if (steps.length < PLAN_MIN_STEPS)
127
+ return null;
128
+ return { goal: "", steps };
129
+ }
130
+ /**
131
+ * Read a planner answer. JSON first, because that is what the prompt asks for,
132
+ * then a numbered list so a model that ignores the format still produces a
133
+ * usable plan. Anything else is "no plan": the turn then runs as before.
134
+ */
135
+ export function parsePlan(text) {
136
+ const raw = String(text ?? "").trim();
137
+ if (!raw || /^\[Error/.test(raw))
138
+ return null;
139
+ for (const candidate of jsonCandidates(raw)) {
140
+ let value;
141
+ try {
142
+ value = JSON.parse(candidate);
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ const plan = normalizePlan(value);
148
+ if (plan)
149
+ return plan;
150
+ }
151
+ return planFromList(raw);
152
+ }
153
+ /**
154
+ * The user turn handed to the planner agent. The planner gets no tools, so
155
+ * everything it needs to make the steps concrete is in this text: where the
156
+ * work happens, what was said before, and what is being asked now.
157
+ */
158
+ export function buildPlannerInput(input) {
159
+ const parts = [];
160
+ if (input.cwd)
161
+ parts.push(`Working directory: ${input.cwd}`);
162
+ const transcript = (input.transcript ?? "").trim();
163
+ if (transcript)
164
+ parts.push(`Conversation so far (oldest first):\n${transcript}`);
165
+ parts.push(`Current request:\n${input.request.trim()}`);
166
+ return parts.join("\n\n");
167
+ }
168
+ /** The plan as numbered text: what the worker and the reviewers read. */
169
+ export function planSpec(plan) {
170
+ return plan.steps
171
+ .map((step, i) => `${i + 1}. ${step.title}${step.detail ? `\n detail: ${step.detail}` : ""}`)
172
+ .join("\n");
173
+ }
174
+ /**
175
+ * The plan as the user reads it in the transcript. It is an ordinary assistant
176
+ * message on purpose: it is saved, replayed, folded and sent back to the model
177
+ * exactly like any other answer.
178
+ *
179
+ * A detail stays on its step's line: markdown renderers fold an indented line
180
+ * into the list item before it, which would run two steps together in the log.
181
+ */
182
+ export function planMessage(plan) {
183
+ const count = plan.steps.length;
184
+ const lines = [`Plan · ${count} step${count === 1 ? "" : "s"}`];
185
+ if (plan.goal)
186
+ lines.push(`> ${plan.goal}`);
187
+ plan.steps.forEach((step, i) => {
188
+ lines.push(`${i + 1}. ${step.title}${step.detail ? ` — ${step.detail}` : ""}`);
189
+ });
190
+ return lines.join("\n");
191
+ }
192
+ /**
193
+ * The system text for one step. It repeats the whole plan so the worker knows
194
+ * where the step sits, and states the two rules the split exists for: do only
195
+ * this step, and stop when it is verified.
196
+ */
197
+ export function renderStepDirective(template, plan, index) {
198
+ const step = plan.steps[index];
199
+ const detail = step?.detail ? `Detail for this step: ${step.detail}` : "";
200
+ return template
201
+ .replace(/\{\{index\}\}/g, String(index + 1))
202
+ .replace(/\{\{total\}\}/g, String(plan.steps.length))
203
+ .replace(/\{\{plan\}\}/g, planSpec(plan))
204
+ .replace(/\{\{step\}\}/g, step?.title ?? "")
205
+ .replace(/\{\{detail\}\}/g, detail)
206
+ .trim();
207
+ }
208
+ /** How far a plan got. Used by the card header and by tests. */
209
+ export function planProgress(plan) {
210
+ let done = 0;
211
+ let active = -1;
212
+ plan.steps.forEach((step, i) => {
213
+ if (step.status === "done")
214
+ done++;
215
+ if (step.status === "active" && active < 0)
216
+ active = i;
217
+ });
218
+ return { done, total: plan.steps.length, active };
219
+ }
220
+ // ------------------------------------------------------- plan bookkeeping
221
+ //
222
+ // Both agent loops (terminal and browser) keep the same plan state machine, so
223
+ // the transitions live here instead of being copied into each of them.
224
+ /** A plan loaded from disk was, by definition, no longer running. */
225
+ export function resumePlan(loaded) {
226
+ if (!loaded || !Array.isArray(loaded.steps))
227
+ return null;
228
+ // Session files are hand-editable: drop entries that are not steps rather
229
+ // than rendering "undefined" in the card.
230
+ const steps = loaded.steps.filter((step) => step && typeof step.title === "string" && step.title.trim());
231
+ if (steps.length === 0)
232
+ return null;
233
+ loaded.steps = steps;
234
+ for (const step of steps) {
235
+ if (step.status !== "pending" && step.status !== "active" && step.status !== "done" && step.status !== "failed") {
236
+ step.status = "pending";
237
+ }
238
+ if (step.status === "active") {
239
+ // Its worker is gone: the card must not claim a step is running, and the
240
+ // plan is now a record of where the turn stopped.
241
+ step.status = "pending";
242
+ loaded.stopped = true;
243
+ }
244
+ }
245
+ return loaded;
246
+ }
247
+ /** Point the plan at one step. Returns false when there was nothing to change. */
248
+ export function advancePlan(plan, index, status) {
249
+ const step = plan?.steps[index];
250
+ if (!plan || !step)
251
+ return false;
252
+ step.status = status;
253
+ if (status === "active")
254
+ plan.stopped = false;
255
+ return true;
256
+ }
257
+ /** A turn that cannot continue must not leave a step looking alive. */
258
+ export function stopPlan(plan) {
259
+ if (!plan)
260
+ return false;
261
+ const active = plan.steps.find((step) => step.status === "active");
262
+ if (!active)
263
+ return false;
264
+ active.status = "pending";
265
+ plan.stopped = true;
266
+ return true;
267
+ }
268
+ /** The user message a turn answers. */
269
+ export function lastRequestText(messages) {
270
+ for (let i = messages.length - 1; i >= 0; i--) {
271
+ const message = messages[i];
272
+ if (message.role === "user")
273
+ return message.content ?? "";
274
+ }
275
+ return "";
276
+ }
277
+ /** How many earlier messages and how much of each the planner is shown. */
278
+ const PLANNER_TRANSCRIPT_MESSAGES = 8;
279
+ const PLANNER_TRANSCRIPT_CHARS = 600;
280
+ const PLANNER_TOOL_CHARS = 400;
281
+ /**
282
+ * The exchanges before the current request, for the planner.
283
+ *
284
+ * Tool output is clipped hard: the planner needs to know what was done, not to
285
+ * re-read it, and a long log must not crowd the request out of its own plan.
286
+ */
287
+ export function planTranscript(messages) {
288
+ let lastUser = -1;
289
+ for (let i = messages.length - 1; i >= 0; i--) {
290
+ if (messages[i].role === "user") {
291
+ lastUser = i;
292
+ break;
293
+ }
294
+ }
295
+ if (lastUser < 0)
296
+ return "";
297
+ const from = Math.max(0, lastUser - PLANNER_TRANSCRIPT_MESSAGES);
298
+ const lines = [];
299
+ for (let i = from; i < lastUser; i++) {
300
+ const message = messages[i];
301
+ if (message.role === "system")
302
+ continue;
303
+ if (message.role === "tool") {
304
+ lines.push(`TOOL ${message.name ?? "tool"}: ${clipText(message.result ?? "", PLANNER_TOOL_CHARS)}`);
305
+ }
306
+ else if (message.role === "summary") {
307
+ lines.push(`SUMMARY: ${clipText(message.content ?? "", PLANNER_TOOL_CHARS)}`);
308
+ }
309
+ else {
310
+ lines.push(`${message.role.toUpperCase()}: ${clipText(message.content ?? "", PLANNER_TRANSCRIPT_CHARS)}`);
311
+ }
312
+ }
313
+ return lines.join("\n");
314
+ }
315
+ function clipText(text, max) {
316
+ const trimmed = text.trim();
317
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
318
+ }
319
+ // Deliberately plain glyphs: every one of these is already used elsewhere in the
320
+ // terminal UI, so a font that draws those lines draws the plan too.
321
+ const STEP_GLYPH = {
322
+ pending: "○",
323
+ active: "●",
324
+ done: "✓",
325
+ failed: "✗",
326
+ };
327
+ /**
328
+ * Lay the plan out as a vertical timeline, top to bottom in execution order.
329
+ *
330
+ * A finished step is drawn dark, a step still to come is drawn lit, and the step
331
+ * being worked on is the one highlighted row — so the card reads as "these are
332
+ * done, this is running, these are next" without a legend. When the plan is
333
+ * longer than the room, a window around the running step is shown with the
334
+ * number of steps above and below it, rather than dropping the middle.
335
+ */
336
+ export function planCardRows(plan, options) {
337
+ const width = Math.max(12, Math.floor(options.width));
338
+ const maxRows = Math.max(2, Math.floor(options.maxRows));
339
+ const steps = plan.steps;
340
+ const { done } = planProgress(plan);
341
+ const failed = steps.some((step) => step.status === "failed");
342
+ let header = `Plan · ${done}/${steps.length}`;
343
+ if (failed)
344
+ header += " · failed";
345
+ else if (done === steps.length)
346
+ header += " · done";
347
+ else if (plan.stopped)
348
+ header += " · stopped";
349
+ const rows = [{ kind: "header", text: ellipsize(header, width) }];
350
+ const available = maxRows - rows.length;
351
+ if (available <= 0)
352
+ return rows;
353
+ const focus = (() => {
354
+ const active = steps.findIndex((step) => step.status === "active");
355
+ if (active >= 0)
356
+ return active;
357
+ const firstFailed = steps.findIndex((step) => step.status === "failed");
358
+ if (firstFailed >= 0)
359
+ return firstFailed;
360
+ const firstPending = steps.findIndex((step) => step.status === "pending");
361
+ if (firstPending >= 0)
362
+ return firstPending;
363
+ return Math.max(0, steps.length - 1);
364
+ })();
365
+ // A step occupies a node row plus the connector below it, except the last
366
+ // visible one; the "… more" markers take a row each.
367
+ let span = Math.max(1, Math.min(steps.length, Math.floor((available + 1) / 2)));
368
+ let start = 0;
369
+ let end = steps.length;
370
+ for (;;) {
371
+ start = Math.max(0, Math.min(focus - Math.floor(span / 2), steps.length - span));
372
+ end = start + span;
373
+ const markers = (start > 0 ? 1 : 0) + (end < steps.length ? 1 : 0);
374
+ if (span * 2 - 1 + markers <= available || span <= 1)
375
+ break;
376
+ span -= 1;
377
+ }
378
+ const plural = (n) => `step${n === 1 ? "" : "s"}`;
379
+ if (start > 0) {
380
+ rows.push({ kind: "more", text: ellipsize(`… ${start} earlier ${plural(start)}`, width) });
381
+ }
382
+ for (let i = start; i < end; i++) {
383
+ const step = steps[i];
384
+ rows.push({
385
+ kind: `step-${step.status}`,
386
+ text: ellipsize(`${STEP_GLYPH[step.status]} ${i + 1}. ${step.title}`, width),
387
+ });
388
+ if (i < end - 1)
389
+ rows.push({ kind: "connector", text: "│" });
390
+ }
391
+ if (end < steps.length) {
392
+ const later = steps.length - end;
393
+ rows.push({ kind: "more", text: ellipsize(`… ${later} later ${plural(later)}`, width) });
394
+ }
395
+ return rows;
396
+ }
397
+ /** The width a card row actually needs, in columns. */
398
+ export function planRowsWidth(rows) {
399
+ let width = 0;
400
+ for (const row of rows)
401
+ width = Math.max(width, displayWidth(row.text));
402
+ return width;
403
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Retry policy for the model connection.
3
+ *
4
+ * A single turn is minutes of work, and the connection carries it the whole way.
5
+ * Providers, and the proxies and load balancers in front of them, drop sockets
6
+ * for reasons a client can never observe: an idle timeout, a rolling restart, a
7
+ * middlebox resetting the flow. Those blips clear on their own within seconds,
8
+ * so a failed request that is simply re-sent saves the turn — while surfacing
9
+ * the error throws away everything the user typed and everything the model had
10
+ * already produced.
11
+ *
12
+ * The policy is exponential: seven attempts in total, waiting 5s, 10s, 20s,
13
+ * 40s, 80s and finally 160s between them (about five minutes end to end). The
14
+ * waits grow because a link that is merely flaky recovers long before the
15
+ * budget runs out, and a link that is genuinely down must not be hammered.
16
+ */
17
+ /** Total tries, the first one included. */
18
+ export const DEFAULT_NETWORK_ATTEMPTS = 7;
19
+ /** Wait before the first retry; every further retry doubles it. */
20
+ export const DEFAULT_RETRY_DELAY_MS = 5_000;
21
+ /** Parse an override, ignoring anything that is not a positive integer. */
22
+ function positiveInt(raw) {
23
+ if (typeof raw !== "string")
24
+ return null;
25
+ const trimmed = raw.trim();
26
+ if (!trimmed)
27
+ return null;
28
+ const value = Number(trimmed);
29
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
30
+ }
31
+ /**
32
+ * The policy in force.
33
+ *
34
+ * Read on every use rather than cached at import time, so the environment can
35
+ * tune it (`NEUTRINO_NETWORK_ATTEMPTS`, `NEUTRINO_RETRY_DELAY_MS`) without a
36
+ * rebuild and the tests can collapse the waits to a millisecond.
37
+ */
38
+ export function retryPolicy() {
39
+ return {
40
+ attempts: positiveInt(process.env.NEUTRINO_NETWORK_ATTEMPTS) ?? DEFAULT_NETWORK_ATTEMPTS,
41
+ baseDelayMs: positiveInt(process.env.NEUTRINO_RETRY_DELAY_MS) ?? DEFAULT_RETRY_DELAY_MS,
42
+ };
43
+ }
44
+ /** Wait before retry number `retry` (0-based): base, 2×base, 4×base, … */
45
+ export function retryDelayMs(retry, policy = retryPolicy()) {
46
+ return policy.baseDelayMs * 2 ** Math.max(0, Math.floor(retry));
47
+ }
48
+ /** A wait in human terms: "5s", "40s", "2m 40s". */
49
+ export function formatDelay(ms) {
50
+ const total = Math.max(0, Math.round(ms / 1000));
51
+ if (total < 60)
52
+ return `${total}s`;
53
+ const minutes = Math.floor(total / 60);
54
+ const seconds = total % 60;
55
+ return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
56
+ }
57
+ /**
58
+ * Terse transport messages that all mean "the connection dropped mid-flight".
59
+ * The \b keeps "terminated" from matching unrelated text such as
60
+ * "Unterminated string constant".
61
+ */
62
+ const DROP_HINT_SOURCE = "\\bterminated\\b|other side closed|socket hang up|premature close";
63
+ export const DROP_HINT_RE = new RegExp(DROP_HINT_SOURCE, "i");
64
+ /**
65
+ * Superset of the hints above, used to decide whether to retry: a socket can
66
+ * also be reported as a bare "read ECONNRESET" with no `code` attached.
67
+ */
68
+ const TRANSIENT_MESSAGE_RE = new RegExp(`${DROP_HINT_SOURCE}|connection reset|network is unreachable|connection aborted|timed out|` +
69
+ "\\b(?:ECONNRESET|ECONNREFUSED|ECONNABORTED|EPIPE|ETIMEDOUT|ENETUNREACH|ENETDOWN|EHOSTUNREACH|EAI_AGAIN)\\b", "i");
70
+ /** `code` values that describe a broken socket rather than a bad request. */
71
+ const TRANSIENT_CODES = new Set([
72
+ "ECONNRESET",
73
+ "ECONNREFUSED",
74
+ "ECONNABORTED",
75
+ "EPIPE",
76
+ "ETIMEDOUT",
77
+ "ENETUNREACH",
78
+ "ENETDOWN",
79
+ "EHOSTUNREACH",
80
+ "EAI_AGAIN",
81
+ "ERR_STREAM_PREMATURE_CLOSE",
82
+ "UND_ERR_SOCKET",
83
+ "UND_ERR_CLOSED",
84
+ "UND_ERR_CONNECT_TIMEOUT",
85
+ "UND_ERR_HEADERS_TIMEOUT",
86
+ "UND_ERR_BODY_TIMEOUT",
87
+ "UND_ERR_RESPONSE_STATUS_CODE",
88
+ ]);
89
+ /**
90
+ * Flatten an error and its `cause` chain into one readable line.
91
+ *
92
+ * `fetch` reports almost every transport problem as a bare
93
+ * `TypeError: fetch failed`; the actual reason (`ECONNRESET`, `EMFILE`,
94
+ * `ENOTFOUND`, `UND_ERR_…`) is tucked away in `error.cause`. Without walking
95
+ * that chain a failure is impossible to diagnose, so this always reports it.
96
+ */
97
+ export function describeError(e) {
98
+ if (!(e instanceof Error))
99
+ return String(e);
100
+ const parts = [];
101
+ const push = (msg, code) => {
102
+ const text = code && !msg.includes(code) ? `${msg} (${code})` : msg;
103
+ if (text && !parts.includes(text))
104
+ parts.push(text);
105
+ };
106
+ let cur = e;
107
+ const seen = new Set();
108
+ while (cur != null && !seen.has(cur)) {
109
+ if (typeof cur === "string") {
110
+ // fetch/undici sometimes stash the real reason in a plain string
111
+ // (`cause: "other side closed"`), which an object-only walk would miss.
112
+ push(cur);
113
+ break;
114
+ }
115
+ if (typeof cur !== "object")
116
+ break;
117
+ seen.add(cur);
118
+ const err = cur;
119
+ const message = typeof err.message === "string" && err.message ? err.message : String(err.name ?? "Error");
120
+ const code = typeof err.code === "string" ? err.code : undefined;
121
+ push(message, code);
122
+ cur = err.cause;
123
+ }
124
+ const text = parts.join(": ") || e.message;
125
+ // A dropped mid-stream connection surfaces as a bare `TypeError: terminated`,
126
+ // which on its own tells the user nothing worth reading.
127
+ return DROP_HINT_RE.test(text) ? `${text} — the connection was closed before the response finished` : text;
128
+ }
129
+ /**
130
+ * Whether a failed request is worth retrying: a transient connection problem
131
+ * (reset / refused / DNS / timeout) rather than a real response from the API.
132
+ */
133
+ export function isRetriableNetworkError(e) {
134
+ let cur = e;
135
+ const seen = new Set();
136
+ while (cur != null && !seen.has(cur)) {
137
+ if (typeof cur === "string") {
138
+ if (TRANSIENT_MESSAGE_RE.test(cur))
139
+ return true;
140
+ break;
141
+ }
142
+ if (typeof cur !== "object")
143
+ break;
144
+ seen.add(cur);
145
+ const err = cur;
146
+ // undici prefixes its own transport codes with UND_ERR_.
147
+ if (typeof err.code === "string" && (TRANSIENT_CODES.has(err.code) || err.code.startsWith("UND_ERR_"))) {
148
+ return true;
149
+ }
150
+ // Our own connect timeout aborts with a plain AbortError; undici wraps a
151
+ // dropped socket in a SocketError.
152
+ if (err.name === "AbortError" || err.name === "SocketError")
153
+ return true;
154
+ // A stream that dies mid-flight: "terminated", "socket hang up", …
155
+ if (typeof err.message === "string" && TRANSIENT_MESSAGE_RE.test(err.message))
156
+ return true;
157
+ cur = err.cause;
158
+ }
159
+ // Undici's generic wrapper: assume the socket dropped and try again.
160
+ return e instanceof Error && /fetch failed/i.test(e.message);
161
+ }
162
+ /**
163
+ * Statuses worth re-sending, because the server (or the proxy in front of it)
164
+ * never produced an answer to *this* request: 408/425 mean "too slow, retry",
165
+ * 429 and 529 mean "overloaded, back off", and the 5xx family is a transient
166
+ * backend failure — a 502/503/504 from a load balancer is the HTTP face of the
167
+ * very connection reset the retry policy exists for.
168
+ *
169
+ * Everything else — 400, 401, 403, 404, 422 — means the request itself is wrong
170
+ * or the key is bad, and re-sending it would only delay the real message.
171
+ */
172
+ const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
173
+ export function isRetriableStatus(status) {
174
+ return TRANSIENT_STATUS.has(status);
175
+ }
176
+ /** A response that did arrive, but carried a failure status. */
177
+ export class HttpStatusError extends Error {
178
+ status;
179
+ constructor(status, body) {
180
+ super(`HTTP ${status}${body ? " - " + body : ""}`);
181
+ this.name = "HttpStatusError";
182
+ this.status = status;
183
+ }
184
+ }
185
+ /** Whether a failure is transient, including HTTP statuses that invite a retry. */
186
+ export function isRetriableFailure(e) {
187
+ if (e instanceof HttpStatusError)
188
+ return isRetriableStatus(e.status);
189
+ return isRetriableNetworkError(e);
190
+ }
191
+ /**
192
+ * Sleep before retry number `retry`, in short slices.
193
+ *
194
+ * The wait can reach 160s, so it must not be a single `setTimeout`: a paused
195
+ * turn or a newer message has to be able to cut it short. Returns false when
196
+ * `cancelled` fired, which tells the caller to stop without an error.
197
+ */
198
+ export async function waitBeforeRetry(retry, policy, cancelled) {
199
+ const sliceMs = 100;
200
+ let left = retryDelayMs(retry, policy);
201
+ while (left > 0) {
202
+ if (cancelled?.())
203
+ return false;
204
+ const step = Math.min(sliceMs, left);
205
+ await new Promise((resolve) => setTimeout(resolve, step));
206
+ left -= step;
207
+ }
208
+ return !cancelled?.();
209
+ }