fix: truncate runaways (#50)
This commit is contained in:
285
src/tests/tools/tool-truncation.test.ts
Normal file
285
src/tests/tools/tool-truncation.test.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
161
src/tests/tools/truncation.test.ts
Normal file
161
src/tests/tools/truncation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
134
src/tools/impl/truncation.ts
Normal file
134
src/tools/impl/truncation.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user