@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/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
- import { checkBlockedCommand, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, isEnvFilePath, isValidGlob, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_SIZE, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, pathArgError, redactSecrets, redactSecretsInCode, resolvePath, shellQuote, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
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
- const argErr = pathArgError(input.path, 'read_file');
240
- if (argErr)
241
- return { error: argErr };
242
- const path = resolve(input.path);
243
- const symlinkErr = await checkSymlink(path);
244
- if (symlinkErr)
245
- return { error: symlinkErr };
246
- try {
247
- const content = await backend.readFile(path);
248
- // A read that produced no usable text is verified against the file's real
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
- content: sanitizeFileContent(verdict.content, path),
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
- if (content.length > MAX_READ_SIZE)
265
- return {
266
- error: `File too large (${Math.round(content.length / 1024)}KB). Maximum is ${MAX_READ_SIZE / 1024 / 1024}MB.`,
267
- };
268
- return { path, content: sanitizeFileContent(content, path) };
269
- }
270
- catch (e) {
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
- catch (_dirErr) {
291
- // readDir also failed — fall through to the steer-to-list_files hint below.
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 { error: directoryReadHint(message, path) ?? `Failed to read ${path}: ${message}` };
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}. Re-read the file with read_file and copy the exact current text — do not construct old_string from memory or the plan.`,
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 { path, ok: true, replacementsApplied: replacements.length };
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
- const result = await backend.run(`grep -rn ${globArg} ${grepExcludeArgs} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)} 2>/dev/null || true`, { timeout: 10000 });
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, MAX_SEARCH_RESULTS)
704
+ .slice(0, cap)
705
+ .filter((line) => line !== '--')
530
706
  .map((line) => {
531
- const m = line.match(/^(.+?):(\d+):(.*)$/);
532
- return m
533
- ? { file: m[1], line: parseInt(m[2]), content: sanitizeFileContent(m[3], m[1]) }
534
- : { file: '', line: 0, content: sanitizeFileContent(line, '') };
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
- ...(commandBudgetMs ? { budgetMs: commandBudgetMs } : {}),
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 { stdout, stderr, exitCode: result.exitCode };
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
- execute: impl,
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;