@xingjisoft/neutrino 0.1.7 → 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.
- package/README.md +136 -28
- package/assets/planner.txt +35 -0
- package/assets/prompt.txt +56 -21
- package/assets/step.txt +14 -0
- package/assets/subagent.txt +20 -0
- package/dist/app.js +44 -2
- package/dist/bin.js +38 -13
- package/dist/config.js +45 -1
- package/dist/header.js +5 -0
- package/dist/llm/client.js +64 -140
- package/dist/llm/plan.js +403 -0
- package/dist/llm/retry.js +209 -0
- package/dist/llm/subagent.js +134 -0
- package/dist/llm/tools.js +43 -1
- package/dist/screens/chat.js +369 -51
- package/dist/screens/settings.js +30 -1
- package/dist/state.js +1 -0
- package/dist/web/session.js +211 -22
- package/package.json +2 -2
- package/web/README.md +46 -10
- package/web/app.js +52 -16
- package/web/index.html +55 -17
- package/web/style.css +2070 -1093
package/dist/config.js
CHANGED
|
@@ -56,6 +56,9 @@ export const DEFAULT_CONFIG = {
|
|
|
56
56
|
language: "en",
|
|
57
57
|
theme: "default",
|
|
58
58
|
default_intensity: "High",
|
|
59
|
+
// Plan a request into steps before answering it: the planner agent writes the
|
|
60
|
+
// plan, the worker executes one step per turn. Off = answer in one pass.
|
|
61
|
+
plan_first: true,
|
|
59
62
|
// Default for new conversations; each conversation can override it.
|
|
60
63
|
approval_mode: "request",
|
|
61
64
|
// Delete-class commands need a human unless this and Auto Approval are on.
|
|
@@ -64,6 +67,9 @@ export const DEFAULT_CONFIG = {
|
|
|
64
67
|
sound_alerts: true,
|
|
65
68
|
// Model that reviews approval requests. Empty = same model as the chat.
|
|
66
69
|
approval_model: "",
|
|
70
|
+
// Mount the browser UI alongside the terminal UI unless it is turned off
|
|
71
|
+
// here or on the command line.
|
|
72
|
+
web_console: true,
|
|
67
73
|
// Initialized as a salted hash of the default Web password on first use.
|
|
68
74
|
web_password_hash: "",
|
|
69
75
|
};
|
|
@@ -163,7 +169,7 @@ export function resolveModelSelection(value, requireKey = true) {
|
|
|
163
169
|
throw new Error("this model has no API key configured");
|
|
164
170
|
return { provider: provider.name, model: { ...model }, intensity: input.intensity };
|
|
165
171
|
}
|
|
166
|
-
export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode) {
|
|
172
|
+
export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode, plan) {
|
|
167
173
|
ensureDirs();
|
|
168
174
|
const now = new Date();
|
|
169
175
|
const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
@@ -192,6 +198,10 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
|
|
|
192
198
|
const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
|
|
193
199
|
if (validContext)
|
|
194
200
|
session.context = validContext;
|
|
201
|
+
// A plan outlives the turn that produced it: reopening a conversation shows
|
|
202
|
+
// how far it got, and a stopped plan is still the record of what was agreed.
|
|
203
|
+
if (plan && plan.steps.length)
|
|
204
|
+
session.plan = plan;
|
|
195
205
|
const filePath = path.join(SESSION_DIR, `${id}.json`);
|
|
196
206
|
// Keep the last complete transcript intact if writing or replacing fails.
|
|
197
207
|
// The temporary file shares the destination directory so rename is atomic.
|
|
@@ -302,6 +312,40 @@ export function loadPrompt() {
|
|
|
302
312
|
return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
|
|
303
313
|
"You are Neutrino, a helpful AI coding assistant.");
|
|
304
314
|
}
|
|
315
|
+
/** The planning agent's system prompt. */
|
|
316
|
+
export function loadPlannerPrompt() {
|
|
317
|
+
return (readAsset(path.join(PKG_ROOT, "assets", "planner.txt"), path.join(PKG_ROOT, "planner.txt")) ??
|
|
318
|
+
"Split the user's request into ordered, independent steps a coding agent can execute one at a " +
|
|
319
|
+
"time. Answer with JSON only: {\"goal\": \"...\", \"steps\": [{\"title\": \"...\"}]}.");
|
|
320
|
+
}
|
|
321
|
+
/** Template for the per-step system text; `{{index}}`, `{{plan}}`, … are filled in. */
|
|
322
|
+
export function loadStepPrompt() {
|
|
323
|
+
return (readAsset(path.join(PKG_ROOT, "assets", "step.txt"), path.join(PKG_ROOT, "step.txt")) ??
|
|
324
|
+
"You are executing step {{index}} of {{total}} of this plan:\n{{plan}}\n\nDo only: {{step}}\n" +
|
|
325
|
+
"Verify it, then stop and report what you did.");
|
|
326
|
+
}
|
|
327
|
+
/** The system prompt a delegated sub-agent works from. */
|
|
328
|
+
export function loadSubAgentPrompt() {
|
|
329
|
+
return (readAsset(path.join(PKG_ROOT, "assets", "subagent.txt"), path.join(PKG_ROOT, "subagent.txt")) ??
|
|
330
|
+
"You are a sub-agent working on one task delegated by another agent. You cannot ask the user " +
|
|
331
|
+
"anything and you cannot delegate further. Do the task with the tools you have, verify it, and " +
|
|
332
|
+
"report what you did and what you found in your final message.");
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Whether a turn is planned before it is executed. Unset means on: the plan is
|
|
336
|
+
* the default workflow, and only an explicit `false` turns it off.
|
|
337
|
+
*
|
|
338
|
+
* `NEUTRINO_PLAN=off` overrides both ways for a single run, the way the other
|
|
339
|
+
* environment variables override the saved configuration.
|
|
340
|
+
*/
|
|
341
|
+
export function planFirstEnabled() {
|
|
342
|
+
const override = String(process.env.NEUTRINO_PLAN ?? "").trim().toLowerCase();
|
|
343
|
+
if (override === "off" || override === "0" || override === "false" || override === "no")
|
|
344
|
+
return false;
|
|
345
|
+
if (override === "on" || override === "1" || override === "true" || override === "yes")
|
|
346
|
+
return true;
|
|
347
|
+
return loadConfig().plan_first !== false;
|
|
348
|
+
}
|
|
305
349
|
let cachedVersion = null;
|
|
306
350
|
/**
|
|
307
351
|
* Read the CLI version straight from the package's `package.json` so the
|
package/dist/header.js
CHANGED
|
@@ -23,6 +23,11 @@ export function drawHeader(buf, state) {
|
|
|
23
23
|
}
|
|
24
24
|
buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
|
|
25
25
|
let text = `${state.selectedProvider}/${state.selectedModelName} | ${state.selectedIntensity}`;
|
|
26
|
+
if (state.webUrl) {
|
|
27
|
+
// A short reminder that the browser console is live; the startup message
|
|
28
|
+
// prints the full addresses when more than one works.
|
|
29
|
+
text += ` · web ${state.webUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
|
|
30
|
+
}
|
|
26
31
|
text = truncateByWidth(text, w);
|
|
27
32
|
buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
|
|
28
33
|
}
|
package/dist/llm/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { INTENSITY_PARAMS } from "../config.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { HttpStatusError, describeError, isRetriableFailure, retryDelayMs, retryPolicy, waitBeforeRetry, } from "./retry.js";
|
|
3
|
+
// Callers import `describeError` from this module; it lives next to the
|
|
4
|
+
// classification helpers that share the same "is this a dropped connection?"
|
|
5
|
+
// knowledge, so it is re-exported rather than duplicated.
|
|
6
|
+
export { describeError } from "./retry.js";
|
|
4
7
|
/** Give up if the server has not started to respond within this window. */
|
|
5
8
|
const CONNECT_TIMEOUT_MS = 120_000;
|
|
6
9
|
/**
|
|
@@ -14,124 +17,34 @@ const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI co
|
|
|
14
17
|
"and their meaningful results, and any unresolved problems or next steps. Drop pleasantries, restated " +
|
|
15
18
|
"instructions and redundant tool output. Output only the summary, with no preface.";
|
|
16
19
|
/**
|
|
17
|
-
*
|
|
18
|
-
* The \b keeps "terminated" from matching unrelated text such as
|
|
19
|
-
* "Unterminated string constant".
|
|
20
|
-
*/
|
|
21
|
-
const DROP_HINT_RE = /\bterminated\b|other side closed|socket hang up|premature close/i;
|
|
22
|
-
/**
|
|
23
|
-
* Flatten an error and its `cause` chain into one readable line.
|
|
20
|
+
* One POST, bounded by a connect timeout.
|
|
24
21
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* that chain a failure is impossible to diagnose, so this always reports it.
|
|
22
|
+
* Retrying deliberately does *not* happen here: only the caller knows whether a
|
|
23
|
+
* token already reached the user, and nesting two retry loops would multiply
|
|
24
|
+
* both the request count and the total wait by the attempt budget.
|
|
29
25
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const text = code && !msg.includes(code) ? `${msg} (${code})` : msg;
|
|
36
|
-
if (text && !parts.includes(text))
|
|
37
|
-
parts.push(text);
|
|
38
|
-
};
|
|
39
|
-
let cur = e;
|
|
40
|
-
const seen = new Set();
|
|
41
|
-
while (cur != null && !seen.has(cur)) {
|
|
42
|
-
if (typeof cur === "string") {
|
|
43
|
-
// fetch/undici sometimes stash the real reason in a plain string
|
|
44
|
-
// (`cause: "other side closed"`), which an object-only walk would miss.
|
|
45
|
-
push(cur);
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
if (typeof cur !== "object")
|
|
49
|
-
break;
|
|
50
|
-
seen.add(cur);
|
|
51
|
-
const err = cur;
|
|
52
|
-
const message = typeof err.message === "string" && err.message ? err.message : String(err.name ?? "Error");
|
|
53
|
-
const code = typeof err.code === "string" ? err.code : undefined;
|
|
54
|
-
push(message, code);
|
|
55
|
-
cur = err.cause;
|
|
26
|
+
async function postStream(url, init) {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
|
|
29
|
+
try {
|
|
30
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
56
31
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// which on its own tells the user nothing worth reading.
|
|
60
|
-
return DROP_HINT_RE.test(text) ? `${text} — the connection was closed before the response finished` : text;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Whether a failed request is worth retrying: a transient connection problem
|
|
64
|
-
* (reset / refused / DNS / timeout) rather than a real response from the API.
|
|
65
|
-
*/
|
|
66
|
-
function isRetriableNetworkError(e) {
|
|
67
|
-
const transient = new Set([
|
|
68
|
-
"ECONNRESET",
|
|
69
|
-
"ECONNREFUSED",
|
|
70
|
-
"ECONNABORTED",
|
|
71
|
-
"EPIPE",
|
|
72
|
-
"ETIMEDOUT",
|
|
73
|
-
"ENETUNREACH",
|
|
74
|
-
"ENETDOWN",
|
|
75
|
-
"EAI_AGAIN",
|
|
76
|
-
"ERR_STREAM_PREMATURE_CLOSE",
|
|
77
|
-
"UND_ERR_SOCKET",
|
|
78
|
-
"UND_ERR_CONNECT_TIMEOUT",
|
|
79
|
-
]);
|
|
80
|
-
let cur = e;
|
|
81
|
-
const seen = new Set();
|
|
82
|
-
while (cur != null && !seen.has(cur)) {
|
|
83
|
-
if (typeof cur === "string") {
|
|
84
|
-
if (DROP_HINT_RE.test(cur))
|
|
85
|
-
return true;
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
if (typeof cur !== "object")
|
|
89
|
-
break;
|
|
90
|
-
seen.add(cur);
|
|
91
|
-
const err = cur;
|
|
92
|
-
if (typeof err.code === "string" && transient.has(err.code))
|
|
93
|
-
return true;
|
|
94
|
-
// Our own connect timeout aborts with a plain AbortError; undici wraps a
|
|
95
|
-
// dropped socket in a SocketError.
|
|
96
|
-
if (err.name === "AbortError" || err.name === "SocketError")
|
|
97
|
-
return true;
|
|
98
|
-
// A stream that dies mid-flight: "terminated", "socket hang up", …
|
|
99
|
-
if (typeof err.message === "string" && DROP_HINT_RE.test(err.message))
|
|
100
|
-
return true;
|
|
101
|
-
cur = err.cause;
|
|
32
|
+
finally {
|
|
33
|
+
clearTimeout(timer);
|
|
102
34
|
}
|
|
103
|
-
// Undici's generic wrapper: assume the socket dropped and try again.
|
|
104
|
-
return e instanceof Error && /fetch failed/i.test(e.message);
|
|
105
35
|
}
|
|
106
|
-
|
|
107
|
-
|
|
36
|
+
/** The policy a stream will actually use, with any per-call override applied. */
|
|
37
|
+
function resolvePolicy(options) {
|
|
38
|
+
return { ...retryPolicy(), ...options.retry };
|
|
108
39
|
}
|
|
109
40
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* Retries are safe here because they only ever happen *before* a response is
|
|
114
|
-
* returned; once bytes start flowing the caller is responsible for the stream.
|
|
41
|
+
* Wait before the next attempt, telling the caller why the stream went quiet.
|
|
42
|
+
* Returns false when the caller stopped caring during the wait.
|
|
115
43
|
*/
|
|
116
|
-
async function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
|
|
121
|
-
try {
|
|
122
|
-
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
123
|
-
clearTimeout(timer);
|
|
124
|
-
return resp;
|
|
125
|
-
}
|
|
126
|
-
catch (e) {
|
|
127
|
-
clearTimeout(timer);
|
|
128
|
-
lastErr = e;
|
|
129
|
-
if (attempt === NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
130
|
-
throw e;
|
|
131
|
-
await sleep(300 * (attempt + 1));
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
throw lastErr;
|
|
44
|
+
async function pauseForRetry(attempt, policy, error, options) {
|
|
45
|
+
const delayMs = retryDelayMs(attempt - 1, policy);
|
|
46
|
+
options.onRetry?.({ attempt: attempt + 1, attempts: policy.attempts, delayMs, reason: describeError(error) });
|
|
47
|
+
return waitBeforeRetry(attempt - 1, policy, options.cancelled);
|
|
135
48
|
}
|
|
136
49
|
export class LLMClient {
|
|
137
50
|
model;
|
|
@@ -155,13 +68,13 @@ export class LLMClient {
|
|
|
155
68
|
* the old messages. Uses a throw-away request (no tools) via the normal
|
|
156
69
|
* streaming path, so provider differences are handled for free.
|
|
157
70
|
*/
|
|
158
|
-
async summarize(transcript) {
|
|
71
|
+
async summarize(transcript, options = {}) {
|
|
159
72
|
const messages = [
|
|
160
73
|
{ role: "system", content: SUMMARY_SYSTEM_PROMPT },
|
|
161
74
|
{ role: "user", content: transcript },
|
|
162
75
|
];
|
|
163
76
|
let out = "";
|
|
164
|
-
for await (const chunk of this.streamTurn(messages))
|
|
77
|
+
for await (const chunk of this.streamTurn(messages, undefined, options))
|
|
165
78
|
out += chunk;
|
|
166
79
|
const text = out.trim();
|
|
167
80
|
if (!text || text.startsWith("[Error"))
|
|
@@ -172,41 +85,45 @@ export class LLMClient {
|
|
|
172
85
|
* One-shot completion with its own system prompt. The approval reviewers use
|
|
173
86
|
* this so they never share context with the conversation they are judging.
|
|
174
87
|
*/
|
|
175
|
-
async complete(system, user) {
|
|
88
|
+
async complete(system, user, options = {}) {
|
|
176
89
|
let out = "";
|
|
177
90
|
for await (const chunk of this.streamTurn([
|
|
178
91
|
{ role: "system", content: system },
|
|
179
92
|
{ role: "user", content: user },
|
|
180
|
-
])) {
|
|
93
|
+
], undefined, options)) {
|
|
181
94
|
out += chunk;
|
|
182
95
|
}
|
|
183
96
|
return out.trim();
|
|
184
97
|
}
|
|
185
|
-
async *streamTurn(messages, tools) {
|
|
98
|
+
async *streamTurn(messages, tools, options = {}) {
|
|
186
99
|
this.toolCalls = [];
|
|
187
100
|
this.usage = null;
|
|
188
101
|
try {
|
|
189
102
|
if (this.provider === "Anthropic") {
|
|
190
|
-
yield* this.streamAnthropic(messages);
|
|
103
|
+
yield* this.streamAnthropic(messages, options);
|
|
191
104
|
return;
|
|
192
105
|
}
|
|
193
|
-
yield* this.streamOpenAI(messages, tools);
|
|
106
|
+
yield* this.streamOpenAI(messages, tools, options);
|
|
194
107
|
}
|
|
195
108
|
catch (e) {
|
|
196
109
|
yield `\n[Error: ${describeError(e)}]`;
|
|
197
110
|
}
|
|
198
111
|
}
|
|
199
112
|
/**
|
|
200
|
-
* Drive one request, retrying
|
|
113
|
+
* Drive one request, retrying while the failure is transient.
|
|
201
114
|
*
|
|
202
115
|
* A response can be cut off on the very first read — "terminated" when a
|
|
203
|
-
* proxy or the server closes the connection mid-flight
|
|
204
|
-
* the user yet, so re-issuing
|
|
205
|
-
*
|
|
206
|
-
*
|
|
116
|
+
* proxy or the server closes the connection mid-flight — or the request can
|
|
117
|
+
* never leave at all. Nothing has reached the user yet, so re-issuing is
|
|
118
|
+
* invisible and cannot duplicate text; the attempts and the 5s/10s/20s/…
|
|
119
|
+
* backoff live in `retry.ts`.
|
|
120
|
+
*
|
|
121
|
+
* Once a token has been emitted a retry would replay the reply from its
|
|
122
|
+
* beginning, so the error is surfaced instead and the partial text is kept.
|
|
207
123
|
*/
|
|
208
|
-
async *streamOpenAI(messages, tools) {
|
|
209
|
-
|
|
124
|
+
async *streamOpenAI(messages, tools, options) {
|
|
125
|
+
const policy = resolvePolicy(options);
|
|
126
|
+
for (let attempt = 1;; attempt++) {
|
|
210
127
|
this.toolCalls = [];
|
|
211
128
|
let produced = false;
|
|
212
129
|
try {
|
|
@@ -217,9 +134,10 @@ export class LLMClient {
|
|
|
217
134
|
return;
|
|
218
135
|
}
|
|
219
136
|
catch (e) {
|
|
220
|
-
if (produced || attempt >=
|
|
137
|
+
if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
|
|
221
138
|
throw e;
|
|
222
|
-
|
|
139
|
+
if (!(await pauseForRetry(attempt, policy, e, options)))
|
|
140
|
+
return;
|
|
223
141
|
}
|
|
224
142
|
}
|
|
225
143
|
}
|
|
@@ -240,15 +158,17 @@ export class LLMClient {
|
|
|
240
158
|
};
|
|
241
159
|
if (tools && tools.length)
|
|
242
160
|
payload.tools = tools;
|
|
243
|
-
const resp = await
|
|
161
|
+
const resp = await postStream(url, {
|
|
244
162
|
method: "POST",
|
|
245
163
|
headers,
|
|
246
164
|
body: JSON.stringify(payload),
|
|
247
165
|
});
|
|
248
166
|
if (!resp.ok) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
167
|
+
// Thrown rather than printed: a 502/503/504/429 is as transient as a
|
|
168
|
+
// reset and deserves the same retries, while a 400/401 must surface at
|
|
169
|
+
// once. `streamTurn` renders the final failure into the transcript.
|
|
170
|
+
const body = await resp.text().catch(() => "");
|
|
171
|
+
throw new HttpStatusError(resp.status, body.slice(0, 500));
|
|
252
172
|
}
|
|
253
173
|
if (!resp.body) {
|
|
254
174
|
yield "\n[Error: empty response body]";
|
|
@@ -333,8 +253,9 @@ export class LLMClient {
|
|
|
333
253
|
.map((k) => toolCallsByIndex[k]);
|
|
334
254
|
}
|
|
335
255
|
/** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
|
|
336
|
-
async *streamAnthropic(messages) {
|
|
337
|
-
|
|
256
|
+
async *streamAnthropic(messages, options) {
|
|
257
|
+
const policy = resolvePolicy(options);
|
|
258
|
+
for (let attempt = 1;; attempt++) {
|
|
338
259
|
this.toolCalls = [];
|
|
339
260
|
let produced = false;
|
|
340
261
|
try {
|
|
@@ -345,9 +266,10 @@ export class LLMClient {
|
|
|
345
266
|
return;
|
|
346
267
|
}
|
|
347
268
|
catch (e) {
|
|
348
|
-
if (produced || attempt >=
|
|
269
|
+
if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
|
|
349
270
|
throw e;
|
|
350
|
-
|
|
271
|
+
if (!(await pauseForRetry(attempt, policy, e, options)))
|
|
272
|
+
return;
|
|
351
273
|
}
|
|
352
274
|
}
|
|
353
275
|
}
|
|
@@ -376,15 +298,17 @@ export class LLMClient {
|
|
|
376
298
|
};
|
|
377
299
|
if (systemMsg)
|
|
378
300
|
payload.system = systemMsg;
|
|
379
|
-
const resp = await
|
|
301
|
+
const resp = await postStream(url, {
|
|
380
302
|
method: "POST",
|
|
381
303
|
headers,
|
|
382
304
|
body: JSON.stringify(payload),
|
|
383
305
|
});
|
|
384
306
|
if (!resp.ok) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
307
|
+
// Thrown rather than printed: a 502/503/504/429 is as transient as a
|
|
308
|
+
// reset and deserves the same retries, while a 400/401 must surface at
|
|
309
|
+
// once. `streamTurn` renders the final failure into the transcript.
|
|
310
|
+
const body = await resp.text().catch(() => "");
|
|
311
|
+
throw new HttpStatusError(resp.status, body.slice(0, 500));
|
|
388
312
|
}
|
|
389
313
|
if (!resp.body) {
|
|
390
314
|
yield "\n[Error: empty response body]";
|