@molecule/api-ai-tools 1.6.6 → 1.8.1
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 +613 -52
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +163 -2
- package/dist/cut-run.d.ts +98 -8
- package/dist/cut-run.d.ts.map +1 -1
- package/dist/cut-run.js +182 -29
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/suite-overlap.d.ts +5 -1
- package/dist/suite-overlap.d.ts.map +1 -1
- package/dist/suite-overlap.js +16 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +290 -82
- package/dist/trace-artifacts.d.ts +147 -0
- package/dist/trace-artifacts.d.ts.map +1 -0
- package/dist/trace-artifacts.js +511 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utilities.d.ts +198 -26
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +594 -67
- package/package.json +2 -2
package/dist/tools.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { commandCutRun, CUT_AT_BUDGET, cutRunSentence } from './cut-run.js';
|
|
9
|
+
import { commandCutRun, CUT_AT_BUDGET, cutRunSentence, suiteRunExitCode } from './cut-run.js';
|
|
10
10
|
import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
|
|
11
11
|
import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
|
|
12
12
|
import { TOOL_SCHEMAS } from './schemas.js';
|
|
@@ -36,6 +36,14 @@ const outputFileByTask = new Map();
|
|
|
36
36
|
* resolves the failing test's `file:line` against the wrong suite.
|
|
37
37
|
*/
|
|
38
38
|
const runByTask = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Why a finished suite run's result carries no exit code: the code a pipeline reports is its last
|
|
41
|
+
* stage's, and a stage after the runner (or a code of 0 beside a failure in the tally) is not the
|
|
42
|
+
* run's verdict. Said in the result so the missing field does not read as an omission.
|
|
43
|
+
*/
|
|
44
|
+
const FILTERED_EXIT_NOTE = 'No exit code is reported for this run: a stage after the test runner produced the pipeline’s ' +
|
|
45
|
+
'code (or the code says 0 beside a failure in the tally), so read the pass/fail tally in the ' +
|
|
46
|
+
'log instead.';
|
|
39
47
|
/**
|
|
40
48
|
* Task ids whose own-output file has already been looked for this process.
|
|
41
49
|
* {@link outputFileByTask} is empty for every task after a restart — it is
|
|
@@ -52,8 +60,20 @@ const repeatingCauseLookMs = () => Number(process.env.MOL_AI_BG_CAUSE_LOOK_MS) |
|
|
|
52
60
|
* Task ids a wait has already named a repeating cause for. The note is news once; a second wait
|
|
53
61
|
* on the same run has nothing to add to it, and re-sending it would spend the executor's turns
|
|
54
62
|
* telling it what it has already been told.
|
|
63
|
+
*
|
|
64
|
+
* Only a wait that ENDED EARLY on the finding lands here ({@link earlyReturnCause}); a lead does
|
|
65
|
+
* not, because the wait it was found in keeps looking — a run can go from a shared duration to a
|
|
66
|
+
* proven cause failing everything as the app goes down, and that is exactly the finding the wait
|
|
67
|
+
* must not sleep through.
|
|
55
68
|
*/
|
|
56
69
|
const repeatingCauseNoted = new Set();
|
|
70
|
+
/**
|
|
71
|
+
* Task ids whose mid-run finding has already ridden a result as a note ({@link
|
|
72
|
+
* repeatingFailureNote} without an {@link earlyReturnCause}). Same rule as
|
|
73
|
+
* {@link repeatingCauseNoted} — news once, and the executor still has the ✘ rows in its context —
|
|
74
|
+
* but kept apart from it, because a task in this set keeps being looked at.
|
|
75
|
+
*/
|
|
76
|
+
const midRunLeadNoted = new Set();
|
|
57
77
|
/**
|
|
58
78
|
* The shell command that stops a background task by pid: the pid the platform wrote when it
|
|
59
79
|
* detached the run, plus every process under it.
|
|
@@ -209,7 +229,8 @@ function runningNote(clauses) {
|
|
|
209
229
|
const joined = clauses.join('; ');
|
|
210
230
|
return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
|
|
211
231
|
}
|
|
212
|
-
import {
|
|
232
|
+
import { diagnosisTraceCommand } from './trace-artifacts.js';
|
|
233
|
+
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, suiteRunPipedAfterRunner, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
|
|
213
234
|
/**
|
|
214
235
|
* How long exec_command waits for a command it backgrounded only because the
|
|
215
236
|
* command asked for more than the tool's ceiling, before handing back a task id.
|
|
@@ -322,6 +343,40 @@ export function installedPackageWriteRefusal(path) {
|
|
|
322
343
|
: '';
|
|
323
344
|
return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
|
|
324
345
|
}
|
|
346
|
+
/** How many failing replacements one refusal names before it stops listing. */
|
|
347
|
+
const MAX_REPORTED_REPLACEMENT_FAILURES = 5;
|
|
348
|
+
/**
|
|
349
|
+
* The one refusal a batch `edit_file` gets, naming EVERY replacement that did not
|
|
350
|
+
* match — each with the same detail a lone failure gets (which replacement, the
|
|
351
|
+
* head of its old_string, where it diverges from the file).
|
|
352
|
+
*
|
|
353
|
+
* A batch is applied all-or-nothing, so one bad `old_string` means none of the
|
|
354
|
+
* others were applied either: they are not "the rest, which worked", they are
|
|
355
|
+
* unchecked work the model will otherwise re-send as-is. Naming one per round trip
|
|
356
|
+
* cost flash-m98 two full re-sends of a six-replacement batch — the second bad
|
|
357
|
+
* hunk was not reported until the first was fixed, 7 s later.
|
|
358
|
+
*
|
|
359
|
+
* @param path - The file the batch targets (nothing was written to it).
|
|
360
|
+
* @param total - How many replacements the batch carried.
|
|
361
|
+
* @param failures - Every failing replacement, in batch order.
|
|
362
|
+
* @returns The error message.
|
|
363
|
+
*/
|
|
364
|
+
function batchFailureError(path, total, failures) {
|
|
365
|
+
// A single replacement keeps the message it has always had: "Replacement 1 of 1"
|
|
366
|
+
// is noise, and the failure of a lone edit is not a batch problem.
|
|
367
|
+
if (total === 1 && failures.length === 1)
|
|
368
|
+
return failures[0].message;
|
|
369
|
+
const shown = failures.slice(0, MAX_REPORTED_REPLACEMENT_FAILURES);
|
|
370
|
+
const omitted = failures.length - shown.length;
|
|
371
|
+
const blocks = shown.map((f) => `Replacement ${f.index} of ${total} failed` +
|
|
372
|
+
(f.start ? ` (its old_string starts "${f.start}")` : '') +
|
|
373
|
+
`:\n${f.message}`);
|
|
374
|
+
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` +
|
|
375
|
+
blocks.join('\n\n') +
|
|
376
|
+
(omitted > 0
|
|
377
|
+
? `\n\n(${omitted} more replacement${omitted === 1 ? '' : 's'} also failed and are not shown.)`
|
|
378
|
+
: ''));
|
|
379
|
+
}
|
|
325
380
|
/**
|
|
326
381
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
327
382
|
*
|
|
@@ -330,7 +385,7 @@ export function installedPackageWriteRefusal(path) {
|
|
|
330
385
|
* @returns Array of AITool objects ready to pass to an AI provider
|
|
331
386
|
*/
|
|
332
387
|
export function buildTools(backend, config) {
|
|
333
|
-
const { include, exclude, pathGuards = true, symlinkGuards = false, redactSecrets: doRedact = true, blockDangerousCommands = false, blockCommand, execTimeoutMs = 120_000, commandBudgetMs, overCeilingGraceMs = OVER_CEILING_GRACE_MS, searchExcludedDirs, outlineLargeHtml, onAfterWrite, onFileDiff, onFileChange, } = config ?? {};
|
|
388
|
+
const { include, exclude, pathGuards = true, symlinkGuards = false, redactSecrets: doRedact = true, blockDangerousCommands = false, blockCommand, execTimeoutMs = 120_000, commandBudgetMs, overCeilingGraceMs = OVER_CEILING_GRACE_MS, searchExcludedDirs, outlineLargeHtml, excludedTestTitles, onAfterWrite, onFileDiff, onFileChange, } = config ?? {};
|
|
334
389
|
const root = backend.projectRoot;
|
|
335
390
|
// A host-filesystem backend with escape guards off is the combination the
|
|
336
391
|
// audit flagged: `pathGuards` off lets `..` paths out of the workspace,
|
|
@@ -1080,6 +1135,13 @@ export function buildTools(backend, config) {
|
|
|
1080
1135
|
// Replacements refused for changing the line break at old_string's boundary and
|
|
1081
1136
|
// nothing else — reported by position, never applied (see below).
|
|
1082
1137
|
const skipped = [];
|
|
1138
|
+
// Every replacement this batch refuses, collected rather than returned on the
|
|
1139
|
+
// first: a batch is atomic, so the other bad hunks are just as unreported, and
|
|
1140
|
+
// one-per-round-trip cost flash-m98 two full re-sends of a six-replacement
|
|
1141
|
+
// batch (the second bad hunk was not named until the first was fixed).
|
|
1142
|
+
const failures = [];
|
|
1143
|
+
/** How a failure names the replacement it is about: the head of its old_string. */
|
|
1144
|
+
const startOf = (text) => text.split('\n')[0].trim().slice(0, 80);
|
|
1083
1145
|
// Where each applied replacement landed, in the coordinates of the
|
|
1084
1146
|
// latest content — rendered into the result so the model sees the edit.
|
|
1085
1147
|
let spans = [];
|
|
@@ -1112,9 +1174,12 @@ export function buildTools(backend, config) {
|
|
|
1112
1174
|
// method access) or silently inserts the literal "undefined" (a missing
|
|
1113
1175
|
// new_string). Fail fast with an actionable message instead.
|
|
1114
1176
|
if (typeof oldString !== 'string' || oldString === '' || typeof newString !== 'string') {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1177
|
+
failures.push({
|
|
1178
|
+
index: index + 1,
|
|
1179
|
+
start: typeof oldString === 'string' ? startOf(oldString) : '',
|
|
1180
|
+
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: "".`,
|
|
1181
|
+
});
|
|
1182
|
+
continue;
|
|
1118
1183
|
}
|
|
1119
1184
|
const count = content.split(oldString).length - 1;
|
|
1120
1185
|
// The edit is ALREADY in the file: old_string is gone and a distinctive
|
|
@@ -1136,9 +1201,12 @@ export function buildTools(backend, config) {
|
|
|
1136
1201
|
// match failed and hand back the file's actual content so it fixes in
|
|
1137
1202
|
// one retry instead of many.
|
|
1138
1203
|
if (oldString.includes('\\n')) {
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1204
|
+
failures.push({
|
|
1205
|
+
index: index + 1,
|
|
1206
|
+
start: startOf(oldString),
|
|
1207
|
+
message: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
|
|
1208
|
+
});
|
|
1209
|
+
continue;
|
|
1142
1210
|
}
|
|
1143
1211
|
// Whitespace-only mismatch: the text is there but indentation/spacing
|
|
1144
1212
|
// differs (tabs vs spaces, trailing whitespace). If a UNIQUE line-run
|
|
@@ -1154,9 +1222,12 @@ export function buildTools(backend, config) {
|
|
|
1154
1222
|
const normalize = (s) => s.replace(/\s+/g, ' ').trim();
|
|
1155
1223
|
const normOld = normalize(oldString);
|
|
1156
1224
|
if (normOld && normalize(content).includes(normOld)) {
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1225
|
+
failures.push({
|
|
1226
|
+
index: index + 1,
|
|
1227
|
+
start: startOf(oldString),
|
|
1228
|
+
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.`,
|
|
1229
|
+
});
|
|
1230
|
+
continue;
|
|
1160
1231
|
}
|
|
1161
1232
|
// Anchor probe: locate the target region by the most DISTINCTIVE line
|
|
1162
1233
|
// of old_string (longest non-trivial line first, requiring a UNIQUE
|
|
@@ -1174,9 +1245,7 @@ export function buildTools(backend, config) {
|
|
|
1174
1245
|
.slice(start, idx + 8)
|
|
1175
1246
|
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
1176
1247
|
.join('\n');
|
|
1177
|
-
return {
|
|
1178
|
-
error: `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
|
|
1179
|
-
};
|
|
1248
|
+
return `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`;
|
|
1180
1249
|
};
|
|
1181
1250
|
// An import block the model rebuilt in its own order: the linter's
|
|
1182
1251
|
// import sort owns that order, so name it and show the real block. X1
|
|
@@ -1193,12 +1262,15 @@ export function buildTools(backend, config) {
|
|
|
1193
1262
|
.slice(start, at + 1)
|
|
1194
1263
|
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
1195
1264
|
.join('\n');
|
|
1196
|
-
|
|
1197
|
-
|
|
1265
|
+
failures.push({
|
|
1266
|
+
index: index + 1,
|
|
1267
|
+
start: startOf(oldString),
|
|
1268
|
+
message: `old_string not found in ${path}: it is an import block, and the linter's import ` +
|
|
1198
1269
|
`sort decides its order, so do not rebuild it. The file's import from ` +
|
|
1199
1270
|
`'${imported}' reads:\n${block}\nEdit one line of it, or add your import as a ` +
|
|
1200
1271
|
'new line and let the linter sort it.',
|
|
1201
|
-
};
|
|
1272
|
+
});
|
|
1273
|
+
continue;
|
|
1202
1274
|
}
|
|
1203
1275
|
}
|
|
1204
1276
|
const distinctiveLines = [
|
|
@@ -1233,13 +1305,11 @@ export function buildTools(backend, config) {
|
|
|
1233
1305
|
.slice(start, base + k + 6)
|
|
1234
1306
|
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
1235
1307
|
.join('\n');
|
|
1236
|
-
return {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
`The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
|
|
1242
|
-
};
|
|
1308
|
+
return (`old_string not found in ${path} — it matches the file through line ${base + k}, ` +
|
|
1309
|
+
`then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
|
|
1310
|
+
`Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
|
|
1311
|
+
`The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
|
|
1312
|
+
`The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`);
|
|
1243
1313
|
};
|
|
1244
1314
|
// The longest run of old_string lines that IS in the file decides which
|
|
1245
1315
|
// alignment to report, so a later distinctive line can beat an earlier
|
|
@@ -1271,10 +1341,22 @@ export function buildTools(backend, config) {
|
|
|
1271
1341
|
if (k > 0 && k < oldLines.length && (best === null || k > best.k))
|
|
1272
1342
|
best = { base, k };
|
|
1273
1343
|
}
|
|
1274
|
-
if (best)
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1344
|
+
if (best) {
|
|
1345
|
+
failures.push({
|
|
1346
|
+
index: index + 1,
|
|
1347
|
+
start: startOf(oldString),
|
|
1348
|
+
message: showDivergence(best.base, best.k),
|
|
1349
|
+
});
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
if (firstUniqueHit !== null) {
|
|
1353
|
+
failures.push({
|
|
1354
|
+
index: index + 1,
|
|
1355
|
+
start: startOf(oldString),
|
|
1356
|
+
message: showSnippet(firstUniqueHit),
|
|
1357
|
+
});
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1278
1360
|
// Fallback: the first non-blank line, even if not unique (better than a
|
|
1279
1361
|
// bare "re-read" when the model just needs to see nearby real content).
|
|
1280
1362
|
const firstLine = oldString
|
|
@@ -1283,8 +1365,14 @@ export function buildTools(backend, config) {
|
|
|
1283
1365
|
?.trim();
|
|
1284
1366
|
if (firstLine) {
|
|
1285
1367
|
const idx = fileLines.findIndex((l) => l.includes(firstLine));
|
|
1286
|
-
if (idx >= 0)
|
|
1287
|
-
|
|
1368
|
+
if (idx >= 0) {
|
|
1369
|
+
failures.push({
|
|
1370
|
+
index: index + 1,
|
|
1371
|
+
start: startOf(oldString),
|
|
1372
|
+
message: showSnippet(idx),
|
|
1373
|
+
});
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1288
1376
|
}
|
|
1289
1377
|
// Nothing anchored. Say WHICH of the two situations this is — they
|
|
1290
1378
|
// call for different next moves, and the old message implied the
|
|
@@ -1294,20 +1382,20 @@ export function buildTools(backend, config) {
|
|
|
1294
1382
|
// "re-read and copy the exact text", which is wrong advice when the
|
|
1295
1383
|
// text is not in the file at all.
|
|
1296
1384
|
const anchorsPresent = distinctiveLines.some((anchor) => fileLines.some((l) => l.includes(anchor)));
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1385
|
+
failures.push({
|
|
1386
|
+
index: index + 1,
|
|
1387
|
+
start: startOf(oldString),
|
|
1388
|
+
message: !anchorsPresent
|
|
1389
|
+
? `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
|
|
1300
1390
|
`that file, so this is not a whitespace problem. Either the edit was already ` +
|
|
1301
1391
|
`applied, or this is the wrong file. read_file it and look before trying ` +
|
|
1302
|
-
`again; do not retry the same old_string
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
`indentation — or include more surrounding context to make it unique.`,
|
|
1310
|
-
};
|
|
1392
|
+
`again; do not retry the same old_string.`
|
|
1393
|
+
: `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
|
|
1394
|
+
`line is distinctive enough to locate the target. read_file the region you ` +
|
|
1395
|
+
`mean and copy old_string verbatim from what it returns — including ` +
|
|
1396
|
+
`indentation — or include more surrounding context to make it unique.`,
|
|
1397
|
+
});
|
|
1398
|
+
continue;
|
|
1311
1399
|
}
|
|
1312
1400
|
if (count > 1 && (replaceAll === true || replaceAllCamel === true)) {
|
|
1313
1401
|
// Every occurrence, by split/join — never String.replace, whose
|
|
@@ -1323,9 +1411,12 @@ export function buildTools(backend, config) {
|
|
|
1323
1411
|
const lines = [];
|
|
1324
1412
|
for (let at = content.indexOf(oldString); at >= 0 && lines.length < 10; at = content.indexOf(oldString, at + 1))
|
|
1325
1413
|
lines.push(content.slice(0, at).split('\n').length);
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1414
|
+
failures.push({
|
|
1415
|
+
index: index + 1,
|
|
1416
|
+
start: startOf(oldString),
|
|
1417
|
+
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.`,
|
|
1418
|
+
});
|
|
1419
|
+
continue;
|
|
1329
1420
|
}
|
|
1330
1421
|
// Splice by index rather than `content.replace(oldString, newString)`:
|
|
1331
1422
|
// String.replace treats `$&`, `$$`, `` $` ``, `$'` in the REPLACEMENT as
|
|
@@ -1352,6 +1443,13 @@ export function buildTools(backend, config) {
|
|
|
1352
1443
|
spans = addChangeSpan(spans, content, spliced);
|
|
1353
1444
|
content = spliced;
|
|
1354
1445
|
}
|
|
1446
|
+
// Every replacement was checked before this: a batch that refuses ANY of them
|
|
1447
|
+
// writes nothing, so the ones that DID match are just as unreported as the ones
|
|
1448
|
+
// that did not — and naming them one per round trip cost flash-m98 two full
|
|
1449
|
+
// re-sends of a six-replacement batch. Nothing is written here: the file on disk
|
|
1450
|
+
// is the original, and the model fixes all of them in one retry.
|
|
1451
|
+
if (failures.length)
|
|
1452
|
+
return { error: batchFailureError(path, replacements.length, failures) };
|
|
1355
1453
|
// One line per refused replacement: what it would have done, and the two ways to
|
|
1356
1454
|
// say it on purpose. The rest of the batch is applied — the second half of the pair
|
|
1357
1455
|
// above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
|
|
@@ -1687,7 +1785,7 @@ export function buildTools(backend, config) {
|
|
|
1687
1785
|
}
|
|
1688
1786
|
},
|
|
1689
1787
|
async exec_command(input) {
|
|
1690
|
-
const
|
|
1788
|
+
const rawCommand = input.command;
|
|
1691
1789
|
const cwd = input.cwd ? resolve(input.cwd, 'cwd') : root;
|
|
1692
1790
|
// A model that sent the command under another name (`cmd`, `script`, …)
|
|
1693
1791
|
// has already been normalized by guardToolExecute; reaching here with
|
|
@@ -1697,21 +1795,32 @@ export function buildTools(backend, config) {
|
|
|
1697
1795
|
// undefined (reading 'match')" — the executor read that as a broken shell,
|
|
1698
1796
|
// stopped verifying, and ended the turn claiming all checks passed while
|
|
1699
1797
|
// seven acceptance checks failed (X0 rehearsal 83, 2026-09-16).
|
|
1700
|
-
if (typeof
|
|
1798
|
+
if (typeof rawCommand !== 'string' || rawCommand.trim() === '') {
|
|
1701
1799
|
return { error: missingParamError(TOOL_SCHEMAS.exec_command, input, ['command']) };
|
|
1702
1800
|
}
|
|
1703
1801
|
if (blockDangerousCommands) {
|
|
1704
|
-
const blocked = checkBlockedCommand(
|
|
1802
|
+
const blocked = checkBlockedCommand(rawCommand);
|
|
1705
1803
|
if (blocked)
|
|
1706
1804
|
return { error: blocked };
|
|
1707
1805
|
}
|
|
1708
1806
|
// Consumer-specific guard (see ToolBuildConfig.blockCommand) — an environment can
|
|
1709
1807
|
// veto commands the generic dangerous-command check can't know about.
|
|
1710
1808
|
if (blockCommand) {
|
|
1711
|
-
const blocked = blockCommand(
|
|
1809
|
+
const blocked = blockCommand(rawCommand, cwd);
|
|
1712
1810
|
if (blocked)
|
|
1713
1811
|
return { error: blocked };
|
|
1714
1812
|
}
|
|
1813
|
+
// A diagnosis run of a suite whose config records no trace gets one recorded anyway
|
|
1814
|
+
// (`diagnosisTraceCommand`, trace-artifacts.ts): the failing tests' own network log is what
|
|
1815
|
+
// tells a swallowed request error from a selector, and X1 xr-molecule-flash-m100 spent the
|
|
1816
|
+
// last minutes of its run hand-writing Chromium probes for what the trace it never wrote
|
|
1817
|
+
// down would have said. The whole rule is that function's; the proof run is never touched.
|
|
1818
|
+
const traced = await diagnosisTraceCommand({
|
|
1819
|
+
command: rawCommand,
|
|
1820
|
+
cwd,
|
|
1821
|
+
readFile: (path) => backend.readFile(path),
|
|
1822
|
+
});
|
|
1823
|
+
const command = traced.command;
|
|
1715
1824
|
// Detached: start it, hand back a handle, let the turn continue. A
|
|
1716
1825
|
// command that outlives the tool's ceiling is otherwise simply lost —
|
|
1717
1826
|
// measured across six agent runs, ten of them were killed at the
|
|
@@ -1759,6 +1868,10 @@ export function buildTools(backend, config) {
|
|
|
1759
1868
|
// Keep the log growing while it runs (see streamingBackgroundCommand):
|
|
1760
1869
|
// an empty log is what wait_for_task reads as stuck.
|
|
1761
1870
|
const streaming = streamingBackgroundCommand(command);
|
|
1871
|
+
// …and the trace flag this tool added (above), which the streaming rewrite never saw:
|
|
1872
|
+
// its changes are what this branch reports the run's own settings in.
|
|
1873
|
+
if (traced.change)
|
|
1874
|
+
streaming.changes.unshift(traced.change);
|
|
1762
1875
|
// A whole-suite run starves the sandbox it shares with the app and with
|
|
1763
1876
|
// every command the executor sends next, so it starts at lower CPU and
|
|
1764
1877
|
// IO priority (see suiteRunPriorityLaunch). Foreground runs are never
|
|
@@ -1774,6 +1887,9 @@ export function buildTools(backend, config) {
|
|
|
1774
1887
|
outputFileByTask.set(id, ownFile);
|
|
1775
1888
|
runByTask.set(id, { command: streaming.command, cwd });
|
|
1776
1889
|
await backend.writeFile(script, `${streaming.command}\n`);
|
|
1890
|
+
// The clock the grace return below reports a stopped run against: the command is
|
|
1891
|
+
// started here, and how long it ran is what a stop at its own bound is read from.
|
|
1892
|
+
const launchedAt = Date.now();
|
|
1777
1893
|
await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
|
|
1778
1894
|
`> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
|
|
1779
1895
|
// A command backgrounded only for the budget it ASKED for often ends in
|
|
@@ -1803,12 +1919,43 @@ export function buildTools(backend, config) {
|
|
|
1803
1919
|
// Finished without writing anything: an empty output is still a result.
|
|
1804
1920
|
output = '';
|
|
1805
1921
|
}
|
|
1922
|
+
const stdout = sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE));
|
|
1923
|
+
// A grace return is a foreground-shaped result, so it carries the same stop: this
|
|
1924
|
+
// command was started because its output is withheld until it ends (a Playwright
|
|
1925
|
+
// run behind a pipe), and a run that printed a tally accounting for fewer tests
|
|
1926
|
+
// than it announced was stopped — not "Finished … with exit code 0" (the pipeline's
|
|
1927
|
+
// 0 is the filter's). No tool budget applies to a detached run, so the command's
|
|
1928
|
+
// own `timeout` is the only bound here.
|
|
1929
|
+
const cut = commandCutRun(command, { exitCode: code, stdout, stderr: '' }, Date.now() - launchedAt, 0, output);
|
|
1930
|
+
if (cut) {
|
|
1931
|
+
return {
|
|
1932
|
+
stdout,
|
|
1933
|
+
stderr: '',
|
|
1934
|
+
status: CUT_AT_BUDGET,
|
|
1935
|
+
exitCode: cut.exitCode,
|
|
1936
|
+
cutRun: cut,
|
|
1937
|
+
...(output.length > MAX_OUTPUT_SIZE
|
|
1938
|
+
? { truncated: true, fullOutput: log }
|
|
1939
|
+
: {}),
|
|
1940
|
+
error: `${cutRunSentence(cut)} ` +
|
|
1941
|
+
(isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
// The exit file holds the PIPELINE's code: when a stage this rewrite could not
|
|
1945
|
+
// prove is output-only still follows the suite runner (`| tee run.log`), that code
|
|
1946
|
+
// is the filter's — report none and let the log's tally decide (m101 +25:54).
|
|
1947
|
+
const piped = suiteRunPipedAfterRunner(streaming.command);
|
|
1948
|
+
const reported = suiteRunExitCode(code, output, piped);
|
|
1949
|
+
const notes = [
|
|
1950
|
+
...streaming.changes,
|
|
1951
|
+
...(reported === null ? [FILTERED_EXIT_NOTE] : []),
|
|
1952
|
+
];
|
|
1806
1953
|
return {
|
|
1807
|
-
stdout
|
|
1954
|
+
stdout,
|
|
1808
1955
|
stderr: '',
|
|
1809
|
-
exitCode:
|
|
1956
|
+
exitCode: reported,
|
|
1810
1957
|
...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
|
|
1811
|
-
...(
|
|
1958
|
+
...(notes.length ? { note: notes.join(' ') } : {}),
|
|
1812
1959
|
};
|
|
1813
1960
|
}
|
|
1814
1961
|
await new Promise((r) => setTimeout(r, 1_500));
|
|
@@ -1900,7 +2047,12 @@ export function buildTools(backend, config) {
|
|
|
1900
2047
|
// output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
|
|
1901
2048
|
// the same state (X1 xr-molecule-flash-m93: `timeout 280 hurl … | tail -60` came back
|
|
1902
2049
|
// `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
|
|
2050
|
+
const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds,
|
|
2051
|
+
// Detection reads the RAW output: `truncateMiddle` keeps the head and the tail, and the
|
|
2052
|
+
// runner's announcement (`Running 139 tests using 1 worker`) is the first line a long run
|
|
2053
|
+
// prints — the one a size cap drops first. Only the counts and the tally come from here;
|
|
2054
|
+
// what the result quotes is still the sanitized, truncated text.
|
|
2055
|
+
`${result.stdout}\n${result.stderr}`);
|
|
1904
2056
|
if (cut) {
|
|
1905
2057
|
const cutLead = cutRunSentence(cut);
|
|
1906
2058
|
return {
|
|
@@ -1926,7 +2078,9 @@ export function buildTools(backend, config) {
|
|
|
1926
2078
|
stdout,
|
|
1927
2079
|
stderr,
|
|
1928
2080
|
exitCode: result.exitCode,
|
|
1929
|
-
...(ceilingNote
|
|
2081
|
+
...(ceilingNote || traced.change
|
|
2082
|
+
? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
|
|
2083
|
+
: {}),
|
|
1930
2084
|
};
|
|
1931
2085
|
}
|
|
1932
2086
|
catch (e) {
|
|
@@ -2092,9 +2246,19 @@ export function buildTools(backend, config) {
|
|
|
2092
2246
|
// and then fixed it in 51 s. The task is NOT stopped — the executor is handed the cause and
|
|
2093
2247
|
// decides, and the note tells it to let the run finish because the run's other causes only
|
|
2094
2248
|
// surface in it (X1 flash-m83 stopped eight runs at their first repeating cause and never
|
|
2095
|
-
// finished one).
|
|
2096
|
-
//
|
|
2097
|
-
//
|
|
2249
|
+
// finished one).
|
|
2250
|
+
//
|
|
2251
|
+
// WHAT ENDS THIS WAIT is the whole of the executor's next move, so only ONE finding does:
|
|
2252
|
+
// `earlyReturnCause` — a PROVEN cause (an error repeating verbatim, never a shared duration)
|
|
2253
|
+
// that is failing the WHOLE run (every failure the log names, across `UNIVERSAL_CAUSE_FILES`
|
|
2254
|
+
// files or more, with nothing passing since the first). Anything else is a LEAD: the wait
|
|
2255
|
+
// keeps going to the caller's own timeout, and the finding is attached to the result of this
|
|
2256
|
+
// wait as a note. X1 xr-molecule-flash-m99 (2026-10-16) was cut short twice by a status that
|
|
2257
|
+
// returned early on three tests sharing the suite's own `timeout: 15_000` — a signature the
|
|
2258
|
+
// note it printed called "a lead, not a cause" — and read the early return as "stop waiting,
|
|
2259
|
+
// edit now", voiding a run that was still walking both times (the second wait, 224 s of a
|
|
2260
|
+
// 280 s budget, ended with nothing at all).
|
|
2261
|
+
let midRunNote = null;
|
|
2098
2262
|
let lastCauseLookAt = startedAt - repeatingCauseLookMs();
|
|
2099
2263
|
let exitCode = await readExit();
|
|
2100
2264
|
while (exitCode === null && Date.now() - startedAt < budgetMs) {
|
|
@@ -2123,29 +2287,46 @@ export function buildTools(backend, config) {
|
|
|
2123
2287
|
const found = repeatingFailure(causeLog);
|
|
2124
2288
|
if (!found)
|
|
2125
2289
|
continue;
|
|
2126
|
-
//
|
|
2127
|
-
//
|
|
2128
|
-
const
|
|
2290
|
+
// The ONE predicate behind this decision and `repeatingFailureNote`'s wording, so the two
|
|
2291
|
+
// cannot come to different verdicts.
|
|
2292
|
+
const stopCause = earlyReturnCause(found, universalCause(causeLog, found));
|
|
2129
2293
|
// How this run was started, so the note can hand over the re-run to send once it ends
|
|
2130
2294
|
// instead of one for now — the platform refuses a second run against the same app and
|
|
2131
2295
|
// database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
|
|
2132
2296
|
// and its overlap hold refused that exact run 23 s later).
|
|
2133
2297
|
const started = runByTask.get(taskId);
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
taskId,
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2298
|
+
if (stopCause) {
|
|
2299
|
+
// …and only now is the pid walk paid for.
|
|
2300
|
+
repeatingCauseNoted.add(taskId);
|
|
2301
|
+
const earlyMs = Date.now() - startedAt;
|
|
2302
|
+
waitedByTask.set(taskId, waitedBefore + earlyMs);
|
|
2303
|
+
return {
|
|
2304
|
+
taskId,
|
|
2305
|
+
status: 'repeating_failure',
|
|
2306
|
+
waitedMs: earlyMs,
|
|
2307
|
+
note: repeatingFailureNote(found, {
|
|
2308
|
+
taskId,
|
|
2309
|
+
log,
|
|
2310
|
+
stop: await taskStopCommand(backend, taskId),
|
|
2311
|
+
universal: stopCause,
|
|
2312
|
+
...(started ? { run: started } : {}),
|
|
2313
|
+
}),
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
// A lead: this wait goes on, and the finding rides its result as a note — the executor has
|
|
2317
|
+
// to keep waiting for the run's own result, so it must not be told one in the status. The
|
|
2318
|
+
// look keeps running for the same reason: an app that goes down mid-run turns a shared
|
|
2319
|
+
// duration into a proven whole-run cause, and that one is worth returning early for.
|
|
2320
|
+
if (!midRunLeadNoted.has(taskId)) {
|
|
2321
|
+
midRunLeadNoted.add(taskId);
|
|
2322
|
+
midRunNote = repeatingFailureNote(found, {
|
|
2142
2323
|
taskId,
|
|
2143
2324
|
log,
|
|
2144
|
-
stop:
|
|
2145
|
-
universal,
|
|
2325
|
+
stop: '',
|
|
2326
|
+
universal: null,
|
|
2146
2327
|
...(started ? { run: started } : {}),
|
|
2147
|
-
})
|
|
2148
|
-
}
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2149
2330
|
}
|
|
2150
2331
|
const waitedMs = Date.now() - startedAt;
|
|
2151
2332
|
waitedByTask.set(taskId, waitedBefore + waitedMs);
|
|
@@ -2188,6 +2369,13 @@ export function buildTools(backend, config) {
|
|
|
2188
2369
|
waitedMs: waitedBefore + waitedMs,
|
|
2189
2370
|
}));
|
|
2190
2371
|
const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
|
|
2372
|
+
// What this wait found mid-run, when it was not a reason to end early: it rides the result
|
|
2373
|
+
// the wait is about to hand back, because that result is where the executor decides what to
|
|
2374
|
+
// do next and a lead it never saw is a lead it cannot weigh. Not on the `stuck` answer
|
|
2375
|
+
// below — that verdict is about the command having stopped PRINTING and carries its own
|
|
2376
|
+
// instruction — and not on a finished run's result, where `failureCausesNote` reads the
|
|
2377
|
+
// whole log instead of the 40 KB tail a mid-run look is given.
|
|
2378
|
+
const midRun = midRunNote ? ` ${midRunNote}` : '';
|
|
2191
2379
|
if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
2192
2380
|
waitedByTask.set(taskId, 0);
|
|
2193
2381
|
return {
|
|
@@ -2196,7 +2384,7 @@ export function buildTools(backend, config) {
|
|
|
2196
2384
|
waitedMs,
|
|
2197
2385
|
...sliceField,
|
|
2198
2386
|
note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
|
|
2199
|
-
`tail ${log}
|
|
2387
|
+
`tail ${log}.${midRun}`,
|
|
2200
2388
|
};
|
|
2201
2389
|
}
|
|
2202
2390
|
if (waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
@@ -2223,9 +2411,11 @@ export function buildTools(backend, config) {
|
|
|
2223
2411
|
(soFar.trim()
|
|
2224
2412
|
? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
|
|
2225
2413
|
`the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
|
|
2226
|
-
`\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error
|
|
2414
|
+
`\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error: this run writes ` +
|
|
2415
|
+
`each failure's own report into the log as that test fails, right under its ✘ line, ` +
|
|
2416
|
+
`not only in the summary at the end. `
|
|
2227
2417
|
: `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
|
|
2228
|
-
`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
|
|
2418
|
+
`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}`,
|
|
2229
2419
|
};
|
|
2230
2420
|
}
|
|
2231
2421
|
let output;
|
|
@@ -2257,16 +2447,27 @@ export function buildTools(backend, config) {
|
|
|
2257
2447
|
// flash-m83 (2026-10-16) spent three deploys (6.7 minutes) each fixing a single cause, and
|
|
2258
2448
|
// the second and third were found by runs stopped before they walked the rest. Null for
|
|
2259
2449
|
// anything that is not a run with failures in it.
|
|
2260
|
-
|
|
2450
|
+
// How this run was started, so the note can hand over the narrowed re-run of exactly its
|
|
2451
|
+
// failing tests instead of leaving the executor to re-run the whole suite (xr-molecule-
|
|
2452
|
+
// flash-m101 +12:13: 139 tests, ~4 minutes, to check two fixes).
|
|
2453
|
+
const run = runByTask.get(taskId);
|
|
2454
|
+
// The tests the request excludes are not causes to fix, and the re-run below inverts them
|
|
2455
|
+
// out — a result must never ask the executor to fix a test it also says does not count.
|
|
2456
|
+
// The log's own path rides along so a recap that leaves causes out can hand over the ONE
|
|
2457
|
+
// grep that prints their bodies: the stdout above is cut in the middle, so the log is where
|
|
2458
|
+
// they still are whole.
|
|
2459
|
+
const causes = failureCausesNote(output, excludedTestTitles?.() ?? [], run ? { ...run, log: ownFile ?? log } : undefined);
|
|
2261
2460
|
// A backgrounded run is under no tool budget, and the exit file hands back the PIPELINE's
|
|
2262
2461
|
// code — so a suite killed at a `timeout 280` the command carried behind a `| tail` reports
|
|
2263
2462
|
// 0 here too. The run's start is in its own id ({@link taskStartedAt}), so the bound it used
|
|
2264
2463
|
// is readable without new state: say stopped, and never hand the pipeline's 0 back as the
|
|
2265
2464
|
// run's (X1 xr-molecule-flash-m93 re-sent that run three times).
|
|
2266
|
-
const run = runByTask.get(taskId);
|
|
2267
2465
|
const startedAtMs = taskStartedAt(taskId);
|
|
2268
2466
|
const cut = run && startedAtMs !== null
|
|
2269
|
-
? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0
|
|
2467
|
+
? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0,
|
|
2468
|
+
// Same reason as the foreground path: the log is raw here, and the returned stdout
|
|
2469
|
+
// below is the truncated copy — detection must not read that copy.
|
|
2470
|
+
output)
|
|
2270
2471
|
: null;
|
|
2271
2472
|
if (cut) {
|
|
2272
2473
|
return {
|
|
@@ -2281,12 +2482,19 @@ export function buildTools(backend, config) {
|
|
|
2281
2482
|
};
|
|
2282
2483
|
}
|
|
2283
2484
|
// Same shaping as a foreground exec result, so the executor reads both
|
|
2284
|
-
// the same way.
|
|
2485
|
+
// the same way. The exit file holds the PIPELINE's code: when a stage this rewrite could not
|
|
2486
|
+
// prove is output-only still follows the suite runner (`| tee run.log`), that code is the
|
|
2487
|
+
// filter's — report none and let the log's tally decide (m101 +25:54).
|
|
2488
|
+
const piped = run ? suiteRunPipedAfterRunner(run.command) : false;
|
|
2489
|
+
const reported = suiteRunExitCode(exitCode, output, piped);
|
|
2490
|
+
const noCodeNote = reported === null ? FILTERED_EXIT_NOTE : null;
|
|
2285
2491
|
return {
|
|
2286
2492
|
taskId,
|
|
2287
|
-
exitCode,
|
|
2493
|
+
exitCode: reported,
|
|
2288
2494
|
waitedMs,
|
|
2289
|
-
...(causes
|
|
2495
|
+
...(causes || noCodeNote
|
|
2496
|
+
? { note: [causes, noCodeNote].filter((n) => Boolean(n)).join(' ') }
|
|
2497
|
+
: {}),
|
|
2290
2498
|
stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
|
|
2291
2499
|
...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
|
|
2292
2500
|
};
|