@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/README.md +326 -38
- package/dist/command-position.d.ts +62 -0
- package/dist/command-position.d.ts.map +1 -0
- package/dist/command-position.js +352 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/no-test-run.d.ts +13 -0
- package/dist/no-test-run.d.ts.map +1 -1
- package/dist/no-test-run.js +31 -22
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +10 -4
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +147 -54
- package/dist/utilities.d.ts +208 -38
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +616 -105
- package/package.json +1 -1
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,
|
|
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
|
-
*
|
|
492
|
-
*
|
|
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
|
|
593
|
-
? { offset: 1, limit:
|
|
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
|
|
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
|
|
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
|
-
|
|
763
|
-
|
|
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
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
775
|
-
files
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
};
|