@molecule/api-ai-tools 1.0.9 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/input-normalizer.d.ts +92 -0
- package/dist/input-normalizer.d.ts.map +1 -0
- package/dist/input-normalizer.js +191 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +69 -4
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +480 -90
- package/dist/utilities.d.ts +102 -0
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +166 -2
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -6,8 +6,16 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
+
import { guardToolExecute, missingParamError } from './input-normalizer.js';
|
|
9
10
|
import { TOOL_SCHEMAS } from './schemas.js';
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Total waiting, per background task, after which wait_for_task reports it
|
|
13
|
+
* stuck instead of waiting again. Env-tunable so a test can exercise it.
|
|
14
|
+
*/
|
|
15
|
+
const stuckAfterMs = () => Number(process.env.MOL_AI_BG_STUCK_AFTER_MS) || 5 * 60 * 1000;
|
|
16
|
+
/** Cumulative wait_for_task time per task id, this process. */
|
|
17
|
+
const waitedByTask = new Map();
|
|
18
|
+
import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, isEnvFilePath, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, parseCheckCommand, parseCheckProblem, pathArgError, redactSecrets, redactSecretsInCode, resolvePath, shellQuote, sliceLines, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
|
|
11
19
|
/**
|
|
12
20
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
13
21
|
*
|
|
@@ -43,6 +51,29 @@ export function buildTools(backend, config) {
|
|
|
43
51
|
const grepExcludeArgs = excludedDirs.map((d) => `--exclude-dir=${shellQuote(d)}`).join(' ');
|
|
44
52
|
const findExcludeArgs = excludedDirs.map((d) => `-not -path ${shellQuote(`*/${d}/*`)}`).join(' ');
|
|
45
53
|
// ── Path helpers ───────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* After a write or edit, parse the file and hand the error back IN THE SAME
|
|
56
|
+
* RESULT. An edit that leaves a file unparsable used to surface only at the
|
|
57
|
+
* next build or type-check, minutes and many calls later — x13 (2026-09-22)
|
|
58
|
+
* mangled a template literal in `scripts/prerender.mjs`, the site stopped
|
|
59
|
+
* building, and the fix round spent 500 calls on the fallout. `node --check`
|
|
60
|
+
* is ~50 ms; the executor sees the exact line while the edit is still in
|
|
61
|
+
* front of it.
|
|
62
|
+
*/
|
|
63
|
+
async function syntaxErrorAfterWrite(path) {
|
|
64
|
+
const command = parseCheckCommand(path);
|
|
65
|
+
if (!command)
|
|
66
|
+
return {};
|
|
67
|
+
const result = await backend
|
|
68
|
+
.run(command, { cwd: path.slice(0, path.lastIndexOf('/')) || '/', timeout: 15_000 })
|
|
69
|
+
.catch(() => null);
|
|
70
|
+
const problem = result ? parseCheckProblem(result) : null;
|
|
71
|
+
return problem
|
|
72
|
+
? {
|
|
73
|
+
syntaxError: `The file no longer parses after this change — fix it before anything else, every build and test fails until it does:\n${problem}`,
|
|
74
|
+
}
|
|
75
|
+
: {};
|
|
76
|
+
}
|
|
46
77
|
/**
|
|
47
78
|
* Resolve a workspace-relative path using optional path guards.
|
|
48
79
|
*
|
|
@@ -221,6 +252,155 @@ export function buildTools(backend, config) {
|
|
|
221
252
|
};
|
|
222
253
|
}
|
|
223
254
|
// ── Tool implementations ───────────────────────────────────────
|
|
255
|
+
/**
|
|
256
|
+
* Read ONE file with every DWIM behavior read_file offers (directory
|
|
257
|
+
* listing, ENOENT parent listing, empty-read classification). Shared by the
|
|
258
|
+
* single-path and batched forms of the tool.
|
|
259
|
+
*
|
|
260
|
+
* @param rawPath - The path as the model sent it.
|
|
261
|
+
* @returns The same result shape read_file returns for one file.
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* How many leading lines of a file fit in a char budget.
|
|
265
|
+
*
|
|
266
|
+
* @param content - The file text.
|
|
267
|
+
* @param chars - The budget in chars.
|
|
268
|
+
* @returns The line count, at least one.
|
|
269
|
+
*/
|
|
270
|
+
function linesWithin(content, chars) {
|
|
271
|
+
let used = 0;
|
|
272
|
+
let lines = 0;
|
|
273
|
+
for (const line of content.split('\n')) {
|
|
274
|
+
used += line.length + 1;
|
|
275
|
+
if (used > chars && lines > 0)
|
|
276
|
+
break;
|
|
277
|
+
lines++;
|
|
278
|
+
}
|
|
279
|
+
return Math.max(1, lines);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Read one file for the model: symlink-checked, size-bounded, optionally
|
|
283
|
+
* windowed by line, and never more than MAX_READ_RETURN_CHARS at once.
|
|
284
|
+
*
|
|
285
|
+
* @param rawPath - The path as the model sent it.
|
|
286
|
+
* @param window - Optional `offset`/`limit` in lines.
|
|
287
|
+
* @returns The file content (or a window of it) or a model-readable error.
|
|
288
|
+
*/
|
|
289
|
+
async function readOneFile(rawPath, window) {
|
|
290
|
+
const argErr = pathArgError(rawPath, 'read_file');
|
|
291
|
+
if (argErr)
|
|
292
|
+
return { error: argErr };
|
|
293
|
+
const path = resolve(rawPath);
|
|
294
|
+
const symlinkErr = await checkSymlink(path);
|
|
295
|
+
if (symlinkErr)
|
|
296
|
+
return { error: symlinkErr };
|
|
297
|
+
try {
|
|
298
|
+
const content = await backend.readFile(path);
|
|
299
|
+
// A read that produced no usable text is verified against the file's real
|
|
300
|
+
// size before it can be reported as content (see classifyEmptyRead). The
|
|
301
|
+
// non-string arm covers a backend whose transport handed back an empty
|
|
302
|
+
// body as `undefined`; `content.length` below would otherwise raise an
|
|
303
|
+
// unactionable "Cannot read properties of undefined".
|
|
304
|
+
if (typeof content !== 'string' || content === '') {
|
|
305
|
+
const verdict = await classifyEmptyRead(path, 'read_file');
|
|
306
|
+
if ('error' in verdict)
|
|
307
|
+
return verdict;
|
|
308
|
+
return {
|
|
309
|
+
path,
|
|
310
|
+
content: sanitizeFileContent(verdict.content, path),
|
|
311
|
+
...(verdict.content === '' ? { empty: true } : {}),
|
|
312
|
+
...(verdict.note ? { note: verdict.note } : {}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (content.length > MAX_READ_SIZE)
|
|
316
|
+
return {
|
|
317
|
+
error: `File too large (${Math.round(content.length / 1024)}KB). Maximum is ${MAX_READ_SIZE / 1024 / 1024}MB.`,
|
|
318
|
+
};
|
|
319
|
+
// No window asked for and the file is bigger than one call should hand
|
|
320
|
+
// back: return its first window, sized to the ceiling, and say so. The
|
|
321
|
+
// model can read on with offset/limit; it cannot un-send 300k tokens.
|
|
322
|
+
const effectiveWindow = !sliceLines(content, window) && content.length > MAX_READ_RETURN_CHARS
|
|
323
|
+
? { offset: 1, limit: linesWithin(content, MAX_READ_RETURN_CHARS) }
|
|
324
|
+
: window;
|
|
325
|
+
const windowed = sliceLines(content, effectiveWindow);
|
|
326
|
+
if (windowed) {
|
|
327
|
+
return {
|
|
328
|
+
path,
|
|
329
|
+
content: sanitizeFileContent(windowed.text, path),
|
|
330
|
+
offset: windowed.offset,
|
|
331
|
+
lines: windowed.lines,
|
|
332
|
+
totalLines: windowed.totalLines,
|
|
333
|
+
// The note decides the executor's next call. Observed (X0 run x7): a
|
|
334
|
+
// 44 KB README read as seven 300-line windows, one round trip each,
|
|
335
|
+
// because the note only ever said "read another window" — when the
|
|
336
|
+
// whole file fits in one call, say that; when it does not, name the
|
|
337
|
+
// exact next window so the executor does not have to compute it.
|
|
338
|
+
...(windowed.truncated
|
|
339
|
+
? {
|
|
340
|
+
note: content.length <= MAX_READ_RETURN_CHARS
|
|
341
|
+
? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
342
|
+
`${windowed.totalLines}. The whole file is ${Math.round(content.length / 1024)} KB ` +
|
|
343
|
+
`and fits in ONE call — read it again with no offset/limit instead of paging.`
|
|
344
|
+
: `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
345
|
+
`${windowed.totalLines}. Next window: offset ${windowed.offset + windowed.lines}, ` +
|
|
346
|
+
`limit ${windowed.lines}.`,
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return { path, content: sanitizeFileContent(content, path) };
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
// Backends (e.g. the docker provider's `cat`) already prefix "Failed to read <path>: ";
|
|
355
|
+
// strip it so the wrap below doesn't stutter ("Failed to read X: Failed to read X: …").
|
|
356
|
+
const rawMessage = e.message;
|
|
357
|
+
const prefix = `Failed to read ${path}: `;
|
|
358
|
+
const message = rawMessage.startsWith(prefix) ? rawMessage.slice(prefix.length) : rawMessage;
|
|
359
|
+
// A weak model often read_file's a directory (handlers/, migrations/). Rather than
|
|
360
|
+
// erroring and costing it a retry loop (observed: 3 such misses + follow-up
|
|
361
|
+
// list_files in one custom build), DWIM: return the directory's listing — what it
|
|
362
|
+
// almost certainly wanted — with a note so it read_file's a specific entry next.
|
|
363
|
+
if (directoryReadHint(message, path)) {
|
|
364
|
+
try {
|
|
365
|
+
const entries = await backend.readDir(path);
|
|
366
|
+
return {
|
|
367
|
+
path,
|
|
368
|
+
isDirectory: true,
|
|
369
|
+
note: `${path} is a directory, not a file — returning its contents. read_file a specific entry inside it to see that file's content.`,
|
|
370
|
+
entries: entries.map((entry) => ({ name: entry.name, type: entry.type })),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
catch (_dirErr) {
|
|
374
|
+
// readDir also failed — fall through to the steer-to-list_files hint below.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// ENOENT DWIM: a weak model GUESSES paths from framework priors (observed: 65 of 89
|
|
378
|
+
// reads in one imported-app build were misses on files that never existed). Return
|
|
379
|
+
// ground truth in the SAME result — what the parent directory actually contains — so
|
|
380
|
+
// the next read uses a real name instead of another guess.
|
|
381
|
+
if (/No such file or directory/i.test(message)) {
|
|
382
|
+
const parent = path.replace(/\/[^/]*$/, '') || '/';
|
|
383
|
+
let listing;
|
|
384
|
+
try {
|
|
385
|
+
const entries = await backend.readDir(parent);
|
|
386
|
+
const names = entries
|
|
387
|
+
.slice(0, 40)
|
|
388
|
+
.map((entry) => entry.name + (entry.type === 'directory' ? '/' : ''));
|
|
389
|
+
listing = names.length
|
|
390
|
+
? `The directory ${parent} exists and contains: ${names.join(', ')}${entries.length > 40 ? ', …' : ''}.`
|
|
391
|
+
: `The directory ${parent} exists but is EMPTY.`;
|
|
392
|
+
}
|
|
393
|
+
catch (_parentErr) {
|
|
394
|
+
// Parent listing failed too — most usefully because it doesn't exist either.
|
|
395
|
+
listing = `The directory ${parent} does not exist either.`;
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
error: `No such file: ${path}. ${listing} Read one of the real entries (or use find_files) — do not guess paths.`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return { error: directoryReadHint(message, path) ?? `Failed to read ${path}: ${message}` };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
224
404
|
const toolImpls = {
|
|
225
405
|
async list_files(input) {
|
|
226
406
|
const path = resolve(input.path || '');
|
|
@@ -236,87 +416,46 @@ export function buildTools(backend, config) {
|
|
|
236
416
|
}
|
|
237
417
|
},
|
|
238
418
|
async read_file(input) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
// size before it can be reported as content (see classifyEmptyRead). The
|
|
250
|
-
// non-string arm covers a backend whose transport handed back an empty
|
|
251
|
-
// body as `undefined`; `content.length` below would otherwise raise an
|
|
252
|
-
// unactionable "Cannot read properties of undefined".
|
|
253
|
-
if (typeof content !== 'string' || content === '') {
|
|
254
|
-
const verdict = await classifyEmptyRead(path, 'read_file');
|
|
255
|
-
if ('error' in verdict)
|
|
256
|
-
return verdict;
|
|
419
|
+
// Several paths in ONE call. The executor issues one tool call per model
|
|
420
|
+
// round-trip 89% of the time (X0: 464 assistant messages, 515 tool calls,
|
|
421
|
+
// a ~10s mean gap), so a turn's wall clock IS its round-trip count — and
|
|
422
|
+
// the largest single block measured was a systematic scaffold survey:
|
|
423
|
+
// 121 sequential read_file calls, 108 of them in the first ten minutes,
|
|
424
|
+
// across 93 distinct files. The same reads batched cost the same tokens
|
|
425
|
+
// and a tenth of the latency.
|
|
426
|
+
if (Array.isArray(input.paths)) {
|
|
427
|
+
const requested = input.paths.map((p) => String(p ?? '')).filter(Boolean);
|
|
428
|
+
if (requested.length === 0) {
|
|
257
429
|
return {
|
|
258
|
-
path,
|
|
259
|
-
|
|
260
|
-
...(verdict.content === '' ? { empty: true } : {}),
|
|
261
|
-
...(verdict.note ? { note: verdict.note } : {}),
|
|
430
|
+
error: 'read_file got an empty "paths" array. Pass "path" for one file, or "paths" with at ' +
|
|
431
|
+
'least one path for several.',
|
|
262
432
|
};
|
|
263
433
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
// Backends (e.g. the docker provider's `cat`) already prefix "Failed to read <path>: ";
|
|
272
|
-
// strip it so the wrap below doesn't stutter ("Failed to read X: Failed to read X: …").
|
|
273
|
-
const rawMessage = e.message;
|
|
274
|
-
const prefix = `Failed to read ${path}: `;
|
|
275
|
-
const message = rawMessage.startsWith(prefix) ? rawMessage.slice(prefix.length) : rawMessage;
|
|
276
|
-
// A weak model often read_file's a directory (handlers/, migrations/). Rather than
|
|
277
|
-
// erroring and costing it a retry loop (observed: 3 such misses + follow-up
|
|
278
|
-
// list_files in one custom build), DWIM: return the directory's listing — what it
|
|
279
|
-
// almost certainly wanted — with a note so it read_file's a specific entry next.
|
|
280
|
-
if (directoryReadHint(message, path)) {
|
|
281
|
-
try {
|
|
282
|
-
const entries = await backend.readDir(path);
|
|
283
|
-
return {
|
|
284
|
-
path,
|
|
285
|
-
isDirectory: true,
|
|
286
|
-
note: `${path} is a directory, not a file — returning its contents. read_file a specific entry inside it to see that file's content.`,
|
|
287
|
-
entries: entries.map((entry) => ({ name: entry.name, type: entry.type })),
|
|
288
|
-
};
|
|
434
|
+
const files = [];
|
|
435
|
+
let budget = MAX_BATCH_READ_BYTES;
|
|
436
|
+
let stoppedAt = null;
|
|
437
|
+
for (const [index, requestedPath] of requested.entries()) {
|
|
438
|
+
if (index >= MAX_BATCH_READ_FILES || budget <= 0) {
|
|
439
|
+
stoppedAt = requestedPath;
|
|
440
|
+
break;
|
|
289
441
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// ENOENT DWIM: a weak model GUESSES paths from framework priors (observed: 65 of 89
|
|
295
|
-
// reads in one imported-app build were misses on files that never existed). Return
|
|
296
|
-
// ground truth in the SAME result — what the parent directory actually contains — so
|
|
297
|
-
// the next read uses a real name instead of another guess.
|
|
298
|
-
if (/No such file or directory/i.test(message)) {
|
|
299
|
-
const parent = path.replace(/\/[^/]*$/, '') || '/';
|
|
300
|
-
let listing;
|
|
301
|
-
try {
|
|
302
|
-
const entries = await backend.readDir(parent);
|
|
303
|
-
const names = entries
|
|
304
|
-
.slice(0, 40)
|
|
305
|
-
.map((entry) => entry.name + (entry.type === 'directory' ? '/' : ''));
|
|
306
|
-
listing = names.length
|
|
307
|
-
? `The directory ${parent} exists and contains: ${names.join(', ')}${entries.length > 40 ? ', …' : ''}.`
|
|
308
|
-
: `The directory ${parent} exists but is EMPTY.`;
|
|
309
|
-
}
|
|
310
|
-
catch (_parentErr) {
|
|
311
|
-
// Parent listing failed too — most usefully because it doesn't exist either.
|
|
312
|
-
listing = `The directory ${parent} does not exist either.`;
|
|
313
|
-
}
|
|
314
|
-
return {
|
|
315
|
-
error: `No such file: ${path}. ${listing} Read one of the real entries (or use find_files) — do not guess paths.`,
|
|
316
|
-
};
|
|
442
|
+
const one = (await readOneFile(requestedPath, input));
|
|
443
|
+
files.push(one);
|
|
444
|
+
if (typeof one.content === 'string')
|
|
445
|
+
budget -= one.content.length;
|
|
317
446
|
}
|
|
318
|
-
return {
|
|
447
|
+
return {
|
|
448
|
+
files,
|
|
449
|
+
...(stoppedAt
|
|
450
|
+
? {
|
|
451
|
+
note: `Stopped after ${files.length} of ${requested.length} files (limit: ${MAX_BATCH_READ_FILES} ` +
|
|
452
|
+
`files or ${Math.round(MAX_BATCH_READ_BYTES / 1024)}KB per call). Call read_file again ` +
|
|
453
|
+
`starting at ${stoppedAt}.`,
|
|
454
|
+
}
|
|
455
|
+
: {}),
|
|
456
|
+
};
|
|
319
457
|
}
|
|
458
|
+
return readOneFile(input.path, input);
|
|
320
459
|
},
|
|
321
460
|
async write_file(input) {
|
|
322
461
|
const argErr = pathArgError(input.path, 'write_file');
|
|
@@ -348,7 +487,7 @@ export function buildTools(backend, config) {
|
|
|
348
487
|
const diff = computeDiff(oldContent, finalContent);
|
|
349
488
|
if (onFileChange)
|
|
350
489
|
onFileChange({ type: oldContent === null ? 'created' : 'modified', path });
|
|
351
|
-
return { path, ok: true, diff };
|
|
490
|
+
return { path, ok: true, diff, ...(await syntaxErrorAfterWrite(path)) };
|
|
352
491
|
}
|
|
353
492
|
catch (e) {
|
|
354
493
|
return { error: `Failed to write ${path}: ${e.message}` };
|
|
@@ -473,8 +612,27 @@ export function buildTools(backend, config) {
|
|
|
473
612
|
if (idx >= 0)
|
|
474
613
|
return showSnippet(idx);
|
|
475
614
|
}
|
|
615
|
+
// Nothing anchored. Say WHICH of the two situations this is — they
|
|
616
|
+
// call for different next moves, and the old message implied the
|
|
617
|
+
// first while being returned for both. Measured across six agent
|
|
618
|
+
// runs, `old_string not found` was the largest remaining tool-error
|
|
619
|
+
// class (15 of 64) and every one of these fell through to a bare
|
|
620
|
+
// "re-read and copy the exact text", which is wrong advice when the
|
|
621
|
+
// text is not in the file at all.
|
|
622
|
+
const anchorsPresent = distinctiveLines.some((anchor) => fileLines.some((l) => l.includes(anchor)));
|
|
623
|
+
if (!anchorsPresent) {
|
|
624
|
+
return {
|
|
625
|
+
error: `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
|
|
626
|
+
`that file, so this is not a whitespace problem. Either the edit was already ` +
|
|
627
|
+
`applied, or this is the wrong file. read_file it and look before trying ` +
|
|
628
|
+
`again; do not retry the same old_string.`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
476
631
|
return {
|
|
477
|
-
error: `old_string not found in ${path}.
|
|
632
|
+
error: `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
|
|
633
|
+
`line is distinctive enough to locate the target. read_file the region you ` +
|
|
634
|
+
`mean and copy old_string verbatim from what it returns — including ` +
|
|
635
|
+
`indentation — or include more surrounding context to make it unique.`,
|
|
478
636
|
};
|
|
479
637
|
}
|
|
480
638
|
if (count > 1)
|
|
@@ -497,7 +655,12 @@ export function buildTools(backend, config) {
|
|
|
497
655
|
await onAfterWrite(path).catch(() => { });
|
|
498
656
|
if (onFileChange)
|
|
499
657
|
onFileChange({ type: 'modified', path });
|
|
500
|
-
return {
|
|
658
|
+
return {
|
|
659
|
+
path,
|
|
660
|
+
ok: true,
|
|
661
|
+
replacementsApplied: replacements.length,
|
|
662
|
+
...(await syntaxErrorAfterWrite(path)),
|
|
663
|
+
};
|
|
501
664
|
}
|
|
502
665
|
catch (e) {
|
|
503
666
|
return { error: `Failed to edit ${path}: ${e.message}` };
|
|
@@ -507,16 +670,27 @@ export function buildTools(backend, config) {
|
|
|
507
670
|
const pattern = input.pattern;
|
|
508
671
|
const path = resolve(input.path || '');
|
|
509
672
|
const include = input.include;
|
|
673
|
+
// Lines of surrounding context per match. The executor reaches for
|
|
674
|
+
// `grep -A 20` through the shell because this tool could not express it;
|
|
675
|
+
// bounded so a broad pattern cannot pull the whole tree into the reply.
|
|
676
|
+
const rawContext = Number(input.contextLines ?? input.context);
|
|
677
|
+
const contextLines = Number.isFinite(rawContext)
|
|
678
|
+
? Math.min(Math.max(Math.trunc(rawContext), 0), MAX_SEARCH_CONTEXT_LINES)
|
|
679
|
+
: 0;
|
|
510
680
|
if (include && !isValidGlob(include))
|
|
511
681
|
return {
|
|
512
|
-
error: 'Invalid include glob pattern. Allowed: alphanumeric and * ? . _ - / [ ] ( ).',
|
|
682
|
+
error: 'Invalid include glob pattern. Allowed: alphanumeric and @ * ? . _ - / [ ] ( ).',
|
|
513
683
|
};
|
|
514
684
|
const symlinkErr = await checkSymlink(path);
|
|
515
685
|
if (symlinkErr)
|
|
516
686
|
return { error: symlinkErr };
|
|
517
687
|
try {
|
|
518
688
|
const globArg = include ? `--include=${shellQuote(include)}` : '';
|
|
519
|
-
|
|
689
|
+
// With context, grep emits `file-line-content` for context lines and
|
|
690
|
+
// `file:line:content` for matches, plus `--` group separators. Both
|
|
691
|
+
// shapes are parsed below; without context the output is unchanged.
|
|
692
|
+
const contextArg = contextLines > 0 ? `-C ${contextLines}` : '';
|
|
693
|
+
const result = await backend.run(`grep -rn ${globArg} ${contextArg} ${grepExcludeArgs} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)} 2>/dev/null || true`, { timeout: 10000 });
|
|
520
694
|
// grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
|
|
521
695
|
// content alone: the file prefix would otherwise hide an env assignment from
|
|
522
696
|
// the line-anchored env pattern, and knowing each match's own path is what
|
|
@@ -524,14 +698,31 @@ export function buildTools(backend, config) {
|
|
|
524
698
|
const output = stripControlChars(result.stdout.trim());
|
|
525
699
|
if (!output)
|
|
526
700
|
return { pattern, path, matches: [] };
|
|
701
|
+
const cap = contextLines > 0 ? MAX_SEARCH_RESULTS * (contextLines * 2 + 2) : MAX_SEARCH_RESULTS;
|
|
527
702
|
const matches = output
|
|
528
703
|
.split('\n')
|
|
529
|
-
.slice(0,
|
|
704
|
+
.slice(0, cap)
|
|
705
|
+
.filter((line) => line !== '--')
|
|
530
706
|
.map((line) => {
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
707
|
+
const hit = line.match(/^(.+?):(\d+):(.*)$/);
|
|
708
|
+
if (hit) {
|
|
709
|
+
return {
|
|
710
|
+
file: hit[1],
|
|
711
|
+
line: parseInt(hit[2]),
|
|
712
|
+
content: sanitizeFileContent(hit[3], hit[1]),
|
|
713
|
+
...(contextLines > 0 ? { match: true } : {}),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const ctx = contextLines > 0 ? line.match(/^(.+?)-(\d+)-(.*)$/) : null;
|
|
717
|
+
if (ctx) {
|
|
718
|
+
return {
|
|
719
|
+
file: ctx[1],
|
|
720
|
+
line: parseInt(ctx[2]),
|
|
721
|
+
content: sanitizeFileContent(ctx[3], ctx[1]),
|
|
722
|
+
match: false,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return { file: '', line: 0, content: sanitizeFileContent(line, '') };
|
|
535
726
|
});
|
|
536
727
|
return { pattern, path, matches };
|
|
537
728
|
}
|
|
@@ -551,7 +742,7 @@ export function buildTools(backend, config) {
|
|
|
551
742
|
};
|
|
552
743
|
if (!isValidGlob(pattern))
|
|
553
744
|
return {
|
|
554
|
-
error: 'Invalid pattern. Allowed: alphanumeric and * ? . _ - / [ ] ( ) ' +
|
|
745
|
+
error: 'Invalid pattern. Allowed: alphanumeric and @ * ? . _ - / [ ] ( ) ' +
|
|
555
746
|
'(brackets/parens support Next.js route dirs like [id] or (group)).',
|
|
556
747
|
};
|
|
557
748
|
const symlinkErr = await checkSymlink(path);
|
|
@@ -623,6 +814,17 @@ export function buildTools(backend, config) {
|
|
|
623
814
|
async exec_command(input) {
|
|
624
815
|
const command = input.command;
|
|
625
816
|
const cwd = input.cwd ? resolve(input.cwd) : root;
|
|
817
|
+
// A model that sent the command under another name (`cmd`, `script`, …)
|
|
818
|
+
// has already been normalized by guardToolExecute; reaching here with
|
|
819
|
+
// nothing means there is genuinely no command. Say so in the terms that
|
|
820
|
+
// let the model fix it. It used to fall through to
|
|
821
|
+
// checkBlockedCommand(undefined) and die with "Cannot read properties of
|
|
822
|
+
// undefined (reading 'match')" — the executor read that as a broken shell,
|
|
823
|
+
// stopped verifying, and ended the turn claiming all checks passed while
|
|
824
|
+
// seven acceptance checks failed (X0 rehearsal 83, 2026-09-16).
|
|
825
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
826
|
+
return { error: missingParamError(TOOL_SCHEMAS.exec_command, input, ['command']) };
|
|
827
|
+
}
|
|
626
828
|
if (blockDangerousCommands) {
|
|
627
829
|
const blocked = checkBlockedCommand(command);
|
|
628
830
|
if (blocked)
|
|
@@ -635,6 +837,70 @@ export function buildTools(backend, config) {
|
|
|
635
837
|
if (blocked)
|
|
636
838
|
return { error: blocked };
|
|
637
839
|
}
|
|
840
|
+
// Detached: start it, hand back a handle, let the turn continue. A
|
|
841
|
+
// command that outlives the tool's ceiling is otherwise simply lost —
|
|
842
|
+
// measured across six agent runs, ten of them were killed at the
|
|
843
|
+
// ceiling for 49 minutes, 11% of all wall clock, every one returning
|
|
844
|
+
// nothing because it was piped through tail/grep. A full test suite or a
|
|
845
|
+
// slow production build could not be run at all, so the executor ran
|
|
846
|
+
// smaller and smaller pieces, or reported a result it never saw.
|
|
847
|
+
//
|
|
848
|
+
// Output goes to a file the executor reads with read_file (which takes
|
|
849
|
+
// offset/limit, so a long log is cheap to follow), and an `.exit` file
|
|
850
|
+
// appears when it finishes — so "is it done?" is a read, not a poll that
|
|
851
|
+
// blocks a turn.
|
|
852
|
+
if (input.run_in_background === true) {
|
|
853
|
+
const id = `mol-bg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
854
|
+
const log = `/tmp/${id}.log`;
|
|
855
|
+
const exitFile = `/tmp/${id}.exit`;
|
|
856
|
+
const script = `/tmp/${id}.sh`;
|
|
857
|
+
try {
|
|
858
|
+
// The command goes to a FILE rather than through nested quoting:
|
|
859
|
+
// an executor's commands routinely carry quotes, heredocs and
|
|
860
|
+
// newlines, and re-quoting them into a detached `sh -c` is how a
|
|
861
|
+
// background runner corrupts the very thing it is running.
|
|
862
|
+
await backend.writeFile(script, `${command}\n`);
|
|
863
|
+
await backend.run(`nohup sh -c ${shellQuote(`sh ${script} > ${log} 2>&1; echo $? > ${exitFile}`)} ` +
|
|
864
|
+
`> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
|
|
865
|
+
return {
|
|
866
|
+
taskId: id,
|
|
867
|
+
log,
|
|
868
|
+
exitFile,
|
|
869
|
+
note: `Started in the background. Its output is being written to ${log} — read it with ` +
|
|
870
|
+
`read_file (use offset/limit to follow a long one). When it finishes, ${exitFile} ` +
|
|
871
|
+
`appears and holds the exit code. If you have other work, do it and check back. If ` +
|
|
872
|
+
`you need this result before you can continue, call wait_for_task with this taskId — ` +
|
|
873
|
+
`never sleep and read the log by hand.`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
catch (e) {
|
|
877
|
+
return {
|
|
878
|
+
error: `Could not start the background command: ${e.message}`,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// A command that declares its OWN budget larger than this tool's ceiling,
|
|
883
|
+
// and whose output is withheld until the pipeline ends, cannot produce
|
|
884
|
+
// anything: it will be stopped at the ceiling having printed nothing.
|
|
885
|
+
// Measured across six agent runs, that combination was ten commands and
|
|
886
|
+
// 49 minutes — 11% of all wall clock — each returning a few hundred bytes.
|
|
887
|
+
// Refusing in five seconds, naming both ways out, is strictly better than
|
|
888
|
+
// spending the ceiling to say the same thing afterwards. A command that
|
|
889
|
+
// declares nothing still RUNS: partial output survives an overrun, so
|
|
890
|
+
// there is something to learn either way.
|
|
891
|
+
if (commandBudgetMs) {
|
|
892
|
+
const ceiling = Math.max(1, Math.round(commandBudgetMs / 1000));
|
|
893
|
+
const declared = declaredBudgetSeconds(command, input.timeout);
|
|
894
|
+
if (declared > ceiling && outputIsWithheldUntilExit(command)) {
|
|
895
|
+
return {
|
|
896
|
+
error: `This command asks for ${declared}s and this tool stops at ${ceiling}s — and its output ` +
|
|
897
|
+
'is piped into tail/head/grep, which print nothing until the pipeline ends, so it ' +
|
|
898
|
+
'would spend the whole budget and hand you back nothing. Either run a smaller unit ' +
|
|
899
|
+
'(one spec file, one build step) that finishes inside the limit, or drop the pipe ' +
|
|
900
|
+
'and use a streaming reporter so the output survives being stopped. Nothing was run.',
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
}
|
|
638
904
|
try {
|
|
639
905
|
// exec_command runs installs/builds/tests — the old 30s hardcap killed
|
|
640
906
|
// those spuriously; use the (generous, caller-configurable) budget.
|
|
@@ -644,10 +910,26 @@ export function buildTools(backend, config) {
|
|
|
644
910
|
// printed until then comes back with exit code 124 — instead of an outer
|
|
645
911
|
// timeout discarding the run and its output together.
|
|
646
912
|
const budgetSeconds = commandBudgetMs ? Math.max(1, Math.round(commandBudgetMs / 1000)) : 0;
|
|
913
|
+
// A requested budget is honored DOWNWARD only. The model used to send
|
|
914
|
+
// `timeout: 900000` for a test suite, the field was not declared so it
|
|
915
|
+
// was silently dropped, and the command was killed at the ceiling with
|
|
916
|
+
// no hint that a ceiling existed — costing a full budget per attempt
|
|
917
|
+
// before the overrun message could teach anything (X0 R83/R84). Now the
|
|
918
|
+
// ceiling is reported the FIRST time a larger one is asked for.
|
|
919
|
+
const requestedMs = Number(input.timeout);
|
|
920
|
+
const wantsMore = Number.isFinite(requestedMs) && requestedMs > 0 && requestedMs > (commandBudgetMs ?? 0);
|
|
921
|
+
const effectiveBudgetMs = Number.isFinite(requestedMs) && requestedMs > 0 && commandBudgetMs
|
|
922
|
+
? Math.min(requestedMs, commandBudgetMs)
|
|
923
|
+
: commandBudgetMs;
|
|
924
|
+
const ceilingNote = wantsMore && budgetSeconds
|
|
925
|
+
? `You asked for ${Math.round(requestedMs / 1000)}s; this tool's ceiling is ${budgetSeconds}s. ` +
|
|
926
|
+
'Split the work into smaller commands (one test file, one build step) rather than ' +
|
|
927
|
+
're-running the same long one.'
|
|
928
|
+
: null;
|
|
647
929
|
const result = await backend.run(command, {
|
|
648
930
|
cwd,
|
|
649
931
|
timeout: execTimeoutMs,
|
|
650
|
-
...(
|
|
932
|
+
...(effectiveBudgetMs ? { budgetMs: effectiveBudgetMs } : {}),
|
|
651
933
|
});
|
|
652
934
|
// truncateMiddle (not truncate): a failing build/test/migration puts its
|
|
653
935
|
// error at the TAIL, so keep the head AND the tail — head-only truncation
|
|
@@ -668,7 +950,12 @@ export function buildTools(backend, config) {
|
|
|
668
950
|
'a build and a whole suite.',
|
|
669
951
|
};
|
|
670
952
|
}
|
|
671
|
-
return {
|
|
953
|
+
return {
|
|
954
|
+
stdout,
|
|
955
|
+
stderr,
|
|
956
|
+
exitCode: result.exitCode,
|
|
957
|
+
...(ceilingNote ? { note: ceilingNote } : {}),
|
|
958
|
+
};
|
|
672
959
|
}
|
|
673
960
|
catch (e) {
|
|
674
961
|
return { error: `Command failed: ${e.message}` };
|
|
@@ -720,6 +1007,103 @@ export function buildTools(backend, config) {
|
|
|
720
1007
|
error: `Skill '${name}' not found. Searched .agents/skills/${name}/SKILL.md and .claude/skills/${name}/SKILL.md`,
|
|
721
1008
|
};
|
|
722
1009
|
},
|
|
1010
|
+
// The other half of run_in_background. A handle is only useful if the
|
|
1011
|
+
// executor has other work to do while the command runs; when it needs the
|
|
1012
|
+
// result to continue — a build before the tests, tests before "done" — it
|
|
1013
|
+
// has nothing to do but wait, and without this it waited by calling
|
|
1014
|
+
// `sleep 75; cat <log>` in exec_command, one round trip per poll, each at
|
|
1015
|
+
// the full context. Observed on X0 run x1 (2026-09-22): the first two
|
|
1016
|
+
// background builds were each followed by a sleep-and-cat call. Waiting
|
|
1017
|
+
// server-side costs no round trip and no context.
|
|
1018
|
+
async wait_for_task(input) {
|
|
1019
|
+
const taskId = typeof input.taskId === 'string' ? input.taskId.trim() : '';
|
|
1020
|
+
// A command that has been waited on for stuckAfterMs() in total without
|
|
1021
|
+
// exiting is not going to: on one run the executor waited five times,
|
|
1022
|
+
// 120 s each, on an e2e suite that could never start (no browser page
|
|
1023
|
+
// attached). Say so once, name the pid, and refuse to wait on it again.
|
|
1024
|
+
const waitedBefore = waitedByTask.get(taskId) ?? 0;
|
|
1025
|
+
if (taskId && waitedBefore >= stuckAfterMs()) {
|
|
1026
|
+
return {
|
|
1027
|
+
taskId,
|
|
1028
|
+
status: 'stuck',
|
|
1029
|
+
waitedMs: waitedBefore,
|
|
1030
|
+
note: `You have already waited ${Math.round(waitedBefore / 1000)}s on this command and it ` +
|
|
1031
|
+
`has not exited — it is stuck, not slow. Kill it (exec_command: kill $(cat /tmp/${taskId}.pid) ` +
|
|
1032
|
+
`2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
// The id names files under /tmp; only an id this tool minted may be used
|
|
1036
|
+
// to read them, so a crafted id cannot turn this into a file reader.
|
|
1037
|
+
if (!/^mol-bg-[a-z0-9]+-[a-z0-9]+$/.test(taskId)) {
|
|
1038
|
+
return {
|
|
1039
|
+
error: 'taskId must be the id a run_in_background command returned (it looks like ' +
|
|
1040
|
+
'`mol-bg-…`).',
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const log = `/tmp/${taskId}.log`;
|
|
1044
|
+
const exitFile = `/tmp/${taskId}.exit`;
|
|
1045
|
+
// Leave the outer tool ceiling a margin so this returns a result rather
|
|
1046
|
+
// than being killed by it.
|
|
1047
|
+
const ceiling = Math.max(5_000, (commandBudgetMs ?? 290_000) - 10_000);
|
|
1048
|
+
const requested = Number(input.timeout);
|
|
1049
|
+
const budgetMs = Number.isFinite(requested) && requested > 0 ? Math.min(requested, ceiling) : ceiling;
|
|
1050
|
+
const startedAt = Date.now();
|
|
1051
|
+
const readExit = async () => {
|
|
1052
|
+
try {
|
|
1053
|
+
const raw = await backend.readFile(exitFile);
|
|
1054
|
+
const code = Number.parseInt(raw.trim(), 10);
|
|
1055
|
+
return Number.isFinite(code) ? code : 0;
|
|
1056
|
+
}
|
|
1057
|
+
catch (_error) {
|
|
1058
|
+
// No exit file yet — the command is still running. Not an error.
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
let exitCode = await readExit();
|
|
1063
|
+
while (exitCode === null && Date.now() - startedAt < budgetMs) {
|
|
1064
|
+
await new Promise((r) => setTimeout(r, Math.min(2_000, budgetMs - (Date.now() - startedAt))));
|
|
1065
|
+
exitCode = await readExit();
|
|
1066
|
+
}
|
|
1067
|
+
const waitedMs = Date.now() - startedAt;
|
|
1068
|
+
waitedByTask.set(taskId, waitedBefore + waitedMs);
|
|
1069
|
+
if (exitCode === null) {
|
|
1070
|
+
if (waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
1071
|
+
return {
|
|
1072
|
+
taskId,
|
|
1073
|
+
status: 'stuck',
|
|
1074
|
+
waitedMs: waitedBefore + waitedMs,
|
|
1075
|
+
note: `${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting and it has not exited — it is ` +
|
|
1076
|
+
`stuck, not slow. Read /tmp/${taskId}.log for what it printed, kill it (exec_command: ` +
|
|
1077
|
+
`kill $(cat /tmp/${taskId}.pid) 2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
return {
|
|
1081
|
+
taskId,
|
|
1082
|
+
status: 'running',
|
|
1083
|
+
waitedMs,
|
|
1084
|
+
note: `Still running after ${Math.round(waitedMs / 1000)}s. Call wait_for_task again to ` +
|
|
1085
|
+
`keep waiting, or read ${log} for its output so far.`,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
let output;
|
|
1089
|
+
try {
|
|
1090
|
+
output = await backend.readFile(log);
|
|
1091
|
+
}
|
|
1092
|
+
catch (_error) {
|
|
1093
|
+
// The command finished but wrote nothing (or the log was removed);
|
|
1094
|
+
// an empty output with a real exit code is still a result.
|
|
1095
|
+
output = '';
|
|
1096
|
+
}
|
|
1097
|
+
// Same shaping as a foreground exec result, so the executor reads both
|
|
1098
|
+
// the same way.
|
|
1099
|
+
return {
|
|
1100
|
+
taskId,
|
|
1101
|
+
exitCode,
|
|
1102
|
+
waitedMs,
|
|
1103
|
+
stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
|
|
1104
|
+
...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
|
|
1105
|
+
};
|
|
1106
|
+
},
|
|
723
1107
|
async save_plan(input) {
|
|
724
1108
|
const name = input.name;
|
|
725
1109
|
// The schema field is `content`, but a weak executor frequently sends the plan under
|
|
@@ -794,7 +1178,13 @@ export function buildTools(backend, config) {
|
|
|
794
1178
|
name: schema.name,
|
|
795
1179
|
description: schema.description,
|
|
796
1180
|
parameters: schema.parameters,
|
|
797
|
-
|
|
1181
|
+
// Every tool goes through the same guard: parameter aliases are moved onto
|
|
1182
|
+
// the declared names, a missing required one returns a model-actionable
|
|
1183
|
+
// error instead of reaching a handler as `undefined`, and a handler that
|
|
1184
|
+
// throws returns an error rather than a bare internal message. See
|
|
1185
|
+
// input-normalizer.ts for why (X0 R83: `{ cmd }` instead of
|
|
1186
|
+
// `{ command }` crashed exec_command and the executor stopped verifying).
|
|
1187
|
+
execute: guardToolExecute(schema, impl),
|
|
798
1188
|
});
|
|
799
1189
|
}
|
|
800
1190
|
return allTools;
|