@molecule/api-ai-tools 1.0.9 → 1.1.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 +241 -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 +450 -88
- package/dist/utilities.d.ts +79 -0
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +130 -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, 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
|
*
|
|
@@ -221,6 +229,155 @@ export function buildTools(backend, config) {
|
|
|
221
229
|
};
|
|
222
230
|
}
|
|
223
231
|
// ── Tool implementations ───────────────────────────────────────
|
|
232
|
+
/**
|
|
233
|
+
* Read ONE file with every DWIM behavior read_file offers (directory
|
|
234
|
+
* listing, ENOENT parent listing, empty-read classification). Shared by the
|
|
235
|
+
* single-path and batched forms of the tool.
|
|
236
|
+
*
|
|
237
|
+
* @param rawPath - The path as the model sent it.
|
|
238
|
+
* @returns The same result shape read_file returns for one file.
|
|
239
|
+
*/
|
|
240
|
+
/**
|
|
241
|
+
* How many leading lines of a file fit in a char budget.
|
|
242
|
+
*
|
|
243
|
+
* @param content - The file text.
|
|
244
|
+
* @param chars - The budget in chars.
|
|
245
|
+
* @returns The line count, at least one.
|
|
246
|
+
*/
|
|
247
|
+
function linesWithin(content, chars) {
|
|
248
|
+
let used = 0;
|
|
249
|
+
let lines = 0;
|
|
250
|
+
for (const line of content.split('\n')) {
|
|
251
|
+
used += line.length + 1;
|
|
252
|
+
if (used > chars && lines > 0)
|
|
253
|
+
break;
|
|
254
|
+
lines++;
|
|
255
|
+
}
|
|
256
|
+
return Math.max(1, lines);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Read one file for the model: symlink-checked, size-bounded, optionally
|
|
260
|
+
* windowed by line, and never more than MAX_READ_RETURN_CHARS at once.
|
|
261
|
+
*
|
|
262
|
+
* @param rawPath - The path as the model sent it.
|
|
263
|
+
* @param window - Optional `offset`/`limit` in lines.
|
|
264
|
+
* @returns The file content (or a window of it) or a model-readable error.
|
|
265
|
+
*/
|
|
266
|
+
async function readOneFile(rawPath, window) {
|
|
267
|
+
const argErr = pathArgError(rawPath, 'read_file');
|
|
268
|
+
if (argErr)
|
|
269
|
+
return { error: argErr };
|
|
270
|
+
const path = resolve(rawPath);
|
|
271
|
+
const symlinkErr = await checkSymlink(path);
|
|
272
|
+
if (symlinkErr)
|
|
273
|
+
return { error: symlinkErr };
|
|
274
|
+
try {
|
|
275
|
+
const content = await backend.readFile(path);
|
|
276
|
+
// A read that produced no usable text is verified against the file's real
|
|
277
|
+
// size before it can be reported as content (see classifyEmptyRead). The
|
|
278
|
+
// non-string arm covers a backend whose transport handed back an empty
|
|
279
|
+
// body as `undefined`; `content.length` below would otherwise raise an
|
|
280
|
+
// unactionable "Cannot read properties of undefined".
|
|
281
|
+
if (typeof content !== 'string' || content === '') {
|
|
282
|
+
const verdict = await classifyEmptyRead(path, 'read_file');
|
|
283
|
+
if ('error' in verdict)
|
|
284
|
+
return verdict;
|
|
285
|
+
return {
|
|
286
|
+
path,
|
|
287
|
+
content: sanitizeFileContent(verdict.content, path),
|
|
288
|
+
...(verdict.content === '' ? { empty: true } : {}),
|
|
289
|
+
...(verdict.note ? { note: verdict.note } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (content.length > MAX_READ_SIZE)
|
|
293
|
+
return {
|
|
294
|
+
error: `File too large (${Math.round(content.length / 1024)}KB). Maximum is ${MAX_READ_SIZE / 1024 / 1024}MB.`,
|
|
295
|
+
};
|
|
296
|
+
// No window asked for and the file is bigger than one call should hand
|
|
297
|
+
// back: return its first window, sized to the ceiling, and say so. The
|
|
298
|
+
// model can read on with offset/limit; it cannot un-send 300k tokens.
|
|
299
|
+
const effectiveWindow = !sliceLines(content, window) && content.length > MAX_READ_RETURN_CHARS
|
|
300
|
+
? { offset: 1, limit: linesWithin(content, MAX_READ_RETURN_CHARS) }
|
|
301
|
+
: window;
|
|
302
|
+
const windowed = sliceLines(content, effectiveWindow);
|
|
303
|
+
if (windowed) {
|
|
304
|
+
return {
|
|
305
|
+
path,
|
|
306
|
+
content: sanitizeFileContent(windowed.text, path),
|
|
307
|
+
offset: windowed.offset,
|
|
308
|
+
lines: windowed.lines,
|
|
309
|
+
totalLines: windowed.totalLines,
|
|
310
|
+
// The note decides the executor's next call. Observed (X0 run x7): a
|
|
311
|
+
// 44 KB README read as seven 300-line windows, one round trip each,
|
|
312
|
+
// because the note only ever said "read another window" — when the
|
|
313
|
+
// whole file fits in one call, say that; when it does not, name the
|
|
314
|
+
// exact next window so the executor does not have to compute it.
|
|
315
|
+
...(windowed.truncated
|
|
316
|
+
? {
|
|
317
|
+
note: content.length <= MAX_READ_RETURN_CHARS
|
|
318
|
+
? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
319
|
+
`${windowed.totalLines}. The whole file is ${Math.round(content.length / 1024)} KB ` +
|
|
320
|
+
`and fits in ONE call — read it again with no offset/limit instead of paging.`
|
|
321
|
+
: `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
322
|
+
`${windowed.totalLines}. Next window: offset ${windowed.offset + windowed.lines}, ` +
|
|
323
|
+
`limit ${windowed.lines}.`,
|
|
324
|
+
}
|
|
325
|
+
: {}),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return { path, content: sanitizeFileContent(content, path) };
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
// Backends (e.g. the docker provider's `cat`) already prefix "Failed to read <path>: ";
|
|
332
|
+
// strip it so the wrap below doesn't stutter ("Failed to read X: Failed to read X: …").
|
|
333
|
+
const rawMessage = e.message;
|
|
334
|
+
const prefix = `Failed to read ${path}: `;
|
|
335
|
+
const message = rawMessage.startsWith(prefix) ? rawMessage.slice(prefix.length) : rawMessage;
|
|
336
|
+
// A weak model often read_file's a directory (handlers/, migrations/). Rather than
|
|
337
|
+
// erroring and costing it a retry loop (observed: 3 such misses + follow-up
|
|
338
|
+
// list_files in one custom build), DWIM: return the directory's listing — what it
|
|
339
|
+
// almost certainly wanted — with a note so it read_file's a specific entry next.
|
|
340
|
+
if (directoryReadHint(message, path)) {
|
|
341
|
+
try {
|
|
342
|
+
const entries = await backend.readDir(path);
|
|
343
|
+
return {
|
|
344
|
+
path,
|
|
345
|
+
isDirectory: true,
|
|
346
|
+
note: `${path} is a directory, not a file — returning its contents. read_file a specific entry inside it to see that file's content.`,
|
|
347
|
+
entries: entries.map((entry) => ({ name: entry.name, type: entry.type })),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
catch (_dirErr) {
|
|
351
|
+
// readDir also failed — fall through to the steer-to-list_files hint below.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// ENOENT DWIM: a weak model GUESSES paths from framework priors (observed: 65 of 89
|
|
355
|
+
// reads in one imported-app build were misses on files that never existed). Return
|
|
356
|
+
// ground truth in the SAME result — what the parent directory actually contains — so
|
|
357
|
+
// the next read uses a real name instead of another guess.
|
|
358
|
+
if (/No such file or directory/i.test(message)) {
|
|
359
|
+
const parent = path.replace(/\/[^/]*$/, '') || '/';
|
|
360
|
+
let listing;
|
|
361
|
+
try {
|
|
362
|
+
const entries = await backend.readDir(parent);
|
|
363
|
+
const names = entries
|
|
364
|
+
.slice(0, 40)
|
|
365
|
+
.map((entry) => entry.name + (entry.type === 'directory' ? '/' : ''));
|
|
366
|
+
listing = names.length
|
|
367
|
+
? `The directory ${parent} exists and contains: ${names.join(', ')}${entries.length > 40 ? ', …' : ''}.`
|
|
368
|
+
: `The directory ${parent} exists but is EMPTY.`;
|
|
369
|
+
}
|
|
370
|
+
catch (_parentErr) {
|
|
371
|
+
// Parent listing failed too — most usefully because it doesn't exist either.
|
|
372
|
+
listing = `The directory ${parent} does not exist either.`;
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
error: `No such file: ${path}. ${listing} Read one of the real entries (or use find_files) — do not guess paths.`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return { error: directoryReadHint(message, path) ?? `Failed to read ${path}: ${message}` };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
224
381
|
const toolImpls = {
|
|
225
382
|
async list_files(input) {
|
|
226
383
|
const path = resolve(input.path || '');
|
|
@@ -236,87 +393,46 @@ export function buildTools(backend, config) {
|
|
|
236
393
|
}
|
|
237
394
|
},
|
|
238
395
|
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;
|
|
396
|
+
// Several paths in ONE call. The executor issues one tool call per model
|
|
397
|
+
// round-trip 89% of the time (X0: 464 assistant messages, 515 tool calls,
|
|
398
|
+
// a ~10s mean gap), so a turn's wall clock IS its round-trip count — and
|
|
399
|
+
// the largest single block measured was a systematic scaffold survey:
|
|
400
|
+
// 121 sequential read_file calls, 108 of them in the first ten minutes,
|
|
401
|
+
// across 93 distinct files. The same reads batched cost the same tokens
|
|
402
|
+
// and a tenth of the latency.
|
|
403
|
+
if (Array.isArray(input.paths)) {
|
|
404
|
+
const requested = input.paths.map((p) => String(p ?? '')).filter(Boolean);
|
|
405
|
+
if (requested.length === 0) {
|
|
257
406
|
return {
|
|
258
|
-
path,
|
|
259
|
-
|
|
260
|
-
...(verdict.content === '' ? { empty: true } : {}),
|
|
261
|
-
...(verdict.note ? { note: verdict.note } : {}),
|
|
407
|
+
error: 'read_file got an empty "paths" array. Pass "path" for one file, or "paths" with at ' +
|
|
408
|
+
'least one path for several.',
|
|
262
409
|
};
|
|
263
410
|
}
|
|
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
|
-
};
|
|
289
|
-
}
|
|
290
|
-
catch (_dirErr) {
|
|
291
|
-
// readDir also failed — fall through to the steer-to-list_files hint below.
|
|
411
|
+
const files = [];
|
|
412
|
+
let budget = MAX_BATCH_READ_BYTES;
|
|
413
|
+
let stoppedAt = null;
|
|
414
|
+
for (const [index, requestedPath] of requested.entries()) {
|
|
415
|
+
if (index >= MAX_BATCH_READ_FILES || budget <= 0) {
|
|
416
|
+
stoppedAt = requestedPath;
|
|
417
|
+
break;
|
|
292
418
|
}
|
|
419
|
+
const one = (await readOneFile(requestedPath, input));
|
|
420
|
+
files.push(one);
|
|
421
|
+
if (typeof one.content === 'string')
|
|
422
|
+
budget -= one.content.length;
|
|
293
423
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
};
|
|
317
|
-
}
|
|
318
|
-
return { error: directoryReadHint(message, path) ?? `Failed to read ${path}: ${message}` };
|
|
424
|
+
return {
|
|
425
|
+
files,
|
|
426
|
+
...(stoppedAt
|
|
427
|
+
? {
|
|
428
|
+
note: `Stopped after ${files.length} of ${requested.length} files (limit: ${MAX_BATCH_READ_FILES} ` +
|
|
429
|
+
`files or ${Math.round(MAX_BATCH_READ_BYTES / 1024)}KB per call). Call read_file again ` +
|
|
430
|
+
`starting at ${stoppedAt}.`,
|
|
431
|
+
}
|
|
432
|
+
: {}),
|
|
433
|
+
};
|
|
319
434
|
}
|
|
435
|
+
return readOneFile(input.path, input);
|
|
320
436
|
},
|
|
321
437
|
async write_file(input) {
|
|
322
438
|
const argErr = pathArgError(input.path, 'write_file');
|
|
@@ -473,8 +589,27 @@ export function buildTools(backend, config) {
|
|
|
473
589
|
if (idx >= 0)
|
|
474
590
|
return showSnippet(idx);
|
|
475
591
|
}
|
|
592
|
+
// Nothing anchored. Say WHICH of the two situations this is — they
|
|
593
|
+
// call for different next moves, and the old message implied the
|
|
594
|
+
// first while being returned for both. Measured across six agent
|
|
595
|
+
// runs, `old_string not found` was the largest remaining tool-error
|
|
596
|
+
// class (15 of 64) and every one of these fell through to a bare
|
|
597
|
+
// "re-read and copy the exact text", which is wrong advice when the
|
|
598
|
+
// text is not in the file at all.
|
|
599
|
+
const anchorsPresent = distinctiveLines.some((anchor) => fileLines.some((l) => l.includes(anchor)));
|
|
600
|
+
if (!anchorsPresent) {
|
|
601
|
+
return {
|
|
602
|
+
error: `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
|
|
603
|
+
`that file, so this is not a whitespace problem. Either the edit was already ` +
|
|
604
|
+
`applied, or this is the wrong file. read_file it and look before trying ` +
|
|
605
|
+
`again; do not retry the same old_string.`,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
476
608
|
return {
|
|
477
|
-
error: `old_string not found in ${path}.
|
|
609
|
+
error: `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
|
|
610
|
+
`line is distinctive enough to locate the target. read_file the region you ` +
|
|
611
|
+
`mean and copy old_string verbatim from what it returns — including ` +
|
|
612
|
+
`indentation — or include more surrounding context to make it unique.`,
|
|
478
613
|
};
|
|
479
614
|
}
|
|
480
615
|
if (count > 1)
|
|
@@ -507,16 +642,27 @@ export function buildTools(backend, config) {
|
|
|
507
642
|
const pattern = input.pattern;
|
|
508
643
|
const path = resolve(input.path || '');
|
|
509
644
|
const include = input.include;
|
|
645
|
+
// Lines of surrounding context per match. The executor reaches for
|
|
646
|
+
// `grep -A 20` through the shell because this tool could not express it;
|
|
647
|
+
// bounded so a broad pattern cannot pull the whole tree into the reply.
|
|
648
|
+
const rawContext = Number(input.contextLines ?? input.context);
|
|
649
|
+
const contextLines = Number.isFinite(rawContext)
|
|
650
|
+
? Math.min(Math.max(Math.trunc(rawContext), 0), MAX_SEARCH_CONTEXT_LINES)
|
|
651
|
+
: 0;
|
|
510
652
|
if (include && !isValidGlob(include))
|
|
511
653
|
return {
|
|
512
|
-
error: 'Invalid include glob pattern. Allowed: alphanumeric and * ? . _ - / [ ] ( ).',
|
|
654
|
+
error: 'Invalid include glob pattern. Allowed: alphanumeric and @ * ? . _ - / [ ] ( ).',
|
|
513
655
|
};
|
|
514
656
|
const symlinkErr = await checkSymlink(path);
|
|
515
657
|
if (symlinkErr)
|
|
516
658
|
return { error: symlinkErr };
|
|
517
659
|
try {
|
|
518
660
|
const globArg = include ? `--include=${shellQuote(include)}` : '';
|
|
519
|
-
|
|
661
|
+
// With context, grep emits `file-line-content` for context lines and
|
|
662
|
+
// `file:line:content` for matches, plus `--` group separators. Both
|
|
663
|
+
// shapes are parsed below; without context the output is unchanged.
|
|
664
|
+
const contextArg = contextLines > 0 ? `-C ${contextLines}` : '';
|
|
665
|
+
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
666
|
// grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
|
|
521
667
|
// content alone: the file prefix would otherwise hide an env assignment from
|
|
522
668
|
// the line-anchored env pattern, and knowing each match's own path is what
|
|
@@ -524,14 +670,31 @@ export function buildTools(backend, config) {
|
|
|
524
670
|
const output = stripControlChars(result.stdout.trim());
|
|
525
671
|
if (!output)
|
|
526
672
|
return { pattern, path, matches: [] };
|
|
673
|
+
const cap = contextLines > 0 ? MAX_SEARCH_RESULTS * (contextLines * 2 + 2) : MAX_SEARCH_RESULTS;
|
|
527
674
|
const matches = output
|
|
528
675
|
.split('\n')
|
|
529
|
-
.slice(0,
|
|
676
|
+
.slice(0, cap)
|
|
677
|
+
.filter((line) => line !== '--')
|
|
530
678
|
.map((line) => {
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
679
|
+
const hit = line.match(/^(.+?):(\d+):(.*)$/);
|
|
680
|
+
if (hit) {
|
|
681
|
+
return {
|
|
682
|
+
file: hit[1],
|
|
683
|
+
line: parseInt(hit[2]),
|
|
684
|
+
content: sanitizeFileContent(hit[3], hit[1]),
|
|
685
|
+
...(contextLines > 0 ? { match: true } : {}),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const ctx = contextLines > 0 ? line.match(/^(.+?)-(\d+)-(.*)$/) : null;
|
|
689
|
+
if (ctx) {
|
|
690
|
+
return {
|
|
691
|
+
file: ctx[1],
|
|
692
|
+
line: parseInt(ctx[2]),
|
|
693
|
+
content: sanitizeFileContent(ctx[3], ctx[1]),
|
|
694
|
+
match: false,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
return { file: '', line: 0, content: sanitizeFileContent(line, '') };
|
|
535
698
|
});
|
|
536
699
|
return { pattern, path, matches };
|
|
537
700
|
}
|
|
@@ -551,7 +714,7 @@ export function buildTools(backend, config) {
|
|
|
551
714
|
};
|
|
552
715
|
if (!isValidGlob(pattern))
|
|
553
716
|
return {
|
|
554
|
-
error: 'Invalid pattern. Allowed: alphanumeric and * ? . _ - / [ ] ( ) ' +
|
|
717
|
+
error: 'Invalid pattern. Allowed: alphanumeric and @ * ? . _ - / [ ] ( ) ' +
|
|
555
718
|
'(brackets/parens support Next.js route dirs like [id] or (group)).',
|
|
556
719
|
};
|
|
557
720
|
const symlinkErr = await checkSymlink(path);
|
|
@@ -623,6 +786,17 @@ export function buildTools(backend, config) {
|
|
|
623
786
|
async exec_command(input) {
|
|
624
787
|
const command = input.command;
|
|
625
788
|
const cwd = input.cwd ? resolve(input.cwd) : root;
|
|
789
|
+
// A model that sent the command under another name (`cmd`, `script`, …)
|
|
790
|
+
// has already been normalized by guardToolExecute; reaching here with
|
|
791
|
+
// nothing means there is genuinely no command. Say so in the terms that
|
|
792
|
+
// let the model fix it. It used to fall through to
|
|
793
|
+
// checkBlockedCommand(undefined) and die with "Cannot read properties of
|
|
794
|
+
// undefined (reading 'match')" — the executor read that as a broken shell,
|
|
795
|
+
// stopped verifying, and ended the turn claiming all checks passed while
|
|
796
|
+
// seven acceptance checks failed (X0 rehearsal 83, 2026-09-16).
|
|
797
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
798
|
+
return { error: missingParamError(TOOL_SCHEMAS.exec_command, input, ['command']) };
|
|
799
|
+
}
|
|
626
800
|
if (blockDangerousCommands) {
|
|
627
801
|
const blocked = checkBlockedCommand(command);
|
|
628
802
|
if (blocked)
|
|
@@ -635,6 +809,70 @@ export function buildTools(backend, config) {
|
|
|
635
809
|
if (blocked)
|
|
636
810
|
return { error: blocked };
|
|
637
811
|
}
|
|
812
|
+
// Detached: start it, hand back a handle, let the turn continue. A
|
|
813
|
+
// command that outlives the tool's ceiling is otherwise simply lost —
|
|
814
|
+
// measured across six agent runs, ten of them were killed at the
|
|
815
|
+
// ceiling for 49 minutes, 11% of all wall clock, every one returning
|
|
816
|
+
// nothing because it was piped through tail/grep. A full test suite or a
|
|
817
|
+
// slow production build could not be run at all, so the executor ran
|
|
818
|
+
// smaller and smaller pieces, or reported a result it never saw.
|
|
819
|
+
//
|
|
820
|
+
// Output goes to a file the executor reads with read_file (which takes
|
|
821
|
+
// offset/limit, so a long log is cheap to follow), and an `.exit` file
|
|
822
|
+
// appears when it finishes — so "is it done?" is a read, not a poll that
|
|
823
|
+
// blocks a turn.
|
|
824
|
+
if (input.run_in_background === true) {
|
|
825
|
+
const id = `mol-bg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
826
|
+
const log = `/tmp/${id}.log`;
|
|
827
|
+
const exitFile = `/tmp/${id}.exit`;
|
|
828
|
+
const script = `/tmp/${id}.sh`;
|
|
829
|
+
try {
|
|
830
|
+
// The command goes to a FILE rather than through nested quoting:
|
|
831
|
+
// an executor's commands routinely carry quotes, heredocs and
|
|
832
|
+
// newlines, and re-quoting them into a detached `sh -c` is how a
|
|
833
|
+
// background runner corrupts the very thing it is running.
|
|
834
|
+
await backend.writeFile(script, `${command}\n`);
|
|
835
|
+
await backend.run(`nohup sh -c ${shellQuote(`sh ${script} > ${log} 2>&1; echo $? > ${exitFile}`)} ` +
|
|
836
|
+
`> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
|
|
837
|
+
return {
|
|
838
|
+
taskId: id,
|
|
839
|
+
log,
|
|
840
|
+
exitFile,
|
|
841
|
+
note: `Started in the background. Its output is being written to ${log} — read it with ` +
|
|
842
|
+
`read_file (use offset/limit to follow a long one). When it finishes, ${exitFile} ` +
|
|
843
|
+
`appears and holds the exit code. If you have other work, do it and check back. If ` +
|
|
844
|
+
`you need this result before you can continue, call wait_for_task with this taskId — ` +
|
|
845
|
+
`never sleep and read the log by hand.`,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
catch (e) {
|
|
849
|
+
return {
|
|
850
|
+
error: `Could not start the background command: ${e.message}`,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// A command that declares its OWN budget larger than this tool's ceiling,
|
|
855
|
+
// and whose output is withheld until the pipeline ends, cannot produce
|
|
856
|
+
// anything: it will be stopped at the ceiling having printed nothing.
|
|
857
|
+
// Measured across six agent runs, that combination was ten commands and
|
|
858
|
+
// 49 minutes — 11% of all wall clock — each returning a few hundred bytes.
|
|
859
|
+
// Refusing in five seconds, naming both ways out, is strictly better than
|
|
860
|
+
// spending the ceiling to say the same thing afterwards. A command that
|
|
861
|
+
// declares nothing still RUNS: partial output survives an overrun, so
|
|
862
|
+
// there is something to learn either way.
|
|
863
|
+
if (commandBudgetMs) {
|
|
864
|
+
const ceiling = Math.max(1, Math.round(commandBudgetMs / 1000));
|
|
865
|
+
const declared = declaredBudgetSeconds(command, input.timeout);
|
|
866
|
+
if (declared > ceiling && outputIsWithheldUntilExit(command)) {
|
|
867
|
+
return {
|
|
868
|
+
error: `This command asks for ${declared}s and this tool stops at ${ceiling}s — and its output ` +
|
|
869
|
+
'is piped into tail/head/grep, which print nothing until the pipeline ends, so it ' +
|
|
870
|
+
'would spend the whole budget and hand you back nothing. Either run a smaller unit ' +
|
|
871
|
+
'(one spec file, one build step) that finishes inside the limit, or drop the pipe ' +
|
|
872
|
+
'and use a streaming reporter so the output survives being stopped. Nothing was run.',
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
}
|
|
638
876
|
try {
|
|
639
877
|
// exec_command runs installs/builds/tests — the old 30s hardcap killed
|
|
640
878
|
// those spuriously; use the (generous, caller-configurable) budget.
|
|
@@ -644,10 +882,26 @@ export function buildTools(backend, config) {
|
|
|
644
882
|
// printed until then comes back with exit code 124 — instead of an outer
|
|
645
883
|
// timeout discarding the run and its output together.
|
|
646
884
|
const budgetSeconds = commandBudgetMs ? Math.max(1, Math.round(commandBudgetMs / 1000)) : 0;
|
|
885
|
+
// A requested budget is honored DOWNWARD only. The model used to send
|
|
886
|
+
// `timeout: 900000` for a test suite, the field was not declared so it
|
|
887
|
+
// was silently dropped, and the command was killed at the ceiling with
|
|
888
|
+
// no hint that a ceiling existed — costing a full budget per attempt
|
|
889
|
+
// before the overrun message could teach anything (X0 R83/R84). Now the
|
|
890
|
+
// ceiling is reported the FIRST time a larger one is asked for.
|
|
891
|
+
const requestedMs = Number(input.timeout);
|
|
892
|
+
const wantsMore = Number.isFinite(requestedMs) && requestedMs > 0 && requestedMs > (commandBudgetMs ?? 0);
|
|
893
|
+
const effectiveBudgetMs = Number.isFinite(requestedMs) && requestedMs > 0 && commandBudgetMs
|
|
894
|
+
? Math.min(requestedMs, commandBudgetMs)
|
|
895
|
+
: commandBudgetMs;
|
|
896
|
+
const ceilingNote = wantsMore && budgetSeconds
|
|
897
|
+
? `You asked for ${Math.round(requestedMs / 1000)}s; this tool's ceiling is ${budgetSeconds}s. ` +
|
|
898
|
+
'Split the work into smaller commands (one test file, one build step) rather than ' +
|
|
899
|
+
're-running the same long one.'
|
|
900
|
+
: null;
|
|
647
901
|
const result = await backend.run(command, {
|
|
648
902
|
cwd,
|
|
649
903
|
timeout: execTimeoutMs,
|
|
650
|
-
...(
|
|
904
|
+
...(effectiveBudgetMs ? { budgetMs: effectiveBudgetMs } : {}),
|
|
651
905
|
});
|
|
652
906
|
// truncateMiddle (not truncate): a failing build/test/migration puts its
|
|
653
907
|
// error at the TAIL, so keep the head AND the tail — head-only truncation
|
|
@@ -668,7 +922,12 @@ export function buildTools(backend, config) {
|
|
|
668
922
|
'a build and a whole suite.',
|
|
669
923
|
};
|
|
670
924
|
}
|
|
671
|
-
return {
|
|
925
|
+
return {
|
|
926
|
+
stdout,
|
|
927
|
+
stderr,
|
|
928
|
+
exitCode: result.exitCode,
|
|
929
|
+
...(ceilingNote ? { note: ceilingNote } : {}),
|
|
930
|
+
};
|
|
672
931
|
}
|
|
673
932
|
catch (e) {
|
|
674
933
|
return { error: `Command failed: ${e.message}` };
|
|
@@ -720,6 +979,103 @@ export function buildTools(backend, config) {
|
|
|
720
979
|
error: `Skill '${name}' not found. Searched .agents/skills/${name}/SKILL.md and .claude/skills/${name}/SKILL.md`,
|
|
721
980
|
};
|
|
722
981
|
},
|
|
982
|
+
// The other half of run_in_background. A handle is only useful if the
|
|
983
|
+
// executor has other work to do while the command runs; when it needs the
|
|
984
|
+
// result to continue — a build before the tests, tests before "done" — it
|
|
985
|
+
// has nothing to do but wait, and without this it waited by calling
|
|
986
|
+
// `sleep 75; cat <log>` in exec_command, one round trip per poll, each at
|
|
987
|
+
// the full context. Observed on X0 run x1 (2026-09-22): the first two
|
|
988
|
+
// background builds were each followed by a sleep-and-cat call. Waiting
|
|
989
|
+
// server-side costs no round trip and no context.
|
|
990
|
+
async wait_for_task(input) {
|
|
991
|
+
const taskId = typeof input.taskId === 'string' ? input.taskId.trim() : '';
|
|
992
|
+
// A command that has been waited on for stuckAfterMs() in total without
|
|
993
|
+
// exiting is not going to: on one run the executor waited five times,
|
|
994
|
+
// 120 s each, on an e2e suite that could never start (no browser page
|
|
995
|
+
// attached). Say so once, name the pid, and refuse to wait on it again.
|
|
996
|
+
const waitedBefore = waitedByTask.get(taskId) ?? 0;
|
|
997
|
+
if (taskId && waitedBefore >= stuckAfterMs()) {
|
|
998
|
+
return {
|
|
999
|
+
taskId,
|
|
1000
|
+
status: 'stuck',
|
|
1001
|
+
waitedMs: waitedBefore,
|
|
1002
|
+
note: `You have already waited ${Math.round(waitedBefore / 1000)}s on this command and it ` +
|
|
1003
|
+
`has not exited — it is stuck, not slow. Kill it (exec_command: kill $(cat /tmp/${taskId}.pid) ` +
|
|
1004
|
+
`2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
// The id names files under /tmp; only an id this tool minted may be used
|
|
1008
|
+
// to read them, so a crafted id cannot turn this into a file reader.
|
|
1009
|
+
if (!/^mol-bg-[a-z0-9]+-[a-z0-9]+$/.test(taskId)) {
|
|
1010
|
+
return {
|
|
1011
|
+
error: 'taskId must be the id a run_in_background command returned (it looks like ' +
|
|
1012
|
+
'`mol-bg-…`).',
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
const log = `/tmp/${taskId}.log`;
|
|
1016
|
+
const exitFile = `/tmp/${taskId}.exit`;
|
|
1017
|
+
// Leave the outer tool ceiling a margin so this returns a result rather
|
|
1018
|
+
// than being killed by it.
|
|
1019
|
+
const ceiling = Math.max(5_000, (commandBudgetMs ?? 290_000) - 10_000);
|
|
1020
|
+
const requested = Number(input.timeout);
|
|
1021
|
+
const budgetMs = Number.isFinite(requested) && requested > 0 ? Math.min(requested, ceiling) : ceiling;
|
|
1022
|
+
const startedAt = Date.now();
|
|
1023
|
+
const readExit = async () => {
|
|
1024
|
+
try {
|
|
1025
|
+
const raw = await backend.readFile(exitFile);
|
|
1026
|
+
const code = Number.parseInt(raw.trim(), 10);
|
|
1027
|
+
return Number.isFinite(code) ? code : 0;
|
|
1028
|
+
}
|
|
1029
|
+
catch (_error) {
|
|
1030
|
+
// No exit file yet — the command is still running. Not an error.
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
let exitCode = await readExit();
|
|
1035
|
+
while (exitCode === null && Date.now() - startedAt < budgetMs) {
|
|
1036
|
+
await new Promise((r) => setTimeout(r, Math.min(2_000, budgetMs - (Date.now() - startedAt))));
|
|
1037
|
+
exitCode = await readExit();
|
|
1038
|
+
}
|
|
1039
|
+
const waitedMs = Date.now() - startedAt;
|
|
1040
|
+
waitedByTask.set(taskId, waitedBefore + waitedMs);
|
|
1041
|
+
if (exitCode === null) {
|
|
1042
|
+
if (waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
1043
|
+
return {
|
|
1044
|
+
taskId,
|
|
1045
|
+
status: 'stuck',
|
|
1046
|
+
waitedMs: waitedBefore + waitedMs,
|
|
1047
|
+
note: `${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting and it has not exited — it is ` +
|
|
1048
|
+
`stuck, not slow. Read /tmp/${taskId}.log for what it printed, kill it (exec_command: ` +
|
|
1049
|
+
`kill $(cat /tmp/${taskId}.pid) 2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
taskId,
|
|
1054
|
+
status: 'running',
|
|
1055
|
+
waitedMs,
|
|
1056
|
+
note: `Still running after ${Math.round(waitedMs / 1000)}s. Call wait_for_task again to ` +
|
|
1057
|
+
`keep waiting, or read ${log} for its output so far.`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
let output;
|
|
1061
|
+
try {
|
|
1062
|
+
output = await backend.readFile(log);
|
|
1063
|
+
}
|
|
1064
|
+
catch (_error) {
|
|
1065
|
+
// The command finished but wrote nothing (or the log was removed);
|
|
1066
|
+
// an empty output with a real exit code is still a result.
|
|
1067
|
+
output = '';
|
|
1068
|
+
}
|
|
1069
|
+
// Same shaping as a foreground exec result, so the executor reads both
|
|
1070
|
+
// the same way.
|
|
1071
|
+
return {
|
|
1072
|
+
taskId,
|
|
1073
|
+
exitCode,
|
|
1074
|
+
waitedMs,
|
|
1075
|
+
stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
|
|
1076
|
+
...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
|
|
1077
|
+
};
|
|
1078
|
+
},
|
|
723
1079
|
async save_plan(input) {
|
|
724
1080
|
const name = input.name;
|
|
725
1081
|
// The schema field is `content`, but a weak executor frequently sends the plan under
|
|
@@ -794,7 +1150,13 @@ export function buildTools(backend, config) {
|
|
|
794
1150
|
name: schema.name,
|
|
795
1151
|
description: schema.description,
|
|
796
1152
|
parameters: schema.parameters,
|
|
797
|
-
|
|
1153
|
+
// Every tool goes through the same guard: parameter aliases are moved onto
|
|
1154
|
+
// the declared names, a missing required one returns a model-actionable
|
|
1155
|
+
// error instead of reaching a handler as `undefined`, and a handler that
|
|
1156
|
+
// throws returns an error rather than a bare internal message. See
|
|
1157
|
+
// input-normalizer.ts for why (X0 R83: `{ cmd }` instead of
|
|
1158
|
+
// `{ command }` crashed exec_command and the executor stopped verifying).
|
|
1159
|
+
execute: guardToolExecute(schema, impl),
|
|
798
1160
|
});
|
|
799
1161
|
}
|
|
800
1162
|
return allTools;
|