diff --git a/src/tests/tools/overflow-integration.test.ts b/src/tests/tools/overflow-integration.test.ts new file mode 100644 index 0000000..1ecaa98 --- /dev/null +++ b/src/tests/tools/overflow-integration.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { bash } from "../../tools/impl/Bash"; +import { grep } from "../../tools/impl/Grep"; +import { getOverflowDirectory } from "../../tools/impl/overflow"; + +describe("overflow integration tests", () => { + const testWorkingDir = process.cwd(); + let overflowDir: string; + + afterEach(() => { + overflowDir = getOverflowDirectory(testWorkingDir); + // Clean up test files + if (fs.existsSync(overflowDir)) { + const files = fs.readdirSync(overflowDir); + for (const file of files) { + fs.unlinkSync(path.join(overflowDir, file)); + } + } + }); + + describe("Bash tool with overflow", () => { + test("creates overflow file for long output", async () => { + // Set USER_CWD for the test + process.env.USER_CWD = testWorkingDir; + + // Generate a large output (more than 30K characters) + const command = + 'for i in {1..2000}; do echo "Line $i with some padding text to make it longer"; done'; + + const result = await bash({ command }); + + // Check that output was truncated + expect(result.status).toBe("success"); + expect(result.content[0]?.text).toContain("Output truncated"); + expect(result.content[0]?.text).toContain("Full output written to:"); + + // Extract overflow path from the output + const match = result.content[0]?.text?.match( + /Full output written to: (.+\.txt)/, + ); + expect(match).toBeDefined(); + + if (match?.[1]) { + const overflowPath = match[1]; + expect(fs.existsSync(overflowPath)).toBe(true); + + // Verify the overflow file contains the full output + const fullContent = fs.readFileSync(overflowPath, "utf-8"); + expect(fullContent).toContain("Line 1 with some padding"); + expect(fullContent).toContain("Line 2000 with some padding"); + expect(fullContent.length).toBeGreaterThan(30_000); + } + }); + + test("no overflow file for short output", async () => { + process.env.USER_CWD = testWorkingDir; + + const command = "echo 'Short output'"; + const result = await bash({ command }); + + expect(result.status).toBe("success"); + expect(result.content[0]?.text).not.toContain("Output truncated"); + expect(result.content[0]?.text).not.toContain("Full output written to"); + }); + }); + + describe("Grep tool with overflow", () => { + test("creates overflow file for large search results", async () => { + process.env.USER_CWD = testWorkingDir; + + // Search for a common pattern that will have many results + const result = await grep({ + pattern: "test", + path: "src/tests", + output_mode: "files_with_matches", + }); + + // If we have enough results to trigger truncation + if ( + result.output.includes("Output truncated") && + result.output.includes("Full output written to") + ) { + const match = result.output.match(/Full output written to: (.+\.txt)/); + expect(match).toBeDefined(); + + if (match?.[1]) { + const overflowPath = match[1]; + expect(fs.existsSync(overflowPath)).toBe(true); + + const fullContent = fs.readFileSync(overflowPath, "utf-8"); + expect(fullContent.length).toBeGreaterThan(0); + } + } + }); + }); + + describe("Middle truncation verification", () => { + test("shows beginning and end of output", async () => { + process.env.USER_CWD = testWorkingDir; + + // Generate output with distinctive beginning and end + const command = + 'echo "START_MARKER"; for i in {1..1000}; do echo "Middle line $i"; done; echo "END_MARKER"'; + + const result = await bash({ command }); + + if (result.content[0]?.text?.includes("Output truncated")) { + // Should contain both START and END markers due to middle truncation + expect(result.content[0].text).toContain("START_MARKER"); + expect(result.content[0].text).toContain("END_MARKER"); + expect(result.content[0].text).toContain("characters omitted"); + } + }); + }); +}); diff --git a/src/tests/tools/overflow.test.ts b/src/tests/tools/overflow.test.ts new file mode 100644 index 0000000..f39562d --- /dev/null +++ b/src/tests/tools/overflow.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + cleanupOldOverflowFiles, + ensureOverflowDirectory, + getOverflowDirectory, + getOverflowStats, + OVERFLOW_CONFIG, + writeOverflowFile, +} from "../../tools/impl/overflow"; + +describe("overflow utilities", () => { + const testWorkingDir = "/test/project/path"; + let overflowDir: string; + + beforeEach(() => { + overflowDir = getOverflowDirectory(testWorkingDir); + }); + + afterEach(() => { + // Clean up test files + if (fs.existsSync(overflowDir)) { + const files = fs.readdirSync(overflowDir); + for (const file of files) { + fs.unlinkSync(path.join(overflowDir, file)); + } + // Try to remove the directory (will fail if not empty) + try { + fs.rmdirSync(overflowDir); + } catch { + // Directory not empty, that's OK + } + } + }); + + describe("OVERFLOW_CONFIG", () => { + test("has expected default values", () => { + expect(OVERFLOW_CONFIG.ENABLED).toBeDefined(); + expect(OVERFLOW_CONFIG.MIDDLE_TRUNCATE).toBeDefined(); + }); + }); + + describe("getOverflowDirectory", () => { + test("generates consistent directory path", () => { + const dir1 = getOverflowDirectory(testWorkingDir); + const dir2 = getOverflowDirectory(testWorkingDir); + + expect(dir1).toBe(dir2); + }); + + test("creates path under ~/.letta", () => { + const dir = getOverflowDirectory(testWorkingDir); + const homeDir = os.homedir(); + + expect(dir).toContain(path.join(homeDir, ".letta")); + }); + + test("sanitizes working directory path", () => { + const dir = getOverflowDirectory("/path/with spaces/and:colons"); + + expect(dir).not.toContain(" "); + expect(dir).not.toContain(":"); + expect(dir).toContain("path_with_spaces_and_colons"); + }); + }); + + describe("ensureOverflowDirectory", () => { + test("creates directory if it doesn't exist", () => { + const dir = ensureOverflowDirectory(testWorkingDir); + + expect(fs.existsSync(dir)).toBe(true); + expect(fs.statSync(dir).isDirectory()).toBe(true); + }); + + test("returns existing directory without error", () => { + const dir1 = ensureOverflowDirectory(testWorkingDir); + const dir2 = ensureOverflowDirectory(testWorkingDir); + + expect(dir1).toBe(dir2); + expect(fs.existsSync(dir1)).toBe(true); + }); + }); + + describe("writeOverflowFile", () => { + test("writes content to a file", () => { + const content = "Test content for overflow file"; + const filePath = writeOverflowFile(content, testWorkingDir, "TestTool"); + + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe(content); + }); + + test("generates unique filenames", () => { + const content = "Test content"; + const file1 = writeOverflowFile(content, testWorkingDir, "TestTool"); + const file2 = writeOverflowFile(content, testWorkingDir, "TestTool"); + + expect(file1).not.toBe(file2); + }); + + test("includes tool name in filename", () => { + const content = "Test content"; + const filePath = writeOverflowFile( + content, + testWorkingDir, + "MyCustomTool", + ); + + expect(path.basename(filePath)).toContain("mycustomtool"); + }); + + test("handles large content", () => { + const largeContent = "x".repeat(100_000); + const filePath = writeOverflowFile( + largeContent, + testWorkingDir, + "TestTool", + ); + + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, "utf-8")).toBe(largeContent); + }); + }); + + describe("cleanupOldOverflowFiles", () => { + test("removes files older than specified age", async () => { + // Create a test file + const content = "Test content"; + const filePath = writeOverflowFile(content, testWorkingDir, "TestTool"); + + // Manually set the file's mtime to be old + const oldTime = Date.now() - 48 * 60 * 60 * 1000; // 48 hours ago + fs.utimesSync(filePath, new Date(oldTime), new Date(oldTime)); + + // Clean up files older than 24 hours + const deletedCount = cleanupOldOverflowFiles( + testWorkingDir, + 24 * 60 * 60 * 1000, + ); + + expect(deletedCount).toBe(1); + expect(fs.existsSync(filePath)).toBe(false); + }); + + test("preserves recent files", () => { + const content = "Test content"; + const filePath = writeOverflowFile(content, testWorkingDir, "TestTool"); + + // Clean up files older than 24 hours (file is recent) + const deletedCount = cleanupOldOverflowFiles( + testWorkingDir, + 24 * 60 * 60 * 1000, + ); + + expect(deletedCount).toBe(0); + expect(fs.existsSync(filePath)).toBe(true); + }); + + test("returns 0 if directory doesn't exist", () => { + const nonExistentDir = "/non/existent/directory"; + const deletedCount = cleanupOldOverflowFiles( + nonExistentDir, + 24 * 60 * 60 * 1000, + ); + + expect(deletedCount).toBe(0); + }); + }); + + describe("getOverflowStats", () => { + test("returns correct stats for empty directory", () => { + ensureOverflowDirectory(testWorkingDir); + const stats = getOverflowStats(testWorkingDir); + + expect(stats.exists).toBe(true); + expect(stats.fileCount).toBe(0); + expect(stats.totalSize).toBe(0); + }); + + test("returns correct stats for directory with files", () => { + const content1 = "Test content 1"; + const content2 = "Test content 2 is longer"; + + writeOverflowFile(content1, testWorkingDir, "Tool1"); + writeOverflowFile(content2, testWorkingDir, "Tool2"); + + const stats = getOverflowStats(testWorkingDir); + + expect(stats.exists).toBe(true); + expect(stats.fileCount).toBe(2); + expect(stats.totalSize).toBeGreaterThan(0); + }); + + test("returns correct stats for non-existent directory", () => { + const nonExistentDir = "/non/existent/directory"; + const stats = getOverflowStats(nonExistentDir); + + expect(stats.exists).toBe(false); + expect(stats.fileCount).toBe(0); + expect(stats.totalSize).toBe(0); + }); + }); +}); diff --git a/src/tests/tools/tool-truncation.test.ts b/src/tests/tools/tool-truncation.test.ts index f6ce28e..862db04 100644 --- a/src/tests/tools/tool-truncation.test.ts +++ b/src/tests/tools/tool-truncation.test.ts @@ -41,7 +41,7 @@ describe("tool truncation integration tests", () => { }); const output = result.content[0]?.text || ""; - expect(output).toContain("[Output truncated after 30,000 characters"); + expect(output).toContain("[Output truncated: showing 30,000"); expect(output.length).toBeLessThan(35000); // Truncated + notice }, ); @@ -64,7 +64,7 @@ describe("tool truncation integration tests", () => { }); const output = result.content[0]?.text || ""; - expect(output).toContain("[Output truncated after 30,000 characters"); + expect(output).toContain("[Output truncated: showing 30,000"); expect(result.status).toBe("error"); }, ); @@ -143,9 +143,7 @@ describe("tool truncation integration tests", () => { }); expect(result.output.length).toBeLessThanOrEqual(15000); // 10K + notice - expect(result.output).toContain( - "[Output truncated after 10,000 characters", - ); + expect(result.output).toContain("[Output truncated: showing 10,000"); }); test("truncates file list exceeding 10K characters", async () => { @@ -165,9 +163,7 @@ describe("tool truncation integration tests", () => { expect(result.output.length).toBeLessThanOrEqual(15000); if (result.output.length > 10000) { - expect(result.output).toContain( - "[Output truncated after 10,000 characters", - ); + expect(result.output).toContain("[Output truncated: showing 10,000"); } }); @@ -196,15 +192,11 @@ describe("tool truncation integration tests", () => { 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", - ); + // Should contain the truncation notice + const filesString = result.files.join("\n"); + expect(filesString).toContain("showing 2,000 of 2,500 files"); }, { timeout: 15000 }, ); // Increased timeout for Windows CI where file creation is slower @@ -286,7 +278,7 @@ describe("tool truncation integration tests", () => { expect(outputResult.message.length).toBeLessThan(35000); // 30K + notice if (outputResult.message.length > 30000) { expect(outputResult.message).toContain( - "[Output truncated after 30,000 characters", + "[Output truncated: showing 30,000", ); } }, diff --git a/src/tests/tools/truncation-overflow.test.ts b/src/tests/tools/truncation-overflow.test.ts new file mode 100644 index 0000000..888235a --- /dev/null +++ b/src/tests/tools/truncation-overflow.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { getOverflowDirectory } from "../../tools/impl/overflow"; +import { + truncateArray, + truncateByChars, + truncateByLines, +} from "../../tools/impl/truncation"; + +describe("truncation with overflow support", () => { + const testWorkingDir = "/test/truncation/path"; + let overflowDir: string; + + beforeEach(() => { + overflowDir = getOverflowDirectory(testWorkingDir); + }); + + afterEach(() => { + // Clean up test files + if (fs.existsSync(overflowDir)) { + const files = fs.readdirSync(overflowDir); + for (const file of files) { + fs.unlinkSync(path.join(overflowDir, file)); + } + } + }); + + describe("truncateByChars with overflow", () => { + test("writes overflow file when content exceeds limit", () => { + const longText = "a".repeat(2000); + const result = truncateByChars(longText, 1000, "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.wasTruncated).toBe(true); + expect(result.overflowPath).toBeDefined(); + + if (result.overflowPath) { + expect(fs.existsSync(result.overflowPath)).toBe(true); + expect(fs.readFileSync(result.overflowPath, "utf-8")).toBe(longText); + } + }); + + test("includes overflow path in truncation notice", () => { + const longText = "x".repeat(2000); + const result = truncateByChars(longText, 1000, "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.content).toContain("Full output written to:"); + expect(result.content).toContain(result.overflowPath || ""); + }); + + test("uses middle truncation when enabled", () => { + const text = `${"a".repeat(500)}MIDDLE${"b".repeat(500)}`; + const result = truncateByChars(text, 600, "TestTool", { + workingDirectory: testWorkingDir, + useMiddleTruncation: true, + }); + + expect(result.wasTruncated).toBe(true); + expect(result.content).toContain("a".repeat(300)); // beginning + expect(result.content).toContain("b".repeat(300)); // end + expect(result.content).toContain("characters omitted"); + // "MIDDLE" should be omitted + expect(result.content.split("[")[0]).not.toContain("MIDDLE"); + }); + + test("uses post truncation when middle truncation disabled", () => { + const text = `${"a".repeat(500)}END`; + const result = truncateByChars(text, 300, "TestTool", { + workingDirectory: testWorkingDir, + useMiddleTruncation: false, + }); + + expect(result.wasTruncated).toBe(true); + expect(result.content).toContain("a".repeat(300)); + expect(result.content.split("[")[0]).not.toContain("END"); + }); + + test("does not create overflow file when under limit", () => { + const shortText = "short text"; + const result = truncateByChars(shortText, 1000, "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.wasTruncated).toBe(false); + expect(result.overflowPath).toBeUndefined(); + }); + }); + + describe("truncateByLines with overflow", () => { + test("writes overflow file when lines exceed limit", () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const text = lines.join("\n"); + + const result = truncateByLines(text, 50, undefined, "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.wasTruncated).toBe(true); + expect(result.overflowPath).toBeDefined(); + + if (result.overflowPath) { + expect(fs.existsSync(result.overflowPath)).toBe(true); + expect(fs.readFileSync(result.overflowPath, "utf-8")).toBe(text); + } + }); + + test("uses middle truncation for lines when enabled", () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const text = lines.join("\n"); + + const result = truncateByLines(text, 50, undefined, "TestTool", { + workingDirectory: testWorkingDir, + useMiddleTruncation: true, + }); + + expect(result.wasTruncated).toBe(true); + expect(result.content).toContain("Line 1"); // beginning + expect(result.content).toContain("Line 25"); // end of first half + expect(result.content).toContain("Line 76"); // beginning of second half + expect(result.content).toContain("Line 100"); // end + expect(result.content).toContain("lines omitted"); + }); + + test("includes overflow path in truncation notice", () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const text = lines.join("\n"); + + const result = truncateByLines(text, 50, undefined, "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.content).toContain("Full output written to:"); + expect(result.content).toContain(result.overflowPath || ""); + }); + }); + + describe("truncateArray with overflow", () => { + test("writes overflow file when items exceed 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", "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.wasTruncated).toBe(true); + expect(result.overflowPath).toBeDefined(); + + if (result.overflowPath) { + expect(fs.existsSync(result.overflowPath)).toBe(true); + const savedContent = fs.readFileSync(result.overflowPath, "utf-8"); + expect(savedContent).toContain("item1"); + expect(savedContent).toContain("item100"); + } + }); + + test("uses middle truncation for arrays when enabled", () => { + const items = Array.from({ length: 100 }, (_, i) => `item${i + 1}`); + const formatter = (arr: string[]) => arr.join("\n"); + + const result = truncateArray(items, 50, formatter, "items", "TestTool", { + workingDirectory: testWorkingDir, + useMiddleTruncation: true, + }); + + expect(result.wasTruncated).toBe(true); + expect(result.content).toContain("item1"); // beginning + expect(result.content).toContain("item25"); // end of first half + expect(result.content).toContain("item76"); // beginning of second half + expect(result.content).toContain("item100"); // end + expect(result.content).toContain("omitted from middle"); + }); + + test("includes overflow path in truncation notice", () => { + const items = Array.from({ length: 100 }, (_, i) => `item${i + 1}`); + const formatter = (arr: string[]) => arr.join("\n"); + + const result = truncateArray(items, 50, formatter, "items", "TestTool", { + workingDirectory: testWorkingDir, + toolName: "TestTool", + }); + + expect(result.content).toContain("Full output written to:"); + expect(result.content).toContain(result.overflowPath || ""); + }); + }); +}); diff --git a/src/tests/tools/truncation.test.ts b/src/tests/tools/truncation.test.ts index 44ee4e5..b5cf0c5 100644 --- a/src/tests/tools/truncation.test.ts +++ b/src/tests/tools/truncation.test.ts @@ -21,9 +21,11 @@ describe("truncation utilities", () => { const result = truncateByChars(text, 500, "Test"); expect(result.wasTruncated).toBe(true); - expect(result.content).toContain("a".repeat(500)); + // With middle truncation, we should see beginning and end + expect(result.content).toContain("a".repeat(250)); // beginning + expect(result.content).toContain("characters omitted"); expect(result.content).toContain( - "[Output truncated after 500 characters: exceeded limit.]", + "[Output truncated: showing 500 of 1,000 characters.]", ); expect(result.content.length).toBeGreaterThan(500); // Due to notice }); @@ -62,10 +64,13 @@ describe("truncation utilities", () => { expect(result.wasTruncated).toBe(true); expect(result.originalLineCount).toBe(100); - expect(result.linesShown).toBe(50); + // With middle truncation, we get beginning + marker + end = 51 lines shown + expect(result.linesShown).toBe(51); expect(result.content).toContain("Line 1"); - expect(result.content).toContain("Line 50"); - expect(result.content).not.toContain("Line 51"); + expect(result.content).toContain("Line 25"); // end of first half + expect(result.content).toContain("lines omitted"); + expect(result.content).toContain("Line 76"); // beginning of second half + expect(result.content).toContain("Line 100"); expect(result.content).toContain("showing 50 of 100 lines"); }); @@ -92,7 +97,8 @@ describe("truncation utilities", () => { expect(result.wasTruncated).toBe(true); expect(result.originalLineCount).toBe(100); - expect(result.linesShown).toBe(50); + // With middle truncation, we get beginning + marker + end = 51 lines shown + expect(result.linesShown).toBe(51); expect(result.content).toContain("showing 50 of 100 lines"); expect(result.content).toContain( "Some lines exceeded 1,000 characters and were truncated", @@ -125,9 +131,12 @@ describe("truncation utilities", () => { expect(result.wasTruncated).toBe(true); expect(result.content).toContain("item1"); - expect(result.content).toContain("item50"); - expect(result.content).not.toContain("item51"); + // With middle truncation, we show first 25 and last 25 + expect(result.content).toContain("item25"); + expect(result.content).toContain("item76"); + expect(result.content).toContain("item100"); expect(result.content).toContain("showing 50 of 100 items"); + expect(result.content).toContain("omitted from middle"); }); test("exactly at limit does not truncate", () => { diff --git a/src/tools/impl/Bash.ts b/src/tools/impl/Bash.ts index 977b4e8..94a576b 100644 --- a/src/tools/impl/Bash.ts +++ b/src/tools/impl/Bash.ts @@ -324,6 +324,7 @@ export async function bash(args: BashArgs): Promise { output || "(Command completed with no output)", LIMITS.BASH_OUTPUT_CHARS, "Bash", + { workingDirectory: userCwd, toolName: "Bash" }, ); // Non-zero exit code is an error @@ -376,6 +377,7 @@ export async function bash(args: BashArgs): Promise { errorMessage.trim() || "Command failed with unknown error", LIMITS.BASH_OUTPUT_CHARS, "Bash", + { workingDirectory: userCwd, toolName: "Bash" }, ); return { diff --git a/src/tools/impl/BashOutput.ts b/src/tools/impl/BashOutput.ts index 133bd6f..0f26f2e 100644 --- a/src/tools/impl/BashOutput.ts +++ b/src/tools/impl/BashOutput.ts @@ -29,11 +29,14 @@ export async function bash_output( .join("\n"); } + const userCwd = process.env.USER_CWD || process.cwd(); + // Apply character limit to prevent excessive token usage (same as Bash) const { content: truncatedOutput } = truncateByChars( text || "(no output yet)", LIMITS.BASH_OUTPUT_CHARS, "BashOutput", + { workingDirectory: userCwd, toolName: "BashOutput" }, ); return { message: truncatedOutput }; diff --git a/src/tools/impl/Glob.ts b/src/tools/impl/Glob.ts index c57b55c..d9f14f2 100644 --- a/src/tools/impl/Glob.ts +++ b/src/tools/impl/Glob.ts @@ -3,7 +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 } from "./truncation.js"; +import { LIMITS, truncateArray } from "./truncation.js"; import { validateRequiredParams } from "./validation.js"; const execFileAsync = promisify(execFile); @@ -32,20 +32,27 @@ interface GlobResult { totalFiles?: number; } -function applyFileLimit(files: string[]): GlobResult { +function applyFileLimit(files: string[], workingDirectory: string): GlobResult { const totalFiles = files.length; if (totalFiles <= LIMITS.GLOB_MAX_FILES) { return { files }; } - const truncatedFiles = files.slice(0, LIMITS.GLOB_MAX_FILES); - truncatedFiles.push( - `\n[Output truncated: showing ${LIMITS.GLOB_MAX_FILES.toLocaleString()} of ${totalFiles.toLocaleString()} files.]`, + const { content, wasTruncated } = truncateArray( + files, + LIMITS.GLOB_MAX_FILES, + (items) => items.join("\n"), + "files", + "Glob", + { workingDirectory, toolName: "Glob" }, ); + // Split the content back into an array of file paths + notice + const resultFiles = content.split("\n"); + return { - files: truncatedFiles, - truncated: true, + files: resultFiles, + truncated: wasTruncated, totalFiles, }; } @@ -90,7 +97,7 @@ export async function glob(args: GlobArgs): Promise { const files = stdout.trim().split("\n").filter(Boolean).sort(); - return applyFileLimit(files); + return applyFileLimit(files, userCwd); } catch (error) { const err = error as Error & { stdout?: string; @@ -105,7 +112,7 @@ export async function glob(args: GlobArgs): Promise { // If stdout has content despite error, use it (partial results) if (err.stdout?.trim()) { const files = err.stdout.trim().split("\n").filter(Boolean).sort(); - return applyFileLimit(files); + return applyFileLimit(files, userCwd); } throw new Error(`Glob failed: ${err.message || "Unknown error"}`); diff --git a/src/tools/impl/Grep.ts b/src/tools/impl/Grep.ts index e78e2ca..32e710c 100644 --- a/src/tools/impl/Grep.ts +++ b/src/tools/impl/Grep.ts @@ -118,6 +118,7 @@ export async function grep(args: GrepArgs): Promise { fullOutput, LIMITS.GREP_OUTPUT_CHARS, "Grep", + { workingDirectory: userCwd, toolName: "Grep" }, ); return { @@ -166,6 +167,7 @@ export async function grep(args: GrepArgs): Promise { content, LIMITS.GREP_OUTPUT_CHARS, "Grep", + { workingDirectory: userCwd, toolName: "Grep" }, ); return { diff --git a/src/tools/impl/Read.ts b/src/tools/impl/Read.ts index ab2692b..72ffcf1 100644 --- a/src/tools/impl/Read.ts +++ b/src/tools/impl/Read.ts @@ -1,5 +1,6 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; +import { OVERFLOW_CONFIG, writeOverflowFile } from "./overflow.js"; import { LIMITS } from "./truncation.js"; import { validateRequiredParams } from "./validation.js"; @@ -52,6 +53,7 @@ function formatWithLineNumbers( content: string, offset?: number, limit?: number, + workingDirectory?: string, ): string { const lines = content.split("\n"); const originalLineCount = lines.length; @@ -88,6 +90,21 @@ function formatWithLineNumbers( const notices: string[] = []; const wasTruncatedByLineCount = actualEndLine < originalLineCount; + // Write to overflow file if content was truncated and overflow is enabled + let overflowPath: string | undefined; + if ( + (wasTruncatedByLineCount || linesWereTruncatedInLength) && + OVERFLOW_CONFIG.ENABLED && + workingDirectory + ) { + try { + overflowPath = writeOverflowFile(content, workingDirectory, "Read"); + } catch (error) { + // Silently fail if overflow file creation fails + console.error("Failed to write overflow file:", error); + } + } + if (wasTruncatedByLineCount && !limit) { // Only show this notice if user didn't explicitly set a limit notices.push( @@ -101,6 +118,10 @@ function formatWithLineNumbers( ); } + if (overflowPath) { + notices.push(`\n\n[Full file content written to: ${overflowPath}]`); + } + if (notices.length > 0) { result += notices.join(""); } @@ -132,7 +153,12 @@ export async function read(args: ReadArgs): Promise { content: `\nThe file ${resolvedPath} exists but has empty contents.\n`, }; } - const formattedContent = formatWithLineNumbers(content, offset, limit); + const formattedContent = formatWithLineNumbers( + content, + offset, + limit, + userCwd, + ); return { content: formattedContent }; } catch (error) { const err = error as NodeJS.ErrnoException; diff --git a/src/tools/impl/overflow.ts b/src/tools/impl/overflow.ts new file mode 100644 index 0000000..9c7959b --- /dev/null +++ b/src/tools/impl/overflow.ts @@ -0,0 +1,171 @@ +/** + * Utilities for writing tool output overflow to files. + * When tool outputs exceed truncation limits, the full output is written to disk + * and a pointer is provided in the truncated output. + */ + +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Configuration options for tool output overflow behavior. + * Can be controlled via environment variables. + */ +export const OVERFLOW_CONFIG = { + /** Whether to write overflow to files (default: true) */ + ENABLED: process.env.LETTA_TOOL_OVERFLOW_TO_FILE?.toLowerCase() !== "false", + /** Whether to use middle-truncation instead of post-truncation (default: true) */ + MIDDLE_TRUNCATE: + process.env.LETTA_TOOL_MIDDLE_TRUNCATE?.toLowerCase() !== "false", +} as const; + +/** + * Get the overflow directory for the current project. + * Pattern: ~/.letta/projects//agent-tools/ + * + * @param workingDirectory - Current working directory (project root) + * @returns Absolute path to the overflow directory + */ +export function getOverflowDirectory(workingDirectory: string): string { + const homeDir = os.homedir(); + const lettaDir = path.join(homeDir, ".letta"); + + // Normalize and sanitize the working directory path for use in the file system + const normalizedPath = path.normalize(workingDirectory); + // Remove leading slash and replace path separators with underscores + const sanitizedPath = normalizedPath + .replace(/^[/\\]/, "") // Remove leading slash + .replace(/[/\\:]/g, "_") // Replace slashes and colons + .replace(/\s+/g, "_"); // Replace spaces with underscores + + const overflowDir = path.join( + lettaDir, + "projects", + sanitizedPath, + "agent-tools", + ); + + return overflowDir; +} + +/** + * Ensure the overflow directory exists, creating it if necessary. + * + * @param workingDirectory - Current working directory (project root) + * @returns Absolute path to the overflow directory + */ +export function ensureOverflowDirectory(workingDirectory: string): string { + const overflowDir = getOverflowDirectory(workingDirectory); + + if (!fs.existsSync(overflowDir)) { + fs.mkdirSync(overflowDir, { recursive: true }); + } + + return overflowDir; +} + +/** + * Write tool output to an overflow file. + * + * @param content - Full content to write + * @param workingDirectory - Current working directory (project root) + * @param toolName - Name of the tool (optional, for filename) + * @returns Absolute path to the written file + */ +export function writeOverflowFile( + content: string, + workingDirectory: string, + toolName?: string, +): string { + const overflowDir = ensureOverflowDirectory(workingDirectory); + + // Generate a unique filename + const uuid = randomUUID(); + const filename = toolName + ? `${toolName.toLowerCase()}-${uuid}.txt` + : `${uuid}.txt`; + + const filePath = path.join(overflowDir, filename); + + // Write the content to the file + fs.writeFileSync(filePath, content, "utf-8"); + + return filePath; +} + +/** + * Clean up old overflow files to prevent directory bloat. + * Removes files older than the specified age. + * + * @param workingDirectory - Current working directory (project root) + * @param maxAgeMs - Maximum age in milliseconds (default: 24 hours) + * @returns Number of files deleted + */ +export function cleanupOldOverflowFiles( + workingDirectory: string, + maxAgeMs: number = 24 * 60 * 60 * 1000, +): number { + const overflowDir = getOverflowDirectory(workingDirectory); + + if (!fs.existsSync(overflowDir)) { + return 0; + } + + const files = fs.readdirSync(overflowDir); + const now = Date.now(); + let deletedCount = 0; + + for (const file of files) { + const filePath = path.join(overflowDir, file); + const stats = fs.statSync(filePath); + + if (now - stats.mtimeMs > maxAgeMs) { + fs.unlinkSync(filePath); + deletedCount++; + } + } + + return deletedCount; +} + +/** + * Get overflow file statistics for debugging/monitoring. + * + * @param workingDirectory - Current working directory (project root) + * @returns Statistics object + */ +export function getOverflowStats(workingDirectory: string): { + directory: string; + exists: boolean; + fileCount: number; + totalSize: number; +} { + const overflowDir = getOverflowDirectory(workingDirectory); + + if (!fs.existsSync(overflowDir)) { + return { + directory: overflowDir, + exists: false, + fileCount: 0, + totalSize: 0, + }; + } + + const files = fs.readdirSync(overflowDir); + let totalSize = 0; + + for (const file of files) { + const filePath = path.join(overflowDir, file); + const stats = fs.statSync(filePath); + totalSize += stats.size; + } + + return { + directory: overflowDir, + exists: true, + fileCount: files.length, + totalSize, + }; +} diff --git a/src/tools/impl/truncation.ts b/src/tools/impl/truncation.ts index 2b25399..00e3360 100644 --- a/src/tools/impl/truncation.ts +++ b/src/tools/impl/truncation.ts @@ -1,8 +1,11 @@ /** * Centralized truncation utilities for tool outputs. * Implements limits similar to Claude Code to prevent excessive token usage. + * When outputs exceed limits, full content can be written to overflow files. */ +import { OVERFLOW_CONFIG, writeOverflowFile } from "./overflow.js"; + // Limits based on Claude Code's proven production values export const LIMITS = { // Command output limits @@ -18,47 +21,125 @@ export const LIMITS = { LS_MAX_ENTRIES: 1_000, // Max directory entries } as const; +/** + * Options for truncation with overflow support + */ +export interface TruncationOptions { + /** Working directory for overflow file creation */ + workingDirectory?: string; + /** Tool name for overflow file naming */ + toolName?: string; + /** Whether to use middle truncation (keep beginning and end) */ + useMiddleTruncation?: boolean; +} + /** * Truncates text to a maximum character count. * Adds a truncation notice when content exceeds limit. + * Optionally writes full output to an overflow file. */ export function truncateByChars( text: string, maxChars: number, - _toolName: string = "output", -): { content: string; wasTruncated: boolean } { + toolName: string = "output", + options?: TruncationOptions, +): { content: string; wasTruncated: boolean; overflowPath?: string } { 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.]`; + // Determine if we should use middle truncation + const useMiddleTruncation = + options?.useMiddleTruncation ?? OVERFLOW_CONFIG.MIDDLE_TRUNCATE; + + // Write to overflow file if enabled and working directory provided + let overflowPath: string | undefined; + if (OVERFLOW_CONFIG.ENABLED && options?.workingDirectory) { + try { + overflowPath = writeOverflowFile( + text, + options.workingDirectory, + options.toolName ?? toolName, + ); + } catch (error) { + // Silently fail if overflow file creation fails + console.error("Failed to write overflow file:", error); + } + } + + let truncated: string; + if (useMiddleTruncation) { + // Middle truncation: keep beginning and end + const halfMax = Math.floor(maxChars / 2); + const beginning = text.slice(0, halfMax); + const end = text.slice(-halfMax); + const omittedChars = text.length - maxChars; + const middleNotice = `\n... [${omittedChars.toLocaleString()} characters omitted] ...\n`; + truncated = beginning + middleNotice + end; + } else { + // Post truncation: keep beginning only + truncated = text.slice(0, maxChars); + } + + const noticeLines = [ + `[Output truncated: showing ${maxChars.toLocaleString()} of ${text.length.toLocaleString()} characters.]`, + ]; + + if (overflowPath) { + noticeLines.push(`[Full output written to: ${overflowPath}]`); + } + + const notice = `\n\n${noticeLines.join("\n")}`; return { content: truncated + notice, wasTruncated: true, + overflowPath, }; } /** * Truncates text by line count. * Optionally enforces max characters per line. + * Optionally writes full output to an overflow file. */ export function truncateByLines( text: string, maxLines: number, maxCharsPerLine?: number, - _toolName: string = "output", + toolName: string = "output", + options?: TruncationOptions, ): { content: string; wasTruncated: boolean; originalLineCount: number; linesShown: number; + overflowPath?: string; } { const lines = text.split("\n"); const originalLineCount = lines.length; - let selectedLines = lines.slice(0, maxLines); + // Determine if we should use middle truncation + const useMiddleTruncation = + options?.useMiddleTruncation ?? OVERFLOW_CONFIG.MIDDLE_TRUNCATE; + + let selectedLines: string[]; + if (useMiddleTruncation && lines.length > maxLines) { + // Middle truncation: keep beginning and end lines + const halfMax = Math.floor(maxLines / 2); + const beginning = lines.slice(0, halfMax); + const end = lines.slice(-halfMax); + const omittedLines = lines.length - maxLines; + selectedLines = [ + ...beginning, + `... [${omittedLines.toLocaleString()} lines omitted] ...`, + ...end, + ]; + } else { + // Post truncation: keep beginning lines only + selectedLines = lines.slice(0, maxLines); + } + let linesWereTruncatedInLength = false; // Apply per-line character limit if specified @@ -73,6 +154,22 @@ export function truncateByLines( } const wasTruncated = lines.length > maxLines || linesWereTruncatedInLength; + + // Write to overflow file if enabled and working directory provided + let overflowPath: string | undefined; + if (wasTruncated && OVERFLOW_CONFIG.ENABLED && options?.workingDirectory) { + try { + overflowPath = writeOverflowFile( + text, + options.workingDirectory, + options.toolName ?? toolName, + ); + } catch (error) { + // Silently fail if overflow file creation fails + console.error("Failed to write overflow file:", error); + } + } + let content = selectedLines.join("\n"); if (wasTruncated) { @@ -90,6 +187,10 @@ export function truncateByLines( ); } + if (overflowPath) { + notices.push(`[Full output written to: ${overflowPath}]`); + } + content += `\n\n${notices.join(" ")}`; } @@ -98,29 +199,82 @@ export function truncateByLines( wasTruncated, originalLineCount, linesShown: selectedLines.length, + overflowPath, }; } /** * Truncates an array of items (file paths, directory entries, etc.) + * Optionally writes full output to an overflow file. */ export function truncateArray( items: T[], maxItems: number, formatter: (items: T[]) => string, itemType: string = "items", -): { content: string; wasTruncated: boolean } { + toolName: string = "output", + options?: TruncationOptions, +): { content: string; wasTruncated: boolean; overflowPath?: string } { 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}.]`; + // Determine if we should use middle truncation + const useMiddleTruncation = + options?.useMiddleTruncation ?? OVERFLOW_CONFIG.MIDDLE_TRUNCATE; + + let selectedItems: T[]; + if (useMiddleTruncation) { + // Middle truncation: keep beginning and end + const halfMax = Math.floor(maxItems / 2); + const beginning = items.slice(0, halfMax); + const end = items.slice(-halfMax); + // Note: We can't insert a marker in the middle of a typed array, + // so we'll just show beginning and end + selectedItems = [...beginning, ...end]; + } else { + // Post truncation: keep beginning only + selectedItems = items.slice(0, maxItems); + } + + // Write to overflow file if enabled and working directory provided + let overflowPath: string | undefined; + if (OVERFLOW_CONFIG.ENABLED && options?.workingDirectory) { + try { + const fullContent = formatter(items); + overflowPath = writeOverflowFile( + fullContent, + options.workingDirectory, + options.toolName ?? toolName, + ); + } catch (error) { + // Silently fail if overflow file creation fails + console.error("Failed to write overflow file:", error); + } + } + + const content = formatter(selectedItems); + const noticeLines = [ + `[Output truncated: showing ${maxItems.toLocaleString()} of ${items.length.toLocaleString()} ${itemType}.]`, + ]; + + if (useMiddleTruncation) { + const omitted = items.length - maxItems; + noticeLines.push( + `[${omitted.toLocaleString()} ${itemType} omitted from middle.]`, + ); + } + + if (overflowPath) { + noticeLines.push(`[Full output written to: ${overflowPath}]`); + } + + const notice = `\n\n${noticeLines.join("\n")}`; return { content: content + notice, wasTruncated: true, + overflowPath, }; }