diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 25427f3b..e8f73db8 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -605,6 +605,7 @@ export async function loadCliConfig( shouldUseNodePtyShell: settings.shouldUseNodePtyShell, skipStartupContext: settings.skipStartupContext, skipNextSpeakerCheck: settings.skipNextSpeakerCheck, + toolOutputCharLimit: settings.toolOutputCharLimit, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0049bd50..ed3c3d94 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -577,6 +577,16 @@ export const SETTINGS_SCHEMA = { description: 'The maximum number of tokens allowed in a session.', showInDialog: false, }, + toolOutputCharLimit: { + type: 'number', + label: 'Tool Output Character Limit', + category: 'General', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Max characters for tool outputs (read_file, read_many_files, shell). If set, text content is truncated to this limit.', + showInDialog: true, + }, systemPromptMappings: { type: 'object', label: 'System Prompt Mappings', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 74a818ff..00cd3cbb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -239,6 +239,8 @@ export interface ConfigParameters { shouldUseNodePtyShell?: boolean; skipStartupContext?: boolean; skipNextSpeakerCheck?: boolean; + // Character limit for tool text outputs (files and shell) + toolOutputCharLimit?: number; } export class Config { @@ -326,6 +328,7 @@ export class Config { private readonly skipStartupContext: boolean; private readonly skipNextSpeakerCheck: boolean; private initialized: boolean = false; + private readonly toolOutputCharLimit?: number; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -407,6 +410,7 @@ export class Config { this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipStartupContext = params.skipStartupContext ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; + this.toolOutputCharLimit = params.toolOutputCharLimit; // Web search this.tavilyApiKey = params.tavilyApiKey; @@ -873,6 +877,14 @@ export class Config { return this.skipNextSpeakerCheck; } + /** + * Returns the configured maximum number of characters for tool outputs. + * If undefined, no character-based truncation is applied by tools. + */ + getToolOutputCharLimit(): number | undefined { + return this.toolOutputCharLimit; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index da8a004d..9f38d642 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -122,21 +122,31 @@ class ReadFileToolInvocation extends BaseToolInvocation< } let llmContent: PartUnion; + const charLimit = this.config.getToolOutputCharLimit(); if (result.isTruncated) { const [start, end] = result.linesShown!; const total = result.originalLineCount!; const nextOffset = this.params.offset ? this.params.offset + end - start + 1 : end; - llmContent = ` -IMPORTANT: The file content has been truncated. -Status: Showing lines ${start}-${end} of ${total} total lines. -Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}. - ---- FILE CONTENT (truncated) --- -${result.llmContent}`; + const header = `\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines ${start}-${end} of ${total} total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}.\n\n--- FILE CONTENT (truncated) ---\n`; + const body = typeof result.llmContent === 'string' ? result.llmContent : ''; + let truncatedBody = body; + if (typeof charLimit === 'number' && charLimit > 0 && body.length > charLimit) { + truncatedBody = `${body.slice(0, charLimit)}\n[... File content truncated to ${charLimit} characters ...]`; + } + llmContent = header + truncatedBody; } else { - llmContent = result.llmContent || ''; + let body = result.llmContent || ''; + if ( + typeof body === 'string' && + typeof charLimit === 'number' && + charLimit > 0 && + body.length > charLimit + ) { + body = `${body.slice(0, charLimit)}\n[... File content truncated to ${charLimit} characters ...]`; + } + llmContent = body; } const lines = diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 0841c142..fa819a95 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -228,6 +228,7 @@ ${finalExclusionPatternsForDescription const skippedFiles: Array<{ path: string; reason: string }> = []; const processedFilesRelativePaths: string[] = []; const contentParts: PartListUnion = []; + const charLimit = this.config.getToolOutputCharLimit(); const effectiveExcludes = useDefaultExcludes ? [...DEFAULT_EXCLUDES, ...exclude] @@ -436,6 +437,9 @@ ${finalExclusionPatternsForDescription ); const results = await Promise.allSettled(fileProcessingPromises); + let remainingContentChars = + typeof charLimit === 'number' && charLimit > 0 ? charLimit : Number.POSITIVE_INFINITY; + let globalTruncated = false; for (const result of results) { if (result.status === 'fulfilled') { @@ -449,22 +453,47 @@ ${finalExclusionPatternsForDescription }); } else { // Handle successfully processed files - const { filePath, relativePathForDisplay, fileReadResult } = - fileResult; + const { filePath, relativePathForDisplay, fileReadResult } = fileResult; if (typeof fileReadResult.llmContent === 'string') { + // Separator does not count toward char budget const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace( '{filePath}', filePath, ); - let fileContentForLlm = ''; + + let prefix = `${separator}\n\n`; + // Warning header (if any) does not count toward char budget if (fileReadResult.isTruncated) { - fileContentForLlm += `[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]\n\n`; + prefix += `[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]\n\n`; + } + contentParts.push(prefix); + + // Apply global char budget to the actual file content only + if (remainingContentChars > 0) { + const body = fileReadResult.llmContent; + if (body.length <= remainingContentChars) { + contentParts.push(body + '\n\n'); + remainingContentChars -= body.length; + } else { + contentParts.push( + body.slice(0, Math.max(0, remainingContentChars)), + ); + contentParts.push( + `\n[... Content truncated to ${charLimit} characters across files ...]\n`, + ); + remainingContentChars = 0; + globalTruncated = true; + } + } else if (!globalTruncated && typeof charLimit === 'number') { + // No remaining budget, emit a single global truncation marker after first overflow + contentParts.push( + `\n[... Content truncated to ${charLimit} characters across files ...]\n`, + ); + globalTruncated = true; } - fileContentForLlm += fileReadResult.llmContent; - contentParts.push(`${separator}\n\n${fileContentForLlm}\n\n`); } else { - // This is a Part for image/pdf, which we don't add the separator to. + // Non-text parts (image/pdf) do not count toward char budget contentParts.push(fileReadResult.llmContent); } @@ -538,6 +567,10 @@ ${finalExclusionPatternsForDescription 'No files matching the criteria were found or all were skipped.', ); } + if (globalTruncated && typeof charLimit === 'number') { + displayMessage += `\n\nNote: Output truncated to ${charLimit} characters (text content only).`; + } + return { llmContent: contentParts, returnDisplay: displayMessage.trim(), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 5707b1ea..e807cc2c 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -279,6 +279,24 @@ class ShellToolInvocation extends BaseToolInvocation< } } + // Apply character truncation (middle) to both llmContent and returnDisplay if configured + const charLimit = this.config.getToolOutputCharLimit(); + const middleTruncate = (s: string, limit: number): string => { + if (!s || s.length <= limit) return s; + const marker = '\n[... Output truncated due to length ...]\n'; + const keep = Math.max(0, Math.floor((limit - marker.length) / 2)); + if (keep <= 0) { + return s.slice(0, limit); + } + return s.slice(0, keep) + marker + s.slice(s.length - keep); + }; + if (typeof charLimit === 'number' && charLimit > 0) { + llmContent = middleTruncate(llmContent, charLimit); + if (returnDisplayMessage) { + returnDisplayMessage = middleTruncate(returnDisplayMessage, charLimit); + } + } + const summarizeConfig = this.config.getSummarizeToolOutputConfig(); if (summarizeConfig && summarizeConfig[ShellTool.Name]) { const summary = await summarizeToolOutput( diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 21db9e52..16ea50cc 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -11,7 +11,7 @@ import mime from 'mime-types'; import { FileSystemService } from '../services/fileSystemService.js'; // Constants for text file processing -export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; +export const DEFAULT_MAX_LINES_TEXT_FILE = 500; const MAX_LINE_LENGTH_TEXT_FILE = 2000; // Default values for encoding and separator format