@molecule/api-ai-tools 1.6.5 → 1.7.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,6 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence } from './cut-run.js';
9
10
  import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
10
11
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
11
12
  import { TOOL_SCHEMAS } from './schemas.js';
@@ -51,8 +52,20 @@ const repeatingCauseLookMs = () => Number(process.env.MOL_AI_BG_CAUSE_LOOK_MS) |
51
52
  * Task ids a wait has already named a repeating cause for. The note is news once; a second wait
52
53
  * on the same run has nothing to add to it, and re-sending it would spend the executor's turns
53
54
  * telling it what it has already been told.
55
+ *
56
+ * Only a wait that ENDED EARLY on the finding lands here ({@link earlyReturnCause}); a lead does
57
+ * not, because the wait it was found in keeps looking — a run can go from a shared duration to a
58
+ * proven cause failing everything as the app goes down, and that is exactly the finding the wait
59
+ * must not sleep through.
54
60
  */
55
61
  const repeatingCauseNoted = new Set();
62
+ /**
63
+ * Task ids whose mid-run finding has already ridden a result as a note ({@link
64
+ * repeatingFailureNote} without an {@link earlyReturnCause}). Same rule as
65
+ * {@link repeatingCauseNoted} — news once, and the executor still has the ✘ rows in its context —
66
+ * but kept apart from it, because a task in this set keeps being looked at.
67
+ */
68
+ const midRunLeadNoted = new Set();
56
69
  /**
57
70
  * The shell command that stops a background task by pid: the pid the platform wrote when it
58
71
  * detached the run, plus every process under it.
@@ -208,12 +221,44 @@ function runningNote(clauses) {
208
221
  const joined = clauses.join('; ');
209
222
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
210
223
  }
211
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, emptyCallError, failureCausesNote, findPatternArgs, fusedLine, htmlOutline, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isValidGlob, linesWithinSerialized, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
224
+ import { diagnosisTraceCommand } from './trace-artifacts.js';
225
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
212
226
  /**
213
227
  * How long exec_command waits for a command it backgrounded only because the
214
228
  * command asked for more than the tool's ceiling, before handing back a task id.
215
229
  */
216
230
  const OVER_CEILING_GRACE_MS = 45_000;
231
+ /**
232
+ * The closing advice on an over-budget message for a command that is not a test
233
+ * suite: the work it was chaining is what to split.
234
+ */
235
+ export const SPLIT_WORK_ADVICE = 'Run a smaller unit per command (one test file, one build step) instead of chaining ' +
236
+ 'a build and a whole suite.';
237
+ /**
238
+ * The closing advice on an over-budget message for a command that IS a suite run
239
+ * (Playwright, Vitest, a `hurl` run — {@link isSuiteRunCommand}): its files are not
240
+ * a bigger unit to break up.
241
+ *
242
+ * X1 flash-m36 asked for this half and the message kept saying "run a smaller unit"
243
+ * for two months. It is the wrong direction for a suite, and measurably: a run
244
+ * stopped here reports NO tally, so nothing in the result says which test or request
245
+ * hung — the run has to be sent again in the background (off this budget, its output
246
+ * written to a log that can be read mid-run) or, when a consumer knows the runner's
247
+ * per-request limit, with each request bounded. xr-molecule-flash-m93 (+06:22,
248
+ * 2026-10-16) lost 4.8 minutes to a Hurl run whose ONE unanswered request had hurl's
249
+ * 300 s default per request holding the file, ten seconds past this tool's budget.
250
+ */
251
+ export const SUITE_OVER_BUDGET_ADVICE = 'A test suite is not a bigger unit to split: stopped here it reports no tally at all, so nothing ' +
252
+ 'in this result says which test or request never finished — run it in the background ' +
253
+ '(`run_in_background`, then `wait_for_task`), which takes it off this budget and writes its output ' +
254
+ "to a log, and read that log (or the app's own log) for what it was waiting on.";
255
+ /**
256
+ * How long a tree walk (`find_files`, `search_files`) may run before the backend's
257
+ * own deadline stops it. Both tools answer that deadline in their own words
258
+ * (see searchTimeoutError) rather than forwarding the sandbox SDK's `timeoutMs`
259
+ * sentence, which names an option the executor cannot set.
260
+ */
261
+ const SEARCH_TIMEOUT_MS = 10_000;
217
262
  /**
218
263
  * The share of its budget a command must use before the "you asked for more than
219
264
  * this tool's ceiling" note is worth attaching. Below it the clamp changed nothing
@@ -290,6 +335,40 @@ export function installedPackageWriteRefusal(path) {
290
335
  : '';
291
336
  return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
292
337
  }
338
+ /** How many failing replacements one refusal names before it stops listing. */
339
+ const MAX_REPORTED_REPLACEMENT_FAILURES = 5;
340
+ /**
341
+ * The one refusal a batch `edit_file` gets, naming EVERY replacement that did not
342
+ * match — each with the same detail a lone failure gets (which replacement, the
343
+ * head of its old_string, where it diverges from the file).
344
+ *
345
+ * A batch is applied all-or-nothing, so one bad `old_string` means none of the
346
+ * others were applied either: they are not "the rest, which worked", they are
347
+ * unchecked work the model will otherwise re-send as-is. Naming one per round trip
348
+ * cost flash-m98 two full re-sends of a six-replacement batch — the second bad
349
+ * hunk was not reported until the first was fixed, 7 s later.
350
+ *
351
+ * @param path - The file the batch targets (nothing was written to it).
352
+ * @param total - How many replacements the batch carried.
353
+ * @param failures - Every failing replacement, in batch order.
354
+ * @returns The error message.
355
+ */
356
+ function batchFailureError(path, total, failures) {
357
+ // A single replacement keeps the message it has always had: "Replacement 1 of 1"
358
+ // is noise, and the failure of a lone edit is not a batch problem.
359
+ if (total === 1 && failures.length === 1)
360
+ return failures[0].message;
361
+ const shown = failures.slice(0, MAX_REPORTED_REPLACEMENT_FAILURES);
362
+ const omitted = failures.length - shown.length;
363
+ const blocks = shown.map((f) => `Replacement ${f.index} of ${total} failed` +
364
+ (f.start ? ` (its old_string starts "${f.start}")` : '') +
365
+ `:\n${f.message}`);
366
+ return (`edit_file (${path}): nothing was written — ${failures.length} of the ${total} replacements do not match the file, and the batch is all-or-nothing, so re-send it with every one of these fixed.\n\n` +
367
+ blocks.join('\n\n') +
368
+ (omitted > 0
369
+ ? `\n\n(${omitted} more replacement${omitted === 1 ? '' : 's'} also failed and are not shown.)`
370
+ : ''));
371
+ }
293
372
  /**
294
373
  * Build a complete set of AI agent tools bound to an execution backend.
295
374
  *
@@ -1048,6 +1127,13 @@ export function buildTools(backend, config) {
1048
1127
  // Replacements refused for changing the line break at old_string's boundary and
1049
1128
  // nothing else — reported by position, never applied (see below).
1050
1129
  const skipped = [];
1130
+ // Every replacement this batch refuses, collected rather than returned on the
1131
+ // first: a batch is atomic, so the other bad hunks are just as unreported, and
1132
+ // one-per-round-trip cost flash-m98 two full re-sends of a six-replacement
1133
+ // batch (the second bad hunk was not named until the first was fixed).
1134
+ const failures = [];
1135
+ /** How a failure names the replacement it is about: the head of its old_string. */
1136
+ const startOf = (text) => text.split('\n')[0].trim().slice(0, 80);
1051
1137
  // Where each applied replacement landed, in the coordinates of the
1052
1138
  // latest content — rendered into the result so the model sees the edit.
1053
1139
  let spans = [];
@@ -1080,9 +1166,12 @@ export function buildTools(backend, config) {
1080
1166
  // method access) or silently inserts the literal "undefined" (a missing
1081
1167
  // new_string). Fail fast with an actionable message instead.
1082
1168
  if (typeof oldString !== 'string' || oldString === '' || typeof newString !== 'string') {
1083
- return {
1084
- error: `edit_file: each replacement needs a non-empty string old_string and a string new_string (got old_string: ${oldString === '' ? 'empty' : typeof oldString}, new_string: ${typeof newString}). To delete text, pass new_string: "".`,
1085
- };
1169
+ failures.push({
1170
+ index: index + 1,
1171
+ start: typeof oldString === 'string' ? startOf(oldString) : '',
1172
+ message: `each replacement needs a non-empty string old_string and a string new_string (got old_string: ${oldString === '' ? 'empty' : typeof oldString}, new_string: ${typeof newString}). To delete text, pass new_string: "".`,
1173
+ });
1174
+ continue;
1086
1175
  }
1087
1176
  const count = content.split(oldString).length - 1;
1088
1177
  // The edit is ALREADY in the file: old_string is gone and a distinctive
@@ -1104,9 +1193,12 @@ export function buildTools(backend, config) {
1104
1193
  // match failed and hand back the file's actual content so it fixes in
1105
1194
  // one retry instead of many.
1106
1195
  if (oldString.includes('\\n')) {
1107
- return {
1108
- error: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
1109
- };
1196
+ failures.push({
1197
+ index: index + 1,
1198
+ start: startOf(oldString),
1199
+ message: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
1200
+ });
1201
+ continue;
1110
1202
  }
1111
1203
  // Whitespace-only mismatch: the text is there but indentation/spacing
1112
1204
  // differs (tabs vs spaces, trailing whitespace). If a UNIQUE line-run
@@ -1122,9 +1214,12 @@ export function buildTools(backend, config) {
1122
1214
  const normalize = (s) => s.replace(/\s+/g, ' ').trim();
1123
1215
  const normOld = normalize(oldString);
1124
1216
  if (normOld && normalize(content).includes(normOld)) {
1125
- return {
1126
- error: `old_string not found in ${path} — a match exists but whitespace/indentation differs and is not unique enough to apply automatically. Re-read the file and copy the exact text (tabs vs spaces, trailing spaces, blank lines must match), or include more surrounding context.`,
1127
- };
1217
+ failures.push({
1218
+ index: index + 1,
1219
+ start: startOf(oldString),
1220
+ message: `old_string not found in ${path} — a match exists but whitespace/indentation differs and is not unique enough to apply automatically. Re-read the file and copy the exact text (tabs vs spaces, trailing spaces, blank lines must match), or include more surrounding context.`,
1221
+ });
1222
+ continue;
1128
1223
  }
1129
1224
  // Anchor probe: locate the target region by the most DISTINCTIVE line
1130
1225
  // of old_string (longest non-trivial line first, requiring a UNIQUE
@@ -1142,9 +1237,7 @@ export function buildTools(backend, config) {
1142
1237
  .slice(start, idx + 8)
1143
1238
  .map((l, i) => `${start + i + 1}: ${l}`)
1144
1239
  .join('\n');
1145
- return {
1146
- error: `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1147
- };
1240
+ return `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`;
1148
1241
  };
1149
1242
  // An import block the model rebuilt in its own order: the linter's
1150
1243
  // import sort owns that order, so name it and show the real block. X1
@@ -1161,12 +1254,15 @@ export function buildTools(backend, config) {
1161
1254
  .slice(start, at + 1)
1162
1255
  .map((l, i) => `${start + i + 1}: ${l}`)
1163
1256
  .join('\n');
1164
- return {
1165
- error: `old_string not found in ${path}: it is an import block, and the linter's import ` +
1257
+ failures.push({
1258
+ index: index + 1,
1259
+ start: startOf(oldString),
1260
+ message: `old_string not found in ${path}: it is an import block, and the linter's import ` +
1166
1261
  `sort decides its order, so do not rebuild it. The file's import from ` +
1167
1262
  `'${imported}' reads:\n${block}\nEdit one line of it, or add your import as a ` +
1168
1263
  'new line and let the linter sort it.',
1169
- };
1264
+ });
1265
+ continue;
1170
1266
  }
1171
1267
  }
1172
1268
  const distinctiveLines = [
@@ -1201,13 +1297,11 @@ export function buildTools(backend, config) {
1201
1297
  .slice(start, base + k + 6)
1202
1298
  .map((l, i) => `${start + i + 1}: ${l}`)
1203
1299
  .join('\n');
1204
- return {
1205
- error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1206
- `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1207
- `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1208
- `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1209
- `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1210
- };
1300
+ return (`old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1301
+ `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1302
+ `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1303
+ `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1304
+ `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`);
1211
1305
  };
1212
1306
  // The longest run of old_string lines that IS in the file decides which
1213
1307
  // alignment to report, so a later distinctive line can beat an earlier
@@ -1239,10 +1333,22 @@ export function buildTools(backend, config) {
1239
1333
  if (k > 0 && k < oldLines.length && (best === null || k > best.k))
1240
1334
  best = { base, k };
1241
1335
  }
1242
- if (best)
1243
- return showDivergence(best.base, best.k);
1244
- if (firstUniqueHit !== null)
1245
- return showSnippet(firstUniqueHit);
1336
+ if (best) {
1337
+ failures.push({
1338
+ index: index + 1,
1339
+ start: startOf(oldString),
1340
+ message: showDivergence(best.base, best.k),
1341
+ });
1342
+ continue;
1343
+ }
1344
+ if (firstUniqueHit !== null) {
1345
+ failures.push({
1346
+ index: index + 1,
1347
+ start: startOf(oldString),
1348
+ message: showSnippet(firstUniqueHit),
1349
+ });
1350
+ continue;
1351
+ }
1246
1352
  // Fallback: the first non-blank line, even if not unique (better than a
1247
1353
  // bare "re-read" when the model just needs to see nearby real content).
1248
1354
  const firstLine = oldString
@@ -1251,8 +1357,14 @@ export function buildTools(backend, config) {
1251
1357
  ?.trim();
1252
1358
  if (firstLine) {
1253
1359
  const idx = fileLines.findIndex((l) => l.includes(firstLine));
1254
- if (idx >= 0)
1255
- return showSnippet(idx);
1360
+ if (idx >= 0) {
1361
+ failures.push({
1362
+ index: index + 1,
1363
+ start: startOf(oldString),
1364
+ message: showSnippet(idx),
1365
+ });
1366
+ continue;
1367
+ }
1256
1368
  }
1257
1369
  // Nothing anchored. Say WHICH of the two situations this is — they
1258
1370
  // call for different next moves, and the old message implied the
@@ -1262,20 +1374,20 @@ export function buildTools(backend, config) {
1262
1374
  // "re-read and copy the exact text", which is wrong advice when the
1263
1375
  // text is not in the file at all.
1264
1376
  const anchorsPresent = distinctiveLines.some((anchor) => fileLines.some((l) => l.includes(anchor)));
1265
- if (!anchorsPresent) {
1266
- return {
1267
- error: `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
1377
+ failures.push({
1378
+ index: index + 1,
1379
+ start: startOf(oldString),
1380
+ message: !anchorsPresent
1381
+ ? `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
1268
1382
  `that file, so this is not a whitespace problem. Either the edit was already ` +
1269
1383
  `applied, or this is the wrong file. read_file it and look before trying ` +
1270
- `again; do not retry the same old_string.`,
1271
- };
1272
- }
1273
- return {
1274
- error: `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
1275
- `line is distinctive enough to locate the target. read_file the region you ` +
1276
- `mean and copy old_string verbatim from what it returns — including ` +
1277
- `indentation — or include more surrounding context to make it unique.`,
1278
- };
1384
+ `again; do not retry the same old_string.`
1385
+ : `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
1386
+ `line is distinctive enough to locate the target. read_file the region you ` +
1387
+ `mean and copy old_string verbatim from what it returns — including ` +
1388
+ `indentation — or include more surrounding context to make it unique.`,
1389
+ });
1390
+ continue;
1279
1391
  }
1280
1392
  if (count > 1 && (replaceAll === true || replaceAllCamel === true)) {
1281
1393
  // Every occurrence, by split/join — never String.replace, whose
@@ -1291,9 +1403,12 @@ export function buildTools(backend, config) {
1291
1403
  const lines = [];
1292
1404
  for (let at = content.indexOf(oldString); at >= 0 && lines.length < 10; at = content.indexOf(oldString, at + 1))
1293
1405
  lines.push(content.slice(0, at).split('\n').length);
1294
- return {
1295
- error: `old_string found ${count} times in ${path} (starting at lines ${lines.join(', ')}${count > lines.length ? ', …' : ''}) — must be unique. Include more surrounding context from the one you mean, or set replace_all: true on this replacement to change every occurrence.`,
1296
- };
1406
+ failures.push({
1407
+ index: index + 1,
1408
+ start: startOf(oldString),
1409
+ message: `old_string found ${count} times in ${path} (starting at lines ${lines.join(', ')}${count > lines.length ? ', …' : ''}) — must be unique. Include more surrounding context from the one you mean, or set replace_all: true on this replacement to change every occurrence.`,
1410
+ });
1411
+ continue;
1297
1412
  }
1298
1413
  // Splice by index rather than `content.replace(oldString, newString)`:
1299
1414
  // String.replace treats `$&`, `$$`, `` $` ``, `$'` in the REPLACEMENT as
@@ -1320,6 +1435,13 @@ export function buildTools(backend, config) {
1320
1435
  spans = addChangeSpan(spans, content, spliced);
1321
1436
  content = spliced;
1322
1437
  }
1438
+ // Every replacement was checked before this: a batch that refuses ANY of them
1439
+ // writes nothing, so the ones that DID match are just as unreported as the ones
1440
+ // that did not — and naming them one per round trip cost flash-m98 two full
1441
+ // re-sends of a six-replacement batch. Nothing is written here: the file on disk
1442
+ // is the original, and the model fixes all of them in one retry.
1443
+ if (failures.length)
1444
+ return { error: batchFailureError(path, replacements.length, failures) };
1323
1445
  // One line per refused replacement: what it would have done, and the two ways to
1324
1446
  // say it on purpose. The rest of the batch is applied — the second half of the pair
1325
1447
  // above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
@@ -1484,7 +1606,7 @@ export function buildTools(backend, config) {
1484
1606
  // -H: a search scoped to one file printed `line:content` with no file, and every match
1485
1607
  // came back `{file: "", line: 0}` (X1 flash-m62).
1486
1608
  const grepArgs = `-rnH ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
1487
- const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: 10000 });
1609
+ const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: SEARCH_TIMEOUT_MS });
1488
1610
  // grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
1489
1611
  // content alone: the file prefix would otherwise hide an env assignment from
1490
1612
  // the line-anchored env pattern, and knowing each match's own path is what
@@ -1521,6 +1643,11 @@ export function buildTools(backend, config) {
1521
1643
  return { pattern, path, matches };
1522
1644
  }
1523
1645
  catch (e) {
1646
+ if (isCommandTimeout(e))
1647
+ return {
1648
+ error: searchTimeoutError('search_files', path, pattern, SEARCH_TIMEOUT_MS / 1000, `Give \`path\` the subdirectory you mean and narrow with \`include\` ` +
1649
+ `(e.g. "*.ts"), or use find_files with the directory in the pattern.`),
1650
+ };
1524
1651
  return { error: `Search failed: ${e.message}` };
1525
1652
  }
1526
1653
  },
@@ -1542,21 +1669,54 @@ export function buildTools(backend, config) {
1542
1669
  const symlinkErr = await checkSymlink(path);
1543
1670
  if (symlinkErr)
1544
1671
  return { error: symlinkErr };
1672
+ // The directory the PATTERN itself names (`node_modules` +
1673
+ // `@molecule/api-resource-user/dist/*.d.ts`) becomes the search root. Matched
1674
+ // against the whole path with an implicit "any parent" anchor, the pattern
1675
+ // made `find` visit every entry below `path` — the whole dependency tree,
1676
+ // which hit the 10 s deadline with no result at all while the one directory
1677
+ // the model meant answered a `list_files` instantly (xr-molecule-flash-m94).
1678
+ // A bare first segment (`helpers/*`) is left alone on purpose: it means "any
1679
+ // `helpers/` below the root" (xr-molecule-flash-m77, see literalDirPrefix).
1680
+ const prefixDir = literalDirPrefix(pattern);
1681
+ let findRoot = path;
1682
+ let findArgs = findPatternArgs(pattern);
1683
+ let missingPrefix;
1545
1684
  try {
1685
+ if (prefixDir) {
1686
+ const candidate = `${path.replace(/\/+$/, '')}/${prefixDir}`;
1687
+ const probe = await backend.run(`test -d ${shellQuote(candidate)}`, {
1688
+ timeout: SEARCH_TIMEOUT_MS,
1689
+ });
1690
+ if (probe.exitCode === 0) {
1691
+ findRoot = candidate;
1692
+ // The remainder keeps the `-path` form: `*` crosses `/`, so a `.d.ts`
1693
+ // in `dist/utilities/` is still found (see findPathArgs).
1694
+ findArgs = findPathArgs(pattern.replace(/^(?:\.?\/)+/, '').slice(prefixDir.length + 1));
1695
+ }
1696
+ else {
1697
+ missingPrefix = `"${candidate}" does not exist; searched all of ${path}.`;
1698
+ }
1699
+ }
1546
1700
  // `findPatternArgs`, not `-name`: a pattern with a `/` in it
1547
1701
  // (`helpers/*`, `e2e/*.spec.ts`) matched nothing at all, because `-name`
1548
1702
  // compares it against the basename (xr-molecule-flash-m77).
1549
- const result = await backend.run(`find ${shellQuote(path)} ${findPruneArgs(path)} ${findPatternArgs(pattern)} -print 2>/dev/null | head -${MAX_FIND_RESULTS}`, { timeout: 10000 });
1703
+ const result = await backend.run(`find ${shellQuote(findRoot)} ${findPruneArgs(findRoot)} ${findArgs} -print 2>/dev/null | head -${MAX_FIND_RESULTS}`, { timeout: SEARCH_TIMEOUT_MS });
1550
1704
  const files = result.stdout.trim().split('\n').filter(Boolean);
1551
1705
  // A slash changes what the pattern means — the whole path, not the file
1552
1706
  // name — and an empty result does not say so, so the model reads it as
1553
1707
  // "no such file" and goes looking elsewhere. Name the rule on the miss.
1554
- const note = files.length === 0 && pattern.includes('/')
1708
+ const missNote = files.length === 0 && pattern.includes('/')
1555
1709
  ? `"${pattern}" contains a "/", so it was matched against each file's PATH below ${path} (a leading "*/" is implicit), not against the file name — no path matched it. list_files shows the real entries.`
1556
1710
  : undefined;
1557
- return { pattern, path, files, ...(note ? { note } : {}) };
1711
+ const notes = [missingPrefix, missNote].filter((n) => Boolean(n));
1712
+ return { pattern, path, files, ...(notes.length ? { note: notes.join(' ') } : {}) };
1558
1713
  }
1559
1714
  catch (e) {
1715
+ if (isCommandTimeout(e))
1716
+ return {
1717
+ error: searchTimeoutError('find_files', findRoot === path ? path : `${findRoot} (under ${path})`, pattern, SEARCH_TIMEOUT_MS / 1000, `Give \`path\` the directory you mean (a package's own folder rather than all of ` +
1718
+ `${path}) or use list_files on it, and keep the pattern narrow.`),
1719
+ };
1560
1720
  return { error: `Find failed: ${e.message}` };
1561
1721
  }
1562
1722
  },
@@ -1617,7 +1777,7 @@ export function buildTools(backend, config) {
1617
1777
  }
1618
1778
  },
1619
1779
  async exec_command(input) {
1620
- const command = input.command;
1780
+ const rawCommand = input.command;
1621
1781
  const cwd = input.cwd ? resolve(input.cwd, 'cwd') : root;
1622
1782
  // A model that sent the command under another name (`cmd`, `script`, …)
1623
1783
  // has already been normalized by guardToolExecute; reaching here with
@@ -1627,21 +1787,32 @@ export function buildTools(backend, config) {
1627
1787
  // undefined (reading 'match')" — the executor read that as a broken shell,
1628
1788
  // stopped verifying, and ended the turn claiming all checks passed while
1629
1789
  // seven acceptance checks failed (X0 rehearsal 83, 2026-09-16).
1630
- if (typeof command !== 'string' || command.trim() === '') {
1790
+ if (typeof rawCommand !== 'string' || rawCommand.trim() === '') {
1631
1791
  return { error: missingParamError(TOOL_SCHEMAS.exec_command, input, ['command']) };
1632
1792
  }
1633
1793
  if (blockDangerousCommands) {
1634
- const blocked = checkBlockedCommand(command);
1794
+ const blocked = checkBlockedCommand(rawCommand);
1635
1795
  if (blocked)
1636
1796
  return { error: blocked };
1637
1797
  }
1638
1798
  // Consumer-specific guard (see ToolBuildConfig.blockCommand) — an environment can
1639
1799
  // veto commands the generic dangerous-command check can't know about.
1640
1800
  if (blockCommand) {
1641
- const blocked = blockCommand(command, cwd);
1801
+ const blocked = blockCommand(rawCommand, cwd);
1642
1802
  if (blocked)
1643
1803
  return { error: blocked };
1644
1804
  }
1805
+ // A diagnosis run of a suite whose config records no trace gets one recorded anyway
1806
+ // (`diagnosisTraceCommand`, trace-artifacts.ts): the failing tests' own network log is what
1807
+ // tells a swallowed request error from a selector, and X1 xr-molecule-flash-m100 spent the
1808
+ // last minutes of its run hand-writing Chromium probes for what the trace it never wrote
1809
+ // down would have said. The whole rule is that function's; the proof run is never touched.
1810
+ const traced = await diagnosisTraceCommand({
1811
+ command: rawCommand,
1812
+ cwd,
1813
+ readFile: (path) => backend.readFile(path),
1814
+ });
1815
+ const command = traced.command;
1645
1816
  // Detached: start it, hand back a handle, let the turn continue. A
1646
1817
  // command that outlives the tool's ceiling is otherwise simply lost —
1647
1818
  // measured across six agent runs, ten of them were killed at the
@@ -1689,6 +1860,10 @@ export function buildTools(backend, config) {
1689
1860
  // Keep the log growing while it runs (see streamingBackgroundCommand):
1690
1861
  // an empty log is what wait_for_task reads as stuck.
1691
1862
  const streaming = streamingBackgroundCommand(command);
1863
+ // …and the trace flag this tool added (above), which the streaming rewrite never saw:
1864
+ // its changes are what this branch reports the run's own settings in.
1865
+ if (traced.change)
1866
+ streaming.changes.unshift(traced.change);
1692
1867
  // A whole-suite run starves the sandbox it shares with the app and with
1693
1868
  // every command the executor sends next, so it starts at lower CPU and
1694
1869
  // IO priority (see suiteRunPriorityLaunch). Foreground runs are never
@@ -1825,25 +2000,40 @@ export function buildTools(backend, config) {
1825
2000
  : sanitizeOutput;
1826
2001
  const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
1827
2002
  const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
1828
- if (budgetSeconds && result.exitCode === 124) {
2003
+ // A run a budget stopped is NOT a run that ended: it reports `status: 'cut_at_budget'`,
2004
+ // never a 0 the pipeline produced, and names how far it got. Read from the command, the
2005
+ // output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
2006
+ // the same state (X1 xr-molecule-flash-m93: `timeout 280 hurl … | tail -60` came back
2007
+ // `exitCode: 0` after 284 s and four of thirteen files, and the executor re-sent it).
2008
+ const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds);
2009
+ if (cut) {
2010
+ const cutLead = cutRunSentence(cut);
1829
2011
  return {
1830
2012
  stdout,
1831
2013
  stderr,
1832
- exitCode: result.exitCode,
1833
- error: `The command was stopped after ${budgetSeconds}s, this tool's limit; the output above is ` +
1834
- 'everything it printed until then' +
1835
- (/\|\s*(tail|head)\b/.test(command)
1836
- ? ' — and a pipe through tail/head holds everything back until the command ends, so it printed nothing: run it without the pipe (use a line reporter and let the output stream)'
2014
+ status: CUT_AT_BUDGET,
2015
+ exitCode: cut.exitCode,
2016
+ // The facts, so a reader that can also see the suite itself (its own `.hurl` file
2017
+ // count) says the same sentence with the total the output alone cannot give.
2018
+ cutRun: cut,
2019
+ error: `${cutLead} ` +
2020
+ // The tool's own budget stopping a run that printed nothing: keep the sentence that
2021
+ // says why the output is empty, since nothing else in the result can say it.
2022
+ (cut.stoppedBy === 'tool-budget'
2023
+ ? `The output above is everything it printed until then${/\|\s*(tail|head)\b/.test(command)
2024
+ ? ' — and a pipe through tail/head holds everything back until the command ends, so it printed nothing: run it without the pipe (use a line reporter and let the output stream)'
2025
+ : ''}. `
1837
2026
  : '') +
1838
- '. Run a smaller unit per command (one test file, one build step) instead of chaining ' +
1839
- 'a build and a whole suite.',
2027
+ (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1840
2028
  };
1841
2029
  }
1842
2030
  return {
1843
2031
  stdout,
1844
2032
  stderr,
1845
2033
  exitCode: result.exitCode,
1846
- ...(ceilingNote ? { note: ceilingNote } : {}),
2034
+ ...(ceilingNote || traced.change
2035
+ ? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
2036
+ : {}),
1847
2037
  };
1848
2038
  }
1849
2039
  catch (e) {
@@ -2009,9 +2199,19 @@ export function buildTools(backend, config) {
2009
2199
  // and then fixed it in 51 s. The task is NOT stopped — the executor is handed the cause and
2010
2200
  // decides, and the note tells it to let the run finish because the run's other causes only
2011
2201
  // surface in it (X1 flash-m83 stopped eight runs at their first repeating cause and never
2012
- // finished one). Only a cause that is failing the WHOLE run — every failure sharing it,
2013
- // across `UNIVERSAL_CAUSE_FILES` files or more, with nothing passing since the first —
2014
- // comes with a stop command.
2202
+ // finished one).
2203
+ //
2204
+ // WHAT ENDS THIS WAIT is the whole of the executor's next move, so only ONE finding does:
2205
+ // `earlyReturnCause` — a PROVEN cause (an error repeating verbatim, never a shared duration)
2206
+ // that is failing the WHOLE run (every failure the log names, across `UNIVERSAL_CAUSE_FILES`
2207
+ // files or more, with nothing passing since the first). Anything else is a LEAD: the wait
2208
+ // keeps going to the caller's own timeout, and the finding is attached to the result of this
2209
+ // wait as a note. X1 xr-molecule-flash-m99 (2026-10-16) was cut short twice by a status that
2210
+ // returned early on three tests sharing the suite's own `timeout: 15_000` — a signature the
2211
+ // note it printed called "a lead, not a cause" — and read the early return as "stop waiting,
2212
+ // edit now", voiding a run that was still walking both times (the second wait, 224 s of a
2213
+ // 280 s budget, ended with nothing at all).
2214
+ let midRunNote = null;
2015
2215
  let lastCauseLookAt = startedAt - repeatingCauseLookMs();
2016
2216
  let exitCode = await readExit();
2017
2217
  while (exitCode === null && Date.now() - startedAt < budgetMs) {
@@ -2040,29 +2240,46 @@ export function buildTools(backend, config) {
2040
2240
  const found = repeatingFailure(causeLog);
2041
2241
  if (!found)
2042
2242
  continue;
2043
- // Only a cause that is failing the whole run gets a stop command — and only then is the
2044
- // pid walk paid for.
2045
- const universal = universalCause(causeLog, found);
2243
+ // The ONE predicate behind this decision and `repeatingFailureNote`'s wording, so the two
2244
+ // cannot come to different verdicts.
2245
+ const stopCause = earlyReturnCause(found, universalCause(causeLog, found));
2046
2246
  // How this run was started, so the note can hand over the re-run to send once it ends
2047
2247
  // instead of one for now — the platform refuses a second run against the same app and
2048
2248
  // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2049
2249
  // and its overlap hold refused that exact run 23 s later).
2050
2250
  const started = runByTask.get(taskId);
2051
- repeatingCauseNoted.add(taskId);
2052
- const earlyMs = Date.now() - startedAt;
2053
- waitedByTask.set(taskId, waitedBefore + earlyMs);
2054
- return {
2055
- taskId,
2056
- status: 'repeating_failure',
2057
- waitedMs: earlyMs,
2058
- note: repeatingFailureNote(found, {
2251
+ if (stopCause) {
2252
+ // …and only now is the pid walk paid for.
2253
+ repeatingCauseNoted.add(taskId);
2254
+ const earlyMs = Date.now() - startedAt;
2255
+ waitedByTask.set(taskId, waitedBefore + earlyMs);
2256
+ return {
2257
+ taskId,
2258
+ status: 'repeating_failure',
2259
+ waitedMs: earlyMs,
2260
+ note: repeatingFailureNote(found, {
2261
+ taskId,
2262
+ log,
2263
+ stop: await taskStopCommand(backend, taskId),
2264
+ universal: stopCause,
2265
+ ...(started ? { run: started } : {}),
2266
+ }),
2267
+ };
2268
+ }
2269
+ // A lead: this wait goes on, and the finding rides its result as a note — the executor has
2270
+ // to keep waiting for the run's own result, so it must not be told one in the status. The
2271
+ // look keeps running for the same reason: an app that goes down mid-run turns a shared
2272
+ // duration into a proven whole-run cause, and that one is worth returning early for.
2273
+ if (!midRunLeadNoted.has(taskId)) {
2274
+ midRunLeadNoted.add(taskId);
2275
+ midRunNote = repeatingFailureNote(found, {
2059
2276
  taskId,
2060
2277
  log,
2061
- stop: universal ? await taskStopCommand(backend, taskId) : '',
2062
- universal,
2278
+ stop: '',
2279
+ universal: null,
2063
2280
  ...(started ? { run: started } : {}),
2064
- }),
2065
- };
2281
+ });
2282
+ }
2066
2283
  }
2067
2284
  const waitedMs = Date.now() - startedAt;
2068
2285
  waitedByTask.set(taskId, waitedBefore + waitedMs);
@@ -2105,6 +2322,13 @@ export function buildTools(backend, config) {
2105
2322
  waitedMs: waitedBefore + waitedMs,
2106
2323
  }));
2107
2324
  const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
2325
+ // What this wait found mid-run, when it was not a reason to end early: it rides the result
2326
+ // the wait is about to hand back, because that result is where the executor decides what to
2327
+ // do next and a lead it never saw is a lead it cannot weigh. Not on the `stuck` answer
2328
+ // below — that verdict is about the command having stopped PRINTING and carries its own
2329
+ // instruction — and not on a finished run's result, where `failureCausesNote` reads the
2330
+ // whole log instead of the 40 KB tail a mid-run look is given.
2331
+ const midRun = midRunNote ? ` ${midRunNote}` : '';
2108
2332
  if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
2109
2333
  waitedByTask.set(taskId, 0);
2110
2334
  return {
@@ -2113,7 +2337,7 @@ export function buildTools(backend, config) {
2113
2337
  waitedMs,
2114
2338
  ...sliceField,
2115
2339
  note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2116
- `tail ${log}.`,
2340
+ `tail ${log}.${midRun}`,
2117
2341
  };
2118
2342
  }
2119
2343
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -2140,9 +2364,11 @@ export function buildTools(backend, config) {
2140
2364
  (soFar.trim()
2141
2365
  ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
2142
2366
  `the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
2143
- `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error. `
2367
+ `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error: this run writes ` +
2368
+ `each failure's own report into the log as that test fails, right under its ✘ line, ` +
2369
+ `not only in the summary at the end. `
2144
2370
  : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
2145
- `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.`,
2371
+ `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.${midRun}`,
2146
2372
  };
2147
2373
  }
2148
2374
  let output;
@@ -2175,6 +2401,28 @@ export function buildTools(backend, config) {
2175
2401
  // the second and third were found by runs stopped before they walked the rest. Null for
2176
2402
  // anything that is not a run with failures in it.
2177
2403
  const causes = failureCausesNote(output);
2404
+ // A backgrounded run is under no tool budget, and the exit file hands back the PIPELINE's
2405
+ // code — so a suite killed at a `timeout 280` the command carried behind a `| tail` reports
2406
+ // 0 here too. The run's start is in its own id ({@link taskStartedAt}), so the bound it used
2407
+ // is readable without new state: say stopped, and never hand the pipeline's 0 back as the
2408
+ // run's (X1 xr-molecule-flash-m93 re-sent that run three times).
2409
+ const run = runByTask.get(taskId);
2410
+ const startedAtMs = taskStartedAt(taskId);
2411
+ const cut = run && startedAtMs !== null
2412
+ ? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0)
2413
+ : null;
2414
+ if (cut) {
2415
+ return {
2416
+ taskId,
2417
+ status: CUT_AT_BUDGET,
2418
+ exitCode: cut.exitCode,
2419
+ waitedMs,
2420
+ cutRun: cut,
2421
+ note: [cutRunSentence(cut), causes].filter((n) => Boolean(n)).join(' '),
2422
+ stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
2423
+ ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2424
+ };
2425
+ }
2178
2426
  // Same shaping as a foreground exec result, so the executor reads both
2179
2427
  // the same way.
2180
2428
  return {