fix: truncate runaways (#50)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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