zidane 5.14.4 → 6.0.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/{acp-24uU2WDs.js → acp-Irby04Us.js} +7 -8
- package/dist/{acp-24uU2WDs.js.map → acp-Irby04Us.js.map} +1 -1
- package/dist/acp-cli.js +7 -7
- package/dist/acp.d.ts +2 -3
- package/dist/acp.d.ts.map +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-Cch2ayTt.js → agent-DkSmGkDM.js} +10871 -5411
- package/dist/agent-DkSmGkDM.js.map +1 -0
- package/dist/agent.d.ts +1 -2
- package/dist/agent.js +2 -2
- package/dist/{anthropic-RGqsJlC8.js → anthropic-CTHsdFG9.js} +46 -6
- package/dist/anthropic-CTHsdFG9.js.map +1 -0
- package/dist/{auth-C-ms5N2x.js → auth-BDSu_0t3.js} +13 -86
- package/dist/auth-BDSu_0t3.js.map +1 -0
- package/dist/chat/pure.d.ts +4 -4
- package/dist/chat/pure.js +4 -3
- package/dist/chat.d.ts +45 -8
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +9 -6
- package/dist/chat.js.map +1 -1
- package/dist/completion-core-CXYSVhZo.js +147 -0
- package/dist/completion-core-CXYSVhZo.js.map +1 -0
- package/dist/{context-breakdown-kO-pDsay.js → context-breakdown-Dzo25N45.js} +2 -23
- package/dist/context-breakdown-Dzo25N45.js.map +1 -0
- package/dist/contexts/daytona.d.ts +3 -3
- package/dist/contexts/daytona.js +1 -1
- package/dist/contexts/docker.d.ts +10 -2
- package/dist/contexts/docker.d.ts.map +1 -1
- package/dist/contexts/docker.js +142 -103
- package/dist/contexts/docker.js.map +1 -1
- package/dist/contexts/e2b.d.ts +2 -2
- package/dist/contexts/e2b.js +1 -1
- package/dist/contexts/sandbox.d.ts +1 -1
- package/dist/contexts/sandbox.js +4 -1
- package/dist/contexts/sandbox.js.map +1 -1
- package/dist/{contexts-Lzf5huID.js → contexts-Vone3qQQ.js} +26 -37
- package/dist/contexts-Vone3qQQ.js.map +1 -0
- package/dist/contexts.d.ts +3 -3
- package/dist/contexts.js +1 -1
- package/dist/errors.d.ts +1 -2
- package/dist/errors.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +3 -3
- package/dist/exec-file-size-PXkr-MGA.js +63 -0
- package/dist/exec-file-size-PXkr-MGA.js.map +1 -0
- package/dist/extensions.d.ts +2 -0
- package/dist/extensions.js +2 -0
- package/dist/generate-data-WL_yGVL6.js +324 -0
- package/dist/generate-data-WL_yGVL6.js.map +1 -0
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +15 -10
- package/dist/headless.js.map +1 -1
- package/dist/index-B8UtiZy7.d.ts +14967 -0
- package/dist/index-B8UtiZy7.d.ts.map +1 -0
- package/dist/{index-B3ciFUZx.d.ts → index-DsPQbyUn.d.ts} +2 -2
- package/dist/index-DsPQbyUn.d.ts.map +1 -0
- package/dist/index.d.ts +6 -12
- package/dist/index.js +20 -43
- package/dist/index.js.map +1 -1
- package/dist/{logger-Ktm-lj1s.js → logger-DBX9uYOw.js} +108 -5
- package/dist/logger-DBX9uYOw.js.map +1 -0
- package/dist/login-DOF8pMVM.js +539 -0
- package/dist/login-DOF8pMVM.js.map +1 -0
- package/dist/{mcp-Dn5W65Lv.js → mcp-CcvuVXB9.js} +2 -2
- package/dist/{mcp-Dn5W65Lv.js.map → mcp-CcvuVXB9.js.map} +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +1 -1
- package/dist/{messages-YZQLKaz2.js → messages-CwujiS74.js} +4 -4
- package/dist/messages-CwujiS74.js.map +1 -0
- package/dist/{openai-compat-0Y7jcncn.js → openai-compat-BvgYGSR1.js} +7 -6
- package/dist/{openai-compat-0Y7jcncn.js.map → openai-compat-BvgYGSR1.js.map} +1 -1
- package/dist/output/stream-json.d.ts +1 -2
- package/dist/output/stream-json.d.ts.map +1 -1
- package/dist/output/terminal.d.ts +1 -2
- package/dist/output/terminal.d.ts.map +1 -1
- package/dist/{glob-DluQFSw1.js → output-cap-kBUHBlnq.js} +50 -2
- package/dist/output-cap-kBUHBlnq.js.map +1 -0
- package/dist/presets.d.ts +1 -2
- package/dist/presets.js +1 -1
- package/dist/providers/anthropic.d.ts +2 -2
- package/dist/providers/anthropic.js +2 -2
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/arcee.js +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/baseten.js +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/cerebras.js +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/local.js +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai-compat.js +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +4 -4
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/openrouter.js +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +1 -1
- package/dist/{providers-DgmdYGKm.js → providers-z6LDMAr3.js} +3 -3
- package/dist/{providers-DgmdYGKm.js.map → providers-z6LDMAr3.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +4 -4
- package/dist/{read-state-BymF41Jb.js → read-state-Dq_TXUX1.js} +2 -2
- package/dist/{read-state-BymF41Jb.js.map → read-state-Dq_TXUX1.js.map} +1 -1
- package/dist/{interpolate-BtIgcCuz.js → resolve-Cr8JSL_O.js} +562 -136
- package/dist/resolve-Cr8JSL_O.js.map +1 -0
- package/dist/restate.d.ts +2 -2
- package/dist/restate.js +2 -2
- package/dist/{sandbox-Dgy-m6UF.d.ts → sandbox-CgjG2VvQ.d.ts} +2 -2
- package/dist/{sandbox-Dgy-m6UF.d.ts.map → sandbox-CgjG2VvQ.d.ts.map} +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/{session-CqrAFAKJ.js → session-CsdDIPY8.js} +108 -3
- package/dist/session-CsdDIPY8.js.map +1 -0
- package/dist/session.d.ts +2 -2
- package/dist/session.js +3 -3
- package/dist/skills-Bsb0rvWI.js +295 -0
- package/dist/skills-Bsb0rvWI.js.map +1 -0
- package/dist/skills.d.ts +2 -3
- package/dist/skills.js +3 -24
- package/dist/{errors-CkTvg97v.d.ts → timeout-8vSIRjzl.d.ts} +10 -2
- package/dist/timeout-8vSIRjzl.d.ts.map +1 -0
- package/dist/{timeout-kGGSXagK.js → timeout-BDetbuN6.js} +59 -2
- package/dist/timeout-BDetbuN6.js.map +1 -0
- package/dist/tool-formatters-DY0TDb1r.d.ts +378 -0
- package/dist/tool-formatters-DY0TDb1r.d.ts.map +1 -0
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +2 -3
- package/dist/tools.js +3 -4
- package/dist/{transcript-anchors-BHIgMlPq.d.ts → transcript-anchors-Cb-bDbNH.d.ts} +233 -640
- package/dist/transcript-anchors-Cb-bDbNH.d.ts.map +1 -0
- package/dist/{transcript-anchors-DsoBHg43.js → transcript-anchors-DSRPWjiH.js} +391 -961
- package/dist/transcript-anchors-DSRPWjiH.js.map +1 -0
- package/dist/tui.d.ts +335 -11
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +6375 -1892
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-Dw02ZoSQ.d.ts → turn-operations-CgMROLsr.d.ts} +29 -4
- package/dist/turn-operations-CgMROLsr.d.ts.map +1 -0
- package/dist/{turn-operations-tG0vuBNc.js → turn-operations-Dq9Kx7m2.js} +321 -81
- package/dist/turn-operations-Dq9Kx7m2.js.map +1 -0
- package/dist/{types-BqdTeBaC.d.ts → types-BDccavwj.d.ts} +42 -1
- package/dist/types-BDccavwj.d.ts.map +1 -0
- package/dist/{types-CyVGdbia.js → types-BIarq1qE.js} +90 -2
- package/dist/types-BIarq1qE.js.map +1 -0
- package/dist/types.d.ts +5 -6
- package/dist/types.js +1 -1
- package/dist/{xai-tPdbAGkq.js → xai-B_e6TOA8.js} +2 -2
- package/dist/{xai-tPdbAGkq.js.map → xai-B_e6TOA8.js.map} +1 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHAT.md +7 -6
- package/docs/EXECUTION_CONTEXT.md +16 -0
- package/docs/EXTENSIONS.md +1063 -0
- package/docs/SKILL.md +16 -0
- package/docs/TUI.md +10 -3
- package/package.json +12 -3
- package/dist/agent-Cch2ayTt.js.map +0 -1
- package/dist/agent-EFWt6uQ_.d.ts +0 -6914
- package/dist/agent-EFWt6uQ_.d.ts.map +0 -1
- package/dist/anthropic-RGqsJlC8.js.map +0 -1
- package/dist/auth-C-ms5N2x.js.map +0 -1
- package/dist/context-breakdown-kO-pDsay.js.map +0 -1
- package/dist/contexts-Lzf5huID.js.map +0 -1
- package/dist/errors-CkTvg97v.d.ts.map +0 -1
- package/dist/glob-DluQFSw1.js.map +0 -1
- package/dist/glob-shell-rJMoCIGb.js +0 -21
- package/dist/glob-shell-rJMoCIGb.js.map +0 -1
- package/dist/index-B3ciFUZx.d.ts.map +0 -1
- package/dist/index-BtzE3Uaq.d.ts +0 -2738
- package/dist/index-BtzE3Uaq.d.ts.map +0 -1
- package/dist/index-tSzOaWNt.d.ts +0 -316
- package/dist/index-tSzOaWNt.d.ts.map +0 -1
- package/dist/interpolate-BtIgcCuz.js.map +0 -1
- package/dist/logger-DGiGf7DW.d.ts +0 -102
- package/dist/logger-DGiGf7DW.d.ts.map +0 -1
- package/dist/logger-Ktm-lj1s.js.map +0 -1
- package/dist/login-CCA-1lgK.js +0 -267
- package/dist/login-CCA-1lgK.js.map +0 -1
- package/dist/messages-YZQLKaz2.js.map +0 -1
- package/dist/policy-DcGlpaNs.d.ts +0 -129
- package/dist/policy-DcGlpaNs.d.ts.map +0 -1
- package/dist/presets-BQWYMHdd.js +0 -114
- package/dist/presets-BQWYMHdd.js.map +0 -1
- package/dist/run-summary-DkN8KsHM.d.ts +0 -249
- package/dist/run-summary-DkN8KsHM.d.ts.map +0 -1
- package/dist/session-CqrAFAKJ.js.map +0 -1
- package/dist/shell-quote-BmnhZmdM.js +0 -33
- package/dist/shell-quote-BmnhZmdM.js.map +0 -1
- package/dist/skills.js.map +0 -1
- package/dist/timeout-CpFm0jJ6.d.ts +0 -10
- package/dist/timeout-CpFm0jJ6.d.ts.map +0 -1
- package/dist/timeout-kGGSXagK.js.map +0 -1
- package/dist/tool-formatters-C6iLgc9f.d.ts +0 -1454
- package/dist/tool-formatters-C6iLgc9f.d.ts.map +0 -1
- package/dist/tools-CCd8e-HT.js +0 -1739
- package/dist/tools-CCd8e-HT.js.map +0 -1
- package/dist/transcript-anchors-BHIgMlPq.d.ts.map +0 -1
- package/dist/transcript-anchors-DsoBHg43.js.map +0 -1
- package/dist/turn-operations-Dw02ZoSQ.d.ts.map +0 -1
- package/dist/turn-operations-tG0vuBNc.js.map +0 -1
- package/dist/types-BqdTeBaC.d.ts.map +0 -1
- package/dist/types-CyVGdbia.js.map +0 -1
package/dist/tools-CCd8e-HT.js
DELETED
|
@@ -1,1739 +0,0 @@
|
|
|
1
|
-
import { c as resolveReadStateMap, r as hashContent, s as readStateKey } from "./read-state-BymF41Jb.js";
|
|
2
|
-
import { i as styleReplacementForVia, n as resolveOldString, r as stripLineNumberPrefixes, t as describeVia } from "./edit-utils-EGosADZq.js";
|
|
3
|
-
import { J as resolveAttachmentFromTurns, c as looksBinary, g as pathArgError, q as parseAttachmentHandle, t as createAgent } from "./agent-Cch2ayTt.js";
|
|
4
|
-
import { a as formatTaskStatus, i as formatDuration, s as previewLine } from "./format-BNOXpl-1.js";
|
|
5
|
-
import { i as parseGitLsFiles, r as GIT_LS_FILES_COMMAND } from "./glob-DluQFSw1.js";
|
|
6
|
-
import { l as errorMessage, u as isAbortLikeError } from "./errors-DJUxZg9b.js";
|
|
7
|
-
import { n as reconcileImageMediaType } from "./media-sniff-Bn76JxAu.js";
|
|
8
|
-
import { n as flattenTurns, r as formatTokenUsage } from "./stats-DAKBEKjc.js";
|
|
9
|
-
import { n as shellQuote, t as alwaysQuote } from "./shell-quote-BmnhZmdM.js";
|
|
10
|
-
import { Buffer } from "node:buffer";
|
|
11
|
-
//#region src/behaviors/read-file-defaults.ts
|
|
12
|
-
function normalizeReadFileInteger(value, fallback) {
|
|
13
|
-
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
14
|
-
if (value < 0) return fallback;
|
|
15
|
-
return Math.floor(value);
|
|
16
|
-
}
|
|
17
|
-
function resolveReadFileDefaults(behavior, fallbacks) {
|
|
18
|
-
return {
|
|
19
|
-
lineLimit: normalizeReadFileInteger(behavior?.readFileDefaults?.limit, fallbacks.lineLimit),
|
|
20
|
-
textMaxBytes: normalizeReadFileInteger(behavior?.readFileDefaults?.maxBytes, fallbacks.textMaxBytes),
|
|
21
|
-
attachmentMaxBytes: normalizeReadFileInteger(behavior?.readFileDefaults?.maxBytes, fallbacks.attachmentMaxBytes)
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
//#endregion
|
|
25
|
-
//#region src/tools/path-suggest.ts
|
|
26
|
-
function entryPath$2(entry) {
|
|
27
|
-
return typeof entry === "string" ? entry : entry.path;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Find a sibling file in the same directory sharing `path`'s basename
|
|
31
|
-
* (sans extension), excluding the missing path itself. Returns just the
|
|
32
|
-
* filename (not the full path) when found, otherwise `null`.
|
|
33
|
-
*
|
|
34
|
-
* Silent on errors — a missing parent directory or a `listFiles` failure
|
|
35
|
-
* means we have no suggestion, not that anything is wrong.
|
|
36
|
-
*/
|
|
37
|
-
async function findSimilarFile(execution, handle, path) {
|
|
38
|
-
const slash = path.lastIndexOf("/");
|
|
39
|
-
const dir = slash === -1 ? "." : path.slice(0, slash) || "/";
|
|
40
|
-
const target = slash === -1 ? path : path.slice(slash + 1);
|
|
41
|
-
const dot = target.lastIndexOf(".");
|
|
42
|
-
const targetBase = dot === -1 ? target : target.slice(0, dot);
|
|
43
|
-
if (targetBase.length === 0) return null;
|
|
44
|
-
let entries;
|
|
45
|
-
try {
|
|
46
|
-
entries = await execution.listFiles(handle, dir);
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
for (const item of entries) {
|
|
51
|
-
const entry = entryPath$2(item);
|
|
52
|
-
if (entry === target) continue;
|
|
53
|
-
const entryDot = entry.lastIndexOf(".");
|
|
54
|
-
if ((entryDot === -1 ? entry : entry.slice(0, entryDot)) === targetBase) return entry;
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Format a `Did you mean X?` suffix for missing-file errors. Returns an empty
|
|
60
|
-
* string when no suggestion is available so callers can string-concat
|
|
61
|
-
* unconditionally.
|
|
62
|
-
*/
|
|
63
|
-
async function suggestionFor(execution, handle, path) {
|
|
64
|
-
const sibling = await findSimilarFile(execution, handle, path);
|
|
65
|
-
return sibling ? ` Did you mean ${sibling}?` : "";
|
|
66
|
-
}
|
|
67
|
-
//#endregion
|
|
68
|
-
//#region src/tools/edit.ts
|
|
69
|
-
/**
|
|
70
|
-
* Surgical edit — replace `old_string` with `new_string` in a single file.
|
|
71
|
-
*
|
|
72
|
-
* Mirrors Claude Code's `Edit` semantics so models post-trained on Anthropic's
|
|
73
|
-
* tool surface need no relearning. Fails clearly when `old_string` isn't unique
|
|
74
|
-
* (unless `replace_all: true`) and when not found, with a nearest-match preview
|
|
75
|
-
* so the model can recover without a separate `read_file` round-trip.
|
|
76
|
-
*/
|
|
77
|
-
const edit = {
|
|
78
|
-
spec: {
|
|
79
|
-
name: "edit",
|
|
80
|
-
description: "Replace exact `old_string` with `new_string` in a file. Fails if `old_string` is not unique unless `replace_all: true`. Prefer over `write_file` for surgical changes — preserves the rest of the file. Tolerates `read_file` line-number prefixes (`<N>\\t…`, `<N>|…`, or `<N>→…`) in `old_string` / `new_string` — they are stripped before matching/writing, so you can paste a numbered chunk verbatim.",
|
|
81
|
-
inputSchema: {
|
|
82
|
-
type: "object",
|
|
83
|
-
properties: {
|
|
84
|
-
path: {
|
|
85
|
-
type: "string",
|
|
86
|
-
description: "File path (relative to the execution-context cwd, or absolute)."
|
|
87
|
-
},
|
|
88
|
-
old_string: {
|
|
89
|
-
type: "string",
|
|
90
|
-
description: "Exact substring to find."
|
|
91
|
-
},
|
|
92
|
-
new_string: {
|
|
93
|
-
type: "string",
|
|
94
|
-
description: "Replacement substring."
|
|
95
|
-
},
|
|
96
|
-
replace_all: {
|
|
97
|
-
type: "boolean",
|
|
98
|
-
description: "Replace every occurrence. Default: false."
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
required: [
|
|
102
|
-
"path",
|
|
103
|
-
"old_string",
|
|
104
|
-
"new_string"
|
|
105
|
-
]
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
async execute({ path, old_string, new_string, replace_all }, ctx) {
|
|
109
|
-
const target = path;
|
|
110
|
-
const find = old_string;
|
|
111
|
-
const replacement = new_string;
|
|
112
|
-
const replaceAll = replace_all === true;
|
|
113
|
-
const pathErr = pathArgError(target);
|
|
114
|
-
if (pathErr) {
|
|
115
|
-
ctx.reportOutcome?.("failed");
|
|
116
|
-
return `Edit error: ${pathErr}`;
|
|
117
|
-
}
|
|
118
|
-
if (find === replacement) return `Edit error: old_string and new_string are identical — nothing to change in ${target}.`;
|
|
119
|
-
if (find.length === 0) return `Edit error: old_string is empty. Use write_file to create or fully overwrite a file.`;
|
|
120
|
-
let original;
|
|
121
|
-
try {
|
|
122
|
-
original = await ctx.execution.readFile(ctx.handle, target);
|
|
123
|
-
} catch {
|
|
124
|
-
const hint = await suggestionFor(ctx.execution, ctx.handle, target);
|
|
125
|
-
ctx.reportOutcome?.("failed");
|
|
126
|
-
return `Edit error: file not found: ${target}.${hint}`;
|
|
127
|
-
}
|
|
128
|
-
if (ctx.behavior?.requireReadBeforeEdit) {
|
|
129
|
-
const readState = resolveReadStateMap(ctx);
|
|
130
|
-
if (readState) {
|
|
131
|
-
const absKey = readStateKey(ctx.handle.cwd, target);
|
|
132
|
-
const prior = readState.get(absKey);
|
|
133
|
-
if (!prior) return `Edit error: ${target} has not been read in this session. Call read_file first so the edit applies against the current contents.`;
|
|
134
|
-
if (prior.contentHash !== hashContent(original)) return `Edit error: ${target} has changed on disk since the last read. Re-read the file before editing.`;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const match = resolveOldString(original, find);
|
|
138
|
-
if (!match) {
|
|
139
|
-
const preview = nearestMatchPreview(original, find);
|
|
140
|
-
ctx.reportOutcome?.("failed");
|
|
141
|
-
return preview ? `Edit error: old_string not found in ${target}. Closest match in the file: ${preview}` : `Edit error: old_string not found in ${target}.`;
|
|
142
|
-
}
|
|
143
|
-
const { actual, occurrences, via } = match;
|
|
144
|
-
if (occurrences > 1 && !replaceAll) return `Edit error: old_string appears ${occurrences} times in ${target}. Pass replace_all=true or expand old_string for uniqueness.`;
|
|
145
|
-
const styledReplacement = styleReplacementForVia(replacement, via, actual);
|
|
146
|
-
const updated = replaceAll ? original.split(actual).join(styledReplacement) : original.replace(actual, styledReplacement);
|
|
147
|
-
if (updated === original) {
|
|
148
|
-
ctx.reportOutcome?.("noop");
|
|
149
|
-
return `Edit error: replacement produced no change in ${target}.`;
|
|
150
|
-
}
|
|
151
|
-
await ctx.execution.writeFile(ctx.handle, target, updated);
|
|
152
|
-
const readState = resolveReadStateMap(ctx);
|
|
153
|
-
if (readState) {
|
|
154
|
-
const absKey = readStateKey(ctx.handle.cwd, target);
|
|
155
|
-
const prior = readState.get(absKey);
|
|
156
|
-
if (prior) readState.set(absKey, {
|
|
157
|
-
...prior,
|
|
158
|
-
contentHash: hashContent(updated),
|
|
159
|
-
mtimeMs: Date.now()
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
ctx.reportOutcome?.("edited");
|
|
163
|
-
const viaNote = via === "exact" ? "" : ` (old_string was not found verbatim; matched via ${describeVia(via)})`;
|
|
164
|
-
return `Edited ${target}: replaced ${occurrences} occurrence${occurrences === 1 ? "" : "s"}.${viaNote}`;
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
/**
|
|
168
|
-
* Find the line that shares the longest common prefix with the needle's first
|
|
169
|
-
* line. Cheap heuristic — better than nothing for the common "model has a typo"
|
|
170
|
-
* case (off-by-one indent, trailing whitespace, escape mismatch).
|
|
171
|
-
*
|
|
172
|
-
* Returns a "line N: <preview>" snippet or null when no line shares a useful
|
|
173
|
-
* prefix. Strips line-number prefixes from the needle first so a model that
|
|
174
|
-
* pasted a numbered `read_file` chunk verbatim still gets a useful diagnostic.
|
|
175
|
-
*/
|
|
176
|
-
function nearestMatchPreview(haystack, needle) {
|
|
177
|
-
const needleFirstLine = stripLineNumberPrefixes(needle).split("\n")[0];
|
|
178
|
-
if (needleFirstLine.length < 3) return null;
|
|
179
|
-
const lines = haystack.split("\n");
|
|
180
|
-
let bestScore = 0;
|
|
181
|
-
let bestIdx = -1;
|
|
182
|
-
for (let i = 0; i < lines.length; i++) {
|
|
183
|
-
const score = sharedPrefixLength(lines[i], needleFirstLine);
|
|
184
|
-
if (score > bestScore) {
|
|
185
|
-
bestScore = score;
|
|
186
|
-
bestIdx = i;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (bestIdx < 0 || bestScore < Math.min(8, Math.floor(needleFirstLine.length / 2))) return null;
|
|
190
|
-
const snippet = lines[bestIdx].slice(0, 80);
|
|
191
|
-
return `line ${bestIdx + 1}: ${JSON.stringify(snippet)}`;
|
|
192
|
-
}
|
|
193
|
-
function sharedPrefixLength(a, b) {
|
|
194
|
-
const max = Math.min(a.length, b.length);
|
|
195
|
-
let i = 0;
|
|
196
|
-
while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) i++;
|
|
197
|
-
return i;
|
|
198
|
-
}
|
|
199
|
-
//#endregion
|
|
200
|
-
//#region src/tools/glob.ts
|
|
201
|
-
/**
|
|
202
|
-
* Glob-pattern file matching.
|
|
203
|
-
*
|
|
204
|
-
* Delegates matching to {@link ExecutionContext.listFiles} with `{ glob:
|
|
205
|
-
* true }`. This keeps filesystem semantics inside the execution context
|
|
206
|
-
* (process / docker / sandbox) instead of reaching around it from the tool.
|
|
207
|
-
*
|
|
208
|
-
* Results are capped at 1000 entries to keep model input bounded. The
|
|
209
|
-
* cap applies after the directory filter, so dirs don't consume the
|
|
210
|
-
* file budget.
|
|
211
|
-
*
|
|
212
|
-
* Patterns are deliberately NOT confined to the workspace: like
|
|
213
|
-
* `read_file`, `../`-prefixed patterns may match outside the cwd — the
|
|
214
|
-
* execution context is the sandbox boundary, not this tool.
|
|
215
|
-
*
|
|
216
|
-
* By default, contexts that can cheaply provide metadata return
|
|
217
|
-
* `<path>\t<size>\t<mtime>` rows. Contexts without cheap metadata support return
|
|
218
|
-
* paths only. Pass `metadata: false` to request plain newline-separated paths.
|
|
219
|
-
*/
|
|
220
|
-
const DEFAULT_LIMIT = 1e3;
|
|
221
|
-
/** Backstop so a pathological walk can never hang the agent's turn. */
|
|
222
|
-
const GLOB_TIMEOUT_MS = 2e4;
|
|
223
|
-
function entryPath$1(entry) {
|
|
224
|
-
return typeof entry === "string" ? entry : entry.path;
|
|
225
|
-
}
|
|
226
|
-
function entryMetadata(entry) {
|
|
227
|
-
return typeof entry === "string" ? void 0 : entry;
|
|
228
|
-
}
|
|
229
|
-
function renderEntry(entry, wantMetadata) {
|
|
230
|
-
const path = entryPath$1(entry);
|
|
231
|
-
const meta = entryMetadata(entry);
|
|
232
|
-
if (!wantMetadata || typeof meta?.size !== "number" || typeof meta.mtimeMs !== "number") return path;
|
|
233
|
-
return `${path}\t${meta.size}\t${new Date(meta.mtimeMs).toISOString()}`;
|
|
234
|
-
}
|
|
235
|
-
const glob = {
|
|
236
|
-
isConcurrencySafe: true,
|
|
237
|
-
spec: {
|
|
238
|
-
name: "glob",
|
|
239
|
-
description: "Match files by glob pattern (supports **, *, ?). Relative to the execution context cwd; patterns may reach outside the workspace (e.g. \"../shared/**/*.ts\"). By default each row is `<path>\\t<size-bytes>\\t<mtime-iso>`; set `metadata: false` for a plain newline-separated list of paths. Always sorted.",
|
|
240
|
-
inputSchema: {
|
|
241
|
-
type: "object",
|
|
242
|
-
properties: {
|
|
243
|
-
pattern: {
|
|
244
|
-
type: "string",
|
|
245
|
-
description: "Glob pattern (e.g. \"src/**/*.ts\", \"*.md\", \"test/**/fixtures/*\")."
|
|
246
|
-
},
|
|
247
|
-
limit: {
|
|
248
|
-
type: "number",
|
|
249
|
-
description: `Maximum number of matches to return. Default: ${DEFAULT_LIMIT}.`
|
|
250
|
-
},
|
|
251
|
-
metadata: {
|
|
252
|
-
type: "boolean",
|
|
253
|
-
description: "Append size (bytes) and mtime (ISO) per row, tab-separated. Default: true. In-process only — non-process execution contexts always return paths."
|
|
254
|
-
},
|
|
255
|
-
gitignore: {
|
|
256
|
-
type: "boolean",
|
|
257
|
-
description: "Skip files git ignores (.gitignore, .git/info/exclude, global excludes). Default: true. Set false to include ignored files like node_modules. Honored where git is available; a no-op otherwise."
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
required: ["pattern"]
|
|
261
|
-
}
|
|
262
|
-
},
|
|
263
|
-
async execute({ pattern, limit, metadata, gitignore }, ctx) {
|
|
264
|
-
const pat = pattern;
|
|
265
|
-
const max = typeof limit === "number" && limit > 0 ? limit : DEFAULT_LIMIT;
|
|
266
|
-
const wantMetadata = metadata !== false;
|
|
267
|
-
try {
|
|
268
|
-
if (ctx.execution.capabilities.glob !== true) throw new Error(`The active execution context (${ctx.execution.type}) does not support glob matching.`);
|
|
269
|
-
const controller = new AbortController();
|
|
270
|
-
const onAbort = () => controller.abort();
|
|
271
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
272
|
-
let timer;
|
|
273
|
-
const timeout = new Promise((resolve) => {
|
|
274
|
-
timer = setTimeout(() => {
|
|
275
|
-
controller.abort();
|
|
276
|
-
resolve("timeout");
|
|
277
|
-
}, GLOB_TIMEOUT_MS);
|
|
278
|
-
});
|
|
279
|
-
try {
|
|
280
|
-
const outcome = await Promise.race([ctx.execution.listFiles(ctx.handle, pat, {
|
|
281
|
-
glob: true,
|
|
282
|
-
limit: max,
|
|
283
|
-
metadata: wantMetadata,
|
|
284
|
-
gitignore,
|
|
285
|
-
signal: controller.signal
|
|
286
|
-
}), timeout]);
|
|
287
|
-
if (outcome === "timeout") return `Glob timed out after ${GLOB_TIMEOUT_MS}ms — narrow the pattern (anchor a literal prefix like \`src/**\`) or scope it to a subdirectory.`;
|
|
288
|
-
if (outcome.length === 0) return "(no matches)";
|
|
289
|
-
return outcome.map((entry) => renderEntry(entry, wantMetadata)).join("\n");
|
|
290
|
-
} finally {
|
|
291
|
-
clearTimeout(timer);
|
|
292
|
-
ctx.signal.removeEventListener("abort", onAbort);
|
|
293
|
-
}
|
|
294
|
-
} catch (err) {
|
|
295
|
-
return `Glob error: ${errorMessage(err)}`;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
};
|
|
299
|
-
//#endregion
|
|
300
|
-
//#region src/tools/grep.ts
|
|
301
|
-
/**
|
|
302
|
-
* Search file contents by regex.
|
|
303
|
-
*
|
|
304
|
-
* Wraps ripgrep (`rg`) when available; when it's not, falls back to a
|
|
305
|
-
* regex search that enumerates files via the execution context's shell
|
|
306
|
-
* (`find` over `exec`) and reads them via `ctx.execution.readFile`. Both
|
|
307
|
-
* legs go through the {@link ToolContext.execution} seam, so the search
|
|
308
|
-
* runs wherever the context's files actually live — in-process, a
|
|
309
|
-
* container, or a remote sandbox — without the tool ever touching the host
|
|
310
|
-
* filesystem directly.
|
|
311
|
-
*
|
|
312
|
-
* The tool surface mirrors Claude Code's `Grep` so models authored against the
|
|
313
|
-
* Anthropic tool surface need no relearning. Output modes:
|
|
314
|
-
* - `files_with_matches` (default) — newline-separated paths.
|
|
315
|
-
* - `content` — `path:line:match` (line numbers on by default).
|
|
316
|
-
* - `count` — `path:N` per matching file.
|
|
317
|
-
*
|
|
318
|
-
* Results are capped via `head_limit` (default 250) to keep model input
|
|
319
|
-
* bounded; `offset` lets the caller page through. Pagination operates on
|
|
320
|
-
* OUTPUT LINES, not match groups — in content mode, context lines and `--`
|
|
321
|
-
* separators consume the budget too (documented in the schema).
|
|
322
|
-
*
|
|
323
|
-
* A line cap is not enough on its own: ripgrep is line-oriented, so a minified
|
|
324
|
-
* / single-line file collapses to ONE match line that is the entire file, and
|
|
325
|
-
* `head_limit` would happily return megabytes — enough to blow the model's
|
|
326
|
-
* context window. Output is therefore ALSO byte-bounded: each line is clipped
|
|
327
|
-
* to `MAX_LINE_BYTES` and the total to `OUTPUT_BYTE_CAP`, mirroring the byte
|
|
328
|
-
* discipline `read_file` already enforces.
|
|
329
|
-
*
|
|
330
|
-
* `path` is deliberately NOT confined to the workspace: like `read_file`,
|
|
331
|
-
* absolute paths and `../` traversal are allowed — the execution context is
|
|
332
|
-
* the sandbox boundary, not this tool.
|
|
333
|
-
*/
|
|
334
|
-
const DEFAULT_HEAD_LIMIT = 250;
|
|
335
|
-
/**
|
|
336
|
-
* Per-line byte cap. ripgrep reports a match as the whole matching LINE, so a
|
|
337
|
-
* minified / single-line file (one line = the entire file) would otherwise be
|
|
338
|
-
* returned in full. Clip each line so a single line can't dump unbounded bytes
|
|
339
|
-
* into the model context.
|
|
340
|
-
*/
|
|
341
|
-
const MAX_LINE_BYTES = 2e3;
|
|
342
|
-
/** Total output byte cap, mirroring read_file's `DEFAULT_BYTE_CAP`. */
|
|
343
|
-
const OUTPUT_BYTE_CAP = 262144;
|
|
344
|
-
const DEFAULT_OUTPUT_MODE = "files_with_matches";
|
|
345
|
-
const grep = {
|
|
346
|
-
isConcurrencySafe: true,
|
|
347
|
-
spec: {
|
|
348
|
-
name: "grep",
|
|
349
|
-
description: "Search file contents by regex. Returns matching paths (default), match content, or per-file counts. Backed by ripgrep when available, with a portable shell-listing + regex fallback when `rg` is absent.",
|
|
350
|
-
inputSchema: {
|
|
351
|
-
type: "object",
|
|
352
|
-
properties: {
|
|
353
|
-
"pattern": {
|
|
354
|
-
type: "string",
|
|
355
|
-
description: "Regex (PCRE-flavored via ripgrep, JS regex via fallback)."
|
|
356
|
-
},
|
|
357
|
-
"path": {
|
|
358
|
-
type: "string",
|
|
359
|
-
description: "File or directory to search. Default: \".\". May be absolute or outside the workspace (no confinement — consistent with read_file)."
|
|
360
|
-
},
|
|
361
|
-
"glob": {
|
|
362
|
-
type: "string",
|
|
363
|
-
description: "Restrict to files matching this glob, e.g. \"**/*.ts\"."
|
|
364
|
-
},
|
|
365
|
-
"type": {
|
|
366
|
-
type: "string",
|
|
367
|
-
description: "rg file type filter, e.g. \"ts\", \"py\", \"rust\". Ignored by the fallback."
|
|
368
|
-
},
|
|
369
|
-
"output_mode": {
|
|
370
|
-
type: "string",
|
|
371
|
-
enum: [
|
|
372
|
-
"content",
|
|
373
|
-
"files_with_matches",
|
|
374
|
-
"count"
|
|
375
|
-
],
|
|
376
|
-
description: "Default: \"files_with_matches\"."
|
|
377
|
-
},
|
|
378
|
-
"-i": {
|
|
379
|
-
type: "boolean",
|
|
380
|
-
description: "Case-insensitive match."
|
|
381
|
-
},
|
|
382
|
-
"-n": {
|
|
383
|
-
type: "boolean",
|
|
384
|
-
description: "Show line numbers (content mode). Default: true."
|
|
385
|
-
},
|
|
386
|
-
"-A": {
|
|
387
|
-
type: "integer",
|
|
388
|
-
description: "Lines of trailing context (content mode)."
|
|
389
|
-
},
|
|
390
|
-
"-B": {
|
|
391
|
-
type: "integer",
|
|
392
|
-
description: "Lines of leading context (content mode)."
|
|
393
|
-
},
|
|
394
|
-
"-C": {
|
|
395
|
-
type: "integer",
|
|
396
|
-
description: "Lines of surrounding context (content mode). Overridden by -A/-B if set."
|
|
397
|
-
},
|
|
398
|
-
"multiline": {
|
|
399
|
-
type: "boolean",
|
|
400
|
-
description: "Allow patterns to match across line boundaries."
|
|
401
|
-
},
|
|
402
|
-
"gitignore": {
|
|
403
|
-
type: "boolean",
|
|
404
|
-
description: "Skip files git ignores (.gitignore, .git/info/exclude, global excludes). Default: true. Set false to also search ignored files like node_modules."
|
|
405
|
-
},
|
|
406
|
-
"head_limit": {
|
|
407
|
-
type: "integer",
|
|
408
|
-
description: `Cap output lines. Default: 250. Set 0 for unlimited lines. In content mode, context lines (-A/-B/-C) and "--" separators count toward the cap, so the number of matches shown may be lower. Output is independently byte-bounded: each line is clipped to ${MAX_LINE_BYTES} bytes and the total to ${OUTPUT_BYTE_CAP} bytes, so long (e.g. minified) lines never overflow the context window.`
|
|
409
|
-
},
|
|
410
|
-
"offset": {
|
|
411
|
-
type: "integer",
|
|
412
|
-
description: "Skip first N output lines. Default: 0."
|
|
413
|
-
}
|
|
414
|
-
},
|
|
415
|
-
required: ["pattern"]
|
|
416
|
-
}
|
|
417
|
-
},
|
|
418
|
-
async execute(rawInput, ctx) {
|
|
419
|
-
const input = rawInput;
|
|
420
|
-
try {
|
|
421
|
-
if (await isRipgrepAvailable(ctx)) return await runViaRipgrep(input, ctx);
|
|
422
|
-
return await runFallback(input, ctx);
|
|
423
|
-
} catch (err) {
|
|
424
|
-
if (isAbortLikeError(err)) throw err;
|
|
425
|
-
return `grep error: ${errorMessage(err)}`;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
};
|
|
429
|
-
/**
|
|
430
|
-
* Probe ripgrep availability **per call**. The probe is intentionally not
|
|
431
|
-
* cached: caching at module scope would leak across execution contexts (an
|
|
432
|
-
* orchestrator running an in-process agent and a docker agent in the same
|
|
433
|
-
* Node process must be able to differ on whether `rg` exists), and caching
|
|
434
|
-
* per handle adds bookkeeping for negligible savings — `rg --version` is
|
|
435
|
-
* ~5 ms, and grep is invoked at most a few times per turn.
|
|
436
|
-
*/
|
|
437
|
-
async function isRipgrepAvailable(ctx) {
|
|
438
|
-
return (await ctx.execution.exec(ctx.handle, "rg --version", { signal: ctx.signal })).exitCode === 0;
|
|
439
|
-
}
|
|
440
|
-
async function runViaRipgrep(input, ctx) {
|
|
441
|
-
const args = ["rg"];
|
|
442
|
-
const mode = input.output_mode ?? DEFAULT_OUTPUT_MODE;
|
|
443
|
-
if (mode === "files_with_matches") args.push("--files-with-matches");
|
|
444
|
-
else if (mode === "count") args.push("--count");
|
|
445
|
-
else args.push(input["-n"] ?? true ? "--line-number" : "--no-line-number");
|
|
446
|
-
if (input["-i"]) args.push("-i");
|
|
447
|
-
if (mode === "content") {
|
|
448
|
-
if (typeof input["-A"] === "number") args.push("-A", String(input["-A"]));
|
|
449
|
-
if (typeof input["-B"] === "number") args.push("-B", String(input["-B"]));
|
|
450
|
-
if (typeof input["-C"] === "number" && typeof input["-A"] !== "number" && typeof input["-B"] !== "number") args.push("-C", String(input["-C"]));
|
|
451
|
-
}
|
|
452
|
-
if (input.multiline) args.push("--multiline", "--multiline-dotall");
|
|
453
|
-
if (input.gitignore === false) args.push("--no-ignore");
|
|
454
|
-
if (input.glob) args.push("--glob", input.glob);
|
|
455
|
-
if (input.type) args.push("--type", input.type);
|
|
456
|
-
args.push("--", input.pattern);
|
|
457
|
-
args.push(input.path ?? ".");
|
|
458
|
-
const command = args.map(shellQuote).join(" ");
|
|
459
|
-
const result = await ctx.execution.exec(ctx.handle, command, { signal: ctx.signal });
|
|
460
|
-
if (result.exitCode !== 0 && result.exitCode !== 1) return `grep error: ${result.stderr.trim() || `rg exited with code ${result.exitCode}`}`;
|
|
461
|
-
return formatPaginated(result.stdout, input);
|
|
462
|
-
}
|
|
463
|
-
async function runFallback(input, ctx) {
|
|
464
|
-
const mode = input.output_mode ?? DEFAULT_OUTPUT_MODE;
|
|
465
|
-
const flags = `${input["-i"] ? "i" : ""}${input.multiline ? "s" : ""}${mode !== "content" ? "" : "g"}`;
|
|
466
|
-
let regex;
|
|
467
|
-
try {
|
|
468
|
-
regex = new RegExp(input.pattern, flags || void 0);
|
|
469
|
-
} catch (err) {
|
|
470
|
-
return `grep error: invalid regex: ${errorMessage(err)}`;
|
|
471
|
-
}
|
|
472
|
-
const files = await enumerateFiles(input, ctx);
|
|
473
|
-
const showLineNumbers = input["-n"] ?? true;
|
|
474
|
-
const before = input["-B"] ?? input["-C"] ?? 0;
|
|
475
|
-
const after = input["-A"] ?? input["-C"] ?? 0;
|
|
476
|
-
const lines = [];
|
|
477
|
-
for (const path of files) {
|
|
478
|
-
let content;
|
|
479
|
-
try {
|
|
480
|
-
content = await ctx.execution.readFile(ctx.handle, path);
|
|
481
|
-
} catch (err) {
|
|
482
|
-
if (isAbortLikeError(err)) throw err;
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
if (input.multiline) {
|
|
486
|
-
const allMatches = [...content.matchAll(new RegExp(regex.source, `${flags.replace(/g/, "")}g`))];
|
|
487
|
-
if (allMatches.length === 0) continue;
|
|
488
|
-
if (mode === "files_with_matches") {
|
|
489
|
-
lines.push(path);
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
492
|
-
if (mode === "count") {
|
|
493
|
-
lines.push(`${path}:${allMatches.length}`);
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
for (const m of allMatches) {
|
|
497
|
-
const lineStart = content.lastIndexOf("\n", m.index - 1) + 1;
|
|
498
|
-
const lineEnd = content.indexOf("\n", m.index);
|
|
499
|
-
const snippet = content.slice(lineStart, lineEnd === -1 ? void 0 : lineEnd);
|
|
500
|
-
const lineNo = content.slice(0, m.index).split("\n").length;
|
|
501
|
-
lines.push(formatContentLine(path, lineNo, snippet, showLineNumbers));
|
|
502
|
-
}
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
const fileLines = content.split("\n");
|
|
506
|
-
const matched = [];
|
|
507
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
508
|
-
regex.lastIndex = 0;
|
|
509
|
-
if (regex.test(fileLines[i])) matched.push(i);
|
|
510
|
-
}
|
|
511
|
-
if (matched.length === 0) continue;
|
|
512
|
-
if (mode === "files_with_matches") {
|
|
513
|
-
lines.push(path);
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
if (mode === "count") {
|
|
517
|
-
lines.push(`${path}:${matched.length}`);
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
const includeLineNos = /* @__PURE__ */ new Set();
|
|
521
|
-
for (const m of matched) for (let i = Math.max(0, m - before); i <= Math.min(fileLines.length - 1, m + after); i++) includeLineNos.add(i);
|
|
522
|
-
const sorted = [...includeLineNos].sort((a, b) => a - b);
|
|
523
|
-
let prev = -2;
|
|
524
|
-
for (const lineNo of sorted) {
|
|
525
|
-
if (lineNo > prev + 1 && lines.length > 0) lines.push("--");
|
|
526
|
-
const snippet = fileLines[lineNo];
|
|
527
|
-
lines.push(formatContentLine(path, lineNo + 1, snippet, showLineNumbers));
|
|
528
|
-
prev = lineNo;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return formatPaginated(lines.join("\n"), input);
|
|
532
|
-
}
|
|
533
|
-
function formatContentLine(path, lineNo, snippet, showLineNumbers) {
|
|
534
|
-
return showLineNumbers ? `${path}:${lineNo}:${snippet}` : `${path}:${snippet}`;
|
|
535
|
-
}
|
|
536
|
-
async function enumerateFiles(input, ctx) {
|
|
537
|
-
const root = input.path ?? ".";
|
|
538
|
-
if (input.path && !input.path.includes("*") && !input.path.includes("?")) {
|
|
539
|
-
if ((await ctx.execution.exec(ctx.handle, `test -f ${shellQuote(input.path)} && echo file || echo dir`, { signal: ctx.signal })).stdout.trim() === "file") return [input.path];
|
|
540
|
-
}
|
|
541
|
-
const result = await ctx.execution.exec(ctx.handle, buildFindCommand(root, input.glob), { signal: ctx.signal });
|
|
542
|
-
if (result.exitCode !== 0 && !result.stdout.trim()) throw new Error(result.stderr.trim() || `find exited with code ${result.exitCode}`);
|
|
543
|
-
const files = result.stdout.split("\n").map((line) => line.replace(/^\.\//, "")).filter((line) => line.length > 0).sort();
|
|
544
|
-
if (input.gitignore === false) return files;
|
|
545
|
-
return filterByGitignore(files, root, ctx);
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Best-effort intersection of `files` with `git ls-files` (non-ignored set).
|
|
549
|
-
* Returns `files` unchanged when git is unavailable, the dir isn't a repo, or
|
|
550
|
-
* `root` escapes the repo (absolute / `..`) — the filter can only fail open.
|
|
551
|
-
*/
|
|
552
|
-
async function filterByGitignore(files, root, ctx) {
|
|
553
|
-
if (files.length === 0) return files;
|
|
554
|
-
if (root !== "." && (root.startsWith("/") || root.split("/").includes(".."))) return files;
|
|
555
|
-
try {
|
|
556
|
-
const res = await ctx.execution.exec(ctx.handle, GIT_LS_FILES_COMMAND, { signal: ctx.signal });
|
|
557
|
-
if (res.exitCode !== 0) return files;
|
|
558
|
-
const allowed = new Set(parseGitLsFiles(res.stdout));
|
|
559
|
-
if (allowed.size === 0) return files;
|
|
560
|
-
return files.filter((file) => allowed.has(file));
|
|
561
|
-
} catch {
|
|
562
|
-
return files;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Build a `find` command listing files under `root`, optionally filtered by
|
|
567
|
-
* `glob`. Mirrors `glob`'s shell fallback:
|
|
568
|
-
* - a bare basename, or one behind a leading recursive-wildcard segment,
|
|
569
|
-
* with no inner slash → `-name` (matches at any depth, including the top
|
|
570
|
-
* level — preserving the recursive-glob semantics).
|
|
571
|
-
* - anything with a slash → `-path` against the full relative path. `find`'s
|
|
572
|
-
* `*` spans `/`, so the single- and double-star forms collapse to the
|
|
573
|
-
* same matcher; close enough for the no-`rg` fallback.
|
|
574
|
-
*/
|
|
575
|
-
function buildFindCommand(root, glob) {
|
|
576
|
-
const findRoot = normalizeFindRoot(root);
|
|
577
|
-
const base = shellQuote(findRoot);
|
|
578
|
-
if (!glob) return `find ${base} -type f`;
|
|
579
|
-
const anyDepth = glob.match(/^(?:\*\*\/)?([^/]+)$/);
|
|
580
|
-
if (anyDepth) return `find ${base} -type f -name ${shellQuote(anyDepth[1])}`;
|
|
581
|
-
const normRoot = findRoot.replace(/\/$/, "");
|
|
582
|
-
return `find ${base} -type f -path ${shellQuote(root === "." ? `./${glob}` : `${normRoot}/${glob}`)}`;
|
|
583
|
-
}
|
|
584
|
-
function normalizeFindRoot(root) {
|
|
585
|
-
return root.startsWith("-") ? `./${root}` : root;
|
|
586
|
-
}
|
|
587
|
-
function formatPaginated(text, input) {
|
|
588
|
-
const headLimit = typeof input.head_limit === "number" && input.head_limit >= 0 ? input.head_limit : DEFAULT_HEAD_LIMIT;
|
|
589
|
-
const offset = typeof input.offset === "number" && input.offset > 0 ? Math.floor(input.offset) : 0;
|
|
590
|
-
if (!text.trim()) return "(no matches)";
|
|
591
|
-
const lines = text.split("\n").filter((l) => l.length > 0);
|
|
592
|
-
const total = lines.length;
|
|
593
|
-
const sliced = headLimit === 0 ? lines.slice(offset) : lines.slice(offset, offset + headLimit);
|
|
594
|
-
if (sliced.length === 0) return "(no matches in this slice)";
|
|
595
|
-
const capped = [];
|
|
596
|
-
let bytesUsed = 0;
|
|
597
|
-
let byteCut = false;
|
|
598
|
-
for (const line of sliced) {
|
|
599
|
-
const clipped = clipLine(line);
|
|
600
|
-
const lineBytes = Buffer.byteLength(clipped) + 1;
|
|
601
|
-
if (bytesUsed + lineBytes > OUTPUT_BYTE_CAP && capped.length > 0) {
|
|
602
|
-
byteCut = true;
|
|
603
|
-
break;
|
|
604
|
-
}
|
|
605
|
-
capped.push(clipped);
|
|
606
|
-
bytesUsed += lineBytes;
|
|
607
|
-
}
|
|
608
|
-
const shown = capped.length;
|
|
609
|
-
const truncatedHead = offset > 0;
|
|
610
|
-
const lineCutTail = headLimit > 0 && offset + headLimit < total;
|
|
611
|
-
let out = capped.join("\n");
|
|
612
|
-
if (truncatedHead) out = `…(${offset} earlier lines skipped)…\n${out}`;
|
|
613
|
-
if (byteCut) out = `${out}\n…(output truncated at ${OUTPUT_BYTE_CAP} bytes after ${shown} of ${total - offset} matching lines; narrow the pattern or page with offset=${offset + shown})`;
|
|
614
|
-
else if (lineCutTail) out = `${out}\n…(${total - offset - headLimit} more lines; re-run with offset=${offset + headLimit} or larger head_limit)`;
|
|
615
|
-
return out;
|
|
616
|
-
}
|
|
617
|
-
/**
|
|
618
|
-
* Clip one output line to `MAX_LINE_BYTES` at a UTF-8-safe boundary, appending
|
|
619
|
-
* a marker that reports how many bytes were withheld. Lines within budget pass
|
|
620
|
-
* through untouched.
|
|
621
|
-
*/
|
|
622
|
-
function clipLine(line) {
|
|
623
|
-
if (Buffer.byteLength(line) <= MAX_LINE_BYTES) return line;
|
|
624
|
-
let cut = Math.min(line.length, MAX_LINE_BYTES);
|
|
625
|
-
while (cut > 0 && Buffer.byteLength(line.slice(0, cut)) > MAX_LINE_BYTES) cut--;
|
|
626
|
-
const omitted = Buffer.byteLength(line) - Buffer.byteLength(line.slice(0, cut));
|
|
627
|
-
return `${line.slice(0, cut)}… [line clipped, ${omitted} more bytes]`;
|
|
628
|
-
}
|
|
629
|
-
//#endregion
|
|
630
|
-
//#region src/tools/interaction.ts
|
|
631
|
-
function abortReason(signal) {
|
|
632
|
-
return typeof signal.reason === "string" && signal.reason.length > 0 ? signal.reason : "Interaction aborted";
|
|
633
|
-
}
|
|
634
|
-
/**
|
|
635
|
-
* Create an interaction tool that lets the agent request structured input.
|
|
636
|
-
*
|
|
637
|
-
* The model calls this tool with a payload matching the schema.
|
|
638
|
-
* `onRequest` is called with the payload and should return the response
|
|
639
|
-
* (string or object) that gets sent back to the model as the tool result.
|
|
640
|
-
*/
|
|
641
|
-
function createInteractionTool(options) {
|
|
642
|
-
return {
|
|
643
|
-
spec: {
|
|
644
|
-
name: options.name ?? "interaction",
|
|
645
|
-
description: options.description ?? "Request structured input from the user or external system.",
|
|
646
|
-
inputSchema: options.schema
|
|
647
|
-
},
|
|
648
|
-
async execute(input, ctx) {
|
|
649
|
-
const { signal } = ctx;
|
|
650
|
-
if (signal?.aborted) throw new Error(abortReason(signal));
|
|
651
|
-
let onAbort;
|
|
652
|
-
const abortPromise = signal ? new Promise((_, reject) => {
|
|
653
|
-
onAbort = () => reject(new Error(abortReason(signal)));
|
|
654
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
655
|
-
}) : void 0;
|
|
656
|
-
try {
|
|
657
|
-
const result = abortPromise ? await Promise.race([options.onRequest(input, ctx), abortPromise]) : await options.onRequest(input, ctx);
|
|
658
|
-
return typeof result === "string" ? result : JSON.stringify(result);
|
|
659
|
-
} finally {
|
|
660
|
-
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
//#endregion
|
|
666
|
-
//#region src/tools/list-files.ts
|
|
667
|
-
function entryPath(entry) {
|
|
668
|
-
return typeof entry === "string" ? entry : entry.path;
|
|
669
|
-
}
|
|
670
|
-
const listFiles = {
|
|
671
|
-
isConcurrencySafe: true,
|
|
672
|
-
spec: {
|
|
673
|
-
name: "list_files",
|
|
674
|
-
description: "List the immediate entries (files and subdirectories) at literal directory `path`. Returns a newline-separated list, or `(empty directory)` when the directory exists but is empty. Returns `Directory not found: <path>` for a missing path. Non-recursive: use `glob` for pattern matching across nested directories.",
|
|
675
|
-
inputSchema: {
|
|
676
|
-
type: "object",
|
|
677
|
-
properties: { path: {
|
|
678
|
-
type: "string",
|
|
679
|
-
description: "Directory path (relative to the execution-context cwd, or absolute). Defaults to `.`."
|
|
680
|
-
} },
|
|
681
|
-
required: []
|
|
682
|
-
}
|
|
683
|
-
},
|
|
684
|
-
async execute({ path }, ctx) {
|
|
685
|
-
try {
|
|
686
|
-
return (await ctx.execution.listFiles(ctx.handle, path || ".")).map(entryPath).join("\n") || "(empty directory)";
|
|
687
|
-
} catch {
|
|
688
|
-
return `Directory not found: ${path}`;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
};
|
|
692
|
-
//#endregion
|
|
693
|
-
//#region src/tools/multi-edit.ts
|
|
694
|
-
/**
|
|
695
|
-
* Inline annotation builder — kept local to avoid importing from
|
|
696
|
-
* `chat/edit-approval.ts` (that's a renderer-side module; tools live
|
|
697
|
-
* one layer below). Line shape matches `parseEditOutcomesFromResult`'s
|
|
698
|
-
* regex so the round-trip is lossless.
|
|
699
|
-
*
|
|
700
|
-
* Newlines in `reason` are folded to spaces because the parser is line-
|
|
701
|
-
* scoped (`body.split('\n')`); a multi-line reason would split into a
|
|
702
|
-
* "trailing prose" line and trip the malformed-block guard, losing every
|
|
703
|
-
* outcome below it. Static reasons in this file are single-line; the
|
|
704
|
-
* sanitize is a guard against a pathological `target` (file path
|
|
705
|
-
* containing a newline) leaking into `old_string not found in <target>`.
|
|
706
|
-
*/
|
|
707
|
-
function annotationFor(outcomes) {
|
|
708
|
-
const lines = ["<edit-outcomes>"];
|
|
709
|
-
for (let i = 0; i < outcomes.length; i++) {
|
|
710
|
-
const o = outcomes[i];
|
|
711
|
-
const reason = o.reason ? `: ${o.reason.replace(/\r?\n/g, " ")}` : "";
|
|
712
|
-
lines.push(`#${i + 1} ${o.kind}${reason}`);
|
|
713
|
-
}
|
|
714
|
-
lines.push("</edit-outcomes>");
|
|
715
|
-
return lines.join("\n");
|
|
716
|
-
}
|
|
717
|
-
const multiEdit = {
|
|
718
|
-
spec: {
|
|
719
|
-
name: "multi_edit",
|
|
720
|
-
description: "Apply a sequential list of edits to a file. Each edit operates on the result of the previous APPLIED edit. Prefer this over multiple `edit` calls when several non-overlapping changes are needed in the same file. Edits run **best-effort**: a per-step failure (`old_string` not found, ambiguous match without `replace_all`, identical strings) is reported in the result but does NOT block the remaining steps. The file is written iff at least one step applied. The result lists per-hunk outcomes (`applied` / `failed`) so the model can re-issue just the failures without resending the whole batch. Each step tolerates `read_file` line-number prefixes (`<N>\\t…`, `<N>|…`, or `<N>→…`) in `old_string` / `new_string`.",
|
|
721
|
-
inputSchema: {
|
|
722
|
-
type: "object",
|
|
723
|
-
properties: {
|
|
724
|
-
path: {
|
|
725
|
-
type: "string",
|
|
726
|
-
description: "File path (relative to the execution-context cwd, or absolute)."
|
|
727
|
-
},
|
|
728
|
-
edits: {
|
|
729
|
-
type: "array",
|
|
730
|
-
description: "List of edits applied in order; each operates on the previous applied edit's output.",
|
|
731
|
-
items: {
|
|
732
|
-
type: "object",
|
|
733
|
-
properties: {
|
|
734
|
-
old_string: { type: "string" },
|
|
735
|
-
new_string: { type: "string" },
|
|
736
|
-
replace_all: { type: "boolean" }
|
|
737
|
-
},
|
|
738
|
-
required: ["old_string", "new_string"]
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
},
|
|
742
|
-
required: ["path", "edits"]
|
|
743
|
-
}
|
|
744
|
-
},
|
|
745
|
-
async execute({ path, edits }, ctx) {
|
|
746
|
-
const target = path;
|
|
747
|
-
const steps = edits;
|
|
748
|
-
const pathErr = pathArgError(target);
|
|
749
|
-
if (pathErr) {
|
|
750
|
-
ctx.reportOutcome?.("failed");
|
|
751
|
-
return `multi_edit error: ${pathErr}`;
|
|
752
|
-
}
|
|
753
|
-
if (!Array.isArray(steps) || steps.length === 0) {
|
|
754
|
-
ctx.reportOutcome?.("failed");
|
|
755
|
-
return `multi_edit error: edits must be a non-empty array.`;
|
|
756
|
-
}
|
|
757
|
-
let current;
|
|
758
|
-
try {
|
|
759
|
-
current = await ctx.execution.readFile(ctx.handle, target);
|
|
760
|
-
} catch {
|
|
761
|
-
const hint = await suggestionFor(ctx.execution, ctx.handle, target);
|
|
762
|
-
ctx.reportOutcome?.("failed");
|
|
763
|
-
return `multi_edit error: file not found: ${target}.${hint}`;
|
|
764
|
-
}
|
|
765
|
-
if (ctx.behavior?.requireReadBeforeEdit) {
|
|
766
|
-
const readState = resolveReadStateMap(ctx);
|
|
767
|
-
if (readState) {
|
|
768
|
-
const absKey = readStateKey(ctx.handle.cwd, target);
|
|
769
|
-
const prior = readState.get(absKey);
|
|
770
|
-
if (!prior) {
|
|
771
|
-
ctx.reportOutcome?.("failed");
|
|
772
|
-
return `multi_edit error: ${target} has not been read in this session. Call read_file first so the edits apply against the current contents.`;
|
|
773
|
-
}
|
|
774
|
-
if (prior.contentHash !== hashContent(current)) {
|
|
775
|
-
ctx.reportOutcome?.("failed");
|
|
776
|
-
return `multi_edit error: ${target} has changed on disk since the last read. Re-read the file before editing.`;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
const outcomes = [];
|
|
781
|
-
const viaNotes = [];
|
|
782
|
-
let totalReplacements = 0;
|
|
783
|
-
for (let i = 0; i < steps.length; i++) {
|
|
784
|
-
const step = steps[i];
|
|
785
|
-
const find = step.old_string;
|
|
786
|
-
const replacement = step.new_string;
|
|
787
|
-
const replaceAll = step.replace_all === true;
|
|
788
|
-
if (typeof find !== "string" || typeof replacement !== "string") {
|
|
789
|
-
outcomes.push({
|
|
790
|
-
kind: "failed",
|
|
791
|
-
reason: "missing old_string or new_string"
|
|
792
|
-
});
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
if (find.length === 0) {
|
|
796
|
-
outcomes.push({
|
|
797
|
-
kind: "failed",
|
|
798
|
-
reason: "empty old_string (use write_file to fully replace a file)"
|
|
799
|
-
});
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
if (find === replacement) {
|
|
803
|
-
outcomes.push({
|
|
804
|
-
kind: "failed",
|
|
805
|
-
reason: "old_string and new_string are identical"
|
|
806
|
-
});
|
|
807
|
-
continue;
|
|
808
|
-
}
|
|
809
|
-
const match = resolveOldString(current, find);
|
|
810
|
-
if (!match) {
|
|
811
|
-
outcomes.push({
|
|
812
|
-
kind: "failed",
|
|
813
|
-
reason: `old_string not found in ${target}`
|
|
814
|
-
});
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
const { actual, occurrences, via } = match;
|
|
818
|
-
if (occurrences > 1 && !replaceAll) {
|
|
819
|
-
outcomes.push({
|
|
820
|
-
kind: "failed",
|
|
821
|
-
reason: `old_string appears ${occurrences} times — pass replace_all=true on this edit or expand old_string for uniqueness`
|
|
822
|
-
});
|
|
823
|
-
continue;
|
|
824
|
-
}
|
|
825
|
-
const styledReplacement = styleReplacementForVia(replacement, via, actual);
|
|
826
|
-
current = replaceAll ? current.split(actual).join(styledReplacement) : current.replace(actual, styledReplacement);
|
|
827
|
-
totalReplacements += occurrences;
|
|
828
|
-
if (via !== "exact") viaNotes.push(`edit #${i + 1}: old_string was not found verbatim; matched via ${describeVia(via)}`);
|
|
829
|
-
outcomes.push({ kind: "applied" });
|
|
830
|
-
}
|
|
831
|
-
const appliedCount = outcomes.reduce((n, o) => o.kind === "applied" ? n + 1 : n, 0);
|
|
832
|
-
const failedCount = outcomes.length - appliedCount;
|
|
833
|
-
if (appliedCount > 0) {
|
|
834
|
-
await ctx.execution.writeFile(ctx.handle, target, current);
|
|
835
|
-
ctx.reportOutcome?.("edited");
|
|
836
|
-
const readState = resolveReadStateMap(ctx);
|
|
837
|
-
if (readState) {
|
|
838
|
-
const absKey = readStateKey(ctx.handle.cwd, target);
|
|
839
|
-
const prior = readState.get(absKey);
|
|
840
|
-
if (prior) readState.set(absKey, {
|
|
841
|
-
...prior,
|
|
842
|
-
contentHash: hashContent(current),
|
|
843
|
-
mtimeMs: Date.now()
|
|
844
|
-
});
|
|
845
|
-
}
|
|
846
|
-
} else ctx.reportOutcome?.("failed");
|
|
847
|
-
const n = steps.length;
|
|
848
|
-
let header;
|
|
849
|
-
if (appliedCount === n) header = `Edited ${target}: applied ${n} edit${n === 1 ? "" : "s"} (${totalReplacements} replacement${totalReplacements === 1 ? "" : "s"}).`;
|
|
850
|
-
else if (appliedCount > 0) header = `Edited ${target}: applied ${appliedCount} of ${n} edits (${totalReplacements} replacement${totalReplacements === 1 ? "" : "s"}).`;
|
|
851
|
-
else header = `multi_edit error: no edits applied to ${target} (${n} attempted).`;
|
|
852
|
-
const failureLines = [];
|
|
853
|
-
for (let i = 0; i < outcomes.length; i++) {
|
|
854
|
-
const o = outcomes[i];
|
|
855
|
-
if (o.kind === "failed") failureLines.push(`edit #${i + 1} failed: ${o.reason}`);
|
|
856
|
-
}
|
|
857
|
-
const parts = [header];
|
|
858
|
-
if (viaNotes.length > 0) parts.push(viaNotes.join("\n"));
|
|
859
|
-
if (failureLines.length > 0) parts.push(failureLines.join("\n"));
|
|
860
|
-
if (failedCount > 0) parts.push(annotationFor(outcomes));
|
|
861
|
-
return parts.join("\n\n");
|
|
862
|
-
}
|
|
863
|
-
};
|
|
864
|
-
//#endregion
|
|
865
|
-
//#region src/tools/binary-read.ts
|
|
866
|
-
/**
|
|
867
|
-
* Best-effort guess at IANA media type from a file extension. Covers the
|
|
868
|
-
* extensions Zidane's `read_file` actually dispatches to image blocks
|
|
869
|
-
* (png/jpg/jpeg/gif/webp). Other extensions return `undefined` and the
|
|
870
|
-
* caller short-circuits the binary route.
|
|
871
|
-
*/
|
|
872
|
-
function imageMediaTypeFor(path) {
|
|
873
|
-
const dot = path.lastIndexOf(".");
|
|
874
|
-
if (dot === -1) return void 0;
|
|
875
|
-
switch (path.slice(dot + 1).toLowerCase()) {
|
|
876
|
-
case "png": return "image/png";
|
|
877
|
-
case "jpg":
|
|
878
|
-
case "jpeg": return "image/jpeg";
|
|
879
|
-
case "gif": return "image/gif";
|
|
880
|
-
case "webp": return "image/webp";
|
|
881
|
-
default: return;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
/**
|
|
885
|
-
* Read a file as base64. Prefers `ExecutionContext.readFileBinary` (zero
|
|
886
|
-
* subprocess overhead in-process) and falls back to `base64 < path` via
|
|
887
|
-
* the shell seam — works on docker / sandbox without an interface change.
|
|
888
|
-
*
|
|
889
|
-
* Returns `{ base64, byteLength }`. `byteLength` is the *decoded* byte count
|
|
890
|
-
* so callers can size-budget against the original file, not the inflated
|
|
891
|
-
* base64 representation (which is ~4/3× larger).
|
|
892
|
-
*/
|
|
893
|
-
async function readFileAsBase64(execution, handle, path) {
|
|
894
|
-
if (execution.readFileBinary) {
|
|
895
|
-
const bytes = await execution.readFileBinary(handle, path);
|
|
896
|
-
return {
|
|
897
|
-
base64: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"),
|
|
898
|
-
byteLength: bytes.byteLength
|
|
899
|
-
};
|
|
900
|
-
}
|
|
901
|
-
const cmd = `base64 < ${alwaysQuote(path)}`;
|
|
902
|
-
const result = await execution.exec(handle, cmd);
|
|
903
|
-
if (result.exitCode !== 0) throw new Error(`base64 read failed: ${result.stderr || `exit ${result.exitCode}`}`);
|
|
904
|
-
const b64 = result.stdout.replace(/\s+/g, "");
|
|
905
|
-
return {
|
|
906
|
-
base64: b64,
|
|
907
|
-
byteLength: decodedBase64ByteLength(b64)
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
/**
|
|
911
|
-
* Decoded byte length of a (whitespace-stripped) base64 string. Accounts for
|
|
912
|
-
* `=` padding so the value matches the original file size to the byte —
|
|
913
|
-
* `Math.floor(len * 3 / 4)` over-reports by 1–2 bytes on padded payloads.
|
|
914
|
-
*/
|
|
915
|
-
function decodedBase64ByteLength(b64) {
|
|
916
|
-
if (b64.length === 0) return 0;
|
|
917
|
-
let pad = 0;
|
|
918
|
-
if (b64.endsWith("==")) pad = 2;
|
|
919
|
-
else if (b64.endsWith("=")) pad = 1;
|
|
920
|
-
return Math.max(0, b64.length * 3 / 4 - pad);
|
|
921
|
-
}
|
|
922
|
-
//#endregion
|
|
923
|
-
//#region src/tools/read-file.ts
|
|
924
|
-
/**
|
|
925
|
-
* Read a file with line-based offset/limit and a hard byte cap.
|
|
926
|
-
*
|
|
927
|
-
* Defaults are tuned for source code: 2000 lines / 256 KiB. A typical source
|
|
928
|
-
* file, lockfile, or large config fits in one read; logs and very large
|
|
929
|
-
* fixtures get truncated with a footer that documents how to fetch the
|
|
930
|
-
* remainder.
|
|
931
|
-
*
|
|
932
|
-
* Binary files are detected on the leading bytes — if the buffer contains a
|
|
933
|
-
* NUL or has an unreasonable proportion of non-printable bytes, we skip text
|
|
934
|
-
* decoding and return a marker so the model doesn't drown in mojibake.
|
|
935
|
-
*/
|
|
936
|
-
const DEFAULT_LINE_LIMIT = 2e3;
|
|
937
|
-
const DEFAULT_BYTE_CAP = 262144;
|
|
938
|
-
/**
|
|
939
|
-
* Hard upper bound on raw attachment bytes we'll inline as a base64 block.
|
|
940
|
-
* Above this, we return a marker instead — the model won't get useful
|
|
941
|
-
* information from a 10 MB+ screenshot rendered as one tool result, and
|
|
942
|
-
* the wire bill gets ugly. Override via the `maxBytes` parameter on the
|
|
943
|
-
* tool call.
|
|
944
|
-
*/
|
|
945
|
-
const DEFAULT_ATTACHMENT_BYTE_CAP = 5 * 1024 * 1024;
|
|
946
|
-
function attachmentByteLength(data, encoding) {
|
|
947
|
-
if (encoding === "text") return Buffer.byteLength(data);
|
|
948
|
-
if (data.length === 0) return 0;
|
|
949
|
-
const pad = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
950
|
-
return Math.max(0, Math.floor(data.length * 3 / 4) - pad);
|
|
951
|
-
}
|
|
952
|
-
const readFile = {
|
|
953
|
-
isConcurrencySafe: true,
|
|
954
|
-
spec: {
|
|
955
|
-
name: "read_file",
|
|
956
|
-
description: "Read a file by path. Text files return lines [offset..offset+limit). Default offset=1, limit=2000. Each line is prefixed with its 1-indexed line number followed by a tab (e.g. `42\\tconst foo = bar`); the prefix is metadata, not part of the file. Mirrors Claude Code's `cat -n`-style compact output for token efficiency. A trailing footer explains how to read the rest when truncated. Images (png/jpg/gif/webp) and PDFs return structured attachments for capable models; other binary files return a short marker rather than mojibake.",
|
|
957
|
-
inputSchema: {
|
|
958
|
-
type: "object",
|
|
959
|
-
properties: {
|
|
960
|
-
path: {
|
|
961
|
-
type: "string",
|
|
962
|
-
description: "File path (relative to the execution-context cwd, or absolute)."
|
|
963
|
-
},
|
|
964
|
-
offset: {
|
|
965
|
-
type: "integer",
|
|
966
|
-
description: "1-indexed line number to start from. Default: 1."
|
|
967
|
-
},
|
|
968
|
-
limit: {
|
|
969
|
-
type: "integer",
|
|
970
|
-
description: "Max lines to return. Default: 2000. Set 0 for unlimited."
|
|
971
|
-
},
|
|
972
|
-
maxBytes: {
|
|
973
|
-
type: "integer",
|
|
974
|
-
description: "Hard byte cap on file content read, regardless of line count. Default: 262144 for text, 5242880 for image/PDF attachments. Set 0 for unlimited. The rendered output may be slightly larger than this cap when `lineNumbers` is on (each line carries a `<N>\\t` prefix)."
|
|
975
|
-
},
|
|
976
|
-
lineNumbers: {
|
|
977
|
-
type: "boolean",
|
|
978
|
-
description: "Prefix each line with its 1-indexed line number. Default: true. Override the agent-wide `behavior.readLineNumbers` for this call."
|
|
979
|
-
}
|
|
980
|
-
},
|
|
981
|
-
required: ["path"]
|
|
982
|
-
}
|
|
983
|
-
},
|
|
984
|
-
async execute({ path, offset, limit, maxBytes, lineNumbers }, ctx) {
|
|
985
|
-
const defaults = resolveReadFileDefaults(ctx.behavior, {
|
|
986
|
-
lineLimit: DEFAULT_LINE_LIMIT,
|
|
987
|
-
textMaxBytes: DEFAULT_BYTE_CAP,
|
|
988
|
-
attachmentMaxBytes: DEFAULT_ATTACHMENT_BYTE_CAP
|
|
989
|
-
});
|
|
990
|
-
const handleHash = typeof path === "string" ? parseAttachmentHandle(path) : null;
|
|
991
|
-
if (handleHash) {
|
|
992
|
-
const turns = ctx.session?.turns;
|
|
993
|
-
if (!turns) return `Attachment ${path} cannot be resolved: no session is bound to this run.`;
|
|
994
|
-
const found = resolveAttachmentFromTurns(turns, handleHash);
|
|
995
|
-
if (!found) return `Attachment ${path} not found in this session's history (it may predate this session, or only ever lived as a host-offloaded reference).`;
|
|
996
|
-
const sizeCap = normalizeReadFileInteger(maxBytes, defaults.attachmentMaxBytes);
|
|
997
|
-
const byteLength = attachmentByteLength(found.data, found.encoding);
|
|
998
|
-
if (sizeCap > 0 && byteLength > sizeCap) return `[attachment too large to re-inline: ${path}, ${byteLength} bytes (cap ${sizeCap}). Raise maxBytes if you need the original bytes.]`;
|
|
999
|
-
return [{
|
|
1000
|
-
type: "text",
|
|
1001
|
-
text: `${found.kind === "image" ? "Image" : "Document"} re-resolved from session history (${found.mediaType}${found.name ? `, "${found.name}"` : ""}).`
|
|
1002
|
-
}, found.kind === "image" ? {
|
|
1003
|
-
type: "image",
|
|
1004
|
-
mediaType: found.mediaType,
|
|
1005
|
-
data: found.data,
|
|
1006
|
-
...found.name ? { name: found.name } : {}
|
|
1007
|
-
} : {
|
|
1008
|
-
type: "document",
|
|
1009
|
-
mediaType: found.mediaType,
|
|
1010
|
-
data: found.data,
|
|
1011
|
-
encoding: found.encoding ?? "base64",
|
|
1012
|
-
...found.name ? { name: found.name } : {}
|
|
1013
|
-
}];
|
|
1014
|
-
}
|
|
1015
|
-
const pathErr = pathArgError(path);
|
|
1016
|
-
if (pathErr) return `Read error: ${pathErr}`;
|
|
1017
|
-
const extMedia = imageMediaTypeFor(path);
|
|
1018
|
-
if (extMedia) {
|
|
1019
|
-
const sizeCap = normalizeReadFileInteger(maxBytes, defaults.attachmentMaxBytes);
|
|
1020
|
-
try {
|
|
1021
|
-
const { base64, byteLength } = await readFileAsBase64(ctx.execution, ctx.handle, path);
|
|
1022
|
-
if (sizeCap > 0 && byteLength > sizeCap) return `[image too large to inline: ${path}, ${byteLength} bytes (cap ${sizeCap}). Raise maxBytes, or use shell to inspect.]`;
|
|
1023
|
-
const imgMedia = reconcileImageMediaType(extMedia, base64);
|
|
1024
|
-
return [{
|
|
1025
|
-
type: "text",
|
|
1026
|
-
text: `Image: ${path} (${byteLength} bytes, ${imgMedia})`
|
|
1027
|
-
}, {
|
|
1028
|
-
type: "image",
|
|
1029
|
-
mediaType: imgMedia,
|
|
1030
|
-
data: base64
|
|
1031
|
-
}];
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
const hint = await suggestionFor(ctx.execution, ctx.handle, path);
|
|
1034
|
-
return `Image read failed: ${path} — ${errorMessage(err)}.${hint}`;
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
const docMedia = documentMediaTypeFor(path);
|
|
1038
|
-
if (docMedia) {
|
|
1039
|
-
const sizeCap = normalizeReadFileInteger(maxBytes, defaults.attachmentMaxBytes);
|
|
1040
|
-
try {
|
|
1041
|
-
const { base64, byteLength } = await readFileAsBase64(ctx.execution, ctx.handle, path);
|
|
1042
|
-
if (sizeCap > 0 && byteLength > sizeCap) return `[document too large to attach: ${path}, ${byteLength} bytes (cap ${sizeCap}). Raise maxBytes, or use shell/read text extraction to inspect.]`;
|
|
1043
|
-
if (docMedia === "application/pdf" && !isPdfBase64(base64)) return `[binary file: ${path}, ${byteLength} bytes; extension suggests PDF but file header does not start with %PDF-]`;
|
|
1044
|
-
return [{
|
|
1045
|
-
type: "text",
|
|
1046
|
-
text: `Document: ${path} (${byteLength} bytes, ${docMedia})`
|
|
1047
|
-
}, {
|
|
1048
|
-
type: "document",
|
|
1049
|
-
mediaType: docMedia,
|
|
1050
|
-
data: base64,
|
|
1051
|
-
encoding: "base64",
|
|
1052
|
-
name: fileNameForPath(path)
|
|
1053
|
-
}];
|
|
1054
|
-
} catch (err) {
|
|
1055
|
-
const hint = await suggestionFor(ctx.execution, ctx.handle, path);
|
|
1056
|
-
return `Document read failed: ${path} — ${errorMessage(err)}.${hint}`;
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
let raw;
|
|
1060
|
-
try {
|
|
1061
|
-
raw = await ctx.execution.readFile(ctx.handle, path);
|
|
1062
|
-
} catch {
|
|
1063
|
-
return `File not found: ${path}.${await suggestionFor(ctx.execution, ctx.handle, path)}`;
|
|
1064
|
-
}
|
|
1065
|
-
const totalBytes = Buffer.byteLength(raw);
|
|
1066
|
-
const dedupEnabled = ctx.behavior?.dedupReads !== false;
|
|
1067
|
-
const gateEnabled = ctx.behavior?.requireReadBeforeEdit === true;
|
|
1068
|
-
const readState = dedupEnabled || gateEnabled ? resolveReadStateMap(ctx) : void 0;
|
|
1069
|
-
const absKey = readStateKey(ctx.handle.cwd, path);
|
|
1070
|
-
const offsetForKey = normalizeReadFileInteger(offset, 1);
|
|
1071
|
-
const limitForKey = normalizeReadFileInteger(limit, defaults.lineLimit);
|
|
1072
|
-
const maxBytesForKey = normalizeReadFileInteger(maxBytes, defaults.textMaxBytes);
|
|
1073
|
-
const showLineNumbers = typeof lineNumbers === "boolean" ? lineNumbers : ctx.behavior?.readLineNumbers ?? true;
|
|
1074
|
-
const currentHash = readState ? hashContent(raw) : "";
|
|
1075
|
-
const rememberRead = () => {
|
|
1076
|
-
if (!readState) return;
|
|
1077
|
-
readState.set(absKey, {
|
|
1078
|
-
contentHash: currentHash,
|
|
1079
|
-
offset: offsetForKey,
|
|
1080
|
-
limit: limitForKey,
|
|
1081
|
-
maxBytes: maxBytesForKey,
|
|
1082
|
-
lineNumbers: showLineNumbers,
|
|
1083
|
-
mtimeMs: Date.now()
|
|
1084
|
-
});
|
|
1085
|
-
};
|
|
1086
|
-
if (dedupEnabled && readState) {
|
|
1087
|
-
const prior = readState.get(absKey);
|
|
1088
|
-
if (prior && prior.contentHash === currentHash && prior.offset === offsetForKey && prior.limit === limitForKey && prior.maxBytes === maxBytesForKey && prior.lineNumbers === showLineNumbers && prior.elided !== true) {
|
|
1089
|
-
rememberRead();
|
|
1090
|
-
return `File ${path} unchanged since the previous read in this session — the prior result above is still current, so this duplicate read was skipped to save tokens.`;
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (looksBinary(raw)) return `[binary file: ${path}, ${totalBytes} bytes; use shell with hexdump | xxd | od to inspect]`;
|
|
1094
|
-
const offsetN = offsetForKey;
|
|
1095
|
-
const limitN = limitForKey;
|
|
1096
|
-
const maxBytesN = maxBytesForKey;
|
|
1097
|
-
const lines = raw.split("\n");
|
|
1098
|
-
const totalLines = lines.length;
|
|
1099
|
-
const startIdx = Math.max(0, offsetN - 1);
|
|
1100
|
-
const endIdx = limitN > 0 ? Math.min(totalLines, startIdx + limitN) : totalLines;
|
|
1101
|
-
let slice = lines.slice(startIdx, endIdx);
|
|
1102
|
-
let bytesCut = false;
|
|
1103
|
-
if (maxBytesN > 0) {
|
|
1104
|
-
const truncatedSlice = [];
|
|
1105
|
-
let bytesUsed = 0;
|
|
1106
|
-
for (const line of slice) {
|
|
1107
|
-
const lineBytes = Buffer.byteLength(line) + 1;
|
|
1108
|
-
if (bytesUsed + lineBytes > maxBytesN && truncatedSlice.length > 0) {
|
|
1109
|
-
bytesCut = true;
|
|
1110
|
-
break;
|
|
1111
|
-
}
|
|
1112
|
-
truncatedSlice.push(line);
|
|
1113
|
-
bytesUsed += lineBytes;
|
|
1114
|
-
if (bytesUsed >= maxBytesN) break;
|
|
1115
|
-
}
|
|
1116
|
-
if (truncatedSlice.length < slice.length) bytesCut = true;
|
|
1117
|
-
slice = truncatedSlice;
|
|
1118
|
-
}
|
|
1119
|
-
let midLineCut = false;
|
|
1120
|
-
if (maxBytesN > 0 && slice.length > 0) {
|
|
1121
|
-
if (Buffer.byteLength(slice.join("\n")) > maxBytesN) {
|
|
1122
|
-
const lastIdx = slice.length - 1;
|
|
1123
|
-
const lastLine = slice[lastIdx];
|
|
1124
|
-
const otherBytes = lastIdx > 0 ? Buffer.byteLength(slice.slice(0, lastIdx).join("\n")) + 1 : 0;
|
|
1125
|
-
const budgetForLast = Math.max(0, maxBytesN - otherBytes);
|
|
1126
|
-
let cut = Math.min(lastLine.length, budgetForLast);
|
|
1127
|
-
while (cut > 0 && Buffer.byteLength(lastLine.slice(0, cut)) > budgetForLast) cut--;
|
|
1128
|
-
slice[lastIdx] = lastLine.slice(0, cut);
|
|
1129
|
-
midLineCut = true;
|
|
1130
|
-
bytesCut = true;
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
const lastLineRead = startIdx + slice.length;
|
|
1134
|
-
const body = showLineNumbers ? slice.map((line, i) => `${startIdx + i + 1}\t${line}`).join("\n") : slice.join("\n");
|
|
1135
|
-
rememberRead();
|
|
1136
|
-
const linesTruncated = endIdx < totalLines || bytesCut;
|
|
1137
|
-
if (!linesTruncated && offsetN === 1) return body;
|
|
1138
|
-
if (!linesTruncated) return `${body}\n\n…read lines ${offsetN}-${lastLineRead} of ${totalLines}.`;
|
|
1139
|
-
if (midLineCut) return `${body}\n\n…truncated mid-line at line ${lastLineRead} (byte cap ${maxBytesN} reached). File has ${totalLines} lines, ${totalBytes} bytes total. Raise maxBytes to read the full line.`;
|
|
1140
|
-
return `${body}\n\n…truncated at line ${lastLineRead} (${bytesCut ? `byte cap (${maxBytesN}) reached` : `line limit (${limitN}) reached`}). File has ${totalLines} lines, ${totalBytes} bytes total — re-read with offset=${lastLineRead + 1} to continue.`;
|
|
1141
|
-
}
|
|
1142
|
-
};
|
|
1143
|
-
function documentMediaTypeFor(path) {
|
|
1144
|
-
const dot = path.lastIndexOf(".");
|
|
1145
|
-
if (dot === -1) return void 0;
|
|
1146
|
-
return path.slice(dot + 1).toLowerCase() === "pdf" ? "application/pdf" : void 0;
|
|
1147
|
-
}
|
|
1148
|
-
function fileNameForPath(path) {
|
|
1149
|
-
const normalized = path.replace(/\\/g, "/");
|
|
1150
|
-
const slash = normalized.lastIndexOf("/");
|
|
1151
|
-
return slash === -1 ? normalized : normalized.slice(slash + 1);
|
|
1152
|
-
}
|
|
1153
|
-
function isPdfBase64(base64) {
|
|
1154
|
-
return Buffer.from(base64.slice(0, 16), "base64").toString("ascii").startsWith("%PDF-");
|
|
1155
|
-
}
|
|
1156
|
-
//#endregion
|
|
1157
|
-
//#region src/tools/shell-kill.ts
|
|
1158
|
-
const shellKill = {
|
|
1159
|
-
isConcurrencySafe: true,
|
|
1160
|
-
spec: {
|
|
1161
|
-
name: "shell_kill",
|
|
1162
|
-
description: [
|
|
1163
|
-
"Terminate a running background task started by `shell({ run_in_background: true })`.",
|
|
1164
|
-
"Sends SIGTERM to the whole process group so the shell wrapper AND its child commands die together.",
|
|
1165
|
-
"Returns the final exit info (status, exit code, output path) or a \"no such task\" message when the id is unknown / already cleaned up.",
|
|
1166
|
-
"Idempotent — calling on a task that has already terminated returns the cached exit info without re-killing."
|
|
1167
|
-
].join("\n"),
|
|
1168
|
-
inputSchema: {
|
|
1169
|
-
type: "object",
|
|
1170
|
-
properties: { task_id: {
|
|
1171
|
-
type: "string",
|
|
1172
|
-
description: "The task id returned by a prior `shell({ run_in_background: true })` call."
|
|
1173
|
-
} },
|
|
1174
|
-
required: ["task_id"],
|
|
1175
|
-
additionalProperties: false
|
|
1176
|
-
}
|
|
1177
|
-
},
|
|
1178
|
-
async execute(input, ctx) {
|
|
1179
|
-
const taskId = input.task_id;
|
|
1180
|
-
if (!ctx.execution.killBackground) return `shell_kill error: the active execution context (${ctx.execution.type}) does not support background tasks.`;
|
|
1181
|
-
const info = await ctx.execution.killBackground(ctx.handle, taskId);
|
|
1182
|
-
if (!info) return `shell_kill: no such task "${taskId}". It may have already exited and been cleaned up, or it was never started in this session.`;
|
|
1183
|
-
return [
|
|
1184
|
-
`Killed ${info.taskId} — ${formatTaskStatus(info)} after ${formatDuration(info.durationMs)}.`,
|
|
1185
|
-
` command: ${previewLine(info.command, 60)}`,
|
|
1186
|
-
` output: ${info.outputPath}`
|
|
1187
|
-
].join("\n");
|
|
1188
|
-
}
|
|
1189
|
-
};
|
|
1190
|
-
//#endregion
|
|
1191
|
-
//#region src/tools/spawn.ts
|
|
1192
|
-
const BUBBLED_EVENTS = [
|
|
1193
|
-
"stream:text",
|
|
1194
|
-
"stream:thinking",
|
|
1195
|
-
"stream:end",
|
|
1196
|
-
"stream:error",
|
|
1197
|
-
"stream:server_tool_use",
|
|
1198
|
-
"stream:server_tool_result",
|
|
1199
|
-
"tool:dispatched",
|
|
1200
|
-
"tool:before",
|
|
1201
|
-
"tool:after",
|
|
1202
|
-
"tool:error",
|
|
1203
|
-
"tool:cancelled",
|
|
1204
|
-
"background:start",
|
|
1205
|
-
"background:exit",
|
|
1206
|
-
"background:reassign",
|
|
1207
|
-
"turn:after"
|
|
1208
|
-
];
|
|
1209
|
-
const BUBBLED_MUTABLE_EVENTS = [
|
|
1210
|
-
"content-ref:resolve",
|
|
1211
|
-
"tool:gate",
|
|
1212
|
-
"mcp:tool:gate",
|
|
1213
|
-
"tool:transform"
|
|
1214
|
-
];
|
|
1215
|
-
const CHILD_EVENT_NAME = {
|
|
1216
|
-
"stream:text": "child:stream:text",
|
|
1217
|
-
"stream:thinking": "child:stream:thinking",
|
|
1218
|
-
"stream:end": "child:stream:end",
|
|
1219
|
-
"stream:error": "child:stream:error",
|
|
1220
|
-
"stream:server_tool_use": "child:stream:server_tool_use",
|
|
1221
|
-
"stream:server_tool_result": "child:stream:server_tool_result",
|
|
1222
|
-
"tool:dispatched": "child:tool:dispatched",
|
|
1223
|
-
"tool:before": "child:tool:before",
|
|
1224
|
-
"tool:after": "child:tool:after",
|
|
1225
|
-
"tool:error": "child:tool:error",
|
|
1226
|
-
"tool:cancelled": "child:tool:cancelled",
|
|
1227
|
-
"background:start": "child:background:start",
|
|
1228
|
-
"background:exit": "child:background:exit",
|
|
1229
|
-
"background:reassign": "child:background:reassign",
|
|
1230
|
-
"turn:after": "child:turn:after"
|
|
1231
|
-
};
|
|
1232
|
-
const CHILD_MUTABLE_EVENT_NAME = {
|
|
1233
|
-
"content-ref:resolve": "content-ref:resolve",
|
|
1234
|
-
"tool:gate": "child:tool:gate",
|
|
1235
|
-
"mcp:tool:gate": "child:mcp:tool:gate",
|
|
1236
|
-
"tool:transform": "child:tool:transform"
|
|
1237
|
-
};
|
|
1238
|
-
const sessionChildCounters = /* @__PURE__ */ new WeakMap();
|
|
1239
|
-
function reserveChildLabel(session) {
|
|
1240
|
-
let counter = sessionChildCounters.get(session);
|
|
1241
|
-
if (counter === void 0) counter = session.runs.filter((r) => (r.depth ?? 0) > 0).length;
|
|
1242
|
-
counter += 1;
|
|
1243
|
-
sessionChildCounters.set(session, counter);
|
|
1244
|
-
return `child-${counter}`;
|
|
1245
|
-
}
|
|
1246
|
-
function extractText(message) {
|
|
1247
|
-
if (!message || typeof message !== "object") return "";
|
|
1248
|
-
const msg = message;
|
|
1249
|
-
if (typeof msg.content === "string") return msg.content;
|
|
1250
|
-
if (Array.isArray(msg.content)) return msg.content.filter((block) => !!block && typeof block === "object" && block.type === "text").map((block) => block.text).join("\n");
|
|
1251
|
-
return "";
|
|
1252
|
-
}
|
|
1253
|
-
/**
|
|
1254
|
-
* Read-only tool whitelist applied when a subagent preset has
|
|
1255
|
-
* `readonly: true` and no explicit `tools` list. Intentionally narrow —
|
|
1256
|
-
* only obviously non-mutating built-ins. Hosts wanting a different
|
|
1257
|
-
* read-only profile (e.g. include `glob` for a code-search agent) should
|
|
1258
|
-
* pass an explicit `tools` array on the subagent def.
|
|
1259
|
-
*/
|
|
1260
|
-
const READONLY_TOOL_DEFAULTS = [
|
|
1261
|
-
"read_file",
|
|
1262
|
-
"grep",
|
|
1263
|
-
"glob",
|
|
1264
|
-
"list_files"
|
|
1265
|
-
];
|
|
1266
|
-
/**
|
|
1267
|
-
* Apply a subagent preset's tool filter to the parent's tool registry.
|
|
1268
|
-
* Always a strict subset of the input — the child agent never gains
|
|
1269
|
-
* tools the parent doesn't have. Names that don't match a parent tool
|
|
1270
|
-
* are silently dropped (matches MCP's lenient `enabledTools` behavior).
|
|
1271
|
-
*
|
|
1272
|
-
* Precedence: `def.tools` (explicit list) > `def.readonly: true`
|
|
1273
|
-
* (built-in read-only set) > unfiltered.
|
|
1274
|
-
*/
|
|
1275
|
-
function filterToolsForSubagent(parentTools, def) {
|
|
1276
|
-
if (!parentTools) return parentTools;
|
|
1277
|
-
const explicit = def.tools;
|
|
1278
|
-
if (explicit && explicit.length > 0) {
|
|
1279
|
-
const wanted = new Set(explicit);
|
|
1280
|
-
const filtered = {};
|
|
1281
|
-
for (const [registryKey, t] of Object.entries(parentTools)) if (wanted.has(t.spec.name)) filtered[registryKey] = t;
|
|
1282
|
-
return filtered;
|
|
1283
|
-
}
|
|
1284
|
-
if (def.readonly) {
|
|
1285
|
-
const wanted = new Set(READONLY_TOOL_DEFAULTS);
|
|
1286
|
-
const filtered = {};
|
|
1287
|
-
for (const [registryKey, t] of Object.entries(parentTools)) if (wanted.has(t.spec.name)) filtered[registryKey] = t;
|
|
1288
|
-
return filtered;
|
|
1289
|
-
}
|
|
1290
|
-
return parentTools;
|
|
1291
|
-
}
|
|
1292
|
-
/**
|
|
1293
|
-
* Render the per-type descriptions into a single schema-field
|
|
1294
|
-
* description string the model reads when choosing a `subagent_type`.
|
|
1295
|
-
* Falls back to a generic line when the host didn't supply any per-type
|
|
1296
|
-
* descriptions.
|
|
1297
|
-
*/
|
|
1298
|
-
function buildSubagentTypeDescription(registry) {
|
|
1299
|
-
const lines = [];
|
|
1300
|
-
let hasAny = false;
|
|
1301
|
-
for (const [key, def] of Object.entries(registry)) if (def.description) {
|
|
1302
|
-
lines.push(`- "${key}": ${def.description}`);
|
|
1303
|
-
hasAny = true;
|
|
1304
|
-
} else lines.push(`- "${key}"`);
|
|
1305
|
-
lines.push("- \"general-purpose\": no specialization; uses the spawn tool's default config.");
|
|
1306
|
-
if (!hasAny) return `Optional subagent preset. One of: ${lines.map((l) => l.replace(/^- /, "").replace(/:.*$/, "")).join(", ")}.`;
|
|
1307
|
-
return `Optional subagent preset that overlays the spawn tool's defaults.\n${lines.join("\n")}`;
|
|
1308
|
-
}
|
|
1309
|
-
/**
|
|
1310
|
-
* Race `task` (an already-running child `agent.run()` promise) against a
|
|
1311
|
-
* timer. Does NOT race against the parent abort signal — the child agent
|
|
1312
|
-
* already observes the same signal internally and handles its own aborted
|
|
1313
|
-
* bookkeeping, so racing here would detach the spawn from the child's
|
|
1314
|
-
* session-persisting finally block.
|
|
1315
|
-
*
|
|
1316
|
-
* On timeout: rejects with `SpawnTimeoutError`; caller is expected to call
|
|
1317
|
-
* `agent.abort()` and subsequently `await` the original `task` so the
|
|
1318
|
-
* child's session state (runs, turns, status) gets flushed before the
|
|
1319
|
-
* parent moves on.
|
|
1320
|
-
*/
|
|
1321
|
-
async function raceWithTimeout(task, timeoutMs) {
|
|
1322
|
-
if (!timeoutMs || timeoutMs <= 0) return task;
|
|
1323
|
-
let timer;
|
|
1324
|
-
try {
|
|
1325
|
-
return await new Promise((resolve, reject) => {
|
|
1326
|
-
timer = setTimeout(() => reject(new SpawnTimeoutError(timeoutMs)), timeoutMs);
|
|
1327
|
-
task.then(resolve, reject);
|
|
1328
|
-
});
|
|
1329
|
-
} finally {
|
|
1330
|
-
if (timer) clearTimeout(timer);
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
var SpawnTimeoutError = class extends Error {
|
|
1334
|
-
timeoutMs;
|
|
1335
|
-
constructor(timeoutMs) {
|
|
1336
|
-
super(`Child agent timed out after ${timeoutMs}ms`);
|
|
1337
|
-
this.name = "SpawnTimeoutError";
|
|
1338
|
-
this.timeoutMs = timeoutMs;
|
|
1339
|
-
}
|
|
1340
|
-
};
|
|
1341
|
-
/**
|
|
1342
|
-
* Wire child's hooks to bubble into `parentHooks` as `child:*` events.
|
|
1343
|
-
*
|
|
1344
|
-
* Three kinds of forwarding:
|
|
1345
|
-
*
|
|
1346
|
-
* 1. **Originating observational events** (`stream:text`, `tool:before`, …)
|
|
1347
|
-
* → rewrite to the matching `child:*` event, inject `{ childId, depth }`,
|
|
1348
|
-
* fire on the parent's hook bus as a **spread copy** (so parent listeners
|
|
1349
|
-
* can't accidentally mutate the child's ctx).
|
|
1350
|
-
* 2. **Originating gate events** (`tool:gate`, `mcp:tool:gate`) → forward
|
|
1351
|
-
* the **same ctx reference**, augmented with `childId` / `depth`, so a
|
|
1352
|
-
* parent listener writing `ctx.block = true` lands on the gate the
|
|
1353
|
-
* child's loop is awaiting on. The bubble itself `await`s the parent's
|
|
1354
|
-
* callHook so any async approval (e.g. a TUI picker) completes before
|
|
1355
|
-
* the child's loop sees the decision.
|
|
1356
|
-
* 3. **Re-bubbled `child:*` events** from a grandchild already carry the
|
|
1357
|
-
* originating `childId` + `depth`. Forward verbatim so a top-level
|
|
1358
|
-
* listener sees true ancestry, not the immediate parent's.
|
|
1359
|
-
*
|
|
1360
|
-
* Returns a function that unregisters every listener registered here.
|
|
1361
|
-
* Called before `agent.run()` starts, torn down in a finally block — so
|
|
1362
|
-
* nothing leaks even if the child throws mid-run.
|
|
1363
|
-
*/
|
|
1364
|
-
/**
|
|
1365
|
-
* Surface a thrown observational-bubble listener without killing the
|
|
1366
|
-
* process. Observational events (`child:stream:text`, `child:tool:after`,
|
|
1367
|
-
* `child:turn:after`, …) are fire-and-forget — a rejected promise here
|
|
1368
|
-
* has no caller. Without this handler, a buggy parent listener bubbles
|
|
1369
|
-
* up to an unhandled-rejection, and Node/Bun under
|
|
1370
|
-
* `--unhandled-rejections=strict` terminates the host.
|
|
1371
|
-
*
|
|
1372
|
-
* Gated on `ZIDANE_DEBUG` so production logs stay quiet; debug builds
|
|
1373
|
-
* still get the tagged line.
|
|
1374
|
-
*/
|
|
1375
|
-
function swallowBubbleError(eventName, err) {
|
|
1376
|
-
if (!process.env.ZIDANE_DEBUG) return;
|
|
1377
|
-
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
1378
|
-
process.stderr.write(`[zidane/spawn] parent listener for "${eventName}" rejected: ${message}\n`);
|
|
1379
|
-
}
|
|
1380
|
-
function bubbleHooks(childHooks, parentHooks, childId, depth) {
|
|
1381
|
-
const unregisters = [];
|
|
1382
|
-
const fire = parentHooks.callHook;
|
|
1383
|
-
for (const evt of BUBBLED_EVENTS) {
|
|
1384
|
-
const parentEvt = CHILD_EVENT_NAME[evt];
|
|
1385
|
-
const unregister = childHooks.hook(evt, (ctx) => {
|
|
1386
|
-
Promise.resolve(fire(parentEvt, {
|
|
1387
|
-
...ctx,
|
|
1388
|
-
childId,
|
|
1389
|
-
depth
|
|
1390
|
-
})).catch((err) => swallowBubbleError(parentEvt, err));
|
|
1391
|
-
});
|
|
1392
|
-
unregisters.push(unregister);
|
|
1393
|
-
}
|
|
1394
|
-
const tagOnCtx = (ctx) => {
|
|
1395
|
-
ctx.childId ??= childId;
|
|
1396
|
-
ctx.depth ??= depth;
|
|
1397
|
-
};
|
|
1398
|
-
for (const evt of BUBBLED_MUTABLE_EVENTS) {
|
|
1399
|
-
const parentEvt = CHILD_MUTABLE_EVENT_NAME[evt];
|
|
1400
|
-
const unregister = childHooks.hook(evt, async (ctx) => {
|
|
1401
|
-
tagOnCtx(ctx);
|
|
1402
|
-
await fire(parentEvt, ctx);
|
|
1403
|
-
});
|
|
1404
|
-
unregisters.push(unregister);
|
|
1405
|
-
}
|
|
1406
|
-
const chainHook = childHooks.hook;
|
|
1407
|
-
for (const evt of BUBBLED_EVENTS) {
|
|
1408
|
-
const parentEvt = CHILD_EVENT_NAME[evt];
|
|
1409
|
-
unregisters.push(chainHook(parentEvt, (ctx) => {
|
|
1410
|
-
Promise.resolve(fire(parentEvt, ctx)).catch((err) => swallowBubbleError(parentEvt, err));
|
|
1411
|
-
}));
|
|
1412
|
-
}
|
|
1413
|
-
for (const evt of BUBBLED_MUTABLE_EVENTS) {
|
|
1414
|
-
const parentEvt = CHILD_MUTABLE_EVENT_NAME[evt];
|
|
1415
|
-
if (parentEvt === evt) continue;
|
|
1416
|
-
unregisters.push(chainHook(parentEvt, async (ctx) => {
|
|
1417
|
-
await fire(parentEvt, ctx);
|
|
1418
|
-
}));
|
|
1419
|
-
}
|
|
1420
|
-
return () => {
|
|
1421
|
-
for (const u of unregisters) u();
|
|
1422
|
-
};
|
|
1423
|
-
}
|
|
1424
|
-
/**
|
|
1425
|
-
* Create a configured spawn tool.
|
|
1426
|
-
*
|
|
1427
|
-
* State (`children`, `totalChildStats`, counters, active count) is scoped to
|
|
1428
|
-
* the returned instance. Multiple parent agents using the same instance will
|
|
1429
|
-
* share counters + stats + concurrency slots — call `createSpawnTool()` per
|
|
1430
|
-
* agent (or use the stateless default `spawn`) to keep them isolated.
|
|
1431
|
-
*/
|
|
1432
|
-
function createSpawnTool(options = {}) {
|
|
1433
|
-
const localChildren = /* @__PURE__ */ new Map();
|
|
1434
|
-
let localCounter = 0;
|
|
1435
|
-
let localActiveCount = 0;
|
|
1436
|
-
const maxConcurrent = options.maxConcurrent ?? 3;
|
|
1437
|
-
const maxDepth = options.maxDepth ?? 3;
|
|
1438
|
-
const forwardHooks = options.forwardHooks ?? true;
|
|
1439
|
-
const localStats = {
|
|
1440
|
-
totalIn: 0,
|
|
1441
|
-
totalOut: 0,
|
|
1442
|
-
totalCacheRead: 0,
|
|
1443
|
-
totalCacheCreation: 0,
|
|
1444
|
-
turns: 0,
|
|
1445
|
-
elapsed: 0
|
|
1446
|
-
};
|
|
1447
|
-
const subagentRegistry = options.subagents;
|
|
1448
|
-
const subagentTypeKeys = !!subagentRegistry && Object.keys(subagentRegistry).length > 0 ? [...new Set([...Object.keys(subagentRegistry), "general-purpose"])] : [];
|
|
1449
|
-
return {
|
|
1450
|
-
get children() {
|
|
1451
|
-
return localChildren;
|
|
1452
|
-
},
|
|
1453
|
-
get totalChildStats() {
|
|
1454
|
-
return { ...localStats };
|
|
1455
|
-
},
|
|
1456
|
-
isConcurrencySafe: true,
|
|
1457
|
-
spec: {
|
|
1458
|
-
name: "spawn",
|
|
1459
|
-
description: "Spawn a sub-agent for a self-contained task that benefits from isolation (separate context window, separate retries) — for example, a deep research dive or a long codegen pass on a specific file. The sub-agent runs independently with its own tool access and returns its final response. Do NOT spawn for sequential steps you could do yourself.",
|
|
1460
|
-
inputSchema: {
|
|
1461
|
-
type: "object",
|
|
1462
|
-
properties: {
|
|
1463
|
-
task: {
|
|
1464
|
-
type: "string",
|
|
1465
|
-
description: "The task prompt for the sub-agent. Be specific about what you want it to accomplish."
|
|
1466
|
-
},
|
|
1467
|
-
system: {
|
|
1468
|
-
type: "string",
|
|
1469
|
-
description: "Optional system prompt override for this specific sub-agent."
|
|
1470
|
-
},
|
|
1471
|
-
...subagentTypeKeys.length > 0 ? { subagent_type: {
|
|
1472
|
-
type: "string",
|
|
1473
|
-
enum: subagentTypeKeys,
|
|
1474
|
-
description: buildSubagentTypeDescription(subagentRegistry)
|
|
1475
|
-
} } : {}
|
|
1476
|
-
},
|
|
1477
|
-
required: ["task"]
|
|
1478
|
-
}
|
|
1479
|
-
},
|
|
1480
|
-
async execute(input, ctx) {
|
|
1481
|
-
const task = input.task;
|
|
1482
|
-
const systemOverride = input.system;
|
|
1483
|
-
const requestedSubagentType = typeof input.subagent_type === "string" ? input.subagent_type : void 0;
|
|
1484
|
-
const subagentDef = requestedSubagentType && subagentRegistry ? subagentRegistry[requestedSubagentType] ?? void 0 : void 0;
|
|
1485
|
-
const parentDepth = ctx.depth ?? 0;
|
|
1486
|
-
const childDepth = parentDepth + 1;
|
|
1487
|
-
if (childDepth > maxDepth) return `Cannot spawn: maxDepth=${maxDepth} reached (parent depth=${parentDepth}). Deepen the cap with createSpawnTool({ maxDepth }).`;
|
|
1488
|
-
if (localActiveCount >= maxConcurrent) return `Cannot spawn: ${localActiveCount}/${maxConcurrent} sub-agents already running. Wait for one to complete.`;
|
|
1489
|
-
if (ctx.signal.aborted) return `[sub-agent pre-aborted] Parent signal was already aborted — skipped "${task.slice(0, 80)}"`;
|
|
1490
|
-
const id = ctx.session ? reserveChildLabel(ctx.session) : `child-${++localCounter}`;
|
|
1491
|
-
localActiveCount++;
|
|
1492
|
-
const child = {
|
|
1493
|
-
id,
|
|
1494
|
-
task,
|
|
1495
|
-
startedAt: Date.now(),
|
|
1496
|
-
depth: childDepth
|
|
1497
|
-
};
|
|
1498
|
-
localChildren.set(id, child);
|
|
1499
|
-
let destroyError;
|
|
1500
|
-
let childRunStatus = "completed";
|
|
1501
|
-
let finalStats;
|
|
1502
|
-
let result = "";
|
|
1503
|
-
let unbubble;
|
|
1504
|
-
try {
|
|
1505
|
-
const filteredTools = subagentDef ? filterToolsForSubagent(ctx.tools, subagentDef) : ctx.tools;
|
|
1506
|
-
const parentPreset = {
|
|
1507
|
-
...ctx.name !== void 0 ? { name: ctx.name } : {},
|
|
1508
|
-
...ctx.system !== void 0 ? { system: ctx.system } : {},
|
|
1509
|
-
tools: filteredTools,
|
|
1510
|
-
...ctx.toolAliases !== void 0 ? { toolAliases: ctx.toolAliases } : {},
|
|
1511
|
-
...ctx.mcpServers !== void 0 ? { mcpServers: ctx.mcpServers } : {},
|
|
1512
|
-
...ctx.skills !== void 0 ? { skills: ctx.skills } : {},
|
|
1513
|
-
...ctx.behavior !== void 0 ? { behavior: ctx.behavior } : {}
|
|
1514
|
-
};
|
|
1515
|
-
const sharedReadState = options.shareReadState ? resolveReadStateMap(ctx) : void 0;
|
|
1516
|
-
const agent = createAgent({
|
|
1517
|
-
...parentPreset,
|
|
1518
|
-
...options.preset,
|
|
1519
|
-
provider: ctx.provider,
|
|
1520
|
-
execution: ctx.execution,
|
|
1521
|
-
...options.persist && ctx.session ? { session: ctx.session } : {},
|
|
1522
|
-
...sharedReadState ? { readState: sharedReadState } : {}
|
|
1523
|
-
});
|
|
1524
|
-
if (forwardHooks) {
|
|
1525
|
-
const unregisterEnricher = agent.hooks.hook("tool:before", async (toolCtx) => {
|
|
1526
|
-
if (toolCtx.name !== "write_file" && toolCtx.name !== "edit" && toolCtx.name !== "multi_edit") return;
|
|
1527
|
-
if (!agent.handle) return;
|
|
1528
|
-
const inputPath = toolCtx.input?.path;
|
|
1529
|
-
if (typeof inputPath !== "string") return;
|
|
1530
|
-
try {
|
|
1531
|
-
toolCtx.priorContent = await agent.execution.readFile(agent.handle, inputPath);
|
|
1532
|
-
} catch {}
|
|
1533
|
-
});
|
|
1534
|
-
const unbubbleInner = bubbleHooks(agent.hooks, ctx.hooks, id, childDepth);
|
|
1535
|
-
unbubble = () => {
|
|
1536
|
-
unregisterEnricher();
|
|
1537
|
-
unbubbleInner();
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
options.onSpawn?.(child);
|
|
1541
|
-
const spawnHookCtx = {
|
|
1542
|
-
id,
|
|
1543
|
-
task,
|
|
1544
|
-
depth: childDepth,
|
|
1545
|
-
tracingContext: {}
|
|
1546
|
-
};
|
|
1547
|
-
await ctx.hooks.callHook("spawn:before", spawnHookCtx);
|
|
1548
|
-
const propagatedTracing = Object.keys(spawnHookCtx.tracingContext).length > 0 ? Object.freeze({ ...spawnHookCtx.tracingContext }) : void 0;
|
|
1549
|
-
const effectiveSystem = systemOverride ?? subagentDef?.system ?? options.system;
|
|
1550
|
-
const runPromise = agent.run({
|
|
1551
|
-
prompt: task,
|
|
1552
|
-
model: options.model ?? ctx.model,
|
|
1553
|
-
system: effectiveSystem,
|
|
1554
|
-
thinking: options.thinking,
|
|
1555
|
-
signal: ctx.signal,
|
|
1556
|
-
depth: childDepth,
|
|
1557
|
-
...options.persist && ctx.runId ? { parentRunId: ctx.runId } : {},
|
|
1558
|
-
...propagatedTracing ? { tracingContext: propagatedTracing } : {}
|
|
1559
|
-
});
|
|
1560
|
-
try {
|
|
1561
|
-
finalStats = await raceWithTimeout(runPromise, options.timeoutMs);
|
|
1562
|
-
const treeTurns = flattenTurns(finalStats).length;
|
|
1563
|
-
const usage = formatTokenUsage(finalStats);
|
|
1564
|
-
if (ctx.signal.aborted) {
|
|
1565
|
-
childRunStatus = "aborted";
|
|
1566
|
-
result = [`[sub-agent ${id}] Aborted after ${treeTurns} turns (${finalStats.elapsed}ms)`, `Tokens: ${usage}`].join("\n");
|
|
1567
|
-
} else {
|
|
1568
|
-
const response = extractText(agent.turns.at(-1));
|
|
1569
|
-
result = [
|
|
1570
|
-
`[sub-agent ${id}] Completed in ${treeTurns} turns (${finalStats.elapsed}ms)`,
|
|
1571
|
-
`Tokens: ${usage}`,
|
|
1572
|
-
"",
|
|
1573
|
-
response || "(no text response)"
|
|
1574
|
-
].join("\n");
|
|
1575
|
-
}
|
|
1576
|
-
} catch (err) {
|
|
1577
|
-
if (err instanceof SpawnTimeoutError) {
|
|
1578
|
-
childRunStatus = "timeout";
|
|
1579
|
-
agent.abort();
|
|
1580
|
-
try {
|
|
1581
|
-
finalStats = await runPromise;
|
|
1582
|
-
} catch {
|
|
1583
|
-
finalStats = {
|
|
1584
|
-
totalIn: 0,
|
|
1585
|
-
totalOut: 0,
|
|
1586
|
-
totalCacheRead: 0,
|
|
1587
|
-
totalCacheCreation: 0,
|
|
1588
|
-
turns: 0,
|
|
1589
|
-
elapsed: err.timeoutMs
|
|
1590
|
-
};
|
|
1591
|
-
}
|
|
1592
|
-
result = `[sub-agent ${id}] Timed out after ${err.timeoutMs}ms`;
|
|
1593
|
-
} else {
|
|
1594
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
1595
|
-
childRunStatus = "error";
|
|
1596
|
-
finalStats = {
|
|
1597
|
-
totalIn: 0,
|
|
1598
|
-
totalOut: 0,
|
|
1599
|
-
totalCacheRead: 0,
|
|
1600
|
-
totalCacheCreation: 0,
|
|
1601
|
-
turns: 0,
|
|
1602
|
-
elapsed: 0
|
|
1603
|
-
};
|
|
1604
|
-
result = `[sub-agent ${id}] Error: ${error.message}`;
|
|
1605
|
-
await ctx.hooks.callHook("spawn:error", {
|
|
1606
|
-
id,
|
|
1607
|
-
task,
|
|
1608
|
-
depth: childDepth,
|
|
1609
|
-
error
|
|
1610
|
-
});
|
|
1611
|
-
}
|
|
1612
|
-
} finally {
|
|
1613
|
-
const childHandle = agent.handle;
|
|
1614
|
-
if (childHandle && ctx.execution.reassignBackgroundTasks) try {
|
|
1615
|
-
const reassigned = await ctx.execution.reassignBackgroundTasks(childHandle, ctx.handle, (info) => {
|
|
1616
|
-
Promise.resolve(ctx.hooks.callHook("background:exit", info)).catch(() => {});
|
|
1617
|
-
});
|
|
1618
|
-
for (const entry of reassigned) await ctx.hooks.callHook("background:reassign", {
|
|
1619
|
-
taskId: entry.taskId,
|
|
1620
|
-
fromHandleId: childHandle.id,
|
|
1621
|
-
toHandleId: ctx.handle.id,
|
|
1622
|
-
childId: id,
|
|
1623
|
-
pid: entry.pid,
|
|
1624
|
-
command: entry.command,
|
|
1625
|
-
outputPath: entry.outputPath,
|
|
1626
|
-
startedAt: entry.startedAt
|
|
1627
|
-
});
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
if (process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/spawn] reassignBackgroundTasks failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1630
|
-
}
|
|
1631
|
-
try {
|
|
1632
|
-
await agent.destroy();
|
|
1633
|
-
} catch (err) {
|
|
1634
|
-
destroyError = err instanceof Error ? err : new Error(String(err));
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
if (finalStats) {
|
|
1638
|
-
localStats.totalIn += finalStats.totalIn;
|
|
1639
|
-
localStats.totalOut += finalStats.totalOut;
|
|
1640
|
-
localStats.totalCacheRead += finalStats.totalCacheRead;
|
|
1641
|
-
localStats.totalCacheCreation += finalStats.totalCacheCreation;
|
|
1642
|
-
localStats.turns += finalStats.turns;
|
|
1643
|
-
localStats.elapsed += finalStats.elapsed;
|
|
1644
|
-
}
|
|
1645
|
-
const childRunStats = {
|
|
1646
|
-
id,
|
|
1647
|
-
task,
|
|
1648
|
-
stats: finalStats,
|
|
1649
|
-
depth: childDepth,
|
|
1650
|
-
status: childRunStatus,
|
|
1651
|
-
...finalStats.output ? { output: finalStats.output } : {}
|
|
1652
|
-
};
|
|
1653
|
-
options.onComplete?.(child, finalStats, childRunStatus);
|
|
1654
|
-
await ctx.hooks.callHook("spawn:complete", childRunStats);
|
|
1655
|
-
if (destroyError) await ctx.hooks.callHook("spawn:error", {
|
|
1656
|
-
id,
|
|
1657
|
-
task,
|
|
1658
|
-
depth: childDepth,
|
|
1659
|
-
error: destroyError
|
|
1660
|
-
});
|
|
1661
|
-
return result;
|
|
1662
|
-
} finally {
|
|
1663
|
-
unbubble?.();
|
|
1664
|
-
localActiveCount--;
|
|
1665
|
-
localChildren.delete(id);
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
};
|
|
1669
|
-
}
|
|
1670
|
-
//#endregion
|
|
1671
|
-
//#region src/tools/write-file.ts
|
|
1672
|
-
/**
|
|
1673
|
-
* Write a file, with an idempotency signal when the content is unchanged.
|
|
1674
|
-
*
|
|
1675
|
-
* Three return shapes — chosen so the model can recognize a no-op without a
|
|
1676
|
-
* separate read:
|
|
1677
|
-
* - `Created path (N bytes)` — file did not exist
|
|
1678
|
-
* - `Updated path (N bytes)` — content differed from on-disk
|
|
1679
|
-
* - `No change needed: path already at target state (N bytes)` — equal
|
|
1680
|
-
*
|
|
1681
|
-
* Race window: in non-process execution contexts (docker, sandbox) shared by
|
|
1682
|
-
* multiple agents, another writer can mutate the file between our read and
|
|
1683
|
-
* our write. Local process context is single-writer per agent so the race is
|
|
1684
|
-
* a non-issue there. Documented rather than locked because the cost of
|
|
1685
|
-
* cross-context locking outweighs the cost of a stale "No change" message.
|
|
1686
|
-
*/
|
|
1687
|
-
const writeFile = {
|
|
1688
|
-
spec: {
|
|
1689
|
-
name: "write_file",
|
|
1690
|
-
description: "Write `content` to `path`, creating any missing parent directories. Overwrites existing files in full; prefer `edit` / `multi_edit` for surgical changes when you only want to alter part of a file. Returns one of: `Created <path> (N bytes)` (file did not exist), `Updated <path> (N bytes)` (content differed), or `No change needed: <path> already at target state (N bytes)` — so the model can detect no-ops without a separate `read_file`.",
|
|
1691
|
-
inputSchema: {
|
|
1692
|
-
type: "object",
|
|
1693
|
-
properties: {
|
|
1694
|
-
path: {
|
|
1695
|
-
type: "string",
|
|
1696
|
-
description: "File path (relative to the execution-context cwd, or absolute)."
|
|
1697
|
-
},
|
|
1698
|
-
content: {
|
|
1699
|
-
type: "string",
|
|
1700
|
-
description: "Complete file content. Overwrites any existing file at `path`."
|
|
1701
|
-
}
|
|
1702
|
-
},
|
|
1703
|
-
required: ["path", "content"]
|
|
1704
|
-
}
|
|
1705
|
-
},
|
|
1706
|
-
async execute({ path, content }, ctx) {
|
|
1707
|
-
const targetPath = path;
|
|
1708
|
-
const targetContent = content;
|
|
1709
|
-
const pathErr = pathArgError(targetPath);
|
|
1710
|
-
if (pathErr) {
|
|
1711
|
-
ctx.reportOutcome?.("failed");
|
|
1712
|
-
return `Write error: ${pathErr}`;
|
|
1713
|
-
}
|
|
1714
|
-
let existing;
|
|
1715
|
-
try {
|
|
1716
|
-
existing = await ctx.execution.readFile(ctx.handle, targetPath);
|
|
1717
|
-
} catch {}
|
|
1718
|
-
const bytes = Buffer.byteLength(targetContent);
|
|
1719
|
-
if (existing === targetContent) {
|
|
1720
|
-
ctx.reportOutcome?.("noop");
|
|
1721
|
-
return `No change needed: ${targetPath} already at target state (${bytes} bytes).`;
|
|
1722
|
-
}
|
|
1723
|
-
await ctx.execution.writeFile(ctx.handle, targetPath, targetContent);
|
|
1724
|
-
const readState = resolveReadStateMap(ctx);
|
|
1725
|
-
if (readState) readState.set(readStateKey(ctx.handle.cwd, targetPath), {
|
|
1726
|
-
contentHash: hashContent(targetContent),
|
|
1727
|
-
offset: 0,
|
|
1728
|
-
limit: Number.POSITIVE_INFINITY,
|
|
1729
|
-
maxBytes: Number.POSITIVE_INFINITY,
|
|
1730
|
-
mtimeMs: Date.now()
|
|
1731
|
-
});
|
|
1732
|
-
ctx.reportOutcome?.(existing === void 0 ? "created" : "updated");
|
|
1733
|
-
return existing === void 0 ? `Created ${targetPath} (${bytes} bytes).` : `Updated ${targetPath} (${bytes} bytes).`;
|
|
1734
|
-
}
|
|
1735
|
-
};
|
|
1736
|
-
//#endregion
|
|
1737
|
-
export { multiEdit as a, grep as c, readFile as i, glob as l, createSpawnTool as n, listFiles as o, shellKill as r, createInteractionTool as s, writeFile as t, edit as u };
|
|
1738
|
-
|
|
1739
|
-
//# sourceMappingURL=tools-CCd8e-HT.js.map
|