@pentoshi/clai 2.0.28 → 2.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/confirm-port.d.ts +30 -0
- package/dist/agent/confirm-port.js +89 -0
- package/dist/agent/confirm-port.js.map +1 -0
- package/dist/agent/plan-tool.d.ts +26 -0
- package/dist/agent/plan-tool.js +188 -0
- package/dist/agent/plan-tool.js.map +1 -0
- package/dist/agent/runner.d.ts +6 -174
- package/dist/agent/runner.js +25 -1606
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/session-policy.d.ts +19 -0
- package/dist/agent/session-policy.js +40 -0
- package/dist/agent/session-policy.js.map +1 -0
- package/dist/agent/stop-summary.d.ts +8 -0
- package/dist/agent/stop-summary.js +53 -0
- package/dist/agent/stop-summary.js.map +1 -0
- package/dist/agent/tool-call-parser.d.ts +155 -0
- package/dist/agent/tool-call-parser.js +1107 -0
- package/dist/agent/tool-call-parser.js.map +1 -0
- package/dist/agent/tool-output-formatting.d.ts +7 -0
- package/dist/agent/tool-output-formatting.js +127 -0
- package/dist/agent/tool-output-formatting.js.map +1 -0
- package/dist/commands/update.js +1 -1
- package/dist/prompts/index.d.ts +2 -2
- package/dist/prompts/index.js +103 -59
- package/dist/prompts/index.js.map +1 -1
- package/dist/repl/prompt-line.d.ts +19 -0
- package/dist/repl/prompt-line.js +555 -0
- package/dist/repl/prompt-line.js.map +1 -0
- package/dist/repl/slash-commands.d.ts +24 -0
- package/dist/repl/slash-commands.js +317 -0
- package/dist/repl/slash-commands.js.map +1 -0
- package/dist/repl.d.ts +2 -25
- package/dist/repl.js +41 -898
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +1 -1
- package/dist/safety/classifier.js.map +1 -1
- package/dist/store/keys.js +0 -4
- package/dist/store/keys.js.map +1 -1
- package/dist/store/plan.js +1 -1
- package/dist/store/plan.js.map +1 -1
- package/dist/tools/nmap-runner.d.ts +19 -0
- package/dist/tools/nmap-runner.js +177 -0
- package/dist/tools/nmap-runner.js.map +1 -0
- package/dist/tools/package-binary.d.ts +1 -0
- package/dist/tools/package-binary.js +30 -0
- package/dist/tools/package-binary.js.map +1 -0
- package/dist/tools/pdf.js +2 -2
- package/dist/tools/pdf.js.map +1 -1
- package/dist/tools/registry.d.ts +2 -11
- package/dist/tools/registry.js +3 -203
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/tool-types.d.ts +12 -0
- package/dist/tools/tool-types.js +2 -0
- package/dist/tools/tool-types.js.map +1 -0
- package/dist/tools/web/audit.d.ts +7 -29
- package/dist/tools/web/audit.js +7 -29
- package/dist/tools/web/audit.js.map +1 -1
- package/dist/tools/web/budget.d.ts +5 -28
- package/dist/tools/web/budget.js +5 -28
- package/dist/tools/web/budget.js.map +1 -1
- package/dist/tools/web/capture.d.ts +8 -35
- package/dist/tools/web/capture.js +8 -35
- package/dist/tools/web/capture.js.map +1 -1
- package/dist/tools/web/fetch-core.d.ts +6 -26
- package/dist/tools/web/fetch-core.js +6 -26
- package/dist/tools/web/fetch-core.js.map +1 -1
- package/dist/tools/web/readable.d.ts +7 -13
- package/dist/tools/web/readable.js +7 -13
- package/dist/tools/web/readable.js.map +1 -1
- package/dist/tools/web/search.d.ts +15 -19
- package/dist/tools/web/search.js +103 -44
- package/dist/tools/web/search.js.map +1 -1
- package/dist/tools/web/ssrf-guard.d.ts +8 -27
- package/dist/tools/web/ssrf-guard.js +8 -27
- package/dist/tools/web/ssrf-guard.js.map +1 -1
- package/dist/tui/App.js +19 -88
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/key-intent.d.ts +36 -0
- package/dist/tui/key-intent.js +54 -0
- package/dist/tui/key-intent.js.map +1 -0
- package/dist/tui/render-lines.js +125 -37
- package/dist/tui/render-lines.js.map +1 -1
- package/dist/tui/state.js +2 -2
- package/dist/tui/state.js.map +1 -1
- package/dist/tui/text-format.d.ts +11 -0
- package/dist/tui/text-format.js +85 -0
- package/dist/tui/text-format.js.map +1 -0
- package/dist/ui/ansi-box.d.ts +7 -0
- package/dist/ui/ansi-box.js +18 -0
- package/dist/ui/ansi-box.js.map +1 -0
- package/dist/ui/banner.d.ts +0 -9
- package/dist/ui/banner.js +1 -43
- package/dist/ui/banner.js.map +1 -1
- package/dist/ui/intro-card.d.ts +10 -0
- package/dist/ui/intro-card.js +39 -0
- package/dist/ui/intro-card.js.map +1 -0
- package/dist/ui/markdown.js +1 -1
- package/dist/ui/markdown.js.map +1 -1
- package/dist/ui/output-pane.js.map +1 -1
- package/dist/ui/spinner.js +6 -17
- package/dist/ui/spinner.js.map +1 -1
- package/dist/ui/wordmark.d.ts +7 -0
- package/dist/ui/wordmark.js +42 -0
- package/dist/ui/wordmark.js.map +1 -0
- package/package.json +1 -1
package/dist/agent/runner.js
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import { confirm } from "@inquirer/prompts";
|
|
2
1
|
import chalk from "chalk";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
2
|
import { homedir } from "node:os";
|
|
5
|
-
import { fixOwner, handlePermissionError } from "../os/permissions.js";
|
|
6
3
|
import { join, resolve } from "node:path";
|
|
7
4
|
import { streamWithProvider, completeWithProvider } from "../llm/router.js";
|
|
8
5
|
import { randomUUID } from "node:crypto";
|
|
9
6
|
import { jobManager } from "../tools/jobs.js";
|
|
10
|
-
import {
|
|
7
|
+
import { renderAgentSystemPrompt, } from "../prompts/index.js";
|
|
11
8
|
import { getConfig } from "../store/config.js";
|
|
12
9
|
import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
13
10
|
import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
|
|
14
11
|
import { looksInteractiveStdin } from "../tools/shell.js";
|
|
15
|
-
import { reduceToolOutput } from "../tools/policies/output-policy.js";
|
|
16
12
|
import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
|
|
17
|
-
import { compactMessagesWithSummary, estimateMessagesTokens,
|
|
13
|
+
import { compactMessagesWithSummary, estimateMessagesTokens, } from "./context-manager.js";
|
|
18
14
|
import { auditLog } from "../store/logs.js";
|
|
19
15
|
import { loadProjectContext } from "../store/project.js";
|
|
20
16
|
import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
|
|
@@ -25,1600 +21,23 @@ import { startThinkingSpinner } from "../ui/spinner.js";
|
|
|
25
21
|
import { safeCwd } from "../os/cwd.js";
|
|
26
22
|
import { analyzeTask } from "./task-analyzer.js";
|
|
27
23
|
import { LoopGuard } from "./loop-guard.js";
|
|
28
|
-
import {
|
|
29
|
-
import { renderPlanChecklist, renderPlanSidePane } from "../ui/plan-pane.js";
|
|
24
|
+
import { loadPlan } from "../store/plan.js";
|
|
30
25
|
import { pathInsideSandbox } from "../tools/fs.js";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
sessionId: sessionId ??
|
|
44
|
-
`sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
45
|
-
planApproved: { value: false },
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
export function preprocessJson(raw) {
|
|
49
|
-
let inString = false;
|
|
50
|
-
let escaped = false;
|
|
51
|
-
let result = "";
|
|
52
|
-
for (let i = 0; i < raw.length; i++) {
|
|
53
|
-
const char = raw[i];
|
|
54
|
-
if (char === '"' && !escaped) {
|
|
55
|
-
inString = !inString;
|
|
56
|
-
result += char;
|
|
57
|
-
}
|
|
58
|
-
else if (inString) {
|
|
59
|
-
if (char === "\n") {
|
|
60
|
-
result += "\\n";
|
|
61
|
-
}
|
|
62
|
-
else if (char === "\r") {
|
|
63
|
-
result += "\\r";
|
|
64
|
-
}
|
|
65
|
-
else if (char === "\t") {
|
|
66
|
-
result += "\\t";
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
result += char;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
if (char === "," && i + 1 < raw.length) {
|
|
74
|
-
let nextNonWs = "";
|
|
75
|
-
for (let j = i + 1; j < raw.length; j++) {
|
|
76
|
-
if (!/\s/.test(raw[j])) {
|
|
77
|
-
nextNonWs = raw[j];
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (nextNonWs === "}" || nextNonWs === "]") {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
result += char;
|
|
86
|
-
}
|
|
87
|
-
if (char === "\\" && inString) {
|
|
88
|
-
escaped = !escaped;
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
escaped = false;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return result;
|
|
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
|
-
}
|
|
173
|
-
function tryParseCall(raw) {
|
|
174
|
-
let parsed;
|
|
175
|
-
try {
|
|
176
|
-
parsed = JSON.parse(preprocessJson(raw).trim());
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
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
|
-
}
|
|
226
|
-
}
|
|
227
|
-
return undefined;
|
|
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
|
-
}
|
|
244
|
-
// Kimi K2 / Moonshot models on NVIDIA NIM emit tool calls using a
|
|
245
|
-
// sentinel-token format that looks like:
|
|
246
|
-
// <|tool_calls_section_begin|>
|
|
247
|
-
// <|tool_call_begin|>functions.shell.exec:0<|tool_call_argument_begin|>
|
|
248
|
-
// {"command":"ls"}
|
|
249
|
-
// <|tool_call_end|>
|
|
250
|
-
// <|tool_calls_section_end|>
|
|
251
|
-
// The `functions.` prefix is optional, the trailing `:N` index is optional,
|
|
252
|
-
// and the surrounding section markers may be absent on truncated streams.
|
|
253
|
-
const KIMI_TOOL_CALL_RE = /<\|tool_call_begin\|>\s*(?:functions\.)?([A-Za-z][\w.]*?)(?::\d+)?\s*<\|tool_call_argument_begin\|>\s*(\{[\s\S]*?\})\s*<\|tool_call_end\|>/i;
|
|
254
|
-
function parseKimiToolCall(text) {
|
|
255
|
-
const match = text.match(KIMI_TOOL_CALL_RE);
|
|
256
|
-
if (!match)
|
|
257
|
-
return undefined;
|
|
258
|
-
const name = match[1];
|
|
259
|
-
return tryParseCall(JSON.stringify({ name, args: tryJson(match[2]) ?? {} }));
|
|
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
|
-
}
|
|
304
|
-
function parseXmlToolCall(text) {
|
|
305
|
-
// Pattern 1 (name + args/arguments/parameters JSON):
|
|
306
|
-
// <tool_call>
|
|
307
|
-
// <name>tool.name</name>
|
|
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);
|
|
311
|
-
if (xmlNameArgs?.[1] && xmlNameArgs?.[2]) {
|
|
312
|
-
try {
|
|
313
|
-
const args = JSON.parse(preprocessJson(xmlNameArgs[2]));
|
|
314
|
-
return {
|
|
315
|
-
name: xmlNameArgs[1],
|
|
316
|
-
args: args,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
catch { }
|
|
320
|
-
}
|
|
321
|
-
// Pattern 1b (MiMo alternative):
|
|
322
|
-
// <tool_call>
|
|
323
|
-
// <tool_name>tool.name</tool_name>
|
|
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);
|
|
327
|
-
if (xmlToolNameParams?.[1] && xmlToolNameParams?.[2]) {
|
|
328
|
-
try {
|
|
329
|
-
const args = JSON.parse(preprocessJson(xmlToolNameParams[2]));
|
|
330
|
-
return {
|
|
331
|
-
name: xmlToolNameParams[1],
|
|
332
|
-
args: args,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
catch { }
|
|
336
|
-
}
|
|
337
|
-
// Pattern 1c (MiMo function/parameter format), with or WITHOUT a
|
|
338
|
-
// surrounding <tool_call> wrapper (some models emit the bare function block):
|
|
339
|
-
// <function=tool.name>
|
|
340
|
-
// <parameter=name>value</parameter>
|
|
341
|
-
// </function>
|
|
342
|
-
const xmlFunctionBlock = text.match(/<function=([\w.]+?)>([\s\S]*?)<\/function>/i);
|
|
343
|
-
if (xmlFunctionBlock?.[1] && xmlFunctionBlock?.[2]) {
|
|
344
|
-
const name = xmlFunctionBlock[1];
|
|
345
|
-
const inner = xmlFunctionBlock[2];
|
|
346
|
-
const args = {};
|
|
347
|
-
const paramRegex = /<parameter=([\w.]+?)>([\s\S]*?)<\/parameter>/gi;
|
|
348
|
-
let paramMatch;
|
|
349
|
-
while ((paramMatch = paramRegex.exec(inner)) !== null) {
|
|
350
|
-
const paramName = paramMatch[1];
|
|
351
|
-
const paramValueStr = paramMatch[2].trim();
|
|
352
|
-
let paramValue = paramValueStr;
|
|
353
|
-
try {
|
|
354
|
-
if (/^(?:\[|\{|true|false|null|\d+(\.\d+)?$)/i.test(paramValueStr)) {
|
|
355
|
-
paramValue = JSON.parse(preprocessJson(paramValueStr));
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
catch { }
|
|
359
|
-
args[paramName] = paramValue;
|
|
360
|
-
}
|
|
361
|
-
return { name, args };
|
|
362
|
-
}
|
|
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
|
-
}
|
|
412
|
-
}
|
|
413
|
-
return undefined;
|
|
414
|
-
}
|
|
415
|
-
function tryJson(raw) {
|
|
416
|
-
try {
|
|
417
|
-
const preprocessed = preprocessJson(raw);
|
|
418
|
-
const parsed = JSON.parse(preprocessed);
|
|
419
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
420
|
-
return parsed;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
catch {
|
|
424
|
-
// ignore
|
|
425
|
-
}
|
|
426
|
-
return undefined;
|
|
427
|
-
}
|
|
428
|
-
/** Strip any leftover Kimi/Moonshot sentinel tokens from final answers
|
|
429
|
-
* so a model that mixes prose and tool-call markers never bleeds raw
|
|
430
|
-
* `<|tool_call_begin|>` strings to the terminal. */
|
|
431
|
-
function stripSentinelTokens(text) {
|
|
432
|
-
return text
|
|
433
|
-
.replace(/<\|tool_calls_section_begin\|>[\s\S]*?<\|tool_calls_section_end\|>/gi, "")
|
|
434
|
-
.replace(/<\|tool_call_begin\|>[\s\S]*?<\|tool_call_end\|>/gi, "")
|
|
435
|
-
.replace(/<\|tool_calls?(?:_section)?_(?:begin|end)\|>/gi, "")
|
|
436
|
-
.replace(/<\|tool_call_argument_begin\|>/gi, "")
|
|
437
|
-
.replace(/<\|tool_[a-z_]*\|>/gi, "")
|
|
438
|
-
.trim();
|
|
439
|
-
}
|
|
440
|
-
export function parseToolCall(text, options = {}) {
|
|
441
|
-
// 1. ```tool ... ``` (standard format)
|
|
442
|
-
const fenced = text.match(/```tool\s*\n?([\s\S]*?)```/i);
|
|
443
|
-
if (fenced?.[1]) {
|
|
444
|
-
const call = tryParseCall(fenced[1]);
|
|
445
|
-
if (call)
|
|
446
|
-
return call;
|
|
447
|
-
}
|
|
448
|
-
// 2. <tool_call>...</tool_call> (XML formats)
|
|
449
|
-
const xmlCall = parseXmlToolCall(text);
|
|
450
|
-
if (xmlCall)
|
|
451
|
-
return xmlCall;
|
|
452
|
-
// 3. Kimi/Moonshot sentinel format (used by kimi-k2 family on NIM).
|
|
453
|
-
const kimi = parseKimiToolCall(text);
|
|
454
|
-
if (kimi)
|
|
455
|
-
return kimi;
|
|
456
|
-
// In strict mode, stop here. Headings, generic fenced blocks, and trailing
|
|
457
|
-
// JSON are too easy to accidentally trigger when the model is showing a
|
|
458
|
-
// worked example.
|
|
459
|
-
if (options.strict)
|
|
460
|
-
return undefined;
|
|
461
|
-
// 4. ### tool / ## tool / # tool heading + JSON
|
|
462
|
-
const heading = text.match(/#{1,3}\s*tool\s*\n\s*(\{[\s\S]*\})/i);
|
|
463
|
-
if (heading?.[1]) {
|
|
464
|
-
const call = tryParseCall(heading[1]);
|
|
465
|
-
if (call)
|
|
466
|
-
return call;
|
|
467
|
-
}
|
|
468
|
-
// 5. **tool** heading + JSON
|
|
469
|
-
const bold = text.match(/\*\*tool\*\*\s*\n\s*(\{[\s\S]*\})/i);
|
|
470
|
-
if (bold?.[1]) {
|
|
471
|
-
const call = tryParseCall(bold[1]);
|
|
472
|
-
if (call)
|
|
473
|
-
return call;
|
|
474
|
-
}
|
|
475
|
-
// 6. Any fenced block (```json, ```, etc.) containing name+args
|
|
476
|
-
const anyFenced = text.match(/```\w*\s*\n?([\s\S]*?)```/);
|
|
477
|
-
if (anyFenced?.[1]) {
|
|
478
|
-
const call = tryParseCall(anyFenced[1]);
|
|
479
|
-
if (call)
|
|
480
|
-
return call;
|
|
481
|
-
}
|
|
482
|
-
// 7. Trailing JSON object with "name" and "args"
|
|
483
|
-
const trailingJson = text.match(/(\{"name"\s*:\s*"[^"]+"\s*,\s*"args"\s*:\s*\{[\s\S]*?\}\s*\})\s*$/);
|
|
484
|
-
if (trailingJson?.[1]) {
|
|
485
|
-
const call = tryParseCall(trailingJson[1]);
|
|
486
|
-
if (call)
|
|
487
|
-
return call;
|
|
488
|
-
}
|
|
489
|
-
return undefined;
|
|
490
|
-
}
|
|
491
|
-
// Argument keys that the built-in tools accept. Used to recognize when a
|
|
492
|
-
// model emitted a bare args object (e.g. {"path":"file.pdf"}) — intending a
|
|
493
|
-
// tool call but forgetting the {"name","args"} wrapper and the ```tool fence.
|
|
494
|
-
const TOOL_ARG_KEYS = new Set([
|
|
495
|
-
"command",
|
|
496
|
-
"path",
|
|
497
|
-
"paths",
|
|
498
|
-
"url",
|
|
499
|
-
"query",
|
|
500
|
-
"target",
|
|
501
|
-
"pattern",
|
|
502
|
-
"tool",
|
|
503
|
-
"tools",
|
|
504
|
-
"files",
|
|
505
|
-
"content",
|
|
506
|
-
"calls",
|
|
507
|
-
"record",
|
|
508
|
-
"ports",
|
|
509
|
-
"profile",
|
|
510
|
-
"id",
|
|
511
|
-
"lang",
|
|
512
|
-
"dpi",
|
|
513
|
-
"psm",
|
|
514
|
-
"recursive",
|
|
515
|
-
"oldText",
|
|
516
|
-
"newText",
|
|
517
|
-
"expectedReplacements",
|
|
518
|
-
"goal",
|
|
519
|
-
"tasks",
|
|
520
|
-
"taskId",
|
|
521
|
-
"state",
|
|
522
|
-
"method",
|
|
523
|
-
"body",
|
|
524
|
-
"headers",
|
|
525
|
-
"maxBytes",
|
|
526
|
-
"maxResults",
|
|
527
|
-
"cwd",
|
|
528
|
-
"name",
|
|
529
|
-
"concurrency",
|
|
530
|
-
// Extra optional keys that commonly ride along with a bare args object so
|
|
531
|
-
// it is still recognized (and its tool inferred) instead of leaking to the
|
|
532
|
-
// screen — e.g. shell.exec with {"command":"…","timeoutMs":300000}.
|
|
533
|
-
"timeoutMs",
|
|
534
|
-
"flags",
|
|
535
|
-
"iOwnThis",
|
|
536
|
-
"own",
|
|
537
|
-
"note",
|
|
538
|
-
"kind",
|
|
539
|
-
"detail",
|
|
540
|
-
"maxEntries",
|
|
541
|
-
"checkBinary",
|
|
542
|
-
"scanType",
|
|
543
|
-
"whois",
|
|
544
|
-
"dns",
|
|
545
|
-
"nmap",
|
|
546
|
-
"bytes",
|
|
547
|
-
"responseMode",
|
|
548
|
-
"includeHeaders",
|
|
549
|
-
"includeTls",
|
|
550
|
-
"includeTiming",
|
|
551
|
-
"includeRedirectChain",
|
|
552
|
-
"redactSensitive",
|
|
553
|
-
]);
|
|
554
|
-
/**
|
|
555
|
-
* When a model emits a bare args object with no {"name", "args"} wrapper and
|
|
556
|
-
* no ```tool fence, infer which tool it MEANT from the argument keys so we
|
|
557
|
-
* can run it directly instead of nudging the model to re-emit (the user
|
|
558
|
-
* should not have to type "run"). Only unambiguous key signatures map to a
|
|
559
|
-
* tool; genuinely ambiguous shapes (a lone `path` could be fs.read / fs.list
|
|
560
|
-
* / pdf.read / image.ocr; a lone `target` could be whois / dns / scan) return
|
|
561
|
-
* undefined so the caller falls back to a re-emit nudge. Inferred calls still
|
|
562
|
-
* pass through the normal safety classifier + confirmation, so inference can
|
|
563
|
-
* never bypass a confirm/block gate.
|
|
564
|
-
*/
|
|
565
|
-
export function inferToolFromArgs(obj) {
|
|
566
|
-
const has = (key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
567
|
-
if (has("command"))
|
|
568
|
-
return "shell.exec";
|
|
569
|
-
if (has("files"))
|
|
570
|
-
return "fs.writeMany";
|
|
571
|
-
if (has("calls"))
|
|
572
|
-
return "tool.batch";
|
|
573
|
-
if (has("oldText") || has("newText"))
|
|
574
|
-
return "fs.edit";
|
|
575
|
-
if (has("content") && has("path"))
|
|
576
|
-
return "fs.write";
|
|
577
|
-
if (has("pattern"))
|
|
578
|
-
return "fs.search";
|
|
579
|
-
if (has("query"))
|
|
580
|
-
return "web.search";
|
|
581
|
-
if (has("tools"))
|
|
582
|
-
return "tool.check";
|
|
583
|
-
if (has("goal") && has("tasks"))
|
|
584
|
-
return "plan.create";
|
|
585
|
-
if (has("taskId") || has("state"))
|
|
586
|
-
return "task.update";
|
|
587
|
-
if (has("tool"))
|
|
588
|
-
return "pkg.install";
|
|
589
|
-
if (has("record") && has("target"))
|
|
590
|
-
return "dns.lookup";
|
|
591
|
-
if (has("ports") && has("target"))
|
|
592
|
-
return "net.scan";
|
|
593
|
-
if (has("url")) {
|
|
594
|
-
// A url with an explicit method/body is a raw HTTP request (http.fetch);
|
|
595
|
-
// a lone url is a content read (web.fetch).
|
|
596
|
-
return has("method") || has("body") ? "http.fetch" : "web.fetch";
|
|
597
|
-
}
|
|
598
|
-
return undefined;
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Strip a single wrapping ```json / ``` fence (if any) and return the inner
|
|
602
|
-
* text trimmed. Leaves un-fenced text unchanged.
|
|
603
|
-
*/
|
|
604
|
-
function stripLoneFence(text) {
|
|
605
|
-
const fenced = text.trim().match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```$/);
|
|
606
|
-
return (fenced?.[1] ?? text).trim();
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Try to recover a bare-args tool call from a single candidate text snippet.
|
|
610
|
-
* Returns the recognized result or undefined if the text isn't a recoverable
|
|
611
|
-
* tool call. Used by both the whole-text path and the embedded-fence path.
|
|
612
|
-
*/
|
|
613
|
-
function tryRecognizeBareArgs(inner) {
|
|
614
|
-
const trimmed = inner.trim();
|
|
615
|
-
if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
|
|
616
|
-
return undefined;
|
|
617
|
-
let parsed;
|
|
618
|
-
try {
|
|
619
|
-
parsed = JSON.parse(trimmed);
|
|
620
|
-
}
|
|
621
|
-
catch {
|
|
622
|
-
return undefined;
|
|
623
|
-
}
|
|
624
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
625
|
-
return undefined;
|
|
626
|
-
}
|
|
627
|
-
const obj = parsed;
|
|
628
|
-
// Complete {name, args} call the earlier matchers didn't catch.
|
|
629
|
-
const direct = tryParseCall(trimmed);
|
|
630
|
-
if (direct)
|
|
631
|
-
return { call: direct };
|
|
632
|
-
// Bare args object: every key is a known tool-arg key.
|
|
633
|
-
const keys = Object.keys(obj);
|
|
634
|
-
if (keys.length === 0 || keys.length > 6)
|
|
635
|
-
return undefined;
|
|
636
|
-
const allKnown = keys.every((key) => TOOL_ARG_KEYS.has(key));
|
|
637
|
-
if (!allKnown)
|
|
638
|
-
return undefined;
|
|
639
|
-
const inferred = inferToolFromArgs(obj);
|
|
640
|
-
if (inferred) {
|
|
641
|
-
return { call: { name: inferred, args: obj } };
|
|
642
|
-
}
|
|
643
|
-
return { argsOnly: true };
|
|
644
|
-
}
|
|
645
|
-
/**
|
|
646
|
-
* When a model means to call a tool but emits ONLY a bare JSON object —
|
|
647
|
-
* either a proper {"name","args"} that the strict matchers missed, or a bare
|
|
648
|
-
* args object like {"path":"file.pdf"} with the wrapper/fence dropped — this
|
|
649
|
-
* recognizes it. Returns:
|
|
650
|
-
* - { call } when the object is a complete {name, args} tool call, or
|
|
651
|
-
* - { argsOnly: true } when it looks like a bare args object (so the caller
|
|
652
|
-
* can nudge the model to re-emit a properly named, fenced tool call).
|
|
653
|
-
* Returns undefined for anything that is plainly a normal prose/JSON answer.
|
|
654
|
-
*
|
|
655
|
-
* Also handles the case where a model emits prose followed by a non-`tool`
|
|
656
|
-
* fenced code block (e.g. ```web\n{"url":"..."}\n```) that contains a bare
|
|
657
|
-
* args object — the fence is scanned even when it's not the sole content.
|
|
658
|
-
*/
|
|
659
|
-
export function recognizeBareToolJson(text) {
|
|
660
|
-
// ── Primary path: the whole (de-fenced) text is a bare JSON object ──────
|
|
661
|
-
const inner = stripLoneFence(text);
|
|
662
|
-
const primary = tryRecognizeBareArgs(inner);
|
|
663
|
-
if (primary)
|
|
664
|
-
return primary;
|
|
665
|
-
// ── Secondary path: scan for any fenced block embedded in the text ──────
|
|
666
|
-
// This catches models that prepend prose before emitting a bare-args fence,
|
|
667
|
-
// e.g. "Let me fetch it.\n\n```web\n{\"url\":\"https://...\"}\n```"
|
|
668
|
-
// We skip ```tool fences — those are handled by parseToolCall already.
|
|
669
|
-
const embeddedFenceRe = /```([a-zA-Z]*)\s*\n?([\s\S]*?)```/g;
|
|
670
|
-
let m;
|
|
671
|
-
while ((m = embeddedFenceRe.exec(text)) !== null) {
|
|
672
|
-
const lang = m[1] ?? "";
|
|
673
|
-
const body = (m[2] ?? "").trim();
|
|
674
|
-
// Skip ```tool blocks — parseToolCall owns those.
|
|
675
|
-
if (lang.toLowerCase() === "tool")
|
|
676
|
-
continue;
|
|
677
|
-
// Skip empty or multi-line JSON that spans more than a simple object.
|
|
678
|
-
if (!body.startsWith("{") || !body.endsWith("}"))
|
|
679
|
-
continue;
|
|
680
|
-
const result = tryRecognizeBareArgs(body);
|
|
681
|
-
if (result)
|
|
682
|
-
return result;
|
|
683
|
-
}
|
|
684
|
-
return undefined;
|
|
685
|
-
}
|
|
686
|
-
/**
|
|
687
|
-
* Detect an opened-but-unparseable tool call. This happens when the model's
|
|
688
|
-
* output is truncated by the token limit mid-JSON: we see the ```tool fence
|
|
689
|
-
* (or a bare {"name":"...","args" prefix) open, but parseToolCall returns
|
|
690
|
-
* undefined because the JSON never closed. Without this, the broken block
|
|
691
|
-
* leaks to the screen as a "final answer" and the requested action (e.g. a
|
|
692
|
-
* multi-file fs.writeMany scaffold) silently never runs.
|
|
693
|
-
*/
|
|
694
|
-
export function looksLikeTruncatedToolCall(text) {
|
|
695
|
-
// An opened ```tool fence with no closing fence.
|
|
696
|
-
const openFence = /```tool\s*\n?/i.test(text);
|
|
697
|
-
const closeFence = /```tool[\s\S]*?```/i.test(text);
|
|
698
|
-
if (openFence && !closeFence)
|
|
699
|
-
return true;
|
|
700
|
-
// A tool-call JSON object that started but whose braces never balanced.
|
|
701
|
-
const jsonStart = text.search(/\{\s*"name"\s*:\s*"[A-Za-z][\w.]*"\s*,\s*"args"/);
|
|
702
|
-
if (jsonStart >= 0) {
|
|
703
|
-
const slice = text.slice(jsonStart);
|
|
704
|
-
let depth = 0;
|
|
705
|
-
let inString = false;
|
|
706
|
-
let escaped = false;
|
|
707
|
-
let balanced = false;
|
|
708
|
-
for (const ch of slice) {
|
|
709
|
-
if (escaped) {
|
|
710
|
-
escaped = false;
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
if (ch === "\\") {
|
|
714
|
-
escaped = true;
|
|
715
|
-
continue;
|
|
716
|
-
}
|
|
717
|
-
if (ch === '"')
|
|
718
|
-
inString = !inString;
|
|
719
|
-
if (inString)
|
|
720
|
-
continue;
|
|
721
|
-
if (ch === "{")
|
|
722
|
-
depth += 1;
|
|
723
|
-
else if (ch === "}") {
|
|
724
|
-
depth -= 1;
|
|
725
|
-
if (depth === 0) {
|
|
726
|
-
balanced = true;
|
|
727
|
-
break;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
if (!balanced)
|
|
732
|
-
return true;
|
|
733
|
-
}
|
|
734
|
-
return false;
|
|
735
|
-
}
|
|
736
|
-
/**
|
|
737
|
-
* Count the number of ```tool fenced blocks in a message. Models sometimes
|
|
738
|
-
* emit MULTIPLE tool calls in one response (e.g. fs.writeMany + npm install +
|
|
739
|
-
* npm run dev). Only the FIRST is parsed and executed; the rest are silently
|
|
740
|
-
* dropped and leak to the screen as code fences, while the model believes it
|
|
741
|
-
* ran all of them — a major cause of "everything is done" fabrications. We
|
|
742
|
-
* detect this so the runner can run the first and explicitly tell the model
|
|
743
|
-
* the others did NOT run and must be re-sent one at a time.
|
|
744
|
-
*/
|
|
745
|
-
export function countToolFences(text) {
|
|
746
|
-
const matches = text.match(/```tool\s*\n[\s\S]*?```/gi);
|
|
747
|
-
return matches ? matches.length : 0;
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Parse EVERY explicitly-delimited tool call in a message, in document
|
|
751
|
-
* order. Unlike parseToolCall (which returns only the first), this lets the
|
|
752
|
-
* runner execute a batch the model emitted in one turn — e.g. the natural
|
|
753
|
-
* "task.update in_progress → do the work → task.update done" sequence, or
|
|
754
|
-
* several fs.write calls. Only the unambiguous, delimited formats are
|
|
755
|
-
* collected (```tool fences, <tool_call> XML, and Kimi sentinel blocks) so a
|
|
756
|
-
* worked example in prose is far less likely to be mistaken for a call.
|
|
757
|
-
* The runner executes them sequentially and STOPS the batch on the first
|
|
758
|
-
* failure so the model can react, mirroring how Claude Code batches reads
|
|
759
|
-
* and edits but pauses when something breaks.
|
|
760
|
-
*/
|
|
761
|
-
export function parseAllToolCalls(text) {
|
|
762
|
-
const found = [];
|
|
763
|
-
let m;
|
|
764
|
-
const fenceRe = /```tool\s*\n?([\s\S]*?)```/gi;
|
|
765
|
-
while ((m = fenceRe.exec(text)) !== null) {
|
|
766
|
-
const call = tryParseCall(m[1] ?? "");
|
|
767
|
-
if (call)
|
|
768
|
-
found.push({ index: m.index, call });
|
|
769
|
-
}
|
|
770
|
-
const xmlRe = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
|
|
771
|
-
while ((m = xmlRe.exec(text)) !== null) {
|
|
772
|
-
const call = parseXmlToolCall(m[0]);
|
|
773
|
-
if (call)
|
|
774
|
-
found.push({ index: m.index, call });
|
|
775
|
-
}
|
|
776
|
-
const kimiRe = new RegExp(KIMI_TOOL_CALL_RE.source, "gi");
|
|
777
|
-
while ((m = kimiRe.exec(text)) !== null) {
|
|
778
|
-
const call = tryParseCall(JSON.stringify({ name: m[1], args: tryJson(m[2] ?? "{}") ?? {} }));
|
|
779
|
-
if (call)
|
|
780
|
-
found.push({ index: m.index, call });
|
|
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
|
-
}
|
|
796
|
-
found.sort((a, b) => a.index - b.index);
|
|
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);
|
|
808
|
-
}
|
|
809
|
-
/** Structural equality for two tool calls (name + canonical args JSON). */
|
|
810
|
-
export function sameToolCall(a, b) {
|
|
811
|
-
if (a.name !== b.name)
|
|
812
|
-
return false;
|
|
813
|
-
try {
|
|
814
|
-
return JSON.stringify(a.args) === JSON.stringify(b.args);
|
|
815
|
-
}
|
|
816
|
-
catch {
|
|
817
|
-
return false;
|
|
818
|
-
}
|
|
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
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* Build the conversation to hand back to the caller at turn end. Strips system
|
|
850
|
-
* prompts (they're re-added each turn) but keeps the user turn plus every
|
|
851
|
-
* assistant tool-call and tool result, then appends the final answer if it
|
|
852
|
-
* isn't already the last message. Persisting this is what lets a resumed
|
|
853
|
-
* session give the model back what it actually did — commands, outputs, and
|
|
854
|
-
* results — instead of only its prose answers.
|
|
855
|
-
*/
|
|
856
|
-
export function buildTurnHistory(messages, answer) {
|
|
857
|
-
// Drop system messages (the main prompt, plan context, and reflections are
|
|
858
|
-
// all re-injected each turn) EXCEPT compacted session memory, which is the
|
|
859
|
-
// only record of summarized older turns and must survive a resume.
|
|
860
|
-
const convo = messages.filter((m) => m.role !== "system" || isCompactionMemoryMessage(m));
|
|
861
|
-
const last = convo[convo.length - 1];
|
|
862
|
-
if (answer &&
|
|
863
|
-
!(last && last.role === "assistant" && last.content === answer)) {
|
|
864
|
-
convo.push({ role: "assistant", content: answer });
|
|
865
|
-
}
|
|
866
|
-
return convo;
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* Collapse pathological repetition before a message is stored in history.
|
|
870
|
-
* Some models degenerate into emitting the same short phrase hundreds of
|
|
871
|
-
* times ("We need to wait.We need to wait.…"), which otherwise bloats the
|
|
872
|
-
* context window and wastes tokens on every subsequent turn. We keep a few
|
|
873
|
-
* copies and note the collapse so the meaning is preserved without the bulk.
|
|
874
|
-
*/
|
|
875
|
-
export function collapseRepeatedText(text) {
|
|
876
|
-
if (!text || text.length < 1500)
|
|
877
|
-
return text;
|
|
878
|
-
try {
|
|
879
|
-
return text.replace(/(.{3,80}?)\1{6,}/gs, (match, unit) => `${unit.repeat(3)} …[repeated ~${Math.round(match.length / Math.max(1, unit.length))}× — collapsed]`);
|
|
880
|
-
}
|
|
881
|
-
catch {
|
|
882
|
-
return text;
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
/** Extract the text before the tool call block for display purposes */
|
|
886
|
-
function textBeforeToolCall(text) {
|
|
887
|
-
const patterns = [
|
|
888
|
-
/```tool\s*\n?[\s\S]*$/i,
|
|
889
|
-
/<tool_call>[\s\S]*$/i,
|
|
890
|
-
// Kimi/Moonshot sentinel block — strip from the section opener
|
|
891
|
-
// (or the first call opener if the section header is missing).
|
|
892
|
-
/<\|tool_calls_section_begin\|>[\s\S]*$/i,
|
|
893
|
-
/<\|tool_call_begin\|>[\s\S]*$/i,
|
|
894
|
-
/#{1,3}\s*tool\s*\n\s*\{[\s\S]*$/i,
|
|
895
|
-
/\*\*tool\*\*\s*\n\s*\{[\s\S]*$/i,
|
|
896
|
-
/```\w*\s*\n?\{[\s\S]*?"name"[\s\S]*$/i,
|
|
897
|
-
/\{"name"\s*:\s*"[^"]+"\s*,\s*"args"\s*:\s*\{[\s\S]*$/i,
|
|
898
|
-
];
|
|
899
|
-
for (const pattern of patterns) {
|
|
900
|
-
const idx = text.search(pattern);
|
|
901
|
-
if (idx >= 0) {
|
|
902
|
-
return text.slice(0, idx).trim();
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
return text.trim();
|
|
906
|
-
}
|
|
907
|
-
export function formatToolArgs(call) {
|
|
908
|
-
if (call.name === "shell.exec")
|
|
909
|
-
return String(call.args.command ?? "");
|
|
910
|
-
if (call.name === "net.scan")
|
|
911
|
-
return `${call.args.target ?? ""}${call.args.ports ? ` -p ${call.args.ports}` : ""}${call.args.flags ? ` ${call.args.flags}` : ""}`;
|
|
912
|
-
if (call.name === "pentest.recon")
|
|
913
|
-
return String(call.args.target ?? "");
|
|
914
|
-
if (call.name === "dns.lookup")
|
|
915
|
-
return `${call.args.target ?? ""}${call.args.record ? ` ${call.args.record}` : " A"}`;
|
|
916
|
-
if (call.name === "whois.lookup")
|
|
917
|
-
return String(call.args.target ?? "");
|
|
918
|
-
if (call.name === "fs.read" || call.name === "fs.write")
|
|
919
|
-
return String(call.args.path ?? "");
|
|
920
|
-
if (call.name === "fs.writeMany") {
|
|
921
|
-
const files = Array.isArray(call.args.files) ? call.args.files : [];
|
|
922
|
-
const names = files
|
|
923
|
-
.map((f) => f && typeof f === "object"
|
|
924
|
-
? String(f.path ?? "")
|
|
925
|
-
: "")
|
|
926
|
-
.filter(Boolean);
|
|
927
|
-
const preview = names.slice(0, 4).join(", ");
|
|
928
|
-
return `${names.length} file(s)${preview ? `: ${preview}${names.length > 4 ? ", …" : ""}` : ""}`;
|
|
929
|
-
}
|
|
930
|
-
if (call.name === "fs.search")
|
|
931
|
-
return String(call.args.pattern ?? "");
|
|
932
|
-
if (call.name === "image.ocr" || call.name === "pdf.read")
|
|
933
|
-
return String(call.args.path ?? "");
|
|
934
|
-
if (call.name === "http.fetch" || call.name === "web.fetch")
|
|
935
|
-
return String(call.args.url ?? "");
|
|
936
|
-
if (call.name === "web.search")
|
|
937
|
-
return String(call.args.query ?? "");
|
|
938
|
-
if (call.name === "pkg.install")
|
|
939
|
-
return String(call.args.tool ?? "");
|
|
940
|
-
if (call.name === "fs.list")
|
|
941
|
-
return String(call.args.path ?? safeCwd());
|
|
942
|
-
return JSON.stringify(call.args);
|
|
943
|
-
}
|
|
944
|
-
const VOLATILE_SIGNAL_RE = /\b(?:current(?:ly)?|latest|newest|today|now|right now|live|recent|breaking|news|release[sd]?|version|prices?|stocks?|market|rates?|weather|forecast|elections?|results?|rankings?|standings?|stats?|cve|advis(?:ory|ories)|vulnerabilit(?:y|ies))\b/i;
|
|
945
|
-
const VOLATILE_ROLE_QUERY_RE = /\b(?:who(?:\s+is|'s)?|whos|name|tell\s+me|what(?:\s+is|'s)?)\b[\s\S]{0,120}\b(?:cm|chief\s+minister|prime\s+minister|president|governor|mayor|ministers?|cabinet|leader|head\s+of|ceo|cto|cfo|coo|chair(?:man|woman|person)?|coach|captain)\b/i;
|
|
946
|
-
const ROLE_OF_ENTITY_RE = /\b(?:cm|chief\s+minister|prime\s+minister|president|governor|mayor|ministers?|ceo|cto|cfo|coo|chair(?:man|woman|person)?|coach|captain)\s+(?:of|for|in)\b/i;
|
|
947
|
-
const EXPLICIT_WEB_LOOKUP_RE = /\b(?:search\s+(?:the\s+)?(?:web|internet|online)|look\s*up|google|verify\s+(?:online|on\s+the\s+web)|check\s+(?:online|the\s+web|internet))\b/i;
|
|
948
|
-
const STATIC_DISAMBIGUATION_RE = /\b(?:stand\s+for|stands\s+for|meaning|definition|define|abbreviation|centimeters?|centimetres?)\b/i;
|
|
949
|
-
const LOCAL_RUNTIME_RE = /\b(?:current\s+(?:directory|dir|cwd|working\s+directory|folder|path|user|shell|process(?:es)?|branch|git\s+branch|network|ip|interfaces?|working\s+tree)|pwd|whoami)\b/i;
|
|
950
|
-
// Signals that the current turn is (or continues) a coding / scaffolding
|
|
951
|
-
// task. These are intentionally broad — over-budgeting a build is cheap
|
|
952
|
-
// (the loop still stops as soon as the model gives a final answer) while
|
|
953
|
-
// under-budgeting silently truncates a half-built project.
|
|
954
|
-
const BUILD_TASK_RE = /\b(?:build|create|scaffold|generate|make|set\s*up|setup|bootstrap|init(?:ialize)?|implement|add|write|develop|code|refactor|migrate|convert|wire\s*up|integrate)\b[\s\S]{0,80}\b(?:app|application|project|site|website|web\s*app|server|api|service|component|page|module|feature|cli|script|library|package|frontend|backend|fullstack|game|bot|dashboard|form|endpoint|database|schema|test|tests|suite)\b/i;
|
|
955
|
-
const BUILD_STACK_RE = /\b(?:react|next(?:\.?js)?|vue|svelte|angular|vite|webpack|express|fastify|nest(?:js)?|django|flask|fastapi|rails|laravel|spring|node(?:\.?js)?|typescript|tailwind|redux|prisma|mongoose|graphql|docker|kubernetes)\b/i;
|
|
956
|
-
// Pentest / security keywords — these tasks are inherently multi-step and
|
|
957
|
-
// always deserve the full step budget, just like build tasks.
|
|
958
|
-
const PENTEST_TASK_RE = /\b(?:pentest|pen[\s-]?test|penetration|security\s*(?:test|audit|scan|assess)|csrf|xss|sqli|sql[\s-]?inject|rce|lfi|rfi|ssrf|idor|xxe|brute[\s-]?force|enumerat|exploit|vulnerabilit|recon|bug[\s-]?bounty|ctf|capture[\s-]?the[\s-]?flag|red[\s-]?team|offensive|nmap|nikto|nuclei|ffuf|gobuster|sqlmap|hydra|metasploit)\b/i;
|
|
959
|
-
/**
|
|
960
|
-
* Detect pentest/security tasks that need the full step budget.
|
|
961
|
-
* Mirrors looksLikeBuildTask but for security work.
|
|
962
|
-
*/
|
|
963
|
-
export function looksLikePentestTask(prompt, history) {
|
|
964
|
-
if (PENTEST_TASK_RE.test(prompt))
|
|
965
|
-
return true;
|
|
966
|
-
if (history && history.length > 0) {
|
|
967
|
-
const recent = history.slice(-6);
|
|
968
|
-
for (const msg of recent) {
|
|
969
|
-
if (msg.role === "user" && PENTEST_TASK_RE.test(msg.content))
|
|
970
|
-
return true;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return false;
|
|
974
|
-
}
|
|
975
|
-
// Short continuation prompts that, on their own, carry no build signal but
|
|
976
|
-
// clearly mean "keep going with what we were doing".
|
|
977
|
-
const CONTINUATION_RE = /^(?:do\s+it|build\s+it|build\s+fully|build\s+it\s+fully|go\s+ahead|continue|proceed|keep\s+going|finish(?:\s+it)?|complete(?:\s+it)?|yes|ok(?:ay)?|make\s+it|run\s+it|next|on\s+your\s+own|build\s+(?:fully\s+)?on\s+your\s+own)\b/i;
|
|
978
|
-
const INCOMPLETE_RE = /\b(?:not\s+complete|incomplete|isn'?t\s+(?:done|complete|working|finished)|doesn'?t\s+work|still\s+(?:broken|missing|failing)|missing\s+(?:files?|parts?)|finish\s+(?:the|it)|complete\s+(?:the|it))\b/i;
|
|
979
|
-
// The synthetic message injected when the user runs /implement to approve a
|
|
980
|
-
// plan ("I approve the plan. Execute it now, task by task…"). It must always
|
|
981
|
-
// count as a build/continuation turn — it contains the word "now", which
|
|
982
|
-
// would otherwise trip the volatile-info freshness guard and divert the run
|
|
983
|
-
// into a pointless web.search instead of executing the plan.
|
|
984
|
-
const PLAN_EXECUTION_RE = /\b(?:approve the plan|execute it (?:now|task by task)|task by task|execute the plan|implement the plan)\b/i;
|
|
985
|
-
// Informational / comparison / explanation intent. These questions want an
|
|
986
|
-
// ANSWER, not a build — even when they mention a framework or an install
|
|
987
|
-
// step (e.g. "compare installation steps in react vite", "how do I set up
|
|
988
|
-
// tailwind", "tailwind 3 vs 4"). They must NOT trigger the explore→plan
|
|
989
|
-
// build workflow.
|
|
990
|
-
const INFORMATIONAL_SIGNAL_RE = /\b(?:compare|comparison|contrast|differ(?:ence|ences|s)?|pros\s+and\s+cons|trade-?offs?|versus|vs\.?|cheat\s*sheet|explain|describe|summari[sz]e|overview)\b/i;
|
|
991
|
-
const INTERROGATIVE_LEAD_RE = /^(?:what|which|why|how|when|who|where|is|are|do|does|did|can|could|should|would|will)\b/i;
|
|
992
|
-
/**
|
|
993
|
-
* Does a single message imply an actual build/scaffold task (as opposed to a
|
|
994
|
-
* question about one)? Comparison/explanation signals and plain questions are
|
|
995
|
-
* treated as informational and return false even when they name a stack.
|
|
996
|
-
*/
|
|
997
|
-
function messageImpliesBuild(text) {
|
|
998
|
-
if (!text)
|
|
999
|
-
return false;
|
|
1000
|
-
if (INFORMATIONAL_SIGNAL_RE.test(text))
|
|
1001
|
-
return false;
|
|
1002
|
-
// Explicit "build/create/scaffold … <thing>" is always a build.
|
|
1003
|
-
if (BUILD_TASK_RE.test(text))
|
|
1004
|
-
return true;
|
|
1005
|
-
// A bare question (interrogative lead or trailing "?") that merely mentions
|
|
1006
|
-
// a stack is informational, not a build.
|
|
1007
|
-
if (text.endsWith("?") || INTERROGATIVE_LEAD_RE.test(text))
|
|
1008
|
-
return false;
|
|
1009
|
-
return BUILD_STACK_RE.test(text);
|
|
1010
|
-
}
|
|
1011
|
-
/**
|
|
1012
|
-
* Decide whether this turn should get the build workflow (explore → plan →
|
|
1013
|
-
* implement) and a generous step budget. Looks at the current prompt first,
|
|
1014
|
-
* then falls back to recent USER turns so a terse follow-up inherits an
|
|
1015
|
-
* ongoing build — but NOT the agent's own (possibly mistaken) plan narration.
|
|
1016
|
-
*/
|
|
1017
|
-
export function looksLikeBuildTask(prompt, history) {
|
|
1018
|
-
const text = prompt.replace(/\s+/g, " ").trim();
|
|
1019
|
-
// Continuation / "not done yet" / plan-execution always count as build.
|
|
1020
|
-
if (CONTINUATION_RE.test(text) ||
|
|
1021
|
-
INCOMPLETE_RE.test(text) ||
|
|
1022
|
-
PLAN_EXECUTION_RE.test(text)) {
|
|
1023
|
-
return true;
|
|
1024
|
-
}
|
|
1025
|
-
if (messageImpliesBuild(text)) {
|
|
1026
|
-
return true;
|
|
1027
|
-
}
|
|
1028
|
-
// Inspect recent USER turns only: if the user was already building
|
|
1029
|
-
// something, treat a terse follow-up as part of that build. (Assistant
|
|
1030
|
-
// turns are excluded so a misfired plan can't keep re-triggering build.)
|
|
1031
|
-
if (history && history.length > 0) {
|
|
1032
|
-
const recent = history.slice(-6);
|
|
1033
|
-
for (const msg of recent) {
|
|
1034
|
-
if (msg.role !== "user")
|
|
1035
|
-
continue;
|
|
1036
|
-
if (messageImpliesBuild(msg.content.replace(/\s+/g, " ").trim())) {
|
|
1037
|
-
return true;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
return false;
|
|
1042
|
-
}
|
|
1043
|
-
// Matrix of action-verb narration: the model says it is *about to* do
|
|
1044
|
-
// something but hasn't. Used to detect "narrate, don't act" stalls.
|
|
1045
|
-
const ACTION_NARRATION_RE = /\b(?:let me|let's|i'?ll|i will|i'?m going to|i am going to|i need to|i should|i'?m about to|going to|now i'?ll|first[,]?\s*i'?ll)\s+(?:now\s+|first\s+|quickly\s+|just\s+|go\s+ahead\s+and\s+)?(?:explore|list|read|fetch|browse|check|inspect|examine|look|create|run|start|write|build|add|scaffold|set\s*up|setup|install|initialize|init|generate|make|review|open|find|search|verify|update|edit|modify|fix|implement|gather|assess|scan|audit)\b/i;
|
|
1046
|
-
/**
|
|
1047
|
-
* Detect a message that narrates an *upcoming* action ("let me explore the
|
|
1048
|
-
* directory", "I'll create the components") rather than an actual answer or
|
|
1049
|
-
* tool call. Used to catch models that describe intent but emit no tool call,
|
|
1050
|
-
* which would otherwise end the turn with nothing done. A real completion
|
|
1051
|
-
* summary (past tense, longer, or containing a code block) is NOT flagged.
|
|
1052
|
-
*/
|
|
1053
|
-
export function looksLikeActionNarration(text) {
|
|
1054
|
-
const t = text.trim();
|
|
1055
|
-
if (t.length === 0 || t.length > 600)
|
|
1056
|
-
return false;
|
|
1057
|
-
if (t.includes("```"))
|
|
1058
|
-
return false;
|
|
1059
|
-
return ACTION_NARRATION_RE.test(t);
|
|
1060
|
-
}
|
|
1061
|
-
/**
|
|
1062
|
-
* Detect a message that narrates a PLAN as prose ("Goal: … Tasks: 1. … Please
|
|
1063
|
-
* approve the plan") instead of calling plan.create. Such a turn leaves no
|
|
1064
|
-
* real plan, so the user can't /implement it — we nudge the model to emit the
|
|
1065
|
-
* plan.create tool call instead.
|
|
1066
|
-
*/
|
|
1067
|
-
export function looksLikePlanNarration(text) {
|
|
1068
|
-
const t = text.trim();
|
|
1069
|
-
if (t.length < 40)
|
|
1070
|
-
return false;
|
|
1071
|
-
const approval = /\b(?:approve|approval|once approved|request changes|await(?:ing)?\s+(?:your\s+)?approval)\b/i.test(t);
|
|
1072
|
-
const goal = /\bgoal\b/i.test(t);
|
|
1073
|
-
const tasks = /\b(?:tasks?|steps?)\b/i.test(t) ||
|
|
1074
|
-
/(?:^|\n)\s*(?:t?1[.)]|step\s*1)\b/im.test(t);
|
|
1075
|
-
return approval || (goal && tasks);
|
|
1076
|
-
}
|
|
1077
|
-
export function requiresFreshWebSearch(prompt) {
|
|
1078
|
-
const text = prompt.replace(/\s+/g, " ").trim();
|
|
1079
|
-
if (!text)
|
|
1080
|
-
return false;
|
|
1081
|
-
if (STATIC_DISAMBIGUATION_RE.test(text) || LOCAL_RUNTIME_RE.test(text)) {
|
|
1082
|
-
return false;
|
|
1083
|
-
}
|
|
1084
|
-
// Plan-execution and terse continuation turns are never "fetch current
|
|
1085
|
-
// info" turns, even when they contain words like "now". (We intentionally
|
|
1086
|
-
// do NOT exclude on build-stack keywords here — "latest vite version" is a
|
|
1087
|
-
// legitimate version lookup. The runAgentLoop caller additionally gates the
|
|
1088
|
-
// guard on looksLikeBuildTask so a real scaffold turn never searches.)
|
|
1089
|
-
if (PLAN_EXECUTION_RE.test(text) || CONTINUATION_RE.test(text)) {
|
|
1090
|
-
return false;
|
|
1091
|
-
}
|
|
1092
|
-
return (VOLATILE_SIGNAL_RE.test(text) ||
|
|
1093
|
-
VOLATILE_ROLE_QUERY_RE.test(text) ||
|
|
1094
|
-
ROLE_OF_ENTITY_RE.test(text) ||
|
|
1095
|
-
EXPLICIT_WEB_LOOKUP_RE.test(text));
|
|
1096
|
-
}
|
|
1097
|
-
/**
|
|
1098
|
-
* Detect a low-quality "everything in one step" plan task. A single task that
|
|
1099
|
-
* itself enumerates many files/actions (multiple commas, an "and", several
|
|
1100
|
-
* slashes, or an overlong title) means the model lumped the whole build into
|
|
1101
|
-
* one checkbox instead of producing a real ordered checklist.
|
|
1102
|
-
*/
|
|
1103
|
-
export function isLumpedSingleTask(taskTitles) {
|
|
1104
|
-
if (taskTitles.length !== 1)
|
|
1105
|
-
return false;
|
|
1106
|
-
const only = taskTitles[0];
|
|
1107
|
-
return ((only.match(/,/g)?.length ?? 0) >= 2 ||
|
|
1108
|
-
/\band\b/i.test(only) ||
|
|
1109
|
-
(only.match(/\//g)?.length ?? 0) >= 2 ||
|
|
1110
|
-
only.length > 90);
|
|
1111
|
-
}
|
|
1112
|
-
function freshnessGuardMessage(now = new Date()) {
|
|
1113
|
-
return (`Freshness guard for this turn: the latest user prompt appears to ask for current, volatile, or externally verifiable information. The present moment is ${currentDateTimeContext(now)}. ` +
|
|
1114
|
-
"Before answering, call web.search FIRST with a concise query derived from the user prompt. " +
|
|
1115
|
-
"Shape the search query for the newest timeline by including current/latest or the current year/month when useful. " +
|
|
1116
|
-
"Do not answer from the snippets alone when detail matters — set fetchTop (e.g. fetchTop:2) to read the top result pages, or follow up with web.fetch on the most relevant URL, then answer from what the pages actually say and cite them. " +
|
|
1117
|
-
"If web.search fails or has no results, say that current information is unavailable instead of guessing from memory.");
|
|
1118
|
-
}
|
|
1119
|
-
/**
|
|
1120
|
-
* Directive injected for build/scaffold turns. Forces the careful
|
|
1121
|
-
* explore → understand → plan → implement loop instead of a one-shot dump,
|
|
1122
|
-
* and forbids stopping before the goal is reached.
|
|
1123
|
-
*/
|
|
1124
|
-
function buildWorkflowDirective() {
|
|
1125
|
-
return [
|
|
1126
|
-
"BUILD WORKFLOW (this is a build/scaffold/feature task — follow this order EXACTLY; deviation is a failure):",
|
|
1127
|
-
"1. EXPLORE: fs.list the working directory (and key subdirs) to see what already exists. Use tool.batch to parallelize reads.",
|
|
1128
|
-
"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.",
|
|
1129
|
-
"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.",
|
|
1130
|
-
"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.",
|
|
1131
|
-
"",
|
|
1132
|
-
"INITIALIZE WITH THE OFFICIAL SCAFFOLDER FIRST (do NOT hand-write build configs):",
|
|
1133
|
-
'- 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`.',
|
|
1134
|
-
"- 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.",
|
|
1135
|
-
"- 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.",
|
|
1136
|
-
'- 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.',
|
|
1137
|
-
"- 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.",
|
|
1138
|
-
"",
|
|
1139
|
-
"CRITICAL RULES during IMPLEMENTATION:",
|
|
1140
|
-
"- 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.",
|
|
1141
|
-
"- Do NOT re-explore. Step 1 (EXPLORE) was already completed during planning. Start executing the first pending task immediately.",
|
|
1142
|
-
"- ONE task at a time, in ORDER. Do NOT skip ahead to task 3 before task 2 is done.",
|
|
1143
|
-
"- 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.",
|
|
1144
|
-
"- VERIFY each step before marking it done: after writing files, fs.read the file back and confirm it is COMPLETE and syntactically valid (balanced braces/parens/JSX tags); after an install, check it exited 0. Marking a task done without a successful, verified tool call is the worst failure.",
|
|
1145
|
-
"- VERIFY THE BUILD, not just the dev server. `vite` / `npm run dev` reports 'ready' even when your App.jsx has syntax errors (the error only shows in the browser). To actually confirm the app works, run `npm run build` (it fails on real syntax/JSX errors) and check it exits 0. Seeing 'VITE ready' is NOT proof the app renders.",
|
|
1146
|
-
"- If a tool call FAILS (error output, non-zero exit, file missing), the task is NOT done. Mark it 'failed', diagnose WHY, fix it, and retry until it succeeds.",
|
|
1147
|
-
"- NEVER claim a task is done, files were created, a dependency is installed, or a server is running unless the tool call ACTUALLY succeeded and you saw the success output. If you have not run it, say so.",
|
|
1148
|
-
"- Start a dev server with shell.start (background job), NOT `npm run dev &` via shell.exec.",
|
|
1149
|
-
"- THE DELIVERABLE IS THE WORKING FEATURE, NOT THE SCAFFOLD. After scaffolding you MUST replace the starter boilerplate (Vite's default App.jsx counter, Next's starter page, etc.) with the actual app the user asked for. If the user asked for a todo app, src/App.jsx must contain a real todo UI with state — finishing with the untouched Vite starter page is a FAILURE even if the build passes.",
|
|
1150
|
-
"",
|
|
1151
|
-
"FORBIDDEN before plan approval (/implement): you MUST NOT use fs.write, fs.writeMany, fs.edit, shell.exec, shell.start, pkg.install, or pkg.uninstall. The ONLY tool allowed before approval is plan.create (and the read/list tools for exploration). If you are nudged to 'take action' before a plan exists, your action MUST be plan.create.",
|
|
1152
|
-
"If the task is genuinely trivial (a single tiny file), you may skip the plan — but for an app/feature, ALWAYS plan first.",
|
|
1153
|
-
].join("\n");
|
|
1154
|
-
}
|
|
1155
|
-
export function shouldDimToolChatter(call) {
|
|
1156
|
-
return call.name === "web.search";
|
|
1157
|
-
}
|
|
1158
|
-
/**
|
|
1159
|
-
* Re-assert raw mode AND resume stdin after an inquirer prompt
|
|
1160
|
-
* (confirm/password). inquirer's readline interface pauses stdin and
|
|
1161
|
-
* switches it to cooked mode when it closes; if we only flip raw mode back
|
|
1162
|
-
* on but leave stdin paused, no `keypress`/`data` events flow to the REPL's
|
|
1163
|
-
* ESC/Ctrl+C abort handler — so a long-running tool launched right after a
|
|
1164
|
-
* confirmation can no longer be aborted (the user had to kill the terminal).
|
|
1165
|
-
* Calling resume() restores the event flow.
|
|
1166
|
-
*/
|
|
1167
|
-
function restoreInteractiveStdin() {
|
|
1168
|
-
if (!process.stdin.isTTY)
|
|
1169
|
-
return;
|
|
1170
|
-
try {
|
|
1171
|
-
if (!process.stdin.isRaw) {
|
|
1172
|
-
process.stdin.setRawMode(true);
|
|
1173
|
-
}
|
|
1174
|
-
process.stdin.resume();
|
|
1175
|
-
}
|
|
1176
|
-
catch {
|
|
1177
|
-
/* ignore */
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
/**
|
|
1181
|
-
* Build a rich summary when the agent stops (user declined to continue or
|
|
1182
|
-
* maxIterations ceiling hit). Includes plan state, key findings, and clear
|
|
1183
|
-
* resume instructions so a later "continue" can pick up exactly here.
|
|
1184
|
-
*/
|
|
1185
|
-
async function buildRichStopSummary(messages, session, steps) {
|
|
1186
|
-
const plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1187
|
-
const parts = [];
|
|
1188
|
-
parts.push(`Session paused after ${steps} steps.\n`);
|
|
1189
|
-
// Plan state
|
|
1190
|
-
if (plan) {
|
|
1191
|
-
parts.push("## Plan Status");
|
|
1192
|
-
parts.push(`Goal: ${plan.goal}`);
|
|
1193
|
-
for (const task of plan.tasks) {
|
|
1194
|
-
const icon = task.state === "done"
|
|
1195
|
-
? "✓"
|
|
1196
|
-
: task.state === "in_progress"
|
|
1197
|
-
? "▶"
|
|
1198
|
-
: task.state === "failed"
|
|
1199
|
-
? "✗"
|
|
1200
|
-
: task.state === "skipped"
|
|
1201
|
-
? "↷"
|
|
1202
|
-
: "·";
|
|
1203
|
-
parts.push(` ${icon} [${task.id}] (${task.state}) ${task.title}${task.note ? ` — ${task.note}` : ""}`);
|
|
1204
|
-
}
|
|
1205
|
-
const next = plan.tasks.find((t) => t.state === "pending" || t.state === "in_progress");
|
|
1206
|
-
if (next) {
|
|
1207
|
-
parts.push(`\nNext task to resume: ${next.id} — "${next.title}"`);
|
|
1208
|
-
}
|
|
1209
|
-
const doneCount = plan.tasks.filter((t) => t.state === "done").length;
|
|
1210
|
-
parts.push(`\nProgress: ${doneCount}/${plan.tasks.length} tasks done.`);
|
|
1211
|
-
}
|
|
1212
|
-
// Key findings from tool results (last 20 tool messages)
|
|
1213
|
-
const toolMsgs = messages.filter((m) => m.role === "tool").slice(-20);
|
|
1214
|
-
if (toolMsgs.length > 0) {
|
|
1215
|
-
parts.push("\n## Key Findings So Far");
|
|
1216
|
-
for (const msg of toolMsgs) {
|
|
1217
|
-
const firstLine = msg.content.split("\n")[0] ?? "";
|
|
1218
|
-
// Extract tool name from the structured format "Tool <name> result ..."
|
|
1219
|
-
const toolMatch = firstLine.match(/^Tool (\S+) result/);
|
|
1220
|
-
if (toolMatch) {
|
|
1221
|
-
parts.push(`- ${toolMatch[1]}: ${firstLine.slice(0, 150)}`);
|
|
1222
|
-
}
|
|
1223
|
-
else {
|
|
1224
|
-
parts.push(`- ${firstLine.slice(0, 150)}`);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
parts.push("\n## To Resume");
|
|
1229
|
-
parts.push('Type "continue" to pick up from where this session left off.');
|
|
1230
|
-
return parts.join("\n");
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Tools allowed while an UN-approved plan is active. Before the user runs
|
|
1234
|
-
* /implement, the agent may only (re)create the plan and do read-only
|
|
1235
|
-
* exploration to refine it — never execute. Everything else is blocked by
|
|
1236
|
-
* the plan-awaiting-approval gate so a stray/recovered tool call can't start
|
|
1237
|
-
* running the plan, and so free-text after a plan is treated as a revision.
|
|
1238
|
-
*/
|
|
1239
|
-
const PRE_APPROVAL_ALLOWED_TOOLS = new Set([
|
|
1240
|
-
"plan.create",
|
|
1241
|
-
"task.update",
|
|
1242
|
-
"fs.read",
|
|
1243
|
-
"fs.list",
|
|
1244
|
-
"fs.search",
|
|
1245
|
-
"sysinfo",
|
|
1246
|
-
"tool.batch",
|
|
1247
|
-
"net.context",
|
|
1248
|
-
]);
|
|
1249
|
-
export function isPreApprovalAllowedTool(name) {
|
|
1250
|
-
return PRE_APPROVAL_ALLOWED_TOOLS.has(name);
|
|
1251
|
-
}
|
|
26
|
+
import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeActionNarration, looksLikePlanNarration, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, shouldDimToolChatter, } from "./tool-call-parser.js";
|
|
27
|
+
import { createSessionPolicy, isPreApprovalAllowedTool, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
|
|
28
|
+
import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
|
|
29
|
+
import { planContextMessage, handlePlanTool, } from "./plan-tool.js";
|
|
30
|
+
import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
|
|
31
|
+
import { buildRichStopSummary } from "./stop-summary.js";
|
|
32
|
+
// Re-exported so existing imports of these names from "./runner.js" keep
|
|
33
|
+
// working unchanged — the parsing/classification engine now lives in
|
|
34
|
+
// tool-call-parser.ts, and the session/plan/confirm/formatting helpers now
|
|
35
|
+
// live in their own dedicated modules.
|
|
36
|
+
export * from "./tool-call-parser.js";
|
|
37
|
+
export { createSessionPolicy, isPreApprovalAllowedTool, shouldEnableImageOcr, } from "./session-policy.js";
|
|
1252
38
|
export function styleToolChatter(call, text) {
|
|
1253
39
|
return shouldDimToolChatter(call) ? chalk.dim(text) : text;
|
|
1254
40
|
}
|
|
1255
|
-
function isAbortError(error, signal) {
|
|
1256
|
-
return (Boolean(signal?.aborted) ||
|
|
1257
|
-
(error instanceof Error && error.name === "AbortError"));
|
|
1258
|
-
}
|
|
1259
|
-
/** OCR is opt-in when real image pixels are already attached to the model. */
|
|
1260
|
-
export function shouldEnableImageOcr(prompt, hasAttachedImages) {
|
|
1261
|
-
if (!hasAttachedImages)
|
|
1262
|
-
return true;
|
|
1263
|
-
return /\b(?:ocr|optical character recognition|tesseract)\b/i.test(prompt);
|
|
1264
|
-
}
|
|
1265
|
-
function safeArtifactName(name) {
|
|
1266
|
-
return (name.replace(/[^a-z0-9_.-]+/gi, "-").replace(/^-+|-+$/g, "") ||
|
|
1267
|
-
"tool-output");
|
|
1268
|
-
}
|
|
1269
|
-
async function saveToolOutput(call, output) {
|
|
1270
|
-
if (!output.trim())
|
|
1271
|
-
return undefined;
|
|
1272
|
-
const dir = join(homedir(), ".clai", "outputs");
|
|
1273
|
-
try {
|
|
1274
|
-
await mkdir(dir, { recursive: true });
|
|
1275
|
-
await fixOwner(dir);
|
|
1276
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1277
|
-
const path = join(dir, `${stamp}-${safeArtifactName(call.name)}.txt`);
|
|
1278
|
-
await writeFile(path, `${output}\n`, "utf8");
|
|
1279
|
-
await fixOwner(path);
|
|
1280
|
-
return path;
|
|
1281
|
-
}
|
|
1282
|
-
catch (err) {
|
|
1283
|
-
handlePermissionError(err);
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
function summarizeOutput(output, maxChars = 8_000) {
|
|
1287
|
-
if (output.length <= maxChars)
|
|
1288
|
-
return { text: output, truncated: false };
|
|
1289
|
-
const lines = output.split(/\r?\n/);
|
|
1290
|
-
const head = [];
|
|
1291
|
-
const tail = [];
|
|
1292
|
-
let used = 0;
|
|
1293
|
-
const half = Math.floor(maxChars / 2);
|
|
1294
|
-
for (const line of lines) {
|
|
1295
|
-
const cost = line.length + 1;
|
|
1296
|
-
if (used + cost > half)
|
|
1297
|
-
break;
|
|
1298
|
-
head.push(line);
|
|
1299
|
-
used += cost;
|
|
1300
|
-
}
|
|
1301
|
-
used = 0;
|
|
1302
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1303
|
-
const line = lines[index];
|
|
1304
|
-
const cost = line.length + 1;
|
|
1305
|
-
if (used + cost > half)
|
|
1306
|
-
break;
|
|
1307
|
-
tail.unshift(line);
|
|
1308
|
-
used += cost;
|
|
1309
|
-
}
|
|
1310
|
-
return {
|
|
1311
|
-
text: [
|
|
1312
|
-
...head,
|
|
1313
|
-
`... (${lines.length.toLocaleString()} output lines truncated) ...`,
|
|
1314
|
-
...tail,
|
|
1315
|
-
].join("\n"),
|
|
1316
|
-
truncated: true,
|
|
1317
|
-
};
|
|
1318
|
-
}
|
|
1319
|
-
// Tools whose output is the actual content the model needs verbatim (file
|
|
1320
|
-
// bodies, listings, search hits). Running these through the security-signal
|
|
1321
|
-
// `genericReducer` was wrong: it ranks lines by pentest keywords and drops
|
|
1322
|
-
// the rest, so source code came back as a fragmentary head+tail — the model
|
|
1323
|
-
// saw a "truncated" file and kept re-reading it in wasted retries. For these
|
|
1324
|
-
// we pass the raw content through (up to a generous cap) and point the model
|
|
1325
|
-
// at the saved artifact when it exceeds the cap.
|
|
1326
|
-
const PASSTHROUGH_TOOLS = new Set([
|
|
1327
|
-
"fs.read",
|
|
1328
|
-
"fs.list",
|
|
1329
|
-
"fs.search",
|
|
1330
|
-
"fs.edit",
|
|
1331
|
-
"pdf.read",
|
|
1332
|
-
]);
|
|
1333
|
-
const PASSTHROUGH_CAP_CHARS = 400_000;
|
|
1334
|
-
// web.fetch/http.fetch pull in arbitrary third-party pages/API responses that
|
|
1335
|
-
// can be hundreds of KB (e.g. a large OpenAPI spec). Unlike local files the
|
|
1336
|
-
// model asked to read, this content is never bounded by the user's own
|
|
1337
|
-
// project, so it must be capped like every other tool's context output —
|
|
1338
|
-
// otherwise a single fetch can single-handedly blow the context budget and
|
|
1339
|
-
// starve the model of room to actually respond (observed as empty/garbled
|
|
1340
|
-
// completions on smaller-context-window models after a big fetch).
|
|
1341
|
-
const WEB_FETCH_CAP_CHARS = 20_000;
|
|
1342
|
-
function formatToolContext(call, result) {
|
|
1343
|
-
const output = result.output.trim();
|
|
1344
|
-
if (!output)
|
|
1345
|
-
return "";
|
|
1346
|
-
if (call.name === "web.fetch" || call.name === "http.fetch") {
|
|
1347
|
-
const { text, truncated } = summarizeOutput(output, WEB_FETCH_CAP_CHARS);
|
|
1348
|
-
if (!truncated)
|
|
1349
|
-
return text;
|
|
1350
|
-
const saved = result.outputPath
|
|
1351
|
-
? `\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.]`
|
|
1352
|
-
: `\n\n[Response exceeds ${WEB_FETCH_CAP_CHARS.toLocaleString()} chars; only head and tail shown.]`;
|
|
1353
|
-
return `${text}${saved}`.trim();
|
|
1354
|
-
}
|
|
1355
|
-
// Pass-through tools: never reduce — the model needs the real content.
|
|
1356
|
-
if (PASSTHROUGH_TOOLS.has(call.name)) {
|
|
1357
|
-
const { text, truncated } = summarizeOutput(output, PASSTHROUGH_CAP_CHARS);
|
|
1358
|
-
if (!truncated)
|
|
1359
|
-
return text;
|
|
1360
|
-
const saved = result.outputPath
|
|
1361
|
-
? `\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.]`
|
|
1362
|
-
: `\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.]`;
|
|
1363
|
-
return `${text}${saved}`.trim();
|
|
1364
|
-
}
|
|
1365
|
-
let reduced;
|
|
1366
|
-
try {
|
|
1367
|
-
const command = call.name === "shell.exec" ? String(call.args.command ?? "") : call.name;
|
|
1368
|
-
const policy = reduceToolOutput(output, {
|
|
1369
|
-
toolName: call.name,
|
|
1370
|
-
command,
|
|
1371
|
-
});
|
|
1372
|
-
reduced = policy.summary.trim();
|
|
1373
|
-
}
|
|
1374
|
-
catch {
|
|
1375
|
-
reduced = undefined;
|
|
1376
|
-
}
|
|
1377
|
-
// Hard cap on the reduced text — reducers should already be small, but
|
|
1378
|
-
// never let one accidentally explode model context.
|
|
1379
|
-
const base = reduced && reduced.length > 0 ? reduced : output;
|
|
1380
|
-
const summary = summarizeOutput(base, 8_000);
|
|
1381
|
-
const saved = result.outputPath
|
|
1382
|
-
? `\nFull output saved to: ${result.outputPath}`
|
|
1383
|
-
: "";
|
|
1384
|
-
return `${summary.text}${saved}`.trim();
|
|
1385
|
-
}
|
|
1386
|
-
const inquirerConfirmPort = {
|
|
1387
|
-
async confirmTool(call) {
|
|
1388
|
-
return confirm({
|
|
1389
|
-
message: chalk.yellow(` run ${call.name}: ${formatToolArgs(call)}?`),
|
|
1390
|
-
default: true,
|
|
1391
|
-
});
|
|
1392
|
-
},
|
|
1393
|
-
async confirmPentest() {
|
|
1394
|
-
return confirm({
|
|
1395
|
-
message: chalk.red("clai only assists with security testing on systems you own or have written permission to test. Confirm for this session?"),
|
|
1396
|
-
default: false,
|
|
1397
|
-
});
|
|
1398
|
-
},
|
|
1399
|
-
async confirmContinue(steps) {
|
|
1400
|
-
return confirm({
|
|
1401
|
-
message: chalk.yellow(` ${steps} steps reached — continue?`),
|
|
1402
|
-
default: true,
|
|
1403
|
-
});
|
|
1404
|
-
},
|
|
1405
|
-
async confirmAgentSwitch(info) {
|
|
1406
|
-
const tools = info.tools.length > 0 ? ` (${info.tools.join(", ")})` : "";
|
|
1407
|
-
return confirm({
|
|
1408
|
-
message: chalk.yellow(` this needs agent mode${tools} — switch and run it?`),
|
|
1409
|
-
default: true,
|
|
1410
|
-
});
|
|
1411
|
-
},
|
|
1412
|
-
};
|
|
1413
|
-
async function ensurePentestAuthorization(call, autoConfirm, session, confirmPort) {
|
|
1414
|
-
if (!isPentestToolCall(call))
|
|
1415
|
-
return true;
|
|
1416
|
-
// Persistent auth (via `clai authorize-pentest AGREE`) wins.
|
|
1417
|
-
if (getConfig().pentestAuthorized)
|
|
1418
|
-
return true;
|
|
1419
|
-
// Session auth flipped earlier in this session — no re-prompt.
|
|
1420
|
-
if (session.pentestAuthorized.value)
|
|
1421
|
-
return true;
|
|
1422
|
-
if (autoConfirm) {
|
|
1423
|
-
// -y is session-scoped only. We do NOT touch the persistent config so
|
|
1424
|
-
// a one-shot `-y` cannot silently authorize later interactive runs.
|
|
1425
|
-
session.pentestAuthorized.value = true;
|
|
1426
|
-
return true;
|
|
1427
|
-
}
|
|
1428
|
-
const ok = await confirmPort.confirmPentest();
|
|
1429
|
-
if (!ok)
|
|
1430
|
-
return false;
|
|
1431
|
-
session.pentestAuthorized.value = true;
|
|
1432
|
-
return true;
|
|
1433
|
-
}
|
|
1434
|
-
async function confirmToolExecution(call, autoConfirm, session, confirmPort) {
|
|
1435
|
-
const config = getConfig();
|
|
1436
|
-
if (autoConfirm)
|
|
1437
|
-
return true;
|
|
1438
|
-
if (session.allow.has(call.name))
|
|
1439
|
-
return true;
|
|
1440
|
-
// Persistent allowlist kept for backwards compat with users who set it
|
|
1441
|
-
// through `clai config` directly, but `/allow` only mutates the session
|
|
1442
|
-
// set so authorizations never leak across processes.
|
|
1443
|
-
if (config.allowAlwaysTools.includes(call.name))
|
|
1444
|
-
return true;
|
|
1445
|
-
return confirmPort.confirmTool(call);
|
|
1446
|
-
}
|
|
1447
|
-
/** Build the system-context block describing the session's active plan. */
|
|
1448
|
-
function planContextMessage(plan, approved) {
|
|
1449
|
-
const lines = [];
|
|
1450
|
-
lines.push(`ACTIVE PLAN for this session (goal: ${plan.goal}, status: ${plan.status}):`);
|
|
1451
|
-
if (plan.detail.trim())
|
|
1452
|
-
lines.push(plan.detail.trim());
|
|
1453
|
-
lines.push("Tasks:");
|
|
1454
|
-
plan.tasks.forEach((t, i) => {
|
|
1455
|
-
lines.push(` ${i + 1}. [${t.id}] (${t.state}) ${t.title}`);
|
|
1456
|
-
});
|
|
1457
|
-
if (approved) {
|
|
1458
|
-
const firstPending = plan.tasks.find((t) => t.state === "pending");
|
|
1459
|
-
lines.push("The user APPROVED this plan. Execute it task by task NOW.");
|
|
1460
|
-
if (firstPending) {
|
|
1461
|
-
lines.push(`START WITH TASK ${firstPending.id} (${firstPending.title}). ` +
|
|
1462
|
-
"Do NOT re-do tasks already marked done, and do NOT skip ahead to later tasks.");
|
|
1463
|
-
}
|
|
1464
|
-
lines.push("STRICT ORDER: call task.update {taskId, state:'in_progress'} → do the real work → " +
|
|
1465
|
-
"call task.update {taskId, state:'done'} ONLY after the tool calls actually succeed. " +
|
|
1466
|
-
"If a tool fails, mark the task 'failed' with a note, fix the problem, then retry. " +
|
|
1467
|
-
"Do NOT mark a task done when its commands error out. " +
|
|
1468
|
-
"Never claim something ran without a successful tool call.");
|
|
1469
|
-
}
|
|
1470
|
-
else {
|
|
1471
|
-
lines.push("This plan is NOT yet approved, so you MUST NOT execute any of its tasks yet. " +
|
|
1472
|
-
"Any new free-text message from the user right now is a PLAN REVISION, not approval — even if it " +
|
|
1473
|
-
"sounds like an instruction (e.g. 'do not install new tools', 'use only X', 'also add Y', 'skip task 2'). " +
|
|
1474
|
-
"Treat it as feedback: call plan.create AGAIN with the revised goal/detail/tasks to produce an updated " +
|
|
1475
|
-
"plan, then STOP and wait. Do NOT call shell.exec, pkg.install, net.scan, tool.check, fs.write, or any " +
|
|
1476
|
-
"other execution tool. The user will APPROVE with /implement, or CANCEL with /discard. Only after " +
|
|
1477
|
-
"/implement may you begin executing.");
|
|
1478
|
-
}
|
|
1479
|
-
return lines.join("\n");
|
|
1480
|
-
}
|
|
1481
|
-
/**
|
|
1482
|
-
* Handle plan.create / task.update inline. These are session-scoped and
|
|
1483
|
-
* persisted via the plan store so the user can view the plan (Ctrl+P) and
|
|
1484
|
-
* the agent keeps it in context across the whole session.
|
|
1485
|
-
*/
|
|
1486
|
-
async function handlePlanTool(call, session, ctx) {
|
|
1487
|
-
void ctx;
|
|
1488
|
-
if (call.name === "plan.create") {
|
|
1489
|
-
const goal = typeof call.args.goal === "string" ? call.args.goal : "";
|
|
1490
|
-
const detail = typeof call.args.detail === "string" ? call.args.detail : "";
|
|
1491
|
-
const kind = typeof call.args.kind === "string" ? call.args.kind : "general";
|
|
1492
|
-
const rawTasks = Array.isArray(call.args.tasks) ? call.args.tasks : [];
|
|
1493
|
-
const taskTitles = rawTasks
|
|
1494
|
-
.map((t) => (typeof t === "string" ? t : ""))
|
|
1495
|
-
.filter(Boolean);
|
|
1496
|
-
if (!goal || taskTitles.length === 0) {
|
|
1497
|
-
return {
|
|
1498
|
-
handled: true,
|
|
1499
|
-
ok: false,
|
|
1500
|
-
display: chalk.red(" ✗ plan.create needs a non-empty goal and at least one task title\n"),
|
|
1501
|
-
modelNote: "plan.create failed: provide a string goal and a non-empty tasks array of step titles.",
|
|
1502
|
-
};
|
|
1503
|
-
}
|
|
1504
|
-
// Reject a low-quality "everything in one step" plan. A single task that
|
|
1505
|
-
// itself enumerates many files/actions (commas, "and", slashes) is a sign
|
|
1506
|
-
// the model lumped the whole build into one checkbox — split it so the
|
|
1507
|
-
// user gets a real, trackable checklist and the executor works step by step.
|
|
1508
|
-
if (isLumpedSingleTask(taskTitles)) {
|
|
1509
|
-
return {
|
|
1510
|
-
handled: true,
|
|
1511
|
-
ok: false,
|
|
1512
|
-
display: chalk.red(" ✗ plan.create: that single task lumps the whole build into one step\n"),
|
|
1513
|
-
modelNote: "plan.create rejected: you put everything into ONE task. Break it into 3-8 SEPARATE, " +
|
|
1514
|
-
"ordered tasks — each a distinct action, e.g. 'scaffold package.json + vite config', " +
|
|
1515
|
-
"'create index.html + entry (main.jsx)', 'build App + Post components', 'add posts data + styles', " +
|
|
1516
|
-
"'install deps and run dev server to verify'. Call plan.create again with that tasks array.",
|
|
1517
|
-
};
|
|
1518
|
-
}
|
|
1519
|
-
const plan = createPlan({
|
|
1520
|
-
sessionId: session.sessionId,
|
|
1521
|
-
goal,
|
|
1522
|
-
detail,
|
|
1523
|
-
taskTitles,
|
|
1524
|
-
kind,
|
|
1525
|
-
});
|
|
1526
|
-
await savePlan(plan).catch(() => undefined);
|
|
1527
|
-
// A freshly (re)created plan resets approval — the user must /implement.
|
|
1528
|
-
session.planApproved.value = false;
|
|
1529
|
-
const checklist = renderPlanForTerminal(plan);
|
|
1530
|
-
const display = chalk.cyan(" ● planning\n") +
|
|
1531
|
-
checklist +
|
|
1532
|
-
"\n" +
|
|
1533
|
-
chalk.dim(" ✦ plan created — press Ctrl+P to view it, /implement to approve and run it,\n" +
|
|
1534
|
-
" or /discard to cancel it. Any other message refines this plan.\n");
|
|
1535
|
-
return {
|
|
1536
|
-
handled: true,
|
|
1537
|
-
ok: true,
|
|
1538
|
-
plan,
|
|
1539
|
-
display,
|
|
1540
|
-
modelNote: `Plan saved with ${plan.tasks.length} task(s). STOP here and wait — produce NO other tool calls now. ` +
|
|
1541
|
-
"Do NOT start executing tasks until the user approves with /implement. " +
|
|
1542
|
-
"If the user's next message gives feedback instead of /implement, that is a REVISION: call plan.create " +
|
|
1543
|
-
"again with the updated plan and STOP again. The user may cancel the whole plan with /discard. " +
|
|
1544
|
-
"Only after /implement do you begin, working task by task, calling task.update to mark each " +
|
|
1545
|
-
"in_progress before and done after you finish it.",
|
|
1546
|
-
};
|
|
1547
|
-
}
|
|
1548
|
-
// task.update
|
|
1549
|
-
const plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1550
|
-
if (!plan) {
|
|
1551
|
-
return {
|
|
1552
|
-
handled: true,
|
|
1553
|
-
ok: false,
|
|
1554
|
-
display: chalk.red(" ✗ task.update: no active plan — call plan.create first\n"),
|
|
1555
|
-
modelNote: "task.update failed: there is no active plan. Call plan.create first.",
|
|
1556
|
-
};
|
|
1557
|
-
}
|
|
1558
|
-
const taskId = typeof call.args.taskId === "string" ? call.args.taskId : "";
|
|
1559
|
-
const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
|
|
1560
|
-
const note = typeof call.args.note === "string" ? call.args.note : undefined;
|
|
1561
|
-
const validStates = [
|
|
1562
|
-
"pending",
|
|
1563
|
-
"in_progress",
|
|
1564
|
-
"done",
|
|
1565
|
-
"failed",
|
|
1566
|
-
"skipped",
|
|
1567
|
-
];
|
|
1568
|
-
if (!validStates.includes(stateRaw)) {
|
|
1569
|
-
return {
|
|
1570
|
-
handled: true,
|
|
1571
|
-
ok: false,
|
|
1572
|
-
display: chalk.red(` ✗ task.update: state must be one of ${validStates.join(", ")}\n`),
|
|
1573
|
-
modelNote: `task.update failed: state must be one of ${validStates.join(", ")}.`,
|
|
1574
|
-
};
|
|
1575
|
-
}
|
|
1576
|
-
// Only one task may be in_progress at a time. This forces genuine
|
|
1577
|
-
// task-by-task execution: the model must close (done/failed/skipped) the
|
|
1578
|
-
// current task before opening the next one, instead of leaving a task
|
|
1579
|
-
// "in_progress" as an umbrella while it quietly works through the rest
|
|
1580
|
-
// of the plan underneath it.
|
|
1581
|
-
if (stateRaw === "in_progress") {
|
|
1582
|
-
const otherInProgress = plan.tasks.find((t) => t.id !== taskId && t.state === "in_progress");
|
|
1583
|
-
if (otherInProgress) {
|
|
1584
|
-
return {
|
|
1585
|
-
handled: true,
|
|
1586
|
-
ok: false,
|
|
1587
|
-
display: chalk.red(` \u2717 task.update: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress\n`),
|
|
1588
|
-
modelNote: `task.update failed: task [${otherInProgress.id}] "${otherInProgress.title}" is still in_progress. ` +
|
|
1589
|
-
"Finish it first \u2014 call task.update with state 'done' (or 'failed'/'skipped' with a note) " +
|
|
1590
|
-
`for [${otherInProgress.id}] before starting [${taskId}].`,
|
|
1591
|
-
};
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
const ok = markTask(plan, taskId, stateRaw, note);
|
|
1595
|
-
if (!ok) {
|
|
1596
|
-
const ids = plan.tasks.map((t) => t.id).join(", ");
|
|
1597
|
-
return {
|
|
1598
|
-
handled: true,
|
|
1599
|
-
ok: false,
|
|
1600
|
-
display: chalk.red(` ✗ task.update: unknown taskId "${taskId}" (have: ${ids})\n`),
|
|
1601
|
-
modelNote: `task.update failed: unknown taskId. Valid ids: ${ids}.`,
|
|
1602
|
-
};
|
|
1603
|
-
}
|
|
1604
|
-
if (plan.status === "draft" || plan.status === "approved") {
|
|
1605
|
-
plan.status = "in_progress";
|
|
1606
|
-
}
|
|
1607
|
-
const allDone = plan.tasks.every((t) => t.state === "done" || t.state === "skipped" || t.state === "failed");
|
|
1608
|
-
if (allDone)
|
|
1609
|
-
plan.status = "completed";
|
|
1610
|
-
await savePlan(plan).catch(() => undefined);
|
|
1611
|
-
const checklist = renderPlanForTerminal(plan);
|
|
1612
|
-
return {
|
|
1613
|
-
handled: true,
|
|
1614
|
-
ok: true,
|
|
1615
|
-
plan,
|
|
1616
|
-
display: checklist + "\n",
|
|
1617
|
-
modelNote: allDone
|
|
1618
|
-
? "Task updated. ALL tasks are now finished. Verify the result and give your final summary."
|
|
1619
|
-
: "Task updated. Continue with the next pending task.",
|
|
1620
|
-
};
|
|
1621
|
-
}
|
|
1622
41
|
export async function runAgentLoop(prompt, options = {}) {
|
|
1623
42
|
const writesDirectly = !options.onEvent;
|
|
1624
43
|
const emit = (event) => options.onEvent?.(event);
|
|
@@ -1746,7 +165,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1746
165
|
let model = options.model ?? config.defaultModel;
|
|
1747
166
|
let lastAnswer = "";
|
|
1748
167
|
const session = options.session ?? createSessionPolicy();
|
|
1749
|
-
//
|
|
168
|
+
// Active plan context
|
|
1750
169
|
// If this session already has a plan, inject it so the model keeps it in
|
|
1751
170
|
// context. When the user has approved it (via /implement) we instruct the
|
|
1752
171
|
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
@@ -1817,7 +236,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1817
236
|
// as a final answer. Bounded so a model that truly can't emit the format
|
|
1818
237
|
// still terminates.
|
|
1819
238
|
let actionIntentRetries = 0;
|
|
1820
|
-
//
|
|
239
|
+
// Multi-tool execution queue
|
|
1821
240
|
// Models naturally emit several tool calls in one message — e.g. the
|
|
1822
241
|
// plan-execution rhythm "task.update in_progress → do the work →
|
|
1823
242
|
// task.update done", or a batch of fs.write calls. Rather than running
|
|
@@ -1828,7 +247,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1828
247
|
// cleared whenever a call fails, is blocked, or needs the model to react,
|
|
1829
248
|
// so the model always sees errors and stays in control.
|
|
1830
249
|
let pendingCalls = [];
|
|
1831
|
-
//
|
|
250
|
+
// Step budget
|
|
1832
251
|
// The budget governs how many *productive* steps (a tool execution or a
|
|
1833
252
|
// final answer) the agent may take. Recovery iterations — nudging a model
|
|
1834
253
|
// that only produced thinking, asking it to re-emit a malformed tool call,
|
|
@@ -1947,7 +366,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1947
366
|
blockOrCancel: true,
|
|
1948
367
|
};
|
|
1949
368
|
}
|
|
1950
|
-
//
|
|
369
|
+
// Task-scoped execution gate
|
|
1951
370
|
// Once a plan is approved, every non-plan tool call must run while
|
|
1952
371
|
// exactly one task is "in_progress". This stops a model from batching
|
|
1953
372
|
// tool calls for many/all tasks in one turn and only touching task
|
|
@@ -2244,7 +663,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2244
663
|
}
|
|
2245
664
|
return { ok: result.ok, call, result, contextOutput };
|
|
2246
665
|
}
|
|
2247
|
-
//
|
|
666
|
+
// Automatic context compaction
|
|
2248
667
|
// As a long turn accumulates tool outputs and reasoning, the context can
|
|
2249
668
|
// grow past what the model can hold. We proactively summarize the older
|
|
2250
669
|
// turns into a single continuation memory (the SAME model-written summary
|
|
@@ -2323,7 +742,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2323
742
|
// `step` is the productive-step index (used for display + audit). It only
|
|
2324
743
|
// advances when the previous iteration actually executed a tool.
|
|
2325
744
|
step = productiveSteps;
|
|
2326
|
-
//
|
|
745
|
+
// Step budget gate: ask the user instead of hard-stopping
|
|
2327
746
|
if (productiveSteps >= stepBudget) {
|
|
2328
747
|
const askContinue = confirmPort.confirmContinue ?? inquirerConfirmPort.confirmContinue;
|
|
2329
748
|
let shouldContinue = false;
|
|
@@ -2498,7 +917,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2498
917
|
writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
|
|
2499
918
|
}
|
|
2500
919
|
}
|
|
2501
|
-
//
|
|
920
|
+
// Empty-response recovery
|
|
2502
921
|
// Some models occasionally return an empty completion: a reasoning
|
|
2503
922
|
// model that spent its whole budget on hidden <think> reasoning and emitted
|
|
2504
923
|
// no visible text, OR (more perniciously) a gateway hiccup that
|
|
@@ -2665,7 +1084,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2665
1084
|
// Normal final-answer path: strip any stray sentinel tokens that
|
|
2666
1085
|
// somehow leaked into prose so the answer renders cleanly.
|
|
2667
1086
|
const cleaned = stripSentinelTokens(assistantText.visible);
|
|
2668
|
-
//
|
|
1087
|
+
// Act, don't narrate
|
|
2669
1088
|
// Build/scaffold/plan turns must DO something. If the model returns
|
|
2670
1089
|
// prose with NO tool call, it is narrating intent ("Let me first
|
|
2671
1090
|
// explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
|
|
@@ -2744,7 +1163,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2744
1163
|
});
|
|
2745
1164
|
continue;
|
|
2746
1165
|
}
|
|
2747
|
-
//
|
|
1166
|
+
// Premature-completion guard (approved plan still has work)
|
|
2748
1167
|
// If the user approved a plan and the model now gives a final answer
|
|
2749
1168
|
// while tasks are still pending/in_progress — without having run the
|
|
2750
1169
|
// work — it is fabricating completion (the exact "all tasks completed,
|
|
@@ -2828,7 +1247,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2828
1247
|
if (allCalls.length > 1) {
|
|
2829
1248
|
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`));
|
|
2830
1249
|
}
|
|
2831
|
-
//
|
|
1250
|
+
// Scoped-parallel batch execution
|
|
2832
1251
|
// The model may emit several calls in one message. We partition them,
|
|
2833
1252
|
// IN DOCUMENT ORDER, into segments:
|
|
2834
1253
|
// • A run of consecutive READ-ONLY, safe-classified calls (the same
|