feat: File based long tool return (#488)
This commit is contained in:
117
src/tests/tools/overflow-integration.test.ts
Normal file
117
src/tests/tools/overflow-integration.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
205
src/tests/tools/overflow.test.ts
Normal file
205
src/tests/tools/overflow.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
197
src/tests/tools/truncation-overflow.test.ts
Normal file
197
src/tests/tools/truncation-overflow.test.ts
Normal file
@@ -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 || "");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -324,6 +324,7 @@ export async function bash(args: BashArgs): Promise<BashResult> {
|
||||
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<BashResult> {
|
||||
errorMessage.trim() || "Command failed with unknown error",
|
||||
LIMITS.BASH_OUTPUT_CHARS,
|
||||
"Bash",
|
||||
{ workingDirectory: userCwd, toolName: "Bash" },
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<GlobResult> {
|
||||
|
||||
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<GlobResult> {
|
||||
// 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"}`);
|
||||
|
||||
@@ -118,6 +118,7 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
|
||||
fullOutput,
|
||||
LIMITS.GREP_OUTPUT_CHARS,
|
||||
"Grep",
|
||||
{ workingDirectory: userCwd, toolName: "Grep" },
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -166,6 +167,7 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
|
||||
content,
|
||||
LIMITS.GREP_OUTPUT_CHARS,
|
||||
"Grep",
|
||||
{ workingDirectory: userCwd, toolName: "Grep" },
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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<ReadResult> {
|
||||
content: `<system-reminder>\nThe file ${resolvedPath} exists but has empty contents.\n</system-reminder>`,
|
||||
};
|
||||
}
|
||||
const formattedContent = formatWithLineNumbers(content, offset, limit);
|
||||
const formattedContent = formatWithLineNumbers(
|
||||
content,
|
||||
offset,
|
||||
limit,
|
||||
userCwd,
|
||||
);
|
||||
return { content: formattedContent };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
|
||||
171
src/tools/impl/overflow.ts
Normal file
171
src/tools/impl/overflow.ts
Normal file
@@ -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/<project-path>/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,
|
||||
};
|
||||
}
|
||||
@@ -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<T>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user