@swoop111/dsh-tool-fs 0.0.0-stage → 0.2.0-rc.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +142 -0
- package/README.md +277 -2
- package/README.zh.md +278 -0
- package/lib/index.js +1838 -0
- package/lib/types/completion.d.ts +58 -0
- package/lib/types/diff.d.ts +41 -0
- package/lib/types/edit.d.ts +73 -0
- package/lib/types/error.d.ts +19 -0
- package/lib/types/index.d.ts +36 -0
- package/lib/types/read-image.d.ts +80 -0
- package/lib/types/read-render.d.ts +129 -0
- package/lib/types/read-target.d.ts +33 -0
- package/lib/types/read.d.ts +150 -0
- package/lib/types/sandbox.d.ts +82 -0
- package/lib/types/session-cwd.d.ts +26 -0
- package/lib/types/structure.d.ts +67 -0
- package/lib/types/write.d.ts +57 -0
- package/package.json +70 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1838 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { DEFAULT_MAX_FILE_CHARS, DEFAULT_MAX_WINDOW_LINES, createDisclosureLedger, createReadWindowProjection, createSessionPathProjection, repairMissingPath, repairStrReplace } from "@deepseek-ai/dsh-fs-edit-repair";
|
|
3
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
4
|
+
import { repairReadArgs } from "@deepseek-ai/dsh-arg-repair";
|
|
5
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
6
|
+
import { readLangHintForPath as langFromPath } from "@deepseek-ai/dsh-util-code-language";
|
|
7
|
+
import { structuredPatch } from "diff";
|
|
8
|
+
import { basename, extname } from "node:path";
|
|
9
|
+
import { AttachmentError, AttachmentId } from "@deepseek-ai/dsh-attachment";
|
|
10
|
+
import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, sandboxPermissionsDescription, validateEscalationArgs } from "@deepseek-ai/dsh-sandbox";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const READ_MAX_LINE_LENGTH = 2e3;
|
|
20
|
+
|
|
21
|
+
const READ_MAX_BYTES = 50 * 1024;
|
|
22
|
+
function newAccumulator() {
|
|
23
|
+
return {
|
|
24
|
+
lines: [],
|
|
25
|
+
totalLines: 0,
|
|
26
|
+
outputBytes: 0,
|
|
27
|
+
truncatedByBytes: false
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function truncateLine(line, maxLineLength) {
|
|
31
|
+
return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line;
|
|
32
|
+
}
|
|
33
|
+
function lineByteSize(line, currentLineCount) {
|
|
34
|
+
return Buffer.byteLength(line, "utf8") + (currentLineCount > 0 ? 1 : 0);
|
|
35
|
+
}
|
|
36
|
+
function consumeLine(acc, rawLine, request) {
|
|
37
|
+
acc.totalLines += 1;
|
|
38
|
+
if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return;
|
|
39
|
+
const text = truncateLine(rawLine, request.maxLineLength);
|
|
40
|
+
const bytes = lineByteSize(text, acc.lines.length);
|
|
41
|
+
if (acc.outputBytes + bytes > request.maxBytes) {
|
|
42
|
+
acc.truncatedByBytes = true;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
acc.outputBytes += bytes;
|
|
46
|
+
acc.lines.push({
|
|
47
|
+
number: acc.totalLines,
|
|
48
|
+
text
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function stripCarriageReturn(line) {
|
|
52
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
53
|
+
}
|
|
54
|
+
function finish(acc, request, displayPath) {
|
|
55
|
+
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, "FS_NOT_FOUND");
|
|
56
|
+
return {
|
|
57
|
+
lines: acc.lines,
|
|
58
|
+
totalLines: acc.totalLines,
|
|
59
|
+
truncatedByBytes: acc.truncatedByBytes
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const OFFSET_OUT_OF_RANGE_RE = /^offset (-?\d+) is out of range for "(?:[^"\\]|\\"|\\\\)*" \((\d+) lines\)$/u;
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
function parseOffsetOutOfRange(error) {
|
|
72
|
+
if (!(error instanceof FsError) || error.code !== "FS_NOT_FOUND") return void 0;
|
|
73
|
+
const match = error.message.match(OFFSET_OUT_OF_RANGE_RE);
|
|
74
|
+
if (match === null) return void 0;
|
|
75
|
+
const requestedOffset = Number(match[1]);
|
|
76
|
+
const totalLines = Number(match[2]);
|
|
77
|
+
return Number.isSafeInteger(requestedOffset) && Number.isSafeInteger(totalLines) ? {
|
|
78
|
+
requestedOffset,
|
|
79
|
+
totalLines
|
|
80
|
+
} : void 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async function buildWindow(chunks, request, displayPath) {
|
|
92
|
+
const acc = newAccumulator();
|
|
93
|
+
const lineBufferCap = request.maxLineLength + 1;
|
|
94
|
+
let lineBuffer = "";
|
|
95
|
+
function appendToLineBuffer(segment) {
|
|
96
|
+
if (lineBuffer.length >= lineBufferCap) return;
|
|
97
|
+
lineBuffer += segment;
|
|
98
|
+
if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap);
|
|
99
|
+
}
|
|
100
|
+
function flushLine() {
|
|
101
|
+
consumeLine(acc, stripCarriageReturn(lineBuffer), request);
|
|
102
|
+
lineBuffer = "";
|
|
103
|
+
}
|
|
104
|
+
for await (const chunk of chunks) {
|
|
105
|
+
let startPos = 0;
|
|
106
|
+
let newlinePos;
|
|
107
|
+
while ((newlinePos = chunk.indexOf("\n", startPos)) !== -1) {
|
|
108
|
+
appendToLineBuffer(chunk.slice(startPos, newlinePos));
|
|
109
|
+
flushLine();
|
|
110
|
+
startPos = newlinePos + 1;
|
|
111
|
+
}
|
|
112
|
+
appendToLineBuffer(chunk.slice(startPos));
|
|
113
|
+
}
|
|
114
|
+
if (lineBuffer.length > 0) flushLine();
|
|
115
|
+
return finish(acc, request, displayPath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
function formatReadOutput(displayPath, outcome) {
|
|
124
|
+
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1);
|
|
125
|
+
let footer;
|
|
126
|
+
if (outcome.truncatedByBytes) footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`;
|
|
127
|
+
else if (endLine < outcome.totalLines) footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`;
|
|
128
|
+
else footer = `(End of file - total ${outcome.totalLines} lines)`;
|
|
129
|
+
return `<path>${displayPath}</path>
|
|
130
|
+
<type>file</type>
|
|
131
|
+
<content>
|
|
132
|
+
${outcome.lines.length > 0 ? `${outcome.lines.map((line) => `${line.number}: ${line.text}`).join("\n")}\n\n${footer}` : footer}${outcome.repairNote === void 0 ? "" : `\n(repair: ${outcome.repairNote})`}
|
|
133
|
+
</content>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
function isFileTextLine(value) {
|
|
142
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
143
|
+
const { number, text } = value;
|
|
144
|
+
return typeof number === "number" && Number.isInteger(number) && number >= 1 && typeof text === "string";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
function readMetaFromMeta(meta) {
|
|
160
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
|
|
161
|
+
const { path, offset, lines, totalLines, lang } = meta;
|
|
162
|
+
if (typeof path !== "string" || typeof totalLines !== "number" || typeof offset !== "number") return void 0;
|
|
163
|
+
if (!Number.isInteger(offset) || offset < 1) return void 0;
|
|
164
|
+
if (!Number.isInteger(totalLines) || totalLines < 0) return void 0;
|
|
165
|
+
if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return void 0;
|
|
166
|
+
if (lang !== void 0 && typeof lang !== "string") return void 0;
|
|
167
|
+
let previous = offset - 1;
|
|
168
|
+
for (const { number } of lines) {
|
|
169
|
+
if (number <= previous || number > totalLines) return void 0;
|
|
170
|
+
previous = number;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
path,
|
|
174
|
+
offset,
|
|
175
|
+
lines,
|
|
176
|
+
totalLines,
|
|
177
|
+
...lang === void 0 ? {} : { lang }
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
function sessionCwd(exec) {
|
|
196
|
+
return exec.agent?.session.header.cwd;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
function sessionResolveOptions(exec, policyWorkspaceRoot) {
|
|
205
|
+
const cwd = policyWorkspaceRoot ?? sessionCwd(exec);
|
|
206
|
+
return {
|
|
207
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
208
|
+
signal: exec.signal
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async function resolveRegularReadTarget(ctx, exec, requestedPath) {
|
|
225
|
+
const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec));
|
|
226
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
227
|
+
if (info === void 0) {
|
|
228
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
229
|
+
throw new FsError(`cannot read "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
230
|
+
}
|
|
231
|
+
if (info.type !== "file") throw new FsError(`cannot read "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
232
|
+
return {
|
|
233
|
+
target,
|
|
234
|
+
info
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async function repairTargetPath(ctx, exec, requestedPath, knownPaths) {
|
|
250
|
+
const cwd = sessionCwd(exec);
|
|
251
|
+
return await repairMissingPath(requestedPath, {
|
|
252
|
+
knownPaths,
|
|
253
|
+
explorer: fsPathExplorer(ctx, exec.signal),
|
|
254
|
+
...cwd === void 0 ? {} : { root: cwd },
|
|
255
|
+
verify: async (candidate) => {
|
|
256
|
+
const target = await ctx.fs.resolve(candidate, sessionResolveOptions(exec));
|
|
257
|
+
return await ctx.fs.stat(target, exec.signal) !== void 0;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function fsPathExplorer(ctx, signal) {
|
|
263
|
+
return { listDir: async (directory) => {
|
|
264
|
+
const target = await ctx.fs.resolve(directory, { signal }).catch(() => void 0);
|
|
265
|
+
if (target === void 0) return void 0;
|
|
266
|
+
return await ctx.fs.listDir(target, signal).then((entries) => entries.map((entry) => entry.name)).catch(() => void 0);
|
|
267
|
+
} };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
function fenceLine(line) {
|
|
287
|
+
const rest = line.slice(line.length - line.trimStart().length);
|
|
288
|
+
if (line.length - rest.length > 3) return void 0;
|
|
289
|
+
const char = rest.charAt(0);
|
|
290
|
+
if (char !== "`" && char !== "~") return void 0;
|
|
291
|
+
let length = 0;
|
|
292
|
+
while (rest.charAt(length) === char) length += 1;
|
|
293
|
+
if (length < 3) return void 0;
|
|
294
|
+
const info = rest.slice(length);
|
|
295
|
+
if (char === "`" && info.includes("`")) return void 0;
|
|
296
|
+
return {
|
|
297
|
+
marker: rest.slice(0, length),
|
|
298
|
+
info
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function closesFence(marker, line) {
|
|
303
|
+
const candidate = fenceLine(line);
|
|
304
|
+
return candidate !== void 0 && candidate.marker.slice(0, 1) === marker.slice(0, 1) && candidate.marker.length >= marker.length && candidate.info.trim().length === 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function codeBrackets(line, state) {
|
|
308
|
+
const found = [];
|
|
309
|
+
let quote;
|
|
310
|
+
let i = 0;
|
|
311
|
+
while (i < line.length) {
|
|
312
|
+
const ch = line.charAt(i);
|
|
313
|
+
if (state.inBlockComment) {
|
|
314
|
+
if (ch === "*" && line.charAt(i + 1) === "/") {
|
|
315
|
+
state.inBlockComment = false;
|
|
316
|
+
i += 2;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
i += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (quote !== void 0) {
|
|
323
|
+
if (ch === "\\") {
|
|
324
|
+
i += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (ch === quote) quote = void 0;
|
|
328
|
+
i += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
332
|
+
quote = ch;
|
|
333
|
+
i += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === "/" && line.charAt(i + 1) === "/") break;
|
|
337
|
+
if (ch === "/" && line.charAt(i + 1) === "*") {
|
|
338
|
+
state.inBlockComment = true;
|
|
339
|
+
i += 2;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (ch === "(" || ch === "[" || ch === "{" || ch === ")" || ch === "]" || ch === "}") found.push(ch);
|
|
343
|
+
i += 1;
|
|
344
|
+
}
|
|
345
|
+
return found;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isOpener(ch) {
|
|
349
|
+
return ch === "(" || ch === "[" || ch === "{";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
function scan(lines) {
|
|
357
|
+
const state = { inBlockComment: false };
|
|
358
|
+
const openBracketLines = [];
|
|
359
|
+
let fence;
|
|
360
|
+
for (const [index, line] of lines.entries()) {
|
|
361
|
+
if (fence !== void 0) {
|
|
362
|
+
if (closesFence(fence.marker, line)) fence = void 0;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const candidate = fenceLine(line);
|
|
366
|
+
if (candidate !== void 0) {
|
|
367
|
+
fence = {
|
|
368
|
+
marker: candidate.marker,
|
|
369
|
+
lineIndex: index
|
|
370
|
+
};
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
for (const ch of codeBrackets(line, state)) if (isOpener(ch)) openBracketLines.push(index);
|
|
374
|
+
else openBracketLines.pop();
|
|
375
|
+
}
|
|
376
|
+
return fence === void 0 ? { openBracketLines } : {
|
|
377
|
+
fence,
|
|
378
|
+
openBracketLines
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
function detectOpenIndent(lines) {
|
|
389
|
+
const lastLine = lines.findLast((line) => line.trim().length > 0);
|
|
390
|
+
if (lastLine === void 0) return void 0;
|
|
391
|
+
const width = indentWidth(lastLine);
|
|
392
|
+
if (width === 0) return void 0;
|
|
393
|
+
const lastContent = lines.findLastIndex((line) => line.trim().length > 0);
|
|
394
|
+
for (const [offset, line] of lines.slice(0, lastContent).toReversed().entries()) {
|
|
395
|
+
if (line.trim().length === 0) continue;
|
|
396
|
+
const prior = indentWidth(line);
|
|
397
|
+
if (prior < width) return {
|
|
398
|
+
kind: "indent",
|
|
399
|
+
width,
|
|
400
|
+
lineIndex: lastContent - 1 - offset
|
|
401
|
+
};
|
|
402
|
+
if (prior === width) return void 0;
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
kind: "indent",
|
|
406
|
+
width,
|
|
407
|
+
lineIndex: 0
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
function detectOpenTail(lines) {
|
|
416
|
+
const facts = scan(lines);
|
|
417
|
+
if (facts.fence !== void 0) return {
|
|
418
|
+
kind: "fence",
|
|
419
|
+
marker: facts.fence.marker,
|
|
420
|
+
lineIndex: facts.fence.lineIndex
|
|
421
|
+
};
|
|
422
|
+
const innermost = facts.openBracketLines.at(-1);
|
|
423
|
+
if (innermost !== void 0) return {
|
|
424
|
+
kind: "brace",
|
|
425
|
+
depth: facts.openBracketLines.length,
|
|
426
|
+
lineIndex: innermost
|
|
427
|
+
};
|
|
428
|
+
return detectOpenIndent(lines);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
function detectOpenIndentHead(window, preceding) {
|
|
439
|
+
const firstContent = window.find((line) => line.trim().length > 0);
|
|
440
|
+
if (firstContent === void 0) return void 0;
|
|
441
|
+
const width = indentWidth(firstContent);
|
|
442
|
+
if (width === 0) return void 0;
|
|
443
|
+
for (const [offset, line] of preceding.toReversed().entries()) {
|
|
444
|
+
if (line.trim().length === 0) continue;
|
|
445
|
+
if (indentWidth(line) < width) return {
|
|
446
|
+
kind: "indent",
|
|
447
|
+
startIndex: preceding.length - 1 - offset
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
function detectOpenHead(window, preceding) {
|
|
461
|
+
if (preceding.length === 0) return void 0;
|
|
462
|
+
const facts = scan(preceding);
|
|
463
|
+
if (facts.fence !== void 0) return {
|
|
464
|
+
kind: "fence",
|
|
465
|
+
startIndex: facts.fence.lineIndex
|
|
466
|
+
};
|
|
467
|
+
const outer = facts.openBracketLines[0];
|
|
468
|
+
if (outer !== void 0) return {
|
|
469
|
+
kind: "brace",
|
|
470
|
+
startIndex: outer
|
|
471
|
+
};
|
|
472
|
+
return detectOpenIndentHead(window, preceding);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
function closureLength(construct, appended) {
|
|
481
|
+
if (construct.kind === "fence") {
|
|
482
|
+
for (const [index, line] of appended.entries()) if (closesFence(construct.marker, line)) return index + 1;
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
if (construct.kind === "brace") {
|
|
486
|
+
const state = { inBlockComment: false };
|
|
487
|
+
let depth = construct.depth;
|
|
488
|
+
for (const [index, line] of appended.entries()) {
|
|
489
|
+
for (const ch of codeBrackets(line, state)) depth += isOpener(ch) ? 1 : -1;
|
|
490
|
+
if (depth <= 0) return index + 1;
|
|
491
|
+
}
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
let cut = -1;
|
|
495
|
+
for (const [index, line] of appended.entries()) {
|
|
496
|
+
if (line.trim().length === 0) continue;
|
|
497
|
+
if (indentWidth(line) < construct.width) break;
|
|
498
|
+
cut = index;
|
|
499
|
+
}
|
|
500
|
+
return cut + 1;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function indentWidth(line) {
|
|
504
|
+
let width = 0;
|
|
505
|
+
for (const ch of line) if (ch === " ") width += 1;
|
|
506
|
+
else if (ch === " ") width += 4;
|
|
507
|
+
else break;
|
|
508
|
+
return width;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
async function completeWindow(lines, supply, tuning) {
|
|
533
|
+
if (lines.length === 0) return void 0;
|
|
534
|
+
let head = [];
|
|
535
|
+
let headRule;
|
|
536
|
+
const preceding = await supply.preceding(tuning.maxLines);
|
|
537
|
+
if (preceding !== void 0 && preceding.length > 0) {
|
|
538
|
+
const context = detectOpenHead(lines.map((line) => line.text), preceding.map((line) => line.text));
|
|
539
|
+
const opening = context === void 0 ? void 0 : preceding[context.startIndex];
|
|
540
|
+
if (context !== void 0 && opening !== void 0) {
|
|
541
|
+
head = [opening];
|
|
542
|
+
headRule = context.kind;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
let tail = [];
|
|
546
|
+
let tailRule;
|
|
547
|
+
const construct = detectOpenTail([...head, ...lines].map((line) => line.text));
|
|
548
|
+
if (construct !== void 0) {
|
|
549
|
+
const following = await supply.following(tuning.maxLines);
|
|
550
|
+
if (following !== void 0) {
|
|
551
|
+
const closing = closureLength(construct, following.map((line) => line.text));
|
|
552
|
+
if (closing > 0) {
|
|
553
|
+
tail = following.slice(0, closing);
|
|
554
|
+
tailRule = construct.kind;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (headRule === void 0 && tailRule === void 0) return void 0;
|
|
559
|
+
return {
|
|
560
|
+
head,
|
|
561
|
+
tail,
|
|
562
|
+
...headRule === void 0 ? {} : { headRule },
|
|
563
|
+
...tailRule === void 0 ? {} : { tailRule }
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
const READ_LIMIT = 2e3;
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
const STREAM_MIN_SIZE = 10 * 1024 * 1024;
|
|
580
|
+
function parsePositiveInteger(value, name) {
|
|
581
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
582
|
+
return value;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
function parseReadArgs(args, maxLimit, repairEnabled = true) {
|
|
596
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
597
|
+
const repair = repairEnabled ? repairReadArgs(args.offset, args.limit, maxLimit) : void 0;
|
|
598
|
+
const offset = repair?.offset ?? (args.offset === void 0 ? 1 : parsePositiveInteger(args.offset, "offset"));
|
|
599
|
+
const limit = repair?.limit ?? (args.limit === void 0 ? maxLimit : parsePositiveInteger(args.limit, "limit"));
|
|
600
|
+
if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`);
|
|
601
|
+
return {
|
|
602
|
+
filePath: args.file_path,
|
|
603
|
+
offset,
|
|
604
|
+
limit,
|
|
605
|
+
force: args.force === true,
|
|
606
|
+
...repair === void 0 ? {} : { repairs: repair.repairs }
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
function duplicateMessage(verdict) {
|
|
614
|
+
const { record, steps } = verdict;
|
|
615
|
+
return `duplicate read blocked: lines ${record.offset}-${record.endLine} unchanged, delivered ${steps} step${steps === 1 ? "" : "s"} ago; that content is still above. Use offset=${record.endLine + 1}, or force=true.`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
function applyReadTool(ctx, caps, repair) {
|
|
624
|
+
ctx.systemPrompt.section({
|
|
625
|
+
name: "tool:read",
|
|
626
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_READ"),
|
|
627
|
+
text: ({ scope }) => ctx.tools.get("read", scope) === void 0 ? "" : "Use the read tool — not shell commands like cat — to inspect text files. Use offset and limit to continue reading large files."
|
|
628
|
+
});
|
|
629
|
+
ctx.tools.register(defineTool({
|
|
630
|
+
name: "read",
|
|
631
|
+
description: "Read a UTF-8 text file and return line-numbered content.",
|
|
632
|
+
parameters: {
|
|
633
|
+
file_path: {
|
|
634
|
+
type: "string",
|
|
635
|
+
required: true,
|
|
636
|
+
description: "Path to read, resolved by the filesystem backend."
|
|
637
|
+
},
|
|
638
|
+
offset: {
|
|
639
|
+
type: "number",
|
|
640
|
+
description: "1-based first line to return. Defaults to 1."
|
|
641
|
+
},
|
|
642
|
+
limit: {
|
|
643
|
+
type: "number",
|
|
644
|
+
description: `Maximum number of lines to return. Defaults to ${caps.limit}.`
|
|
645
|
+
},
|
|
646
|
+
...repair.duplicate.enabled ? { force: {
|
|
647
|
+
type: "boolean",
|
|
648
|
+
description: "Read this window even when it was recently delivered and the file is unchanged."
|
|
649
|
+
} } : {}
|
|
650
|
+
},
|
|
651
|
+
output: {
|
|
652
|
+
schema: {
|
|
653
|
+
type: "object",
|
|
654
|
+
additionalProperties: false,
|
|
655
|
+
properties: {
|
|
656
|
+
path: {
|
|
657
|
+
type: "string",
|
|
658
|
+
required: true
|
|
659
|
+
},
|
|
660
|
+
offset: {
|
|
661
|
+
type: "integer",
|
|
662
|
+
required: true
|
|
663
|
+
},
|
|
664
|
+
lines: {
|
|
665
|
+
type: "array",
|
|
666
|
+
required: true,
|
|
667
|
+
items: {
|
|
668
|
+
type: "object",
|
|
669
|
+
additionalProperties: false,
|
|
670
|
+
properties: {
|
|
671
|
+
number: {
|
|
672
|
+
type: "integer",
|
|
673
|
+
required: true
|
|
674
|
+
},
|
|
675
|
+
text: {
|
|
676
|
+
type: "string",
|
|
677
|
+
required: true
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
totalLines: {
|
|
683
|
+
type: "integer",
|
|
684
|
+
required: true
|
|
685
|
+
},
|
|
686
|
+
note: { type: "string" }
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
render: (args, value) => {
|
|
690
|
+
const input = parseReadArgs(args, caps.limit, repair.enabled);
|
|
691
|
+
const endLine = value.lines.at(-1)?.number ?? Math.max(0, value.offset - 1);
|
|
692
|
+
const truncatedByBytes = value.lines.length < input.limit && endLine < value.totalLines;
|
|
693
|
+
return [{
|
|
694
|
+
type: "text",
|
|
695
|
+
text: formatReadOutput(value.path, {
|
|
696
|
+
offset: value.offset,
|
|
697
|
+
lines: value.lines,
|
|
698
|
+
totalLines: value.totalLines,
|
|
699
|
+
...truncatedByBytes ? { truncatedByBytes: true } : {},
|
|
700
|
+
...value.note === void 0 ? {} : { repairNote: value.note }
|
|
701
|
+
})
|
|
702
|
+
}];
|
|
703
|
+
},
|
|
704
|
+
presentationMeta: (_args, value) => {
|
|
705
|
+
const lang = langFromPath(value.path);
|
|
706
|
+
return {
|
|
707
|
+
path: value.path,
|
|
708
|
+
offset: value.offset,
|
|
709
|
+
lines: value.lines.map(({ number, text }) => ({
|
|
710
|
+
number,
|
|
711
|
+
text
|
|
712
|
+
})),
|
|
713
|
+
totalLines: value.totalLines,
|
|
714
|
+
...lang === void 0 ? {} : { lang }
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
isConcurrencySafe: () => true,
|
|
719
|
+
async execute(args, exec) {
|
|
720
|
+
const input = parseReadArgs(args, caps.limit, repair.enabled);
|
|
721
|
+
const session = exec.agent?.session;
|
|
722
|
+
const notes = (input.repairs ?? []).filter((rule) => repair.claimDisclosure(session, `read:${rule.rule}`)).map((rule) => rule.note);
|
|
723
|
+
let requestedOffset = input.offset;
|
|
724
|
+
let target;
|
|
725
|
+
let info;
|
|
726
|
+
try {
|
|
727
|
+
({target, info} = await resolveRegularReadTarget(ctx, exec, input.filePath));
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (!repair.enabled || !(error instanceof FsError) || error.code !== "FS_NOT_FOUND") throw error;
|
|
730
|
+
const repaired = await repairTargetPath(ctx, exec, input.filePath, repair.priorPaths(exec.agent?.session));
|
|
731
|
+
if (repaired === void 0) throw error;
|
|
732
|
+
notes.push(repaired.note);
|
|
733
|
+
({target, info} = await resolveRegularReadTarget(ctx, exec, repaired.path));
|
|
734
|
+
}
|
|
735
|
+
if (repair.duplicate.enabled && !input.force) {
|
|
736
|
+
const verdict = repair.duplicate.judge(exec.agent?.session, input.filePath, info.version, {
|
|
737
|
+
offset: input.offset,
|
|
738
|
+
endLine: input.offset + input.limit - 1
|
|
739
|
+
});
|
|
740
|
+
if (verdict?.action === "block") throw new Error(duplicateMessage(verdict));
|
|
741
|
+
}
|
|
742
|
+
const readChunks = () => info.size === void 0 || info.size >= caps.streamMinSize ? ctx.fs.streamText(target, exec.signal) : ctx.fs.readText(target, exec.signal).then((text) => [text]);
|
|
743
|
+
const windowRequest = {
|
|
744
|
+
offset: input.offset,
|
|
745
|
+
limit: input.limit,
|
|
746
|
+
maxLineLength: caps.maxLineLength,
|
|
747
|
+
maxBytes: caps.maxBytes
|
|
748
|
+
};
|
|
749
|
+
let window;
|
|
750
|
+
try {
|
|
751
|
+
window = await buildWindow(await readChunks(), windowRequest, target.displayPath);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
const out = repair.enabled ? parseOffsetOutOfRange(error) : void 0;
|
|
754
|
+
if (out === void 0) throw error;
|
|
755
|
+
const windowSize = Math.min(input.limit, out.totalLines);
|
|
756
|
+
requestedOffset = Math.max(1, out.totalLines - windowSize + 1);
|
|
757
|
+
window = await buildWindow(await readChunks(), {
|
|
758
|
+
...windowRequest,
|
|
759
|
+
offset: requestedOffset
|
|
760
|
+
}, target.displayPath);
|
|
761
|
+
}
|
|
762
|
+
const windowOffset = window.lines[0]?.number ?? requestedOffset;
|
|
763
|
+
if (repair.completion.enabled && window.lines.length > 0 && !window.truncatedByBytes) {
|
|
764
|
+
const firstAfterWindow = windowOffset + window.lines.length;
|
|
765
|
+
const fetchLines = async (offset, count) => {
|
|
766
|
+
return (await buildWindow(await readChunks(), {
|
|
767
|
+
...windowRequest,
|
|
768
|
+
offset,
|
|
769
|
+
limit: count,
|
|
770
|
+
maxBytes: Number.MAX_SAFE_INTEGER
|
|
771
|
+
}, target.displayPath)).lines;
|
|
772
|
+
};
|
|
773
|
+
const completion = await completeWindow(window.lines, {
|
|
774
|
+
preceding: async (count) => {
|
|
775
|
+
const lastBefore = windowOffset - 1;
|
|
776
|
+
if (lastBefore < 1) return void 0;
|
|
777
|
+
const offset = Math.max(1, lastBefore - count + 1);
|
|
778
|
+
return await fetchLines(offset, lastBefore - offset + 1);
|
|
779
|
+
},
|
|
780
|
+
following: async (count) => {
|
|
781
|
+
if (firstAfterWindow > window.totalLines) return void 0;
|
|
782
|
+
return await fetchLines(firstAfterWindow, count);
|
|
783
|
+
}
|
|
784
|
+
}, { maxLines: repair.completion.maxLines });
|
|
785
|
+
if (completion !== void 0) window = {
|
|
786
|
+
...window,
|
|
787
|
+
lines: [
|
|
788
|
+
...completion.head,
|
|
789
|
+
...window.lines,
|
|
790
|
+
...completion.tail
|
|
791
|
+
]
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const note = notes.length === 0 ? void 0 : notes.join("; ");
|
|
795
|
+
repair.duplicate.record(exec.agent?.session, input.filePath, {
|
|
796
|
+
offset: windowOffset,
|
|
797
|
+
endLine: window.lines.at(-1)?.number ?? Math.max(0, windowOffset - 1),
|
|
798
|
+
totalLines: window.totalLines,
|
|
799
|
+
version: info.version
|
|
800
|
+
});
|
|
801
|
+
const outcome = {
|
|
802
|
+
path: target.displayPath,
|
|
803
|
+
offset: window.lines[0]?.number ?? requestedOffset,
|
|
804
|
+
lines: window.lines,
|
|
805
|
+
totalLines: window.totalLines,
|
|
806
|
+
...note === void 0 ? {} : { note }
|
|
807
|
+
};
|
|
808
|
+
ctx.emit("fs/observed", target, {
|
|
809
|
+
kind: "present",
|
|
810
|
+
version: info.version
|
|
811
|
+
}, exec);
|
|
812
|
+
return outcome;
|
|
813
|
+
},
|
|
814
|
+
presentResult(_args, result) {
|
|
815
|
+
if (result.isError) return void 0;
|
|
816
|
+
const meta = readMetaFromMeta(result.meta);
|
|
817
|
+
if (meta === void 0) return void 0;
|
|
818
|
+
const only = result.content.length === 1 ? result.content[0] : void 0;
|
|
819
|
+
const text = only?.type === "text" ? only.text : void 0;
|
|
820
|
+
if (text === void 0) return void 0;
|
|
821
|
+
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1];
|
|
822
|
+
if (body === void 0) return void 0;
|
|
823
|
+
return {
|
|
824
|
+
card: "read",
|
|
825
|
+
path: meta.path,
|
|
826
|
+
offset: meta.offset,
|
|
827
|
+
lines: meta.lines,
|
|
828
|
+
totalLines: meta.totalLines,
|
|
829
|
+
...meta.lang === void 0 ? {} : { lang: meta.lang },
|
|
830
|
+
content: [{
|
|
831
|
+
type: "text",
|
|
832
|
+
text: body
|
|
833
|
+
}]
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
presentCall(args) {
|
|
837
|
+
const { offset, limit } = args;
|
|
838
|
+
const window = limit !== void 0 && limit > 0 ? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` : offset !== void 0 ? ` (from line ${offset})` : "";
|
|
839
|
+
return {
|
|
840
|
+
card: "generic",
|
|
841
|
+
title: `Read ${args.file_path}${window}`,
|
|
842
|
+
kind: "read",
|
|
843
|
+
locations: [{
|
|
844
|
+
path: args.file_path,
|
|
845
|
+
line: offset ?? 1
|
|
846
|
+
}]
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
function computeHunkDiffs(path, before, after) {
|
|
863
|
+
const patch = structuredPatch("", "", before, after, void 0, void 0, { context: 3 });
|
|
864
|
+
const diffs = [];
|
|
865
|
+
for (const hunk of patch.hunks) {
|
|
866
|
+
const oldLines = [];
|
|
867
|
+
const newLines = [];
|
|
868
|
+
for (const line of hunk.lines) {
|
|
869
|
+
if (line.startsWith("\\")) continue;
|
|
870
|
+
const text = line.slice(1);
|
|
871
|
+
if (line.startsWith("-")) oldLines.push(text);
|
|
872
|
+
else if (line.startsWith("+")) newLines.push(text);
|
|
873
|
+
else {
|
|
874
|
+
oldLines.push(text);
|
|
875
|
+
newLines.push(text);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
diffs.push({
|
|
879
|
+
path,
|
|
880
|
+
oldText: oldLines.length > 0 ? oldLines.join("\n") : null,
|
|
881
|
+
newText: newLines.join("\n")
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
return diffs;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function isFileDiff(value) {
|
|
888
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
889
|
+
const { path, oldText, newText } = value;
|
|
890
|
+
return typeof path === "string" && (oldText === null || typeof oldText === "string") && typeof newText === "string";
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
function diffsFromMeta(meta) {
|
|
899
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
|
|
900
|
+
const diffs = meta.diffs;
|
|
901
|
+
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return void 0;
|
|
902
|
+
return diffs;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
function remediateFsError(error, displayPath) {
|
|
924
|
+
if (!(error instanceof FsError)) return error;
|
|
925
|
+
if (error.code === "FS_NOT_OBSERVED") return new FsError(`cannot modify "${displayPath}": file has not been read — read the file, then retry`, error.code, { cause: error });
|
|
926
|
+
if (error.code === "FS_STALE_VERSION") return new FsError(`${error.message} — re-read the file, then retry`, error.code, { cause: error });
|
|
927
|
+
return error;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
function parseWriteArgs(args) {
|
|
944
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
945
|
+
return {
|
|
946
|
+
filePath: args.file_path,
|
|
947
|
+
content: args.content
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
function formatWriteOutput(displayPath, outcome, hint) {
|
|
958
|
+
return `<path>${displayPath}</path>
|
|
959
|
+
<type>file</type>
|
|
960
|
+
<content>
|
|
961
|
+
${outcome.operation === "create" ? "Created" : "Updated"} file${hint === void 0 ? "" : `\n(hint: ${hint})`}
|
|
962
|
+
</content>`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
function applyWriteTool(ctx, sandbox, hints) {
|
|
971
|
+
ctx.systemPrompt.section({
|
|
972
|
+
name: "tool:write",
|
|
973
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_WRITE"),
|
|
974
|
+
text: ({ scope }) => ctx.tools.get("write", scope) === void 0 ? "" : "Read an existing file before overwriting it with write (the default fs-observation-policy requires it)" + (ctx.tools.get("edit", scope) === void 0 ? "" : " and prefer edit for targeted changes") + "."
|
|
975
|
+
});
|
|
976
|
+
ctx.tools.register(defineTool({
|
|
977
|
+
name: "write",
|
|
978
|
+
description: "Create or fully replace a UTF-8 text file.",
|
|
979
|
+
parameters: {
|
|
980
|
+
file_path: {
|
|
981
|
+
type: "string",
|
|
982
|
+
required: true,
|
|
983
|
+
description: "Path to write, resolved by the filesystem backend."
|
|
984
|
+
},
|
|
985
|
+
content: {
|
|
986
|
+
type: "string",
|
|
987
|
+
required: true,
|
|
988
|
+
description: "Full UTF-8 text content to write."
|
|
989
|
+
},
|
|
990
|
+
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
|
|
991
|
+
},
|
|
992
|
+
output: {
|
|
993
|
+
schema: {
|
|
994
|
+
type: "object",
|
|
995
|
+
additionalProperties: false,
|
|
996
|
+
properties: {
|
|
997
|
+
path: {
|
|
998
|
+
type: "string",
|
|
999
|
+
required: true
|
|
1000
|
+
},
|
|
1001
|
+
operation: {
|
|
1002
|
+
type: "string",
|
|
1003
|
+
required: true,
|
|
1004
|
+
enum: ["create", "update"]
|
|
1005
|
+
},
|
|
1006
|
+
before: {
|
|
1007
|
+
required: true,
|
|
1008
|
+
oneOf: [{ type: "string" }, { type: "null" }]
|
|
1009
|
+
},
|
|
1010
|
+
after: {
|
|
1011
|
+
type: "string",
|
|
1012
|
+
required: true
|
|
1013
|
+
},
|
|
1014
|
+
note: { type: "string" }
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
render: (_args, value) => [{
|
|
1018
|
+
type: "text",
|
|
1019
|
+
text: formatWriteOutput(value.path, value, value.note)
|
|
1020
|
+
}],
|
|
1021
|
+
presentationMeta: (args, value) => ({
|
|
1022
|
+
operation: value.operation,
|
|
1023
|
+
diffs: value.before === null ? [] : computeHunkDiffs(args.file_path, value.before, value.after).map(({ path, oldText, newText }) => ({
|
|
1024
|
+
path,
|
|
1025
|
+
oldText,
|
|
1026
|
+
newText
|
|
1027
|
+
}))
|
|
1028
|
+
})
|
|
1029
|
+
},
|
|
1030
|
+
async execute(args, exec) {
|
|
1031
|
+
const input = parseWriteArgs(args);
|
|
1032
|
+
const sandboxPolicy = await sandbox.resolvePolicy("write", args, exec);
|
|
1033
|
+
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot));
|
|
1034
|
+
const intent = await ctx.waterfall("fs/write-intent", target, exec, () => void 0);
|
|
1035
|
+
let outcome;
|
|
1036
|
+
try {
|
|
1037
|
+
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath);
|
|
1040
|
+
}
|
|
1041
|
+
ctx.emit("fs/observed", target, {
|
|
1042
|
+
kind: "present",
|
|
1043
|
+
version: outcome.version
|
|
1044
|
+
}, exec);
|
|
1045
|
+
let note;
|
|
1046
|
+
if (hints.pathHint && outcome.operation === "create") {
|
|
1047
|
+
const similar = await repairTargetPath(ctx, exec, input.filePath, hints.priorPaths(exec.agent?.session));
|
|
1048
|
+
if (similar !== void 0) note = `created a new file; similar existing "${similar.path}" — write to that path if you meant to update it`;
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
path: target.displayPath,
|
|
1052
|
+
operation: outcome.operation,
|
|
1053
|
+
before: outcome.before,
|
|
1054
|
+
after: outcome.after,
|
|
1055
|
+
...note === void 0 ? {} : { note }
|
|
1056
|
+
};
|
|
1057
|
+
},
|
|
1058
|
+
presentCall(args) {
|
|
1059
|
+
return {
|
|
1060
|
+
card: "diff",
|
|
1061
|
+
title: `Write ${args.file_path}`,
|
|
1062
|
+
diffs: [{
|
|
1063
|
+
path: args.file_path,
|
|
1064
|
+
oldText: null,
|
|
1065
|
+
newText: args.content
|
|
1066
|
+
}],
|
|
1067
|
+
locations: [{ path: args.file_path }]
|
|
1068
|
+
};
|
|
1069
|
+
},
|
|
1070
|
+
presentResult(args, result) {
|
|
1071
|
+
if (result.isError) return void 0;
|
|
1072
|
+
const diffs = diffsFromMeta(result.meta) ?? [{
|
|
1073
|
+
path: args.file_path,
|
|
1074
|
+
oldText: null,
|
|
1075
|
+
newText: args.content
|
|
1076
|
+
}];
|
|
1077
|
+
return {
|
|
1078
|
+
card: "diff",
|
|
1079
|
+
title: `Write ${args.file_path}`,
|
|
1080
|
+
diffs
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
}));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
const ABSENT_TARGET_CODES = new Set([
|
|
1104
|
+
"FS_NOT_FOUND",
|
|
1105
|
+
"FS_STALE_VERSION",
|
|
1106
|
+
"FS_EDIT_NOT_FOUND",
|
|
1107
|
+
"FS_NOT_OBSERVED"
|
|
1108
|
+
]);
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
function parseEditArgs(args) {
|
|
1117
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
1118
|
+
if (args.old_string.length === 0) throw new Error("old_string must be a non-empty string");
|
|
1119
|
+
if (args.old_string === args.new_string) throw new Error("old_string and new_string must differ");
|
|
1120
|
+
return {
|
|
1121
|
+
filePath: args.file_path,
|
|
1122
|
+
oldString: args.old_string,
|
|
1123
|
+
newString: args.new_string,
|
|
1124
|
+
replaceAll: args.replace_all ?? false
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
function formatEditOutput(displayPath, replaceAll) {
|
|
1134
|
+
return replaceAll ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` : `The file ${displayPath} has been updated successfully.`;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
async function repairEditFailure(ctx, sandbox, repair, target, input, intent, exec, sandboxPolicy, error) {
|
|
1154
|
+
const mapped = sandbox.mapError(error, sandboxPolicy);
|
|
1155
|
+
if (!repair.enabled || input.replaceAll || !(mapped instanceof FsError) || mapped.code !== "FS_EDIT_NOT_FOUND" && mapped.code !== "FS_AMBIGUOUS_EDIT") throw remediateFsError(mapped, target.displayPath);
|
|
1156
|
+
let content;
|
|
1157
|
+
try {
|
|
1158
|
+
content = await ctx.fs.readText(target, exec.signal);
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw remediateFsError(mapped, target.displayPath);
|
|
1161
|
+
}
|
|
1162
|
+
const attempt = repairStrReplace({
|
|
1163
|
+
content,
|
|
1164
|
+
oldString: input.oldString,
|
|
1165
|
+
newString: input.newString
|
|
1166
|
+
}, repair);
|
|
1167
|
+
const patch = attempt.patch;
|
|
1168
|
+
if (patch !== void 0) return {
|
|
1169
|
+
outcome: await ctx.fs.editText(target, {
|
|
1170
|
+
oldString: patch.oldString,
|
|
1171
|
+
newString: patch.newString,
|
|
1172
|
+
replaceAll: false
|
|
1173
|
+
}, intent, exec.signal, sandboxPolicy),
|
|
1174
|
+
note: patch.note
|
|
1175
|
+
};
|
|
1176
|
+
const hints = attempt.diagnosis.hints;
|
|
1177
|
+
const base = remediateFsError(mapped, target.displayPath);
|
|
1178
|
+
if (hints.length === 0 || !(base instanceof FsError)) throw base;
|
|
1179
|
+
throw new FsError(`${base.message} ${hints.join(" ")}`, base.code, { cause: base });
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
function applyEditTool(ctx, sandbox, repair) {
|
|
1188
|
+
ctx.systemPrompt.section({
|
|
1189
|
+
name: "tool:edit",
|
|
1190
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_EDIT"),
|
|
1191
|
+
text: ({ scope }) => ctx.tools.get("edit", scope) === void 0 ? "" : "Read a file before editing it (the default fs-observation-policy requires it), unless you just created or edited it in this session."
|
|
1192
|
+
});
|
|
1193
|
+
ctx.tools.register(defineTool({
|
|
1194
|
+
name: "edit",
|
|
1195
|
+
description: "Edit an existing UTF-8 text file by replacing literal text.",
|
|
1196
|
+
parameters: {
|
|
1197
|
+
file_path: {
|
|
1198
|
+
type: "string",
|
|
1199
|
+
required: true,
|
|
1200
|
+
description: "Path to edit, resolved by the filesystem backend."
|
|
1201
|
+
},
|
|
1202
|
+
old_string: {
|
|
1203
|
+
type: "string",
|
|
1204
|
+
required: true,
|
|
1205
|
+
description: "Literal text to replace."
|
|
1206
|
+
},
|
|
1207
|
+
new_string: {
|
|
1208
|
+
type: "string",
|
|
1209
|
+
required: true,
|
|
1210
|
+
description: "Literal replacement text. Use an empty string to delete the match."
|
|
1211
|
+
},
|
|
1212
|
+
replace_all: {
|
|
1213
|
+
type: "boolean",
|
|
1214
|
+
description: "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
|
|
1215
|
+
},
|
|
1216
|
+
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}
|
|
1217
|
+
},
|
|
1218
|
+
output: {
|
|
1219
|
+
schema: {
|
|
1220
|
+
type: "object",
|
|
1221
|
+
additionalProperties: false,
|
|
1222
|
+
properties: {
|
|
1223
|
+
path: {
|
|
1224
|
+
type: "string",
|
|
1225
|
+
required: true
|
|
1226
|
+
},
|
|
1227
|
+
before: {
|
|
1228
|
+
type: "string",
|
|
1229
|
+
required: true
|
|
1230
|
+
},
|
|
1231
|
+
after: {
|
|
1232
|
+
type: "string",
|
|
1233
|
+
required: true
|
|
1234
|
+
},
|
|
1235
|
+
note: { type: "string" }
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
render: (args, value) => [{
|
|
1239
|
+
type: "text",
|
|
1240
|
+
text: `${formatEditOutput(value.path, args.replace_all ?? false)}${value.note === void 0 ? "" : ` (repair: ${value.note})`}`
|
|
1241
|
+
}],
|
|
1242
|
+
presentationMeta: (args, value) => ({ diffs: computeHunkDiffs(args.file_path, value.before, value.after).map(({ path, oldText, newText }) => ({
|
|
1243
|
+
path,
|
|
1244
|
+
oldText,
|
|
1245
|
+
newText
|
|
1246
|
+
})) })
|
|
1247
|
+
},
|
|
1248
|
+
async execute(args, exec) {
|
|
1249
|
+
const input = parseEditArgs(args);
|
|
1250
|
+
const sandboxPolicy = await sandbox.resolvePolicy("edit", args, exec);
|
|
1251
|
+
let target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot));
|
|
1252
|
+
let outcome;
|
|
1253
|
+
let note;
|
|
1254
|
+
let intent;
|
|
1255
|
+
const applyAt = async (editTarget) => {
|
|
1256
|
+
const slot = await ctx.waterfall("fs/edit-intent", editTarget, exec, () => void 0);
|
|
1257
|
+
intent = slot;
|
|
1258
|
+
return await ctx.fs.editText(editTarget, {
|
|
1259
|
+
oldString: input.oldString,
|
|
1260
|
+
newString: input.newString,
|
|
1261
|
+
replaceAll: input.replaceAll
|
|
1262
|
+
}, slot, exec.signal, sandboxPolicy);
|
|
1263
|
+
};
|
|
1264
|
+
try {
|
|
1265
|
+
outcome = await applyAt(target);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
const mapped = sandbox.mapError(error, sandboxPolicy);
|
|
1268
|
+
if (repair.enabled && mapped instanceof FsError && ABSENT_TARGET_CODES.has(mapped.code) && await ctx.fs.stat(target, exec.signal) === void 0) {
|
|
1269
|
+
const repaired = await repairTargetPath(ctx, exec, input.filePath, repair.priorPaths(exec.agent?.session));
|
|
1270
|
+
if (repaired === void 0) throw remediateFsError(mapped, target.displayPath);
|
|
1271
|
+
target = await ctx.fs.resolve(repaired.path, sessionResolveOptions(exec, sandboxPolicy?.workspaceRoot));
|
|
1272
|
+
note = repaired.note;
|
|
1273
|
+
try {
|
|
1274
|
+
outcome = await applyAt(target);
|
|
1275
|
+
} catch (retryError) {
|
|
1276
|
+
throw remediateFsError(sandbox.mapError(retryError, sandboxPolicy), target.displayPath);
|
|
1277
|
+
}
|
|
1278
|
+
} else {
|
|
1279
|
+
const attempt = await repairEditFailure(ctx, sandbox, repair, target, input, intent, exec, sandboxPolicy, error);
|
|
1280
|
+
outcome = attempt.outcome;
|
|
1281
|
+
note = attempt.note;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
ctx.emit("fs/observed", target, {
|
|
1285
|
+
kind: "present",
|
|
1286
|
+
version: outcome.version
|
|
1287
|
+
}, exec);
|
|
1288
|
+
return {
|
|
1289
|
+
path: target.displayPath,
|
|
1290
|
+
before: outcome.before,
|
|
1291
|
+
after: outcome.after,
|
|
1292
|
+
...note === void 0 ? {} : { note }
|
|
1293
|
+
};
|
|
1294
|
+
},
|
|
1295
|
+
presentCall(args) {
|
|
1296
|
+
return {
|
|
1297
|
+
card: "diff",
|
|
1298
|
+
title: `Edit ${args.file_path}`,
|
|
1299
|
+
diffs: [{
|
|
1300
|
+
path: args.file_path,
|
|
1301
|
+
oldText: args.old_string || null,
|
|
1302
|
+
newText: args.new_string
|
|
1303
|
+
}],
|
|
1304
|
+
locations: [{ path: args.file_path }]
|
|
1305
|
+
};
|
|
1306
|
+
},
|
|
1307
|
+
presentResult(args, result) {
|
|
1308
|
+
if (result.isError) return void 0;
|
|
1309
|
+
const diffs = diffsFromMeta(result.meta);
|
|
1310
|
+
if (diffs === void 0) return void 0;
|
|
1311
|
+
return {
|
|
1312
|
+
card: "diff",
|
|
1313
|
+
title: `Edit ${args.file_path}`,
|
|
1314
|
+
diffs
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
}));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
const IMAGE_EXTENSIONS = {
|
|
1335
|
+
".png": "image/png",
|
|
1336
|
+
".jpg": "image/jpeg",
|
|
1337
|
+
".jpeg": "image/jpeg",
|
|
1338
|
+
".webp": "image/webp",
|
|
1339
|
+
".gif": "image/gif"
|
|
1340
|
+
};
|
|
1341
|
+
const PNG_SIGNATURE = [
|
|
1342
|
+
137,
|
|
1343
|
+
80,
|
|
1344
|
+
78,
|
|
1345
|
+
71,
|
|
1346
|
+
13,
|
|
1347
|
+
10,
|
|
1348
|
+
26,
|
|
1349
|
+
10
|
|
1350
|
+
];
|
|
1351
|
+
const JPEG_SIGNATURE = [
|
|
1352
|
+
255,
|
|
1353
|
+
216,
|
|
1354
|
+
255
|
|
1355
|
+
];
|
|
1356
|
+
function matchesBytes(data, offset, expected) {
|
|
1357
|
+
if (data.byteLength < offset + expected.length) return false;
|
|
1358
|
+
return expected.every((byte, index) => data[offset + index] === byte);
|
|
1359
|
+
}
|
|
1360
|
+
function matchesAscii(data, offset, value) {
|
|
1361
|
+
if (data.byteLength < offset + value.length) return false;
|
|
1362
|
+
for (let index = 0; index < value.length; index += 1) if (data[offset + index] !== value.charCodeAt(index)) return false;
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
function sniffImageMediaType(data) {
|
|
1371
|
+
if (matchesBytes(data, 0, PNG_SIGNATURE)) return "image/png";
|
|
1372
|
+
if (matchesBytes(data, 0, JPEG_SIGNATURE)) return "image/jpeg";
|
|
1373
|
+
if (matchesAscii(data, 0, "GIF87a") || matchesAscii(data, 0, "GIF89a")) return "image/gif";
|
|
1374
|
+
if (matchesAscii(data, 0, "RIFF") && matchesAscii(data, 8, "WEBP")) return "image/webp";
|
|
1375
|
+
}
|
|
1376
|
+
const IMAGE_VALUE_SCHEMA = {
|
|
1377
|
+
type: "object",
|
|
1378
|
+
additionalProperties: false,
|
|
1379
|
+
required: true,
|
|
1380
|
+
properties: {
|
|
1381
|
+
attachmentId: {
|
|
1382
|
+
type: "string",
|
|
1383
|
+
required: true
|
|
1384
|
+
},
|
|
1385
|
+
mediaType: {
|
|
1386
|
+
type: "string",
|
|
1387
|
+
enum: [
|
|
1388
|
+
"image/png",
|
|
1389
|
+
"image/jpeg",
|
|
1390
|
+
"image/webp",
|
|
1391
|
+
"image/gif"
|
|
1392
|
+
],
|
|
1393
|
+
required: true
|
|
1394
|
+
},
|
|
1395
|
+
bytes: {
|
|
1396
|
+
type: "integer",
|
|
1397
|
+
required: true
|
|
1398
|
+
},
|
|
1399
|
+
width: {
|
|
1400
|
+
type: "integer",
|
|
1401
|
+
required: true
|
|
1402
|
+
},
|
|
1403
|
+
height: {
|
|
1404
|
+
type: "integer",
|
|
1405
|
+
required: true
|
|
1406
|
+
},
|
|
1407
|
+
name: { type: "string" },
|
|
1408
|
+
originalDimensions: {
|
|
1409
|
+
type: "object",
|
|
1410
|
+
additionalProperties: false,
|
|
1411
|
+
properties: {
|
|
1412
|
+
width: {
|
|
1413
|
+
type: "integer",
|
|
1414
|
+
required: true
|
|
1415
|
+
},
|
|
1416
|
+
height: {
|
|
1417
|
+
type: "integer",
|
|
1418
|
+
required: true
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
function imageMediaTypeForPath(filePath) {
|
|
1430
|
+
return IMAGE_EXTENSIONS[extname(filePath).toLowerCase()];
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
async function assertImageCapableRoute(ctx, exec, requestedPath) {
|
|
1441
|
+
const routed = exec.agent?.session.requestHeader()?.config;
|
|
1442
|
+
const provider = routed?.provider ?? exec.agent?.options.provider;
|
|
1443
|
+
const model = routed?.model ?? exec.agent?.options.model;
|
|
1444
|
+
const llm = ctx.get("llm");
|
|
1445
|
+
if (provider === void 0 || model === void 0 || llm === void 0) throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`);
|
|
1446
|
+
const active = await llm.resolveModelInfo(provider, model, exec.signal);
|
|
1447
|
+
if (active.inputModalities === void 0 || !active.inputModalities.includes("image")) throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function assertDeploymentAccepts(attachments, mediaType, displayPath) {
|
|
1451
|
+
if (!attachments.imageLimits.mediaTypes.includes(mediaType)) throw new Error(`cannot read "${displayPath}": ${mediaType} images are not accepted by this deployment`);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
function imageRefFromValue(image) {
|
|
1460
|
+
return {
|
|
1461
|
+
attachmentId: AttachmentId(image.attachmentId),
|
|
1462
|
+
mediaType: image.mediaType,
|
|
1463
|
+
bytes: image.bytes,
|
|
1464
|
+
width: image.width,
|
|
1465
|
+
height: image.height,
|
|
1466
|
+
...image.name === void 0 ? {} : { name: image.name },
|
|
1467
|
+
...image.originalDimensions === void 0 ? {} : { originalDimensions: { ...image.originalDimensions } }
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
function formatImageReadOutput(displayPath, image) {
|
|
1479
|
+
let scaled = "";
|
|
1480
|
+
if (image.originalDimensions !== void 0) {
|
|
1481
|
+
const x = (image.originalDimensions.width / image.width).toFixed(2);
|
|
1482
|
+
const y = (image.originalDimensions.height / image.height).toFixed(2);
|
|
1483
|
+
const advice = x === y ? `multiply coordinates by ${x}` : `multiply x coordinates by ${x} and y coordinates by ${y}`;
|
|
1484
|
+
scaled = ` (downscaled from ${image.originalDimensions.width}x${image.originalDimensions.height} px; ${advice} to locate features in the original file)`;
|
|
1485
|
+
}
|
|
1486
|
+
return `<path>${displayPath}</path>
|
|
1487
|
+
<type>image</type>
|
|
1488
|
+
<content>
|
|
1489
|
+
${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes${scaled}
|
|
1490
|
+
</content>`;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
function imageReadContent(value) {
|
|
1498
|
+
return [{
|
|
1499
|
+
type: "text",
|
|
1500
|
+
text: formatImageReadOutput(value.path, value.image)
|
|
1501
|
+
}, {
|
|
1502
|
+
type: "image",
|
|
1503
|
+
attachment: imageRefFromValue(value.image)
|
|
1504
|
+
}];
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
function applyReadImageTool(ctx) {
|
|
1516
|
+
ctx.tools.register(defineTool({
|
|
1517
|
+
name: "read_image",
|
|
1518
|
+
description: "Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.",
|
|
1519
|
+
parameters: { file_path: {
|
|
1520
|
+
type: "string",
|
|
1521
|
+
required: true,
|
|
1522
|
+
description: "Path to the image file, resolved by the filesystem backend."
|
|
1523
|
+
} },
|
|
1524
|
+
output: {
|
|
1525
|
+
schema: {
|
|
1526
|
+
type: "object",
|
|
1527
|
+
additionalProperties: false,
|
|
1528
|
+
properties: {
|
|
1529
|
+
path: {
|
|
1530
|
+
type: "string",
|
|
1531
|
+
required: true
|
|
1532
|
+
},
|
|
1533
|
+
image: IMAGE_VALUE_SCHEMA
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
render: (_args, value) => imageReadContent(value),
|
|
1537
|
+
presentationMeta: (_args, value) => ({ path: value.path })
|
|
1538
|
+
},
|
|
1539
|
+
isConcurrencySafe: () => true,
|
|
1540
|
+
async execute(args, exec) {
|
|
1541
|
+
if (args.file_path.trim().length === 0) throw new Error("file_path must be a non-empty string");
|
|
1542
|
+
const extension = extname(args.file_path).toLowerCase();
|
|
1543
|
+
const declared = imageMediaTypeForPath(args.file_path);
|
|
1544
|
+
if (declared === void 0 && extension !== "") throw new Error(`cannot read "${args.file_path}": the ${extension} extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`);
|
|
1545
|
+
const attachments = ctx.get("attachments");
|
|
1546
|
+
if (attachments === void 0) throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`);
|
|
1547
|
+
if (declared !== void 0) assertDeploymentAccepts(attachments, declared, args.file_path);
|
|
1548
|
+
await assertImageCapableRoute(ctx, exec, args.file_path);
|
|
1549
|
+
const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path);
|
|
1550
|
+
const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes);
|
|
1551
|
+
const data = await ctx.fs.readBytes(target, exec.signal, byteCap);
|
|
1552
|
+
const mediaType = declared ?? sniffImageMediaType(data);
|
|
1553
|
+
if (mediaType === void 0) throw new Error(`cannot read "${target.displayPath}": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`);
|
|
1554
|
+
if (declared === void 0) assertDeploymentAccepts(attachments, mediaType, target.displayPath);
|
|
1555
|
+
let ref;
|
|
1556
|
+
try {
|
|
1557
|
+
ref = await attachments.saveImage({
|
|
1558
|
+
data,
|
|
1559
|
+
mediaType,
|
|
1560
|
+
name: basename(target.displayPath)
|
|
1561
|
+
});
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
if (!(error instanceof AttachmentError)) throw error;
|
|
1564
|
+
if (error.code === "IMAGE_DIMENSION_TOO_LARGE") throw new Error(`cannot read "${target.displayPath}": at least one image side exceeds the ${attachments.imageLimits.maxImageDimension}px limit; downscale the image and read the smaller copy`, { cause: error });
|
|
1565
|
+
if (error.code === "IMAGE_TOO_MANY_PIXELS") throw new Error(`cannot read "${target.displayPath}": the image exceeds the ${attachments.imageLimits.maxImagePixels}-pixel decoded-size limit; downscale the image and read the smaller copy`, { cause: error });
|
|
1566
|
+
if (error.code === "IMAGE_TOO_LARGE") throw new Error(`cannot read "${target.displayPath}": the image cannot be stored within the deployment's byte limits; downscale the image and read the smaller copy`, { cause: error });
|
|
1567
|
+
if (error.code === "ATTACHMENT_WRITE_FAILED" && /16-bit PNG/iu.test(error.message)) throw new Error(`cannot read "${target.displayPath}": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`, { cause: error });
|
|
1568
|
+
if (error.code === "INVALID_IMAGE" && declared === void 0) throw new Error(`cannot read "${target.displayPath}": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, { cause: error });
|
|
1569
|
+
if (error.code !== "IMAGE_TYPE_MISMATCH") throw error;
|
|
1570
|
+
if (declared === void 0) throw new Error(`cannot read "${target.displayPath}": the file signature claims ${mediaType}, but the bytes decode as a different image format; the file may be corrupt`, { cause: error });
|
|
1571
|
+
throw new Error(`cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, { cause: error });
|
|
1572
|
+
}
|
|
1573
|
+
ctx.emit("fs/observed", target, {
|
|
1574
|
+
kind: "present",
|
|
1575
|
+
version: info.version
|
|
1576
|
+
}, exec);
|
|
1577
|
+
return {
|
|
1578
|
+
path: target.displayPath,
|
|
1579
|
+
image: {
|
|
1580
|
+
attachmentId: ref.attachmentId,
|
|
1581
|
+
mediaType: ref.mediaType,
|
|
1582
|
+
bytes: ref.bytes,
|
|
1583
|
+
width: ref.width,
|
|
1584
|
+
height: ref.height,
|
|
1585
|
+
...ref.name === void 0 ? {} : { name: ref.name },
|
|
1586
|
+
...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
},
|
|
1590
|
+
presentCall(args) {
|
|
1591
|
+
return {
|
|
1592
|
+
card: "generic",
|
|
1593
|
+
title: `Read image ${args.file_path}`,
|
|
1594
|
+
kind: "read",
|
|
1595
|
+
locations: [{ path: args.file_path }]
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
}));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
var FsSandboxController = class {
|
|
1619
|
+
ctx;
|
|
1620
|
+
|
|
1621
|
+
escalationModes;
|
|
1622
|
+
|
|
1623
|
+
policy;
|
|
1624
|
+
constructor(ctx) {
|
|
1625
|
+
this.ctx = ctx;
|
|
1626
|
+
const defaultMode = ctx.fs.sandboxMode;
|
|
1627
|
+
this.escalationModes = defaultMode === void 0 ? [] : ESCALATION_TARGETS;
|
|
1628
|
+
this.policy = defaultMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
|
|
1629
|
+
if (defaultMode !== void 0 && this.policy === void 0) throw new Error("tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing");
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
schemaFields() {
|
|
1639
|
+
return {
|
|
1640
|
+
sandbox_permissions: {
|
|
1641
|
+
type: "string",
|
|
1642
|
+
enum: [...this.escalationModes],
|
|
1643
|
+
description: sandboxPermissionsDescription("operation")
|
|
1644
|
+
},
|
|
1645
|
+
justification: {
|
|
1646
|
+
type: "string",
|
|
1647
|
+
description: "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. Use the language of the user’s current request."
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
async resolvePolicy(toolName, args, exec) {
|
|
1665
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification);
|
|
1666
|
+
const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} });
|
|
1667
|
+
if (args.sandbox_permissions === void 0 || args.justification === void 0) return standingPolicy;
|
|
1668
|
+
if (this.escalationModes.length === 0) throw new Error("sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)");
|
|
1669
|
+
const policy = standingPolicy;
|
|
1670
|
+
const approvedMode = await approveEscalation({
|
|
1671
|
+
requestedMode: args.sandbox_permissions,
|
|
1672
|
+
justification: args.justification,
|
|
1673
|
+
effectiveMode: policy.mode,
|
|
1674
|
+
subject: "operation"
|
|
1675
|
+
}, {
|
|
1676
|
+
approver: this.ctx.get("approval"),
|
|
1677
|
+
agent: exec.agent,
|
|
1678
|
+
callId: exec.callId,
|
|
1679
|
+
toolName,
|
|
1680
|
+
signal: exec.signal
|
|
1681
|
+
});
|
|
1682
|
+
return {
|
|
1683
|
+
...policy,
|
|
1684
|
+
mode: approvedMode
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
mapError(error, policy) {
|
|
1702
|
+
if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
|
|
1703
|
+
const mode = policy.mode;
|
|
1704
|
+
return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker("operation")}`, "FS_SANDBOX_DENIED", { cause: error });
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
const name = "tool-fs";
|
|
1717
|
+
|
|
1718
|
+
const inject = [
|
|
1719
|
+
"tools",
|
|
1720
|
+
"fs",
|
|
1721
|
+
"systemPrompt"
|
|
1722
|
+
];
|
|
1723
|
+
|
|
1724
|
+
const DEFAULT_DUPLICATE_STEPS = 4;
|
|
1725
|
+
|
|
1726
|
+
const DEFAULT_COMPLETION_MAX_LINES = 40;
|
|
1727
|
+
const Config = z.object({
|
|
1728
|
+
readLimit: z.number().default(READ_LIMIT),
|
|
1729
|
+
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
|
|
1730
|
+
readMaxBytes: z.number().default(READ_MAX_BYTES),
|
|
1731
|
+
readStreamMinSize: z.number().default(STREAM_MIN_SIZE),
|
|
1732
|
+
readRepair: z.object({
|
|
1733
|
+
enabled: z.boolean().default(true),
|
|
1734
|
+
duplicate: z.object({
|
|
1735
|
+
enabled: z.boolean().default(true),
|
|
1736
|
+
maxSteps: z.number().default(DEFAULT_DUPLICATE_STEPS)
|
|
1737
|
+
}).default({
|
|
1738
|
+
enabled: true,
|
|
1739
|
+
maxSteps: DEFAULT_DUPLICATE_STEPS
|
|
1740
|
+
}),
|
|
1741
|
+
completion: z.object({
|
|
1742
|
+
enabled: z.boolean().default(true),
|
|
1743
|
+
maxLines: z.number().default(DEFAULT_COMPLETION_MAX_LINES)
|
|
1744
|
+
}).default({
|
|
1745
|
+
enabled: true,
|
|
1746
|
+
maxLines: DEFAULT_COMPLETION_MAX_LINES
|
|
1747
|
+
})
|
|
1748
|
+
}).default({
|
|
1749
|
+
enabled: true,
|
|
1750
|
+
duplicate: {
|
|
1751
|
+
enabled: true,
|
|
1752
|
+
maxSteps: DEFAULT_DUPLICATE_STEPS
|
|
1753
|
+
},
|
|
1754
|
+
completion: {
|
|
1755
|
+
enabled: true,
|
|
1756
|
+
maxLines: DEFAULT_COMPLETION_MAX_LINES
|
|
1757
|
+
}
|
|
1758
|
+
}),
|
|
1759
|
+
editRepair: z.object({
|
|
1760
|
+
enabled: z.boolean().default(true),
|
|
1761
|
+
maxFileChars: z.number().default(DEFAULT_MAX_FILE_CHARS),
|
|
1762
|
+
maxWindowLines: z.number().default(DEFAULT_MAX_WINDOW_LINES)
|
|
1763
|
+
}).default({
|
|
1764
|
+
enabled: true,
|
|
1765
|
+
maxFileChars: DEFAULT_MAX_FILE_CHARS,
|
|
1766
|
+
maxWindowLines: DEFAULT_MAX_WINDOW_LINES
|
|
1767
|
+
}),
|
|
1768
|
+
writeRepair: z.object({ pathHint: z.boolean().default(true) }).default({ pathHint: true })
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
function assertPositiveInteger(name, value) {
|
|
1772
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`tool-fs: ${name} must be a positive integer`);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function apply(ctx, config) {
|
|
1776
|
+
const resolved = config;
|
|
1777
|
+
assertPositiveInteger("readLimit", resolved.readLimit);
|
|
1778
|
+
assertPositiveInteger("readMaxLineLength", resolved.readMaxLineLength);
|
|
1779
|
+
assertPositiveInteger("readMaxBytes", resolved.readMaxBytes);
|
|
1780
|
+
assertPositiveInteger("readStreamMinSize", resolved.readStreamMinSize);
|
|
1781
|
+
const editRepair = resolved.editRepair;
|
|
1782
|
+
const repairMaxFileChars = editRepair.maxFileChars ?? DEFAULT_MAX_FILE_CHARS;
|
|
1783
|
+
const repairMaxWindowLines = editRepair.maxWindowLines ?? DEFAULT_MAX_WINDOW_LINES;
|
|
1784
|
+
assertPositiveInteger("editRepair.maxFileChars", repairMaxFileChars);
|
|
1785
|
+
assertPositiveInteger("editRepair.maxWindowLines", repairMaxWindowLines);
|
|
1786
|
+
const sessionPaths = createSessionPathProjection(["file_path", "path"]);
|
|
1787
|
+
const readWindows = createReadWindowProjection();
|
|
1788
|
+
const disclosures = createDisclosureLedger();
|
|
1789
|
+
ctx.on("session/event", (session, event) => {
|
|
1790
|
+
sessionPaths.observe(session, event);
|
|
1791
|
+
readWindows.observe(session, event);
|
|
1792
|
+
disclosures.observe(session, event);
|
|
1793
|
+
});
|
|
1794
|
+
const readRepair = resolved.readRepair;
|
|
1795
|
+
const duplicateEnabled = readRepair.enabled === true && readRepair.duplicate?.enabled === true;
|
|
1796
|
+
const duplicateSteps = readRepair.duplicate?.maxSteps ?? DEFAULT_DUPLICATE_STEPS;
|
|
1797
|
+
const completionMaxLines = readRepair.completion?.maxLines ?? DEFAULT_COMPLETION_MAX_LINES;
|
|
1798
|
+
assertPositiveInteger("readRepair.duplicate.maxSteps", duplicateSteps);
|
|
1799
|
+
assertPositiveInteger("readRepair.completion.maxLines", completionMaxLines);
|
|
1800
|
+
applyReadTool(ctx, {
|
|
1801
|
+
limit: resolved.readLimit,
|
|
1802
|
+
maxLineLength: resolved.readMaxLineLength,
|
|
1803
|
+
maxBytes: resolved.readMaxBytes,
|
|
1804
|
+
streamMinSize: resolved.readStreamMinSize
|
|
1805
|
+
}, {
|
|
1806
|
+
enabled: readRepair.enabled ?? true,
|
|
1807
|
+
priorPaths: (session, limit) => sessionPaths.priorPaths(session, limit),
|
|
1808
|
+
duplicate: {
|
|
1809
|
+
enabled: duplicateEnabled,
|
|
1810
|
+
maxSteps: duplicateSteps,
|
|
1811
|
+
judge: (session, path, version, window) => readWindows.judge(session, path, version, window, duplicateSteps),
|
|
1812
|
+
record: (session, path, observation) => {
|
|
1813
|
+
readWindows.record(session, path, observation);
|
|
1814
|
+
}
|
|
1815
|
+
},
|
|
1816
|
+
completion: {
|
|
1817
|
+
enabled: readRepair.enabled === true && readRepair.completion?.enabled === true,
|
|
1818
|
+
maxLines: completionMaxLines
|
|
1819
|
+
},
|
|
1820
|
+
claimDisclosure: (session, key) => disclosures.claim(session, key)
|
|
1821
|
+
});
|
|
1822
|
+
ctx.inject(["attachments"], (imageCtx) => {
|
|
1823
|
+
applyReadImageTool(imageCtx);
|
|
1824
|
+
});
|
|
1825
|
+
const sandbox = new FsSandboxController(ctx);
|
|
1826
|
+
applyWriteTool(ctx, sandbox, {
|
|
1827
|
+
pathHint: resolved.writeRepair.pathHint ?? true,
|
|
1828
|
+
priorPaths: (session, limit) => sessionPaths.priorPaths(session, limit)
|
|
1829
|
+
});
|
|
1830
|
+
applyEditTool(ctx, sandbox, {
|
|
1831
|
+
enabled: editRepair.enabled ?? true,
|
|
1832
|
+
maxFileChars: repairMaxFileChars,
|
|
1833
|
+
maxWindowLines: repairMaxWindowLines,
|
|
1834
|
+
priorPaths: (session, limit) => sessionPaths.priorPaths(session, limit)
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
export { Config, apply, inject, name };
|