diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 76934a9..15d5f84 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -7,7 +7,7 @@ The tools module implements all 15 AI agent tools in BashKit. These tools bridge | File | Purpose | |------|---------| | `bash.ts` | Execute shell commands with timeout and output limits | -| `read.ts` | Read files and list directories with pagination support | +| `read.ts` | Read files and list directories with pagination, per-line truncation (2000 chars), and total output truncation (60K chars) | | `write.ts` | Write files with size limits and path restrictions | | `edit.ts` | String-based find/replace editing with uniqueness validation | | `glob.ts` | Pattern-based file discovery using find command | @@ -227,7 +227,7 @@ const DEFAULT_CACHEABLE = [ ### Test Coverage Located at `/tests/tools/`: - `bash.test.ts` -- Command execution, timeouts, output truncation -- `read.test.ts` -- File reading, directory listing, pagination, binary detection +- `read.test.ts` -- File reading, directory listing, pagination, binary detection, line/output truncation - `write.test.ts` -- File creation, overwriting, size limits - `edit.test.ts` -- String replacement, uniqueness validation, replace_all - `glob.test.ts` -- Pattern matching, path filtering diff --git a/src/tools/read.ts b/src/tools/read.ts index 96432a8..9033550 100644 --- a/src/tools/read.ts +++ b/src/tools/read.ts @@ -8,6 +8,10 @@ import { debugStart, isDebugEnabled, } from "../utils/debug"; +import { middleTruncate } from "../utils/helpers"; + +const DEFAULT_MAX_LINE_LENGTH = 2000; +const DEFAULT_MAX_OUTPUT_LENGTH = 60000; export interface ReadTextOutput { type: "text"; @@ -52,6 +56,7 @@ Usage: - By default, it reads up to 500 lines starting from the beginning of the file - You can optionally specify a line offset and limit (especially handy for long files) - Results are returned with line numbers starting at 1 +- Any lines longer than 2000 characters will be truncated - This tool can only read text files, not binary files (images, PDFs, etc.) - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - It is always better to speculatively read multiple potentially useful files in parallel @@ -157,12 +162,26 @@ export function createReadTool(sandbox: Sandbox, config?: ToolConfig) { const endLine = limit ? startLine + limit : allLines.length; const selectedLines = allLines.slice(startLine, endLine); - const lines = selectedLines.map((line, i) => ({ + // Per-line truncation (silent, like Claude Code's MAX_LINE_LENGTH) + const maxLineLength = + config?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH; + const truncatedLines = selectedLines.map((line) => + line.length > maxLineLength + ? line.slice(0, maxLineLength) + "…" + : line, + ); + + const lines = truncatedLines.map((line, i) => ({ line_number: startLine + i + 1, content: line, })); - const contentStr = selectedLines.join("\n"); + let contentStr = truncatedLines.join("\n"); + + // Total output cap (silent, prevents context bloat) + const maxOutputLength = + config?.maxOutputLength ?? DEFAULT_MAX_OUTPUT_LENGTH; + contentStr = middleTruncate(contentStr, maxOutputLength); const durationMs = Math.round(performance.now() - startTime); if (debugId) { debugEnd(debugId, "read", { diff --git a/src/types.ts b/src/types.ts index 0fdee32..b6d73d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,7 @@ export type ToolConfig = { timeout?: number; maxFileSize?: number; maxOutputLength?: number; + maxLineLength?: number; allowedPaths?: string[]; blockedCommands?: string[]; } & SDKToolOptions; diff --git a/tests/tools/read.test.ts b/tests/tools/read.test.ts index dff501a..7719df8 100644 --- a/tests/tools/read.test.ts +++ b/tests/tools/read.test.ts @@ -279,6 +279,96 @@ describe("Read Tool", () => { }); }); + describe("per-line truncation", () => { + it("should truncate lines exceeding default max line length", async () => { + const longLine = "x".repeat(2500); + sandbox.setFile("/workspace/long-line.ts", `short\n${longLine}\nshort`); + + const tool = createReadTool(sandbox); + const result = await executeTool(tool, { + file_path: "/workspace/long-line.ts", + }); + + assertSuccess(result); + if (result.type === "text") { + expect(result.lines[1].content).toHaveLength(2001); // 2000 + "…" + expect(result.lines[1].content.endsWith("…")).toBe(true); + expect(result.lines[0].content).toBe("short"); + expect(result.lines[2].content).toBe("short"); + } + }); + + it("should not truncate lines at exactly max length", async () => { + const exactLine = "x".repeat(2000); + sandbox.setFile("/workspace/exact.ts", exactLine); + + const tool = createReadTool(sandbox); + const result = await executeTool(tool, { + file_path: "/workspace/exact.ts", + }); + + assertSuccess(result); + if (result.type === "text") { + expect(result.lines[0].content).toHaveLength(2000); + expect(result.lines[0].content).not.toContain("…"); + } + }); + + it("should respect custom maxLineLength config", async () => { + const line = "x".repeat(200); + sandbox.setFile("/workspace/custom.ts", line); + + const tool = createReadTool(sandbox, { maxLineLength: 100 }); + const result = await executeTool(tool, { + file_path: "/workspace/custom.ts", + }); + + assertSuccess(result); + if (result.type === "text") { + expect(result.lines[0].content).toHaveLength(101); // 100 + "…" + expect(result.lines[0].content.endsWith("…")).toBe(true); + } + }); + }); + + describe("total output truncation", () => { + it("should truncate total output exceeding maxOutputLength", async () => { + // Create a file with many lines that exceed 500 chars total + const fileLines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}: ${"a".repeat(20)}`); + const fileContent = fileLines.join("\n"); + sandbox.setFile("/workspace/big.ts", fileContent); + + const tool = createReadTool(sandbox, { maxOutputLength: 500 }); + const result = await executeTool(tool, { + file_path: "/workspace/big.ts", + limit: 50, + }); + + assertSuccess(result); + if (result.type === "text") { + // middleTruncate adds a header + marker, so content will be longer than maxOutputLength + // but shorter than the original untruncated content + expect(result.content.length).toBeLessThan(fileContent.length); + expect(result.content).toContain("truncated"); + } + }); + + it("should not truncate output within maxOutputLength", async () => { + sandbox.setFile("/workspace/small.ts", "line1\nline2\nline3"); + + const tool = createReadTool(sandbox, { maxOutputLength: 60000 }); + const result = await executeTool(tool, { + file_path: "/workspace/small.ts", + }); + + assertSuccess(result); + if (result.type === "text") { + expect(result.content).toBe("line1\nline2\nline3"); + expect(result.content).not.toContain("truncated"); + } + }); + }); + describe("line number formatting", () => { it("should return 1-indexed line numbers", async () => { sandbox.setFile("/workspace/test.ts", "line1\nline2\nline3");