@xingjisoft/neutrino 0.2.0 → 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 +89 -71
- package/assets/prompt.txt +3 -2
- package/dist/config.js +14 -1
- package/dist/llm/client.js +37 -2
- package/dist/llm/plan.js +49 -0
- package/dist/llm/subagent.js +3 -0
- package/dist/screens/chat.js +17 -2
- package/dist/web/session.js +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
|
|
|
17
17
|
- **Session history** — messages are saved before a turn starts, streamed replies are checkpointed every second, and tool calls/results are saved immediately. The history screen lists conversations started in the current working directory. Failed saves are shown in the terminal and browser UI.
|
|
18
18
|
- **Auto-folding turns** — once a turn finishes, the work behind the reply (the thinking and the tool calls) folds into one summary line in both the terminal and the browser, while the answer itself always stays on screen. Click the line to read the whole round again; a turn with little to hide, or a long answer on its own, stays open. A turn stopped with `ESC ESC` is paused, not finished, so it stays open. Nothing is dropped: the transcript, the saved session and the model's context keep every message.
|
|
19
19
|
- **Complete conversation history** — context compression keeps every original message and tool result on disk. The earlier portion starts collapsed in the terminal and browser; click **Earlier conversation** to expand it, including in reopened history. Model requests use a separate summary plus recent messages.
|
|
20
|
-
- **Plan-first turns** — before a request is answered, a planner agent splits it into ordered, independent steps ("model the case from box.png", "animate the camera: push in, orbit, pull back"), and the worker then executes those steps one at a time, top to bottom. Every step is a concrete deliverable, so later work is explicit instead of one vague instruction. See [Plan-first turns](#plan-first-turns).
|
|
21
|
-
- **The step card** — click `ⓘ` in the chat header (or press `Ctrl+P`) to open the info card in the top-right corner. It lists the plan as a vertical timeline of nodes: the step being worked on is highlighted, steps still to come are lit, and finished steps are drawn dark with a tick. The same card shows prompt/completion/total token counts when no plan is running.
|
|
20
|
+
- **Plan-first turns** — before a request is answered, a planner agent splits it into ordered, independent steps ("model the case from box.png", "animate the camera: push in, orbit, pull back"), and the worker then executes those steps one at a time, top to bottom. Every step is a concrete deliverable, so later work is explicit instead of one vague instruction. See [Plan-first turns](#plan-first-turns).
|
|
21
|
+
- **The step card** — click `ⓘ` in the chat header (or press `Ctrl+P`) to open the info card in the top-right corner. It lists the plan as a vertical timeline of nodes: the step being worked on is highlighted, steps still to come are lit, and finished steps are drawn dark with a tick. The same card shows prompt/completion/total token counts when no plan is running.
|
|
22
22
|
- **Sub-agents** — independent work can be handed to a sub-agent: a second agent loop with its own context, its own tool calls and the same approval gate. Only its report comes back, so a survey that reads thirty files does not fill the conversation with thirty files. See [Sub-agents](#sub-agents).
|
|
23
23
|
- **Agent history browser** — view past sessions from other CLI agents (Claude Code, Codex, OpenCode) directly from the history screen.
|
|
24
24
|
- **Approval gate** — risky commands and paths outside the working directory need your sign-off. The header turns orange and reads **Request Approval**; press `Y` or `N`. Delete-class commands always ask, unless you explicitly granted full trust, and there is an opt-in reviewer agent for automatic decisions.
|
|
@@ -70,7 +70,7 @@ The browser UI is mounted at <http://127.0.0.1:8765> at the same time; see
|
|
|
70
70
|
| `Ctrl+C` | Do nothing while text is selected; otherwise pause the running conversation or show the exit hint at the top |
|
|
71
71
|
| `Ctrl+C` × 3 | Quit after three consecutive presses, no more than 1.5 seconds apart (another key, paste, click or scroll resets the count) |
|
|
72
72
|
| `Ctrl+Shift+C` | Quit immediately (requires the terminal to forward the shortcut) |
|
|
73
|
-
| `Ctrl+A` | Toggle Request Approval / Auto Approval in a conversation |
|
|
73
|
+
| `Ctrl+A` | Toggle Request Approval / Auto Approval in a conversation |
|
|
74
74
|
| `Ctrl+P` | Toggle the plan / token info card in the top-right corner |
|
|
75
75
|
| `Y` | Approve the pending request (while the approval card is up) |
|
|
76
76
|
| `N` / `Esc` | Deny the pending request |
|
|
@@ -101,75 +101,93 @@ highlight stops at the log's edge instead of spilling into the header or the
|
|
|
101
101
|
input box, and the copy includes the lines that scrolled out of view. Scroll back
|
|
102
102
|
and the selection shrinks again; press `Esc` to drop it.
|
|
103
103
|
|
|
104
|
-
## Plan-first turns
|
|
105
|
-
|
|
106
|
-
A request is answered in two passes. A **planner agent** first turns the message
|
|
107
|
-
into an ordered list of independent steps; a **worker agent** then executes
|
|
108
|
-
exactly one step per pass, from the top down. Each step names one deliverable, so
|
|
109
|
-
a long request stops being a single vague instruction and becomes a sequence of
|
|
110
|
-
concrete, verifiable ones. The plan is written into the conversation as a
|
|
111
|
-
message and saved with the session, so the steps of a reopened conversation are
|
|
112
|
-
still there.
|
|
113
|
-
|
|
114
|
-
The steps are also listed in the `ⓘ` card in the top-right corner of the chat —
|
|
115
|
-
click it, or press `Ctrl+P`:
|
|
116
|
-
|
|
117
|
-
- nodes run top to bottom in execution order, joined by a connector,
|
|
118
|
-
- the step being worked on is the highlighted row,
|
|
119
|
-
- steps still to come are lit, and
|
|
120
|
-
- finished steps are drawn dark with a tick; a failed one is marked in red.
|
|
121
|
-
|
|
122
|
-
The card opens by itself as soon as a plan is written, and closing it loses
|
|
123
|
-
nothing: `Ctrl+P` brings it back. On a short terminal the card shows a window
|
|
124
|
-
around the running step and counts the steps above and below it.
|
|
125
|
-
|
|
126
|
-
A step that fails stops the plan: the remaining steps stay pending and the next
|
|
127
|
-
message can re-plan them. A turn paused with `Esc Esc` stops the plan and marks
|
|
128
|
-
it **stopped**, so no step keeps looking live. When no usable plan comes back —
|
|
129
|
-
a single-action request, a provider error, an answer that is not a plan — the
|
|
130
|
-
turn runs in one pass exactly as before, so planning is never a new way to fail.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
sub-agent
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
104
|
+
## Plan-first turns
|
|
105
|
+
|
|
106
|
+
A request is answered in two passes. A **planner agent** first turns the message
|
|
107
|
+
into an ordered list of independent steps; a **worker agent** then executes
|
|
108
|
+
exactly one step per pass, from the top down. Each step names one deliverable, so
|
|
109
|
+
a long request stops being a single vague instruction and becomes a sequence of
|
|
110
|
+
concrete, verifiable ones. The plan is written into the conversation as a
|
|
111
|
+
message and saved with the session, so the steps of a reopened conversation are
|
|
112
|
+
still there.
|
|
113
|
+
|
|
114
|
+
The steps are also listed in the `ⓘ` card in the top-right corner of the chat —
|
|
115
|
+
click it, or press `Ctrl+P`:
|
|
116
|
+
|
|
117
|
+
- nodes run top to bottom in execution order, joined by a connector,
|
|
118
|
+
- the step being worked on is the highlighted row,
|
|
119
|
+
- steps still to come are lit, and
|
|
120
|
+
- finished steps are drawn dark with a tick; a failed one is marked in red.
|
|
121
|
+
|
|
122
|
+
The card opens by itself as soon as a plan is written, and closing it loses
|
|
123
|
+
nothing: `Ctrl+P` brings it back. On a short terminal the card shows a window
|
|
124
|
+
around the running step and counts the steps above and below it.
|
|
125
|
+
|
|
126
|
+
A step that fails stops the plan: the remaining steps stay pending and the next
|
|
127
|
+
message can re-plan them. A turn paused with `Esc Esc` stops the plan and marks
|
|
128
|
+
it **stopped**, so no step keeps looking live. When no usable plan comes back —
|
|
129
|
+
a single-action request, a provider error, an answer that is not a plan — the
|
|
130
|
+
turn runs in one pass exactly as before, so planning is never a new way to fail.
|
|
131
|
+
|
|
132
|
+
**Simple requests are not planned at all.** Planning costs a model round-trip
|
|
133
|
+
before any work starts, and for "把这个变量改名" that round-trip is pure friction.
|
|
134
|
+
A request is planned only when it shows a reason to be split:
|
|
135
|
+
|
|
136
|
+
| Signal | Example |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| More than one thing to do | `先改配置,再更新文档`, `运行测试,并且把报错修掉` |
|
|
139
|
+
| Its own structure | a numbered or bulleted list |
|
|
140
|
+
| It reads as a brief | longer than about 180 columns |
|
|
141
|
+
| It asks for a plan | `列个计划`, `plan`, `steps` |
|
|
142
|
+
|
|
143
|
+
Everything else runs in one pass: no planner call, no plan card, and the agent
|
|
144
|
+
still names the steps it is taking inside its own answer. The check lives in
|
|
145
|
+
`needsPlan()` in `src/llm/plan.ts`.
|
|
146
|
+
|
|
147
|
+
The prompts are files, not code: [`assets/planner.txt`](./assets/planner.txt)
|
|
148
|
+
says how steps are written, [`assets/step.txt`](./assets/step.txt) is the
|
|
149
|
+
instruction for each step, and [`assets/prompt.txt`](./assets/prompt.txt) is the
|
|
150
|
+
worker's base system prompt. Turn planning off for one run with
|
|
151
|
+
`NEUTRINO_PLAN=off`, or permanently with `"plan_first": false` in `config.json`.
|
|
152
|
+
|
|
153
|
+
## Sub-agents
|
|
154
|
+
|
|
155
|
+
The agent can hand one self-contained sub-task to a **sub-agent**: a second agent
|
|
156
|
+
loop with its own transcript, its own tool calls and the same working directory.
|
|
157
|
+
Only its final report comes back — the files it read and the commands it ran
|
|
158
|
+
never enter the main conversation. That is the point: surveying a large codebase
|
|
159
|
+
or probing how a command behaves costs many messages, and the parent needs the
|
|
160
|
+
conclusion, not the transcript. The child's tokens still count in the
|
|
161
|
+
conversation's usage.
|
|
162
|
+
|
|
163
|
+
- The parent calls the `delegate` tool with a complete task and, optionally, a
|
|
164
|
+
little context. The sub-agent cannot see the conversation and cannot ask the
|
|
165
|
+
user, so a task that refers to "the file above" fails; the system prompt tells
|
|
166
|
+
the model to write a brief that stands alone.
|
|
167
|
+
- Its tool calls go through the **same approval gate**: a risky call parks the
|
|
168
|
+
turn and shows the usual card (the header turns orange, the browser shows its
|
|
169
|
+
banner). Denying it tells the sub-agent not to retry unchanged, and the parent
|
|
170
|
+
receives the refusal in the report.
|
|
171
|
+
- Delegation is one level deep — `delegate` is not in a sub-agent's toolset — so
|
|
172
|
+
the work always has one owner and one pause button.
|
|
173
|
+
- While it runs, the terminal status line and the browser badge name the current
|
|
174
|
+
phase (`Sub-agent — read_file`), and the `delegate` tool block shows the task
|
|
175
|
+
it was given. The report replaces that marker when it arrives, as the result of
|
|
176
|
+
the tool call.
|
|
177
|
+
- The round limit (12) bounds a child that never finishes; the parent then gets
|
|
178
|
+
the partial report, clearly marked as partial. A paused turn aborts the
|
|
179
|
+
sub-agent and says so in the tool result.
|
|
180
|
+
|
|
181
|
+
The sub-agent's prompt is a file too: [`assets/subagent.txt`](./assets/subagent.txt).
|
|
182
|
+
|
|
168
183
|
## Configuration
|
|
169
184
|
|
|
170
185
|
Settings are stored under `~/.neutrino/`:
|
|
171
186
|
|
|
172
187
|
- `model.json` — provider and model definitions (edit to add your own endpoints).
|
|
188
|
+
DeepSeek-style thinking endpoints get `reasoning_content` sent back on their
|
|
189
|
+
assistant messages automatically; a differently named endpoint can opt in
|
|
190
|
+
with `"reasoning": true` (or opt out with `false`).
|
|
173
191
|
- `config.json` — app configuration (selected provider/model/intensity, theme,
|
|
174
192
|
language, approval mode, `auto_approve_extreme_risk`, `sound_alerts`,
|
|
175
193
|
`web_console`).
|
|
@@ -184,7 +202,7 @@ A few environment variables override flags:
|
|
|
184
202
|
| `NEUTRINO_WEB_PORT` | Web UI port: like `--port` in either mode, and it mounts the UI even when `web_console` is off. |
|
|
185
203
|
| `NEUTRINO_WEB_HOST` | Interface to bind, like `--host`; also mounts the UI when `web_console` is off. |
|
|
186
204
|
| `NEUTRINO_NETWORK_ATTEMPTS` | Total tries for a model request that keeps dropping (default 7). |
|
|
187
|
-
| `NEUTRINO_RETRY_DELAY_MS` | Wait before the first retry in ms; each retry doubles it (default 5000). |
|
|
205
|
+
| `NEUTRINO_RETRY_DELAY_MS` | Wait before the first retry in ms; each retry doubles it (default 5000). |
|
|
188
206
|
| `NEUTRINO_PLAN` | `off` answers a request in one pass without planning it (overrides `plan_first`); `on` forces planning. |
|
|
189
207
|
|
|
190
208
|
### Approvals
|
|
@@ -389,7 +407,7 @@ The agent can call the following tools during a conversation:
|
|
|
389
407
|
- `run_command` — execute a shell command (120s timeout, output capped).
|
|
390
408
|
- `read_file` — read a text file (capped at 200KB).
|
|
391
409
|
- `write_file` — write content to a file.
|
|
392
|
-
- `list_files` — list directory contents.
|
|
410
|
+
- `list_files` — list directory contents.
|
|
393
411
|
- `delegate` — hand one self-contained sub-task to a sub-agent and return its report.
|
|
394
412
|
|
|
395
413
|
Every tool takes an additional `reason` argument: one sentence saying what the
|
|
@@ -425,8 +443,8 @@ node scripts/test-compact.mjs # verify context-compression planning
|
|
|
425
443
|
node scripts/test-history-fold.mjs # verify full-history retention, folding and resume
|
|
426
444
|
node scripts/test-web-history.mjs # verify browser history disclosures and streaming state
|
|
427
445
|
node scripts/test-web-default.mjs # verify the web console starts and toggles by default
|
|
428
|
-
node scripts/test-turn-fold.mjs # verify folded turns keep their reply and every message
|
|
429
|
-
node scripts/test-plan.mjs # verify plan-first turns, step order, the step card and the fallback
|
|
446
|
+
node scripts/test-turn-fold.mjs # verify folded turns keep their reply and every message
|
|
447
|
+
node scripts/test-plan.mjs # verify plan-first turns, step order, the step card and the fallback
|
|
430
448
|
node scripts/test-subagent.mjs # verify delegation: isolation, approvals, abort, round limits
|
|
431
449
|
node scripts/test-tools.mjs # verify tool-output folding
|
|
432
450
|
node scripts/test-web.mjs # verify the web server, API, SSE stream and path-traversal guards
|
package/assets/prompt.txt
CHANGED
|
@@ -7,8 +7,9 @@ is described.
|
|
|
7
7
|
- Your work is planned before it starts. When a turn carries a plan you are given
|
|
8
8
|
exactly one step of it: do that step, verify it, and stop. Never start a later
|
|
9
9
|
step, and never redo an earlier one.
|
|
10
|
-
- Without a plan,
|
|
11
|
-
|
|
10
|
+
- Without a plan, just do the work. A single action goes straight to the answer —
|
|
11
|
+
do not decorate it with a plan. When you are taking several steps, name them in
|
|
12
|
+
one short line each and then take them in that order.
|
|
12
13
|
- Look before you change: read the files you are about to edit, list the
|
|
13
14
|
directory, run the command that shows the current state. Anything about the
|
|
14
15
|
code you have not read is a guess, and guesses are the main source of bad
|
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];
|
|
@@ -57,7 +68,9 @@ export const DEFAULT_CONFIG = {
|
|
|
57
68
|
theme: "default",
|
|
58
69
|
default_intensity: "High",
|
|
59
70
|
// Plan a request into steps before answering it: the planner agent writes the
|
|
60
|
-
// plan, the worker executes one step per turn.
|
|
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.
|
|
61
74
|
plan_first: true,
|
|
62
75
|
// Default for new conversations; each conversation can override it.
|
|
63
76
|
approval_mode: "request",
|
package/dist/llm/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { INTENSITY_PARAMS } from "../config.js";
|
|
1
|
+
import { INTENSITY_PARAMS, 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?"
|
|
@@ -54,6 +54,12 @@ export class LLMClient {
|
|
|
54
54
|
top_p;
|
|
55
55
|
toolCalls = [];
|
|
56
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 = "";
|
|
57
63
|
constructor(model, provider, intensity) {
|
|
58
64
|
this.model = model;
|
|
59
65
|
this.provider = provider;
|
|
@@ -98,6 +104,7 @@ export class LLMClient {
|
|
|
98
104
|
async *streamTurn(messages, tools, options = {}) {
|
|
99
105
|
this.toolCalls = [];
|
|
100
106
|
this.usage = null;
|
|
107
|
+
this.reasoningContent = "";
|
|
101
108
|
try {
|
|
102
109
|
if (this.provider === "Anthropic") {
|
|
103
110
|
yield* this.streamAnthropic(messages, options);
|
|
@@ -125,6 +132,7 @@ export class LLMClient {
|
|
|
125
132
|
const policy = resolvePolicy(options);
|
|
126
133
|
for (let attempt = 1;; attempt++) {
|
|
127
134
|
this.toolCalls = [];
|
|
135
|
+
this.reasoningContent = "";
|
|
128
136
|
let produced = false;
|
|
129
137
|
try {
|
|
130
138
|
for await (const token of this.openAIAttempt(messages, tools)) {
|
|
@@ -141,6 +149,28 @@ export class LLMClient {
|
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
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
|
+
}
|
|
144
174
|
/** A single streaming attempt; see `streamOpenAI` for the retry policy. */
|
|
145
175
|
async *openAIAttempt(messages, tools) {
|
|
146
176
|
const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
|
|
@@ -150,7 +180,7 @@ export class LLMClient {
|
|
|
150
180
|
};
|
|
151
181
|
const payload = {
|
|
152
182
|
model: this.model.name,
|
|
153
|
-
messages,
|
|
183
|
+
messages: this.wireMessages(messages),
|
|
154
184
|
temperature: this.temperature,
|
|
155
185
|
top_p: this.top_p,
|
|
156
186
|
stream: true,
|
|
@@ -216,6 +246,11 @@ export class LLMClient {
|
|
|
216
246
|
}
|
|
217
247
|
const choice = chunk.choices?.[0];
|
|
218
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;
|
|
219
254
|
const content = delta.content;
|
|
220
255
|
if (typeof content === "string" && content)
|
|
221
256
|
yield content;
|
package/dist/llm/plan.js
CHANGED
|
@@ -20,6 +20,55 @@ export const PLAN_MIN_STEPS = 2;
|
|
|
20
20
|
export const PLAN_MAX_STEPS = 10;
|
|
21
21
|
export const PLAN_MAX_TITLE = 160;
|
|
22
22
|
export const PLAN_MAX_DETAIL = 400;
|
|
23
|
+
// -------------------------------------------------------------- when to plan
|
|
24
|
+
/**
|
|
25
|
+
* A request longer than this many columns is treated as a brief and planned.
|
|
26
|
+
* In practice that is roughly eighty Chinese characters or one hundred and
|
|
27
|
+
* eighty ASCII ones — past that, "one action" is already unlikely.
|
|
28
|
+
*/
|
|
29
|
+
export const PLAN_LENGTH_LIMIT = 180;
|
|
30
|
+
/** The user brought their own structure: numbered steps or bullets. */
|
|
31
|
+
const LIST_LINE = /^\s*(?:[-*•]|\d+[.)、])\s+/m;
|
|
32
|
+
/**
|
|
33
|
+
* Wording that means "more than one thing to do".
|
|
34
|
+
*
|
|
35
|
+
* Chinese has no spaces, so the weak conjunctions only count after punctuation
|
|
36
|
+
* (",并且…") or as a clear pair ("先…再…"): otherwise 合并, 并发 and 再运行一次
|
|
37
|
+
* would all read as sequencing.
|
|
38
|
+
*/
|
|
39
|
+
const SEQUENCING = /然后|接着|随后|之后再|并且|以及|[,,;;]\s*并|先[^。!?\n]{0,40}再|\b(?:then|after that|afterwards|finally|also|next|followed by)\b/iu;
|
|
40
|
+
/** An explicit plan request: the user wants steps, however small the task. */
|
|
41
|
+
const PLAN_REQUEST = /计划|规划|步骤|拆(?:分|解)|\bplan\b|\bsteps?\b/iu;
|
|
42
|
+
/** Sentence terminators; an English full stop only counts before whitespace. */
|
|
43
|
+
function sentenceMarks(text) {
|
|
44
|
+
const cjk = text.match(/[。!?!?]/gu)?.length ?? 0;
|
|
45
|
+
const dots = text.match(/\.(?=\s|$)/gu)?.length ?? 0;
|
|
46
|
+
return cjk + dots;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Does this request need a plan at all?
|
|
50
|
+
*
|
|
51
|
+
* Planning costs a model round-trip before any work starts, and for "rename
|
|
52
|
+
* this variable" that round-trip is pure friction. A request is planned only
|
|
53
|
+
* when it shows a reason to be split into steps: it asks for more than one
|
|
54
|
+
* thing, brings its own structure (a list), reads as a brief (long), or asks
|
|
55
|
+
* for a plan. Everything else runs in one pass, where the system prompt still
|
|
56
|
+
* has the agent state the steps it is taking in its own answer.
|
|
57
|
+
*/
|
|
58
|
+
export function needsPlan(request) {
|
|
59
|
+
const text = request.trim();
|
|
60
|
+
if (!text)
|
|
61
|
+
return false;
|
|
62
|
+
if (PLAN_REQUEST.test(text))
|
|
63
|
+
return true;
|
|
64
|
+
if (LIST_LINE.test(text))
|
|
65
|
+
return true;
|
|
66
|
+
if (SEQUENCING.test(text))
|
|
67
|
+
return true;
|
|
68
|
+
if (sentenceMarks(text) >= 2)
|
|
69
|
+
return true;
|
|
70
|
+
return displayWidth(text) > PLAN_LENGTH_LIMIT;
|
|
71
|
+
}
|
|
23
72
|
// ------------------------------------------------------------------ parsing
|
|
24
73
|
function cleanText(value, max) {
|
|
25
74
|
if (typeof value !== "string")
|
package/dist/llm/subagent.js
CHANGED
|
@@ -68,6 +68,9 @@ export async function runSubAgent(options) {
|
|
|
68
68
|
messages.push({
|
|
69
69
|
role: "assistant",
|
|
70
70
|
content,
|
|
71
|
+
// A tool-call turn must carry the thinking that produced it back to a
|
|
72
|
+
// thinking-mode endpoint, or its next request is rejected.
|
|
73
|
+
...(client.reasoningContent ? { reasoning_content: client.reasoningContent } : {}),
|
|
71
74
|
...(toolCalls.length > 0
|
|
72
75
|
? {
|
|
73
76
|
tool_calls: toolCalls.map((call) => ({
|
package/dist/screens/chat.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Screen } from "../terminal/screen.js";
|
|
2
2
|
import { C } from "../colors.js";
|
|
3
|
-
import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
|
|
3
|
+
import { findModel, contextCompressionThreshold, loadConfig, loadModels, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
|
|
4
4
|
import { drawHeader } from "../header.js";
|
|
5
5
|
import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText, approvalToggle, drawApprovalToggle, inRect } from "../ui.js";
|
|
6
6
|
import { drawSpans, renderMarkdownText } from "../markdown.js";
|
|
@@ -8,7 +8,7 @@ import { ellipsize, displayWidth } from "../width.js";
|
|
|
8
8
|
import { sanitizeText } from "../sanitize.js";
|
|
9
9
|
import { LLMClient, describeError } from "../llm/client.js";
|
|
10
10
|
import { formatDelay } from "../llm/retry.js";
|
|
11
|
-
import { advancePlan, buildPlannerInput, lastRequestText, parsePlan, planCardRows, planMessage, planRowsWidth, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
|
|
11
|
+
import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planCardRows, planMessage, planRowsWidth, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
|
|
12
12
|
import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
|
|
13
13
|
import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
|
|
14
14
|
import { assessToolCall } from "../llm/risk.js";
|
|
@@ -173,6 +173,13 @@ export class ChatScreen extends Screen {
|
|
|
173
173
|
api_key: st.selectedModelApiKey,
|
|
174
174
|
supports_streaming: st.selectedModelStreaming,
|
|
175
175
|
};
|
|
176
|
+
// The selected-state snapshot has no room for provider options; re-read the
|
|
177
|
+
// thinking-mode flag from model.json so a hand-configured endpoint keeps it.
|
|
178
|
+
const configured = loadModels().providers
|
|
179
|
+
.find((provider) => provider.name === this.modelProvider)?.models
|
|
180
|
+
.find((model) => model.name === this.model.name);
|
|
181
|
+
if (configured && typeof configured.reasoning === "boolean")
|
|
182
|
+
this.model.reasoning = configured.reasoning;
|
|
176
183
|
this.intensity = this.loadedIntensity ?? st.selectedIntensity;
|
|
177
184
|
if (this.loadedTokens) {
|
|
178
185
|
this.tokens = { prompt: this.loadedTokens.prompt, completion: this.loadedTokens.completion };
|
|
@@ -668,6 +675,11 @@ export class ChatScreen extends Screen {
|
|
|
668
675
|
const request = lastRequestText(this.messages);
|
|
669
676
|
if (!request.trim())
|
|
670
677
|
return null;
|
|
678
|
+
// A request that is one action is not planned: the planner round-trip would
|
|
679
|
+
// cost more than the steps are worth, and the worker states its steps in its
|
|
680
|
+
// own answer. The card then shows the token counts, not a one-line plan.
|
|
681
|
+
if (!needsPlan(request))
|
|
682
|
+
return null;
|
|
671
683
|
this.planning = true;
|
|
672
684
|
this.app.render();
|
|
673
685
|
this.publishLive();
|
|
@@ -954,6 +966,9 @@ export class ChatScreen extends Screen {
|
|
|
954
966
|
apiMessages.push({
|
|
955
967
|
role: "assistant",
|
|
956
968
|
content,
|
|
969
|
+
// DeepSeek's thinking mode needs the reasoning behind a tool call
|
|
970
|
+
// returned with it; without it the next request is a 400.
|
|
971
|
+
reasoning_content: client.reasoningContent ?? "",
|
|
957
972
|
tool_calls: toolCalls.map((tc) => ({
|
|
958
973
|
id: tc.id,
|
|
959
974
|
type: "function",
|
package/dist/web/session.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
|
|
14
14
|
import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
|
|
15
15
|
import { LLMClient, describeError } from "../llm/client.js";
|
|
16
|
-
import { advancePlan, buildPlannerInput, lastRequestText, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
|
|
16
|
+
import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
|
|
17
17
|
import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
|
|
18
18
|
import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
|
|
19
19
|
import { assessToolCall } from "../llm/risk.js";
|
|
@@ -371,6 +371,9 @@ export class WebSession {
|
|
|
371
371
|
const request = lastRequestText(this.messages);
|
|
372
372
|
if (!request.trim())
|
|
373
373
|
return null;
|
|
374
|
+
// One action is not a plan: run the turn in one pass, as the terminal does.
|
|
375
|
+
if (!needsPlan(request))
|
|
376
|
+
return null;
|
|
374
377
|
this.planning = true;
|
|
375
378
|
this.publishLive();
|
|
376
379
|
try {
|
|
@@ -472,13 +475,32 @@ export class WebSession {
|
|
|
472
475
|
this.tokens.completion += client.usage.completion;
|
|
473
476
|
this.lastPromptTokens = client.usage.prompt;
|
|
474
477
|
}
|
|
478
|
+
const toolCalls = client.toolCalls;
|
|
475
479
|
if (text.trim()) {
|
|
476
480
|
this.messages.push({ role: "assistant", content: text });
|
|
477
|
-
|
|
481
|
+
}
|
|
482
|
+
if (text.trim() || toolCalls.length > 0) {
|
|
483
|
+
// The assistant message and the tool results it produced must stay
|
|
484
|
+
// paired: a `tool` message without its `tool_calls` parent is
|
|
485
|
+
// rejected by every OpenAI-compatible endpoint. The reasoning is the
|
|
486
|
+
// thinking that produced the calls, which DeepSeek requires back.
|
|
487
|
+
apiMessages.push({
|
|
488
|
+
role: "assistant",
|
|
489
|
+
content: text,
|
|
490
|
+
reasoning_content: client.reasoningContent ?? "",
|
|
491
|
+
...(toolCalls.length > 0
|
|
492
|
+
? {
|
|
493
|
+
tool_calls: toolCalls.map((call) => ({
|
|
494
|
+
id: call.id,
|
|
495
|
+
type: "function",
|
|
496
|
+
function: { name: call.name, arguments: call.arguments },
|
|
497
|
+
})),
|
|
498
|
+
}
|
|
499
|
+
: {}),
|
|
500
|
+
});
|
|
478
501
|
}
|
|
479
502
|
this.save();
|
|
480
503
|
this.publishLive();
|
|
481
|
-
const toolCalls = client.toolCalls;
|
|
482
504
|
if (toolCalls.length === 0)
|
|
483
505
|
break;
|
|
484
506
|
for (const call of toolCalls) {
|