feat: File based long tool return (#488)

This commit is contained in:
Kevin Lin
2026-01-08 06:15:51 +08:00
committed by GitHub
parent 4c59ca45ba
commit d0837e3536
12 changed files with 929 additions and 44 deletions

View File

@@ -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 {

View File

@@ -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 };

View File

@@ -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"}`);

View File

@@ -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 {

View File

@@ -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
View 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,
};
}

View File

@@ -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,
};
}