add config: tool output char limit

This commit is contained in:
koalazf.99
2025-09-17 14:13:36 +08:00
parent a0d77f5a44
commit 514f292770
7 changed files with 100 additions and 16 deletions

View File

@@ -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<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);

View File

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

View File

@@ -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(),

View File

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

View File

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