@xingjisoft/neutrino 0.2.1 → 0.4.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 +312 -436
- package/assets/pro/backend_output_model.txt +5 -0
- package/assets/pro/claude-module-prompt.txt +65 -0
- package/assets/pro/common.txt +9 -0
- package/assets/pro/confirm_model.txt +5 -0
- package/assets/pro/front_output_model.txt +9 -0
- package/assets/pro/genimage_model.txt +1 -0
- package/assets/pro/main_model.txt +5 -0
- package/assets/pro/model-template.json +376 -0
- package/assets/pro/modeling_model.txt +5 -0
- package/assets/pro/plan_model.txt +8 -0
- package/assets/pro/thinking_model.txt +3 -0
- package/dist/app.js +43 -7
- package/dist/bin.js +5 -2
- package/dist/browser.js +57 -0
- package/dist/config.js +179 -26
- package/dist/index.js +1 -1
- package/dist/llm/approval.js +6 -2
- package/dist/llm/client.js +197 -28
- package/dist/llm/subagent.js +37 -1
- package/dist/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/chat.js +54 -38
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +9 -7
- package/dist/screens/start.js +48 -15
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +31 -4
- package/dist/web/server.js +611 -50
- package/dist/web/session.js +320 -20
- package/dist/web/settings.js +117 -24
- package/package.json +37 -37
- package/web/README.md +158 -16
- package/web/app.js +1603 -128
- package/web/i18n.js +382 -0
- package/web/index.html +289 -37
- package/web/markdown.js +710 -0
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +322 -193
- package/web/style.css +1378 -42
package/dist/llm/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { INTENSITY_PARAMS, modelUsesReasoning } from "../config.js";
|
|
1
|
+
import { INTENSITY_PARAMS, intensityForModel, modelUsesReasoning } from "../config.js";
|
|
2
2
|
import { HttpStatusError, describeError, isRetriableFailure, retryDelayMs, retryPolicy, waitBeforeRetry, } from "./retry.js";
|
|
3
3
|
// Callers import `describeError` from this module; it lives next to the
|
|
4
4
|
// classification helpers that share the same "is this a dropped connection?"
|
|
@@ -25,12 +25,21 @@ const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI co
|
|
|
25
25
|
*/
|
|
26
26
|
async function postStream(url, init) {
|
|
27
27
|
const controller = new AbortController();
|
|
28
|
+
const abort = () => controller.abort(init.signal?.reason);
|
|
29
|
+
if (init.signal?.aborted)
|
|
30
|
+
abort();
|
|
31
|
+
else
|
|
32
|
+
init.signal?.addEventListener("abort", abort, { once: true });
|
|
28
33
|
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
|
|
29
34
|
try {
|
|
30
35
|
return await fetch(url, { ...init, signal: controller.signal });
|
|
31
36
|
}
|
|
32
37
|
finally {
|
|
33
38
|
clearTimeout(timer);
|
|
39
|
+
// The caller's stream controller is aborted in streamTurn's finally, so
|
|
40
|
+
// this listener also covers cancellation after the response headers.
|
|
41
|
+
if (controller.signal.aborted)
|
|
42
|
+
init.signal?.removeEventListener("abort", abort);
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
45
|
/** The policy a stream will actually use, with any per-call override applied. */
|
|
@@ -47,6 +56,7 @@ async function pauseForRetry(attempt, policy, error, options) {
|
|
|
47
56
|
return waitBeforeRetry(attempt - 1, policy, options.cancelled);
|
|
48
57
|
}
|
|
49
58
|
export class LLMClient {
|
|
59
|
+
professional;
|
|
50
60
|
model;
|
|
51
61
|
provider;
|
|
52
62
|
intensity;
|
|
@@ -60,11 +70,18 @@ export class LLMClient {
|
|
|
60
70
|
* call, so the agent loops attach it to that message.
|
|
61
71
|
*/
|
|
62
72
|
reasoningContent = "";
|
|
63
|
-
|
|
73
|
+
anthropicContent = [];
|
|
74
|
+
constructor(model, provider, intensity, options = {}) {
|
|
64
75
|
this.model = model;
|
|
65
76
|
this.provider = provider;
|
|
66
|
-
this.
|
|
67
|
-
|
|
77
|
+
this.professional = options.professional === true;
|
|
78
|
+
// Final safety net: even a stale UI, a saved session or a hand-edited
|
|
79
|
+
// selection can never send a level this model does not declare.
|
|
80
|
+
this.intensity = this.professional ? intensity.trim().toLowerCase() || "high" : intensityForModel(model, intensity);
|
|
81
|
+
const sampling = this.professional
|
|
82
|
+
? this.intensity.charAt(0).toUpperCase() + this.intensity.slice(1)
|
|
83
|
+
: this.intensity;
|
|
84
|
+
const params = INTENSITY_PARAMS[sampling] ?? INTENSITY_PARAMS["High"];
|
|
68
85
|
this.temperature = params.temperature;
|
|
69
86
|
this.top_p = params.top_p;
|
|
70
87
|
}
|
|
@@ -105,15 +122,37 @@ export class LLMClient {
|
|
|
105
122
|
this.toolCalls = [];
|
|
106
123
|
this.usage = null;
|
|
107
124
|
this.reasoningContent = "";
|
|
125
|
+
this.anthropicContent = [];
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
128
|
+
if (options.signal?.aborted)
|
|
129
|
+
abort();
|
|
130
|
+
else
|
|
131
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
132
|
+
const cancelled = () => controller.signal.aborted || options.cancelled?.() === true;
|
|
133
|
+
const timer = options.cancelled ? setInterval(() => {
|
|
134
|
+
if (options.cancelled?.())
|
|
135
|
+
controller.abort();
|
|
136
|
+
}, 100) : null;
|
|
137
|
+
const requestOptions = { ...options, signal: controller.signal, cancelled };
|
|
108
138
|
try {
|
|
139
|
+
if (controller.signal.aborted)
|
|
140
|
+
return;
|
|
109
141
|
if (this.provider === "Anthropic") {
|
|
110
|
-
yield* this.streamAnthropic(messages,
|
|
142
|
+
yield* this.streamAnthropic(messages, tools, requestOptions);
|
|
111
143
|
return;
|
|
112
144
|
}
|
|
113
|
-
yield* this.streamOpenAI(messages, tools,
|
|
145
|
+
yield* this.streamOpenAI(messages, tools, requestOptions);
|
|
114
146
|
}
|
|
115
147
|
catch (e) {
|
|
116
|
-
|
|
148
|
+
if (!cancelled())
|
|
149
|
+
yield `\n[Error: ${describeError(e)}]`;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
if (timer)
|
|
153
|
+
clearInterval(timer);
|
|
154
|
+
options.signal?.removeEventListener("abort", abort);
|
|
155
|
+
controller.abort();
|
|
117
156
|
}
|
|
118
157
|
}
|
|
119
158
|
/**
|
|
@@ -135,7 +174,7 @@ export class LLMClient {
|
|
|
135
174
|
this.reasoningContent = "";
|
|
136
175
|
let produced = false;
|
|
137
176
|
try {
|
|
138
|
-
for await (const token of this.openAIAttempt(messages, tools)) {
|
|
177
|
+
for await (const token of this.openAIAttempt(messages, tools, options)) {
|
|
139
178
|
produced = true;
|
|
140
179
|
yield token;
|
|
141
180
|
}
|
|
@@ -172,7 +211,7 @@ export class LLMClient {
|
|
|
172
211
|
: message);
|
|
173
212
|
}
|
|
174
213
|
/** A single streaming attempt; see `streamOpenAI` for the retry policy. */
|
|
175
|
-
async *openAIAttempt(messages, tools) {
|
|
214
|
+
async *openAIAttempt(messages, tools, options) {
|
|
176
215
|
const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
|
|
177
216
|
const headers = {
|
|
178
217
|
"Content-Type": "application/json",
|
|
@@ -181,10 +220,12 @@ export class LLMClient {
|
|
|
181
220
|
const payload = {
|
|
182
221
|
model: this.model.name,
|
|
183
222
|
messages: this.wireMessages(messages),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
223
|
+
...(this.professional ? { reasoning_effort: this.intensity } : {
|
|
224
|
+
temperature: this.temperature,
|
|
225
|
+
top_p: this.top_p,
|
|
226
|
+
}),
|
|
227
|
+
stream: this.model.supports_streaming !== false,
|
|
228
|
+
...(this.model.supports_streaming !== false ? { stream_options: { include_usage: true } } : {}),
|
|
188
229
|
};
|
|
189
230
|
if (tools && tools.length)
|
|
190
231
|
payload.tools = tools;
|
|
@@ -192,6 +233,7 @@ export class LLMClient {
|
|
|
192
233
|
method: "POST",
|
|
193
234
|
headers,
|
|
194
235
|
body: JSON.stringify(payload),
|
|
236
|
+
signal: options.signal,
|
|
195
237
|
});
|
|
196
238
|
if (!resp.ok) {
|
|
197
239
|
// Thrown rather than printed: a 502/503/504/429 is as transient as a
|
|
@@ -200,6 +242,24 @@ export class LLMClient {
|
|
|
200
242
|
const body = await resp.text().catch(() => "");
|
|
201
243
|
throw new HttpStatusError(resp.status, body.slice(0, 500));
|
|
202
244
|
}
|
|
245
|
+
if (this.model.supports_streaming === false || resp.headers.get("content-type")?.includes("application/json")) {
|
|
246
|
+
const body = await resp.json();
|
|
247
|
+
const message = body.choices?.[0]?.message;
|
|
248
|
+
if (!message)
|
|
249
|
+
throw new Error("endpoint did not return a valid model completion");
|
|
250
|
+
this.reasoningContent = message.reasoning_content ?? message.reasoning ?? "";
|
|
251
|
+
this.toolCalls = (message.tool_calls ?? []).map((call) => ({
|
|
252
|
+
id: call.id, name: call.function.name, arguments: call.function.arguments,
|
|
253
|
+
}));
|
|
254
|
+
if (body.usage) {
|
|
255
|
+
const prompt = body.usage.prompt_tokens ?? 0;
|
|
256
|
+
const completion = body.usage.completion_tokens ?? 0;
|
|
257
|
+
this.usage = { prompt, completion, total: body.usage.total_tokens ?? prompt + completion };
|
|
258
|
+
}
|
|
259
|
+
if (message.content)
|
|
260
|
+
yield message.content;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
203
263
|
if (!resp.body) {
|
|
204
264
|
yield "\n[Error: empty response body]";
|
|
205
265
|
return;
|
|
@@ -288,13 +348,14 @@ export class LLMClient {
|
|
|
288
348
|
.map((k) => toolCallsByIndex[k]);
|
|
289
349
|
}
|
|
290
350
|
/** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
|
|
291
|
-
async *streamAnthropic(messages, options) {
|
|
351
|
+
async *streamAnthropic(messages, tools, options) {
|
|
292
352
|
const policy = resolvePolicy(options);
|
|
293
353
|
for (let attempt = 1;; attempt++) {
|
|
294
354
|
this.toolCalls = [];
|
|
355
|
+
this.anthropicContent = [];
|
|
295
356
|
let produced = false;
|
|
296
357
|
try {
|
|
297
|
-
for await (const token of this.anthropicAttempt(messages)) {
|
|
358
|
+
for await (const token of this.anthropicAttempt(messages, tools, options)) {
|
|
298
359
|
produced = true;
|
|
299
360
|
yield token;
|
|
300
361
|
}
|
|
@@ -309,34 +370,71 @@ export class LLMClient {
|
|
|
309
370
|
}
|
|
310
371
|
}
|
|
311
372
|
/** A single streaming attempt; see `streamAnthropic` for the retry policy. */
|
|
312
|
-
async *anthropicAttempt(messages) {
|
|
373
|
+
async *anthropicAttempt(messages, tools, options) {
|
|
313
374
|
const url = this.model.url.replace(/\/+$/, "") + "/messages";
|
|
314
375
|
const headers = {
|
|
315
376
|
"Content-Type": "application/json",
|
|
316
377
|
"x-api-key": this.model.api_key,
|
|
317
378
|
"anthropic-version": "2023-06-01",
|
|
318
379
|
};
|
|
319
|
-
|
|
380
|
+
const systemMessages = [];
|
|
320
381
|
const chatMessages = [];
|
|
321
382
|
for (const m of messages) {
|
|
322
|
-
if (m.role === "system")
|
|
323
|
-
|
|
383
|
+
if (m.role === "system") {
|
|
384
|
+
if (m.content)
|
|
385
|
+
systemMessages.push(m.content);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
let blocks;
|
|
389
|
+
const role = m.role === "assistant" ? "assistant" : "user";
|
|
390
|
+
if (m.role === "tool") {
|
|
391
|
+
blocks = [{ type: "tool_result", tool_use_id: m.tool_call_id, content: m.content ?? "" }];
|
|
392
|
+
}
|
|
393
|
+
else if (m.role === "assistant" && m.anthropic_content?.length) {
|
|
394
|
+
blocks = m.anthropic_content;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
blocks = m.content ? [{ type: "text", text: m.content }] : [];
|
|
398
|
+
for (const raw of m.tool_calls ?? []) {
|
|
399
|
+
const call = raw;
|
|
400
|
+
let input;
|
|
401
|
+
try {
|
|
402
|
+
input = JSON.parse(call.function.arguments);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
input = { value: call.function.arguments };
|
|
406
|
+
}
|
|
407
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.function.name, input });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (!blocks.length)
|
|
411
|
+
continue;
|
|
412
|
+
const previous = chatMessages.at(-1);
|
|
413
|
+
if (previous?.role === role)
|
|
414
|
+
previous.content.push(...blocks);
|
|
324
415
|
else
|
|
325
|
-
chatMessages.push({ role
|
|
416
|
+
chatMessages.push({ role, content: [...blocks] });
|
|
326
417
|
}
|
|
327
418
|
const payload = {
|
|
328
419
|
model: this.model.name,
|
|
329
420
|
messages: chatMessages,
|
|
330
421
|
max_tokens: 4096,
|
|
331
|
-
temperature: this.temperature,
|
|
332
|
-
stream:
|
|
422
|
+
...(this.professional ? { output_config: { effort: this.intensity } } : { temperature: this.temperature }),
|
|
423
|
+
stream: this.model.supports_streaming !== false,
|
|
333
424
|
};
|
|
334
|
-
if (
|
|
335
|
-
payload.system =
|
|
425
|
+
if (systemMessages.length)
|
|
426
|
+
payload.system = systemMessages.join("\n\n");
|
|
427
|
+
if (tools?.length)
|
|
428
|
+
payload.tools = tools.map((tool) => ({
|
|
429
|
+
name: tool.function.name,
|
|
430
|
+
description: tool.function.description,
|
|
431
|
+
input_schema: tool.function.parameters,
|
|
432
|
+
}));
|
|
336
433
|
const resp = await postStream(url, {
|
|
337
434
|
method: "POST",
|
|
338
435
|
headers,
|
|
339
436
|
body: JSON.stringify(payload),
|
|
437
|
+
signal: options.signal,
|
|
340
438
|
});
|
|
341
439
|
if (!resp.ok) {
|
|
342
440
|
// Thrown rather than printed: a 502/503/504/429 is as transient as a
|
|
@@ -345,6 +443,21 @@ export class LLMClient {
|
|
|
345
443
|
const body = await resp.text().catch(() => "");
|
|
346
444
|
throw new HttpStatusError(resp.status, body.slice(0, 500));
|
|
347
445
|
}
|
|
446
|
+
if (this.model.supports_streaming === false || resp.headers.get("content-type")?.includes("application/json")) {
|
|
447
|
+
const body = await resp.json();
|
|
448
|
+
if (!Array.isArray(body.content))
|
|
449
|
+
throw new Error("endpoint did not return a valid model completion");
|
|
450
|
+
this.captureAnthropicContent(body.content);
|
|
451
|
+
if (body.usage) {
|
|
452
|
+
const prompt = body.usage.input_tokens ?? 0;
|
|
453
|
+
const completion = body.usage.output_tokens ?? 0;
|
|
454
|
+
this.usage = { prompt, completion, total: prompt + completion };
|
|
455
|
+
}
|
|
456
|
+
for (const block of body.content)
|
|
457
|
+
if (block.type === "text" && typeof block.text === "string")
|
|
458
|
+
yield block.text;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
348
461
|
if (!resp.body) {
|
|
349
462
|
yield "\n[Error: empty response body]";
|
|
350
463
|
return;
|
|
@@ -353,6 +466,8 @@ export class LLMClient {
|
|
|
353
466
|
const decoder = new TextDecoder();
|
|
354
467
|
let buf = "";
|
|
355
468
|
let stopped = false;
|
|
469
|
+
const blocks = {};
|
|
470
|
+
const toolArguments = {};
|
|
356
471
|
try {
|
|
357
472
|
while (!stopped) {
|
|
358
473
|
const { done, value } = await reader.read();
|
|
@@ -378,11 +493,42 @@ export class LLMClient {
|
|
|
378
493
|
catch {
|
|
379
494
|
continue;
|
|
380
495
|
}
|
|
381
|
-
const
|
|
496
|
+
const event = chunk;
|
|
497
|
+
const type = event.type;
|
|
498
|
+
if (type === "error")
|
|
499
|
+
throw new Error(event.error?.message ?? "Anthropic stream failed");
|
|
500
|
+
if (type === "message_start") {
|
|
501
|
+
const input = event.message?.usage?.input_tokens ?? 0;
|
|
502
|
+
const output = event.message?.usage?.output_tokens ?? 0;
|
|
503
|
+
this.usage = { prompt: input, completion: output, total: input + output };
|
|
504
|
+
}
|
|
505
|
+
else if (type === "message_delta" && event.usage) {
|
|
506
|
+
const prompt = this.usage?.prompt ?? 0;
|
|
507
|
+
const completion = event.usage.output_tokens ?? this.usage?.completion ?? 0;
|
|
508
|
+
this.usage = { prompt, completion, total: prompt + completion };
|
|
509
|
+
}
|
|
510
|
+
else if (type === "content_block_start") {
|
|
511
|
+
blocks[event.index ?? 0] = { ...event.content_block };
|
|
512
|
+
if (event.content_block?.type === "tool_use")
|
|
513
|
+
toolArguments[event.index ?? 0] = "";
|
|
514
|
+
if (typeof event.content_block?.text === "string" && event.content_block.text)
|
|
515
|
+
yield event.content_block.text;
|
|
516
|
+
}
|
|
382
517
|
if (type === "content_block_delta") {
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
518
|
+
const index = event.index ?? 0;
|
|
519
|
+
const block = blocks[index] ??= { type: "text", text: "" };
|
|
520
|
+
if (event.delta?.text) {
|
|
521
|
+
block.text = String(block.text ?? "") + event.delta.text;
|
|
522
|
+
yield event.delta.text;
|
|
523
|
+
}
|
|
524
|
+
if (event.delta?.partial_json)
|
|
525
|
+
toolArguments[index] = (toolArguments[index] ?? "") + event.delta.partial_json;
|
|
526
|
+
if (event.delta?.thinking) {
|
|
527
|
+
block.thinking = String(block.thinking ?? "") + event.delta.thinking;
|
|
528
|
+
this.reasoningContent += event.delta.thinking;
|
|
529
|
+
}
|
|
530
|
+
if (event.delta?.signature)
|
|
531
|
+
block.signature = String(block.signature ?? "") + event.delta.signature;
|
|
386
532
|
}
|
|
387
533
|
else if (type === "message_stop") {
|
|
388
534
|
stopped = true;
|
|
@@ -399,5 +545,28 @@ export class LLMClient {
|
|
|
399
545
|
// Already closed / errored; nothing left to release.
|
|
400
546
|
}
|
|
401
547
|
}
|
|
548
|
+
const content = Object.keys(blocks).map(Number).sort((a, b) => a - b).map((index) => {
|
|
549
|
+
const block = blocks[index];
|
|
550
|
+
if (block.type === "tool_use") {
|
|
551
|
+
const raw = toolArguments[index];
|
|
552
|
+
if (raw) {
|
|
553
|
+
try {
|
|
554
|
+
block.input = JSON.parse(raw);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
block.input = { value: raw };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return block;
|
|
562
|
+
});
|
|
563
|
+
this.captureAnthropicContent(content);
|
|
564
|
+
}
|
|
565
|
+
captureAnthropicContent(blocks) {
|
|
566
|
+
this.anthropicContent = blocks;
|
|
567
|
+
this.reasoningContent = blocks.filter((block) => block.type === "thinking").map((block) => String(block.thinking ?? "")).join("");
|
|
568
|
+
this.toolCalls = blocks.filter((block) => block.type === "tool_use").map((block) => ({
|
|
569
|
+
id: String(block.id ?? ""), name: String(block.name ?? ""), arguments: JSON.stringify(block.input ?? {}),
|
|
570
|
+
}));
|
|
402
571
|
}
|
|
403
572
|
}
|
package/dist/llm/subagent.js
CHANGED
|
@@ -44,10 +44,43 @@ export async function runSubAgent(options) {
|
|
|
44
44
|
];
|
|
45
45
|
let report = "";
|
|
46
46
|
let calls = 0;
|
|
47
|
+
let lastPromptTokens = 0;
|
|
47
48
|
const aborted = (rounds) => ({ report, aborted: true, exhausted: false, rounds, calls });
|
|
48
49
|
for (let round = 1; round <= maxRounds; round++) {
|
|
49
50
|
if (options.cancelled?.())
|
|
50
51
|
return aborted(round - 1);
|
|
52
|
+
const capacity = options.contextWindow;
|
|
53
|
+
if (capacity && messages.length > 2 &&
|
|
54
|
+
Math.max(lastPromptTokens, Math.ceil(JSON.stringify(messages).length / 3)) >= capacity * 0.8) {
|
|
55
|
+
// Only split at an assistant boundary: every retained tool result must
|
|
56
|
+
// still have the assistant/tool_calls message that produced it.
|
|
57
|
+
const starts = messages.flatMap((message, index) => index >= 2 && message.role === "assistant" ? [index] : []);
|
|
58
|
+
let keep = starts.length > 1 ? starts.at(-1) : messages.length;
|
|
59
|
+
if (JSON.stringify(messages.slice(keep)).length > capacity)
|
|
60
|
+
keep = messages.length;
|
|
61
|
+
const head = messages.slice(2, keep);
|
|
62
|
+
if (head.length) {
|
|
63
|
+
options.onProgress?.("compressing context");
|
|
64
|
+
const compressor = options.createClient();
|
|
65
|
+
const transcript = JSON.stringify(head);
|
|
66
|
+
// A single tool can produce more text than the role can hold. Bound
|
|
67
|
+
// the compression request itself, retaining its beginning and end.
|
|
68
|
+
const budget = Math.max(256, Math.floor(capacity * 0.6));
|
|
69
|
+
const input = transcript.length > budget
|
|
70
|
+
? `${transcript.slice(0, Math.floor(budget / 2))}\n[Earlier activity truncated for compression]\n${transcript.slice(-Math.floor(budget / 2))}`
|
|
71
|
+
: transcript;
|
|
72
|
+
const summary = await compressor.summarize(input, { cancelled: options.cancelled, onRetry: options.onRetry });
|
|
73
|
+
if (compressor.usage)
|
|
74
|
+
options.onUsage?.(compressor.usage);
|
|
75
|
+
if (options.cancelled?.())
|
|
76
|
+
return aborted(round - 1);
|
|
77
|
+
messages.splice(2, keep - 2, {
|
|
78
|
+
role: "assistant",
|
|
79
|
+
content: summary || "Earlier specialist activity was compressed. Inspect the working directory for any evidence needed to continue.",
|
|
80
|
+
});
|
|
81
|
+
lastPromptTokens = 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
51
84
|
options.onProgress?.("thinking");
|
|
52
85
|
const client = options.createClient();
|
|
53
86
|
let content = "";
|
|
@@ -59,8 +92,10 @@ export async function runSubAgent(options) {
|
|
|
59
92
|
return aborted(round - 1);
|
|
60
93
|
content += chunk;
|
|
61
94
|
}
|
|
62
|
-
if (client.usage)
|
|
95
|
+
if (client.usage) {
|
|
63
96
|
options.onUsage?.(client.usage);
|
|
97
|
+
lastPromptTokens = client.usage.prompt;
|
|
98
|
+
}
|
|
64
99
|
const toolCalls = client.toolCalls;
|
|
65
100
|
if (content.trim())
|
|
66
101
|
report = content.trim();
|
|
@@ -71,6 +106,7 @@ export async function runSubAgent(options) {
|
|
|
71
106
|
// A tool-call turn must carry the thinking that produced it back to a
|
|
72
107
|
// thinking-mode endpoint, or its next request is rejected.
|
|
73
108
|
...(client.reasoningContent ? { reasoning_content: client.reasoningContent } : {}),
|
|
109
|
+
...(client.anthropicContent?.length ? { anthropic_content: client.anthropicContent } : {}),
|
|
74
110
|
...(toolCalls.length > 0
|
|
75
111
|
? {
|
|
76
112
|
tool_calls: toolCalls.map((call) => ({
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { LLMClient } from "../llm/client.js";
|
|
2
|
+
import { buildSubAgentTask, formatSubAgentResult, runSubAgent } from "../llm/subagent.js";
|
|
3
|
+
import { DELEGATE_TOOL, getSubAgentToolDefinitions } from "../llm/tools.js";
|
|
4
|
+
import { ACTIVE_PRO_ROLES, PRO_ROLES, loadProPrompt } from "./config.js";
|
|
5
|
+
export const PRO_AGENT_TOOL = "call_pro_agent";
|
|
6
|
+
const CALL_AGENT = {
|
|
7
|
+
type: "function",
|
|
8
|
+
function: {
|
|
9
|
+
name: PRO_AGENT_TOOL,
|
|
10
|
+
description: "Ask a specialist agent to perform a self-contained task in its own context, using the same working directory and approval gate. " +
|
|
11
|
+
"Use thinking_model for reasoning, front_output_model for UI, backend_output_model for other code, modeling_model for modeling, " +
|
|
12
|
+
"plan_model for planning, confirm_model for read-only review, or main_model for coordination. Specialists can call other specialists within the configured recursion depth.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
role: { type: "string", enum: [...ACTIVE_PRO_ROLES] },
|
|
17
|
+
task: { type: "string", description: "Complete task, expected deliverables and acceptance criteria." },
|
|
18
|
+
context: { type: "string", description: "Relevant evidence, constraints and decisions; the agent cannot see the parent's conversation." },
|
|
19
|
+
reason: { type: "string", description: "Why this specialist is needed for the user's request." },
|
|
20
|
+
},
|
|
21
|
+
required: ["role", "task", "reason"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
/** All Pro agents can call the same specialists; the runtime enforces depth. */
|
|
26
|
+
export function getProToolDefinitions() {
|
|
27
|
+
return [...getSubAgentToolDefinitions(), CALL_AGENT];
|
|
28
|
+
}
|
|
29
|
+
/** A resolved model team belongs to one task; substitutions never touch disk. */
|
|
30
|
+
export class ProAgentTeam {
|
|
31
|
+
options;
|
|
32
|
+
maxDepth;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
this.maxDepth = Number.isSafeInteger(options.maxDepth) && options.maxDepth > 0 ? options.maxDepth : 10;
|
|
36
|
+
}
|
|
37
|
+
client(role) {
|
|
38
|
+
const selection = this.options.models[role];
|
|
39
|
+
if (!selection)
|
|
40
|
+
throw new Error(`Pro agent ${role} is unavailable`);
|
|
41
|
+
return new LLMClient(selection.model, selection.provider, selection.intensity, { professional: true });
|
|
42
|
+
}
|
|
43
|
+
async call(role, task, context = "", depth = 1, readOnly = false) {
|
|
44
|
+
if (!PRO_ROLES.includes(role))
|
|
45
|
+
return "[Error: unknown Pro agent role]";
|
|
46
|
+
if (role === "genimage_model")
|
|
47
|
+
return "[Error: image generation is reserved and is not enabled]";
|
|
48
|
+
if (this.options.cancelled())
|
|
49
|
+
return "[Pro agent stopped: the turn was paused or replaced.]";
|
|
50
|
+
if (!Number.isSafeInteger(depth) || depth < 0 || depth > this.maxDepth) {
|
|
51
|
+
return `[Error: Pro agent recursion limit (${this.maxDepth}) reached. Complete the work without further delegation.]`;
|
|
52
|
+
}
|
|
53
|
+
if (typeof task !== "string" || !task.trim())
|
|
54
|
+
return "[Error: a Pro agent needs a nonempty task]";
|
|
55
|
+
const selection = this.options.models[role];
|
|
56
|
+
if (!selection)
|
|
57
|
+
return `[Error: Pro agent ${role} is unavailable]`;
|
|
58
|
+
// Reviewers are always read-only, including when consulted as specialists.
|
|
59
|
+
const onlyRead = readOnly || role === "confirm_model";
|
|
60
|
+
const tools = getProToolDefinitions().filter((tool) => !onlyRead ||
|
|
61
|
+
["read_file", "list_files", PRO_AGENT_TOOL].includes(tool.function.name));
|
|
62
|
+
const names = new Set(tools.map((tool) => tool.function.name));
|
|
63
|
+
const label = `${role} · ${selection.model.name} · ${depth}/${this.maxDepth}`;
|
|
64
|
+
try {
|
|
65
|
+
const result = await runSubAgent({
|
|
66
|
+
createClient: () => this.client(role),
|
|
67
|
+
system: `${loadProPrompt(role)}\n\nDelegation depth: ${depth}/${this.maxDepth}. ` +
|
|
68
|
+
(onlyRead ? "This consultation is read-only. Do not modify files or execute commands, including through another agent." :
|
|
69
|
+
"Use the shared approval gate for every tool call. Return a self-contained report with changes, evidence and unresolved work."),
|
|
70
|
+
task: buildSubAgentTask({ task, context, cwd: this.options.cwd }),
|
|
71
|
+
tools,
|
|
72
|
+
contextWindow: selection.context_window,
|
|
73
|
+
approve: (name, args) => {
|
|
74
|
+
if (!names.has(name) && name !== DELEGATE_TOOL)
|
|
75
|
+
return Promise.resolve({
|
|
76
|
+
approved: false, source: "input", reason: "This tool is not available to this Pro agent",
|
|
77
|
+
});
|
|
78
|
+
return this.options.approve(name, args);
|
|
79
|
+
},
|
|
80
|
+
execute: async (name, args) => {
|
|
81
|
+
if (name === PRO_AGENT_TOOL || name === DELEGATE_TOOL) {
|
|
82
|
+
const nextRole = typeof args.role === "string" ? args.role : "backend_output_model";
|
|
83
|
+
const nextTask = typeof args.task === "string" ? args.task : "";
|
|
84
|
+
const nextContext = typeof args.context === "string" ? args.context : "";
|
|
85
|
+
const report = await this.call(nextRole, nextTask, nextContext, depth + 1, onlyRead);
|
|
86
|
+
this.options.onProgress?.(label);
|
|
87
|
+
return report;
|
|
88
|
+
}
|
|
89
|
+
if (!names.has(name))
|
|
90
|
+
return "[Error: tool is unavailable to this Pro agent]";
|
|
91
|
+
return this.options.execute(name, args);
|
|
92
|
+
},
|
|
93
|
+
cancelled: this.options.cancelled,
|
|
94
|
+
onUsage: this.options.onUsage,
|
|
95
|
+
onRetry: this.options.onRetry,
|
|
96
|
+
onProgress: (activity) => this.options.onProgress?.(`${label} · ${activity}`),
|
|
97
|
+
});
|
|
98
|
+
return formatSubAgentResult(result);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
this.options.onProgress?.(null);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { PRO_DIR, loadConfig } from "../config.js";
|
|
5
|
+
/** Provider URLs and credentials stay in the shared, ordinary model catalogue. */
|
|
6
|
+
export const PRO_MODEL_FILE = path.join(PRO_DIR, "model.json");
|
|
7
|
+
const ASSET_DIR = fileURLToPath(new URL("../../assets/pro/", import.meta.url));
|
|
8
|
+
export const PRO_ROLES = [
|
|
9
|
+
"main_model", "thinking_model", "front_output_model", "backend_output_model",
|
|
10
|
+
"confirm_model", "genimage_model", "plan_model", "modeling_model",
|
|
11
|
+
];
|
|
12
|
+
export const ACTIVE_PRO_ROLES = PRO_ROLES.filter((role) => role !== "genimage_model");
|
|
13
|
+
export const PRO_ROLE_LABELS = {
|
|
14
|
+
main_model: "Main", thinking_model: "Reasoning", front_output_model: "Frontend",
|
|
15
|
+
backend_output_model: "Backend", confirm_model: "Approval", genimage_model: "Image generation",
|
|
16
|
+
plan_model: "Planning", modeling_model: "Modeling",
|
|
17
|
+
};
|
|
18
|
+
/** Copy the bundled template byte-for-byte, exclusively on first use. */
|
|
19
|
+
export function ensureProModels() {
|
|
20
|
+
fs.mkdirSync(PRO_DIR, { recursive: true });
|
|
21
|
+
try {
|
|
22
|
+
fs.copyFileSync(path.join(ASSET_DIR, "model-template.json"), PRO_MODEL_FILE, fs.constants.COPYFILE_EXCL);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== "EEXIST")
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function loadProModels() {
|
|
30
|
+
ensureProModels();
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(fs.readFileSync(PRO_MODEL_FILE, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error("Pro model.json is not valid JSON; fix ~/.neutrino/pro/model.json");
|
|
37
|
+
}
|
|
38
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.tier) ||
|
|
39
|
+
value.tier.length === 0) {
|
|
40
|
+
throw new Error("Pro model.json must contain a nonempty tier array");
|
|
41
|
+
}
|
|
42
|
+
const names = new Set();
|
|
43
|
+
for (const preset of value.tier) {
|
|
44
|
+
if (!preset || typeof preset !== "object" || typeof preset.name !== "string" || !preset.name.trim() ||
|
|
45
|
+
names.has(preset.name) || typeof preset.chinese_name !== "string" || !Array.isArray(preset["model-setting"])) {
|
|
46
|
+
throw new Error("Each Pro preset needs a unique name, chinese_name and model-setting array");
|
|
47
|
+
}
|
|
48
|
+
names.add(preset.name);
|
|
49
|
+
const roles = new Set();
|
|
50
|
+
for (const setting of preset["model-setting"]) {
|
|
51
|
+
if (!setting || typeof setting !== "object" || !PRO_ROLES.includes(setting.type) || roles.has(setting.type) ||
|
|
52
|
+
typeof setting.provider !== "string" || (setting.model !== null && typeof setting.model !== "string") ||
|
|
53
|
+
typeof setting.intensity !== "string" ||
|
|
54
|
+
!Number.isSafeInteger(setting.context_window) || setting.context_window < 1) {
|
|
55
|
+
throw new Error(`Invalid model-setting in Pro preset ${preset.name}`);
|
|
56
|
+
}
|
|
57
|
+
roles.add(setting.type);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
export function requireProPreset(value) {
|
|
63
|
+
if (typeof value !== "string" || !loadProModels().tier.some((preset) => preset.name === value)) {
|
|
64
|
+
throw new Error("Unknown Pro preset");
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
export function defaultProPreset() {
|
|
69
|
+
const presets = loadProModels().tier;
|
|
70
|
+
const preferred = loadConfig("pro").pro_preset;
|
|
71
|
+
return presets.find((preset) => preset.name === preferred)?.name ??
|
|
72
|
+
presets.find((preset) => preset.name === "medium")?.name ??
|
|
73
|
+
presets.find((preset) => preset.name !== "custom")?.name ?? presets[0].name;
|
|
74
|
+
}
|
|
75
|
+
export const DEFAULT_PRO_RECURSION_DEPTH = 10;
|
|
76
|
+
export const MAX_PRO_RECURSION_DEPTH = 100;
|
|
77
|
+
export function proMaxRecursionDepth() {
|
|
78
|
+
const configured = loadConfig("pro").pro_max_recursion_depth;
|
|
79
|
+
return typeof configured === "number" && Number.isSafeInteger(configured) &&
|
|
80
|
+
configured >= 1 && configured <= MAX_PRO_RECURSION_DEPTH ? configured : DEFAULT_PRO_RECURSION_DEPTH;
|
|
81
|
+
}
|
|
82
|
+
/** Every agent prompt lives in the requested assets/pro directory. */
|
|
83
|
+
export function loadProPrompt(role) {
|
|
84
|
+
if (!PRO_ROLES.includes(role))
|
|
85
|
+
throw new Error("Unknown Pro role");
|
|
86
|
+
return fs.readFileSync(path.join(ASSET_DIR, "common.txt"), "utf8") + "\n\n" +
|
|
87
|
+
fs.readFileSync(path.join(ASSET_DIR, `${role}.txt`), "utf8");
|
|
88
|
+
}
|