@pentoshi/clai 2.0.26 → 2.0.27
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 +1 -1
- package/dist/agent/context-manager.d.ts +5 -2
- package/dist/agent/context-manager.js +26 -69
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/events.d.ts +1 -1
- package/dist/agent/runner.d.ts +21 -1
- package/dist/agent/runner.js +686 -135
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/session-title.d.ts +27 -0
- package/dist/agent/session-title.js +109 -0
- package/dist/agent/session-title.js.map +1 -0
- package/dist/commands/update.js +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/http.js +20 -6
- package/dist/llm/http.js.map +1 -1
- package/dist/modes/ask.d.ts +29 -0
- package/dist/modes/ask.js +188 -23
- package/dist/modes/ask.js.map +1 -1
- package/dist/prompts/index.d.ts +2 -2
- package/dist/prompts/index.js +32 -9
- package/dist/prompts/index.js.map +1 -1
- package/dist/repl.js +104 -3
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +66 -7
- package/dist/safety/classifier.js.map +1 -1
- package/dist/store/history.js +68 -48
- package/dist/store/history.js.map +1 -1
- package/dist/tools/fs.d.ts +4 -0
- package/dist/tools/fs.js +40 -2
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/registry.d.ts +8 -0
- package/dist/tools/registry.js +3 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tui/App.js +242 -33
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/components/ConfirmModal.js +14 -2
- package/dist/tui/components/ConfirmModal.js.map +1 -1
- package/dist/tui/components/PickerPanel.d.ts +2 -1
- package/dist/tui/components/PickerPanel.js +73 -23
- package/dist/tui/components/PickerPanel.js.map +1 -1
- package/dist/tui/confirm.d.ts +1 -1
- package/dist/tui/confirm.js +8 -0
- package/dist/tui/confirm.js.map +1 -1
- package/dist/tui/hooks/useAgentRunner.d.ts +27 -2
- package/dist/tui/hooks/useAgentRunner.js +132 -17
- package/dist/tui/hooks/useAgentRunner.js.map +1 -1
- package/dist/tui/render-lines.js +28 -1
- package/dist/tui/render-lines.js.map +1 -1
- package/dist/tui/state.d.ts +1 -1
- package/dist/ui/markdown.js +63 -30
- package/dist/ui/markdown.js.map +1 -1
- package/package.json +3 -2
package/dist/agent/runner.js
CHANGED
|
@@ -4,17 +4,17 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { fixOwner, handlePermissionError } from "../os/permissions.js";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
|
-
import { streamWithProvider } from "../llm/router.js";
|
|
7
|
+
import { streamWithProvider, completeWithProvider } from "../llm/router.js";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { jobManager } from "../tools/jobs.js";
|
|
10
10
|
import { currentDateTimeContext, renderAgentSystemPrompt, } from "../prompts/index.js";
|
|
11
11
|
import { getConfig } from "../store/config.js";
|
|
12
12
|
import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
13
|
-
import { availableToolNames, normalizeToolCall, runToolCall } from "../tools/registry.js";
|
|
13
|
+
import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
|
|
14
14
|
import { looksInteractiveStdin } from "../tools/shell.js";
|
|
15
15
|
import { reduceToolOutput } from "../tools/policies/output-policy.js";
|
|
16
16
|
import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
|
|
17
|
-
import {
|
|
17
|
+
import { compactMessagesWithSummary, estimateMessagesTokens, } from "./context-manager.js";
|
|
18
18
|
import { auditLog } from "../store/logs.js";
|
|
19
19
|
import { loadProjectContext } from "../store/project.js";
|
|
20
20
|
import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
|
|
@@ -36,11 +36,12 @@ function renderPlanForTerminal(plan) {
|
|
|
36
36
|
: undefined;
|
|
37
37
|
return side ?? renderPlanChecklist(plan);
|
|
38
38
|
}
|
|
39
|
-
export function createSessionPolicy() {
|
|
39
|
+
export function createSessionPolicy(sessionId) {
|
|
40
40
|
return {
|
|
41
41
|
allow: new Set(),
|
|
42
42
|
pentestAuthorized: { value: false },
|
|
43
|
-
sessionId:
|
|
43
|
+
sessionId: sessionId ??
|
|
44
|
+
`sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
44
45
|
planApproved: { value: false },
|
|
45
46
|
};
|
|
46
47
|
}
|
|
@@ -55,21 +56,21 @@ export function preprocessJson(raw) {
|
|
|
55
56
|
result += char;
|
|
56
57
|
}
|
|
57
58
|
else if (inString) {
|
|
58
|
-
if (char ===
|
|
59
|
-
result +=
|
|
59
|
+
if (char === "\n") {
|
|
60
|
+
result += "\\n";
|
|
60
61
|
}
|
|
61
|
-
else if (char ===
|
|
62
|
-
result +=
|
|
62
|
+
else if (char === "\r") {
|
|
63
|
+
result += "\\r";
|
|
63
64
|
}
|
|
64
|
-
else if (char ===
|
|
65
|
-
result +=
|
|
65
|
+
else if (char === "\t") {
|
|
66
|
+
result += "\\t";
|
|
66
67
|
}
|
|
67
68
|
else {
|
|
68
69
|
result += char;
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
else {
|
|
72
|
-
if (char ===
|
|
73
|
+
if (char === "," && i + 1 < raw.length) {
|
|
73
74
|
let nextNonWs = "";
|
|
74
75
|
for (let j = i + 1; j < raw.length; j++) {
|
|
75
76
|
if (!/\s/.test(raw[j])) {
|
|
@@ -77,13 +78,13 @@ export function preprocessJson(raw) {
|
|
|
77
78
|
break;
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
|
-
if (nextNonWs ===
|
|
81
|
+
if (nextNonWs === "}" || nextNonWs === "]") {
|
|
81
82
|
continue;
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
result += char;
|
|
85
86
|
}
|
|
86
|
-
if (char ===
|
|
87
|
+
if (char === "\\" && inString) {
|
|
87
88
|
escaped = !escaped;
|
|
88
89
|
}
|
|
89
90
|
else {
|
|
@@ -92,24 +93,154 @@ export function preprocessJson(raw) {
|
|
|
92
93
|
}
|
|
93
94
|
return result;
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Last-resort lenient repair for tool-call JSON that strict JSON.parse
|
|
98
|
+
* rejected. Models frequently emit "almost JSON": smart/curly quotes from a
|
|
99
|
+
* copy-paste, Python-style True/False/None literals, or an object that is
|
|
100
|
+
* wholly single-quoted. We only apply these transforms when a strict parse
|
|
101
|
+
* has already failed, so well-formed JSON is never touched.
|
|
102
|
+
*/
|
|
103
|
+
function lenientJsonParse(text) {
|
|
104
|
+
const candidates = [];
|
|
105
|
+
// 1. Normalize unicode/smart quotes to ASCII quotes.
|
|
106
|
+
const deSmart = text
|
|
107
|
+
.replace(/[\u201C\u201D\u201E\u2033]/g, '"')
|
|
108
|
+
.replace(/[\u2018\u2019\u201A\u2032]/g, "'");
|
|
109
|
+
candidates.push(deSmart);
|
|
110
|
+
// 2. Python/JS literals → JSON literals (outside of double-quoted strings).
|
|
111
|
+
candidates.push(replaceOutsideStrings(deSmart));
|
|
112
|
+
// 3. Single-quoted object → double-quoted (only when there are no double
|
|
113
|
+
// quotes already, so we don't corrupt strings that contain apostrophes).
|
|
114
|
+
if (!deSmart.includes('"') && deSmart.includes("'")) {
|
|
115
|
+
candidates.push(deSmart.replace(/'/g, '"'));
|
|
116
|
+
}
|
|
117
|
+
for (const candidate of candidates) {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(preprocessJson(candidate).trim());
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// try the next repair
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
/** Replace bare Python/JS literals (True/False/None/NaN) with JSON equivalents,
|
|
128
|
+
* skipping anything inside a double-quoted string. */
|
|
129
|
+
function replaceOutsideStrings(text) {
|
|
130
|
+
let inString = false;
|
|
131
|
+
let escaped = false;
|
|
132
|
+
let out = "";
|
|
133
|
+
let i = 0;
|
|
134
|
+
while (i < text.length) {
|
|
135
|
+
const ch = text[i];
|
|
136
|
+
if (inString) {
|
|
137
|
+
out += ch;
|
|
138
|
+
if (escaped)
|
|
139
|
+
escaped = false;
|
|
140
|
+
else if (ch === "\\")
|
|
141
|
+
escaped = true;
|
|
142
|
+
else if (ch === '"')
|
|
143
|
+
inString = false;
|
|
144
|
+
i += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === '"') {
|
|
148
|
+
inString = true;
|
|
149
|
+
out += ch;
|
|
150
|
+
i += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const rest = text.slice(i);
|
|
154
|
+
const m = /^(True|False|None|NaN|undefined)\b/.exec(rest);
|
|
155
|
+
if (m) {
|
|
156
|
+
const word = m[1];
|
|
157
|
+
out +=
|
|
158
|
+
word === "True"
|
|
159
|
+
? "true"
|
|
160
|
+
: word === "False"
|
|
161
|
+
? "false"
|
|
162
|
+
: word === "NaN"
|
|
163
|
+
? "0"
|
|
164
|
+
: "null"; // None / undefined
|
|
165
|
+
i += word.length;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
out += ch;
|
|
169
|
+
i += 1;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
95
173
|
function tryParseCall(raw) {
|
|
174
|
+
let parsed;
|
|
96
175
|
try {
|
|
97
|
-
|
|
98
|
-
const parsed = JSON.parse(preprocessed.trim());
|
|
99
|
-
if (typeof parsed.name === "string" &&
|
|
100
|
-
parsed.args &&
|
|
101
|
-
typeof parsed.args === "object") {
|
|
102
|
-
return {
|
|
103
|
-
name: parsed.name,
|
|
104
|
-
args: parsed.args,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
176
|
+
parsed = JSON.parse(preprocessJson(raw).trim());
|
|
107
177
|
}
|
|
108
178
|
catch {
|
|
109
|
-
//
|
|
179
|
+
// Strict parse failed — try lenient repairs before giving up so a model
|
|
180
|
+
// that emits smart quotes / single quotes / Python literals still works.
|
|
181
|
+
const repaired = lenientJsonParse(raw.trim());
|
|
182
|
+
if (repaired && typeof repaired === "object" && !Array.isArray(repaired)) {
|
|
183
|
+
parsed = repaired;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!parsed)
|
|
187
|
+
return undefined;
|
|
188
|
+
const anyParsed = parsed;
|
|
189
|
+
// Accept name under several keys models commonly use.
|
|
190
|
+
const nameRaw = typeof parsed.name === "string"
|
|
191
|
+
? parsed.name
|
|
192
|
+
: typeof anyParsed.tool_name === "string"
|
|
193
|
+
? anyParsed.tool_name
|
|
194
|
+
: undefined;
|
|
195
|
+
if (typeof nameRaw === "string" && nameRaw.length > 0) {
|
|
196
|
+
// Strip a leading "functions." namespace some models add.
|
|
197
|
+
const name = nameRaw.replace(/^functions\./, "");
|
|
198
|
+
// Many OpenAI/Hermes/Qwen-trained models emit {"name","arguments"}
|
|
199
|
+
// (or "parameters"/"input") instead of {"name","args"} — accept any.
|
|
200
|
+
const argsSrc = pickObject(parsed.args) ??
|
|
201
|
+
pickObject(parsed.arguments) ??
|
|
202
|
+
pickObject(anyParsed.parameters) ??
|
|
203
|
+
pickObject(anyParsed.input);
|
|
204
|
+
if (argsSrc) {
|
|
205
|
+
return { name, args: argsSrc };
|
|
206
|
+
}
|
|
207
|
+
// Allow an empty args object explicitly written as {} or null (common for
|
|
208
|
+
// sysinfo), but do NOT invent args for objects that merely happen to
|
|
209
|
+
// contain a "name" key (e.g. {"name":"shell.exec"} with no command).
|
|
210
|
+
if (parsed.args === null || parsed.arguments === null) {
|
|
211
|
+
return { name, args: {} };
|
|
212
|
+
}
|
|
213
|
+
// Flattened form: the args are emitted as SIBLINGS of `name` rather than
|
|
214
|
+
// nested (e.g. {"name":"web.fetch","url":"…","responseMode":"raw"}). Treat
|
|
215
|
+
// the non-reserved keys as args, but only when at least one is a known
|
|
216
|
+
// tool-arg key so plain data objects carrying a `name` are not misread.
|
|
217
|
+
const flat = {};
|
|
218
|
+
for (const key of Object.keys(anyParsed)) {
|
|
219
|
+
if (!FLATTENED_RESERVED_KEYS.has(key))
|
|
220
|
+
flat[key] = anyParsed[key];
|
|
221
|
+
}
|
|
222
|
+
const flatKeys = Object.keys(flat);
|
|
223
|
+
if (flatKeys.length > 0 && flatKeys.some((k) => TOOL_ARG_KEYS.has(k))) {
|
|
224
|
+
return { name, args: flat };
|
|
225
|
+
}
|
|
110
226
|
}
|
|
111
227
|
return undefined;
|
|
112
228
|
}
|
|
229
|
+
// Keys that name the tool or wrap its arguments — excluded when recovering a
|
|
230
|
+
// flattened tool call whose args sit alongside `name`.
|
|
231
|
+
const FLATTENED_RESERVED_KEYS = new Set([
|
|
232
|
+
"name",
|
|
233
|
+
"tool_name",
|
|
234
|
+
"args",
|
|
235
|
+
"arguments",
|
|
236
|
+
"parameters",
|
|
237
|
+
"input",
|
|
238
|
+
]);
|
|
239
|
+
function pickObject(value) {
|
|
240
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
241
|
+
? value
|
|
242
|
+
: undefined;
|
|
243
|
+
}
|
|
113
244
|
// Kimi K2 / Moonshot models on NVIDIA NIM emit tool calls using a
|
|
114
245
|
// sentinel-token format that looks like:
|
|
115
246
|
// <|tool_calls_section_begin|>
|
|
@@ -127,13 +258,56 @@ function parseKimiToolCall(text) {
|
|
|
127
258
|
const name = match[1];
|
|
128
259
|
return tryParseCall(JSON.stringify({ name, args: tryJson(match[2]) ?? {} }));
|
|
129
260
|
}
|
|
261
|
+
// Recognized XML-ish tool-call wrappers some models emit (with or without a
|
|
262
|
+
// matching close tag). Used so we can recover a call even when the model
|
|
263
|
+
// forgot the closing tag, while plain prose never matches.
|
|
264
|
+
const XML_BLOCK_OPENERS = /<tool_call>|<function_calls>|<invoke>|<ant:invoke>/i;
|
|
265
|
+
/**
|
|
266
|
+
* Scan `text` starting at the index of the first `{`, returning the balanced
|
|
267
|
+
* JSON substring (respecting strings) or undefined if braces never balance.
|
|
268
|
+
* Lets us recover a tool-call JSON object the model emitted without a closing
|
|
269
|
+
* wrapper tag, where a non-greedy regex would stop at the first inner brace.
|
|
270
|
+
*/
|
|
271
|
+
function extractBalancedJson(text) {
|
|
272
|
+
const start = text.indexOf("{");
|
|
273
|
+
if (start < 0)
|
|
274
|
+
return undefined;
|
|
275
|
+
let depth = 0;
|
|
276
|
+
let inString = false;
|
|
277
|
+
let escaped = false;
|
|
278
|
+
for (let i = start; i < text.length; i += 1) {
|
|
279
|
+
const ch = text[i];
|
|
280
|
+
if (escaped) {
|
|
281
|
+
escaped = false;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (ch === "\\") {
|
|
285
|
+
escaped = true;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (ch === '"') {
|
|
289
|
+
inString = !inString;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (inString)
|
|
293
|
+
continue;
|
|
294
|
+
if (ch === "{")
|
|
295
|
+
depth += 1;
|
|
296
|
+
else if (ch === "}") {
|
|
297
|
+
depth -= 1;
|
|
298
|
+
if (depth === 0)
|
|
299
|
+
return text.slice(start, i + 1);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
130
304
|
function parseXmlToolCall(text) {
|
|
131
|
-
// Pattern 1:
|
|
132
|
-
//
|
|
305
|
+
// Pattern 1 (name + args/arguments/parameters JSON):
|
|
306
|
+
// <tool_call>
|
|
133
307
|
// <name>tool.name</name>
|
|
134
|
-
// <args>{...}</args>
|
|
135
|
-
//
|
|
136
|
-
const xmlNameArgs = text.match(/<tool_call>[\s\S]*?<name>\s*([\w.]+?)\s*<\/name>\s*<args>\s*(\{[\s\S]*?\})\s*<\/args>[\s\S]*?<\/tool_call>/i);
|
|
308
|
+
// <args>{...}</args> (or <arguments> / <parameters>)
|
|
309
|
+
// <tool_call>
|
|
310
|
+
const xmlNameArgs = text.match(/<tool_call>[\s\S]*?<name>\s*([\w.]+?)\s*<\/name>\s*<(?:args|arguments|parameters)>\s*(\{[\s\S]*?\})\s*<\/(?:args|arguments|parameters)>[\s\S]*?<\/tool_call>/i);
|
|
137
311
|
if (xmlNameArgs?.[1] && xmlNameArgs?.[2]) {
|
|
138
312
|
try {
|
|
139
313
|
const args = JSON.parse(preprocessJson(xmlNameArgs[2]));
|
|
@@ -145,11 +319,11 @@ function parseXmlToolCall(text) {
|
|
|
145
319
|
catch { }
|
|
146
320
|
}
|
|
147
321
|
// Pattern 1b (MiMo alternative):
|
|
148
|
-
//
|
|
322
|
+
// <tool_call>
|
|
149
323
|
// <tool_name>tool.name</tool_name>
|
|
150
|
-
// <parameters>{...}</parameters>
|
|
151
|
-
//
|
|
152
|
-
const xmlToolNameParams = text.match(/<tool_call>[\s\S]*?<tool_name>\s*([\w.]+?)\s*<\/tool_name>\s*<parameters>\s*(\{[\s\S]*?\})\s*<\/parameters>[\s\S]*?<\/tool_call>/i);
|
|
324
|
+
// <parameters>{...}</parameters> (or <arguments> / <args>)
|
|
325
|
+
// <tool_call>
|
|
326
|
+
const xmlToolNameParams = text.match(/<tool_call>[\s\S]*?<tool_name>\s*([\w.]+?)\s*<\/tool_name>\s*<(?:parameters|arguments|args)>\s*(\{[\s\S]*?\})\s*<\/(?:parameters|arguments|args)>[\s\S]*?<\/tool_call>/i);
|
|
153
327
|
if (xmlToolNameParams?.[1] && xmlToolNameParams?.[2]) {
|
|
154
328
|
try {
|
|
155
329
|
const args = JSON.parse(preprocessJson(xmlToolNameParams[2]));
|
|
@@ -160,13 +334,12 @@ function parseXmlToolCall(text) {
|
|
|
160
334
|
}
|
|
161
335
|
catch { }
|
|
162
336
|
}
|
|
163
|
-
// Pattern 1c (MiMo function/parameter format)
|
|
164
|
-
// <tool_call>
|
|
337
|
+
// Pattern 1c (MiMo function/parameter format), with or WITHOUT a
|
|
338
|
+
// surrounding <tool_call> wrapper (some models emit the bare function block):
|
|
165
339
|
// <function=tool.name>
|
|
166
340
|
// <parameter=name>value</parameter>
|
|
167
341
|
// </function>
|
|
168
|
-
|
|
169
|
-
const xmlFunctionBlock = text.match(/<tool_call>[\s\S]*?<function=([\w.]+?)>([\s\S]*?)<\/function>[\s\S]*?<\/tool_call>/i);
|
|
342
|
+
const xmlFunctionBlock = text.match(/<function=([\w.]+?)>([\s\S]*?)<\/function>/i);
|
|
170
343
|
if (xmlFunctionBlock?.[1] && xmlFunctionBlock?.[2]) {
|
|
171
344
|
const name = xmlFunctionBlock[1];
|
|
172
345
|
const inner = xmlFunctionBlock[2];
|
|
@@ -187,17 +360,55 @@ function parseXmlToolCall(text) {
|
|
|
187
360
|
}
|
|
188
361
|
return { name, args };
|
|
189
362
|
}
|
|
190
|
-
// Pattern 2:
|
|
191
|
-
// <
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
363
|
+
// Pattern 2: JSON object inside a recognized wrapper (closed). The wrapper
|
|
364
|
+
// may be the tool_call sentinel, <function_calls>, or <invoke>/<ant:invoke>.
|
|
365
|
+
// Backtracking off the closing tag handles nested {} in the args.
|
|
366
|
+
const wrappers = [
|
|
367
|
+
"<tool_call>",
|
|
368
|
+
"<function_calls>",
|
|
369
|
+
"<invoke>",
|
|
370
|
+
"<ant:invoke>",
|
|
371
|
+
];
|
|
372
|
+
for (const open of wrappers) {
|
|
373
|
+
const close = open === "<function_calls>"
|
|
374
|
+
? "</function_calls>"
|
|
375
|
+
: open === "<invoke>"
|
|
376
|
+
? "</invoke>"
|
|
377
|
+
: open === "<ant:invoke>"
|
|
378
|
+
? "</ant:invoke>"
|
|
379
|
+
: "</tool_call>";
|
|
380
|
+
const re = new RegExp(open.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
|
|
381
|
+
"[\\s\\S]*?(?:<tool>)?\\s*(\\{[\\s\\S]*?\\})\\s*" +
|
|
382
|
+
close.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
383
|
+
const match = text.match(re);
|
|
384
|
+
if (match?.[1]) {
|
|
385
|
+
const call = tryParseCall(match[1]);
|
|
386
|
+
if (call)
|
|
387
|
+
return call;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Pattern 3: a recognized opener is present but there is no matching close
|
|
391
|
+
// (model forgot it, or the stream was cut). Use a balanced brace scan from
|
|
392
|
+
// the first `{` after the opener so nested args survive, then try to parse.
|
|
393
|
+
const openerMatch = XML_BLOCK_OPENERS.exec(text);
|
|
394
|
+
if (openerMatch) {
|
|
395
|
+
const after = text.slice(openerMatch.index + openerMatch[0].length);
|
|
396
|
+
const json = extractBalancedJson(after);
|
|
397
|
+
if (json) {
|
|
398
|
+
const call = tryParseCall(json);
|
|
399
|
+
if (call)
|
|
400
|
+
return call;
|
|
401
|
+
}
|
|
402
|
+
// Also try the <name>...</name><arguments>{...}</arguments> tag shape
|
|
403
|
+
// without a closing wrapper (Hermes/Qwen often omit it).
|
|
404
|
+
const tagShape = after.match(/<name>\s*([\w.]+?)\s*<\/name>\s*<(?:args|arguments|parameters)>\s*(\{[\s\S]*?\})\s*<\/(?:args|arguments|parameters)>/i);
|
|
405
|
+
if (tagShape?.[1] && tagShape?.[2]) {
|
|
406
|
+
try {
|
|
407
|
+
const args = JSON.parse(preprocessJson(tagShape[2]));
|
|
408
|
+
return { name: tagShape[1], args: args };
|
|
409
|
+
}
|
|
410
|
+
catch { }
|
|
411
|
+
}
|
|
201
412
|
}
|
|
202
413
|
return undefined;
|
|
203
414
|
}
|
|
@@ -568,8 +779,32 @@ export function parseAllToolCalls(text) {
|
|
|
568
779
|
if (call)
|
|
569
780
|
found.push({ index: m.index, call });
|
|
570
781
|
}
|
|
782
|
+
// Bare <function=name>…</function> blocks (no <tool_call> wrapper) — some
|
|
783
|
+
// models emit one or several of these. Route each through parseXmlToolCall
|
|
784
|
+
// so the <parameter=…> args are decoded. Skip any that overlap a
|
|
785
|
+
// <tool_call> block already captured above (avoid double-counting).
|
|
786
|
+
const fnRe = /<function=[\w.]+?>[\s\S]*?<\/function>/gi;
|
|
787
|
+
while ((m = fnRe.exec(text)) !== null) {
|
|
788
|
+
const alreadyCaptured = found.some((f) => m.index >= f.index && m.index < f.index + 12);
|
|
789
|
+
const overlapsToolCall = /<tool_call>/i.test(text.slice(Math.max(0, m.index - 24), m.index));
|
|
790
|
+
if (alreadyCaptured || overlapsToolCall)
|
|
791
|
+
continue;
|
|
792
|
+
const call = parseXmlToolCall(m[0]);
|
|
793
|
+
if (call)
|
|
794
|
+
found.push({ index: m.index, call });
|
|
795
|
+
}
|
|
571
796
|
found.sort((a, b) => a.index - b.index);
|
|
572
|
-
|
|
797
|
+
// De-duplicate calls that two different matchers picked up at (nearly) the
|
|
798
|
+
// same spot so a single XML call isn't executed twice.
|
|
799
|
+
const deduped = [];
|
|
800
|
+
for (const entry of found) {
|
|
801
|
+
if (deduped.some((d) => sameToolCall(d.call, entry.call) &&
|
|
802
|
+
Math.abs(d.index - entry.index) < 64)) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
deduped.push(entry);
|
|
806
|
+
}
|
|
807
|
+
return deduped.map((f) => f.call);
|
|
573
808
|
}
|
|
574
809
|
/** Structural equality for two tool calls (name + canonical args JSON). */
|
|
575
810
|
export function sameToolCall(a, b) {
|
|
@@ -582,6 +817,34 @@ export function sameToolCall(a, b) {
|
|
|
582
817
|
return false;
|
|
583
818
|
}
|
|
584
819
|
}
|
|
820
|
+
/**
|
|
821
|
+
* Partition a batch of tool calls (in document order) into execution groups.
|
|
822
|
+
* A run of consecutive parallel-safe calls forms one group to be run
|
|
823
|
+
* concurrently (bounded by maxGroupSize); every non-parallel-safe call is its
|
|
824
|
+
* own single-element group, i.e. a sequential barrier. Because plan updates
|
|
825
|
+
* and side-effecting tools are never parallel-safe, they always split the
|
|
826
|
+
* batch — which keeps parallelism scoped within a single task and prevents
|
|
827
|
+
* plan-state races and overlapping writes.
|
|
828
|
+
*/
|
|
829
|
+
export function groupToolCallsForExecution(calls, isParallelSafe, maxGroupSize = 4) {
|
|
830
|
+
const groups = [];
|
|
831
|
+
let cursor = 0;
|
|
832
|
+
while (cursor < calls.length) {
|
|
833
|
+
const group = [calls[cursor]];
|
|
834
|
+
if (isParallelSafe(calls[cursor])) {
|
|
835
|
+
let j = cursor + 1;
|
|
836
|
+
while (j < calls.length &&
|
|
837
|
+
group.length < maxGroupSize &&
|
|
838
|
+
isParallelSafe(calls[j])) {
|
|
839
|
+
group.push(calls[j]);
|
|
840
|
+
j += 1;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
groups.push(group);
|
|
844
|
+
cursor += group.length;
|
|
845
|
+
}
|
|
846
|
+
return groups;
|
|
847
|
+
}
|
|
585
848
|
/**
|
|
586
849
|
* Collapse pathological repetition before a message is stored in history.
|
|
587
850
|
* Some models degenerate into emitting the same short phrase hundreds of
|
|
@@ -621,7 +884,7 @@ function textBeforeToolCall(text) {
|
|
|
621
884
|
}
|
|
622
885
|
return text.trim();
|
|
623
886
|
}
|
|
624
|
-
function formatToolArgs(call) {
|
|
887
|
+
export function formatToolArgs(call) {
|
|
625
888
|
if (call.name === "shell.exec")
|
|
626
889
|
return String(call.args.command ?? "");
|
|
627
890
|
if (call.name === "net.scan")
|
|
@@ -844,17 +1107,17 @@ function buildWorkflowDirective() {
|
|
|
844
1107
|
"1. EXPLORE: fs.list the working directory (and key subdirs) to see what already exists. Use tool.batch to parallelize reads.",
|
|
845
1108
|
"2. UNDERSTAND: fs.read the files that matter (like package.json for js related and same for other languages too, config, entry points, existing components). Detect the existing stack/tooling and MATCH it. If the dir is empty or only has a stub, start fresh with a sensible modern default and say so.",
|
|
846
1109
|
"3. PLAN: call plan.create with a COMPREHENSIVE plan — a detailed `detail` (stack chosen and WHY, architecture, how you'll verify) and 4-8 SEPARATE, ordered, high-quality tasks. The FIRST task initializes the project (scaffolder); the MIDDLE tasks MUST implement the ACTUAL FEATURE the user asked for by REPLACING the scaffolder's boilerplate (e.g. rewrite src/App.jsx into the real todo/blog/etc. UI, add components, state, styles); the LAST task verifies with a build. Scaffolding + install + run ALONE is NOT acceptable — that just leaves the Vite starter page. Each task is one distinct, verifiable action. Then STOP and wait for the user to /implement.",
|
|
847
|
-
"4. IMPLEMENT: once approved, work task by task in STRICT ORDER. For each task: call task.update {taskId, state:'in_progress'} → do the real work → VERIFY it actually succeeded (read a file you wrote, check the command's exit/output) → call task.update {taskId, state:'done'}, then move to the NEXT task. You MAY emit several tool calls in one message
|
|
1110
|
+
"4. IMPLEMENT: once approved, work task by task in STRICT ORDER. For each task: call task.update {taskId, state:'in_progress'} → do the real work → VERIFY it actually succeeded (read a file you wrote, check the command's exit/output) → call task.update {taskId, state:'done'}, then move to the NEXT task. You MAY emit several tool calls in one message; they run in document order (independent read-only lookups run in parallel, while task.update and any write/command runs one at a time), and the batch STOPS if one fails. Keep every batch scoped to ONE task. A clean rhythm is: task.update in_progress + the work + task.update done together. Keep going until EVERY task is done. Do NOT claim work you didn't actually run.",
|
|
848
1111
|
"",
|
|
849
1112
|
"INITIALIZE WITH THE OFFICIAL SCAFFOLDER FIRST (do NOT hand-write build configs):",
|
|
850
|
-
|
|
1113
|
+
'- React/Vue/Svelte/vanilla → `npm create vite@latest <appname> -- --template react` (templates: react, react-ts, vue, vue-ts, svelte, vanilla). Next.js → `npx --yes create-next-app@latest <appname> --yes --eslint --no-tailwind --app --src-dir --import-alias "@/*"`. Node API → `npm init -y`.',
|
|
851
1114
|
"- GET THE TEMPLATE FLAG RIGHT. With `npm create vite@latest NAME -- --template react` the `--` IS required (it forwards --template to create-vite). With `npx create-vite@latest NAME --template react` do NOT add `--` (npx passes args straight through, so `-- --template react` makes npx DROP the flag and you silently get the WRONG, vanilla template). Pick ONE form and keep the template flag attached. After scaffolding, fs.read the generated index.html / src entry to CONFIRM you got React (a src/main.jsx + App.jsx, not a vanilla main.js/counter.js). If it's the wrong template, delete the folder and re-run with the correct command.",
|
|
852
1115
|
"- RUN SCAFFOLDERS NON-INTERACTIVELY and into a NEW SUBFOLDER (`<appname>`). Scaffolders REFUSE to run in a non-empty directory and then print 'Operation cancelled' — and the current dir frequently already has a file like .DS_Store. So scaffold into a subfolder (always works). `--yes` does NOT fix the non-empty-dir cancel; a subfolder does. NEVER background a scaffolder with `&` or pipe `yes |` into it.",
|
|
853
|
-
|
|
1116
|
+
'- If a scaffolder cannot be driven non-interactively or keeps failing, FALL BACK to hand-writing a minimal Vite setup (package.json with "type":"module", @vitejs/plugin-react, index.html that loads /src/main.jsx, src/main.jsx, src/App.jsx) then `npm install`. That never prompts and you control every file.',
|
|
854
1117
|
"- VERIFY the init actually worked before marking the task done: fs.read package.json (it must now exist AND list react + react-dom) and fs.read index.html (it must reference your jsx entry). 'Operation cancelled' / non-zero exit means the task FAILED — do not proceed as if it succeeded.",
|
|
855
1118
|
"",
|
|
856
1119
|
"CRITICAL RULES during IMPLEMENTATION:",
|
|
857
|
-
"- You may batch tool calls: emit one or several ```tool blocks in a message
|
|
1120
|
+
"- You may batch tool calls: emit one or several ```tool blocks in a message. They run in document order — independent READ-ONLY lookups (fs.read/list/search, dns/whois, http.fetch GET, web.search/fetch, sysinfo) run in parallel, while task.update and any write/command (fs.write*, shell.exec, pkg.install, net.scan) run one at a time. If any call fails, the rest of that batch is cancelled so you can react — so order dependent steps correctly and keep every batch scoped to ONE task. A good batch is task.update(in_progress) + the work + task.update(done) for ONE task.",
|
|
858
1121
|
"- Do NOT re-explore. Step 1 (EXPLORE) was already completed during planning. Start executing the first pending task immediately.",
|
|
859
1122
|
"- ONE task at a time, in ORDER. Do NOT skip ahead to task 3 before task 2 is done.",
|
|
860
1123
|
"- KEEP EACH FILE SMALL ENOUGH TO WRITE IN ONE CALL. If a fs.write is reported as 'cut off (output too long)', the file was NOT fully written and is likely broken/invalid — re-write it, splitting a large component into smaller files if needed. NEVER leave a half-written file and move on.",
|
|
@@ -1033,15 +1296,51 @@ function summarizeOutput(output, maxChars = 8_000) {
|
|
|
1033
1296
|
truncated: true,
|
|
1034
1297
|
};
|
|
1035
1298
|
}
|
|
1299
|
+
// Tools whose output is the actual content the model needs verbatim (file
|
|
1300
|
+
// bodies, listings, search hits). Running these through the security-signal
|
|
1301
|
+
// `genericReducer` was wrong: it ranks lines by pentest keywords and drops
|
|
1302
|
+
// the rest, so source code came back as a fragmentary head+tail — the model
|
|
1303
|
+
// saw a "truncated" file and kept re-reading it in wasted retries. For these
|
|
1304
|
+
// we pass the raw content through (up to a generous cap) and point the model
|
|
1305
|
+
// at the saved artifact when it exceeds the cap.
|
|
1306
|
+
const PASSTHROUGH_TOOLS = new Set([
|
|
1307
|
+
"fs.read",
|
|
1308
|
+
"fs.list",
|
|
1309
|
+
"fs.search",
|
|
1310
|
+
"fs.edit",
|
|
1311
|
+
"pdf.read",
|
|
1312
|
+
]);
|
|
1313
|
+
const PASSTHROUGH_CAP_CHARS = 400_000;
|
|
1314
|
+
// web.fetch/http.fetch pull in arbitrary third-party pages/API responses that
|
|
1315
|
+
// can be hundreds of KB (e.g. a large OpenAPI spec). Unlike local files the
|
|
1316
|
+
// model asked to read, this content is never bounded by the user's own
|
|
1317
|
+
// project, so it must be capped like every other tool's context output —
|
|
1318
|
+
// otherwise a single fetch can single-handedly blow the context budget and
|
|
1319
|
+
// starve the model of room to actually respond (observed as empty/garbled
|
|
1320
|
+
// completions on smaller-context-window models after a big fetch).
|
|
1321
|
+
const WEB_FETCH_CAP_CHARS = 20_000;
|
|
1036
1322
|
function formatToolContext(call, result) {
|
|
1037
1323
|
const output = result.output.trim();
|
|
1038
1324
|
if (!output)
|
|
1039
1325
|
return "";
|
|
1040
1326
|
if (call.name === "web.fetch" || call.name === "http.fetch") {
|
|
1327
|
+
const { text, truncated } = summarizeOutput(output, WEB_FETCH_CAP_CHARS);
|
|
1328
|
+
if (!truncated)
|
|
1329
|
+
return text;
|
|
1330
|
+
const saved = result.outputPath
|
|
1331
|
+
? `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; showing the head and tail. The FULL response is saved at: ${result.outputPath} — re-fetch a narrower URL or read the saved file if you need the middle.]`
|
|
1332
|
+
: `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; only head and tail shown.]`;
|
|
1333
|
+
return `${text}${saved}`.trim();
|
|
1334
|
+
}
|
|
1335
|
+
// Pass-through tools: never reduce — the model needs the real content.
|
|
1336
|
+
if (PASSTHROUGH_TOOLS.has(call.name)) {
|
|
1337
|
+
const { text, truncated } = summarizeOutput(output, PASSTHROUGH_CAP_CHARS);
|
|
1338
|
+
if (!truncated)
|
|
1339
|
+
return text;
|
|
1041
1340
|
const saved = result.outputPath
|
|
1042
|
-
? `\
|
|
1043
|
-
:
|
|
1044
|
-
return `${
|
|
1341
|
+
? `\n\n[File content exceeds ${PASSTHROUGH_CAP_CHARS.toLocaleString()} chars; showing the head and tail. The FULL content is saved at: ${result.outputPath} — re-read specific line ranges with fs.read if you need the middle.]`
|
|
1342
|
+
: `\n\n[File content exceeds ${PASSTHROUGH_CAP_CHARS.toLocaleString()} chars; only head and tail shown. Read it in smaller chunks if the middle is needed.]`;
|
|
1343
|
+
return `${text}${saved}`.trim();
|
|
1045
1344
|
}
|
|
1046
1345
|
let reduced;
|
|
1047
1346
|
try {
|
|
@@ -1083,6 +1382,13 @@ const inquirerConfirmPort = {
|
|
|
1083
1382
|
default: true,
|
|
1084
1383
|
});
|
|
1085
1384
|
},
|
|
1385
|
+
async confirmAgentSwitch(info) {
|
|
1386
|
+
const tools = info.tools.length > 0 ? ` (${info.tools.join(", ")})` : "";
|
|
1387
|
+
return confirm({
|
|
1388
|
+
message: chalk.yellow(` this needs agent mode${tools} — switch and run it?`),
|
|
1389
|
+
default: true,
|
|
1390
|
+
});
|
|
1391
|
+
},
|
|
1086
1392
|
};
|
|
1087
1393
|
async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
|
|
1088
1394
|
if (!isPentestToolCall(call))
|
|
@@ -1247,6 +1553,24 @@ async function handlePlanTool(call, session, ctx) {
|
|
|
1247
1553
|
modelNote: `task.update failed: state must be one of ${validStates.join(", ")}.`,
|
|
1248
1554
|
};
|
|
1249
1555
|
}
|
|
1556
|
+
// Only one task may be in_progress at a time. This forces genuine
|
|
1557
|
+
// task-by-task execution: the model must close (done/failed/skipped) the
|
|
1558
|
+
// current task before opening the next one, instead of leaving a task
|
|
1559
|
+
// "in_progress" as an umbrella while it quietly works through the rest
|
|
1560
|
+
// of the plan underneath it.
|
|
1561
|
+
if (stateRaw === "in_progress") {
|
|
1562
|
+
const otherInProgress = plan.tasks.find((t) => t.id !== taskId && t.state === "in_progress");
|
|
1563
|
+
if (otherInProgress) {
|
|
1564
|
+
return {
|
|
1565
|
+
handled: true,
|
|
1566
|
+
ok: false,
|
|
1567
|
+
display: chalk.red(` \u2717 task.update: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress\n`),
|
|
1568
|
+
modelNote: `task.update failed: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress. ` +
|
|
1569
|
+
"Finish it first \u2014 call task.update with state 'done' (or 'failed'/'skipped' with a note) " +
|
|
1570
|
+
`for [${otherInProgress.id}] before starting [${taskId}].`,
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1250
1574
|
const ok = markTask(plan, taskId, stateRaw, note);
|
|
1251
1575
|
if (!ok) {
|
|
1252
1576
|
const ids = plan.tasks.map((t) => t.id).join(", ");
|
|
@@ -1513,12 +1837,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1513
1837
|
promise: Promise.resolve(),
|
|
1514
1838
|
async acquire() {
|
|
1515
1839
|
let release = () => { };
|
|
1516
|
-
const next = new Promise((r) => {
|
|
1840
|
+
const next = new Promise((r) => {
|
|
1841
|
+
release = r;
|
|
1842
|
+
});
|
|
1517
1843
|
const current = this.promise;
|
|
1518
1844
|
this.promise = current.then(() => next);
|
|
1519
1845
|
await current;
|
|
1520
1846
|
return release;
|
|
1521
|
-
}
|
|
1847
|
+
},
|
|
1522
1848
|
};
|
|
1523
1849
|
async function executeSingleTool(rawCall, toolEventId, parentSignal) {
|
|
1524
1850
|
let call = normalizeToolCall(rawCall);
|
|
@@ -1537,20 +1863,34 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1537
1863
|
const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
|
|
1538
1864
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1539
1865
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1540
|
-
return {
|
|
1866
|
+
return {
|
|
1867
|
+
ok: false,
|
|
1868
|
+
call,
|
|
1869
|
+
result,
|
|
1870
|
+
contextOutput: reason,
|
|
1871
|
+
blockOrCancel: true,
|
|
1872
|
+
};
|
|
1541
1873
|
}
|
|
1542
1874
|
if (loopCheck.reason) {
|
|
1543
1875
|
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
1544
1876
|
}
|
|
1545
1877
|
if (call.name === "plan.create" || call.name === "task.update") {
|
|
1546
|
-
const planResult = await handlePlanTool(call, session, {
|
|
1878
|
+
const planResult = await handlePlanTool(call, session, {
|
|
1879
|
+
loopGuard,
|
|
1880
|
+
step,
|
|
1881
|
+
});
|
|
1547
1882
|
if (planResult.handled) {
|
|
1548
1883
|
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
1549
1884
|
if (planResult.plan) {
|
|
1550
1885
|
writePlanUpdate(planResult.plan, planResult.display);
|
|
1551
1886
|
}
|
|
1552
1887
|
const result = { ok: planResult.ok, output: planResult.modelNote };
|
|
1553
|
-
return {
|
|
1888
|
+
return {
|
|
1889
|
+
ok: planResult.ok,
|
|
1890
|
+
call,
|
|
1891
|
+
result,
|
|
1892
|
+
contextOutput: planResult.modelNote,
|
|
1893
|
+
};
|
|
1554
1894
|
}
|
|
1555
1895
|
}
|
|
1556
1896
|
const scope = await loadScope();
|
|
@@ -1566,7 +1906,45 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1566
1906
|
const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
|
|
1567
1907
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1568
1908
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1569
|
-
return {
|
|
1909
|
+
return {
|
|
1910
|
+
ok: false,
|
|
1911
|
+
call,
|
|
1912
|
+
result,
|
|
1913
|
+
contextOutput: reason,
|
|
1914
|
+
blockOrCancel: true,
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
// ── Task-scoped execution gate ───────────────────────────────────
|
|
1918
|
+
// Once a plan is approved, every non-plan tool call must run while
|
|
1919
|
+
// exactly one task is "in_progress". This stops a model from batching
|
|
1920
|
+
// tool calls for many/all tasks in one turn and only touching task
|
|
1921
|
+
// state at the very end (or never) — the failure mode where a model
|
|
1922
|
+
// claimed most tasks "done" in prose without ever recording it in the
|
|
1923
|
+
// plan. Multiple tool calls per task are still fine; they just must be
|
|
1924
|
+
// bracketed by task.update in_progress → (work) → task.update done.
|
|
1925
|
+
if (session.planApproved.value) {
|
|
1926
|
+
const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1927
|
+
if (livePlanForGate) {
|
|
1928
|
+
const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
|
|
1929
|
+
const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
|
|
1930
|
+
if (unfinished && !inProgress) {
|
|
1931
|
+
const nextPending = livePlanForGate.tasks.find((t) => t.state === "pending");
|
|
1932
|
+
const reason = nextPending
|
|
1933
|
+
? `${call.name} blocked — no task is in_progress. Call task.update {taskId:"${nextPending.id}", state:"in_progress"} before doing any work for it.`
|
|
1934
|
+
: `${call.name} blocked — no task is in_progress.`;
|
|
1935
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1936
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1937
|
+
// Recoverable ordering mistake (NOT a user/session control gate):
|
|
1938
|
+
// feed the reason back so the model marks the task in_progress and
|
|
1939
|
+
// retries within the same turn, rather than ending the turn.
|
|
1940
|
+
return {
|
|
1941
|
+
ok: false,
|
|
1942
|
+
call,
|
|
1943
|
+
result,
|
|
1944
|
+
contextOutput: `${reason}\nThis tool did NOT run. Emit task.update {state:"in_progress"} for the task first, then the work.`,
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1570
1948
|
}
|
|
1571
1949
|
if (call.name === "web.search") {
|
|
1572
1950
|
sawFreshWebSearch = true;
|
|
@@ -1580,9 +1958,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1580
1958
|
}
|
|
1581
1959
|
if (decision.level === "block") {
|
|
1582
1960
|
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
1583
|
-
const
|
|
1584
|
-
const result = { ok: false, output:
|
|
1585
|
-
|
|
1961
|
+
const message = `Blocked: ${call.name} — ${decision.reason}`;
|
|
1962
|
+
const result = { ok: false, output: message, exitCode: 1 };
|
|
1963
|
+
// Safety classifier blocks are recoverable model mistakes: feed the
|
|
1964
|
+
// failed result back to the model and let it choose a safer next step
|
|
1965
|
+
// instead of ending the entire agent turn. Hard workflow gates
|
|
1966
|
+
// (plan not approved, task not in_progress, auth declined, aborts)
|
|
1967
|
+
// still use blockOrCancel above/below because continuing would violate
|
|
1968
|
+
// user/session control rather than merely correcting a bad command.
|
|
1969
|
+
return {
|
|
1970
|
+
ok: false,
|
|
1971
|
+
call,
|
|
1972
|
+
result,
|
|
1973
|
+
contextOutput: `${message}\nThis tool call did not run. Continue the task using a safer allowed method; do not retry the same blocked command unchanged.`,
|
|
1974
|
+
};
|
|
1586
1975
|
}
|
|
1587
1976
|
let authorized = true;
|
|
1588
1977
|
let pentestJustConfirmed = false;
|
|
@@ -1598,7 +1987,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1598
1987
|
const lastAnswer = "Pentest authorization not confirmed.";
|
|
1599
1988
|
writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1600
1989
|
const result = { ok: false, output: lastAnswer, exitCode: 1 };
|
|
1601
|
-
return {
|
|
1990
|
+
return {
|
|
1991
|
+
ok: false,
|
|
1992
|
+
call,
|
|
1993
|
+
result,
|
|
1994
|
+
contextOutput: lastAnswer,
|
|
1995
|
+
lastAnswer,
|
|
1996
|
+
blockOrCancel: true,
|
|
1997
|
+
};
|
|
1602
1998
|
}
|
|
1603
1999
|
if (needsPentestAuth) {
|
|
1604
2000
|
pentestJustConfirmed = true;
|
|
@@ -1608,9 +2004,17 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1608
2004
|
!isPreApprovalAllowedTool(call.name)) {
|
|
1609
2005
|
const pathArg = typeof call.args.path === "string" ? call.args.path : undefined;
|
|
1610
2006
|
if (pathArg) {
|
|
1611
|
-
const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\")
|
|
2007
|
+
const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\")
|
|
2008
|
+
? join(homedir(), p.slice(2))
|
|
2009
|
+
: p === "~"
|
|
2010
|
+
? homedir()
|
|
2011
|
+
: p;
|
|
1612
2012
|
const resolved = resolve(expandHomeLocal(pathArg));
|
|
1613
|
-
const mode =
|
|
2013
|
+
const mode = call.name === "fs.read" ||
|
|
2014
|
+
call.name === "fs.list" ||
|
|
2015
|
+
call.name === "fs.search"
|
|
2016
|
+
? "read"
|
|
2017
|
+
: "write";
|
|
1614
2018
|
if (!pathInsideSandbox(resolved, mode)) {
|
|
1615
2019
|
forceManualConfirm = true;
|
|
1616
2020
|
}
|
|
@@ -1623,7 +2027,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1623
2027
|
const lastAnswer = "Cancelled.";
|
|
1624
2028
|
writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
|
|
1625
2029
|
const result = { ok: false, output: lastAnswer, exitCode: 1 };
|
|
1626
|
-
return {
|
|
2030
|
+
return {
|
|
2031
|
+
ok: false,
|
|
2032
|
+
call,
|
|
2033
|
+
result,
|
|
2034
|
+
contextOutput: lastAnswer,
|
|
2035
|
+
lastAnswer,
|
|
2036
|
+
blockOrCancel: true,
|
|
2037
|
+
};
|
|
1627
2038
|
}
|
|
1628
2039
|
}
|
|
1629
2040
|
}
|
|
@@ -1715,7 +2126,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1715
2126
|
jobManager.updateJobStatus(jobId, "failed", 1);
|
|
1716
2127
|
if (isAbortError(toolError, toolAc.signal)) {
|
|
1717
2128
|
writeAbort();
|
|
1718
|
-
return {
|
|
2129
|
+
return {
|
|
2130
|
+
ok: false,
|
|
2131
|
+
call,
|
|
2132
|
+
result: { ok: false, output: "Aborted." },
|
|
2133
|
+
contextOutput: "Aborted.",
|
|
2134
|
+
lastAnswer: "Aborted.",
|
|
2135
|
+
};
|
|
1719
2136
|
}
|
|
1720
2137
|
const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
|
|
1721
2138
|
result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
|
|
@@ -1794,6 +2211,81 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1794
2211
|
}
|
|
1795
2212
|
return { ok: result.ok, call, result, contextOutput };
|
|
1796
2213
|
}
|
|
2214
|
+
// ── Automatic context compaction ───────────────────────────────────────
|
|
2215
|
+
// As a long turn accumulates tool outputs and reasoning, the context can
|
|
2216
|
+
// grow past what the model can hold. We proactively summarize the older
|
|
2217
|
+
// turns into a single continuation memory (the SAME model-written summary
|
|
2218
|
+
// the /compact command uses — never a mechanical transcript dump) and then
|
|
2219
|
+
// re-inject the ACTIVE PLAN so the agent never loses track of the plan,
|
|
2220
|
+
// what is done, and what remains. The estimate is chars/4; the budget is
|
|
2221
|
+
// deliberately conservative so we compact a little early rather than hit a
|
|
2222
|
+
// provider context-window error mid-task.
|
|
2223
|
+
const AUTO_COMPACT_TOKEN_BUDGET = 60_000;
|
|
2224
|
+
const AUTO_COMPACT_KEEP_RECENT = 12;
|
|
2225
|
+
let lastCompactionMsgCount = 0;
|
|
2226
|
+
const summarizeForCompaction = async (summaryPrompt) => {
|
|
2227
|
+
const response = await completeWithProvider({
|
|
2228
|
+
provider,
|
|
2229
|
+
model,
|
|
2230
|
+
messages: [
|
|
2231
|
+
{
|
|
2232
|
+
role: "system",
|
|
2233
|
+
content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
|
|
2234
|
+
},
|
|
2235
|
+
{ role: "user", content: summaryPrompt },
|
|
2236
|
+
],
|
|
2237
|
+
temperature: 0.1,
|
|
2238
|
+
maxTokens: 2_048,
|
|
2239
|
+
signal: options.signal,
|
|
2240
|
+
});
|
|
2241
|
+
return response.text;
|
|
2242
|
+
};
|
|
2243
|
+
async function maybeAutoCompact(reason, force = false) {
|
|
2244
|
+
const beforeTokens = estimateMessagesTokens(messages);
|
|
2245
|
+
if (!force && beforeTokens < AUTO_COMPACT_TOKEN_BUDGET)
|
|
2246
|
+
return;
|
|
2247
|
+
if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
|
|
2248
|
+
return;
|
|
2249
|
+
// Avoid compaction loops: don't re-compact until enough new messages have
|
|
2250
|
+
// accumulated since the last compaction.
|
|
2251
|
+
if (messages.length <= lastCompactionMsgCount + 4)
|
|
2252
|
+
return;
|
|
2253
|
+
try {
|
|
2254
|
+
const result = await compactMessagesWithSummary(messages, summarizeForCompaction, { budgetTokens: 0, keepRecent: AUTO_COMPACT_KEEP_RECENT });
|
|
2255
|
+
if (!result.summarized || result.afterTokens >= beforeTokens)
|
|
2256
|
+
return;
|
|
2257
|
+
messages.splice(0, messages.length, ...result.messages);
|
|
2258
|
+
loopGuard.resetReadOnly();
|
|
2259
|
+
// Re-inject the live plan so the model keeps full plan awareness even
|
|
2260
|
+
// after older turns (which carried the plan context) were summarized.
|
|
2261
|
+
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2262
|
+
if (livePlan) {
|
|
2263
|
+
messages.push({
|
|
2264
|
+
role: "system",
|
|
2265
|
+
content: planContextMessage(livePlan, session.planApproved.value),
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
lastCompactionMsgCount = messages.length;
|
|
2269
|
+
const afterTokens = estimateMessagesTokens(messages);
|
|
2270
|
+
await auditLog("agent.compact", {
|
|
2271
|
+
newLength: messages.length,
|
|
2272
|
+
estimatedTokens: afterTokens,
|
|
2273
|
+
reason,
|
|
2274
|
+
});
|
|
2275
|
+
writeNotice("info", `context auto-compacted to fit the window (~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens)`, chalk.dim(` ℹ context auto-compacted (~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens)\n`));
|
|
2276
|
+
}
|
|
2277
|
+
catch (error) {
|
|
2278
|
+
if (error instanceof Error &&
|
|
2279
|
+
(error.name === "AbortError" || error.message.includes("aborted"))) {
|
|
2280
|
+
throw error;
|
|
2281
|
+
}
|
|
2282
|
+
// Summarization failed — DO NOT fall back to a mechanical dump. Keep the
|
|
2283
|
+
// current context and continue; we'll try again as it keeps growing.
|
|
2284
|
+
await auditLog("agent.compact.failed", {
|
|
2285
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
1797
2289
|
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1798
2290
|
// `step` is the productive-step index (used for display + audit). It only
|
|
1799
2291
|
// advances when the previous iteration actually executed a tool.
|
|
@@ -1815,25 +2307,19 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1815
2307
|
const extension = Math.max(40, maxSteps);
|
|
1816
2308
|
stepBudget += extension;
|
|
1817
2309
|
maxIterations = stepBudget * 3;
|
|
1818
|
-
// Compact older messages
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
messages.splice(0, messages.length, ...compacted);
|
|
1822
|
-
loopGuard.resetReadOnly();
|
|
1823
|
-
await auditLog("agent.compact", {
|
|
1824
|
-
newLength: messages.length,
|
|
1825
|
-
estimatedTokens: estimateMessagesTokens(messages),
|
|
1826
|
-
reason: "step-budget-continue",
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
2310
|
+
// Compact older messages (model-written summary, no mechanical dump)
|
|
2311
|
+
// to free context space for the next chunk of work.
|
|
2312
|
+
await maybeAutoCompact("step-budget-continue", true);
|
|
1829
2313
|
// Inject a progress summary so the model stays focused.
|
|
1830
2314
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1831
2315
|
let progressNote = "The step limit was reached and the user chose to continue. ";
|
|
1832
|
-
progressNote +=
|
|
1833
|
-
|
|
2316
|
+
progressNote +=
|
|
2317
|
+
"Review what you have accomplished so far and continue with the NEXT unfinished step. ";
|
|
2318
|
+
progressNote +=
|
|
2319
|
+
"Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
|
|
1834
2320
|
if (livePlan) {
|
|
1835
|
-
const doneTasks = livePlan.tasks.filter(t => t.state === "done");
|
|
1836
|
-
const pendingTasks = livePlan.tasks.filter(t => t.state === "pending" || t.state === "in_progress");
|
|
2321
|
+
const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
|
|
2322
|
+
const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1837
2323
|
progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
|
|
1838
2324
|
if (pendingTasks.length > 0) {
|
|
1839
2325
|
progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
|
|
@@ -1867,6 +2353,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1867
2353
|
writeStatus(batchStatus, chalk.dim(batchStatus));
|
|
1868
2354
|
}
|
|
1869
2355
|
else {
|
|
2356
|
+
// Before a fresh model round-trip, proactively compact if the context has
|
|
2357
|
+
// grown too large, so we never hit a provider context-window error and the
|
|
2358
|
+
// model keeps a clean, plan-aware memory.
|
|
2359
|
+
await maybeAutoCompact("auto-token-budget");
|
|
1870
2360
|
// Buffer LLM output so tool JSON and hidden thinking are not printed raw.
|
|
1871
2361
|
// Status messages (rate-limit retries, fallback hints) still surface live.
|
|
1872
2362
|
// A spinner gives the user feedback during long thinking phases on
|
|
@@ -1950,6 +2440,16 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1950
2440
|
deltaParser?.finish();
|
|
1951
2441
|
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1952
2442
|
assistantText = assistantTextResult;
|
|
2443
|
+
// Commit thinking to the transcript IMMEDIATELY, before any of the
|
|
2444
|
+
// branches below decide to `continue` (retry a malformed tool call,
|
|
2445
|
+
// nudge for narration, guard premature completion, etc). Previously
|
|
2446
|
+
// writeThinkingBlock was only called from a few terminal branches, so
|
|
2447
|
+
// any retry path silently dropped the model's reasoning — the user
|
|
2448
|
+
// would see the live "thinking…" preview during streaming and then
|
|
2449
|
+
// watch it vanish with nothing committed once the turn moved on.
|
|
2450
|
+
if (assistantText.hasThinking) {
|
|
2451
|
+
writeThinkingBlock(assistantText.thinkContent);
|
|
2452
|
+
}
|
|
1953
2453
|
// Try visible text first, then thinking content — some models (e.g. glm-5.1)
|
|
1954
2454
|
// wrap tool calls inside considering tags, so stripThinking removes them
|
|
1955
2455
|
// into thinkContent and visible becomes empty. Recovering from thinkContent
|
|
@@ -1977,7 +2477,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1977
2477
|
emptyVisibleRetries += 1;
|
|
1978
2478
|
if (emptyVisibleRetries <= 3) {
|
|
1979
2479
|
if (assistantText.hasThinking) {
|
|
1980
|
-
writeThinkingBlock(assistantText.thinkContent);
|
|
1981
2480
|
writeNotice("warn", "model produced only thinking — nudging it to take action", chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
|
|
1982
2481
|
}
|
|
1983
2482
|
else {
|
|
@@ -2041,7 +2540,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2041
2540
|
bareToolJsonRetries += 1;
|
|
2042
2541
|
if (bareToolJsonRetries <= 3) {
|
|
2043
2542
|
writeNotice("warn", "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
2044
|
-
messages.push({
|
|
2543
|
+
messages.push({
|
|
2544
|
+
role: "assistant",
|
|
2545
|
+
content: assistantText.visible,
|
|
2546
|
+
});
|
|
2045
2547
|
messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
|
|
2046
2548
|
? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
2047
2549
|
"This is a BUILD/SCAFFOLD task with NO plan yet. " +
|
|
@@ -2063,7 +2565,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2063
2565
|
// model to retry the tool call in a clean JSON format.
|
|
2064
2566
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
2065
2567
|
writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
|
|
2066
|
-
messages.push({
|
|
2568
|
+
messages.push({
|
|
2569
|
+
role: "assistant",
|
|
2570
|
+
content: assistantText.visible,
|
|
2571
|
+
});
|
|
2067
2572
|
messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
|
|
2068
2573
|
"Reply with ONLY a fenced ```tool block containing valid JSON " +
|
|
2069
2574
|
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
@@ -2078,7 +2583,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2078
2583
|
truncatedToolRetries += 1;
|
|
2079
2584
|
if (truncatedToolRetries <= 3) {
|
|
2080
2585
|
writeNotice("warn", "tool call was cut off (output too long) — asking the model to retry in smaller pieces", chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry in smaller pieces\n"));
|
|
2081
|
-
messages.push({
|
|
2586
|
+
messages.push({
|
|
2587
|
+
role: "assistant",
|
|
2588
|
+
content: assistantText.visible,
|
|
2589
|
+
});
|
|
2082
2590
|
messages.push({
|
|
2083
2591
|
role: "user",
|
|
2084
2592
|
content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
@@ -2105,7 +2613,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2105
2613
|
malformedFenceRetries += 1;
|
|
2106
2614
|
if (malformedFenceRetries <= 3) {
|
|
2107
2615
|
writeNotice("warn", "tool block present but its JSON didn't parse — asking the model to re-emit valid JSON", chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
|
|
2108
|
-
messages.push({
|
|
2616
|
+
messages.push({
|
|
2617
|
+
role: "assistant",
|
|
2618
|
+
content: assistantText.visible,
|
|
2619
|
+
});
|
|
2109
2620
|
messages.push({
|
|
2110
2621
|
role: "user",
|
|
2111
2622
|
content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
|
|
@@ -2138,9 +2649,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2138
2649
|
if (wantsAction &&
|
|
2139
2650
|
cleaned.trim().length > 0 &&
|
|
2140
2651
|
actionIntentRetries < 3 &&
|
|
2141
|
-
(productiveSteps === 0 ||
|
|
2142
|
-
planNarrated ||
|
|
2143
|
-
narratedAction)) {
|
|
2652
|
+
(productiveSteps === 0 || planNarrated || narratedAction)) {
|
|
2144
2653
|
actionIntentRetries += 1;
|
|
2145
2654
|
let nudge;
|
|
2146
2655
|
if (activePlan && session.planApproved.value) {
|
|
@@ -2179,14 +2688,22 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2179
2688
|
"Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
|
|
2180
2689
|
writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
2181
2690
|
}
|
|
2182
|
-
messages.push({
|
|
2691
|
+
messages.push({
|
|
2692
|
+
role: "assistant",
|
|
2693
|
+
content: assistantText.visible,
|
|
2694
|
+
});
|
|
2183
2695
|
messages.push(recoveryUserMessage(nudge));
|
|
2184
2696
|
continue;
|
|
2185
2697
|
}
|
|
2186
|
-
if (freshWebSearchRequired &&
|
|
2698
|
+
if (freshWebSearchRequired &&
|
|
2699
|
+
!sawFreshWebSearch &&
|
|
2700
|
+
!freshnessRetryUsed) {
|
|
2187
2701
|
freshnessRetryUsed = true;
|
|
2188
2702
|
writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
|
|
2189
|
-
messages.push({
|
|
2703
|
+
messages.push({
|
|
2704
|
+
role: "assistant",
|
|
2705
|
+
content: assistantText.visible,
|
|
2706
|
+
});
|
|
2190
2707
|
messages.push({
|
|
2191
2708
|
role: "user",
|
|
2192
2709
|
content: freshnessGuardMessage() +
|
|
@@ -2207,7 +2724,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2207
2724
|
prematureCompletionRetries += 1;
|
|
2208
2725
|
const next = unfinished[0];
|
|
2209
2726
|
writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
|
|
2210
|
-
messages.push({
|
|
2727
|
+
messages.push({
|
|
2728
|
+
role: "assistant",
|
|
2729
|
+
content: assistantText.visible,
|
|
2730
|
+
});
|
|
2211
2731
|
messages.push({
|
|
2212
2732
|
role: "user",
|
|
2213
2733
|
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
@@ -2248,9 +2768,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2248
2768
|
if (completionWarning) {
|
|
2249
2769
|
writeNotice("warn", completionWarningText, completionWarning);
|
|
2250
2770
|
}
|
|
2251
|
-
if (assistantText.hasThinking) {
|
|
2252
|
-
writeThinkingBlock(assistantText.thinkContent);
|
|
2253
|
-
}
|
|
2254
2771
|
await auditLog("agent.final", { provider, model, steps: step + 1 });
|
|
2255
2772
|
lastAnswer = cleaned;
|
|
2256
2773
|
return finishTurn(lastAnswer, step + 1);
|
|
@@ -2263,44 +2780,86 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2263
2780
|
if (beforeTool) {
|
|
2264
2781
|
writeAssistantMessage(beforeTool);
|
|
2265
2782
|
}
|
|
2266
|
-
if (assistantText.hasThinking) {
|
|
2267
|
-
writeThinkingBlock(assistantText.thinkContent);
|
|
2268
|
-
}
|
|
2269
2783
|
let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
|
|
2270
2784
|
if (allCalls.length === 0 && call) {
|
|
2271
2785
|
allCalls = [call];
|
|
2272
2786
|
}
|
|
2273
2787
|
const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
|
|
2274
|
-
allCalls
|
|
2788
|
+
allCalls
|
|
2789
|
+
.map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
|
|
2790
|
+
.join("\n\n");
|
|
2275
2791
|
messages.push({
|
|
2276
2792
|
role: "assistant",
|
|
2277
2793
|
content: standardizedContent,
|
|
2278
2794
|
});
|
|
2279
2795
|
if (allCalls.length > 1) {
|
|
2280
|
-
writeNotice("info", `${allCalls.length} tool calls in this message — running
|
|
2796
|
+
writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
|
|
2281
2797
|
}
|
|
2282
|
-
//
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2798
|
+
// ── Scoped-parallel batch execution ────────────────────────────────
|
|
2799
|
+
// The model may emit several calls in one message. We partition them,
|
|
2800
|
+
// IN DOCUMENT ORDER, into segments:
|
|
2801
|
+
// • A run of consecutive READ-ONLY, safe-classified calls (the same
|
|
2802
|
+
// allowlist tool.batch uses) executes CONCURRENTLY — this is where
|
|
2803
|
+
// independent lookups within a single task fan out (e.g. whois +
|
|
2804
|
+
// dns + http.fetch during recon).
|
|
2805
|
+
// • Every other call (plan.create/task.update, and any mutating or
|
|
2806
|
+
// confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
|
|
2807
|
+
// runs ALONE as a sequential barrier.
|
|
2808
|
+
// Because task.update is never parallel-safe, it always acts as a
|
|
2809
|
+
// barrier: it commits before the work it gates and after the work it
|
|
2810
|
+
// closes. That keeps execution strictly task-by-task and eliminates the
|
|
2811
|
+
// plan-state races / overlapping writes that a blanket Promise.all
|
|
2812
|
+
// caused, while still letting one task's independent lookups run in
|
|
2813
|
+
// parallel. The whole batch stops on the first abort, block, or failure
|
|
2814
|
+
// so the model always sees and reacts to an error.
|
|
2815
|
+
const scopeForBatch = await loadScope().catch(() => undefined);
|
|
2816
|
+
const isParallelSafe = (c) => {
|
|
2817
|
+
if (!BATCH_SAFE_TOOLS.has(c.name))
|
|
2818
|
+
return false;
|
|
2819
|
+
try {
|
|
2820
|
+
return (classifyToolCall(c, { scope: scopeForBatch }).level === "safe");
|
|
2821
|
+
}
|
|
2822
|
+
catch {
|
|
2823
|
+
return false;
|
|
2824
|
+
}
|
|
2825
|
+
};
|
|
2826
|
+
const PARALLEL_LIMIT = 4;
|
|
2288
2827
|
let aborted = false;
|
|
2289
2828
|
let blocked = false;
|
|
2290
2829
|
let blockedResult = null;
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
aborted = true;
|
|
2294
|
-
}
|
|
2295
|
-
if (res.blockOrCancel) {
|
|
2296
|
-
blocked = true;
|
|
2297
|
-
blockedResult = res;
|
|
2298
|
-
}
|
|
2830
|
+
let failed = false;
|
|
2831
|
+
const recordResult = (res) => {
|
|
2299
2832
|
messages.push({
|
|
2300
2833
|
role: "tool",
|
|
2301
2834
|
content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
|
|
2302
2835
|
});
|
|
2303
2836
|
productiveSteps += 1;
|
|
2837
|
+
if (res.lastAnswer === "Aborted.")
|
|
2838
|
+
aborted = true;
|
|
2839
|
+
else if (res.blockOrCancel) {
|
|
2840
|
+
blocked = true;
|
|
2841
|
+
blockedResult = res;
|
|
2842
|
+
}
|
|
2843
|
+
else if (!res.ok)
|
|
2844
|
+
failed = true;
|
|
2845
|
+
};
|
|
2846
|
+
const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
|
|
2847
|
+
for (const group of groups) {
|
|
2848
|
+
if (aborted || blocked || failed)
|
|
2849
|
+
break;
|
|
2850
|
+
if (group.length === 1) {
|
|
2851
|
+
const id = `tool-${++nextToolEventId}`;
|
|
2852
|
+
const res = await executeSingleTool(group[0], id, options.signal || new AbortController().signal);
|
|
2853
|
+
recordResult(res);
|
|
2854
|
+
}
|
|
2855
|
+
else {
|
|
2856
|
+
// Concurrent group — assign ids in document order, then push their
|
|
2857
|
+
// results in document order for a stable transcript.
|
|
2858
|
+
const ids = group.map(() => `tool-${++nextToolEventId}`);
|
|
2859
|
+
const results = await Promise.all(group.map((c, k) => executeSingleTool(c, ids[k], options.signal || new AbortController().signal)));
|
|
2860
|
+
for (const res of results)
|
|
2861
|
+
recordResult(res);
|
|
2862
|
+
}
|
|
2304
2863
|
}
|
|
2305
2864
|
if (aborted) {
|
|
2306
2865
|
lastAnswer = "Aborted.";
|
|
@@ -2311,20 +2870,12 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2311
2870
|
lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
|
|
2312
2871
|
return finishTurn(lastAnswer, productiveSteps);
|
|
2313
2872
|
}
|
|
2314
|
-
//
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
// whose results were compacted away.
|
|
2321
|
-
loopGuard.resetReadOnly();
|
|
2322
|
-
await auditLog("agent.compact", {
|
|
2323
|
-
newLength: messages.length,
|
|
2324
|
-
estimatedTokens: estimateMessagesTokens(messages),
|
|
2325
|
-
});
|
|
2326
|
-
}
|
|
2327
|
-
}
|
|
2873
|
+
// A plain failure just stops the remaining calls; we fall through so
|
|
2874
|
+
// the model sees the failed tool's output and decides what to do next.
|
|
2875
|
+
// Compact older messages when the running estimate exceeds budget. Uses
|
|
2876
|
+
// the model-written summary path (with plan re-injection) — never a
|
|
2877
|
+
// mechanical transcript dump.
|
|
2878
|
+
await maybeAutoCompact("post-tool-token-budget");
|
|
2328
2879
|
}
|
|
2329
2880
|
}
|
|
2330
2881
|
// maxIterations ceiling reached (safety net — normally the step budget
|