@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/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, 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
- 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;
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
- content: sanitizeFileContent(verdict.content, path),
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
- 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
- };
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
- // 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
- };
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}. Re-read the file with read_file and copy the exact current text — do not construct old_string from memory or the plan.`,
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
- const result = await backend.run(`grep -rn ${globArg} ${grepExcludeArgs} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)} 2>/dev/null || true`, { timeout: 10000 });
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, MAX_SEARCH_RESULTS)
676
+ .slice(0, cap)
677
+ .filter((line) => line !== '--')
530
678
  .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, '') };
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
- ...(commandBudgetMs ? { budgetMs: commandBudgetMs } : {}),
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 { stdout, stderr, exitCode: result.exitCode };
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
- execute: impl,
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;