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