@arnilo/prism-coding-agent 0.0.5 → 0.0.6
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/CHANGELOG.md +14 -1
- package/README.md +10 -6
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +25 -0
- package/dist/edit-diff.d.ts +0 -10
- package/dist/edit-diff.js +1 -33
- package/dist/edit.d.ts +23 -4
- package/dist/edit.js +55 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/limits.d.ts +22 -0
- package/dist/limits.js +28 -0
- package/dist/output-accumulator.d.ts +17 -10
- package/dist/output-accumulator.js +127 -86
- package/dist/read.d.ts +39 -8
- package/dist/read.js +174 -60
- package/dist/shell.d.ts +4 -0
- package/dist/shell.js +98 -74
- package/dist/truncate.d.ts +7 -4
- package/dist/truncate.js +6 -6
- package/dist/write.d.ts +9 -2
- package/dist/write.js +10 -5
- package/package.json +2 -2
package/dist/read.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Read tool: read a file from the host filesystem.
|
|
3
3
|
*
|
|
4
4
|
* Behavioral port of pi's core/tools/read for @arnilo/prism-coding-agent, adapted to Prism's
|
|
5
|
-
* `ToolDefinition` contract.
|
|
6
|
-
*
|
|
5
|
+
* `ToolDefinition` contract. Keeps pi's offset/limit continuation behavior while streaming one
|
|
6
|
+
* bounded text page; image path remains magic-byte MIME → bounded `ImageContent`. Drops pi's
|
|
7
7
|
* TUI (`renderCall`/`renderResult`, theme/syntax-highlight, compact classifications, key hints) and
|
|
8
8
|
* the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
|
|
9
9
|
*
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { Buffer } from "node:buffer";
|
|
23
23
|
import { constants } from "node:fs";
|
|
24
|
-
import { access as fsAccess, open,
|
|
24
|
+
import { access as fsAccess, open, stat as fsStat } from "node:fs/promises";
|
|
25
|
+
import { readFileBounded } from "./bounded-file.js";
|
|
25
26
|
import { enforceExecutionPolicy } from "./execution-policy.js";
|
|
26
27
|
import { resolveReadPathAsync } from "./path-utils.js";
|
|
27
|
-
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
|
|
28
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_BYTES, HARD_MAX_IMAGE_BYTES, HARD_MAX_LINES, HARD_MAX_TEXT_SCAN_BYTES, validateCodingLimit, } from "./limits.js";
|
|
29
|
+
import { formatSize } from "./truncate.js";
|
|
28
30
|
// --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
|
|
29
31
|
const IMAGE_TYPE_SNIFF_BYTES = 4100;
|
|
30
32
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
@@ -143,9 +145,119 @@ function startsWithAscii(buffer, offset, text) {
|
|
|
143
145
|
}
|
|
144
146
|
// --- read tool ---
|
|
145
147
|
/** Default maximum image file size before read/transform (10 MB). */
|
|
146
|
-
export
|
|
148
|
+
export { DEFAULT_MAX_IMAGE_BYTES } from "./limits.js";
|
|
149
|
+
async function readLocalTextPage(path, options) {
|
|
150
|
+
const handle = await open(path, "r");
|
|
151
|
+
let fileSize;
|
|
152
|
+
try {
|
|
153
|
+
fileSize = (await handle.stat()).size;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
await handle.close();
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let retainedBytes = 0;
|
|
161
|
+
let outputLines = 0;
|
|
162
|
+
let lineNumber = 1;
|
|
163
|
+
let scannedBytes = 0;
|
|
164
|
+
let lineChunkStart = 0;
|
|
165
|
+
let lineByteStart = 0;
|
|
166
|
+
const targetLines = options.limit ?? options.maxLines;
|
|
167
|
+
const content = (trimFinalLf) => {
|
|
168
|
+
const buffer = Buffer.concat(chunks, retainedBytes);
|
|
169
|
+
const end = trimFinalLf && buffer[buffer.length - 1] === 0x0a ? buffer.length - 1 : buffer.length;
|
|
170
|
+
return buffer.subarray(0, end).toString("utf-8");
|
|
171
|
+
};
|
|
172
|
+
const partial = (truncatedBy, nextOffset, firstLineExceedsLimit = false, totalLines) => ({
|
|
173
|
+
content: firstLineExceedsLimit ? "" : content(true),
|
|
174
|
+
startLine: options.offset,
|
|
175
|
+
outputLines,
|
|
176
|
+
hasMore: true,
|
|
177
|
+
nextOffset,
|
|
178
|
+
truncatedBy,
|
|
179
|
+
firstLineExceedsLimit,
|
|
180
|
+
scannedBytes,
|
|
181
|
+
totalLines,
|
|
182
|
+
totalBytes: fileSize,
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
186
|
+
while (true) {
|
|
187
|
+
if (options.signal?.aborted)
|
|
188
|
+
throw new Error("Operation aborted");
|
|
189
|
+
if (scannedBytes >= options.maxScanBytes) {
|
|
190
|
+
if (scannedBytes >= fileSize)
|
|
191
|
+
break;
|
|
192
|
+
throw new Error(`Text read exceeded ${formatSize(options.maxScanBytes)} scan limit before reaching the requested page`);
|
|
193
|
+
}
|
|
194
|
+
const length = Math.min(buffer.length, options.maxScanBytes - scannedBytes);
|
|
195
|
+
const { bytesRead } = await handle.read(buffer, 0, length, null);
|
|
196
|
+
if (bytesRead === 0)
|
|
197
|
+
break;
|
|
198
|
+
scannedBytes += bytesRead;
|
|
199
|
+
let cursor = 0;
|
|
200
|
+
while (cursor < bytesRead) {
|
|
201
|
+
const newline = buffer.indexOf(0x0a, cursor);
|
|
202
|
+
const end = newline === -1 || newline >= bytesRead ? bytesRead : newline + 1;
|
|
203
|
+
const selected = lineNumber >= options.offset && outputLines < targetLines;
|
|
204
|
+
if (selected) {
|
|
205
|
+
const piece = buffer.subarray(cursor, end);
|
|
206
|
+
if (retainedBytes + piece.length > options.maxBytes) {
|
|
207
|
+
chunks.length = lineChunkStart;
|
|
208
|
+
retainedBytes = lineByteStart;
|
|
209
|
+
return partial("bytes", lineNumber, outputLines === 0);
|
|
210
|
+
}
|
|
211
|
+
chunks.push(Buffer.from(piece));
|
|
212
|
+
retainedBytes += piece.length;
|
|
213
|
+
}
|
|
214
|
+
cursor = end;
|
|
215
|
+
if (newline === -1 || newline >= bytesRead)
|
|
216
|
+
break;
|
|
217
|
+
if (selected) {
|
|
218
|
+
outputLines++;
|
|
219
|
+
if (outputLines >= targetLines) {
|
|
220
|
+
let totalLines;
|
|
221
|
+
if (scannedBytes === fileSize) {
|
|
222
|
+
let remainingNewlines = 0;
|
|
223
|
+
for (let index = cursor; index < bytesRead; index++) {
|
|
224
|
+
if (buffer[index] === 0x0a)
|
|
225
|
+
remainingNewlines++;
|
|
226
|
+
}
|
|
227
|
+
totalLines = lineNumber + remainingNewlines + 1;
|
|
228
|
+
}
|
|
229
|
+
return partial(options.limit === undefined ? "lines" : null, lineNumber + 1, false, totalLines);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
lineNumber++;
|
|
233
|
+
lineChunkStart = chunks.length;
|
|
234
|
+
lineByteStart = retainedBytes;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (lineNumber < options.offset) {
|
|
238
|
+
throw new Error(`Offset ${options.offset} is beyond end of file (${lineNumber} lines total)`);
|
|
239
|
+
}
|
|
240
|
+
if (lineNumber >= options.offset && outputLines < targetLines)
|
|
241
|
+
outputLines++;
|
|
242
|
+
return {
|
|
243
|
+
content: content(false),
|
|
244
|
+
startLine: options.offset,
|
|
245
|
+
outputLines,
|
|
246
|
+
hasMore: false,
|
|
247
|
+
truncatedBy: null,
|
|
248
|
+
firstLineExceedsLimit: false,
|
|
249
|
+
scannedBytes,
|
|
250
|
+
totalLines: lineNumber,
|
|
251
|
+
totalBytes: scannedBytes,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await handle.close();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
147
258
|
const defaultReadOperations = {
|
|
148
|
-
readFile: (path) =>
|
|
259
|
+
readFile: (path, options) => readFileBounded(path, options.maxBytes, options.signal),
|
|
260
|
+
readText: readLocalTextPage,
|
|
149
261
|
access: (path) => fsAccess(path, constants.R_OK),
|
|
150
262
|
statFile: async (path) => {
|
|
151
263
|
const info = await fsStat(path);
|
|
@@ -160,13 +272,14 @@ async function loadImageBuffer(absolutePath, mimeType, ops, options) {
|
|
|
160
272
|
if (options.signal?.aborted) {
|
|
161
273
|
throw new Error("Operation aborted");
|
|
162
274
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
throw new Error(imageSizeError(size, options.maxImageBytes));
|
|
167
|
-
}
|
|
275
|
+
const { size } = await ops.statFile(absolutePath, { signal: options.signal });
|
|
276
|
+
if (size > options.maxImageBytes) {
|
|
277
|
+
throw new Error(imageSizeError(size, options.maxImageBytes));
|
|
168
278
|
}
|
|
169
|
-
let buffer = await ops.readFile(absolutePath
|
|
279
|
+
let buffer = await ops.readFile(absolutePath, {
|
|
280
|
+
maxBytes: options.maxImageBytes,
|
|
281
|
+
signal: options.signal,
|
|
282
|
+
});
|
|
170
283
|
if (buffer.length > options.maxImageBytes) {
|
|
171
284
|
throw new Error(imageSizeError(buffer.length, options.maxImageBytes));
|
|
172
285
|
}
|
|
@@ -195,10 +308,11 @@ function errorResult(toolCallId, message) {
|
|
|
195
308
|
};
|
|
196
309
|
}
|
|
197
310
|
export function createReadTool(cwd, options) {
|
|
198
|
-
const ops =
|
|
199
|
-
const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
|
|
200
|
-
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
201
|
-
const maxImageBytes = options?.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
|
|
311
|
+
const ops = options?.operations ?? defaultReadOperations;
|
|
312
|
+
const maxLines = validateCodingLimit("maxLines", options?.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
|
|
313
|
+
const maxBytes = validateCodingLimit("maxBytes", options?.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
|
|
314
|
+
const maxImageBytes = validateCodingLimit("maxImageBytes", options?.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, HARD_MAX_IMAGE_BYTES);
|
|
315
|
+
const maxScanBytes = validateCodingLimit("maxScanBytes", options?.maxScanBytes ?? DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_TEXT_SCAN_BYTES);
|
|
202
316
|
return {
|
|
203
317
|
name: "read",
|
|
204
318
|
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
|
@@ -224,6 +338,10 @@ export function createReadTool(cwd, options) {
|
|
|
224
338
|
return errorResult(toolCallId, "path is required and must be a non-empty string.");
|
|
225
339
|
}
|
|
226
340
|
try {
|
|
341
|
+
const startLine = validateCodingLimit("offset", offset ?? 1, Number.MAX_SAFE_INTEGER);
|
|
342
|
+
const requestedLines = limit === undefined
|
|
343
|
+
? undefined
|
|
344
|
+
: validateCodingLimit("limit", limit, HARD_MAX_LINES);
|
|
227
345
|
const absolutePath = await resolveReadPathAsync(path, cwd);
|
|
228
346
|
const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
|
|
229
347
|
kind: "read",
|
|
@@ -235,7 +353,7 @@ export function createReadTool(cwd, options) {
|
|
|
235
353
|
if (!policyCheck.allowed)
|
|
236
354
|
return policyCheck.result;
|
|
237
355
|
const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
|
|
238
|
-
await ops.access(allowedPath);
|
|
356
|
+
await ops.access(allowedPath, { signal: context.signal });
|
|
239
357
|
const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(allowedPath) : undefined;
|
|
240
358
|
if (mimeType) {
|
|
241
359
|
const { buffer, resized } = await loadImageBuffer(allowedPath, mimeType, ops, {
|
|
@@ -254,59 +372,55 @@ export function createReadTool(cwd, options) {
|
|
|
254
372
|
metadata: { image: { mimeType, resized, bytes: buffer.length } },
|
|
255
373
|
};
|
|
256
374
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
375
|
+
const page = await ops.readText(allowedPath, {
|
|
376
|
+
offset: startLine,
|
|
377
|
+
limit: requestedLines,
|
|
378
|
+
maxLines,
|
|
379
|
+
maxBytes,
|
|
380
|
+
maxScanBytes,
|
|
381
|
+
signal: context.signal,
|
|
382
|
+
});
|
|
383
|
+
const contentBytes = Buffer.byteLength(page.content, "utf-8");
|
|
384
|
+
if (contentBytes > maxBytes || page.outputLines > (requestedLines ?? maxLines)) {
|
|
385
|
+
throw new Error("ReadOperations.readText returned data beyond the requested bounds");
|
|
267
386
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
387
|
+
const totalLines = page.totalLines ?? page.startLine + page.outputLines + (page.hasMore ? 1 : 0) - 1;
|
|
388
|
+
const truncation = {
|
|
389
|
+
content: page.content,
|
|
390
|
+
truncated: page.truncatedBy !== null,
|
|
391
|
+
truncatedBy: page.truncatedBy,
|
|
392
|
+
totalLines,
|
|
393
|
+
totalBytes: page.totalBytes ?? page.scannedBytes,
|
|
394
|
+
outputLines: page.outputLines,
|
|
395
|
+
outputBytes: contentBytes,
|
|
396
|
+
lastLinePartial: false,
|
|
397
|
+
firstLineExceedsLimit: page.firstLineExceedsLimit,
|
|
398
|
+
maxLines,
|
|
399
|
+
maxBytes,
|
|
400
|
+
totalLinesKnown: page.totalLines !== undefined,
|
|
401
|
+
totalBytesKnown: page.totalBytes !== undefined,
|
|
402
|
+
};
|
|
403
|
+
let outputText = page.content;
|
|
404
|
+
if (page.firstLineExceedsLimit) {
|
|
405
|
+
outputText = `[Line ${page.startLine} exceeds ${formatSize(maxBytes)} limit. Use the shell tool: sed -n '${page.startLine}p' ${path} | head -c ${maxBytes}]`;
|
|
284
406
|
}
|
|
285
|
-
else if (
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
407
|
+
else if (page.hasMore && page.nextOffset !== undefined) {
|
|
408
|
+
const endLine = page.startLine + page.outputLines - 1;
|
|
409
|
+
if (page.totalLines !== undefined) {
|
|
410
|
+
const remaining = page.totalLines - endLine;
|
|
411
|
+
outputText += page.truncatedBy
|
|
412
|
+
? `\n\n[Showing lines ${page.startLine}-${endLine} of ${page.totalLines}${page.truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${page.nextOffset} to continue.]`
|
|
413
|
+
: `\n\n[${remaining} more lines in file. Use offset=${page.nextOffset} to continue.]`;
|
|
291
414
|
}
|
|
292
415
|
else {
|
|
293
|
-
outputText += `\n\n[Showing lines ${
|
|
416
|
+
outputText += `\n\n[Showing lines ${page.startLine}-${endLine}${page.truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${page.nextOffset} to continue.]`;
|
|
294
417
|
}
|
|
295
418
|
}
|
|
296
|
-
else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
|
297
|
-
// User limit stopped early but the file has more content.
|
|
298
|
-
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
299
|
-
const nextOffset = startLine + userLimitedLines + 1;
|
|
300
|
-
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
outputText = truncation.content;
|
|
304
|
-
}
|
|
305
419
|
return {
|
|
306
420
|
toolCallId,
|
|
307
421
|
name: "read",
|
|
308
422
|
content: [{ type: "text", text: outputText }],
|
|
309
|
-
metadata: { truncation },
|
|
423
|
+
metadata: { truncation, scannedBytes: page.scannedBytes },
|
|
310
424
|
};
|
|
311
425
|
}
|
|
312
426
|
catch (error) {
|
package/dist/shell.d.ts
CHANGED
|
@@ -59,6 +59,10 @@ export interface ShellToolOptions {
|
|
|
59
59
|
maxLines?: number;
|
|
60
60
|
/** Max bytes kept in the tail snapshot (default 50KB). */
|
|
61
61
|
maxBytes?: number;
|
|
62
|
+
/** Default wall timeout in seconds (default 600, hard cap 3600). */
|
|
63
|
+
timeout?: number;
|
|
64
|
+
/** Maximum combined raw stdout/stderr retained or spilled (default 64 MiB). */
|
|
65
|
+
maxTotalOutputBytes?: number;
|
|
62
66
|
/** Temp-file prefix for spilled full output (default "prism-shell"). */
|
|
63
67
|
tempFilePrefix?: string;
|
|
64
68
|
}
|
package/dist/shell.js
CHANGED
|
@@ -25,7 +25,8 @@ import { constants, existsSync } from "node:fs";
|
|
|
25
25
|
import { access as fsAccess } from "node:fs/promises";
|
|
26
26
|
import { enforceExecutionPolicy } from "./execution-policy.js";
|
|
27
27
|
import { OutputAccumulator } from "./output-accumulator.js";
|
|
28
|
-
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
|
|
28
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TOTAL_OUTPUT_BYTES, DEFAULT_SHELL_TIMEOUT_SECONDS, HARD_MAX_BYTES, HARD_MAX_LINES, HARD_MAX_TOTAL_OUTPUT_BYTES, HARD_SHELL_TIMEOUT_SECONDS, validateCodingLimit, } from "./limits.js";
|
|
29
|
+
import { formatSize } from "./truncate.js";
|
|
29
30
|
const EXIT_STDIO_GRACE_MS = 100;
|
|
30
31
|
// --- spawn internals (re-ported from pi utils/shell.js + utils/child-process.js) ---
|
|
31
32
|
/** Resolve the shell binary + args. shellPath → SHELL env → /bin/bash → sh. */
|
|
@@ -160,6 +161,7 @@ export function waitForChildProcess(child) {
|
|
|
160
161
|
export function createLocalBashOperations(options) {
|
|
161
162
|
return {
|
|
162
163
|
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
|
164
|
+
const timeoutSeconds = validateCodingLimit("timeout", timeout ?? DEFAULT_SHELL_TIMEOUT_SECONDS, HARD_SHELL_TIMEOUT_SECONDS);
|
|
163
165
|
const shellConfig = getShellConfig(options?.shellPath);
|
|
164
166
|
try {
|
|
165
167
|
await fsAccess(cwd, constants.F_OK);
|
|
@@ -184,13 +186,11 @@ export function createLocalBashOperations(options) {
|
|
|
184
186
|
killProcessTree(child.pid);
|
|
185
187
|
};
|
|
186
188
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}, timeout * 1000);
|
|
193
|
-
}
|
|
189
|
+
timeoutHandle = setTimeout(() => {
|
|
190
|
+
timedOut = true;
|
|
191
|
+
if (child.pid)
|
|
192
|
+
killProcessTree(child.pid);
|
|
193
|
+
}, timeoutSeconds * 1000);
|
|
194
194
|
child.stdout?.on("data", onData);
|
|
195
195
|
child.stderr?.on("data", onData);
|
|
196
196
|
if (signal) {
|
|
@@ -203,7 +203,7 @@ export function createLocalBashOperations(options) {
|
|
|
203
203
|
if (signal?.aborted)
|
|
204
204
|
throw new Error("aborted");
|
|
205
205
|
if (timedOut)
|
|
206
|
-
throw new Error(`timeout:${
|
|
206
|
+
throw new Error(`timeout:${timeoutSeconds}`);
|
|
207
207
|
return { exitCode };
|
|
208
208
|
}
|
|
209
209
|
finally {
|
|
@@ -224,13 +224,13 @@ function formatOutput(snapshot, lastLineBytes, emptyText = "(no output)") {
|
|
|
224
224
|
const endLine = truncation.totalLines;
|
|
225
225
|
if (truncation.lastLinePartial) {
|
|
226
226
|
const lastLineSize = formatSize(lastLineBytes);
|
|
227
|
-
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
|
|
227
|
+
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}).${snapshot.fullOutputPath ? ` Full output: ${snapshot.fullOutputPath}` : ""}]`;
|
|
228
228
|
}
|
|
229
229
|
else if (truncation.truncatedBy === "lines") {
|
|
230
|
-
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
|
|
230
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}.${snapshot.fullOutputPath ? ` Full output: ${snapshot.fullOutputPath}` : ""}]`;
|
|
231
231
|
}
|
|
232
232
|
else {
|
|
233
|
-
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${snapshot.fullOutputPath}]`;
|
|
233
|
+
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit).${snapshot.fullOutputPath ? ` Full output: ${snapshot.fullOutputPath}` : ""}]`;
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
return { text, truncation, fullOutputPath: snapshot.fullOutputPath };
|
|
@@ -243,18 +243,22 @@ export function createShellTool(cwd, options) {
|
|
|
243
243
|
const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath });
|
|
244
244
|
const commandPrefix = options?.commandPrefix;
|
|
245
245
|
const spawnHook = options?.spawnHook;
|
|
246
|
-
const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
|
|
247
|
-
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
246
|
+
const maxLines = validateCodingLimit("maxLines", options?.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
|
|
247
|
+
const maxBytes = validateCodingLimit("maxBytes", options?.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
|
|
248
|
+
const defaultTimeout = validateCodingLimit("timeout", options?.timeout ?? DEFAULT_SHELL_TIMEOUT_SECONDS, HARD_SHELL_TIMEOUT_SECONDS);
|
|
249
|
+
const maxTotalOutputBytes = validateCodingLimit("maxTotalOutputBytes", options?.maxTotalOutputBytes ?? DEFAULT_MAX_TOTAL_OUTPUT_BYTES, HARD_MAX_TOTAL_OUTPUT_BYTES);
|
|
250
|
+
if (maxTotalOutputBytes < maxBytes)
|
|
251
|
+
throw new Error("maxTotalOutputBytes must be at least maxBytes");
|
|
248
252
|
const tempFilePrefix = options?.tempFilePrefix ?? "prism-shell";
|
|
249
253
|
return {
|
|
250
254
|
name: "shell",
|
|
251
255
|
exclusive: true,
|
|
252
|
-
description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB
|
|
256
|
+
description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB and capped at ${formatSize(maxTotalOutputBytes)} total. Truncated successful output is saved to a temp file. Timeout defaults to ${defaultTimeout} seconds.`,
|
|
253
257
|
parameters: {
|
|
254
258
|
type: "object",
|
|
255
259
|
properties: {
|
|
256
260
|
command: { type: "string", description: "Shell command to execute" },
|
|
257
|
-
timeout: { type: "number", description:
|
|
261
|
+
timeout: { type: "number", description: `Timeout in seconds (default ${defaultTimeout}, max ${HARD_SHELL_TIMEOUT_SECONDS})` },
|
|
258
262
|
},
|
|
259
263
|
required: ["command"],
|
|
260
264
|
additionalProperties: false,
|
|
@@ -262,7 +266,7 @@ export function createShellTool(cwd, options) {
|
|
|
262
266
|
async execute(args, context) {
|
|
263
267
|
const toolCallId = context.toolCallId;
|
|
264
268
|
const command = typeof args.command === "string" ? args.command : "";
|
|
265
|
-
const
|
|
269
|
+
const timeoutInput = typeof args.timeout === "number" ? args.timeout : undefined;
|
|
266
270
|
if (command.length === 0) {
|
|
267
271
|
return {
|
|
268
272
|
toolCallId,
|
|
@@ -271,6 +275,14 @@ export function createShellTool(cwd, options) {
|
|
|
271
275
|
error: { message: "command is required and must be a non-empty string." },
|
|
272
276
|
};
|
|
273
277
|
}
|
|
278
|
+
let timeout;
|
|
279
|
+
try {
|
|
280
|
+
timeout = validateCodingLimit("timeout", timeoutInput ?? defaultTimeout, HARD_SHELL_TIMEOUT_SECONDS);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
284
|
+
return { toolCallId, name: "shell", content: [{ type: "text", text: message }], error: { message } };
|
|
285
|
+
}
|
|
274
286
|
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
|
|
275
287
|
let spawnContext = spawnHook
|
|
276
288
|
? spawnHook({ command: resolvedCommand, cwd, env: { ...process.env } })
|
|
@@ -288,77 +300,88 @@ export function createShellTool(cwd, options) {
|
|
|
288
300
|
if (policyCheck.action.command) {
|
|
289
301
|
spawnContext = { ...spawnContext, command: policyCheck.action.command };
|
|
290
302
|
}
|
|
291
|
-
const
|
|
303
|
+
const outputAbort = new AbortController();
|
|
304
|
+
const output = new OutputAccumulator({
|
|
305
|
+
maxLines,
|
|
306
|
+
maxBytes,
|
|
307
|
+
maxTotalOutputBytes,
|
|
308
|
+
tempFilePrefix,
|
|
309
|
+
onLimit: () => outputAbort.abort("output-limit"),
|
|
310
|
+
onStorageError: () => outputAbort.abort("output-storage-error"),
|
|
311
|
+
});
|
|
312
|
+
const signal = context.signal
|
|
313
|
+
? AbortSignal.any([context.signal, outputAbort.signal])
|
|
314
|
+
: outputAbort.signal;
|
|
292
315
|
let acceptingOutput = true;
|
|
293
316
|
const handleData = (data) => {
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
output.append(data);
|
|
317
|
+
if (acceptingOutput)
|
|
318
|
+
output.append(data);
|
|
297
319
|
};
|
|
298
|
-
const finishOutput = async () => {
|
|
320
|
+
const finishOutput = async (retainSpill) => {
|
|
299
321
|
acceptingOutput = false;
|
|
300
322
|
output.finish();
|
|
301
323
|
const snapshot = output.snapshot({ persistIfTruncated: true });
|
|
302
|
-
|
|
303
|
-
|
|
324
|
+
if (retainSpill) {
|
|
325
|
+
await output.closeTempFile();
|
|
326
|
+
return snapshot;
|
|
327
|
+
}
|
|
328
|
+
const hadStorageError = output.hasStorageError();
|
|
329
|
+
try {
|
|
330
|
+
await output.cleanupTempFile();
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (!hadStorageError)
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
return { ...snapshot, fullOutputPath: undefined };
|
|
304
337
|
};
|
|
305
|
-
// Safety net: never leak an unhandled throw to the host runtime.
|
|
306
338
|
try {
|
|
307
|
-
let exitCode;
|
|
339
|
+
let exitCode = null;
|
|
340
|
+
let executionError;
|
|
308
341
|
try {
|
|
309
|
-
|
|
342
|
+
exitCode = (await ops.exec(spawnContext.command, spawnContext.cwd, {
|
|
310
343
|
onData: handleData,
|
|
311
|
-
signal
|
|
344
|
+
signal,
|
|
312
345
|
timeout,
|
|
313
346
|
env: spawnContext.env,
|
|
314
|
-
});
|
|
315
|
-
|
|
347
|
+
})).exitCode;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
executionError = error;
|
|
316
351
|
}
|
|
317
|
-
|
|
318
|
-
const
|
|
352
|
+
if (executionError !== undefined || output.isOutputLimitExceeded() || output.hasStorageError() || context.signal?.aborted) {
|
|
353
|
+
const outputLimitExceeded = output.isOutputLimitExceeded();
|
|
354
|
+
const outputStorageFailed = output.hasStorageError();
|
|
355
|
+
const snapshot = await finishOutput(false);
|
|
319
356
|
const { text } = formatOutput(snapshot, output.getLastLineBytes(), "");
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
content: [{ type: "text", text: appendStatus(text, "[Command aborted]") }],
|
|
331
|
-
error: { message: "Command aborted" },
|
|
332
|
-
metadata: meta,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
if (message.startsWith("timeout:")) {
|
|
336
|
-
const timeoutSecs = message.split(":")[1];
|
|
337
|
-
const status = `Command timed out after ${timeoutSecs} seconds`;
|
|
338
|
-
return {
|
|
339
|
-
toolCallId,
|
|
340
|
-
name: "shell",
|
|
341
|
-
content: [{ type: "text", text: appendStatus(text, `[${status}]`) }],
|
|
342
|
-
error: { message: status },
|
|
343
|
-
metadata: meta,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
// Spawn error (missing cwd, shell ENOENT, …): message is already host-friendly.
|
|
357
|
+
const rawMessage = executionError instanceof Error ? executionError.message : String(executionError ?? "");
|
|
358
|
+
const status = outputLimitExceeded
|
|
359
|
+
? `Command output exceeded ${formatSize(maxTotalOutputBytes)} limit`
|
|
360
|
+
: outputStorageFailed
|
|
361
|
+
? "Command output spill failed"
|
|
362
|
+
: rawMessage.startsWith("timeout:")
|
|
363
|
+
? `Command timed out after ${rawMessage.split(":")[1]} seconds`
|
|
364
|
+
: context.signal?.aborted
|
|
365
|
+
? "Command aborted"
|
|
366
|
+
: rawMessage || "Command failed";
|
|
347
367
|
return {
|
|
348
368
|
toolCallId,
|
|
349
369
|
name: "shell",
|
|
350
|
-
content: [{ type: "text", text: appendStatus(text,
|
|
351
|
-
error: { message },
|
|
352
|
-
metadata:
|
|
370
|
+
content: [{ type: "text", text: appendStatus(text, `[${status}]`) }],
|
|
371
|
+
error: { message: status },
|
|
372
|
+
metadata: {
|
|
373
|
+
exitCode: null,
|
|
374
|
+
truncation: snapshot.truncation,
|
|
375
|
+
totalOutputBytes: output.getTotalRawBytes(),
|
|
376
|
+
outputLimitExceeded,
|
|
377
|
+
outputStorageFailed,
|
|
378
|
+
},
|
|
353
379
|
};
|
|
354
380
|
}
|
|
355
|
-
const snapshot = await finishOutput();
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// Non-zero exit is not a tool error: surface exit code in a footer + metadata.
|
|
359
|
-
if (exitCode !== 0 && exitCode !== null) {
|
|
381
|
+
const snapshot = await finishOutput(true);
|
|
382
|
+
let text = formatOutput(snapshot, output.getLastLineBytes()).text;
|
|
383
|
+
if (exitCode !== 0 && exitCode !== null)
|
|
360
384
|
text = appendStatus(text, `[Command exited with code ${exitCode}]`);
|
|
361
|
-
}
|
|
362
385
|
return {
|
|
363
386
|
toolCallId,
|
|
364
387
|
name: "shell",
|
|
@@ -367,17 +390,18 @@ export function createShellTool(cwd, options) {
|
|
|
367
390
|
exitCode,
|
|
368
391
|
truncation: snapshot.truncation,
|
|
369
392
|
fullOutputPath: snapshot.fullOutputPath,
|
|
393
|
+
totalOutputBytes: output.getTotalRawBytes(),
|
|
394
|
+
outputLimitExceeded: false,
|
|
370
395
|
},
|
|
371
396
|
};
|
|
372
397
|
}
|
|
373
398
|
catch (err) {
|
|
399
|
+
try {
|
|
400
|
+
await output.cleanupTempFile();
|
|
401
|
+
}
|
|
402
|
+
catch { /* retain primary error */ }
|
|
374
403
|
const message = err instanceof Error ? err.message : String(err);
|
|
375
|
-
return {
|
|
376
|
-
toolCallId,
|
|
377
|
-
name: "shell",
|
|
378
|
-
content: [{ type: "text", text: message }],
|
|
379
|
-
error: { message },
|
|
380
|
-
};
|
|
404
|
+
return { toolCallId, name: "shell", content: [{ type: "text", text: message }], error: { message } };
|
|
381
405
|
}
|
|
382
406
|
},
|
|
383
407
|
};
|
package/dist/truncate.d.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Never returns partial lines (except the documented tail single-line edge case).
|
|
11
11
|
*/
|
|
12
|
-
export
|
|
13
|
-
export declare const DEFAULT_MAX_BYTES: number;
|
|
12
|
+
export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "./limits.js";
|
|
14
13
|
export interface TruncationOptions {
|
|
15
14
|
/** Maximum number of lines (default: 2000) */
|
|
16
15
|
maxLines?: number;
|
|
@@ -24,10 +23,14 @@ export interface TruncationResult {
|
|
|
24
23
|
truncated: boolean;
|
|
25
24
|
/** Which limit was hit: "lines", "bytes", or null if not truncated */
|
|
26
25
|
truncatedBy: "lines" | "bytes" | null;
|
|
27
|
-
/** Total
|
|
26
|
+
/** Total lines when known; otherwise a scanned lower bound. */
|
|
28
27
|
totalLines: number;
|
|
29
|
-
/**
|
|
28
|
+
/** Whether `totalLines` is exact (streamed text pages may stop early). */
|
|
29
|
+
totalLinesKnown?: boolean;
|
|
30
|
+
/** Total bytes when known; otherwise scanned bytes. */
|
|
30
31
|
totalBytes: number;
|
|
32
|
+
/** Whether `totalBytes` is exact (streamed text pages may stop early). */
|
|
33
|
+
totalBytesKnown?: boolean;
|
|
31
34
|
/** Number of complete lines in the truncated output */
|
|
32
35
|
outputLines: number;
|
|
33
36
|
/** Number of bytes in the truncated output */
|