fix: truncate runaways (#50)
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ExecOptions } from "node:child_process";
|
||||
import { exec, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { backgroundProcesses, getNextBashId } from "./process_manager.js";
|
||||
import { LIMITS, truncateByChars } from "./truncation.js";
|
||||
import { validateRequiredParams } from "./validation.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -115,10 +116,16 @@ export async function bash(args: BashArgs): Promise<BashResult> {
|
||||
const stderrStr = typeof stderr === "string" ? stderr : stderr.toString();
|
||||
let output = stdoutStr;
|
||||
if (stderrStr) output = output ? `${output}\n${stderrStr}` : stderrStr;
|
||||
|
||||
// Apply character limit to prevent excessive token usage
|
||||
const { content: truncatedOutput } = truncateByChars(
|
||||
output || "(Command completed with no output)",
|
||||
LIMITS.BASH_OUTPUT_CHARS,
|
||||
"Bash",
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: output || "(Command completed with no output)" },
|
||||
],
|
||||
content: [{ type: "text", text: truncatedOutput }],
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
@@ -134,13 +141,16 @@ export async function bash(args: BashArgs): Promise<BashResult> {
|
||||
if (err.stderr) errorMessage += err.stderr;
|
||||
else if (err.message) errorMessage += err.message;
|
||||
if (err.stdout) errorMessage = `${err.stdout}\n${errorMessage}`;
|
||||
|
||||
// Apply character limit even to error messages
|
||||
const { content: truncatedError } = truncateByChars(
|
||||
errorMessage.trim() || "Command failed with unknown error",
|
||||
LIMITS.BASH_OUTPUT_CHARS,
|
||||
"Bash",
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: errorMessage.trim() || "Command failed with unknown error",
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: truncatedError }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { backgroundProcesses } from "./process_manager.js";
|
||||
import { LIMITS, truncateByChars } from "./truncation.js";
|
||||
import { validateRequiredParams } from "./validation.js";
|
||||
|
||||
interface BashOutputArgs {
|
||||
@@ -27,5 +28,13 @@ export async function bash_output(
|
||||
.filter((line) => line.includes(filter))
|
||||
.join("\n");
|
||||
}
|
||||
return { message: text || "(no output yet)" };
|
||||
|
||||
// Apply character limit to prevent excessive token usage (same as Bash)
|
||||
const { content: truncatedOutput } = truncateByChars(
|
||||
text || "(no output yet)",
|
||||
LIMITS.BASH_OUTPUT_CHARS,
|
||||
"BashOutput",
|
||||
);
|
||||
|
||||
return { message: truncatedOutput };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import { LIMITS } from "./truncation.js";
|
||||
import { validateRequiredParams } from "./validation.js";
|
||||
|
||||
interface GlobArgs {
|
||||
@@ -9,6 +10,27 @@ interface GlobArgs {
|
||||
}
|
||||
interface GlobResult {
|
||||
files: string[];
|
||||
truncated?: boolean;
|
||||
totalFiles?: number;
|
||||
}
|
||||
|
||||
function applyFileLimit(files: string[]): GlobResult {
|
||||
const totalFiles = files.length;
|
||||
if (totalFiles <= LIMITS.GLOB_MAX_FILES) {
|
||||
return { files };
|
||||
}
|
||||
|
||||
const truncatedFiles = files.slice(0, LIMITS.GLOB_MAX_FILES);
|
||||
// Add truncation notice as last entry
|
||||
truncatedFiles.push(
|
||||
`\n[Output truncated: showing ${LIMITS.GLOB_MAX_FILES.toLocaleString()} of ${totalFiles.toLocaleString()} files.]`,
|
||||
);
|
||||
|
||||
return {
|
||||
files: truncatedFiles,
|
||||
truncated: true,
|
||||
totalFiles,
|
||||
};
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
@@ -60,17 +82,17 @@ export async function glob(args: GlobArgs): Promise<GlobResult> {
|
||||
const matchedFiles = allFiles.filter((file) =>
|
||||
matcher(path.basename(file)),
|
||||
);
|
||||
return { files: matchedFiles.sort() };
|
||||
return applyFileLimit(matchedFiles.sort());
|
||||
} else if (pattern.includes("**")) {
|
||||
const fullPattern = path.join(baseDir, pattern);
|
||||
matcher = picomatch(fullPattern, { dot: true });
|
||||
const matchedFiles = allFiles.filter((file) => matcher(file));
|
||||
return { files: matchedFiles.sort() };
|
||||
return applyFileLimit(matchedFiles.sort());
|
||||
} else {
|
||||
matcher = picomatch(pattern, { dot: true });
|
||||
const matchedFiles = allFiles.filter((file) =>
|
||||
matcher(path.relative(baseDir, file)),
|
||||
);
|
||||
return { files: matchedFiles.sort() };
|
||||
return applyFileLimit(matchedFiles.sort());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { LIMITS, truncateByChars } from "./truncation.js";
|
||||
import { validateRequiredParams } from "./validation.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -90,8 +91,19 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
|
||||
const files = stdout.trim().split("\n").filter(Boolean);
|
||||
const fileCount = files.length;
|
||||
if (fileCount === 0) return { output: "No files found", files: 0 };
|
||||
|
||||
const fileList = files.join("\n");
|
||||
const fullOutput = `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${fileList}`;
|
||||
|
||||
// Apply character limit to prevent large file lists
|
||||
const { content: truncatedOutput } = truncateByChars(
|
||||
fullOutput,
|
||||
LIMITS.GREP_OUTPUT_CHARS,
|
||||
"Grep",
|
||||
);
|
||||
|
||||
return {
|
||||
output: `Found ${fileCount} file${fileCount !== 1 ? "s" : ""}\n${files.join("\n")}`,
|
||||
output: truncatedOutput,
|
||||
files: fileCount,
|
||||
};
|
||||
} else if (output_mode === "count") {
|
||||
@@ -123,8 +135,16 @@ export async function grep(args: GrepArgs): Promise<GrepResult> {
|
||||
} else {
|
||||
if (!stdout || stdout.trim() === "")
|
||||
return { output: "No matches found", matches: 0 };
|
||||
|
||||
// Apply character limit to content output
|
||||
const { content: truncatedOutput } = truncateByChars(
|
||||
stdout,
|
||||
LIMITS.GREP_OUTPUT_CHARS,
|
||||
"Grep",
|
||||
);
|
||||
|
||||
return {
|
||||
output: stdout,
|
||||
output: truncatedOutput,
|
||||
matches: stdout.split("\n").filter(Boolean).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readdir, stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import LSSchema from "../schemas/LS.json";
|
||||
import { LIMITS } from "./truncation.js";
|
||||
import { validateParamTypes, validateRequiredParams } from "./validation.js";
|
||||
|
||||
interface LSArgs {
|
||||
@@ -48,7 +49,16 @@ export async function ls(
|
||||
: 1
|
||||
: a.name.localeCompare(b.name),
|
||||
);
|
||||
const tree = formatTree(dirPath, fileInfos);
|
||||
|
||||
// Apply entry limit to prevent massive directories
|
||||
const totalEntries = fileInfos.length;
|
||||
let truncated = false;
|
||||
if (totalEntries > LIMITS.LS_MAX_ENTRIES) {
|
||||
fileInfos.splice(LIMITS.LS_MAX_ENTRIES);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const tree = formatTree(dirPath, fileInfos, truncated, totalEntries);
|
||||
return { content: [{ type: "text", text: tree }] };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
@@ -60,7 +70,12 @@ export async function ls(
|
||||
}
|
||||
}
|
||||
|
||||
function formatTree(basePath: string, items: FileInfo[]): string {
|
||||
function formatTree(
|
||||
basePath: string,
|
||||
items: FileInfo[],
|
||||
truncated: boolean,
|
||||
totalEntries: number,
|
||||
): string {
|
||||
if (items.length === 0) return `${basePath}/ (empty directory)`;
|
||||
const lines: string[] = [];
|
||||
const pathParts = basePath.split("/");
|
||||
@@ -74,6 +89,15 @@ function formatTree(basePath: string, items: FileInfo[]): string {
|
||||
`${prefix}- ${item.name}${item.type === "directory" ? "/" : ""}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Add truncation notice if applicable
|
||||
if (truncated) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`[Output truncated: showing ${LIMITS.LS_MAX_ENTRIES.toLocaleString()} of ${totalEntries.toLocaleString()} entries.]`,
|
||||
);
|
||||
}
|
||||
|
||||
const hasHiddenFiles = items.some((item) => item.name.startsWith("."));
|
||||
if (hasHiddenFiles) {
|
||||
lines.push("");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { LIMITS } from "./truncation.js";
|
||||
import { validateRequiredParams } from "./validation.js";
|
||||
|
||||
interface ReadArgs {
|
||||
@@ -61,22 +62,58 @@ function formatWithLineNumbers(
|
||||
limit?: number,
|
||||
): string {
|
||||
const lines = content.split("\n");
|
||||
const originalLineCount = lines.length;
|
||||
const startLine = offset || 0;
|
||||
const endLine = limit
|
||||
? Math.min(startLine + limit, lines.length)
|
||||
: lines.length;
|
||||
|
||||
// Apply default limit if not specified (Claude Code: 2000 lines)
|
||||
const effectiveLimit = limit ?? LIMITS.READ_MAX_LINES;
|
||||
const endLine = Math.min(startLine + effectiveLimit, lines.length);
|
||||
const actualStartLine = Math.min(startLine, lines.length);
|
||||
const actualEndLine = Math.min(endLine, lines.length);
|
||||
const selectedLines = lines.slice(actualStartLine, actualEndLine);
|
||||
const maxLineNumber = actualStartLine + selectedLines.length;
|
||||
const padding = Math.max(1, maxLineNumber.toString().length);
|
||||
return selectedLines
|
||||
.map((line, index) => {
|
||||
const lineNumber = actualStartLine + index + 1;
|
||||
const paddedNumber = lineNumber.toString().padStart(padding);
|
||||
return `${paddedNumber}→${line}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// Apply per-line character limit (Claude Code: 2000 chars/line)
|
||||
let linesWereTruncatedInLength = false;
|
||||
const formattedLines = selectedLines.map((line, index) => {
|
||||
const lineNumber = actualStartLine + index + 1;
|
||||
const maxLineNumber = actualStartLine + selectedLines.length;
|
||||
const padding = Math.max(1, maxLineNumber.toString().length);
|
||||
const paddedNumber = lineNumber.toString().padStart(padding);
|
||||
|
||||
// Truncate long lines
|
||||
if (line.length > LIMITS.READ_MAX_CHARS_PER_LINE) {
|
||||
linesWereTruncatedInLength = true;
|
||||
const truncated = line.slice(0, LIMITS.READ_MAX_CHARS_PER_LINE);
|
||||
return `${paddedNumber}→${truncated}... [line truncated]`;
|
||||
}
|
||||
|
||||
return `${paddedNumber}→${line}`;
|
||||
});
|
||||
|
||||
let result = formattedLines.join("\n");
|
||||
|
||||
// Add truncation notices if applicable
|
||||
const notices: string[] = [];
|
||||
const wasTruncatedByLineCount = actualEndLine < originalLineCount;
|
||||
|
||||
if (wasTruncatedByLineCount && !limit) {
|
||||
// Only show this notice if user didn't explicitly set a limit
|
||||
notices.push(
|
||||
`\n\n[File truncated: showing lines ${actualStartLine + 1}-${actualEndLine} of ${originalLineCount} total lines. Use offset and limit parameters to read other sections.]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (linesWereTruncatedInLength) {
|
||||
notices.push(
|
||||
`\n\n[Some lines exceeded ${LIMITS.READ_MAX_CHARS_PER_LINE.toLocaleString()} characters and were truncated.]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (notices.length > 0) {
|
||||
result += notices.join("");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function read(args: ReadArgs): Promise<ReadResult> {
|
||||
|
||||
134
src/tools/impl/truncation.ts
Normal file
134
src/tools/impl/truncation.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Centralized truncation utilities for tool outputs.
|
||||
* Implements limits similar to Claude Code to prevent excessive token usage.
|
||||
*/
|
||||
|
||||
// Limits based on Claude Code's proven production values
|
||||
export const LIMITS = {
|
||||
// Command output limits
|
||||
BASH_OUTPUT_CHARS: 30_000, // 30K characters for bash/shell output
|
||||
|
||||
// File reading limits
|
||||
READ_MAX_LINES: 2_000, // Max lines per file read
|
||||
READ_MAX_CHARS_PER_LINE: 2_000, // Max characters per line
|
||||
|
||||
// Search/discovery limits
|
||||
GREP_OUTPUT_CHARS: 10_000, // Max characters for grep results
|
||||
GLOB_MAX_FILES: 2_000, // Max number of file paths
|
||||
LS_MAX_ENTRIES: 1_000, // Max directory entries
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Truncates text to a maximum character count.
|
||||
* Adds a truncation notice when content exceeds limit.
|
||||
*/
|
||||
export function truncateByChars(
|
||||
text: string,
|
||||
maxChars: number,
|
||||
toolName: string = "output",
|
||||
): { content: string; wasTruncated: boolean } {
|
||||
if (text.length <= maxChars) {
|
||||
return { content: text, wasTruncated: false };
|
||||
}
|
||||
|
||||
const truncated = text.slice(0, maxChars);
|
||||
const notice = `\n\n[Output truncated after ${maxChars.toLocaleString()} characters: exceeded limit.]`;
|
||||
|
||||
return {
|
||||
content: truncated + notice,
|
||||
wasTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text by line count.
|
||||
* Optionally enforces max characters per line.
|
||||
*/
|
||||
export function truncateByLines(
|
||||
text: string,
|
||||
maxLines: number,
|
||||
maxCharsPerLine?: number,
|
||||
toolName: string = "output",
|
||||
): {
|
||||
content: string;
|
||||
wasTruncated: boolean;
|
||||
originalLineCount: number;
|
||||
linesShown: number;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const originalLineCount = lines.length;
|
||||
|
||||
let selectedLines = lines.slice(0, maxLines);
|
||||
let linesWereTruncatedInLength = false;
|
||||
|
||||
// Apply per-line character limit if specified
|
||||
if (maxCharsPerLine !== undefined) {
|
||||
selectedLines = selectedLines.map((line) => {
|
||||
if (line.length > maxCharsPerLine) {
|
||||
linesWereTruncatedInLength = true;
|
||||
return line.slice(0, maxCharsPerLine) + "... [line truncated]";
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
const wasTruncated = lines.length > maxLines || linesWereTruncatedInLength;
|
||||
let content = selectedLines.join("\n");
|
||||
|
||||
if (wasTruncated) {
|
||||
const notices: string[] = [];
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
notices.push(
|
||||
`[Output truncated: showing ${maxLines.toLocaleString()} of ${originalLineCount.toLocaleString()} lines.]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (linesWereTruncatedInLength && maxCharsPerLine) {
|
||||
notices.push(
|
||||
`[Some lines exceeded ${maxCharsPerLine.toLocaleString()} characters and were truncated.]`,
|
||||
);
|
||||
}
|
||||
|
||||
content += "\n\n" + notices.join(" ");
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
wasTruncated,
|
||||
originalLineCount,
|
||||
linesShown: selectedLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates an array of items (file paths, directory entries, etc.)
|
||||
*/
|
||||
export function truncateArray<T>(
|
||||
items: T[],
|
||||
maxItems: number,
|
||||
formatter: (items: T[]) => string,
|
||||
itemType: string = "items",
|
||||
): { content: string; wasTruncated: boolean } {
|
||||
if (items.length <= maxItems) {
|
||||
return { content: formatter(items), wasTruncated: false };
|
||||
}
|
||||
|
||||
const truncatedItems = items.slice(0, maxItems);
|
||||
const content = formatter(truncatedItems);
|
||||
const notice = `\n\n[Output truncated: showing ${maxItems.toLocaleString()} of ${items.length.toLocaleString()} ${itemType}.]`;
|
||||
|
||||
return {
|
||||
content: content + notice,
|
||||
wasTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes for human-readable display
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user