@molecule/api-ai-tools 1.6.4 → 1.6.6
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 +520 -3
- package/dist/command-position.d.ts +27 -1
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +109 -28
- package/dist/cut-run.d.ts +124 -0
- package/dist/cut-run.d.ts.map +1 -0
- package/dist/cut-run.js +267 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/suite-overlap.d.ts +132 -0
- package/dist/suite-overlap.d.ts.map +1 -0
- package/dist/suite-overlap.js +208 -0
- package/dist/tools.d.ts +45 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +333 -27
- package/dist/utilities.d.ts +113 -2
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +171 -12
- package/package.json +1 -1
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';
|
|
@@ -25,6 +26,16 @@ const waitedByTask = new Map();
|
|
|
25
26
|
const logBytesByTask = new Map();
|
|
26
27
|
/** The file a background command writes its own output to, when it redirects it. */
|
|
27
28
|
const outputFileByTask = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* How each background task was started: the command as it was actually run (after
|
|
31
|
+
* {@link streamingBackgroundCommand} rewrote it) and the directory it ran in.
|
|
32
|
+
*
|
|
33
|
+
* A mid-run note has to hand over the re-run of a test that just failed, and a re-run is composed
|
|
34
|
+
* from the command that is still walking ({@link rerunOfRunningSuite}) — a whole-suite re-run is
|
|
35
|
+
* what the executor must not be handed for now, and a re-run narrowed for the wrong directory
|
|
36
|
+
* resolves the failing test's `file:line` against the wrong suite.
|
|
37
|
+
*/
|
|
38
|
+
const runByTask = new Map();
|
|
28
39
|
/**
|
|
29
40
|
* Task ids whose own-output file has already been looked for this process.
|
|
30
41
|
* {@link outputFileByTask} is empty for every task after a restart — it is
|
|
@@ -198,12 +209,43 @@ function runningNote(clauses) {
|
|
|
198
209
|
const joined = clauses.join('; ');
|
|
199
210
|
return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
|
|
200
211
|
}
|
|
201
|
-
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';
|
|
212
|
+
import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, 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';
|
|
202
213
|
/**
|
|
203
214
|
* How long exec_command waits for a command it backgrounded only because the
|
|
204
215
|
* command asked for more than the tool's ceiling, before handing back a task id.
|
|
205
216
|
*/
|
|
206
217
|
const OVER_CEILING_GRACE_MS = 45_000;
|
|
218
|
+
/**
|
|
219
|
+
* The closing advice on an over-budget message for a command that is not a test
|
|
220
|
+
* suite: the work it was chaining is what to split.
|
|
221
|
+
*/
|
|
222
|
+
export const SPLIT_WORK_ADVICE = 'Run a smaller unit per command (one test file, one build step) instead of chaining ' +
|
|
223
|
+
'a build and a whole suite.';
|
|
224
|
+
/**
|
|
225
|
+
* The closing advice on an over-budget message for a command that IS a suite run
|
|
226
|
+
* (Playwright, Vitest, a `hurl` run — {@link isSuiteRunCommand}): its files are not
|
|
227
|
+
* a bigger unit to break up.
|
|
228
|
+
*
|
|
229
|
+
* X1 flash-m36 asked for this half and the message kept saying "run a smaller unit"
|
|
230
|
+
* for two months. It is the wrong direction for a suite, and measurably: a run
|
|
231
|
+
* stopped here reports NO tally, so nothing in the result says which test or request
|
|
232
|
+
* hung — the run has to be sent again in the background (off this budget, its output
|
|
233
|
+
* written to a log that can be read mid-run) or, when a consumer knows the runner's
|
|
234
|
+
* per-request limit, with each request bounded. xr-molecule-flash-m93 (+06:22,
|
|
235
|
+
* 2026-10-16) lost 4.8 minutes to a Hurl run whose ONE unanswered request had hurl's
|
|
236
|
+
* 300 s default per request holding the file, ten seconds past this tool's budget.
|
|
237
|
+
*/
|
|
238
|
+
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 ' +
|
|
239
|
+
'in this result says which test or request never finished — run it in the background ' +
|
|
240
|
+
'(`run_in_background`, then `wait_for_task`), which takes it off this budget and writes its output ' +
|
|
241
|
+
"to a log, and read that log (or the app's own log) for what it was waiting on.";
|
|
242
|
+
/**
|
|
243
|
+
* How long a tree walk (`find_files`, `search_files`) may run before the backend's
|
|
244
|
+
* own deadline stops it. Both tools answer that deadline in their own words
|
|
245
|
+
* (see searchTimeoutError) rather than forwarding the sandbox SDK's `timeoutMs`
|
|
246
|
+
* sentence, which names an option the executor cannot set.
|
|
247
|
+
*/
|
|
248
|
+
const SEARCH_TIMEOUT_MS = 10_000;
|
|
207
249
|
/**
|
|
208
250
|
* The share of its budget a command must use before the "you asked for more than
|
|
209
251
|
* this tool's ceiling" note is worth attaching. Below it the clamp changed nothing
|
|
@@ -234,6 +276,52 @@ export function moleculeDistReadNote(path) {
|
|
|
234
276
|
? ' The declarations above are generated from it.'
|
|
235
277
|
: ' For one signature, read the matching .d.ts file.'));
|
|
236
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* The refusal for a write whose target is inside an installed package, or null
|
|
281
|
+
* when the path is not under a `node_modules` directory.
|
|
282
|
+
*
|
|
283
|
+
* `node_modules` is not the project's code: nothing written there survives the
|
|
284
|
+
* next install, no deploy carries it, and a sandbox whose dependency tree is
|
|
285
|
+
* shared (the pre-installed superset) breaks every other run through it. X0
|
|
286
|
+
* xr-molecule-flash-m91 (+04:43) called `write_file` on
|
|
287
|
+
* `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
|
|
288
|
+
* the package's declarations and clobbered its runtime module a call later
|
|
289
|
+
* (`module.exports = {}` over the ESM handler its own code imports) — the write
|
|
290
|
+
* answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
|
|
291
|
+
* putting the file back. Reading these files stays allowed (that is how the
|
|
292
|
+
* declarations above are read at all).
|
|
293
|
+
*
|
|
294
|
+
* The restore route is named because the model that has ALREADY clobbered a file
|
|
295
|
+
* (this refusal is too late for it) is the one who needs it, and because the
|
|
296
|
+
* obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
|
|
297
|
+
* sandbox. `npm pack` + a `tar` extraction over the package directory is what
|
|
298
|
+
* m91 used, and what both guards leave alone.
|
|
299
|
+
*
|
|
300
|
+
* @param path - An absolute path a tool is about to write.
|
|
301
|
+
* @returns The one-line refusal naming the package, or null when nothing is installed there.
|
|
302
|
+
*/
|
|
303
|
+
export function installedPackageWriteRefusal(path) {
|
|
304
|
+
// A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
|
|
305
|
+
// hoisted root one: both are installed packages.
|
|
306
|
+
const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
|
|
307
|
+
if (!at)
|
|
308
|
+
return null;
|
|
309
|
+
const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
|
|
310
|
+
const name = named?.[1] ?? null;
|
|
311
|
+
const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
|
|
312
|
+
const dir = name ? `${scoped}/node_modules/${name}` : null;
|
|
313
|
+
const what = name ? `the installed package ${name}` : 'an installed package';
|
|
314
|
+
const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
|
|
315
|
+
'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
|
|
316
|
+
'bond), and where the package itself is wrong, report it rather than patching it here. ' +
|
|
317
|
+
'Reading these files is fine.';
|
|
318
|
+
const restore = name && dir
|
|
319
|
+
? ` If you have already overwritten one there, restore the package before anything else: ` +
|
|
320
|
+
`\`npm pack ${name}@<version>\` (the version is in ${dir}/package.json), then ` +
|
|
321
|
+
`\`tar -xzf <the tarball npm pack printed> --strip-components=1 -C ${dir}\` — never a hand-edit.`
|
|
322
|
+
: '';
|
|
323
|
+
return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
|
|
324
|
+
}
|
|
237
325
|
/**
|
|
238
326
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
239
327
|
*
|
|
@@ -336,6 +424,22 @@ export function buildTools(backend, config) {
|
|
|
336
424
|
}
|
|
337
425
|
return pathGuards ? resolvePath(path, root) : path;
|
|
338
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Resolve the path a WRITING tool was given, refusing a target inside an
|
|
429
|
+
* installed package ({@link installedPackageWriteRefusal}). Reads are not sent
|
|
430
|
+
* through this: reading `node_modules` is how a package's declarations are
|
|
431
|
+
* read at all, and only the write is unrecoverable.
|
|
432
|
+
*
|
|
433
|
+
* @param path - Relative or absolute path requested by the tool input.
|
|
434
|
+
* @returns The normalized path, when writing it is allowed.
|
|
435
|
+
*/
|
|
436
|
+
function resolveWrite(path) {
|
|
437
|
+
const resolved = resolve(path);
|
|
438
|
+
const refusal = installedPackageWriteRefusal(resolved);
|
|
439
|
+
if (refusal)
|
|
440
|
+
throw new ToolInputError(refusal);
|
|
441
|
+
return resolved;
|
|
442
|
+
}
|
|
339
443
|
/** Paths read with a window in this tool set, to tell paging from one targeted read. */
|
|
340
444
|
const windowedReads = new Set();
|
|
341
445
|
/** The text each path was last handed back WHOLE as, so that correction fires once per version. */
|
|
@@ -887,7 +991,7 @@ export function buildTools(backend, config) {
|
|
|
887
991
|
const argErr = pathArgError(input.path, 'write_file');
|
|
888
992
|
if (argErr)
|
|
889
993
|
return { error: argErr };
|
|
890
|
-
const path =
|
|
994
|
+
const path = resolveWrite(input.path);
|
|
891
995
|
const content = input.content;
|
|
892
996
|
if (content.length > MAX_WRITE_SIZE)
|
|
893
997
|
return {
|
|
@@ -936,7 +1040,7 @@ export function buildTools(backend, config) {
|
|
|
936
1040
|
const argErr = pathArgError(input.path, 'edit_file');
|
|
937
1041
|
if (argErr)
|
|
938
1042
|
return { error: argErr };
|
|
939
|
-
const path =
|
|
1043
|
+
const path = resolveWrite(input.path);
|
|
940
1044
|
// Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
|
|
941
1045
|
let replacements = input.replacements;
|
|
942
1046
|
// Accept the single format when BOTH are strings — `typeof` (not truthy) so
|
|
@@ -973,13 +1077,35 @@ export function buildTools(backend, config) {
|
|
|
973
1077
|
}
|
|
974
1078
|
const oldContent = content;
|
|
975
1079
|
let alreadyApplied = 0;
|
|
1080
|
+
// Replacements refused for changing the line break at old_string's boundary and
|
|
1081
|
+
// nothing else — reported by position, never applied (see below).
|
|
1082
|
+
const skipped = [];
|
|
976
1083
|
// Where each applied replacement landed, in the coordinates of the
|
|
977
1084
|
// latest content — rendered into the result so the model sees the edit.
|
|
978
1085
|
let spans = [];
|
|
979
|
-
|
|
1086
|
+
// A replacement whose ONLY effect is the line break at old_string's boundary:
|
|
1087
|
+
// `new_string` is `old_string` minus its trailing newline (applying it runs the
|
|
1088
|
+
// line after it onto the last line) or `old_string` plus one (applying it moves
|
|
1089
|
+
// the rest of that line down). A weaker model sends the first as an "anchor" — it
|
|
1090
|
+
// repeats a line it means to keep, minus its `\n` — and the join is silent when
|
|
1091
|
+
// it still parses: flash-m28 merged two lines three times in one run, flash-m89
|
|
1092
|
+
// twice in two consecutive calls (comments.ts, then articles.ts), flash-m55,
|
|
1093
|
+
// m62 and m89 all paid a repair round trip for it. A real edit changes some other
|
|
1094
|
+
// character, so this costs nothing.
|
|
1095
|
+
const boundaryOnly = (oldText, newText) => {
|
|
1096
|
+
const oldBody = oldText.replace(/\r?\n$/, '');
|
|
1097
|
+
// `newText !== ''` keeps a deletion out of this: `old_string: "\n"` → `""` is a
|
|
1098
|
+
// real request to remove a blank line, and the notes below already cover it.
|
|
1099
|
+
if (newText !== '' && oldBody !== oldText && newText === oldBody)
|
|
1100
|
+
return 'removed';
|
|
1101
|
+
if (!oldText.endsWith('\n') && newText === `${oldText}\n`)
|
|
1102
|
+
return 'added';
|
|
1103
|
+
return null;
|
|
1104
|
+
};
|
|
1105
|
+
for (const [index, { old_string: oldString, new_string: newString, replace_all: replaceAll,
|
|
980
1106
|
// Models sometimes send the camelCase spelling inside an item, where
|
|
981
1107
|
// the input normalizer (top-level keys only) does not reach.
|
|
982
|
-
replaceAll: replaceAllCamel, } of replacements) {
|
|
1108
|
+
replaceAll: replaceAllCamel, },] of replacements.entries()) {
|
|
983
1109
|
// Validate each replacement is well-formed BEFORE touching content. A
|
|
984
1110
|
// weak model sometimes emits a batch element missing a field or with a
|
|
985
1111
|
// non-string value; left unchecked that either crashes (`undefined`
|
|
@@ -1081,15 +1207,74 @@ export function buildTools(backend, config) {
|
|
|
1081
1207
|
.map((l) => l.trim())
|
|
1082
1208
|
.filter((l) => l.length >= 8)),
|
|
1083
1209
|
].sort((a, b) => b.length - a.length);
|
|
1210
|
+
const oldLines = oldString.split('\n');
|
|
1211
|
+
// Whitespace is the one difference the model cannot see in its own
|
|
1212
|
+
// text, so make it visible when that is the whole difference.
|
|
1213
|
+
const visible = (l) => l.replace(/ /g, '·').replace(/\t/g, '⇥');
|
|
1214
|
+
// Report where old_string and the file STOP agreeing, not where the
|
|
1215
|
+
// match starts. A snippet centred on the anchor is the part the model
|
|
1216
|
+
// already has right, so it re-sends the same old_string and fails the
|
|
1217
|
+
// same way (X0 m89: two failed edits, a whole-file re-read and a
|
|
1218
|
+
// rewrite, 9 s, for a divergence 35 lines below the anchor).
|
|
1219
|
+
const showDivergence = (base, k) => {
|
|
1220
|
+
const expected = oldLines[k];
|
|
1221
|
+
const actual = fileLines[base + k] ?? '(end of file)';
|
|
1222
|
+
const whitespaceOnly = expected.replace(/\s/g, '') === actual.replace(/\s/g, '');
|
|
1223
|
+
let col = 0;
|
|
1224
|
+
while (col < expected.length && col < actual.length && expected[col] === actual[col])
|
|
1225
|
+
col++;
|
|
1226
|
+
const howFar = whitespaceOnly
|
|
1227
|
+
? ' (whitespace only — · is a space, ⇥ a tab)'
|
|
1228
|
+
: col > 0
|
|
1229
|
+
? ` (first character difference at column ${col + 1})`
|
|
1230
|
+
: '';
|
|
1231
|
+
const start = Math.max(0, base + k - 2);
|
|
1232
|
+
const snippet = fileLines
|
|
1233
|
+
.slice(start, base + k + 6)
|
|
1234
|
+
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
1235
|
+
.join('\n');
|
|
1236
|
+
return {
|
|
1237
|
+
error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
|
|
1238
|
+
`then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
|
|
1239
|
+
`Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
|
|
1240
|
+
`The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
|
|
1241
|
+
`The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
|
|
1242
|
+
};
|
|
1243
|
+
};
|
|
1244
|
+
// The longest run of old_string lines that IS in the file decides which
|
|
1245
|
+
// alignment to report, so a later distinctive line can beat an earlier
|
|
1246
|
+
// one. k === 0 means nothing beyond the anchor itself lines up; only
|
|
1247
|
+
// then fall back to the old anchor snippet.
|
|
1248
|
+
let best = null;
|
|
1249
|
+
let firstUniqueHit = null;
|
|
1084
1250
|
for (const anchor of distinctiveLines) {
|
|
1085
1251
|
const hits = fileLines.reduce((acc, l, i) => {
|
|
1086
1252
|
if (l.includes(anchor))
|
|
1087
1253
|
acc.push(i);
|
|
1088
1254
|
return acc;
|
|
1089
1255
|
}, []);
|
|
1090
|
-
if (hits.length
|
|
1091
|
-
|
|
1256
|
+
if (hits.length !== 1)
|
|
1257
|
+
continue;
|
|
1258
|
+
if (firstUniqueHit === null)
|
|
1259
|
+
firstUniqueHit = hits[0];
|
|
1260
|
+
const base = hits[0] - oldLines.findIndex((l) => l.trim() === anchor);
|
|
1261
|
+
if (base < 0)
|
|
1262
|
+
continue;
|
|
1263
|
+
// Exact comparison: a line that differs only in trailing whitespace
|
|
1264
|
+
// is itself a reason the exact match failed, and it is invisible in
|
|
1265
|
+
// the model's own text — report it as the divergence (the message
|
|
1266
|
+
// renders it with · for space).
|
|
1267
|
+
let k = 0;
|
|
1268
|
+
while (k < oldLines.length && fileLines[base + k] === oldLines[k]) {
|
|
1269
|
+
k++;
|
|
1270
|
+
}
|
|
1271
|
+
if (k > 0 && k < oldLines.length && (best === null || k > best.k))
|
|
1272
|
+
best = { base, k };
|
|
1092
1273
|
}
|
|
1274
|
+
if (best)
|
|
1275
|
+
return showDivergence(best.base, best.k);
|
|
1276
|
+
if (firstUniqueHit !== null)
|
|
1277
|
+
return showSnippet(firstUniqueHit);
|
|
1093
1278
|
// Fallback: the first non-blank line, even if not unique (better than a
|
|
1094
1279
|
// bare "re-read" when the model just needs to see nearby real content).
|
|
1095
1280
|
const firstLine = oldString
|
|
@@ -1149,10 +1334,36 @@ export function buildTools(backend, config) {
|
|
|
1149
1334
|
// a self-inflicted syntax error written with ok:true that the executor
|
|
1150
1335
|
// then can't locate. count === 1 here, so indexOf is the unique site.
|
|
1151
1336
|
const at = content.indexOf(oldString);
|
|
1337
|
+
// Refuse a replacement whose only effect is the line break at the boundary,
|
|
1338
|
+
// INSTEAD of applying it (flash-m89 +08:32 left `function pageLimit(req): number
|
|
1339
|
+
// { const raw = …` on one line and +07:25 left a comment handler unparseable,
|
|
1340
|
+
// twice in two calls, and the API was down between the pair). The file's own
|
|
1341
|
+
// next character is the evidence that decides it: text on the same line means
|
|
1342
|
+
// the break is moving across lines (a join, or a split), which the shape of the
|
|
1343
|
+
// replacement says the model did not ask for. At a line boundary — a blank line
|
|
1344
|
+
// after it, or the end of the file — nothing crosses lines and the edit stands.
|
|
1345
|
+
const boundary = boundaryOnly(oldString, newString);
|
|
1346
|
+
const after = content[at + oldString.length];
|
|
1347
|
+
if (boundary !== null && after !== undefined && after !== '\n') {
|
|
1348
|
+
skipped.push({ position: index + 1, shape: boundary });
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1152
1351
|
const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
|
|
1153
1352
|
spans = addChangeSpan(spans, content, spliced);
|
|
1154
1353
|
content = spliced;
|
|
1155
1354
|
}
|
|
1355
|
+
// One line per refused replacement: what it would have done, and the two ways to
|
|
1356
|
+
// say it on purpose. The rest of the batch is applied — the second half of the pair
|
|
1357
|
+
// above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
|
|
1358
|
+
// on text a joined file no longer has, so applying the join takes the batch down
|
|
1359
|
+
// with it.
|
|
1360
|
+
const skippedNote = skipped.length === 0
|
|
1361
|
+
? null
|
|
1362
|
+
: skipped
|
|
1363
|
+
.map(({ position, shape }) => shape === 'removed'
|
|
1364
|
+
? `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string minus the trailing newline, so the only thing it changes is the line break between that text and the line after it — applying it would run the two lines together. To join two lines on purpose, include the next line in old_string.`
|
|
1365
|
+
: `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string plus a trailing newline, so the only thing it changes is the line break after that text — applying it would move the rest of that line down. To split a line on purpose, include the text that follows it in old_string.`)
|
|
1366
|
+
.join('\n');
|
|
1156
1367
|
const alreadyNote = alreadyApplied > 0
|
|
1157
1368
|
? `${alreadyApplied} of these edits ${alreadyApplied === 1 ? 'was' : 'were'} already in the file (its new_string is there and its old_string is not) — nothing to do for ${alreadyApplied === 1 ? 'it' : 'them'}.`
|
|
1158
1369
|
: null;
|
|
@@ -1164,7 +1375,16 @@ export function buildTools(backend, config) {
|
|
|
1164
1375
|
(identical > 0
|
|
1165
1376
|
? `Nothing changed: ${identical === 1 ? 'the replacement’s new_string is' : `${identical} replacements’ new_string are`} identical to old_string, so the file is exactly as it was. Write the text you want in new_string.`
|
|
1166
1377
|
: 'Nothing changed: the file is exactly as it was.');
|
|
1167
|
-
|
|
1378
|
+
// A refused boundary-newline replacement is why nothing changed: "the file is
|
|
1379
|
+
// exactly as it was" would read as "your edit landed".
|
|
1380
|
+
return {
|
|
1381
|
+
path,
|
|
1382
|
+
ok: true,
|
|
1383
|
+
replacementsApplied: 0,
|
|
1384
|
+
alreadyApplied,
|
|
1385
|
+
note: skippedNote ?? note,
|
|
1386
|
+
...(skippedNote ? { skippedNote } : {}),
|
|
1387
|
+
};
|
|
1168
1388
|
}
|
|
1169
1389
|
if (onFileDiff)
|
|
1170
1390
|
onFileDiff({ path, oldContent, newContent: content });
|
|
@@ -1190,10 +1410,13 @@ export function buildTools(backend, config) {
|
|
|
1190
1410
|
const excerpt = renderEditExcerpt(finalContent, spans);
|
|
1191
1411
|
// A replacement whose old text ended in a newline and whose new text does not
|
|
1192
1412
|
// joins the line after it onto its last line. Applied as written, but said:
|
|
1193
|
-
// flash-m28 saw two lines merged only one round trip later.
|
|
1413
|
+
// flash-m28 saw two lines merged only one round trip later. The refused ones
|
|
1414
|
+
// above were never applied, so what the file holds is not their work.
|
|
1415
|
+
const refused = new Set(skipped.map((s) => s.position));
|
|
1194
1416
|
const dropped = replacements
|
|
1195
1417
|
.map((r, i) => ({ ...r, position: i + 1 }))
|
|
1196
|
-
.filter((r) =>
|
|
1418
|
+
.filter((r) => !refused.has(r.position) &&
|
|
1419
|
+
typeof r.old_string === 'string' &&
|
|
1197
1420
|
typeof r.new_string === 'string' &&
|
|
1198
1421
|
r.old_string.endsWith('\n') &&
|
|
1199
1422
|
r.new_string !== '' &&
|
|
@@ -1232,7 +1455,7 @@ export function buildTools(backend, config) {
|
|
|
1232
1455
|
return {
|
|
1233
1456
|
path,
|
|
1234
1457
|
ok: true,
|
|
1235
|
-
replacementsApplied: replacements.length - alreadyApplied,
|
|
1458
|
+
replacementsApplied: replacements.length - alreadyApplied - skipped.length,
|
|
1236
1459
|
...(fusedNote
|
|
1237
1460
|
? { syntaxError: fusedNote }
|
|
1238
1461
|
: {
|
|
@@ -1251,6 +1474,7 @@ export function buildTools(backend, config) {
|
|
|
1251
1474
|
: {}),
|
|
1252
1475
|
...syntax,
|
|
1253
1476
|
}),
|
|
1477
|
+
...(skippedNote ? { skippedNote } : {}),
|
|
1254
1478
|
...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
|
|
1255
1479
|
totalLines: countLines(finalContent),
|
|
1256
1480
|
...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
|
|
@@ -1292,7 +1516,7 @@ export function buildTools(backend, config) {
|
|
|
1292
1516
|
// -H: a search scoped to one file printed `line:content` with no file, and every match
|
|
1293
1517
|
// came back `{file: "", line: 0}` (X1 flash-m62).
|
|
1294
1518
|
const grepArgs = `-rnH ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
|
|
1295
|
-
const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout:
|
|
1519
|
+
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 });
|
|
1296
1520
|
// grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
|
|
1297
1521
|
// content alone: the file prefix would otherwise hide an env assignment from
|
|
1298
1522
|
// the line-anchored env pattern, and knowing each match's own path is what
|
|
@@ -1329,6 +1553,11 @@ export function buildTools(backend, config) {
|
|
|
1329
1553
|
return { pattern, path, matches };
|
|
1330
1554
|
}
|
|
1331
1555
|
catch (e) {
|
|
1556
|
+
if (isCommandTimeout(e))
|
|
1557
|
+
return {
|
|
1558
|
+
error: searchTimeoutError('search_files', path, pattern, SEARCH_TIMEOUT_MS / 1000, `Give \`path\` the subdirectory you mean and narrow with \`include\` ` +
|
|
1559
|
+
`(e.g. "*.ts"), or use find_files with the directory in the pattern.`),
|
|
1560
|
+
};
|
|
1332
1561
|
return { error: `Search failed: ${e.message}` };
|
|
1333
1562
|
}
|
|
1334
1563
|
},
|
|
@@ -1350,26 +1579,59 @@ export function buildTools(backend, config) {
|
|
|
1350
1579
|
const symlinkErr = await checkSymlink(path);
|
|
1351
1580
|
if (symlinkErr)
|
|
1352
1581
|
return { error: symlinkErr };
|
|
1582
|
+
// The directory the PATTERN itself names (`node_modules` +
|
|
1583
|
+
// `@molecule/api-resource-user/dist/*.d.ts`) becomes the search root. Matched
|
|
1584
|
+
// against the whole path with an implicit "any parent" anchor, the pattern
|
|
1585
|
+
// made `find` visit every entry below `path` — the whole dependency tree,
|
|
1586
|
+
// which hit the 10 s deadline with no result at all while the one directory
|
|
1587
|
+
// the model meant answered a `list_files` instantly (xr-molecule-flash-m94).
|
|
1588
|
+
// A bare first segment (`helpers/*`) is left alone on purpose: it means "any
|
|
1589
|
+
// `helpers/` below the root" (xr-molecule-flash-m77, see literalDirPrefix).
|
|
1590
|
+
const prefixDir = literalDirPrefix(pattern);
|
|
1591
|
+
let findRoot = path;
|
|
1592
|
+
let findArgs = findPatternArgs(pattern);
|
|
1593
|
+
let missingPrefix;
|
|
1353
1594
|
try {
|
|
1595
|
+
if (prefixDir) {
|
|
1596
|
+
const candidate = `${path.replace(/\/+$/, '')}/${prefixDir}`;
|
|
1597
|
+
const probe = await backend.run(`test -d ${shellQuote(candidate)}`, {
|
|
1598
|
+
timeout: SEARCH_TIMEOUT_MS,
|
|
1599
|
+
});
|
|
1600
|
+
if (probe.exitCode === 0) {
|
|
1601
|
+
findRoot = candidate;
|
|
1602
|
+
// The remainder keeps the `-path` form: `*` crosses `/`, so a `.d.ts`
|
|
1603
|
+
// in `dist/utilities/` is still found (see findPathArgs).
|
|
1604
|
+
findArgs = findPathArgs(pattern.replace(/^(?:\.?\/)+/, '').slice(prefixDir.length + 1));
|
|
1605
|
+
}
|
|
1606
|
+
else {
|
|
1607
|
+
missingPrefix = `"${candidate}" does not exist; searched all of ${path}.`;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1354
1610
|
// `findPatternArgs`, not `-name`: a pattern with a `/` in it
|
|
1355
1611
|
// (`helpers/*`, `e2e/*.spec.ts`) matched nothing at all, because `-name`
|
|
1356
1612
|
// compares it against the basename (xr-molecule-flash-m77).
|
|
1357
|
-
const result = await backend.run(`find ${shellQuote(
|
|
1613
|
+
const result = await backend.run(`find ${shellQuote(findRoot)} ${findPruneArgs(findRoot)} ${findArgs} -print 2>/dev/null | head -${MAX_FIND_RESULTS}`, { timeout: SEARCH_TIMEOUT_MS });
|
|
1358
1614
|
const files = result.stdout.trim().split('\n').filter(Boolean);
|
|
1359
1615
|
// A slash changes what the pattern means — the whole path, not the file
|
|
1360
1616
|
// name — and an empty result does not say so, so the model reads it as
|
|
1361
1617
|
// "no such file" and goes looking elsewhere. Name the rule on the miss.
|
|
1362
|
-
const
|
|
1618
|
+
const missNote = files.length === 0 && pattern.includes('/')
|
|
1363
1619
|
? `"${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.`
|
|
1364
1620
|
: undefined;
|
|
1365
|
-
|
|
1621
|
+
const notes = [missingPrefix, missNote].filter((n) => Boolean(n));
|
|
1622
|
+
return { pattern, path, files, ...(notes.length ? { note: notes.join(' ') } : {}) };
|
|
1366
1623
|
}
|
|
1367
1624
|
catch (e) {
|
|
1625
|
+
if (isCommandTimeout(e))
|
|
1626
|
+
return {
|
|
1627
|
+
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 ` +
|
|
1628
|
+
`${path}) or use list_files on it, and keep the pattern narrow.`),
|
|
1629
|
+
};
|
|
1368
1630
|
return { error: `Find failed: ${e.message}` };
|
|
1369
1631
|
}
|
|
1370
1632
|
},
|
|
1371
1633
|
async create_directory(input) {
|
|
1372
|
-
const path =
|
|
1634
|
+
const path = resolveWrite(input.path);
|
|
1373
1635
|
const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
|
|
1374
1636
|
const symlinkErr = await checkSymlink(parentPath);
|
|
1375
1637
|
if (symlinkErr)
|
|
@@ -1385,8 +1647,10 @@ export function buildTools(backend, config) {
|
|
|
1385
1647
|
}
|
|
1386
1648
|
},
|
|
1387
1649
|
async rename_file(input) {
|
|
1388
|
-
|
|
1389
|
-
|
|
1650
|
+
// Both ends: moving a package's file OUT is as much a change to the installed
|
|
1651
|
+
// package as moving one in.
|
|
1652
|
+
const oldPath = resolveWrite(input.old_path);
|
|
1653
|
+
const newPath = resolveWrite(input.new_path);
|
|
1390
1654
|
const oldSymlinkErr = await checkSymlink(oldPath);
|
|
1391
1655
|
if (oldSymlinkErr)
|
|
1392
1656
|
return { error: oldSymlinkErr };
|
|
@@ -1408,7 +1672,7 @@ export function buildTools(backend, config) {
|
|
|
1408
1672
|
}
|
|
1409
1673
|
},
|
|
1410
1674
|
async delete_file(input) {
|
|
1411
|
-
const path =
|
|
1675
|
+
const path = resolveWrite(input.path);
|
|
1412
1676
|
const symlinkErr = await checkSymlink(path);
|
|
1413
1677
|
if (symlinkErr)
|
|
1414
1678
|
return { error: symlinkErr };
|
|
@@ -1508,6 +1772,7 @@ export function buildTools(backend, config) {
|
|
|
1508
1772
|
: null;
|
|
1509
1773
|
if (ownFile)
|
|
1510
1774
|
outputFileByTask.set(id, ownFile);
|
|
1775
|
+
runByTask.set(id, { command: streaming.command, cwd });
|
|
1511
1776
|
await backend.writeFile(script, `${streaming.command}\n`);
|
|
1512
1777
|
await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
|
|
1513
1778
|
`> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
|
|
@@ -1630,18 +1895,31 @@ export function buildTools(backend, config) {
|
|
|
1630
1895
|
: sanitizeOutput;
|
|
1631
1896
|
const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
|
|
1632
1897
|
const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
|
|
1633
|
-
|
|
1898
|
+
// A run a budget stopped is NOT a run that ended: it reports `status: 'cut_at_budget'`,
|
|
1899
|
+
// never a 0 the pipeline produced, and names how far it got. Read from the command, the
|
|
1900
|
+
// output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
|
|
1901
|
+
// the same state (X1 xr-molecule-flash-m93: `timeout 280 hurl … | tail -60` came back
|
|
1902
|
+
// `exitCode: 0` after 284 s and four of thirteen files, and the executor re-sent it).
|
|
1903
|
+
const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds);
|
|
1904
|
+
if (cut) {
|
|
1905
|
+
const cutLead = cutRunSentence(cut);
|
|
1634
1906
|
return {
|
|
1635
1907
|
stdout,
|
|
1636
1908
|
stderr,
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1909
|
+
status: CUT_AT_BUDGET,
|
|
1910
|
+
exitCode: cut.exitCode,
|
|
1911
|
+
// The facts, so a reader that can also see the suite itself (its own `.hurl` file
|
|
1912
|
+
// count) says the same sentence with the total the output alone cannot give.
|
|
1913
|
+
cutRun: cut,
|
|
1914
|
+
error: `${cutLead} ` +
|
|
1915
|
+
// The tool's own budget stopping a run that printed nothing: keep the sentence that
|
|
1916
|
+
// says why the output is empty, since nothing else in the result can say it.
|
|
1917
|
+
(cut.stoppedBy === 'tool-budget'
|
|
1918
|
+
? `The output above is everything it printed until then${/\|\s*(tail|head)\b/.test(command)
|
|
1919
|
+
? ' — 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)'
|
|
1920
|
+
: ''}. `
|
|
1642
1921
|
: '') +
|
|
1643
|
-
|
|
1644
|
-
'a build and a whole suite.',
|
|
1922
|
+
(isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
|
|
1645
1923
|
};
|
|
1646
1924
|
}
|
|
1647
1925
|
return {
|
|
@@ -1848,6 +2126,11 @@ export function buildTools(backend, config) {
|
|
|
1848
2126
|
// Only a cause that is failing the whole run gets a stop command — and only then is the
|
|
1849
2127
|
// pid walk paid for.
|
|
1850
2128
|
const universal = universalCause(causeLog, found);
|
|
2129
|
+
// How this run was started, so the note can hand over the re-run to send once it ends
|
|
2130
|
+
// instead of one for now — the platform refuses a second run against the same app and
|
|
2131
|
+
// database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
|
|
2132
|
+
// and its overlap hold refused that exact run 23 s later).
|
|
2133
|
+
const started = runByTask.get(taskId);
|
|
1851
2134
|
repeatingCauseNoted.add(taskId);
|
|
1852
2135
|
const earlyMs = Date.now() - startedAt;
|
|
1853
2136
|
waitedByTask.set(taskId, waitedBefore + earlyMs);
|
|
@@ -1860,6 +2143,7 @@ export function buildTools(backend, config) {
|
|
|
1860
2143
|
log,
|
|
1861
2144
|
stop: universal ? await taskStopCommand(backend, taskId) : '',
|
|
1862
2145
|
universal,
|
|
2146
|
+
...(started ? { run: started } : {}),
|
|
1863
2147
|
}),
|
|
1864
2148
|
};
|
|
1865
2149
|
}
|
|
@@ -1974,6 +2258,28 @@ export function buildTools(backend, config) {
|
|
|
1974
2258
|
// the second and third were found by runs stopped before they walked the rest. Null for
|
|
1975
2259
|
// anything that is not a run with failures in it.
|
|
1976
2260
|
const causes = failureCausesNote(output);
|
|
2261
|
+
// A backgrounded run is under no tool budget, and the exit file hands back the PIPELINE's
|
|
2262
|
+
// code — so a suite killed at a `timeout 280` the command carried behind a `| tail` reports
|
|
2263
|
+
// 0 here too. The run's start is in its own id ({@link taskStartedAt}), so the bound it used
|
|
2264
|
+
// is readable without new state: say stopped, and never hand the pipeline's 0 back as the
|
|
2265
|
+
// run's (X1 xr-molecule-flash-m93 re-sent that run three times).
|
|
2266
|
+
const run = runByTask.get(taskId);
|
|
2267
|
+
const startedAtMs = taskStartedAt(taskId);
|
|
2268
|
+
const cut = run && startedAtMs !== null
|
|
2269
|
+
? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0)
|
|
2270
|
+
: null;
|
|
2271
|
+
if (cut) {
|
|
2272
|
+
return {
|
|
2273
|
+
taskId,
|
|
2274
|
+
status: CUT_AT_BUDGET,
|
|
2275
|
+
exitCode: cut.exitCode,
|
|
2276
|
+
waitedMs,
|
|
2277
|
+
cutRun: cut,
|
|
2278
|
+
note: [cutRunSentence(cut), causes].filter((n) => Boolean(n)).join(' '),
|
|
2279
|
+
stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
|
|
2280
|
+
...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
1977
2283
|
// Same shaping as a foreground exec result, so the executor reads both
|
|
1978
2284
|
// the same way.
|
|
1979
2285
|
return {
|