@molecule/api-ai-tools 1.5.20 → 1.6.2

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
@@ -161,12 +161,20 @@ function waitProgressNote(o) {
161
161
  const sentence = parts.join('; ');
162
162
  return ` ${sentence[0].toUpperCase()}${sentence.slice(1)}.`;
163
163
  }
164
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, findPatternArgs, fusedLine, htmlOutline, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
164
+ 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';
165
165
  /**
166
166
  * How long exec_command waits for a command it backgrounded only because the
167
167
  * command asked for more than the tool's ceiling, before handing back a task id.
168
168
  */
169
169
  const OVER_CEILING_GRACE_MS = 45_000;
170
+ /**
171
+ * The share of its budget a command must use before the "you asked for more than
172
+ * this tool's ceiling" note is worth attaching. Below it the clamp changed nothing
173
+ * about the run, and the line read as a warning about work that had already
174
+ * finished (xr-molecule-flash-m83 +53:11: a Hurl run of 25 s against a 290 s
175
+ * ceiling carried the note).
176
+ */
177
+ const CEILING_NOTE_MIN_USED = 0.5;
170
178
  /**
171
179
  * The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
172
180
  * redirect below fires only when the read FAILS, so a model that knows the hoisted
@@ -488,36 +496,22 @@ export function buildTools(backend, config) {
488
496
  * listing, ENOENT parent listing, empty-read classification). Shared by the
489
497
  * single-path and batched forms of the tool.
490
498
  *
491
- * @param rawPath - The path as the model sent it.
492
- * @returns The same result shape read_file returns for one file.
493
- */
494
- /**
495
- * How many leading lines of a file fit in a char budget.
496
- *
497
- * @param content - The file text.
498
- * @param chars - The budget in chars.
499
- * @returns The line count, at least one.
500
- */
501
- function linesWithin(content, chars) {
502
- let used = 0;
503
- let lines = 0;
504
- for (const line of content.split('\n')) {
505
- used += line.length + 1;
506
- if (used > chars && lines > 0)
507
- break;
508
- lines++;
509
- }
510
- return Math.max(1, lines);
511
- }
512
- /**
513
- * Read one file for the model: symlink-checked, size-bounded, optionally
514
- * windowed by line, and never more than MAX_READ_RETURN_CHARS at once.
499
+ * Symlink-checked, size-bounded, optionally windowed by line, and never more
500
+ * than `ceiling` SERIALIZED chars at once (a window of raw text grows by its
501
+ * own newlines once the result is JSON — see {@link serializedChars}).
515
502
  *
516
503
  * @param rawPath - The path as the model sent it.
517
504
  * @param window - Optional `offset`/`limit` in lines.
505
+ * @param ceiling - The most this read may return, in serialized chars
506
+ * ({@link MAX_READ_RETURN_CHARS} when the call has the whole result to
507
+ * itself; a batched call passes what it has left, so the read stops short of
508
+ * the request-side cut instead of being cut by it). A read that comes back
509
+ * short carries `truncated: true`, which is how the batch tells a whole file
510
+ * from a window of one.
518
511
  * @returns The file content (or a window of it) or a model-readable error.
519
512
  */
520
- async function readOneFile(rawPath, window) {
513
+ async function readOneFile(rawPath, window, ceiling) {
514
+ const maxChars = ceiling ?? MAX_READ_RETURN_CHARS;
521
515
  const argErr = pathArgError(rawPath, 'read_file');
522
516
  if (argErr)
523
517
  return { error: argErr };
@@ -589,8 +583,8 @@ export function buildTools(backend, config) {
589
583
  // No window asked for and the file is bigger than one call should hand
590
584
  // back: return its first window, sized to the ceiling, and say so. The
591
585
  // model can read on with offset/limit; it cannot un-send 300k tokens.
592
- const effectiveWindow = !sliceLines(content, window) && content.length > MAX_READ_RETURN_CHARS
593
- ? { offset: 1, limit: linesWithin(content, MAX_READ_RETURN_CHARS) }
586
+ const effectiveWindow = !sliceLines(content, window) && serializedChars(content) > maxChars
587
+ ? { offset: 1, limit: linesWithinSerialized(content, maxChars) }
594
588
  : window;
595
589
  // A SECOND window of the same small file: hand back all of it (one window is a targeted read). X1 flash-m50 paged files that fit in one
596
590
  // call 13 times in a row, each window answered with "read it again with no offset/limit".
@@ -606,7 +600,7 @@ export function buildTools(backend, config) {
606
600
  if (small?.truncated &&
607
601
  pagedBefore &&
608
602
  !sentWhole &&
609
- content.length <= SMALL_FILE_WHOLE_CHARS) {
603
+ serializedChars(content) <= Math.min(SMALL_FILE_WHOLE_CHARS, maxChars)) {
610
604
  wholeReturned.set(path, content);
611
605
  return noted({
612
606
  path,
@@ -625,6 +619,10 @@ export function buildTools(backend, config) {
625
619
  offset: windowed.offset,
626
620
  lines: windowed.lines,
627
621
  totalLines: windowed.totalLines,
622
+ // What the model has is PART of the file. Named on the result, not only
623
+ // in the note: a batched read keeps whole files and lists the rest, and
624
+ // this is how it tells the two apart (see the `paths` branch).
625
+ ...(windowed.truncated ? { truncated: true } : {}),
628
626
  // The note decides the executor's next call. Observed (X0 run x7): a
629
627
  // 44 KB README read as seven 300-line windows, one round trip each,
630
628
  // because the note only ever said "read another window" — when the
@@ -635,7 +633,7 @@ export function buildTools(backend, config) {
635
633
  note: sentWhole
636
634
  ? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ${windowed.totalLines}; ` +
637
635
  'the whole file, unchanged, was returned earlier in this conversation.'
638
- : content.length <= MAX_READ_RETURN_CHARS
636
+ : serializedChars(content) <= MAX_READ_RETURN_CHARS
639
637
  ? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
640
638
  `${windowed.totalLines}. The whole file is ${Math.round(content.length / 1024)} KB ` +
641
639
  `and fits in ONE call — read it again with no offset/limit instead of paging.`
@@ -743,6 +741,9 @@ export function buildTools(backend, config) {
743
741
  }
744
742
  },
745
743
  async read_file(input) {
744
+ const emptyErr = emptyCallError(input, 'read_file');
745
+ if (emptyErr)
746
+ return { error: emptyErr };
746
747
  // Several paths in ONE call. The executor issues one tool call per model
747
748
  // round-trip 89% of the time (X0: 464 assistant messages, 515 tool calls,
748
749
  // a ~10s mean gap), so a turn's wall clock IS its round-trip count — and
@@ -759,32 +760,93 @@ export function buildTools(backend, config) {
759
760
  };
760
761
  }
761
762
  const files = [];
762
- let budget = MAX_BATCH_READ_BYTES;
763
- let stoppedAt = null;
763
+ // The paths this call does NOT return, in request order, each with the
764
+ // reason it is missing. Never a partial file in their place: a batch
765
+ // that broke off mid-file is what MAX_READ_CALL_CHARS exists to stop —
766
+ // the request side cuts a longer block through its middle, between two
767
+ // `"content"` values, so whole files vanish and the note says only
768
+ // "N characters cut here" (X0 flash-m81).
769
+ const notReturned = [];
770
+ const explicitWindow = input.offset !== undefined || input.limit !== undefined;
771
+ // Every entry names its file the way a returned entry does — resolved —
772
+ // so a path out of this list can go straight back into the next call.
773
+ const missing = (requestedPath, reason) => {
774
+ notReturned.push({ path: resolve(requestedPath), reason });
775
+ };
764
776
  for (const [index, requestedPath] of requested.entries()) {
765
- if (index >= MAX_BATCH_READ_FILES || budget <= 0) {
766
- stoppedAt = requestedPath;
767
- break;
777
+ if (index >= MAX_BATCH_READ_FILES) {
778
+ missing(requestedPath, 'count');
779
+ continue;
780
+ }
781
+ // What this file's read may still carry: the call's ceiling less the
782
+ // envelope, which has to hold the note and one entry per missing path.
783
+ const room = MAX_READ_CALL_CHARS - JSON.stringify(files).length - READ_RESULT_ENVELOPE_CHARS;
784
+ if (room <= 0) {
785
+ missing(requestedPath, 'budget');
786
+ continue;
787
+ }
788
+ const one = (await readOneFile(requestedPath, input, room));
789
+ // A window is not a batch entry. It is returned only when the caller
790
+ // asked for a window, or when the FIRST file alone is bigger than the
791
+ // whole call — then a window with its own offset/limit note is the only
792
+ // answer available, and everything after it is listed anyway.
793
+ if (one.truncated === true && !explicitWindow && files.length > 0) {
794
+ missing(requestedPath, 'budget');
795
+ continue;
768
796
  }
769
- const one = (await readOneFile(requestedPath, input));
770
797
  files.push(one);
771
- if (typeof one.content === 'string')
772
- budget -= one.content.length;
773
798
  }
774
- return {
775
- files,
776
- ...(stoppedAt
777
- ? {
778
- note: `Stopped after ${files.length} of ${requested.length} files (limit: ${MAX_BATCH_READ_FILES} ` +
779
- `files or ${Math.round(MAX_BATCH_READ_BYTES / 1024)}KB per call). Call read_file again ` +
780
- `starting at ${stoppedAt}.`,
781
- }
782
- : {}),
799
+ const entryError = (reason) => reason === 'count'
800
+ ? `not returned: one read_file call returns at most ${MAX_BATCH_READ_FILES} files. Read this file in its own read_file call.`
801
+ : `not returned: this call goes in order and has reached the ${Math.round(MAX_READ_CALL_CHARS / 1024)} KB one read_file result carries. Read this file in its own read_file call.`;
802
+ const build = (returned) => {
803
+ let returnedChars = 0;
804
+ let windowed = 0;
805
+ for (const f of returned) {
806
+ const entry = f;
807
+ if (typeof entry.content === 'string')
808
+ returnedChars += entry.content.length;
809
+ if (entry.truncated === true)
810
+ windowed++;
811
+ }
812
+ const size = `${Math.round(returnedChars / 1024)} KB of the ` +
813
+ `${Math.round(MAX_READ_CALL_CHARS / 1024)} KB one read_file result carries`;
814
+ return {
815
+ files: [
816
+ ...returned,
817
+ ...notReturned.map(({ path, reason }) => ({ path, error: entryError(reason) })),
818
+ ],
819
+ ...(notReturned.length
820
+ ? {
821
+ note: (windowed > 0
822
+ ? `Returned ${returned.length} of ${requested.length} files (${size}); the ` +
823
+ `${windowed} marked truncated is a first window — its own note names the next one. `
824
+ : `Returned whole: ${returned.length} of ${requested.length} files (${size}). `) +
825
+ 'Not returned — read these next, in this order, in further read_file calls: ' +
826
+ `${notReturned.map((n) => n.path).join(', ')}.`,
827
+ }
828
+ : {}),
829
+ };
783
830
  };
831
+ let result = build(files);
832
+ // The envelope is measured, not estimated: if the note and the entries for
833
+ // the missing paths take more than their share, whole files — from the end
834
+ // — move to the missing list until the result fits.
835
+ while (notReturned.length > 0 &&
836
+ files.length > 0 &&
837
+ JSON.stringify(result).length > MAX_READ_CALL_CHARS) {
838
+ const last = files.pop();
839
+ notReturned.unshift({ path: String(last?.path ?? ''), reason: 'budget' });
840
+ result = build(files);
841
+ }
842
+ return result;
784
843
  }
785
844
  return readOneFile(input.path, input);
786
845
  },
787
846
  async write_file(input) {
847
+ const emptyErr = emptyCallError(input, 'write_file');
848
+ if (emptyErr)
849
+ return { error: emptyErr };
788
850
  const argErr = pathArgError(input.path, 'write_file');
789
851
  if (argErr)
790
852
  return { error: argErr };
@@ -831,6 +893,9 @@ export function buildTools(backend, config) {
831
893
  }
832
894
  },
833
895
  async edit_file(input) {
896
+ const emptyErr = emptyCallError(input, 'edit_file');
897
+ if (emptyErr)
898
+ return { error: emptyErr };
834
899
  const argErr = pathArgError(input.path, 'edit_file');
835
900
  if (argErr)
836
901
  return { error: argErr };
@@ -1496,16 +1561,28 @@ export function buildTools(backend, config) {
1496
1561
  const effectiveBudgetMs = Number.isFinite(requestedMs) && requestedMs > 0 && commandBudgetMs
1497
1562
  ? Math.min(requestedMs, commandBudgetMs)
1498
1563
  : commandBudgetMs;
1499
- const ceilingNote = wantsMore && budgetSeconds
1500
- ? `You asked for ${Math.round(requestedMs / 1000)}s; this tool's ceiling is ${budgetSeconds}s. ` +
1501
- 'Split the work into smaller commands (one test file, one build step) rather than ' +
1502
- 're-running the same long one.'
1503
- : null;
1564
+ const startedAt = Date.now();
1504
1565
  const result = await backend.run(command, {
1505
1566
  cwd,
1506
1567
  timeout: execTimeoutMs,
1507
1568
  ...(effectiveBudgetMs ? { budgetMs: effectiveBudgetMs } : {}),
1508
1569
  });
1570
+ const elapsedMs = Date.now() - startedAt;
1571
+ // The clamped ask is only worth a line when the clamp was felt: a command
1572
+ // that asked for 600 s and finished in 25 s says nothing about splitting
1573
+ // work, and the note read as a warning about a run that had already
1574
+ // finished cleanly (xr-molecule-flash-m83 +53:11 — a Hurl run, 25 s).
1575
+ // Half the budget is the line; below it no smaller unit would have been
1576
+ // chosen any differently. A run that actually hit the ceiling is answered
1577
+ // by the exit-124 message below, not by this note.
1578
+ const ceilingNote = wantsMore &&
1579
+ budgetSeconds &&
1580
+ effectiveBudgetMs &&
1581
+ elapsedMs >= effectiveBudgetMs * CEILING_NOTE_MIN_USED
1582
+ ? `You asked for ${Math.round(requestedMs / 1000)}s; this tool's ceiling is ${budgetSeconds}s. ` +
1583
+ 'Split the work into smaller commands (one test file, one build step) rather than ' +
1584
+ 're-running the same long one.'
1585
+ : null;
1509
1586
  // truncateMiddle (not truncate): a failing build/test/migration puts its
1510
1587
  // error at the TAIL, so keep the head AND the tail — head-only truncation
1511
1588
  // strands the executor with passing progress and no failure reason.
@@ -1698,7 +1775,11 @@ export function buildTools(backend, config) {
1698
1775
  // from the finished log: X1 flash-m76 waited out 4.0 minutes of 15 tests where every
1699
1776
  // failure was the same `TimeoutError: page.waitForURL` from one missing `navigate('/')`,
1700
1777
  // and then fixed it in 51 s. The task is NOT stopped — the executor is handed the cause and
1701
- // its own pid command and decides.
1778
+ // decides, and the note tells it to let the run finish because the run's other causes only
1779
+ // surface in it (X1 flash-m83 stopped eight runs at their first repeating cause and never
1780
+ // finished one). Only a cause that is failing the WHOLE run — every failure sharing it,
1781
+ // across `UNIVERSAL_CAUSE_FILES` files or more, with nothing passing since the first —
1782
+ // comes with a stop command.
1702
1783
  let lastCauseLookAt = startedAt - repeatingCauseLookMs();
1703
1784
  let exitCode = await readExit();
1704
1785
  while (exitCode === null && Date.now() - startedAt < budgetMs) {
@@ -1727,6 +1808,9 @@ export function buildTools(backend, config) {
1727
1808
  const found = repeatingFailure(causeLog);
1728
1809
  if (!found)
1729
1810
  continue;
1811
+ // Only a cause that is failing the whole run gets a stop command — and only then is the
1812
+ // pid walk paid for.
1813
+ const universal = universalCause(causeLog, found);
1730
1814
  repeatingCauseNoted.add(taskId);
1731
1815
  const earlyMs = Date.now() - startedAt;
1732
1816
  waitedByTask.set(taskId, waitedBefore + earlyMs);
@@ -1737,7 +1821,8 @@ export function buildTools(backend, config) {
1737
1821
  note: repeatingFailureNote(found, {
1738
1822
  taskId,
1739
1823
  log,
1740
- stop: await taskStopCommand(backend, taskId),
1824
+ stop: universal ? await taskStopCommand(backend, taskId) : '',
1825
+ universal,
1741
1826
  }),
1742
1827
  };
1743
1828
  }
@@ -1837,12 +1922,20 @@ export function buildTools(backend, config) {
1837
1922
  // The file is gone or unreadable: the task log is the whole result, as before.
1838
1923
  }
1839
1924
  }
1925
+ // Every cause a finished run found, one line each, so ONE round of fixes covers all of
1926
+ // them. A Playwright report prints its failures in sequence with nothing saying which of
1927
+ // them share a cause, so a run's other causes are read only by reading every failure: X1
1928
+ // flash-m83 (2026-10-16) spent three deploys (6.7 minutes) each fixing a single cause, and
1929
+ // the second and third were found by runs stopped before they walked the rest. Null for
1930
+ // anything that is not a run with failures in it.
1931
+ const causes = failureCausesNote(output);
1840
1932
  // Same shaping as a foreground exec result, so the executor reads both
1841
1933
  // the same way.
1842
1934
  return {
1843
1935
  taskId,
1844
1936
  exitCode,
1845
1937
  waitedMs,
1938
+ ...(causes ? { note: causes } : {}),
1846
1939
  stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
1847
1940
  ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
1848
1941
  };