@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/README.md +151 -25
- package/assets/planner.txt +35 -0
- package/assets/prompt.txt +57 -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 +58 -1
- package/dist/header.js +5 -0
- package/dist/llm/client.js +101 -142
- package/dist/llm/plan.js +452 -0
- package/dist/llm/retry.js +209 -0
- package/dist/llm/subagent.js +137 -0
- package/dist/llm/tools.js +43 -1
- package/dist/screens/chat.js +384 -51
- package/dist/screens/settings.js +30 -1
- package/dist/state.js +1 -0
- package/dist/web/session.js +235 -24
- 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/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 --
|
|
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
|
|
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>
|
|
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 --
|
|
50
|
+
NEUTRINO_WEB_PORT Same as --port in either mode.
|
|
49
51
|
NEUTRINO_WEB_HOST Same as --host.
|
|
50
52
|
|
|
51
|
-
The web UI
|
|
52
|
-
|
|
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 ??
|
|
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 ??
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
}
|
package/dist/llm/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { INTENSITY_PARAMS } from "../config.js";
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
*
|
|
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;
|
|
@@ -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
|
|
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
|
|
204
|
-
* the user yet, so re-issuing
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
-
|
|
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 >=
|
|
145
|
+
if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
|
|
221
146
|
throw e;
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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 >=
|
|
304
|
+
if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
|
|
349
305
|
throw e;
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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]";
|