@semalt-ai/code 1.8.1 → 1.8.3
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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +582 -121
- package/lib/api.js +182 -25
- package/lib/commands.js +57 -80
- package/lib/config.js +32 -4
- package/lib/constants.js +51 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +93 -86
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +405 -192
- package/lib/ui/ansi.js +13 -1
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +75 -57
- package/lib/ui/status-bar.js +53 -23
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
package/lib/tools.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const fsp = require('fs/promises');
|
|
4
5
|
const http = require('http');
|
|
5
6
|
const https = require('https');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const path = require('path');
|
|
8
|
-
const {
|
|
9
|
+
const { spawn } = require('child_process');
|
|
9
10
|
|
|
10
11
|
const { logToolCall } = require('./audit');
|
|
11
12
|
|
|
12
13
|
const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
|
|
13
14
|
|
|
14
15
|
const _dryRun = process.argv.includes('--dry-run');
|
|
16
|
+
const _allowAnywhere = process.argv.includes('--allow-anywhere');
|
|
15
17
|
const _skippedOps = [];
|
|
16
18
|
function getSkippedOps() { return _skippedOps.slice(); }
|
|
17
19
|
|
|
@@ -19,15 +21,62 @@ function getSkippedOps() { return _skippedOps.slice(); }
|
|
|
19
21
|
// handles all tool-status display via onToolEnd callbacks instead.
|
|
20
22
|
let _uiActive = false;
|
|
21
23
|
function setUIActive(v) { _uiActive = v; }
|
|
24
|
+
function isUIActive() { return _uiActive; }
|
|
22
25
|
function _log(...args) { if (!_uiActive) console.log(...args); }
|
|
23
26
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// Reject writes outside the project CWD and in sensitive system/home dirs
|
|
28
|
+
// (~/.ssh, ~/.aws, ~/.gnupg, /etc, /boot, /sys, /proc). Override with
|
|
29
|
+
// --allow-anywhere when an agent genuinely needs to touch outside paths.
|
|
30
|
+
function isPathSafe(filePath) {
|
|
31
|
+
if (_allowAnywhere) return true;
|
|
32
|
+
if (typeof filePath !== 'string' || !filePath) return false;
|
|
33
|
+
const resolved = path.resolve(filePath);
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
const banned = [
|
|
36
|
+
path.join(home, '.ssh') + path.sep,
|
|
37
|
+
path.join(home, '.aws') + path.sep,
|
|
38
|
+
path.join(home, '.gnupg') + path.sep,
|
|
39
|
+
'/etc/',
|
|
40
|
+
'/boot/',
|
|
41
|
+
'/sys/',
|
|
42
|
+
'/proc/',
|
|
43
|
+
];
|
|
44
|
+
for (const b of banned) {
|
|
45
|
+
if (resolved === b.slice(0, -1) || resolved.startsWith(b)) return false;
|
|
46
|
+
}
|
|
47
|
+
const cwd = process.cwd();
|
|
48
|
+
const cwdPrefix = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
|
|
49
|
+
return resolved === cwd || resolved.startsWith(cwdPrefix);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _sandboxError(filePath) {
|
|
53
|
+
return { error: `Path outside allowed area: ${filePath}. Use --allow-anywhere to override.` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cheap ReDoS guard. Rejects pathologically long patterns, common
|
|
57
|
+
// catastrophic-backtracking anti-patterns, and pattern×data sizes large
|
|
58
|
+
// enough to hang the regex engine.
|
|
59
|
+
function _checkRegexSafety(pattern, data) {
|
|
60
|
+
if (typeof pattern !== 'string') return null;
|
|
61
|
+
if (pattern.length > 1000) {
|
|
62
|
+
return { error: 'Pattern rejected: length exceeds 1000 chars' };
|
|
63
|
+
}
|
|
64
|
+
if (/(\(.*[+*].*\).*[+*])|(\[.*\].*[+*].*[+*])/.test(pattern)) {
|
|
65
|
+
return { error: 'Pattern rejected: potentially catastrophic backtracking' };
|
|
66
|
+
}
|
|
67
|
+
const dataLen = typeof data === 'string' ? data.length : 0;
|
|
68
|
+
if (dataLen * pattern.length > 10_000_000) {
|
|
69
|
+
return { error: 'Pattern too complex for input size' };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
28
73
|
|
|
29
74
|
function createToolExecutor(permissionManager, ui, getConfig) {
|
|
30
75
|
const { BOLD, DIM, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, renderDiff } = ui;
|
|
76
|
+
// Continuation lines in a system-message bubble (chat-history.js else branch)
|
|
77
|
+
// are indented by 5 spaces. Let the diff renderer reserve those columns so
|
|
78
|
+
// its lines don't auto-wrap inside the bubble.
|
|
79
|
+
const DIFF_BUBBLE_INSET = 5;
|
|
31
80
|
|
|
32
81
|
function _parseNumberedOptions(text) {
|
|
33
82
|
const options = [];
|
|
@@ -52,20 +101,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
52
101
|
return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
|
|
53
102
|
}
|
|
54
103
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
104
|
+
const cfg = getConfig ? getConfig() : {};
|
|
105
|
+
const timeout = cfg.command_timeout_ms || 30000;
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
let child;
|
|
109
|
+
try {
|
|
110
|
+
child = spawn(command, { shell: true });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
113
|
+
logToolCall('exec', { command }, true, 'error');
|
|
114
|
+
return resolve({ exit_code: -1, stdout: '', stderr: error.message });
|
|
115
|
+
}
|
|
116
|
+
let stdout = '';
|
|
117
|
+
let stderr = '';
|
|
118
|
+
let killed = false;
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
killed = true;
|
|
121
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
122
|
+
}, timeout);
|
|
123
|
+
child.stdout.setEncoding('utf8');
|
|
124
|
+
child.stderr.setEncoding('utf8');
|
|
125
|
+
child.stdout.on('data', (c) => { stdout += c; });
|
|
126
|
+
child.stderr.on('data', (c) => { stderr += c; });
|
|
127
|
+
child.on('error', (error) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
130
|
+
logToolCall('exec', { command }, true, 'error');
|
|
131
|
+
resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
|
|
132
|
+
});
|
|
133
|
+
child.on('close', (code, signal) => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
|
|
136
|
+
const exit_code = killed ? -1 : (code != null ? code : (signal ? -1 : 0));
|
|
137
|
+
logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
|
|
138
|
+
resolve({ exit_code, stdout, stderr });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
69
141
|
}
|
|
70
142
|
|
|
71
143
|
async function agentExecFile(action, ...args) {
|
|
@@ -73,8 +145,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
73
145
|
|
|
74
146
|
if (action === 'read') {
|
|
75
147
|
const filePath = arg0;
|
|
76
|
-
|
|
77
|
-
|
|
148
|
+
const stat = await fsp.stat(filePath).catch(() => null);
|
|
149
|
+
if (stat) {
|
|
78
150
|
const cfg = getConfig ? getConfig() : {};
|
|
79
151
|
const maxBytes = (cfg.max_file_size_kb || 512) * 1024;
|
|
80
152
|
if (stat.size > maxBytes) {
|
|
@@ -82,16 +154,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
82
154
|
logToolCall('read_file', { path: filePath }, false, 'error');
|
|
83
155
|
return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
|
|
84
156
|
}
|
|
85
|
-
} catch {
|
|
86
|
-
// file doesn't exist yet — readFileSync will report it
|
|
87
|
-
}
|
|
88
|
-
const approved = await permissionManager.askPermission('file', `Read ${filePath}`, 'read_file');
|
|
89
|
-
if (!approved) {
|
|
90
|
-
logToolCall('read_file', { path: filePath }, false, 'denied');
|
|
91
|
-
return { error: 'Permission denied' };
|
|
92
157
|
}
|
|
93
158
|
try {
|
|
94
|
-
const data =
|
|
159
|
+
const data = await fsp.readFile(filePath, 'utf8');
|
|
95
160
|
const lines = data.split('\n').length;
|
|
96
161
|
if (lines > 10) {
|
|
97
162
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
|
|
@@ -118,9 +183,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
118
183
|
return blocked;
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
if (!isPathSafe(filePath)) {
|
|
187
|
+
logToolCall(tag, { path: filePath }, false, 'denied');
|
|
188
|
+
return _sandboxError(filePath);
|
|
189
|
+
}
|
|
190
|
+
|
|
121
191
|
// Read existing content for diff display
|
|
122
192
|
let existing = '';
|
|
123
|
-
try { existing =
|
|
193
|
+
try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
|
|
124
194
|
|
|
125
195
|
// For append the final state is existing + new content
|
|
126
196
|
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
@@ -129,7 +199,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
129
199
|
// collide with the live chat-history/status-bar redraw, so we route the
|
|
130
200
|
// diff into the permission description instead (where it renders inside
|
|
131
201
|
// the permission bubble and is safely truncated by MAX_DESC_LINES).
|
|
132
|
-
const diffOutput =
|
|
202
|
+
const diffOutput = _uiActive
|
|
203
|
+
? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
|
|
204
|
+
: renderDiff(existing, finalContent, filePath);
|
|
133
205
|
if (!_uiActive) process.stdout.write(diffOutput + '\n');
|
|
134
206
|
|
|
135
207
|
// Dry-run: record the skipped op and return without writing
|
|
@@ -151,9 +223,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
151
223
|
}
|
|
152
224
|
try {
|
|
153
225
|
const dir = path.dirname(filePath);
|
|
154
|
-
if (dir && dir !== '.')
|
|
155
|
-
if (action === 'write')
|
|
156
|
-
else
|
|
226
|
+
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
227
|
+
if (action === 'write') await fsp.writeFile(filePath, content || '');
|
|
228
|
+
else await fsp.appendFile(filePath, content || '');
|
|
157
229
|
const verb = action === 'write' ? 'Wrote' : 'Appended to';
|
|
158
230
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
|
|
159
231
|
logToolCall(tag, { path: filePath, content }, true, 'ok');
|
|
@@ -167,13 +239,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
167
239
|
|
|
168
240
|
if (action === 'list_dir') {
|
|
169
241
|
const dirPath = arg0;
|
|
170
|
-
const approved = await permissionManager.askPermission('file', `List ${dirPath}`, 'list_dir');
|
|
171
|
-
if (!approved) {
|
|
172
|
-
logToolCall('list_dir', { path: dirPath }, false, 'denied');
|
|
173
|
-
return { error: 'Permission denied' };
|
|
174
|
-
}
|
|
175
242
|
try {
|
|
176
|
-
const entries =
|
|
243
|
+
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
177
244
|
const items = entries.map((e) => {
|
|
178
245
|
if (e.isSymbolicLink()) return `[L] ${e.name}`;
|
|
179
246
|
if (e.isDirectory()) return `[D] ${e.name}`;
|
|
@@ -198,6 +265,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
198
265
|
return blocked;
|
|
199
266
|
}
|
|
200
267
|
|
|
268
|
+
if (!isPathSafe(filePath)) {
|
|
269
|
+
logToolCall('delete_file', { path: filePath }, false, 'denied');
|
|
270
|
+
return _sandboxError(filePath);
|
|
271
|
+
}
|
|
272
|
+
|
|
201
273
|
_log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
|
|
202
274
|
|
|
203
275
|
const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
|
|
@@ -206,7 +278,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
206
278
|
return { error: 'Permission denied' };
|
|
207
279
|
}
|
|
208
280
|
try {
|
|
209
|
-
|
|
281
|
+
await fsp.unlink(filePath);
|
|
210
282
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
|
|
211
283
|
logToolCall('delete_file', { path: filePath }, true, 'ok');
|
|
212
284
|
return { status: 'ok', path: filePath };
|
|
@@ -219,13 +291,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
219
291
|
|
|
220
292
|
if (action === 'make_dir') {
|
|
221
293
|
const dirPath = arg0;
|
|
294
|
+
if (!isPathSafe(dirPath)) {
|
|
295
|
+
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
296
|
+
return _sandboxError(dirPath);
|
|
297
|
+
}
|
|
222
298
|
const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
|
|
223
299
|
if (!approved) {
|
|
224
300
|
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
225
301
|
return { error: 'Permission denied' };
|
|
226
302
|
}
|
|
227
303
|
try {
|
|
228
|
-
|
|
304
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
229
305
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
|
|
230
306
|
logToolCall('make_dir', { path: dirPath }, true, 'ok');
|
|
231
307
|
return { status: 'ok', path: dirPath };
|
|
@@ -238,13 +314,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
238
314
|
|
|
239
315
|
if (action === 'remove_dir') {
|
|
240
316
|
const dirPath = arg0;
|
|
317
|
+
if (!isPathSafe(dirPath)) {
|
|
318
|
+
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
319
|
+
return _sandboxError(dirPath);
|
|
320
|
+
}
|
|
241
321
|
const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
|
|
242
322
|
if (!approved) {
|
|
243
323
|
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
244
324
|
return { error: 'Permission denied' };
|
|
245
325
|
}
|
|
246
326
|
try {
|
|
247
|
-
|
|
327
|
+
await fsp.rm(dirPath, { recursive: true, force: true });
|
|
248
328
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
|
|
249
329
|
logToolCall('remove_dir', { path: dirPath }, true, 'ok');
|
|
250
330
|
return { status: 'ok', path: dirPath };
|
|
@@ -265,6 +345,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
265
345
|
return blocked;
|
|
266
346
|
}
|
|
267
347
|
|
|
348
|
+
if (!isPathSafe(dst)) {
|
|
349
|
+
logToolCall('move_file', { src, dst }, false, 'denied');
|
|
350
|
+
return _sandboxError(dst);
|
|
351
|
+
}
|
|
352
|
+
|
|
268
353
|
_log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
|
|
269
354
|
|
|
270
355
|
const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
|
|
@@ -274,14 +359,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
274
359
|
}
|
|
275
360
|
try {
|
|
276
361
|
const dstDir = path.dirname(dst);
|
|
277
|
-
if (dstDir && dstDir !== '.')
|
|
362
|
+
if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
|
|
278
363
|
try {
|
|
279
|
-
|
|
364
|
+
await fsp.rename(src, dst);
|
|
280
365
|
} catch (renameErr) {
|
|
281
366
|
if (renameErr.code !== 'EXDEV') throw renameErr;
|
|
282
367
|
// Cross-device rename not supported — copy then remove
|
|
283
|
-
|
|
284
|
-
|
|
368
|
+
await fsp.cp(src, dst, { recursive: true });
|
|
369
|
+
await fsp.rm(src, { recursive: true, force: true });
|
|
285
370
|
}
|
|
286
371
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
|
|
287
372
|
logToolCall('move_file', { src, dst }, true, 'ok');
|
|
@@ -303,6 +388,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
303
388
|
return blocked;
|
|
304
389
|
}
|
|
305
390
|
|
|
391
|
+
if (!isPathSafe(dst)) {
|
|
392
|
+
logToolCall('copy_file', { src, dst }, false, 'denied');
|
|
393
|
+
return _sandboxError(dst);
|
|
394
|
+
}
|
|
395
|
+
|
|
306
396
|
const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
|
|
307
397
|
if (!approved) {
|
|
308
398
|
logToolCall('copy_file', { src, dst }, false, 'denied');
|
|
@@ -310,8 +400,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
310
400
|
}
|
|
311
401
|
try {
|
|
312
402
|
const dstDir = path.dirname(dst);
|
|
313
|
-
if (dstDir && dstDir !== '.')
|
|
314
|
-
|
|
403
|
+
if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
|
|
404
|
+
await fsp.cp(src, dst, { recursive: true });
|
|
315
405
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Copied ${src} → ${dst}${RST}`);
|
|
316
406
|
logToolCall('copy_file', { src, dst }, true, 'ok');
|
|
317
407
|
return { status: 'ok', src, dst };
|
|
@@ -332,14 +422,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
332
422
|
return { error: 'Permission denied' };
|
|
333
423
|
}
|
|
334
424
|
try {
|
|
335
|
-
const data =
|
|
425
|
+
const data = await fsp.readFile(filePath, 'utf8');
|
|
336
426
|
const lines = data.split('\n');
|
|
337
427
|
if (lineNum < 1 || lineNum > lines.length) {
|
|
338
428
|
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
|
|
339
429
|
return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
|
|
340
430
|
}
|
|
341
431
|
lines[lineNum - 1] = newContent;
|
|
342
|
-
|
|
432
|
+
await fsp.writeFile(filePath, lines.join('\n'));
|
|
343
433
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
|
|
344
434
|
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
|
|
345
435
|
return { status: 'ok', path: filePath, line: lineNum };
|
|
@@ -353,13 +443,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
353
443
|
if (action === 'search_in_file') {
|
|
354
444
|
const filePath = arg0;
|
|
355
445
|
const pattern = arg1;
|
|
356
|
-
const approved = await permissionManager.askPermission('file', `Search in ${filePath}`, 'search_in_file');
|
|
357
|
-
if (!approved) {
|
|
358
|
-
logToolCall('search_in_file', { path: filePath, pattern }, false, 'denied');
|
|
359
|
-
return { error: 'Permission denied' };
|
|
360
|
-
}
|
|
361
446
|
try {
|
|
362
|
-
const data =
|
|
447
|
+
const data = await fsp.readFile(filePath, 'utf8');
|
|
448
|
+
const guardErr = _checkRegexSafety(pattern, data);
|
|
449
|
+
if (guardErr) {
|
|
450
|
+
logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
|
|
451
|
+
return guardErr;
|
|
452
|
+
}
|
|
363
453
|
const regex = new RegExp(pattern);
|
|
364
454
|
const matches = data.split('\n')
|
|
365
455
|
.map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
|
|
@@ -385,13 +475,18 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
385
475
|
return { error: 'Permission denied' };
|
|
386
476
|
}
|
|
387
477
|
try {
|
|
388
|
-
const data =
|
|
478
|
+
const data = await fsp.readFile(filePath, 'utf8');
|
|
479
|
+
const guardErr = _checkRegexSafety(searchStr, data);
|
|
480
|
+
if (guardErr) {
|
|
481
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
482
|
+
return guardErr;
|
|
483
|
+
}
|
|
389
484
|
const safeFlags = flags.replace(/[^gimsuy]/g, '');
|
|
390
485
|
const countFlags = safeFlags.includes('g') ? safeFlags : safeFlags + 'g';
|
|
391
486
|
const count = (data.match(new RegExp(searchStr, countFlags)) || []).length;
|
|
392
487
|
const regex = new RegExp(searchStr, safeFlags || undefined);
|
|
393
488
|
const newData = data.replace(regex, replaceStr);
|
|
394
|
-
|
|
489
|
+
await fsp.writeFile(filePath, newData);
|
|
395
490
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
|
|
396
491
|
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
|
|
397
492
|
return { status: 'ok', path: filePath, count };
|
|
@@ -405,11 +500,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
405
500
|
if (action === 'search_files') {
|
|
406
501
|
const pattern = arg0;
|
|
407
502
|
const searchDir = arg1 || '.';
|
|
408
|
-
const approved = await permissionManager.askPermission('file', `Search files: ${pattern} in ${searchDir}`, 'search_files');
|
|
409
|
-
if (!approved) {
|
|
410
|
-
logToolCall('search_files', { pattern, dir: searchDir }, false, 'denied');
|
|
411
|
-
return { error: 'Permission denied' };
|
|
412
|
-
}
|
|
413
503
|
try {
|
|
414
504
|
let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
415
505
|
regStr = regStr.replace(/\*\*/g, '\x00');
|
|
@@ -419,16 +509,16 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
419
509
|
const regex = new RegExp(`^${regStr}$`);
|
|
420
510
|
const matchName = !pattern.includes('/');
|
|
421
511
|
const files = [];
|
|
422
|
-
function walk(dir, rel) {
|
|
512
|
+
async function walk(dir, rel) {
|
|
423
513
|
let entries;
|
|
424
|
-
try { entries =
|
|
514
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
425
515
|
for (const entry of entries) {
|
|
426
516
|
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
427
517
|
if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
|
|
428
|
-
if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
|
|
518
|
+
if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
|
|
429
519
|
}
|
|
430
520
|
}
|
|
431
|
-
walk(searchDir, '');
|
|
521
|
+
await walk(searchDir, '');
|
|
432
522
|
files.sort();
|
|
433
523
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
|
|
434
524
|
logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
|
|
@@ -442,13 +532,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
442
532
|
|
|
443
533
|
if (action === 'file_stat') {
|
|
444
534
|
const filePath = arg0;
|
|
445
|
-
const approved = await permissionManager.askPermission('file', `Stat ${filePath}`, 'file_stat');
|
|
446
|
-
if (!approved) {
|
|
447
|
-
logToolCall('file_stat', { path: filePath }, false, 'denied');
|
|
448
|
-
return { error: 'Permission denied' };
|
|
449
|
-
}
|
|
450
535
|
try {
|
|
451
|
-
const stat =
|
|
536
|
+
const stat = await fsp.stat(filePath);
|
|
452
537
|
const type = stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file';
|
|
453
538
|
const size_kb = (stat.size / 1024).toFixed(2);
|
|
454
539
|
const mode = '0o' + stat.mode.toString(8);
|
|
@@ -474,7 +559,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
474
559
|
if (action === 'set_env') {
|
|
475
560
|
const varName = arg0;
|
|
476
561
|
const value = arg1 || '';
|
|
477
|
-
const approved = await permissionManager.askPermission('
|
|
562
|
+
const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
|
|
478
563
|
if (!approved) {
|
|
479
564
|
logToolCall('set_env', { name: varName }, false, 'denied');
|
|
480
565
|
return { error: 'Permission denied' };
|
|
@@ -499,7 +584,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
499
584
|
fileName = 'download';
|
|
500
585
|
}
|
|
501
586
|
const outPath = path.join(process.cwd(), fileName);
|
|
502
|
-
const approved = await permissionManager.askPermission('
|
|
587
|
+
const approved = await permissionManager.askPermission('net', `Download ${url}`, 'download');
|
|
503
588
|
if (!approved) {
|
|
504
589
|
logToolCall('download', { url }, false, 'denied');
|
|
505
590
|
return { error: 'Permission denied' };
|
|
@@ -554,6 +639,10 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
554
639
|
if (action === 'upload') {
|
|
555
640
|
const filePath = arg0;
|
|
556
641
|
const encodedContent = arg1 || '';
|
|
642
|
+
if (!isPathSafe(filePath)) {
|
|
643
|
+
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
644
|
+
return _sandboxError(filePath);
|
|
645
|
+
}
|
|
557
646
|
const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
|
|
558
647
|
if (!approved) {
|
|
559
648
|
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
@@ -561,9 +650,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
561
650
|
}
|
|
562
651
|
try {
|
|
563
652
|
const dir = path.dirname(filePath);
|
|
564
|
-
if (dir && dir !== '.')
|
|
653
|
+
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
565
654
|
const buffer = Buffer.from(encodedContent.trim(), 'base64');
|
|
566
|
-
|
|
655
|
+
await fsp.writeFile(filePath, buffer);
|
|
567
656
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Uploaded ${buffer.length} bytes to ${filePath}${RST}`);
|
|
568
657
|
logToolCall('upload', { path: filePath }, true, 'ok');
|
|
569
658
|
return { status: 'ok', path: filePath, bytes: buffer.length };
|
|
@@ -574,69 +663,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
574
663
|
}
|
|
575
664
|
}
|
|
576
665
|
|
|
577
|
-
function buildHttpResult(url, statusCode, body, raw) {
|
|
578
|
-
// Strip HTML markup so the LLM receives readable text instead of raw HTML.
|
|
579
|
-
// Reduces a typical 150k-char page to ~25-40k chars, cutting chunk count
|
|
580
|
-
// from ~16 to ~3 and preventing context-overflow re-fetch loops.
|
|
581
|
-
// Pass raw=true to skip stripping (e.g. when the agent needs to parse markup).
|
|
582
|
-
const looksLikeHtml = !raw && (/^\s*<!doctype\s+html/i.test(body) || /^\s*<html[\s>]/i.test(body));
|
|
583
|
-
const processedBody = looksLikeHtml
|
|
584
|
-
? body
|
|
585
|
-
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
586
|
-
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
587
|
-
.replace(/<[^>]+>/g, ' ')
|
|
588
|
-
.replace(/ /gi, ' ')
|
|
589
|
-
.replace(/&/gi, '&')
|
|
590
|
-
.replace(/</gi, '<')
|
|
591
|
-
.replace(/>/gi, '>')
|
|
592
|
-
.replace(/"/gi, '"')
|
|
593
|
-
.replace(/&#x?[\da-f]+;/gi, ' ')
|
|
594
|
-
.replace(/\s{2,}/g, ' ')
|
|
595
|
-
.trim()
|
|
596
|
-
: body;
|
|
597
|
-
|
|
598
|
-
if (processedBody.length > HTTP_CHUNK_CHARS) {
|
|
599
|
-
const chunks = [];
|
|
600
|
-
for (let i = 0; i < processedBody.length; i += HTTP_CHUNK_CHARS) {
|
|
601
|
-
chunks.push(processedBody.slice(i, i + HTTP_CHUNK_CHARS));
|
|
602
|
-
}
|
|
603
|
-
_httpChunkStore.set(url, { chunks: chunks.slice(1), total: chunks.length, delivered: 1 });
|
|
604
|
-
return { status_code: statusCode, body: chunks[0], chunked: true, part: 1, total_parts: chunks.length, key: url };
|
|
605
|
-
}
|
|
606
|
-
_httpChunkStore.delete(url);
|
|
607
|
-
return { status_code: statusCode, body: processedBody };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
if (action === 'http_get_next') {
|
|
611
|
-
const key = arg0;
|
|
612
|
-
const store = _httpChunkStore.get(key);
|
|
613
|
-
if (!store || store.chunks.length === 0) {
|
|
614
|
-
_httpChunkStore.delete(key);
|
|
615
|
-
return { key, body: '', part: null, total_parts: null, done: true };
|
|
616
|
-
}
|
|
617
|
-
const nextChunk = store.chunks[0];
|
|
618
|
-
store.chunks = store.chunks.slice(1);
|
|
619
|
-
store.delivered += 1;
|
|
620
|
-
const done = store.chunks.length === 0;
|
|
621
|
-
if (done) _httpChunkStore.delete(key);
|
|
622
|
-
return { key, body: nextChunk, part: store.delivered, total_parts: store.total, done };
|
|
623
|
-
}
|
|
624
|
-
|
|
625
666
|
if (action === 'http_get') {
|
|
626
667
|
const url = arg0;
|
|
627
|
-
const rawHtml = arg1 === 'true';
|
|
628
668
|
if (_dryRun) {
|
|
629
669
|
_skippedOps.push({ category: 'net', symbol: '↓', desc: `GET ${url}` });
|
|
630
670
|
logToolCall('http_get', { url }, false, 'dry-run');
|
|
631
671
|
return { status: 'dry-run', message: 'dry-run: network call skipped' };
|
|
632
672
|
}
|
|
633
|
-
const approved = await permissionManager.askPermission('
|
|
673
|
+
const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
|
|
634
674
|
if (!approved) {
|
|
635
675
|
logToolCall('http_get', { url }, false, 'denied');
|
|
636
676
|
return { error: 'Permission denied' };
|
|
637
677
|
}
|
|
638
678
|
const httpCfg = getConfig ? getConfig() : {};
|
|
639
679
|
const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
|
|
680
|
+
const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
|
|
640
681
|
return new Promise((resolve) => {
|
|
641
682
|
function doGet(target, redirectsLeft) {
|
|
642
683
|
const proto = target.startsWith('https') ? https : http;
|
|
@@ -645,13 +686,36 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
645
686
|
res.resume();
|
|
646
687
|
return doGet(res.headers.location, redirectsLeft - 1);
|
|
647
688
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
689
|
+
const bufs = [];
|
|
690
|
+
let totalBytes = 0;
|
|
691
|
+
let capped = false;
|
|
692
|
+
res.on('data', (chunk) => {
|
|
693
|
+
totalBytes += chunk.length;
|
|
694
|
+
if (!capped) {
|
|
695
|
+
if (totalBytes <= maxBytes) {
|
|
696
|
+
bufs.push(chunk);
|
|
697
|
+
} else {
|
|
698
|
+
const keep = maxBytes - (totalBytes - chunk.length);
|
|
699
|
+
if (keep > 0) bufs.push(chunk.slice(0, keep));
|
|
700
|
+
capped = true;
|
|
701
|
+
// Keep the connection draining so totalBytes reflects reality,
|
|
702
|
+
// but stop buffering further bytes.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
651
706
|
res.on('end', () => {
|
|
652
|
-
|
|
707
|
+
const kept = Buffer.concat(bufs);
|
|
708
|
+
const keptBytes = kept.length;
|
|
709
|
+
let body = kept.toString('utf8');
|
|
710
|
+
if (capped) {
|
|
711
|
+
const origKb = (totalBytes / 1024).toFixed(0);
|
|
712
|
+
const keptKb = (keptBytes / 1024).toFixed(0);
|
|
713
|
+
const droppedKb = ((totalBytes - keptBytes) / 1024).toFixed(0);
|
|
714
|
+
body += `\n\n[... truncated: original was ${origKb}KB, showing first ${keptKb}KB. The remaining ${droppedKb}KB was discarded. If you need the rest, narrow your request (e.g. fetch a specific subpage) rather than retrying this URL.]`;
|
|
715
|
+
}
|
|
716
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${totalBytes} bytes${capped ? `, truncated to ${keptBytes}` : ''})${RST}`);
|
|
653
717
|
logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
|
|
654
|
-
resolve(
|
|
718
|
+
resolve({ status_code: res.statusCode, body });
|
|
655
719
|
});
|
|
656
720
|
});
|
|
657
721
|
req.on('error', (err) => {
|
|
@@ -671,7 +735,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
671
735
|
|
|
672
736
|
if (action === 'ask_user') {
|
|
673
737
|
const question = arg0;
|
|
674
|
-
const approved = await permissionManager.askPermission('
|
|
738
|
+
const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
|
|
675
739
|
if (!approved) {
|
|
676
740
|
logToolCall('ask_user', { question }, false, 'denied');
|
|
677
741
|
return { error: 'Permission denied' };
|
|
@@ -706,17 +770,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
706
770
|
if (action === 'store_memory') {
|
|
707
771
|
const key = arg0;
|
|
708
772
|
const value = arg1 || '';
|
|
709
|
-
const approved = await permissionManager.askPermission('
|
|
773
|
+
const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
|
|
710
774
|
if (!approved) {
|
|
711
775
|
logToolCall('store_memory', { key }, false, 'denied');
|
|
712
776
|
return { error: 'Permission denied' };
|
|
713
777
|
}
|
|
714
778
|
try {
|
|
715
779
|
let mem = {};
|
|
716
|
-
try { mem = JSON.parse(
|
|
780
|
+
try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
|
|
717
781
|
mem[key] = value;
|
|
718
|
-
|
|
719
|
-
|
|
782
|
+
await fsp.mkdir(path.dirname(MEMORY_PATH), { recursive: true });
|
|
783
|
+
await fsp.writeFile(MEMORY_PATH, JSON.stringify(mem, null, 2));
|
|
720
784
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Stored memory: ${key}${RST}`);
|
|
721
785
|
logToolCall('store_memory', { key }, true, 'ok');
|
|
722
786
|
return { status: 'ok', key };
|
|
@@ -729,14 +793,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
729
793
|
|
|
730
794
|
if (action === 'recall_memory') {
|
|
731
795
|
const key = arg0;
|
|
732
|
-
const approved = await permissionManager.askPermission('file', `Recall memory: ${key}`, 'recall_memory');
|
|
733
|
-
if (!approved) {
|
|
734
|
-
logToolCall('recall_memory', { key }, false, 'denied');
|
|
735
|
-
return { error: 'Permission denied' };
|
|
736
|
-
}
|
|
737
796
|
try {
|
|
738
797
|
let mem = {};
|
|
739
|
-
try { mem = JSON.parse(
|
|
798
|
+
try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
|
|
740
799
|
const found = key in mem;
|
|
741
800
|
const value = found ? mem[key] : null;
|
|
742
801
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Recalled memory: ${key}${RST}`);
|
|
@@ -750,14 +809,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
750
809
|
}
|
|
751
810
|
|
|
752
811
|
if (action === 'list_memories') {
|
|
753
|
-
const approved = await permissionManager.askPermission('file', 'List memories', 'list_memories');
|
|
754
|
-
if (!approved) {
|
|
755
|
-
logToolCall('list_memories', {}, false, 'denied');
|
|
756
|
-
return { error: 'Permission denied' };
|
|
757
|
-
}
|
|
758
812
|
try {
|
|
759
813
|
let mem = {};
|
|
760
|
-
try { mem = JSON.parse(
|
|
814
|
+
try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
|
|
761
815
|
const keys = Object.keys(mem);
|
|
762
816
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Listed ${keys.length} memory key(s)${RST}`);
|
|
763
817
|
logToolCall('list_memories', {}, true, 'ok');
|
|
@@ -770,11 +824,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
770
824
|
}
|
|
771
825
|
|
|
772
826
|
if (action === 'system_info') {
|
|
773
|
-
const approved = await permissionManager.askPermission('shell', 'System info', 'system_info');
|
|
774
|
-
if (!approved) {
|
|
775
|
-
logToolCall('system_info', {}, false, 'denied');
|
|
776
|
-
return { error: 'Permission denied' };
|
|
777
|
-
}
|
|
778
827
|
const info = {
|
|
779
828
|
platform: os.platform(),
|
|
780
829
|
arch: os.arch(),
|
|
@@ -848,9 +897,7 @@ function mapInvokeToCall(toolName, params) {
|
|
|
848
897
|
case 'upload':
|
|
849
898
|
return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
|
|
850
899
|
case 'http_get':
|
|
851
|
-
return p.url ? ['http_get', p.url
|
|
852
|
-
case 'http_get_next':
|
|
853
|
-
return p.key ? ['http_get_next', p.key] : null;
|
|
900
|
+
return p.url ? ['http_get', p.url] : null;
|
|
854
901
|
case 'ask_user':
|
|
855
902
|
return p.question ? ['ask_user', p.question] : null;
|
|
856
903
|
case 'store_memory':
|
|
@@ -872,7 +919,74 @@ function mapInvokeToCall(toolName, params) {
|
|
|
872
919
|
}
|
|
873
920
|
}
|
|
874
921
|
|
|
875
|
-
|
|
922
|
+
// Compile a regex twice — once with double quotes, once with single — from a
|
|
923
|
+
// template where `Q` stands for the quote char. Matches from both variants
|
|
924
|
+
// are returned in a single iterable.
|
|
925
|
+
function _matchDual(text, template) {
|
|
926
|
+
const results = [];
|
|
927
|
+
for (const q of ['"', "'"]) {
|
|
928
|
+
const re = new RegExp(template.replace(/Q/g, q), 'g');
|
|
929
|
+
for (const m of text.matchAll(re)) results.push(m);
|
|
930
|
+
}
|
|
931
|
+
return results;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Models sometimes wrap the inline body of a single-value tool tag in a nested
|
|
935
|
+
// pseudo-tag, e.g. `<list_dir><path>/tmp/foo</path></list_dir>` instead of the
|
|
936
|
+
// documented `<list_dir>/tmp/foo</list_dir>`. When the body is exactly one
|
|
937
|
+
// wrapper element (no siblings, no surrounding text), unwrap it once so the
|
|
938
|
+
// parser recovers the intended value. Safe to call on any inline-content body
|
|
939
|
+
// — a plain path/command/URL won't match the regex and is returned as-is.
|
|
940
|
+
function _unwrapInnerTag(inner) {
|
|
941
|
+
if (inner == null) return inner;
|
|
942
|
+
const trimmed = String(inner).trim();
|
|
943
|
+
const m = trimmed.match(/^<(\w+)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/);
|
|
944
|
+
if (!m) return inner;
|
|
945
|
+
return m[2].trim();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// MiniMax-M2 tool-call XML repair. Some inference backends — notably mlx-lm
|
|
949
|
+
// on Apple Silicon clusters (see ml-explore/mlx-lm#1145) — strip the leading
|
|
950
|
+
// `<` (or `</`) from `<invoke>` and `<parameter>` tags when sampling this
|
|
951
|
+
// model, producing malformed output like:
|
|
952
|
+
//
|
|
953
|
+
// <minimax:tool_call>
|
|
954
|
+
// invoke name="get_weather"> <-- `<` missing
|
|
955
|
+
// parameter name="x">v</parameter>
|
|
956
|
+
// invoke> <-- `</` missing
|
|
957
|
+
// </minimax:tool_call>
|
|
958
|
+
//
|
|
959
|
+
// Conservative repair: anchor each rewrite to the start of a line so parameter
|
|
960
|
+
// values that happen to contain the substring `parameter>` or `invoke>` mid-
|
|
961
|
+
// line are not corrupted. Limitation: a parameter value whose content begins a
|
|
962
|
+
// new line with exactly `invoke>` or `parameter>` at column 0 will still be
|
|
963
|
+
// rewritten — accepted as unfixable without full XML parsing. Opt-in via
|
|
964
|
+
// `repair_malformed_tool_xml`; silent text mutation is dangerous when wrong.
|
|
965
|
+
function repairMinimaxMalformedXml(text) {
|
|
966
|
+
if (typeof text !== 'string' || !text) return text;
|
|
967
|
+
return text
|
|
968
|
+
.replace(/^(\s*)invoke(\s+name=)/gm, '$1<invoke$2')
|
|
969
|
+
.replace(/^(\s*)parameter(\s+name=)/gm, '$1<parameter$2')
|
|
970
|
+
.replace(/^(\s*)invoke>/gm, '$1</invoke>')
|
|
971
|
+
.replace(/^(\s*)parameter>/gm, '$1</parameter>');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Parse tool-call tags out of assistant text.
|
|
976
|
+
*
|
|
977
|
+
* @param {string} text Assistant reply text to scan.
|
|
978
|
+
* @param {object} [options]
|
|
979
|
+
* @param {boolean} [options.repairMalformedXml] Run the MiniMax XML repair
|
|
980
|
+
* pass before parsing.
|
|
981
|
+
* @param {string} [options.model] Active model name. Accepted but unused
|
|
982
|
+
* today — reserved for future per-model preprocess hooks and per-model
|
|
983
|
+
* format prioritization (e.g. preferring JSON over XML for models that
|
|
984
|
+
* emit both).
|
|
985
|
+
* @param {object} [options.config] Resolved config. Same rationale as
|
|
986
|
+
* `model`: accepted for forward-compat, not consumed here yet.
|
|
987
|
+
*/
|
|
988
|
+
function extractToolCalls(text, options = {}) {
|
|
989
|
+
if (options.repairMalformedXml) text = repairMinimaxMalformedXml(text);
|
|
876
990
|
const calls = [];
|
|
877
991
|
|
|
878
992
|
// MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
|
|
@@ -902,6 +1016,48 @@ function extractToolCalls(text) {
|
|
|
902
1016
|
}
|
|
903
1017
|
}
|
|
904
1018
|
|
|
1019
|
+
// Qwen3-Coder / Qwen3.5 XML tool-call format. Distinct from MiniMax and
|
|
1020
|
+
// Hermes: the tool name lives on the opening tag as an `=name` suffix
|
|
1021
|
+
// rather than a `name="..."` attribute, and parameters use the same `=key`
|
|
1022
|
+
// shape:
|
|
1023
|
+
//
|
|
1024
|
+
// <function=write_file>
|
|
1025
|
+
// <parameter=path>a.json</parameter>
|
|
1026
|
+
// <parameter=content>{"k":1}</parameter>
|
|
1027
|
+
// </function>
|
|
1028
|
+
//
|
|
1029
|
+
// Values are kept as raw strings. The vLLM reference parser consults the
|
|
1030
|
+
// tool's JSON schema to decide per-parameter whether to string/int/
|
|
1031
|
+
// literal_eval a value; `mapInvokeToCall` has no schema, so schema-guided
|
|
1032
|
+
// decoding isn't available here. The existing string-typed tools match the
|
|
1033
|
+
// MiniMax/Hermes convention (content for write_file, command for shell,
|
|
1034
|
+
// etc.) arrive as raw strings, which is also the vLLM default when a param
|
|
1035
|
+
// is declared string-typed — the dominant case.
|
|
1036
|
+
//
|
|
1037
|
+
// TODO: if a tool ever takes an object/array param, revisit and port the
|
|
1038
|
+
// schema-guided typing logic from
|
|
1039
|
+
// https://github.com/vllm-project/vllm/tree/main/vllm/tool_parsers
|
|
1040
|
+
// (qwen3xml_tool_parser.py → find_tool_properties / repair_param_type).
|
|
1041
|
+
//
|
|
1042
|
+
// Limitation: the non-greedy `[\s\S]*?` anchors on the first `</parameter>`
|
|
1043
|
+
// inside the value. A parameter whose value legitimately contains the
|
|
1044
|
+
// literal substring `</parameter>` — possible for a code-writing tool
|
|
1045
|
+
// emitting docs about this very format — will be truncated. We accept
|
|
1046
|
+
// this rather than attempt balanced/escaped matching.
|
|
1047
|
+
const QWEN3_FN_BLOCK_RE = /<function=([^\s>]+)\s*>([\s\S]*?)<\/function>/g;
|
|
1048
|
+
const QWEN3_PARAM_RE = /<parameter=([^\s>]+)\s*>([\s\S]*?)<\/parameter>/g;
|
|
1049
|
+
for (const fnMatch of text.matchAll(QWEN3_FN_BLOCK_RE)) {
|
|
1050
|
+
const params = {};
|
|
1051
|
+
for (const pMatch of fnMatch[2].matchAll(QWEN3_PARAM_RE)) {
|
|
1052
|
+
let val = pMatch[2];
|
|
1053
|
+
if (val.startsWith('\n')) val = val.slice(1);
|
|
1054
|
+
if (val.endsWith('\n')) val = val.slice(0, -1);
|
|
1055
|
+
params[pMatch[1]] = val;
|
|
1056
|
+
}
|
|
1057
|
+
const call = mapInvokeToCall(fnMatch[1], params);
|
|
1058
|
+
if (call) calls.push(call);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
905
1061
|
// Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
|
|
906
1062
|
// and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
|
|
907
1063
|
//
|
|
@@ -935,10 +1091,39 @@ function extractToolCalls(text) {
|
|
|
935
1091
|
let parsed = null;
|
|
936
1092
|
try { parsed = JSON.parse(inner); } catch {}
|
|
937
1093
|
if (!parsed) {
|
|
1094
|
+
// Walk the string tracking quote/escape state and brace depth. Slice
|
|
1095
|
+
// the first balanced {...} block we find. Falls back to lastIndexOf
|
|
1096
|
+
// if the walker can't lock onto a balanced pair.
|
|
938
1097
|
const firstBrace = inner.indexOf('{');
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1098
|
+
if (firstBrace !== -1) {
|
|
1099
|
+
let depth = 0;
|
|
1100
|
+
let inString = false;
|
|
1101
|
+
let escaped = false;
|
|
1102
|
+
let endIdx = -1;
|
|
1103
|
+
for (let i = firstBrace; i < inner.length; i++) {
|
|
1104
|
+
const ch = inner[i];
|
|
1105
|
+
if (inString) {
|
|
1106
|
+
if (escaped) { escaped = false; continue; }
|
|
1107
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
1108
|
+
if (ch === '"') { inString = false; }
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (ch === '"') { inString = true; continue; }
|
|
1112
|
+
if (ch === '{') depth++;
|
|
1113
|
+
else if (ch === '}') {
|
|
1114
|
+
depth--;
|
|
1115
|
+
if (depth === 0) { endIdx = i; break; }
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (endIdx !== -1) {
|
|
1119
|
+
try { parsed = JSON.parse(inner.slice(firstBrace, endIdx + 1)); } catch {}
|
|
1120
|
+
}
|
|
1121
|
+
if (!parsed) {
|
|
1122
|
+
const lastBrace = inner.lastIndexOf('}');
|
|
1123
|
+
if (lastBrace > firstBrace) {
|
|
1124
|
+
try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
942
1127
|
}
|
|
943
1128
|
}
|
|
944
1129
|
if (!parsed) continue;
|
|
@@ -964,113 +1149,120 @@ function extractToolCalls(text) {
|
|
|
964
1149
|
}
|
|
965
1150
|
|
|
966
1151
|
for (const match of text.matchAll(/<(?:shell|exec|run_command|run)>([\s\S]*?)<\/(?:shell|exec|run_command|run)>/g)) {
|
|
967
|
-
calls.push(['shell', match[1].trim()]);
|
|
1152
|
+
calls.push(['shell', _unwrapInnerTag(match[1]).trim()]);
|
|
968
1153
|
}
|
|
969
1154
|
|
|
970
1155
|
for (const match of text.matchAll(/<read_file>([\s\S]*?)<\/read_file>/g)) {
|
|
971
|
-
calls.push(['read', match[1].trim()]);
|
|
1156
|
+
calls.push(['read', _unwrapInnerTag(match[1]).trim()]);
|
|
972
1157
|
}
|
|
973
1158
|
|
|
974
|
-
for (const match of text
|
|
1159
|
+
for (const match of _matchDual(text, '<read_file\\s+path=Q([^Q]+)Q\\s*\\/?>')) {
|
|
975
1160
|
calls.push(['read', match[1]]);
|
|
976
1161
|
}
|
|
977
1162
|
|
|
978
|
-
for (const match of text
|
|
1163
|
+
for (const match of _matchDual(text, '<write_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/write_file>')) {
|
|
979
1164
|
calls.push(['write', match[1], match[2]]);
|
|
980
1165
|
}
|
|
981
1166
|
|
|
982
|
-
for (const match of text
|
|
1167
|
+
for (const match of _matchDual(text, '<create_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/create_file>')) {
|
|
983
1168
|
calls.push(['write', match[1], match[2]]);
|
|
984
1169
|
}
|
|
985
1170
|
|
|
986
|
-
for (const match of text
|
|
1171
|
+
for (const match of _matchDual(text, '<append_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/append_file>')) {
|
|
987
1172
|
calls.push(['append', match[1], match[2]]);
|
|
988
1173
|
}
|
|
989
1174
|
|
|
990
1175
|
for (const match of text.matchAll(/<list_dir>([\s\S]*?)<\/list_dir>/g)) {
|
|
991
|
-
calls.push(['list_dir', match[1].trim()]);
|
|
1176
|
+
calls.push(['list_dir', _unwrapInnerTag(match[1]).trim()]);
|
|
992
1177
|
}
|
|
993
1178
|
|
|
994
1179
|
for (const match of text.matchAll(/<search_files>([\s\S]*?)<\/search_files>/g)) {
|
|
995
|
-
calls.push(['search_files', match[1].trim(), '.']);
|
|
1180
|
+
calls.push(['search_files', _unwrapInnerTag(match[1]).trim(), '.']);
|
|
996
1181
|
}
|
|
997
1182
|
|
|
998
|
-
for (const match of text
|
|
1183
|
+
for (const match of _matchDual(text, '<search_files\\s+pattern=Q([^Q]+)Q(?:\\s+dir=Q([^Q]*)Q)?\\s*(?:><\\/search_files>|\\/>)')) {
|
|
999
1184
|
calls.push(['search_files', match[1], match[2] || '.']);
|
|
1000
1185
|
}
|
|
1001
1186
|
|
|
1002
1187
|
for (const match of text.matchAll(/<delete_file>([\s\S]*?)<\/delete_file>/g)) {
|
|
1003
|
-
calls.push(['delete_file', match[1].trim()]);
|
|
1188
|
+
calls.push(['delete_file', _unwrapInnerTag(match[1]).trim()]);
|
|
1004
1189
|
}
|
|
1005
1190
|
|
|
1006
1191
|
for (const match of text.matchAll(/<make_dir>([\s\S]*?)<\/make_dir>/g)) {
|
|
1007
|
-
calls.push(['make_dir', match[1].trim()]);
|
|
1192
|
+
calls.push(['make_dir', _unwrapInnerTag(match[1]).trim()]);
|
|
1008
1193
|
}
|
|
1009
1194
|
|
|
1010
1195
|
for (const match of text.matchAll(/<remove_dir>([\s\S]*?)<\/remove_dir>/g)) {
|
|
1011
|
-
calls.push(['remove_dir', match[1].trim()]);
|
|
1196
|
+
calls.push(['remove_dir', _unwrapInnerTag(match[1]).trim()]);
|
|
1012
1197
|
}
|
|
1013
1198
|
|
|
1014
1199
|
for (const match of text.matchAll(/<get_env>([\s\S]*?)<\/get_env>/g)) {
|
|
1015
|
-
calls.push(['get_env', match[1].trim()]);
|
|
1200
|
+
calls.push(['get_env', _unwrapInnerTag(match[1]).trim()]);
|
|
1016
1201
|
}
|
|
1017
1202
|
|
|
1018
|
-
for (const match of text
|
|
1203
|
+
for (const match of _matchDual(text, '<set_env\\s+name=Q([^Q]+)Q\\s+value=Q([^Q]*)Q\\s*(?:><\\/set_env>|\\/>)')) {
|
|
1019
1204
|
calls.push(['set_env', match[1], match[2]]);
|
|
1020
1205
|
}
|
|
1021
1206
|
|
|
1022
|
-
for (const match of text
|
|
1207
|
+
for (const match of _matchDual(text, '<move_file\\s+src=Q([^Q]+)Q\\s+dst=Q([^Q]+)Q\\s*(?:><\\/move_file>|\\/>)')) {
|
|
1023
1208
|
calls.push(['move_file', match[1], match[2]]);
|
|
1024
1209
|
}
|
|
1025
1210
|
|
|
1026
|
-
for (const match of text
|
|
1211
|
+
for (const match of _matchDual(text, '<copy_file\\s+src=Q([^Q]+)Q\\s+dst=Q([^Q]+)Q\\s*(?:><\\/copy_file>|\\/>)')) {
|
|
1027
1212
|
calls.push(['copy_file', match[1], match[2]]);
|
|
1028
1213
|
}
|
|
1029
1214
|
|
|
1030
|
-
for (const match of text
|
|
1215
|
+
for (const match of _matchDual(text, '<edit_file\\s+path=Q([^Q]+)Q\\s+line=Q(\\d+)Q>([\\s\\S]*?)<\\/edit_file>')) {
|
|
1031
1216
|
calls.push(['edit_file', match[1], parseInt(match[2], 10), match[3]]);
|
|
1032
1217
|
}
|
|
1033
1218
|
|
|
1034
|
-
for (const match of text
|
|
1219
|
+
for (const match of _matchDual(text, '<search_in_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/search_in_file>')) {
|
|
1035
1220
|
calls.push(['search_in_file', match[1], match[2].trim()]);
|
|
1036
1221
|
}
|
|
1037
1222
|
|
|
1038
|
-
for (const match of text
|
|
1223
|
+
for (const match of _matchDual(text, '<replace_in_file\\s+path=Q([^Q]+)Q\\s+search=Q([^Q]*)Q\\s+replace=Q([^Q]*)Q>([\\s\\S]*?)<\\/replace_in_file>')) {
|
|
1039
1224
|
calls.push(['replace_in_file', match[1], match[2], match[3], match[4].trim()]);
|
|
1040
1225
|
}
|
|
1041
1226
|
|
|
1042
1227
|
for (const match of text.matchAll(/<download>([\s\S]*?)<\/download>/g)) {
|
|
1043
|
-
calls.push(['download', match[1].trim()]);
|
|
1228
|
+
calls.push(['download', _unwrapInnerTag(match[1]).trim()]);
|
|
1044
1229
|
}
|
|
1045
1230
|
|
|
1046
|
-
for (const match of text
|
|
1231
|
+
for (const match of _matchDual(text, '<upload\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/upload>')) {
|
|
1047
1232
|
calls.push(['upload', match[1], match[2]]);
|
|
1048
1233
|
}
|
|
1049
1234
|
|
|
1050
1235
|
for (const match of text.matchAll(/<file_stat>([\s\S]*?)<\/file_stat>/g)) {
|
|
1051
|
-
calls.push(['file_stat', match[1].trim()]);
|
|
1236
|
+
calls.push(['file_stat', _unwrapInnerTag(match[1]).trim()]);
|
|
1052
1237
|
}
|
|
1053
1238
|
|
|
1054
1239
|
for (const match of text.matchAll(/<http_get\b([^>]*?)(?:><\/http_get>|\/>)/g)) {
|
|
1055
1240
|
const attrStr = match[1];
|
|
1056
|
-
const urlMatch = attrStr.match(/url="([^"]+)"/);
|
|
1057
|
-
|
|
1058
|
-
if (urlMatch) calls.push(['http_get', urlMatch[1], rawMatch ? rawMatch[1] : '']);
|
|
1241
|
+
const urlMatch = attrStr.match(/url="([^"]+)"/) || attrStr.match(/url='([^']+)'/);
|
|
1242
|
+
if (urlMatch) calls.push(['http_get', urlMatch[1]]);
|
|
1059
1243
|
}
|
|
1060
1244
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1245
|
+
// Inline-content form: <http_get>URL</http_get>. Models mirror the style of
|
|
1246
|
+
// <list_dir>, <download>, etc. even though the system prompt advertises the
|
|
1247
|
+
// attribute form — accept both so the second tag in a multi-call response
|
|
1248
|
+
// isn't silently dropped. Also tolerate `<http_get>url="URL"</http_get>` where
|
|
1249
|
+
// the model put the attribute syntax in the body.
|
|
1250
|
+
for (const match of text.matchAll(/<http_get>([\s\S]*?)<\/http_get>/g)) {
|
|
1251
|
+
const inner = match[1].trim();
|
|
1252
|
+
if (!inner) continue;
|
|
1253
|
+
const urlAttr = inner.match(/url="([^"]+)"/) || inner.match(/url='([^']+)'/);
|
|
1254
|
+
calls.push(['http_get', urlAttr ? urlAttr[1] : _unwrapInnerTag(inner).trim()]);
|
|
1063
1255
|
}
|
|
1064
1256
|
|
|
1065
|
-
for (const match of text
|
|
1257
|
+
for (const match of _matchDual(text, '<ask_user\\s+question=Q([^Q]+)Q\\s*(?:><\\/ask_user>|\\/>)')) {
|
|
1066
1258
|
calls.push(['ask_user', match[1]]);
|
|
1067
1259
|
}
|
|
1068
1260
|
|
|
1069
|
-
for (const match of text
|
|
1261
|
+
for (const match of _matchDual(text, '<store_memory\\s+key=Q([^Q]+)Q>([\\s\\S]*?)<\\/store_memory>')) {
|
|
1070
1262
|
calls.push(['store_memory', match[1], match[2]]);
|
|
1071
1263
|
}
|
|
1072
1264
|
|
|
1073
|
-
for (const match of text
|
|
1265
|
+
for (const match of _matchDual(text, '<recall_memory\\s+key=Q([^Q]+)Q\\s*(?:><\\/recall_memory>|\\/>)')) {
|
|
1074
1266
|
calls.push(['recall_memory', match[1]]);
|
|
1075
1267
|
}
|
|
1076
1268
|
|
|
@@ -1085,10 +1277,31 @@ function extractToolCalls(text) {
|
|
|
1085
1277
|
return calls;
|
|
1086
1278
|
}
|
|
1087
1279
|
|
|
1280
|
+
// Transform a TOOL_SPECS-shaped object into an OpenAI-format `tools` array
|
|
1281
|
+
// suitable for the `tools` field of a chat/completions request. Pure: no
|
|
1282
|
+
// filtering, no caching, no validation. Insertion order is preserved.
|
|
1283
|
+
function buildToolsSchema(toolSpecs) {
|
|
1284
|
+
const tools = [];
|
|
1285
|
+
for (const [name, spec] of Object.entries(toolSpecs)) {
|
|
1286
|
+
tools.push({
|
|
1287
|
+
type: 'function',
|
|
1288
|
+
function: {
|
|
1289
|
+
name,
|
|
1290
|
+
description: spec.description,
|
|
1291
|
+
parameters: spec.parameters,
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
return tools;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1088
1298
|
module.exports = {
|
|
1299
|
+
buildToolsSchema,
|
|
1089
1300
|
createToolExecutor,
|
|
1090
1301
|
extractToolCalls,
|
|
1091
1302
|
getSkippedOps,
|
|
1303
|
+
isUIActive,
|
|
1092
1304
|
mapInvokeToCall,
|
|
1305
|
+
repairMinimaxMalformedXml,
|
|
1093
1306
|
setUIActive,
|
|
1094
1307
|
};
|