@xingjisoft/neutrino 0.1.7 → 0.2.1

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/dist/bin.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
3
  import { App } from "./app.js";
4
+ import { loadConfig } from "./config.js";
4
5
  import { installCrashGuard } from "./terminal/restore.js";
5
6
  import { startWebServer } from "./web/server.js";
6
7
  const DEFAULT_WEB_PORT = 8765;
@@ -31,25 +32,28 @@ function printHelp() {
31
32
  process.stdout.write(`Neutrino
32
33
 
33
34
  Usage:
34
- neutrino Start the terminal UI.
35
- neutrino --web-port <port> Start the terminal UI and mount the web UI.
35
+ neutrino Start the terminal UI (with the web UI).
36
+ neutrino --lan Start both, reachable from other devices.
36
37
  neutrino web [--port <port>] Start only the web UI (serves saved history).
37
38
 
38
39
  Options:
39
- -p, --port <port> Web server port (web mode). Default: ${DEFAULT_WEB_PORT}
40
+ -p, --port <port> Web UI port. Default: ${DEFAULT_WEB_PORT}
40
41
  --host <host> Interface to bind. Default: 127.0.0.1
41
42
  --lan Short for --host ${WILDCARD_HOST}: reachable from other
42
43
  devices on your LAN or VPN.
44
+ --no-web Do not start the web UI with the terminal UI.
43
45
  --no-auth Serve without password login.
44
- --web-port <n> Also mount the web UI while the TUI runs.
46
+ --web-port <n> Deprecated alias for --port while the TUI runs.
45
47
  -h, --help Show this help.
46
48
 
47
49
  Environment:
48
- NEUTRINO_WEB_PORT Same as --web-port for the TUI, or the port in web mode.
50
+ NEUTRINO_WEB_PORT Same as --port in either mode.
49
51
  NEUTRINO_WEB_HOST Same as --host.
50
52
 
51
- The web UI can read every saved conversation and drive an agent with shell
52
- access, so it binds to loopback by default. Log in with the permanent password
53
+ The web UI starts by default alongside the terminal UI on loopback. Turn it
54
+ off for one run with --no-web, or permanently in Settings → Web console. It can
55
+ read every saved conversation and drive an agent with shell access, so it binds
56
+ to loopback unless you ask for --host/--lan. Log in with the permanent password
53
57
  (default: 114514). Change it in Settings. --no-auth disables password login.
54
58
  `);
55
59
  }
@@ -62,7 +66,7 @@ function requireValue(argv, index, flag) {
62
66
  return value;
63
67
  }
64
68
  function parseArgs(argv) {
65
- const options = { mode: "tui", port: null, lan: false, noAuth: false };
69
+ const options = { mode: "tui", port: null, lan: false, noWeb: false, noAuth: false };
66
70
  for (let i = 0; i < argv.length; i += 1) {
67
71
  const arg = argv[i];
68
72
  if (arg === "web") {
@@ -89,6 +93,9 @@ function parseArgs(argv) {
89
93
  else if (arg === "--lan") {
90
94
  options.lan = true;
91
95
  }
96
+ else if (arg === "--no-web") {
97
+ options.noWeb = true;
98
+ }
92
99
  else if (arg === "--no-auth") {
93
100
  options.noAuth = true;
94
101
  }
@@ -103,6 +110,12 @@ function parseArgs(argv) {
103
110
  if (options.lan && options.host) {
104
111
  throw new Error("use either --lan or --host, not both");
105
112
  }
113
+ if (options.noWeb && options.mode === "web") {
114
+ throw new Error("--no-web cannot be used with the 'web' command");
115
+ }
116
+ if (options.noWeb && (options.port !== null || options.host !== undefined || options.lan)) {
117
+ throw new Error("use either --no-web or web UI options, not both");
118
+ }
106
119
  return options;
107
120
  }
108
121
  /** Print every address a client can open, plus what it takes to open one. */
@@ -140,10 +153,11 @@ catch (err) {
140
153
  installCrashGuard();
141
154
  const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
142
155
  const auth = !options.noAuth;
156
+ const portFromEnv = envPort();
143
157
  if (options.mode === "web") {
144
158
  // Headless: serve the saved history (and any live sessions in this process,
145
159
  // which for a standalone server is none).
146
- const port = options.port ?? envPort() ?? DEFAULT_WEB_PORT;
160
+ const port = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
147
161
  try {
148
162
  const handle = await startWebServer({ port, host, auth });
149
163
  reportAccess(handle);
@@ -166,15 +180,26 @@ else {
166
180
  process.exit(1);
167
181
  }
168
182
  const app = new App();
169
- const webPort = options.port ?? envPort();
170
- if (webPort !== null) {
183
+ const webPort = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
184
+ // Remember where the UI should live even when it is off right now, so the
185
+ // Settings toggle can mount it without restarting the terminal.
186
+ app.configureWeb(webPort, host, auth);
187
+ // Explicit web settings win over the saved preference; otherwise the web UI
188
+ // is part of a normal launch unless the user turned it off in Settings.
189
+ const requested = options.port !== null || options.host !== undefined || options.lan
190
+ || options.noAuth || portFromEnv !== null || Boolean(process.env.NEUTRINO_WEB_HOST);
191
+ const enabledByDefault = loadConfig().web_console !== false;
192
+ if (!options.noWeb && (requested || enabledByDefault)) {
171
193
  try {
172
194
  const handle = await app.enableWeb(webPort, host, auth);
173
195
  reportAccess(handle);
174
196
  }
175
197
  catch (err) {
176
- process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
177
- process.exit(1);
198
+ // A console that cannot bind (usually a second instance on 8765) must
199
+ // never take the terminal down with it.
200
+ const detail = err instanceof Error ? err.message : String(err);
201
+ process.stderr.write(`neutrino: web UI not started: ${detail}\n`);
202
+ process.stderr.write("The terminal UI will keep running. Pass --port <port> to use another port, or --no-web to skip the web UI.\n");
178
203
  }
179
204
  }
180
205
  await app.start();
package/dist/config.js CHANGED
@@ -11,6 +11,17 @@ export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
11
11
  export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
12
12
  export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
13
13
  export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
14
+ /**
15
+ * Whether assistant messages must be sent back with a `reasoning_content`
16
+ * field. DeepSeek's thinking mode requires one on *every* assistant message of
17
+ * a tools-bearing request (an empty string is accepted), and the plan message
18
+ * written by the planner is otherwise an assistant message without it.
19
+ */
20
+ export function modelUsesReasoning(provider, model) {
21
+ if (typeof model.reasoning === "boolean")
22
+ return model.reasoning;
23
+ return provider === "DeepSeek" || /deepseek|reasoner|thinking/i.test(model.name);
24
+ }
14
25
  export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
15
26
  export const CONTEXT_COMPRESSION_RATIO = 0.8;
16
27
  export const CONTEXT_WINDOW_OPTIONS = [32_000, 64_000, 128_000, 200_000, 256_000, 512_000, 1_000_000, 2_000_000, 4_000_000];
@@ -56,6 +67,11 @@ export const DEFAULT_CONFIG = {
56
67
  language: "en",
57
68
  theme: "default",
58
69
  default_intensity: "High",
70
+ // Plan a request into steps before answering it: the planner agent writes the
71
+ // plan, the worker executes one step per turn. Requests that look like a
72
+ // single action are never planned (see needsPlan), and false turns planning
73
+ // off for every request.
74
+ plan_first: true,
59
75
  // Default for new conversations; each conversation can override it.
60
76
  approval_mode: "request",
61
77
  // Delete-class commands need a human unless this and Auto Approval are on.
@@ -64,6 +80,9 @@ export const DEFAULT_CONFIG = {
64
80
  sound_alerts: true,
65
81
  // Model that reviews approval requests. Empty = same model as the chat.
66
82
  approval_model: "",
83
+ // Mount the browser UI alongside the terminal UI unless it is turned off
84
+ // here or on the command line.
85
+ web_console: true,
67
86
  // Initialized as a salted hash of the default Web password on first use.
68
87
  web_password_hash: "",
69
88
  };
@@ -163,7 +182,7 @@ export function resolveModelSelection(value, requireKey = true) {
163
182
  throw new Error("this model has no API key configured");
164
183
  return { provider: provider.name, model: { ...model }, intensity: input.intensity };
165
184
  }
166
- export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode) {
185
+ export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode, plan) {
167
186
  ensureDirs();
168
187
  const now = new Date();
169
188
  const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
@@ -192,6 +211,10 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
192
211
  const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
193
212
  if (validContext)
194
213
  session.context = validContext;
214
+ // A plan outlives the turn that produced it: reopening a conversation shows
215
+ // how far it got, and a stopped plan is still the record of what was agreed.
216
+ if (plan && plan.steps.length)
217
+ session.plan = plan;
195
218
  const filePath = path.join(SESSION_DIR, `${id}.json`);
196
219
  // Keep the last complete transcript intact if writing or replacing fails.
197
220
  // The temporary file shares the destination directory so rename is atomic.
@@ -302,6 +325,40 @@ export function loadPrompt() {
302
325
  return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
303
326
  "You are Neutrino, a helpful AI coding assistant.");
304
327
  }
328
+ /** The planning agent's system prompt. */
329
+ export function loadPlannerPrompt() {
330
+ return (readAsset(path.join(PKG_ROOT, "assets", "planner.txt"), path.join(PKG_ROOT, "planner.txt")) ??
331
+ "Split the user's request into ordered, independent steps a coding agent can execute one at a " +
332
+ "time. Answer with JSON only: {\"goal\": \"...\", \"steps\": [{\"title\": \"...\"}]}.");
333
+ }
334
+ /** Template for the per-step system text; `{{index}}`, `{{plan}}`, … are filled in. */
335
+ export function loadStepPrompt() {
336
+ return (readAsset(path.join(PKG_ROOT, "assets", "step.txt"), path.join(PKG_ROOT, "step.txt")) ??
337
+ "You are executing step {{index}} of {{total}} of this plan:\n{{plan}}\n\nDo only: {{step}}\n" +
338
+ "Verify it, then stop and report what you did.");
339
+ }
340
+ /** The system prompt a delegated sub-agent works from. */
341
+ export function loadSubAgentPrompt() {
342
+ return (readAsset(path.join(PKG_ROOT, "assets", "subagent.txt"), path.join(PKG_ROOT, "subagent.txt")) ??
343
+ "You are a sub-agent working on one task delegated by another agent. You cannot ask the user " +
344
+ "anything and you cannot delegate further. Do the task with the tools you have, verify it, and " +
345
+ "report what you did and what you found in your final message.");
346
+ }
347
+ /**
348
+ * Whether a turn is planned before it is executed. Unset means on: the plan is
349
+ * the default workflow, and only an explicit `false` turns it off.
350
+ *
351
+ * `NEUTRINO_PLAN=off` overrides both ways for a single run, the way the other
352
+ * environment variables override the saved configuration.
353
+ */
354
+ export function planFirstEnabled() {
355
+ const override = String(process.env.NEUTRINO_PLAN ?? "").trim().toLowerCase();
356
+ if (override === "off" || override === "0" || override === "false" || override === "no")
357
+ return false;
358
+ if (override === "on" || override === "1" || override === "true" || override === "yes")
359
+ return true;
360
+ return loadConfig().plan_first !== false;
361
+ }
305
362
  let cachedVersion = null;
306
363
  /**
307
364
  * 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
  }
@@ -1,6 +1,9 @@
1
- import { INTENSITY_PARAMS } from "../config.js";
2
- /** How many extra times a request is retried when the connection drops. */
3
- const NETWORK_RETRIES = 2;
1
+ import { INTENSITY_PARAMS, modelUsesReasoning } from "../config.js";
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
- * Terse transport messages that all mean "the connection dropped mid-flight".
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
- * `fetch` reports almost every transport problem as a bare
26
- * `TypeError: fetch failed`; the actual reason (`ECONNRESET`, `EMFILE`,
27
- * `ENOTFOUND`, `UND_ERR_…`) is tucked away in `error.cause`. Without walking
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
- export function describeError(e) {
31
- if (!(e instanceof Error))
32
- return String(e);
33
- const parts = [];
34
- const push = (msg, code) => {
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
- const text = parts.join(": ") || e.message;
58
- // A dropped mid-stream connection surfaces as a bare `TypeError: terminated`,
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
- function sleep(ms) {
107
- return new Promise((resolve) => setTimeout(resolve, ms));
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
- * Issue a streaming request, retrying a few times on transient network errors
111
- * and giving up with a clear error if the server never starts responding.
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 requestStream(url, init) {
117
- let lastErr;
118
- for (let attempt = 0; attempt <= NETWORK_RETRIES; attempt++) {
119
- const controller = new AbortController();
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;
@@ -141,6 +54,12 @@ export class LLMClient {
141
54
  top_p;
142
55
  toolCalls = [];
143
56
  usage = null;
57
+ /**
58
+ * The thinking text streamed alongside the reply. DeepSeek's thinking mode
59
+ * requires it to be echoed back on the assistant message that carries a tool
60
+ * call, so the agent loops attach it to that message.
61
+ */
62
+ reasoningContent = "";
144
63
  constructor(model, provider, intensity) {
145
64
  this.model = model;
146
65
  this.provider = provider;
@@ -155,13 +74,13 @@ export class LLMClient {
155
74
  * the old messages. Uses a throw-away request (no tools) via the normal
156
75
  * streaming path, so provider differences are handled for free.
157
76
  */
158
- async summarize(transcript) {
77
+ async summarize(transcript, options = {}) {
159
78
  const messages = [
160
79
  { role: "system", content: SUMMARY_SYSTEM_PROMPT },
161
80
  { role: "user", content: transcript },
162
81
  ];
163
82
  let out = "";
164
- for await (const chunk of this.streamTurn(messages))
83
+ for await (const chunk of this.streamTurn(messages, undefined, options))
165
84
  out += chunk;
166
85
  const text = out.trim();
167
86
  if (!text || text.startsWith("[Error"))
@@ -172,42 +91,48 @@ export class LLMClient {
172
91
  * One-shot completion with its own system prompt. The approval reviewers use
173
92
  * this so they never share context with the conversation they are judging.
174
93
  */
175
- async complete(system, user) {
94
+ async complete(system, user, options = {}) {
176
95
  let out = "";
177
96
  for await (const chunk of this.streamTurn([
178
97
  { role: "system", content: system },
179
98
  { role: "user", content: user },
180
- ])) {
99
+ ], undefined, options)) {
181
100
  out += chunk;
182
101
  }
183
102
  return out.trim();
184
103
  }
185
- async *streamTurn(messages, tools) {
104
+ async *streamTurn(messages, tools, options = {}) {
186
105
  this.toolCalls = [];
187
106
  this.usage = null;
107
+ this.reasoningContent = "";
188
108
  try {
189
109
  if (this.provider === "Anthropic") {
190
- yield* this.streamAnthropic(messages);
110
+ yield* this.streamAnthropic(messages, options);
191
111
  return;
192
112
  }
193
- yield* this.streamOpenAI(messages, tools);
113
+ yield* this.streamOpenAI(messages, tools, options);
194
114
  }
195
115
  catch (e) {
196
116
  yield `\n[Error: ${describeError(e)}]`;
197
117
  }
198
118
  }
199
119
  /**
200
- * Drive one request, retrying if the stream dies before it produced anything.
120
+ * Drive one request, retrying while the failure is transient.
201
121
  *
202
122
  * A response can be cut off on the very first read — "terminated" when a
203
- * proxy or the server closes the connection mid-flight. Nothing has reached
204
- * the user yet, so re-issuing the request is invisible (and cannot duplicate
205
- * text). Once a token has been emitted a retry is unsafe, so the error is
206
- * surfaced instead.
123
+ * proxy or the server closes the connection mid-flight — or the request can
124
+ * never leave at all. Nothing has reached the user yet, so re-issuing is
125
+ * invisible and cannot duplicate text; the attempts and the 5s/10s/20s/…
126
+ * backoff live in `retry.ts`.
127
+ *
128
+ * Once a token has been emitted a retry would replay the reply from its
129
+ * beginning, so the error is surfaced instead and the partial text is kept.
207
130
  */
208
- async *streamOpenAI(messages, tools) {
209
- for (let attempt = 0;; attempt++) {
131
+ async *streamOpenAI(messages, tools, options) {
132
+ const policy = resolvePolicy(options);
133
+ for (let attempt = 1;; attempt++) {
210
134
  this.toolCalls = [];
135
+ this.reasoningContent = "";
211
136
  let produced = false;
212
137
  try {
213
138
  for await (const token of this.openAIAttempt(messages, tools)) {
@@ -217,12 +142,35 @@ export class LLMClient {
217
142
  return;
218
143
  }
219
144
  catch (e) {
220
- if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
145
+ if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
221
146
  throw e;
222
- await sleep(400 * (attempt + 1));
147
+ if (!(await pauseForRetry(attempt, policy, e, options)))
148
+ return;
223
149
  }
224
150
  }
225
151
  }
152
+ /**
153
+ * DeepSeek's thinking mode rejects a tools-bearing request when any assistant
154
+ * message in its history carries no `reasoning_content` ("The
155
+ * `reasoning_content` in the thinking mode must be passed back to the API").
156
+ * An empty string is accepted, which is the honest value for a message that
157
+ * predates this turn's thinking: a compressed summary, the planner's plan, an
158
+ * error note or a session saved before this rule existed. Endpoints outside
159
+ * thinking mode must not receive the field at all.
160
+ */
161
+ wireMessages(messages) {
162
+ if (!modelUsesReasoning(this.provider, this.model)) {
163
+ return messages.map((message) => {
164
+ if (message.role !== "assistant" || message.reasoning_content === undefined)
165
+ return message;
166
+ const { reasoning_content: _ignored, ...rest } = message;
167
+ return rest;
168
+ });
169
+ }
170
+ return messages.map((message) => message.role === "assistant" && typeof message.reasoning_content !== "string"
171
+ ? { ...message, reasoning_content: "" }
172
+ : message);
173
+ }
226
174
  /** A single streaming attempt; see `streamOpenAI` for the retry policy. */
227
175
  async *openAIAttempt(messages, tools) {
228
176
  const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
@@ -232,7 +180,7 @@ export class LLMClient {
232
180
  };
233
181
  const payload = {
234
182
  model: this.model.name,
235
- messages,
183
+ messages: this.wireMessages(messages),
236
184
  temperature: this.temperature,
237
185
  top_p: this.top_p,
238
186
  stream: true,
@@ -240,15 +188,17 @@ export class LLMClient {
240
188
  };
241
189
  if (tools && tools.length)
242
190
  payload.tools = tools;
243
- const resp = await requestStream(url, {
191
+ const resp = await postStream(url, {
244
192
  method: "POST",
245
193
  headers,
246
194
  body: JSON.stringify(payload),
247
195
  });
248
196
  if (!resp.ok) {
249
- const text = await resp.text().catch(() => "");
250
- yield `\n[Error: HTTP ${resp.status}${text ? " - " + text.slice(0, 500) : ""}]`;
251
- return;
197
+ // Thrown rather than printed: a 502/503/504/429 is as transient as a
198
+ // reset and deserves the same retries, while a 400/401 must surface at
199
+ // once. `streamTurn` renders the final failure into the transcript.
200
+ const body = await resp.text().catch(() => "");
201
+ throw new HttpStatusError(resp.status, body.slice(0, 500));
252
202
  }
253
203
  if (!resp.body) {
254
204
  yield "\n[Error: empty response body]";
@@ -296,6 +246,11 @@ export class LLMClient {
296
246
  }
297
247
  const choice = chunk.choices?.[0];
298
248
  const delta = choice?.delta ?? {};
249
+ // DeepSeek calls it reasoning_content; other compatible endpoints use
250
+ // the shorter `reasoning`. Neither is part of the visible reply.
251
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
252
+ if (typeof reasoning === "string" && reasoning)
253
+ this.reasoningContent += reasoning;
299
254
  const content = delta.content;
300
255
  if (typeof content === "string" && content)
301
256
  yield content;
@@ -333,8 +288,9 @@ export class LLMClient {
333
288
  .map((k) => toolCallsByIndex[k]);
334
289
  }
335
290
  /** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
336
- async *streamAnthropic(messages) {
337
- for (let attempt = 0;; attempt++) {
291
+ async *streamAnthropic(messages, options) {
292
+ const policy = resolvePolicy(options);
293
+ for (let attempt = 1;; attempt++) {
338
294
  this.toolCalls = [];
339
295
  let produced = false;
340
296
  try {
@@ -345,9 +301,10 @@ export class LLMClient {
345
301
  return;
346
302
  }
347
303
  catch (e) {
348
- if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
304
+ if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
349
305
  throw e;
350
- await sleep(400 * (attempt + 1));
306
+ if (!(await pauseForRetry(attempt, policy, e, options)))
307
+ return;
351
308
  }
352
309
  }
353
310
  }
@@ -376,15 +333,17 @@ export class LLMClient {
376
333
  };
377
334
  if (systemMsg)
378
335
  payload.system = systemMsg;
379
- const resp = await requestStream(url, {
336
+ const resp = await postStream(url, {
380
337
  method: "POST",
381
338
  headers,
382
339
  body: JSON.stringify(payload),
383
340
  });
384
341
  if (!resp.ok) {
385
- const text = await resp.text().catch(() => "");
386
- yield `\n[Error: HTTP ${resp.status}${text ? " - " + text.slice(0, 500) : ""}]`;
387
- return;
342
+ // Thrown rather than printed: a 502/503/504/429 is as transient as a
343
+ // reset and deserves the same retries, while a 400/401 must surface at
344
+ // once. `streamTurn` renders the final failure into the transcript.
345
+ const body = await resp.text().catch(() => "");
346
+ throw new HttpStatusError(resp.status, body.slice(0, 500));
388
347
  }
389
348
  if (!resp.body) {
390
349
  yield "\n[Error: empty response body]";