@sema-agent/core 1.450.0 → 1.452.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/dist/agents/observer.js +3 -0
- package/dist/agents/subagent.js +51 -12
- package/dist/core/ask-question.js +5 -5
- package/dist/core/background-agent-store.d.ts +2 -0
- package/dist/core/background-agent-store.js +5 -1
- package/dist/core/exec-output-tail.d.ts +18 -1
- package/dist/core/exec-output-tail.js +38 -5
- package/dist/core/lsp-session.d.ts +1 -0
- package/dist/core/lsp-session.js +24 -6
- package/dist/core/lsp.d.ts +10 -0
- package/dist/core/lsp.js +63 -6
- package/dist/core/mailbox-store.d.ts +3 -2
- package/dist/core/mailbox-store.js +19 -4
- package/dist/core/memory.js +1 -1
- package/dist/core/runner/prepare-task.js +11 -1
- package/dist/core/runner/runtask.js +1 -1
- package/dist/core/session-reconcile.js +5 -2
- package/dist/core/task-notification.d.ts +1 -0
- package/dist/core/task-registry.d.ts +15 -9
- package/dist/core/task-registry.js +290 -53
- package/dist/core/tool-result-store.js +2 -2
- package/dist/core/tools.d.ts +5 -0
- package/dist/core/tools.js +3 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/workflow-journal-store.d.ts +2 -0
- package/dist/core/workflow-journal-store.js +14 -0
- package/dist/engine/execution-env/node-execution-env.d.ts +1 -0
- package/dist/engine/execution-env/node-execution-env.js +130 -20
- package/dist/engine/lsp/node-lsp-manager.d.ts +3 -1
- package/dist/engine/lsp/node-lsp-manager.js +22 -5
- package/dist/engine/lsp/stdio-lsp-transport.d.ts +1 -1
- package/dist/engine/lsp/stdio-lsp-transport.js +17 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/orchestration/run-workflow-tool.d.ts +2 -0
- package/dist/orchestration/run-workflow-tool.js +35 -6
- package/dist/orchestration/workflow.d.ts +9 -0
- package/dist/orchestration/workflow.js +80 -5
- package/dist/stores/cc/mailbox-store.js +6 -1
- package/dist/stores/file/background-agent-store.js +3 -2
- package/dist/stores/file/mailbox-store.d.ts +1 -1
- package/dist/stores/file/mailbox-store.js +9 -7
- package/dist/tools/fs/encoding.d.ts +5 -0
- package/dist/tools/fs/encoding.js +6 -0
- package/dist/tools/fs/index.js +184 -120
- package/dist/tools/fs/notebook.d.ts +43 -0
- package/dist/tools/fs/notebook.js +141 -0
- package/dist/tools/fs/repo-map.js +2 -2
- package/dist/tools/fs/search.js +141 -12
- package/dist/tools/gitea-issue.js +4 -2
- package/dist/tools/monitor.js +12 -8
- package/dist/tools/scheduler-tools.js +16 -16
- package/dist/tools/task-list.js +34 -12
- package/dist/tools/web.d.ts +2 -0
- package/dist/tools/web.js +105 -19
- package/dist/tools/worktree.js +14 -14
- package/package.json +1 -1
package/dist/tools/fs/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { defineTool } from "../../core/tools.js";
|
|
3
|
+
import { defineTool, errorResult } from "../../core/tools.js";
|
|
4
4
|
import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../../core/task-registry.js";
|
|
5
5
|
import { hasBackgroundShell } from "../../core/background-shell.js";
|
|
6
6
|
import { delimitUntrusted } from "../../core/untrusted-text.js";
|
|
7
7
|
import { clipWithFilePointer } from "../../core/tool-errors.js";
|
|
8
8
|
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, similarNameSuggestion, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, OVERSIZE_READ_ESCAPE_HINT, isBlockedDevicePath, normalizeAbsPathLexically, } from "./safety.js";
|
|
9
|
-
import { decodeTextBytes, encodeTextForFile, normalizeEditText } from "./encoding.js";
|
|
9
|
+
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText, splitLeadingBom } from "./encoding.js";
|
|
10
10
|
import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, shellQuote } from "./search.js";
|
|
11
|
+
import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
|
|
11
12
|
import { makeRepoMapTool } from "./repo-map.js";
|
|
12
13
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
13
14
|
import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE, sharpImageDownsampler } from "../../core/mcp.js";
|
|
@@ -35,10 +36,13 @@ function decodeEditBytes(bytes, path) {
|
|
|
35
36
|
return { ok: false, message: `Error (Edit): "${path}" is too large to edit (${formatByteSize(bytes.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.` };
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
function persistedTextOf(encoded) {
|
|
40
|
+
return decodeTextBytes(typeof encoded === "string" ? Buffer.from(encoded, "utf8") : encoded).text;
|
|
41
|
+
}
|
|
38
42
|
async function notReadRefusalText(env, toolName, key, v, signal) {
|
|
39
43
|
const base = `Error (${toolName}): ${v.message}`;
|
|
40
44
|
const info = await env.fileInfo(key, signal);
|
|
41
|
-
return info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES ? `${base} ${OVERSIZE_READ_ESCAPE_HINT}` : base;
|
|
45
|
+
return info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key) ? `${base} ${OVERSIZE_READ_ESCAPE_HINT}` : base;
|
|
42
46
|
}
|
|
43
47
|
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
|
|
44
48
|
const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
|
|
@@ -107,7 +111,7 @@ export function isReadDedupStubResult(resultText) {
|
|
|
107
111
|
resultText.includes("has not changed on disk. Use that content instead of re-reading"));
|
|
108
112
|
}
|
|
109
113
|
export function seedReadFileStateFromContext(state, key, content) {
|
|
110
|
-
state.set(key, { hash: sha256(content), totalLines: countLines(content), truncated: false, lastReadAt: Date.now(), seededFromContext: true });
|
|
114
|
+
state.set(key, { hash: sha256(normalizeFileText(content)), totalLines: countLines(content), truncated: false, lastReadAt: Date.now(), seededFromContext: true });
|
|
111
115
|
}
|
|
112
116
|
export function applyCompactionToReadFileState(state, attachedComplete, preserveKeys = []) {
|
|
113
117
|
const preserve = new Set(preserveKeys);
|
|
@@ -371,6 +375,9 @@ async function readPdfFile(env, path, key, pages, signal, downsamplerOpt, cwd, p
|
|
|
371
375
|
},
|
|
372
376
|
};
|
|
373
377
|
}
|
|
378
|
+
function pdfResultToToolReturn(r) {
|
|
379
|
+
return typeof r === "string" ? errorResult(r) : r;
|
|
380
|
+
}
|
|
374
381
|
export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRoots, imageDownsampler, pdfCapabilities, bgOutputReadExemption) {
|
|
375
382
|
return defineTool({
|
|
376
383
|
name: "Read",
|
|
@@ -381,7 +388,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
381
388
|
"- By default, it reads the whole file (from `offset`, 1-based); a very large file is served as a partial view with an explicit marker and the next-page call.\n" +
|
|
382
389
|
"- When you already know which part of the file you need, only read that part. This can be important for larger files.\n" +
|
|
383
390
|
"- Results are returned using cat -n format, with line numbers starting at 1\n" +
|
|
384
|
-
"- Reads images (PNG, JPG, …) and presents them visually. Reads PDFs via the `pages` parameter (e.g. \"1-5\", max 20 pages/request; a PDF whose detected page count exceeds 10 requires `pages`). Reads Jupyter notebooks (.ipynb) as
|
|
391
|
+
"- Reads images (PNG, JPG, …) and presents them visually. Reads PDFs via the `pages` parameter (e.g. \"1-5\", max 20 pages/request; a PDF whose detected page count exceeds 10 requires `pages`). Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
|
|
385
392
|
"- Reading a directory, a missing file, or an empty file returns an error or warning rather than content.\n" +
|
|
386
393
|
"- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" +
|
|
387
394
|
"- You must read a file before editing it.\n" +
|
|
@@ -396,6 +403,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
396
403
|
"- This tool can only read files, not directories. To read a directory, use Glob/Grep or an ls command via the Bash tool.\n" +
|
|
397
404
|
"- You must read a file before editing it.\n" +
|
|
398
405
|
"- Reads PDFs (.pdf): the document is provided to the model directly (text layer included). Use the `pages` parameter (e.g., \"1-5\") to read a page range; a PDF whose detected page count exceeds 10 requires `pages`. Maximum 20 pages per request.\n" +
|
|
406
|
+
"- Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
|
|
399
407
|
"- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" +
|
|
400
408
|
"- If you read a file that exists but has empty contents you will receive a warning in place of file contents.\n" +
|
|
401
409
|
"- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
|
|
@@ -410,35 +418,36 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
410
418
|
const { offset, limit, pages } = args;
|
|
411
419
|
const path = fileArgPath(args);
|
|
412
420
|
if (path === undefined)
|
|
413
|
-
return `Error (Read): file_path is required
|
|
421
|
+
return errorResult(`Error (Read): file_path is required.`);
|
|
414
422
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx));
|
|
415
423
|
if (!r.ok)
|
|
416
|
-
return violationText("Read", r.violation);
|
|
424
|
+
return errorResult(violationText("Read", r.violation));
|
|
425
|
+
const isNb = isNotebookPath(r.key);
|
|
417
426
|
const imageMime = imageMimeForRead(r.key);
|
|
418
427
|
if (imageMime !== undefined) {
|
|
419
428
|
const meta = await env.fileInfo(r.key, ctx.signal);
|
|
420
429
|
if (!meta.ok) {
|
|
421
430
|
const ex = await env.exists(r.key, ctx.signal);
|
|
422
431
|
if (ex.ok && !ex.value)
|
|
423
|
-
return `Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}
|
|
424
|
-
return `Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}
|
|
432
|
+
return errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
|
|
433
|
+
return errorResult(`Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}`);
|
|
425
434
|
}
|
|
426
435
|
if (meta.value.kind === "directory") {
|
|
427
|
-
return `Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell
|
|
436
|
+
return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
|
|
428
437
|
}
|
|
429
438
|
const downsampler = imageDownsampler === false ? undefined : (imageDownsampler ?? (await (autoDownsampler ??= sharpImageDownsampler())));
|
|
430
439
|
const readCap = downsampler ? MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES : MAX_IMAGE_READ_BYTES;
|
|
431
440
|
if (meta.value.size > readCap) {
|
|
432
|
-
return `Error (Read): image "${path}" is too large to read (${meta.value.size} bytes > ${readCap}-byte cap)
|
|
441
|
+
return errorResult(`Error (Read): image "${path}" is too large to read (${meta.value.size} bytes > ${readCap}-byte cap).`);
|
|
433
442
|
}
|
|
434
443
|
const bin = await env.readBinaryFile(r.key, ctx.signal);
|
|
435
444
|
if (!bin.ok)
|
|
436
|
-
return `Error (Read): cannot read image "${path}": ${bin.error.message}
|
|
445
|
+
return errorResult(`Error (Read): cannot read image "${path}": ${bin.error.message}`);
|
|
437
446
|
if (bin.value.byteLength > readCap) {
|
|
438
|
-
return `Error (Read): image "${path}" is too large to read (${bin.value.byteLength} bytes > ${readCap}-byte cap)
|
|
447
|
+
return errorResult(`Error (Read): image "${path}" is too large to read (${bin.value.byteLength} bytes > ${readCap}-byte cap).`);
|
|
439
448
|
}
|
|
440
449
|
if (!imageMagicMatches(bin.value, imageMime)) {
|
|
441
|
-
return `Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named)
|
|
450
|
+
return errorResult(`Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`);
|
|
442
451
|
}
|
|
443
452
|
let base64;
|
|
444
453
|
let mime = imageMime;
|
|
@@ -453,7 +462,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
453
462
|
}
|
|
454
463
|
base64 ??= Buffer.from(bin.value).toString("base64");
|
|
455
464
|
if (base64.length > MCP_IMAGE_MAX_BASE64) {
|
|
456
|
-
return (`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` +
|
|
465
|
+
return errorResult(`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` +
|
|
457
466
|
(downsampler
|
|
458
467
|
? "; downsampling could not bring it under the limit)."
|
|
459
468
|
: `; raw images over ${IMAGE_TARGET_RAW_SIZE} bytes always exceed it — shrink it first, e.g. with an image tool via bash).`));
|
|
@@ -476,58 +485,62 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
476
485
|
};
|
|
477
486
|
}
|
|
478
487
|
if (r.key.toLowerCase().endsWith(".pdf")) {
|
|
479
|
-
return await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities);
|
|
488
|
+
return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities));
|
|
480
489
|
}
|
|
481
490
|
if (hasBinaryExtension(r.key)) {
|
|
482
|
-
return `Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only
|
|
491
|
+
return errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
|
|
483
492
|
}
|
|
484
493
|
const info = await env.fileInfo(r.key, ctx.signal);
|
|
485
494
|
if (info.ok) {
|
|
486
495
|
if (info.value.kind === "directory") {
|
|
487
|
-
return `Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell
|
|
496
|
+
return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
|
|
488
497
|
}
|
|
489
498
|
if (info.value.size > SLICED_READ_MAX_BYTES) {
|
|
490
|
-
return (`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
|
|
499
|
+
return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
|
|
491
500
|
`(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
|
|
492
501
|
`Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
|
|
493
502
|
}
|
|
494
|
-
if (info.value.size > MAX_READ_BYTES && offset === undefined && limit === undefined) {
|
|
495
|
-
return `Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead
|
|
503
|
+
if (!isNb && info.value.size > MAX_READ_BYTES && offset === undefined && limit === undefined) {
|
|
504
|
+
return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
|
|
496
505
|
}
|
|
497
506
|
}
|
|
498
507
|
else {
|
|
499
508
|
const ex = await env.exists(r.key, ctx.signal);
|
|
500
509
|
if (ex.ok && !ex.value)
|
|
501
|
-
return `Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}
|
|
510
|
+
return errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
|
|
502
511
|
}
|
|
503
512
|
const readBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
504
513
|
if (!readBin.ok)
|
|
505
|
-
return `Error (Read): cannot read "${path}": ${readBin.error.message}
|
|
514
|
+
return errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`);
|
|
506
515
|
const readSize = readBin.value.byteLength;
|
|
507
516
|
if (readSize > SLICED_READ_MAX_BYTES) {
|
|
508
|
-
return (`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
|
|
517
|
+
return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
|
|
509
518
|
`(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
|
|
510
519
|
`Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
|
|
511
520
|
}
|
|
512
|
-
if (readSize > MAX_READ_BYTES && offset === undefined && limit === undefined) {
|
|
513
|
-
return `Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead
|
|
521
|
+
if (!isNb && readSize > MAX_READ_BYTES && offset === undefined && limit === undefined) {
|
|
522
|
+
return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
|
|
514
523
|
}
|
|
515
524
|
if (pdfMagicMatches(readBin.value)) {
|
|
516
|
-
return await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities);
|
|
525
|
+
return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities));
|
|
517
526
|
}
|
|
518
527
|
const decoded = decodeTextBytes(readBin.value);
|
|
519
528
|
if (decoded.malformed) {
|
|
520
|
-
return `Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first
|
|
529
|
+
return errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`);
|
|
521
530
|
}
|
|
522
531
|
const content = decoded.text;
|
|
523
532
|
if (isBinaryContent(content.slice(0, 4096))) {
|
|
524
|
-
return (`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
|
|
533
|
+
return errorResult(`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
|
|
525
534
|
`If it is UTF-16 without a BOM or a legacy encoding, convert it first (e.g. \`iconv -f UTF-16LE -t UTF-8\`) or inspect/transform it with bash.`);
|
|
526
535
|
}
|
|
536
|
+
const nbParsed = isNb ? parseNotebookCells(content, path) : undefined;
|
|
537
|
+
const nbForced = nbParsed !== undefined && nbParsed.ok;
|
|
538
|
+
const effOffset = nbForced ? undefined : offset;
|
|
539
|
+
const effLimit = nbForced ? undefined : limit;
|
|
527
540
|
const lines = content.length === 0 ? [] : content.split("\n");
|
|
528
541
|
const total = lines.length;
|
|
529
|
-
const start = Math.max(1, Math.floor(
|
|
530
|
-
const max =
|
|
542
|
+
const start = Math.max(1, Math.floor(effOffset ?? 1));
|
|
543
|
+
const max = effLimit !== undefined ? Math.max(1, Math.floor(effLimit)) : Number.MAX_SAFE_INTEGER;
|
|
531
544
|
if (total > 0 && start > total) {
|
|
532
545
|
return `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has ${total} lines.</system-reminder>`;
|
|
533
546
|
}
|
|
@@ -538,13 +551,41 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
538
551
|
end = total;
|
|
539
552
|
}
|
|
540
553
|
const hash = sha256(content);
|
|
554
|
+
let nbFallbackPrefix = "";
|
|
555
|
+
if (nbParsed !== undefined) {
|
|
556
|
+
if (nbParsed.ok) {
|
|
557
|
+
const parsed = nbParsed;
|
|
558
|
+
const rendered = renderNotebookCells(parsed.cells, NOTEBOOK_IMAGE_BASE64_BUDGET);
|
|
559
|
+
if (rendered.textChars > MAX_READ_OUTPUT_CHARS) {
|
|
560
|
+
return errorResult(`Error (Read): notebook "${path}" projects to ~${Math.round(rendered.textChars / 4)} tokens of cell content, over the 25000-token limit ` +
|
|
561
|
+
`(offset/limit do not apply to notebooks — the whole notebook is always projected). Inspect portions with Bash + jq instead, e.g.\n` +
|
|
562
|
+
` cat "${path}" | jq '.cells[:20]' # First 20 cells\n` +
|
|
563
|
+
` cat "${path}" | jq '.cells | length' # Count total cells\n` +
|
|
564
|
+
` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`);
|
|
565
|
+
}
|
|
566
|
+
const prevNb = state.get(r.key);
|
|
567
|
+
if (prevNb?.seededFromContext && prevNb.hash === hash) {
|
|
568
|
+
return seededFileUnchangedReminder(r.key);
|
|
569
|
+
}
|
|
570
|
+
if (prevNb && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
|
|
571
|
+
return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`;
|
|
572
|
+
}
|
|
573
|
+
state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
|
|
574
|
+
const bodyBlocks = rendered.blocks.length > 0 ? rendered.blocks : [{ type: "text", text: "[notebook has 0 cells]" }];
|
|
575
|
+
return {
|
|
576
|
+
content: [...bodyBlocks, { type: "text", text: READ_CYBER_REMINDER }],
|
|
577
|
+
details: { type: "notebook", file: { filePath: path, cells: parsed.cells.map(stripNotebookImageData) } },
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
nbFallbackPrefix = "[not a parseable notebook — showing raw file text]\n";
|
|
581
|
+
}
|
|
541
582
|
const renderBody = (sl) => sl.map((l, i) => `${start + i}\t${l}`).join("\n");
|
|
542
583
|
let body = renderBody(slice);
|
|
543
584
|
let pageMarker;
|
|
544
585
|
if (body.length > MAX_READ_OUTPUT_CHARS) {
|
|
545
|
-
const defaultFullRead = start <= 1 &&
|
|
586
|
+
const defaultFullRead = start <= 1 && effLimit === undefined;
|
|
546
587
|
if (!defaultFullRead) {
|
|
547
|
-
return `Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file
|
|
588
|
+
return errorResult(`Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file.`);
|
|
548
589
|
}
|
|
549
590
|
const approxTokens = Math.round(body.length / 4);
|
|
550
591
|
let n = Math.max(1, Math.min(slice.length, Math.floor(slice.length * (MAX_READ_OUTPUT_CHARS / body.length) * 0.95)));
|
|
@@ -580,7 +621,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
580
621
|
}
|
|
581
622
|
const truncated = start > 1 || end < total || pageMarker !== undefined;
|
|
582
623
|
const prev = state.get(r.key);
|
|
583
|
-
if (total > 0 && prev?.seededFromContext && start === 1 &&
|
|
624
|
+
if (total > 0 && prev?.seededFromContext && start === 1 && effLimit === undefined && prev.hash === hash) {
|
|
584
625
|
return seededFileUnchangedReminder(r.key);
|
|
585
626
|
}
|
|
586
627
|
if (total > 0 && prev && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
|
|
@@ -598,7 +639,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
598
639
|
return `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has 1 lines.</system-reminder>`;
|
|
599
640
|
const header = pageMarker ?? (truncated ? `[${path}: lines ${start}-${end} of ${total}${end < total ? " — use offset to see more" : ""}]\n` : "");
|
|
600
641
|
return {
|
|
601
|
-
content: `${header}${body}${READ_CYBER_REMINDER}`,
|
|
642
|
+
content: `${nbFallbackPrefix}${header}${body}${READ_CYBER_REMINDER}`,
|
|
602
643
|
details: {
|
|
603
644
|
type: "text",
|
|
604
645
|
file: {
|
|
@@ -653,107 +694,115 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
653
694
|
? a.edits
|
|
654
695
|
: [{ old_string: a.old_string ?? "", new_string: a.new_string ?? "", ...(a.replace_all !== undefined ? { replace_all: a.replace_all } : {}) }];
|
|
655
696
|
if (!batch && (typeof a.old_string !== "string" || typeof a.new_string !== "string")) {
|
|
656
|
-
return `Error (Edit): old_string and new_string are required
|
|
697
|
+
return errorResult(`Error (Edit): old_string and new_string are required.`);
|
|
657
698
|
}
|
|
658
699
|
for (let i = 0; i < edits.length; i++) {
|
|
659
700
|
const e = edits[i];
|
|
660
701
|
if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
|
|
661
|
-
return `Error (Edit): edit ${i + 1} of ${edits.length}: old_string and new_string must both be strings (no changes written — the batch is atomic)
|
|
702
|
+
return errorResult(`Error (Edit): edit ${i + 1} of ${edits.length}: old_string and new_string must both be strings (no changes written — the batch is atomic).`);
|
|
662
703
|
}
|
|
663
704
|
}
|
|
664
705
|
const path = fileArgPath(args);
|
|
665
706
|
if (path === undefined)
|
|
666
|
-
return `Error (Edit): file_path is required
|
|
707
|
+
return errorResult(`Error (Edit): file_path is required.`);
|
|
667
708
|
const ipynb = ipynbRedirect("Edit", path);
|
|
668
709
|
if (ipynb)
|
|
669
|
-
return ipynb;
|
|
710
|
+
return errorResult(ipynb);
|
|
670
711
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
671
712
|
if (!r.ok)
|
|
672
|
-
return violationText("Edit", r.violation);
|
|
713
|
+
return errorResult(violationText("Edit", r.violation));
|
|
673
714
|
const singleOld = batch ? undefined : a.old_string;
|
|
674
715
|
const exists = await env.exists(r.key, ctx.signal);
|
|
675
716
|
if (!exists.ok)
|
|
676
|
-
return `Error (Edit): cannot stat "${path}": ${exists.error.message}
|
|
717
|
+
return errorResult(`Error (Edit): cannot stat "${path}": ${exists.error.message}`);
|
|
677
718
|
if (!exists.value) {
|
|
678
719
|
if (singleOld === "") {
|
|
679
720
|
const created = a.new_string ?? "";
|
|
680
721
|
if (created === "")
|
|
681
|
-
return violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." });
|
|
722
|
+
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
|
|
682
723
|
const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, created);
|
|
683
724
|
if (gated !== undefined)
|
|
684
|
-
return gated;
|
|
725
|
+
return errorResult(gated);
|
|
685
726
|
const write = env.writeFileExclusive !== undefined
|
|
686
727
|
? await env.writeFileExclusive(r.key, created, ctx.signal)
|
|
687
728
|
: await env.writeFile(r.key, created, ctx.signal);
|
|
688
729
|
if (!write.ok) {
|
|
689
730
|
if (write.error.code === "already_exists") {
|
|
690
|
-
return `Error (Edit): cannot create "${path}": file already exists (created concurrently since the existence check). Read the file first, then edit it normally (or Write after the Read to overwrite it)
|
|
731
|
+
return errorResult(`Error (Edit): cannot create "${path}": file already exists (created concurrently since the existence check). Read the file first, then edit it normally (or Write after the Read to overwrite it).`);
|
|
691
732
|
}
|
|
692
|
-
return `Error (Edit): cannot write "${path}": ${write.error.message}
|
|
733
|
+
return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
|
|
693
734
|
}
|
|
694
|
-
const createdNorm =
|
|
735
|
+
const createdNorm = normalizeFileText(created);
|
|
695
736
|
state.set(r.key, { hash: sha256(createdNorm), totalLines: countLines(createdNorm), truncated: false, lastReadAt: Date.now() });
|
|
696
737
|
return {
|
|
697
738
|
content: `Created ${path} (${countLines(createdNorm)} lines).`,
|
|
698
739
|
details: { type: "create", filePath: path, content: created },
|
|
699
740
|
};
|
|
700
741
|
}
|
|
701
|
-
return `Error (Edit): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}
|
|
742
|
+
return errorResult(`Error (Edit): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
|
|
702
743
|
}
|
|
703
744
|
const editInfo = await env.fileInfo(r.key, ctx.signal);
|
|
704
745
|
if (editInfo.ok && editInfo.value.size > MAX_EDIT_BYTES) {
|
|
705
|
-
return `Error (Edit): "${path}" is too large to edit (${formatByteSize(editInfo.value.size)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}
|
|
746
|
+
return errorResult(`Error (Edit): "${path}" is too large to edit (${formatByteSize(editInfo.value.size)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`);
|
|
706
747
|
}
|
|
707
748
|
if (singleOld === "") {
|
|
708
749
|
const preBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
709
750
|
if (!preBin.ok)
|
|
710
|
-
return `Error (Edit): cannot read "${path}": ${preBin.error.message}
|
|
751
|
+
return errorResult(`Error (Edit): cannot read "${path}": ${preBin.error.message}`);
|
|
711
752
|
if (preBin.value.byteLength > MAX_EDIT_BYTES) {
|
|
712
|
-
return `Error (Edit): "${path}" is too large to edit (${formatByteSize(preBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}
|
|
753
|
+
return errorResult(`Error (Edit): "${path}" is too large to edit (${formatByteSize(preBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`);
|
|
713
754
|
}
|
|
714
755
|
const preDecResult = decodeEditBytes(preBin.value, path);
|
|
715
756
|
if (!preDecResult.ok)
|
|
716
|
-
return preDecResult.message;
|
|
757
|
+
return errorResult(preDecResult.message);
|
|
717
758
|
const preDec = preDecResult.value;
|
|
718
759
|
if (preDec.malformed)
|
|
719
|
-
return `Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first
|
|
760
|
+
return errorResult(`Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
|
|
720
761
|
if (preDec.text.trim() !== "")
|
|
721
|
-
return `Error (Edit): Cannot create new file - file already exists
|
|
762
|
+
return errorResult(`Error (Edit): Cannot create new file - file already exists.`);
|
|
763
|
+
const priorRead = state.get(r.key);
|
|
764
|
+
if (priorRead !== undefined) {
|
|
765
|
+
const staleCreate = checkStale(priorRead, sha256(preDec.text));
|
|
766
|
+
if (staleCreate)
|
|
767
|
+
return errorResult(violationText("Edit", staleCreate));
|
|
768
|
+
}
|
|
722
769
|
const newContent = normalizeEditText(a.new_string ?? "");
|
|
723
770
|
if (newContent === preDec.text)
|
|
724
|
-
return violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." });
|
|
771
|
+
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
|
|
725
772
|
const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, newContent);
|
|
726
773
|
if (gated !== undefined)
|
|
727
|
-
return gated;
|
|
728
|
-
const
|
|
774
|
+
return errorResult(gated);
|
|
775
|
+
const encodedOverwrite = encodeTextForFile(newContent, preDec.encoding, preDec.endings);
|
|
776
|
+
const write = await env.writeFile(r.key, encodedOverwrite, ctx.signal);
|
|
729
777
|
if (!write.ok)
|
|
730
|
-
return `Error (Edit): cannot write "${path}": ${write.error.message}
|
|
731
|
-
|
|
778
|
+
return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
|
|
779
|
+
const persistedOverwrite = persistedTextOf(encodedOverwrite);
|
|
780
|
+
state.set(r.key, { hash: sha256(persistedOverwrite), totalLines: countLines(persistedOverwrite), truncated: false, lastReadAt: Date.now() });
|
|
732
781
|
return {
|
|
733
|
-
content: `
|
|
782
|
+
content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
|
|
734
783
|
details: { type: "edit", filePath: path, originalFile: preDec.text, oldString: "", newString: newContent, replaceAll: false, edits: [{ oldString: "", newString: newContent, replaceAll: false }] },
|
|
735
784
|
};
|
|
736
785
|
}
|
|
737
786
|
const notRead = requireRead(state, r.key);
|
|
738
787
|
if (notRead)
|
|
739
|
-
return await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal);
|
|
788
|
+
return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal));
|
|
740
789
|
const readBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
741
790
|
if (!readBin.ok)
|
|
742
|
-
return `Error (Edit): cannot read "${path}": ${readBin.error.message}
|
|
791
|
+
return errorResult(`Error (Edit): cannot read "${path}": ${readBin.error.message}`);
|
|
743
792
|
if (readBin.value.byteLength > MAX_EDIT_BYTES) {
|
|
744
|
-
return `Error (Edit): "${path}" is too large to edit (${formatByteSize(readBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}
|
|
793
|
+
return errorResult(`Error (Edit): "${path}" is too large to edit (${formatByteSize(readBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`);
|
|
745
794
|
}
|
|
746
795
|
const decodedResult = decodeEditBytes(readBin.value, path);
|
|
747
796
|
if (!decodedResult.ok)
|
|
748
|
-
return decodedResult.message;
|
|
797
|
+
return errorResult(decodedResult.message);
|
|
749
798
|
const decoded = decodedResult.value;
|
|
750
799
|
if (decoded.malformed)
|
|
751
|
-
return `Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first
|
|
800
|
+
return errorResult(`Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
|
|
752
801
|
const original = decoded.text;
|
|
753
802
|
const entry = state.get(r.key);
|
|
754
803
|
const stale = checkStale(entry, sha256(original));
|
|
755
804
|
if (stale)
|
|
756
|
-
return violationText("Edit", stale);
|
|
805
|
+
return errorResult(violationText("Edit", stale));
|
|
757
806
|
let working = original;
|
|
758
807
|
let replacements = 0;
|
|
759
808
|
const appliedEdits = [];
|
|
@@ -764,7 +813,7 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
764
813
|
let newS = normalizeEditText(e.new_string);
|
|
765
814
|
const noChange = checkNoChange(oldS, newS);
|
|
766
815
|
if (noChange)
|
|
767
|
-
return batch ? `Error (Edit): ${where}${noChange.message}` : violationText("Edit", noChange);
|
|
816
|
+
return errorResult(batch ? `Error (Edit): ${where}${noChange.message}` : violationText("Edit", noChange));
|
|
768
817
|
if (oldS !== "" && !working.includes(oldS)) {
|
|
769
818
|
const resolved = resolveQuoteMatch(working, oldS);
|
|
770
819
|
if (resolved !== undefined && resolved !== oldS) {
|
|
@@ -774,7 +823,7 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
774
823
|
}
|
|
775
824
|
const match = checkEditMatch(working, oldS, e.replace_all === true, entry.truncated);
|
|
776
825
|
if (match)
|
|
777
|
-
return batch ? `Error (Edit): ${where}${match.message} (no changes written — the batch is atomic).` : violationText("Edit", match);
|
|
826
|
+
return errorResult(batch ? `Error (Edit): ${where}${match.message} (no changes written — the batch is atomic).` : violationText("Edit", match));
|
|
778
827
|
replacements += e.replace_all === true ? countOccurrences(working, oldS) : 1;
|
|
779
828
|
const effOld = e.replace_all === true ? oldS : deletionOldString(working, oldS, newS);
|
|
780
829
|
working = e.replace_all === true ? working.split(effOld).join(newS) : working.replace(effOld, () => newS);
|
|
@@ -782,11 +831,13 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
|
|
|
782
831
|
}
|
|
783
832
|
const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, working);
|
|
784
833
|
if (gated !== undefined)
|
|
785
|
-
return gated;
|
|
786
|
-
const
|
|
834
|
+
return errorResult(gated);
|
|
835
|
+
const encodedEdit = encodeTextForFile(working, decoded.encoding, decoded.endings);
|
|
836
|
+
const write = await env.writeFile(r.key, encodedEdit, ctx.signal);
|
|
787
837
|
if (!write.ok)
|
|
788
|
-
return `Error (Edit): cannot write "${path}": ${write.error.message}
|
|
789
|
-
|
|
838
|
+
return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
|
|
839
|
+
const persistedEdit = persistedTextOf(encodedEdit);
|
|
840
|
+
state.set(r.key, { hash: sha256(persistedEdit), totalLines: countLines(persistedEdit), truncated: false, lastReadAt: Date.now() });
|
|
790
841
|
const a0 = appliedEdits[0];
|
|
791
842
|
return {
|
|
792
843
|
content: batch
|
|
@@ -823,54 +874,56 @@ export function makeWriteFileTool(env, state, rootCanonical, cwdRef, additionalR
|
|
|
823
874
|
const { content } = args;
|
|
824
875
|
const path = fileArgPath(args);
|
|
825
876
|
if (path === undefined)
|
|
826
|
-
return `Error (Write): file_path is required
|
|
877
|
+
return errorResult(`Error (Write): file_path is required.`);
|
|
827
878
|
const ipynb = ipynbRedirect("Write", path);
|
|
828
879
|
if (ipynb)
|
|
829
|
-
return ipynb;
|
|
880
|
+
return errorResult(ipynb);
|
|
830
881
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
831
882
|
if (!r.ok)
|
|
832
|
-
return violationText("Write", r.violation);
|
|
883
|
+
return errorResult(violationText("Write", r.violation));
|
|
833
884
|
const exists = await env.exists(r.key, ctx.signal);
|
|
834
885
|
if (!exists.ok)
|
|
835
|
-
return `Error (Write): cannot stat "${path}": ${exists.error.message}
|
|
886
|
+
return errorResult(`Error (Write): cannot stat "${path}": ${exists.error.message}`);
|
|
836
887
|
let originalFile;
|
|
837
888
|
if (exists.value) {
|
|
838
889
|
const notRead = requireRead(state, r.key);
|
|
839
890
|
if (notRead) {
|
|
840
|
-
return (`${violationText("Write", notRead)} ` +
|
|
891
|
+
return errorResult(`${violationText("Write", notRead)} ` +
|
|
841
892
|
`(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. \`rm\` + rewrite, or \`iconv\`.)`);
|
|
842
893
|
}
|
|
843
894
|
const readBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
844
895
|
if (!readBin.ok)
|
|
845
|
-
return `Error (Write): cannot re-read "${path}" to verify it is unchanged: ${readBin.error.message}
|
|
896
|
+
return errorResult(`Error (Write): cannot re-read "${path}" to verify it is unchanged: ${readBin.error.message}`);
|
|
846
897
|
const decodedPrev = decodeTextBytes(readBin.value);
|
|
847
898
|
if (decodedPrev.malformed)
|
|
848
|
-
return `Error (Write): "${path}" has a truncated UTF-16 body; repair/convert it with bash first
|
|
899
|
+
return errorResult(`Error (Write): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
|
|
849
900
|
const stale = checkStale(state.get(r.key), sha256(decodedPrev.text));
|
|
850
901
|
if (stale)
|
|
851
|
-
return violationText("Write", stale);
|
|
902
|
+
return errorResult(violationText("Write", stale));
|
|
852
903
|
originalFile = decodedPrev.text;
|
|
853
904
|
const gated = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
|
|
854
905
|
if (gated !== undefined)
|
|
855
|
-
return gated;
|
|
856
|
-
const
|
|
906
|
+
return errorResult(gated);
|
|
907
|
+
const outgoing = splitLeadingBom(content);
|
|
908
|
+
const writeEncoding = outgoing.hadBom && !decodedPrev.encoding.hadBom ? { ...decodedPrev.encoding, hadBom: true } : decodedPrev.encoding;
|
|
909
|
+
const write = await env.writeFile(r.key, encodeTextForFile(outgoing.text, writeEncoding, "preserve"), ctx.signal);
|
|
857
910
|
if (!write.ok)
|
|
858
|
-
return `Error (Write): cannot write "${path}": ${write.error.message}
|
|
859
|
-
const totalLines = countLines(
|
|
860
|
-
state.set(r.key, { hash: sha256(
|
|
911
|
+
return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
|
|
912
|
+
const totalLines = countLines(outgoing.text);
|
|
913
|
+
state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
|
|
861
914
|
return {
|
|
862
915
|
content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
|
|
863
|
-
details: { type: "update", filePath: path, content:
|
|
916
|
+
details: { type: "update", filePath: path, content: normalizeFileText(content), originalFile },
|
|
864
917
|
};
|
|
865
918
|
}
|
|
866
919
|
const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
|
|
867
920
|
if (gatedCreate !== undefined)
|
|
868
|
-
return gatedCreate;
|
|
921
|
+
return errorResult(gatedCreate);
|
|
869
922
|
const write = await env.writeFile(r.key, content, ctx.signal);
|
|
870
923
|
if (!write.ok)
|
|
871
|
-
return `Error (Write): cannot write "${path}": ${write.error.message}
|
|
924
|
+
return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
|
|
872
925
|
const totalLines = countLines(content);
|
|
873
|
-
state.set(r.key, { hash: sha256(
|
|
926
|
+
state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
|
|
874
927
|
void originalFile;
|
|
875
928
|
return {
|
|
876
929
|
content: `File created successfully at: ${path}${FILE_STATE_TRAILER}`,
|
|
@@ -887,7 +940,7 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
|
|
|
887
940
|
"\n" +
|
|
888
941
|
"Usage:\n" +
|
|
889
942
|
"- You must use the Read tool on the notebook in this conversation before editing — this tool will fail otherwise.\n" +
|
|
890
|
-
"- `cell_id` is the
|
|
943
|
+
"- `cell_id` is the `id` attribute shown in the Read tool's `<cell id=\"...\">` output. It is required for `replace` and `delete`.\n" +
|
|
891
944
|
"- `edit_mode` defaults to `replace`. Use `insert` to add a new cell after the cell with the given `cell_id` " +
|
|
892
945
|
"(or at the beginning of the notebook if `cell_id` is omitted) — `cell_type` is required when inserting. " +
|
|
893
946
|
"Use `delete` to remove the cell.",
|
|
@@ -911,37 +964,37 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
|
|
|
911
964
|
let cellType = a.cell_type;
|
|
912
965
|
const mode = a.edit_mode ?? "replace";
|
|
913
966
|
if (!notebook_path.toLowerCase().endsWith(".ipynb")) {
|
|
914
|
-
return `Error (NotebookEdit): "${notebook_path}" is not a .ipynb file; use Edit for other file types
|
|
967
|
+
return errorResult(`Error (NotebookEdit): "${notebook_path}" is not a .ipynb file; use Edit for other file types.`);
|
|
915
968
|
}
|
|
916
969
|
if (mode === "insert" && !cellType)
|
|
917
|
-
return `Error (NotebookEdit): Cell type is required when using edit_mode=insert
|
|
970
|
+
return errorResult(`Error (NotebookEdit): Cell type is required when using edit_mode=insert.`);
|
|
918
971
|
if (mode !== "insert" && !cell_id)
|
|
919
|
-
return `Error (NotebookEdit): cell_id is required for replace/delete
|
|
972
|
+
return errorResult(`Error (NotebookEdit): cell_id is required for replace/delete.`);
|
|
920
973
|
const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
921
974
|
if (!r.ok)
|
|
922
|
-
return violationText("NotebookEdit", r.violation);
|
|
975
|
+
return errorResult(violationText("NotebookEdit", r.violation));
|
|
923
976
|
const notRead = requireRead(state, r.key);
|
|
924
977
|
if (notRead)
|
|
925
|
-
return await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal);
|
|
978
|
+
return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
|
|
926
979
|
const readBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
927
980
|
if (!readBin.ok)
|
|
928
|
-
return `Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}
|
|
981
|
+
return errorResult(`Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}`);
|
|
929
982
|
const decodedNb = decodeTextBytes(readBin.value);
|
|
930
983
|
if (decodedNb.malformed)
|
|
931
|
-
return `Error (NotebookEdit): "${notebook_path}" has a truncated UTF-16 body; repair/convert it with bash first
|
|
984
|
+
return errorResult(`Error (NotebookEdit): "${notebook_path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
|
|
932
985
|
const nbText = decodedNb.text;
|
|
933
986
|
const stale = checkStale(state.get(r.key), sha256(nbText));
|
|
934
987
|
if (stale)
|
|
935
|
-
return violationText("NotebookEdit", stale);
|
|
988
|
+
return errorResult(violationText("NotebookEdit", stale));
|
|
936
989
|
let nb;
|
|
937
990
|
try {
|
|
938
991
|
nb = JSON.parse(nbText);
|
|
939
992
|
}
|
|
940
993
|
catch {
|
|
941
|
-
return `Error (NotebookEdit): "${notebook_path}" is not valid JSON
|
|
994
|
+
return errorResult(`Error (NotebookEdit): "${notebook_path}" is not valid JSON.`);
|
|
942
995
|
}
|
|
943
996
|
if (!nb || typeof nb !== "object" || !Array.isArray(nb.cells)) {
|
|
944
|
-
return `Error (NotebookEdit): "${notebook_path}" is not a valid notebook (no cells array)
|
|
997
|
+
return errorResult(`Error (NotebookEdit): "${notebook_path}" is not a valid notebook (no cells array).`);
|
|
945
998
|
}
|
|
946
999
|
const cells = nb.cells;
|
|
947
1000
|
let h;
|
|
@@ -959,7 +1012,7 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
|
|
|
959
1012
|
}
|
|
960
1013
|
}
|
|
961
1014
|
if (h === -1)
|
|
962
|
-
return `Error (NotebookEdit): Cell with ID "${cell_id}" not found in notebook
|
|
1015
|
+
return errorResult(`Error (NotebookEdit): Cell with ID "${cell_id}" not found in notebook.`);
|
|
963
1016
|
if (mode === "insert")
|
|
964
1017
|
h += 1;
|
|
965
1018
|
}
|
|
@@ -996,10 +1049,10 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
|
|
|
996
1049
|
const updated = JSON.stringify(nb, null, 1);
|
|
997
1050
|
const gated = await gateToolWrite(beforeWrite, "NotebookEdit", notebook_path, r.key, updated);
|
|
998
1051
|
if (gated !== undefined)
|
|
999
|
-
return gated;
|
|
1052
|
+
return errorResult(gated);
|
|
1000
1053
|
const w = await env.writeFile(r.key, encodeTextForFile(updated, decodedNb.encoding, decodedNb.endings), ctx.signal);
|
|
1001
1054
|
if (!w.ok)
|
|
1002
|
-
return `Error (NotebookEdit): cannot write "${notebook_path}": ${w.error.message}
|
|
1055
|
+
return errorResult(`Error (NotebookEdit): cannot write "${notebook_path}": ${w.error.message}`);
|
|
1003
1056
|
state.set(r.key, { hash: sha256(updated), totalLines: countLines(updated), truncated: false, lastReadAt: Date.now() });
|
|
1004
1057
|
const nbDetails = {
|
|
1005
1058
|
type: "notebook-edit",
|
|
@@ -1062,7 +1115,7 @@ export function makeGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
1062
1115
|
if (a.path !== undefined) {
|
|
1063
1116
|
const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
|
|
1064
1117
|
if (!r.ok)
|
|
1065
|
-
return violationText("Grep", r.violation);
|
|
1118
|
+
return errorResult(violationText("Grep", r.violation));
|
|
1066
1119
|
scoped = r.key;
|
|
1067
1120
|
}
|
|
1068
1121
|
const grepRun = await runGrepDetailed(env, rootCanonical, {
|
|
@@ -1077,7 +1130,7 @@ export function makeGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
1077
1130
|
}, ctx.signal);
|
|
1078
1131
|
const text = grepRun.text;
|
|
1079
1132
|
if (text.startsWith("Error (grep)") || text.startsWith("Error (Grep)"))
|
|
1080
|
-
return text;
|
|
1133
|
+
return errorResult(text);
|
|
1081
1134
|
const mode = a.output_mode ?? "files_with_matches";
|
|
1082
1135
|
const rows = text.startsWith("No matches.")
|
|
1083
1136
|
? []
|
|
@@ -1136,12 +1189,12 @@ export function makeGlobTool(env, rootCanonical, additionalRoots) {
|
|
|
1136
1189
|
description: 'Fast file pattern matching. Supports glob patterns like "**/*.js" or "src/**/*.ts". Returns matching file paths sorted by modification time.\n' +
|
|
1137
1190
|
"\n" +
|
|
1138
1191
|
"- On environments that do not report modification times, results fall back to alphabetical order\n" +
|
|
1139
|
-
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped",
|
|
1192
|
+
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)",
|
|
1140
1193
|
descriptionClassic: "- Fast file pattern matching tool that works with any codebase size\n" +
|
|
1141
1194
|
'- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n' +
|
|
1142
1195
|
"- Returns matching file paths sorted by modification time\n" +
|
|
1143
1196
|
"- On environments that do not report modification times, results fall back to alphabetical order\n" +
|
|
1144
|
-
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped\n" +
|
|
1197
|
+
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)\n" +
|
|
1145
1198
|
"- Use this tool when you need to find files by name patterns; use `path` to scope to a sub-directory\n" +
|
|
1146
1199
|
"- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead",
|
|
1147
1200
|
parameters: Type.Object({
|
|
@@ -1164,7 +1217,7 @@ export function makeGlobTool(env, rootCanonical, additionalRoots) {
|
|
|
1164
1217
|
if (path !== undefined) {
|
|
1165
1218
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, rootCanonical, additionalRoots);
|
|
1166
1219
|
if (!r.ok)
|
|
1167
|
-
return violationText("Glob", r.violation);
|
|
1220
|
+
return errorResult(violationText("Glob", r.violation));
|
|
1168
1221
|
scoped = r.key;
|
|
1169
1222
|
}
|
|
1170
1223
|
const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
|
|
@@ -1560,7 +1613,11 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
1560
1613
|
throw new Error(diagnosis ?? `Error (${toolName}): command did not run (${res.error.code}): ${res.error.message}`);
|
|
1561
1614
|
}
|
|
1562
1615
|
if (res.error.code === "transport_lost") {
|
|
1563
|
-
return
|
|
1616
|
+
return {
|
|
1617
|
+
content: `Error (${toolName}): connection to the execution environment was lost — the command's outcome is unknown and it may still be running. Verify its effects before retrying; retry only if the command is idempotent. (${res.error.message})`,
|
|
1618
|
+
details: { type: "bash", transportLost: true },
|
|
1619
|
+
isError: true,
|
|
1620
|
+
};
|
|
1564
1621
|
}
|
|
1565
1622
|
if (res.error.code === "timeout" || res.error.code === "aborted" || res.error.code === "callback_error") {
|
|
1566
1623
|
const rawStdout = res.error.partialStdout ?? "";
|
|
@@ -1605,7 +1662,11 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
|
|
|
1605
1662
|
},
|
|
1606
1663
|
};
|
|
1607
1664
|
}
|
|
1608
|
-
return
|
|
1665
|
+
return {
|
|
1666
|
+
content: `Error (${toolName}): command execution failed (${res.error.code}): ${res.error.message}. Whether the command started or completed could not be determined — verify its effects before retrying.`,
|
|
1667
|
+
details: { type: "bash" },
|
|
1668
|
+
isError: true,
|
|
1669
|
+
};
|
|
1609
1670
|
}
|
|
1610
1671
|
if (res.value.detached && detach) {
|
|
1611
1672
|
return await detach.onDetached(res.value.detached.shellId, res.value.stdout, res.value.stderr, res.value.detached.cause);
|
|
@@ -1724,7 +1785,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
|
|
|
1724
1785
|
const { command, timeout, run_in_background, description } = args;
|
|
1725
1786
|
if (run_in_background) {
|
|
1726
1787
|
if (!hasBackgroundShell(env)) {
|
|
1727
|
-
return "Error (Bash): this environment does not support background processes (run_in_background).";
|
|
1788
|
+
return errorResult("Error (Bash): this environment does not support background processes (run_in_background).");
|
|
1728
1789
|
}
|
|
1729
1790
|
if (cwdRef && (await isDeadCwd(env, cwdRef.current))) {
|
|
1730
1791
|
const diagnosis = await diagnoseDeadCwd(env, "Bash", cwdRef.current, rootCanonical, cwdRef);
|
|
@@ -1737,7 +1798,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
|
|
|
1737
1798
|
...(requestedTimeoutSec !== undefined ? { timeout: requestedTimeoutSec } : {}),
|
|
1738
1799
|
});
|
|
1739
1800
|
if (!r.ok)
|
|
1740
|
-
return `Error (Bash): ${r.error.message}
|
|
1801
|
+
return errorResult(`Error (Bash): ${r.error.message}`);
|
|
1741
1802
|
const bgCaps = env.backgroundCapabilities;
|
|
1742
1803
|
const appliedTimeoutSec = typeof bgCaps.defaultBgTimeoutSec === "number" && typeof bgCaps.maxBgTimeoutSec === "number"
|
|
1743
1804
|
? Math.min(requestedTimeoutSec ?? bgCaps.defaultBgTimeoutSec, bgCaps.maxBgTimeoutSec)
|
|
@@ -1783,7 +1844,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
|
|
|
1783
1844
|
kill = undefined;
|
|
1784
1845
|
}
|
|
1785
1846
|
if (kill !== undefined && (kill.ok || kill.error.code === "not_found")) {
|
|
1786
|
-
return `Error (Bash): background process was started but could not be registered (${reason}); it has been terminated
|
|
1847
|
+
return errorResult(`Error (Bash): background process was started but could not be registered (${reason}); it has been terminated.`);
|
|
1787
1848
|
}
|
|
1788
1849
|
const killMsg = kill !== undefined && !kill.ok ? kill.error.message : "killBackground threw";
|
|
1789
1850
|
return {
|
|
@@ -1791,6 +1852,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
|
|
|
1791
1852
|
`The process could NOT be terminated — it may still be running, but it has no task_id, so TaskOutput/TaskStop cannot reach it. ` +
|
|
1792
1853
|
`If it must be stopped, kill it manually (e.g. \`pkill -f\` on its command line).`,
|
|
1793
1854
|
details: { type: "bash", registerFailed: true, killFailed: true },
|
|
1855
|
+
isError: true,
|
|
1794
1856
|
};
|
|
1795
1857
|
}
|
|
1796
1858
|
const interimNote = outputFile !== undefined
|
|
@@ -1863,6 +1925,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
|
|
|
1863
1925
|
`it has been terminated (an unregistered background process would have no task_id and could not be read or stopped).` +
|
|
1864
1926
|
tails,
|
|
1865
1927
|
details: { type: "bash", detached: true, registerFailed: true },
|
|
1928
|
+
isError: true,
|
|
1866
1929
|
};
|
|
1867
1930
|
}
|
|
1868
1931
|
const killMsg = kill !== undefined && !kill.ok ? kill.error.message : "killBackground threw";
|
|
@@ -1872,6 +1935,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
|
|
|
1872
1935
|
`If it must be stopped, kill it manually (e.g. \`pkill -f\` on its command line).` +
|
|
1873
1936
|
tails,
|
|
1874
1937
|
details: { type: "bash", detached: true, registerFailed: true, killFailed: true },
|
|
1938
|
+
isError: true,
|
|
1875
1939
|
};
|
|
1876
1940
|
}
|
|
1877
1941
|
const tail = stdoutSoFar.slice(-1_000);
|
|
@@ -1934,7 +1998,7 @@ export function makeBashReadonlyTool(env, rootCanonical, allow, execClamp) {
|
|
|
1934
1998
|
const { command, timeout } = args;
|
|
1935
1999
|
const reason = coarseReadonlyCheck(command, allow);
|
|
1936
2000
|
if (reason)
|
|
1937
|
-
return `Error (Bash): ${reason}
|
|
2001
|
+
return errorResult(`Error (Bash): ${reason}`);
|
|
1938
2002
|
return runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
|
|
1939
2003
|
},
|
|
1940
2004
|
});
|
|
@@ -1958,9 +2022,9 @@ export function makeBashOutputTool(env) {
|
|
|
1958
2022
|
const { task_id, bash_id, filter } = args;
|
|
1959
2023
|
const id = task_id ?? bash_id;
|
|
1960
2024
|
if (!id)
|
|
1961
|
-
return "Error (TaskOutput): Missing required parameter: task_id";
|
|
2025
|
+
return errorResult("Error (TaskOutput): Missing required parameter: task_id");
|
|
1962
2026
|
if (!hasBackgroundShell(env)) {
|
|
1963
|
-
return "Error (TaskOutput): this environment does not support background processes.";
|
|
2027
|
+
return errorResult("Error (TaskOutput): this environment does not support background processes.");
|
|
1964
2028
|
}
|
|
1965
2029
|
let rx;
|
|
1966
2030
|
if (filter !== undefined) {
|
|
@@ -1968,12 +2032,12 @@ export function makeBashOutputTool(env) {
|
|
|
1968
2032
|
rx = new RegExp(filter);
|
|
1969
2033
|
}
|
|
1970
2034
|
catch {
|
|
1971
|
-
return `Error (TaskOutput): invalid filter regex: ${filter}
|
|
2035
|
+
return errorResult(`Error (TaskOutput): invalid filter regex: ${filter}`);
|
|
1972
2036
|
}
|
|
1973
2037
|
}
|
|
1974
2038
|
const r = await env.pollBackground(id);
|
|
1975
2039
|
if (!r.ok)
|
|
1976
|
-
return `Error (TaskOutput): ${r.error.message}
|
|
2040
|
+
return errorResult(`Error (TaskOutput): ${r.error.message}`);
|
|
1977
2041
|
const shape = (text) => clipShellOutput(rx ? text.split(/\r?\n/).filter((l) => rx.test(l)).join("\n") : text);
|
|
1978
2042
|
const status = r.value.status === "exited" ? `exited(code ${r.value.exitCode})` : r.value.status;
|
|
1979
2043
|
const dropped = r.value.bytesDroppedBeforeCursor ?? 0;
|
|
@@ -2002,15 +2066,15 @@ export function makeKillShellTool(env, registry = defaultTaskRegistry) {
|
|
|
2002
2066
|
const { task_id, shell_id } = args;
|
|
2003
2067
|
const id = task_id ?? shell_id;
|
|
2004
2068
|
if (!id)
|
|
2005
|
-
return "Missing required parameter: task_id";
|
|
2069
|
+
return errorResult("Missing required parameter: task_id");
|
|
2006
2070
|
if (!hasBackgroundShell(env)) {
|
|
2007
|
-
return "Error (TaskStop): this environment does not support background processes.";
|
|
2071
|
+
return errorResult("Error (TaskStop): this environment does not support background processes.");
|
|
2008
2072
|
}
|
|
2009
2073
|
registry.markStopSourceByShellId(id, "parent", env);
|
|
2010
2074
|
const r = await env.killBackground(id);
|
|
2011
2075
|
if (!r.ok) {
|
|
2012
2076
|
registry.clearPendingStopSourceByShellId(id, "parent", env);
|
|
2013
|
-
return `${id} is unknown or has already ended
|
|
2077
|
+
return errorResult(`${id} is unknown or has already ended.`);
|
|
2014
2078
|
}
|
|
2015
2079
|
return `Terminated ${id}.`;
|
|
2016
2080
|
},
|