fix: truncate runaways (#50)

This commit is contained in:
Charles Packer
2025-11-01 02:04:37 -07:00
committed by GitHub
parent 94393a8566
commit 4118d018fe
13 changed files with 739 additions and 32 deletions

View File

@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bash } from "../../tools/impl/Bash";
import { bash_output } from "../../tools/impl/BashOutput";
import { glob } from "../../tools/impl/Glob";
import { grep } from "../../tools/impl/Grep";
import { ls } from "../../tools/impl/LS";
import { read } from "../../tools/impl/Read";
import { LIMITS } from "../../tools/impl/truncation";
describe("tool truncation integration tests", () => {
let testDir: string;
let originalUserCwd: string | undefined;
beforeEach(async () => {
// Create a temporary directory for tests
testDir = await mkdtemp(join(tmpdir(), "letta-test-"));
// Save and set USER_CWD so tools operate within the temp dir
originalUserCwd = process.env.USER_CWD;
process.env.USER_CWD = testDir;
});
afterEach(async () => {
// Restore USER_CWD before removing the temp dir to avoid leaving
// an invalid cwd for other tests that may run afterwards.
if (originalUserCwd === undefined) delete process.env.USER_CWD;
else process.env.USER_CWD = originalUserCwd;
// Clean up the temp directory
await rm(testDir, { recursive: true, force: true });
});
describe("Bash tool truncation", () => {
test.skipIf(process.platform === "win32")(
"truncates output exceeding 30K characters",
async () => {
// Generate output larger than 30K chars
const result = await bash({
command: `echo "${Array.from({ length: 1000 }, () => "x".repeat(50)).join("\\n")}"`,
});
const output = result.content[0]?.text || "";
expect(output).toContain("[Output truncated after 30,000 characters");
expect(output.length).toBeLessThan(35000); // Truncated + notice
},
);
test("does not truncate small output", async () => {
const result = await bash({ command: "echo 'Hello, world!'" });
const output = result.content[0]?.text || "";
expect(output).toContain("Hello, world!");
expect(output).not.toContain("truncated");
});
test.skipIf(process.platform === "win32")(
"truncates error output",
async () => {
// Generate large error output
const largeString = "e".repeat(40000);
const result = await bash({
command: `>&2 echo "${largeString}" && exit 1`,
});
const output = result.content[0]?.text || "";
expect(output).toContain("[Output truncated after 30,000 characters");
expect(result.isError).toBe(true);
},
);
});
describe("Read tool truncation", () => {
test("truncates file exceeding 2000 lines", async () => {
const filePath = join(testDir, "large-file.txt");
const lines = Array.from({ length: 3000 }, (_, i) => `Line ${i + 1}`);
await writeFile(filePath, lines.join("\n"));
const result = await read({ file_path: filePath });
expect(result.content).toContain("Line 1");
expect(result.content).toContain("Line 2000");
expect(result.content).not.toContain("Line 2001");
expect(result.content).toContain("showing lines 1-2000 of 3000 total");
});
test("truncates lines exceeding 2000 characters", async () => {
const filePath = join(testDir, "long-lines.txt");
const content = `short line\n${"a".repeat(3000)}\nshort line`;
await writeFile(filePath, content);
const result = await read({ file_path: filePath });
expect(result.content).toContain("short line");
expect(result.content).toContain("a".repeat(2000));
expect(result.content).not.toContain("a".repeat(2001));
expect(result.content).toContain("... [line truncated]");
expect(result.content).toContain(
"Some lines exceeded 2,000 characters and were truncated",
);
});
test("respects user-specified limit parameter", async () => {
const filePath = join(testDir, "file.txt");
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
await writeFile(filePath, lines.join("\n"));
const result = await read({ file_path: filePath, limit: 50 });
expect(result.content).toContain("Line 1");
expect(result.content).toContain("Line 50");
expect(result.content).not.toContain("Line 51");
// Should not show truncation notice when user explicitly set limit
expect(result.content).not.toContain("File truncated");
});
test("does not truncate small files", async () => {
const filePath = join(testDir, "small.txt");
await writeFile(filePath, "Hello\nWorld\n");
const result = await read({ file_path: filePath });
expect(result.content).toContain("Hello");
expect(result.content).toContain("World");
expect(result.content).not.toContain("truncated");
});
});
describe("Grep tool truncation", () => {
beforeEach(async () => {
// Create test files for grep
for (let i = 1; i <= 100; i++) {
await writeFile(join(testDir, `file${i}.txt`), `match\n`.repeat(100));
}
});
test("truncates content output exceeding 10K characters", async () => {
const result = await grep({
pattern: "match",
path: testDir,
output_mode: "content",
});
expect(result.output.length).toBeLessThanOrEqual(15000); // 10K + notice
expect(result.output).toContain(
"[Output truncated after 10,000 characters",
);
});
test("truncates file list exceeding 10K characters", async () => {
// Create files with long paths
for (let i = 1; i <= 1000; i++) {
await writeFile(
join(testDir, `very-long-filename-to-make-output-large-${i}.txt`),
"match",
);
}
const result = await grep({
pattern: "match",
path: testDir,
output_mode: "files_with_matches",
});
expect(result.output.length).toBeLessThanOrEqual(15000);
if (result.output.length > 10000) {
expect(result.output).toContain(
"[Output truncated after 10,000 characters",
);
}
});
test("does not truncate small results", async () => {
await writeFile(join(testDir, "single.txt"), "match\n");
const result = await grep({
pattern: "match",
path: join(testDir, "single.txt"),
output_mode: "content",
});
expect(result.output).toContain("match");
expect(result.output).not.toContain("truncated");
});
});
describe("Glob tool truncation", () => {
test("truncates file list exceeding 2000 files", async () => {
// Create 2500 files
for (let i = 1; i <= 2500; i++) {
await writeFile(join(testDir, `file${i}.txt`), "content");
}
const result = await glob({ pattern: "**/*.txt", path: testDir });
expect(result.files.length).toBeLessThanOrEqual(
LIMITS.GLOB_MAX_FILES + 1,
); // +1 for notice
expect(result.truncated).toBe(true);
expect(result.totalFiles).toBe(2500);
// Last entry should be the truncation notice
expect(result.files[result.files.length - 1]).toContain(
"showing 2,000 of 2,500 files",
);
});
test("does not truncate when under limit", async () => {
// Create 10 files
for (let i = 1; i <= 10; i++) {
await writeFile(join(testDir, `file${i}.txt`), "content");
}
const result = await glob({ pattern: "**/*.txt", path: testDir });
expect(result.files.length).toBe(10);
expect(result.truncated).toBeUndefined();
expect(result.totalFiles).toBeUndefined();
});
});
describe("LS tool truncation", () => {
test("truncates directory exceeding 1000 entries", async () => {
// Create 1500 files
for (let i = 1; i <= 1500; i++) {
await writeFile(join(testDir, `file${i}.txt`), "content");
}
const result = await ls({ path: testDir });
const output = result.content[0]?.text || "";
expect(output).toContain("[Output truncated");
expect(output).toContain("showing 1,000 of 1,500 entries");
expect(output).toContain("file1.txt");
// Should not contain files beyond 1000
const lines = output.split("\n");
// Count actual file entries (excluding headers and notices)
const fileEntries = lines.filter((line) => line.match(/^\s+- file/));
expect(fileEntries.length).toBeLessThanOrEqual(LIMITS.LS_MAX_ENTRIES);
});
test("does not truncate small directories", async () => {
// Create 5 files
for (let i = 1; i <= 5; i++) {
await writeFile(join(testDir, `file${i}.txt`), "content");
}
const result = await ls({ path: testDir });
const output = result.content[0]?.text || "";
expect(output).not.toContain("truncated");
expect(output).toContain("file1.txt");
expect(output).toContain("file5.txt");
});
});
describe("BashOutput tool truncation", () => {
test.skipIf(process.platform === "win32")(
"truncates accumulated output exceeding 30K characters",
async () => {
// Start a background process that generates lots of output
const startResult = await bash({
command: `for i in {1..1000}; do echo "$(printf 'x%.0s' {1..100})"; done`,
run_in_background: true,
});
const message = startResult.content[0]?.text || "";
const bashIdMatch = message.match(/with ID: (.+)/);
expect(bashIdMatch).toBeTruthy();
const bashId = bashIdMatch![1];
// Wait a bit for output to accumulate
await new Promise((resolve) => setTimeout(resolve, 100));
const outputResult = await bash_output({ bash_id: bashId });
expect(outputResult.message.length).toBeLessThan(35000); // 30K + notice
if (outputResult.message.length > 30000) {
expect(outputResult.message).toContain(
"[Output truncated after 30,000 characters",
);
}
},
);
});
});

View File

@@ -0,0 +1,161 @@
import { describe, expect, test } from "bun:test";
import {
LIMITS,
truncateArray,
truncateByChars,
truncateByLines,
} from "../../tools/impl/truncation";
describe("truncation utilities", () => {
describe("truncateByChars", () => {
test("does not truncate when under limit", () => {
const text = "Hello, world!";
const result = truncateByChars(text, 100, "Test");
expect(result.wasTruncated).toBe(false);
expect(result.content).toBe(text);
});
test("truncates when exceeding limit", () => {
const text = "a".repeat(1000);
const result = truncateByChars(text, 500, "Test");
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("a".repeat(500));
expect(result.content).toContain(
"[Output truncated after 500 characters: exceeded limit.]",
);
expect(result.content.length).toBeGreaterThan(500); // Due to notice
});
test("exactly at limit does not truncate", () => {
const text = "a".repeat(500);
const result = truncateByChars(text, 500, "Test");
expect(result.wasTruncated).toBe(false);
expect(result.content).toBe(text);
});
test("includes correct character count in notice", () => {
const text = "x".repeat(2000);
const result = truncateByChars(text, 1000, "Test");
expect(result.content).toContain("1,000 characters");
});
});
describe("truncateByLines", () => {
test("does not truncate when under line limit", () => {
const text = "line1\nline2\nline3";
const result = truncateByLines(text, 10, undefined, "Test");
expect(result.wasTruncated).toBe(false);
expect(result.content).toBe(text);
expect(result.originalLineCount).toBe(3);
expect(result.linesShown).toBe(3);
});
test("truncates when exceeding line limit", () => {
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
const text = lines.join("\n");
const result = truncateByLines(text, 50, undefined, "Test");
expect(result.wasTruncated).toBe(true);
expect(result.originalLineCount).toBe(100);
expect(result.linesShown).toBe(50);
expect(result.content).toContain("Line 1");
expect(result.content).toContain("Line 50");
expect(result.content).not.toContain("Line 51");
expect(result.content).toContain("showing 50 of 100 lines");
});
test("truncates long lines when maxCharsPerLine specified", () => {
const text = "short\n" + "a".repeat(1000) + "\nshort";
const result = truncateByLines(text, 10, 500, "Test");
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("short");
expect(result.content).toContain("a".repeat(500));
expect(result.content).toContain("... [line truncated]");
expect(result.content).toContain(
"Some lines exceeded 500 characters and were truncated",
);
});
test("handles both line count and character truncation", () => {
const lines = Array.from(
{ length: 100 },
(_, i) => `Line ${i + 1}: ${"x".repeat(2000)}`,
);
const text = lines.join("\n");
const result = truncateByLines(text, 50, 1000, "Test");
expect(result.wasTruncated).toBe(true);
expect(result.originalLineCount).toBe(100);
expect(result.linesShown).toBe(50);
expect(result.content).toContain("showing 50 of 100 lines");
expect(result.content).toContain(
"Some lines exceeded 1,000 characters and were truncated",
);
});
test("exactly at line limit does not truncate", () => {
const text = "line1\nline2\nline3";
const result = truncateByLines(text, 3, undefined, "Test");
expect(result.wasTruncated).toBe(false);
expect(result.content).toBe(text);
});
});
describe("truncateArray", () => {
test("does not truncate when under limit", () => {
const items = ["item1", "item2", "item3"];
const formatter = (arr: string[]) => arr.join("\n");
const result = truncateArray(items, 10, formatter, "items");
expect(result.wasTruncated).toBe(false);
expect(result.content).toBe("item1\nitem2\nitem3");
});
test("truncates when exceeding limit", () => {
const items = Array.from({ length: 100 }, (_, i) => `item${i + 1}`);
const formatter = (arr: string[]) => arr.join("\n");
const result = truncateArray(items, 50, formatter, "items");
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("item1");
expect(result.content).toContain("item50");
expect(result.content).not.toContain("item51");
expect(result.content).toContain("showing 50 of 100 items");
});
test("exactly at limit does not truncate", () => {
const items = ["a", "b", "c"];
const formatter = (arr: string[]) => arr.join(", ");
const result = truncateArray(items, 3, formatter, "entries");
expect(result.wasTruncated).toBe(false);
expect(result.content).toBe("a, b, c");
});
test("uses custom item type in notice", () => {
const items = Array.from({ length: 1000 }, (_, i) => `/file${i}.txt`);
const formatter = (arr: string[]) => arr.join("\n");
const result = truncateArray(items, 100, formatter, "files");
expect(result.content).toContain("showing 100 of 1,000 files");
});
});
describe("LIMITS constants", () => {
test("has expected values", () => {
expect(LIMITS.BASH_OUTPUT_CHARS).toBe(30_000);
expect(LIMITS.READ_MAX_LINES).toBe(2_000);
expect(LIMITS.READ_MAX_CHARS_PER_LINE).toBe(2_000);
expect(LIMITS.GREP_OUTPUT_CHARS).toBe(10_000);
expect(LIMITS.GLOB_MAX_FILES).toBe(2_000);
expect(LIMITS.LS_MAX_ENTRIES).toBe(1_000);
});
});
});

View File

@@ -6,4 +6,5 @@
- Returns stdout and stderr output along with shell status
- Supports optional regex filtering to show only lines matching a pattern
- Use this tool when you need to monitor or check the output of a long-running shell
- Shell IDs can be found using the /bashes command
- Shell IDs can be found using the /bashes command
- If the accumulated output exceeds 30,000 characters, it will be truncated before being returned to you

View File

@@ -5,4 +5,5 @@
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
- If more than 2,000 files match the pattern, only the first 2,000 will be returned

View File

@@ -10,3 +10,4 @@ A powerful search tool built on ripgrep
- Use Task tool for open-ended searches requiring multiple rounds
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code)
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true`
- If the output exceeds 10,000 characters, it will be truncated before being returned to you

View File

@@ -1,3 +1,5 @@
# LS
Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.
Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.
If a directory has more than 1,000 entries, only the first 1,000 will be shown.

View File

@@ -2,6 +2,7 @@ import type { ExecOptions } from "node:child_process";
import { exec, spawn } from "node:child_process";
import { promisify } from "node:util";
import { backgroundProcesses, getNextBashId } from "./process_manager.js";
import { LIMITS, truncateByChars } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
const execAsync = promisify(exec);
@@ -115,10 +116,16 @@ export async function bash(args: BashArgs): Promise<BashResult> {
const stderrStr = typeof stderr === "string" ? stderr : stderr.toString();
let output = stdoutStr;
if (stderrStr) output = output ? `${output}\n${stderrStr}` : stderrStr;
// Apply character limit to prevent excessive token usage
const { content: truncatedOutput } = truncateByChars(
output || "(Command completed with no output)",
LIMITS.BASH_OUTPUT_CHARS,
"Bash",
);
return {
content: [
{ type: "text", text: output || "(Command completed with no output)" },
],
content: [{ type: "text", text: truncatedOutput }],
};
} catch (error) {
const err = error as NodeJS.ErrnoException & {
@@ -134,13 +141,16 @@ export async function bash(args: BashArgs): Promise<BashResult> {
if (err.stderr) errorMessage += err.stderr;
else if (err.message) errorMessage += err.message;
if (err.stdout) errorMessage = `${err.stdout}\n${errorMessage}`;
// Apply character limit even to error messages
const { content: truncatedError } = truncateByChars(
errorMessage.trim() || "Command failed with unknown error",
LIMITS.BASH_OUTPUT_CHARS,
"Bash",
);
return {
content: [
{
type: "text",
text: errorMessage.trim() || "Command failed with unknown error",
},
],
content: [{ type: "text", text: truncatedError }],
isError: true,
};
}

View File

@@ -1,4 +1,5 @@
import { backgroundProcesses } from "./process_manager.js";
import { LIMITS, truncateByChars } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
interface BashOutputArgs {
@@ -27,5 +28,13 @@ export async function bash_output(
.filter((line) => line.includes(filter))
.join("\n");
}
return { message: text || "(no output yet)" };
// Apply character limit to prevent excessive token usage (same as Bash)
const { content: truncatedOutput } = truncateByChars(
text || "(no output yet)",
LIMITS.BASH_OUTPUT_CHARS,
"BashOutput",
);
return { message: truncatedOutput };
}

View File

@@ -1,6 +1,7 @@
import { promises as fs } from "node:fs";
import * as path from "node:path";
import picomatch from "picomatch";
import { LIMITS } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
interface GlobArgs {
@@ -9,6 +10,27 @@ interface GlobArgs {
}
interface GlobResult {
files: string[];
truncated?: boolean;
totalFiles?: number;
}
function applyFileLimit(files: string[]): GlobResult {
const totalFiles = files.length;
if (totalFiles <= LIMITS.GLOB_MAX_FILES) {
return { files };
}
const truncatedFiles = files.slice(0, LIMITS.GLOB_MAX_FILES);
// Add truncation notice as last entry
truncatedFiles.push(
`\n[Output truncated: showing ${LIMITS.GLOB_MAX_FILES.toLocaleString()} of ${totalFiles.toLocaleString()} files.]`,
);
return {
files: truncatedFiles,
truncated: true,
totalFiles,
};
}
async function walkDirectory(dir: string): Promise<string[]> {
@@ -60,17 +82,17 @@ export async function glob(args: GlobArgs): Promise<GlobResult> {
const matchedFiles = allFiles.filter((file) =>
matcher(path.basename(file)),
);
return { files: matchedFiles.sort() };
return applyFileLimit(matchedFiles.sort());
} else if (pattern.includes("**")) {
const fullPattern = path.join(baseDir, pattern);
matcher = picomatch(fullPattern, { dot: true });
const matchedFiles = allFiles.filter((file) => matcher(file));
return { files: matchedFiles.sort() };
return applyFileLimit(matchedFiles.sort());
} else {
matcher = picomatch(pattern, { dot: true });
const matchedFiles = allFiles.filter((file) =>
matcher(path.relative(baseDir, file)),
);
return { files: matchedFiles.sort() };
return applyFileLimit(matchedFiles.sort());
}
}

View File

@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { LIMITS, truncateByChars } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
const execFileAsync = promisify(execFile);
@@ -90,8 +91,19 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
const files = stdout.trim().split("\n").filter(Boolean);
const fileCount = files.length;
if (fileCount === 0) return { output: "No files found", files: 0 };
const fileList = files.join("\n");
const fullOutput = `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${fileList}`;
// Apply character limit to prevent large file lists
const { content: truncatedOutput } = truncateByChars(
fullOutput,
LIMITS.GREP_OUTPUT_CHARS,
"Grep",
);
return {
output: `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${files.join("\n")}`,
output: truncatedOutput,
files: fileCount,
};
} else if (output_mode === "count") {
@@ -123,8 +135,16 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
} else {
if (!stdout || stdout.trim() === "")
return { output: "No matches found", matches: 0 };
// Apply character limit to content output
const { content: truncatedOutput } = truncateByChars(
stdout,
LIMITS.GREP_OUTPUT_CHARS,
"Grep",
);
return {
output: stdout,
output: truncatedOutput,
matches: stdout.split("\n").filter(Boolean).length,
};
}

View File

@@ -2,6 +2,7 @@ import { readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import picomatch from "picomatch";
import LSSchema from "../schemas/LS.json";
import { LIMITS } from "./truncation.js";
import { validateParamTypes, validateRequiredParams } from "./validation.js";
interface LSArgs {
@@ -48,7 +49,16 @@ export async function ls(
: 1
: a.name.localeCompare(b.name),
);
const tree = formatTree(dirPath, fileInfos);
// Apply entry limit to prevent massive directories
const totalEntries = fileInfos.length;
let truncated = false;
if (totalEntries > LIMITS.LS_MAX_ENTRIES) {
fileInfos.splice(LIMITS.LS_MAX_ENTRIES);
truncated = true;
}
const tree = formatTree(dirPath, fileInfos, truncated, totalEntries);
return { content: [{ type: "text", text: tree }] };
} catch (error) {
const err = error as NodeJS.ErrnoException;
@@ -60,7 +70,12 @@ export async function ls(
}
}
function formatTree(basePath: string, items: FileInfo[]): string {
function formatTree(
basePath: string,
items: FileInfo[],
truncated: boolean,
totalEntries: number,
): string {
if (items.length === 0) return `${basePath}/ (empty directory)`;
const lines: string[] = [];
const pathParts = basePath.split("/");
@@ -74,6 +89,15 @@ function formatTree(basePath: string, items: FileInfo[]): string {
`${prefix}- ${item.name}${item.type === "directory" ? "/" : ""}`,
);
});
// Add truncation notice if applicable
if (truncated) {
lines.push("");
lines.push(
`[Output truncated: showing ${LIMITS.LS_MAX_ENTRIES.toLocaleString()} of ${totalEntries.toLocaleString()} entries.]`,
);
}
const hasHiddenFiles = items.some((item) => item.name.startsWith("."));
if (hasHiddenFiles) {
lines.push("");

View File

@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { LIMITS } from "./truncation.js";
import { validateRequiredParams } from "./validation.js";
interface ReadArgs {
@@ -61,22 +62,58 @@ function formatWithLineNumbers(
limit?: number,
): string {
const lines = content.split("\n");
const originalLineCount = lines.length;
const startLine = offset || 0;
const endLine = limit
? Math.min(startLine + limit, lines.length)
: lines.length;
// Apply default limit if not specified (Claude Code: 2000 lines)
const effectiveLimit = limit ?? LIMITS.READ_MAX_LINES;
const endLine = Math.min(startLine + effectiveLimit, lines.length);
const actualStartLine = Math.min(startLine, lines.length);
const actualEndLine = Math.min(endLine, lines.length);
const selectedLines = lines.slice(actualStartLine, actualEndLine);
const maxLineNumber = actualStartLine + selectedLines.length;
const padding = Math.max(1, maxLineNumber.toString().length);
return selectedLines
.map((line, index) => {
const lineNumber = actualStartLine + index + 1;
const paddedNumber = lineNumber.toString().padStart(padding);
return `${paddedNumber}${line}`;
})
.join("\n");
// Apply per-line character limit (Claude Code: 2000 chars/line)
let linesWereTruncatedInLength = false;
const formattedLines = selectedLines.map((line, index) => {
const lineNumber = actualStartLine + index + 1;
const maxLineNumber = actualStartLine + selectedLines.length;
const padding = Math.max(1, maxLineNumber.toString().length);
const paddedNumber = lineNumber.toString().padStart(padding);
// Truncate long lines
if (line.length > LIMITS.READ_MAX_CHARS_PER_LINE) {
linesWereTruncatedInLength = true;
const truncated = line.slice(0, LIMITS.READ_MAX_CHARS_PER_LINE);
return `${paddedNumber}${truncated}... [line truncated]`;
}
return `${paddedNumber}${line}`;
});
let result = formattedLines.join("\n");
// Add truncation notices if applicable
const notices: string[] = [];
const wasTruncatedByLineCount = actualEndLine < originalLineCount;
if (wasTruncatedByLineCount && !limit) {
// Only show this notice if user didn't explicitly set a limit
notices.push(
`\n\n[File truncated: showing lines ${actualStartLine + 1}-${actualEndLine} of ${originalLineCount} total lines. Use offset and limit parameters to read other sections.]`,
);
}
if (linesWereTruncatedInLength) {
notices.push(
`\n\n[Some lines exceeded ${LIMITS.READ_MAX_CHARS_PER_LINE.toLocaleString()} characters and were truncated.]`,
);
}
if (notices.length > 0) {
result += notices.join("");
}
return result;
}
export async function read(args: ReadArgs): Promise<ReadResult> {

View File

@@ -0,0 +1,134 @@
/**
* Centralized truncation utilities for tool outputs.
* Implements limits similar to Claude Code to prevent excessive token usage.
*/
// Limits based on Claude Code's proven production values
export const LIMITS = {
// Command output limits
BASH_OUTPUT_CHARS: 30_000, // 30K characters for bash/shell output
// File reading limits
READ_MAX_LINES: 2_000, // Max lines per file read
READ_MAX_CHARS_PER_LINE: 2_000, // Max characters per line
// Search/discovery limits
GREP_OUTPUT_CHARS: 10_000, // Max characters for grep results
GLOB_MAX_FILES: 2_000, // Max number of file paths
LS_MAX_ENTRIES: 1_000, // Max directory entries
} as const;
/**
* Truncates text to a maximum character count.
* Adds a truncation notice when content exceeds limit.
*/
export function truncateByChars(
text: string,
maxChars: number,
toolName: string = "output",
): { content: string; wasTruncated: boolean } {
if (text.length <= maxChars) {
return { content: text, wasTruncated: false };
}
const truncated = text.slice(0, maxChars);
const notice = `\n\n[Output truncated after ${maxChars.toLocaleString()} characters: exceeded limit.]`;
return {
content: truncated + notice,
wasTruncated: true,
};
}
/**
* Truncates text by line count.
* Optionally enforces max characters per line.
*/
export function truncateByLines(
text: string,
maxLines: number,
maxCharsPerLine?: number,
toolName: string = "output",
): {
content: string;
wasTruncated: boolean;
originalLineCount: number;
linesShown: number;
} {
const lines = text.split("\n");
const originalLineCount = lines.length;
let selectedLines = lines.slice(0, maxLines);
let linesWereTruncatedInLength = false;
// Apply per-line character limit if specified
if (maxCharsPerLine !== undefined) {
selectedLines = selectedLines.map((line) => {
if (line.length > maxCharsPerLine) {
linesWereTruncatedInLength = true;
return line.slice(0, maxCharsPerLine) + "... [line truncated]";
}
return line;
});
}
const wasTruncated = lines.length > maxLines || linesWereTruncatedInLength;
let content = selectedLines.join("\n");
if (wasTruncated) {
const notices: string[] = [];
if (lines.length > maxLines) {
notices.push(
`[Output truncated: showing ${maxLines.toLocaleString()} of ${originalLineCount.toLocaleString()} lines.]`,
);
}
if (linesWereTruncatedInLength && maxCharsPerLine) {
notices.push(
`[Some lines exceeded ${maxCharsPerLine.toLocaleString()} characters and were truncated.]`,
);
}
content += "\n\n" + notices.join(" ");
}
return {
content,
wasTruncated,
originalLineCount,
linesShown: selectedLines.length,
};
}
/**
* Truncates an array of items (file paths, directory entries, etc.)
*/
export function truncateArray<T>(
items: T[],
maxItems: number,
formatter: (items: T[]) => string,
itemType: string = "items",
): { content: string; wasTruncated: boolean } {
if (items.length <= maxItems) {
return { content: formatter(items), wasTruncated: false };
}
const truncatedItems = items.slice(0, maxItems);
const content = formatter(truncatedItems);
const notice = `\n\n[Output truncated: showing ${maxItems.toLocaleString()} of ${items.length.toLocaleString()} ${itemType}.]`;
return {
content: content + notice,
wasTruncated: true,
};
}
/**
* Format bytes for human-readable display
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} bytes`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}