refactor: simplify tool output truncation to single config (#18446)

This commit is contained in:
Sandy Tao
2026-02-06 13:41:19 -08:00
committed by GitHub
parent fd72a8c40f
commit 28805a4b2d
22 changed files with 56 additions and 189 deletions
+4 -4
View File
@@ -1104,8 +1104,8 @@ describe('Server Config (config.ts)', () => {
1000,
);
// 4 * (32000 - 1000) = 4 * 31000 = 124000
// default is 4_000_000
expect(config.getTruncateToolOutputThreshold()).toBe(124000);
// default is 40_000, so min(124000, 40000) = 40000
expect(config.getTruncateToolOutputThreshold()).toBe(40_000);
});
it('should return the default threshold when the calculated value is larger', () => {
@@ -1115,8 +1115,8 @@ describe('Server Config (config.ts)', () => {
500_000,
);
// 4 * (2_000_000 - 500_000) = 4 * 1_500_000 = 6_000_000
// default is 4_000_000
expect(config.getTruncateToolOutputThreshold()).toBe(4_000_000);
// default is 40_000
expect(config.getTruncateToolOutputThreshold()).toBe(40_000);
});
it('should use a custom truncateToolOutputThreshold if provided', () => {
+1 -17
View File
@@ -303,8 +303,7 @@ export {
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
};
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 4_000_000;
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES = 1000;
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 40_000;
export class MCPServerConfig {
constructor(
@@ -442,8 +441,6 @@ export interface ConfigParameters {
extensionManagement?: boolean;
enablePromptCompletion?: boolean;
truncateToolOutputThreshold?: number;
truncateToolOutputLines?: number;
enableToolOutputTruncation?: boolean;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
@@ -586,9 +583,7 @@ export class Config {
private readonly extensionManagement: boolean = true;
private readonly enablePromptCompletion: boolean = false;
private readonly truncateToolOutputThreshold: number;
private readonly truncateToolOutputLines: number;
private compressionTruncationCounter = 0;
private readonly enableToolOutputTruncation: boolean;
private initialized: boolean = false;
readonly storage: Storage;
private readonly fileExclusions: FileExclusions;
@@ -778,9 +773,6 @@ export class Config {
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
this.truncateToolOutputLines =
params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES;
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
// // TODO(joshualitt): Re-evaluate the todo tool for 3 family.
this.useWriteTodos = isPreviewModel(this.model)
? false
@@ -2063,10 +2055,6 @@ export class Config {
return this.enablePromptCompletion;
}
getEnableToolOutputTruncation(): boolean {
return this.enableToolOutputTruncation;
}
getTruncateToolOutputThreshold(): number {
return Math.min(
// Estimate remaining context window in characters (1 token ~= 4 chars).
@@ -2076,10 +2064,6 @@ export class Config {
);
}
getTruncateToolOutputLines(): number {
return this.truncateToolOutputLines;
}
getNextCompressionTruncationId(): number {
return ++this.compressionTruncationCounter;
}
@@ -23,7 +23,6 @@ import type {
MessageBus,
} from '../index.js';
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
BaseDeclarativeTool,
BaseToolInvocation,
@@ -271,7 +270,6 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
},
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getToolRegistry: () => defaultToolRegistry,
getActiveModel: () => DEFAULT_GEMINI_MODEL,
getGeminiClient: () => null,
@@ -44,7 +44,6 @@ describe('ToolExecutor', () => {
// Default mock implementation
vi.mocked(fileUtils.saveTruncatedToolOutput).mockResolvedValue({
outputFile: '/tmp/truncated_output.txt',
totalLines: 100,
});
vi.mocked(fileUtils.formatTruncatedToolOutput).mockReturnValue(
'TruncatedContent...',
@@ -180,9 +179,7 @@ describe('ToolExecutor', () => {
it('should truncate large shell output', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getEnableToolOutputTruncation').mockReturnValue(true);
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config, 'getTruncateToolOutputLines').mockReturnValue(5);
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
@@ -227,7 +224,7 @@ describe('ToolExecutor', () => {
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
longOutput,
'/tmp/truncated_output.txt',
5, // lines
10, // threshold (maxChars)
);
expect(result.status).toBe('success');
+4 -12
View File
@@ -204,18 +204,11 @@ export class ToolExecutor {
const toolName = call.request.name;
const callId = call.request.callId;
if (
typeof content === 'string' &&
toolName === SHELL_TOOL_NAME &&
this.config.getEnableToolOutputTruncation() &&
this.config.getTruncateToolOutputThreshold() > 0 &&
this.config.getTruncateToolOutputLines() > 0
) {
const originalContentLength = content.length;
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
const threshold = this.config.getTruncateToolOutputThreshold();
const lines = this.config.getTruncateToolOutputLines();
if (content.length > threshold) {
if (threshold > 0 && content.length > threshold) {
const originalContentLength = content.length;
const { outputFile: savedPath } = await saveTruncatedToolOutput(
content,
toolName,
@@ -224,7 +217,7 @@ export class ToolExecutor {
this.config.getSessionId(),
);
outputFile = savedPath;
content = formatTruncatedToolOutput(content, outputFile, lines);
content = formatTruncatedToolOutput(content, outputFile, threshold);
logToolOutputTruncated(
this.config,
@@ -233,7 +226,6 @@ export class ToolExecutor {
originalContentLength,
truncatedContentLength: content.length,
threshold,
lines,
}),
);
}
@@ -183,6 +183,7 @@ describe('ChatCompressionService', () => {
getMessageBus: vi.fn().mockReturnValue(undefined),
getHookSystem: () => undefined,
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
storage: {
getProjectTempDir: vi.fn().mockReturnValue(testTempDir),
},
@@ -581,10 +582,10 @@ describe('ChatCompressionService', () => {
const truncatedPart = shellResponse!.parts![0].functionResponse;
const content = truncatedPart?.response?.['output'] as string;
// DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 40000 -> head=8000 (20%), tail=32000 (80%)
expect(content).toContain(
'Output too large. Showing the last 4,000 characters of the output.',
'Showing first 8,000 and last 32,000 characters',
);
// It's a single line, so NO [LINE WIDTH TRUNCATED]
});
it('should use character-based truncation for massive single-line raw strings', async () => {
@@ -645,8 +646,9 @@ describe('ChatCompressionService', () => {
const truncatedPart = rawResponse!.parts![0].functionResponse;
const content = truncatedPart?.response?.['output'] as string;
// DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 40000 -> head=8000 (20%), tail=32000 (80%)
expect(content).toContain(
'Output too large. Showing the last 4,000 characters of the output.',
'Showing first 8,000 and last 32,000 characters',
);
});
@@ -49,11 +49,6 @@ export const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
*/
export const COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET = 50_000;
/**
* The number of lines to keep when truncating a function response during compression.
*/
export const COMPRESSION_TRUNCATE_LINES = 30;
/**
* Returns the index of the oldest item to keep when compressing. May return
* contents.length which indicates that everything should be compressed.
@@ -189,11 +184,10 @@ async function truncateHistoryToBudget(
config.storage.getProjectTempDir(),
);
// Prepare a honest, readable snippet of the tail.
const truncatedMessage = formatTruncatedToolOutput(
contentStr,
outputFile,
COMPRESSION_TRUNCATE_LINES,
config.getTruncateToolOutputThreshold(),
);
newParts.unshift({
@@ -1213,10 +1213,6 @@ export class ClearcutLogger {
EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_THRESHOLD,
value: JSON.stringify(event.threshold),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_OUTPUT_TRUNCATED_LINES,
value: JSON.stringify(event.lines),
},
];
const logEvent = this.createLogEvent(
@@ -1663,7 +1663,6 @@ describe('loggers', () => {
originalContentLength: 1000,
truncatedContentLength: 100,
threshold: 500,
lines: 10,
});
logToolOutputTruncated(mockConfig, event);
@@ -1683,7 +1682,6 @@ describe('loggers', () => {
original_content_length: 1000,
truncated_content_length: 100,
threshold: 500,
lines: 10,
},
});
});
-4
View File
@@ -1334,7 +1334,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent {
original_content_length: number;
truncated_content_length: number;
threshold: number;
lines: number;
prompt_id: string;
constructor(
@@ -1344,7 +1343,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent {
originalContentLength: number;
truncatedContentLength: number;
threshold: number;
lines: number;
},
) {
this['event.name'] = this.eventName;
@@ -1353,7 +1351,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent {
this.original_content_length = details.originalContentLength;
this.truncated_content_length = details.truncatedContentLength;
this.threshold = details.threshold;
this.lines = details.lines;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
@@ -1366,7 +1363,6 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent {
original_content_length: this.original_content_length,
truncated_content_length: this.truncated_content_length,
threshold: this.threshold,
lines: this.lines,
prompt_id: this.prompt_id,
};
}
+13 -25
View File
@@ -1125,7 +1125,6 @@ describe('fileUtils', () => {
'shell_123.txt',
);
expect(result.outputFile).toBe(expectedOutputFile);
expect(result.totalLines).toBe(1);
const savedContent = await fsPromises.readFile(
expectedOutputFile,
@@ -1200,43 +1199,32 @@ describe('fileUtils', () => {
expect(result.outputFile).toBe(expectedOutputFile);
});
it('should format multi-line output correctly', () => {
const lines = Array.from({ length: 50 }, (_, i) => `line ${i}`);
const content = lines.join('\n');
it('should truncate showing first 20% and last 80%', () => {
const content = 'abcdefghijklmnopqrstuvwxyz'; // 26 chars
const outputFile = '/tmp/out.txt';
// maxChars=10 -> head=2 (20%), tail=8 (80%)
const formatted = formatTruncatedToolOutput(content, outputFile, 10);
expect(formatted).toContain(
'Output too large. Showing the last 10 of 50 lines.',
);
expect(formatted).toContain('Showing first 2 and last 8 characters');
expect(formatted).toContain('For full output see: /tmp/out.txt');
expect(formatted).toContain('line 49');
expect(formatted).not.toContain('line 0');
expect(formatted).toContain('ab'); // first 2 chars
expect(formatted).toContain('stuvwxyz'); // last 8 chars
expect(formatted).toContain('[16 characters omitted]'); // 26 - 2 - 8 = 16
});
it('should truncate "elephant lines" (long single line in multi-line output)', () => {
const longLine = 'a'.repeat(2000);
const content = `line 1\n${longLine}\nline 3`;
const outputFile = '/tmp/out.txt';
const formatted = formatTruncatedToolOutput(content, outputFile, 3);
expect(formatted).toContain('(some long lines truncated)');
expect(formatted).toContain('... [LINE WIDTH TRUNCATED]');
expect(formatted.length).toBeLessThan(longLine.length);
});
it('should handle massive single-line string with character-based truncation', () => {
it('should format large content with head/tail truncation', () => {
const content = 'a'.repeat(50000);
const outputFile = '/tmp/out.txt';
const formatted = formatTruncatedToolOutput(content, outputFile);
// maxChars=4000 -> head=800 (20%), tail=3200 (80%)
const formatted = formatTruncatedToolOutput(content, outputFile, 4000);
expect(formatted).toContain(
'Output too large. Showing the last 4,000 characters',
'Showing first 800 and last 3,200 characters',
);
expect(formatted.endsWith(content.slice(-4000))).toBe(true);
expect(formatted).toContain('For full output see: /tmp/out.txt');
expect(formatted).toContain('[46,000 characters omitted]'); // 50000 - 800 - 3200
});
});
});
+18 -39
View File
@@ -569,9 +569,6 @@ export async function fileExists(filePath: string): Promise<boolean> {
}
}
const MAX_TRUNCATED_LINE_WIDTH = 1000;
const MAX_TRUNCATED_CHARS = 4000;
/**
* Sanitizes a string for use as a filename part by removing path traversal
* characters and other non-alphanumeric characters.
@@ -581,43 +578,29 @@ export function sanitizeFilenamePart(part: string): string {
}
/**
* Formats a truncated message for tool output, handling multi-line and single-line (elephant) cases.
* Formats a truncated message for tool output.
* Shows the first 20% and last 80% of the allowed characters with a marker in between.
*/
export function formatTruncatedToolOutput(
contentStr: string,
outputFile: string,
truncateLines: number = 30,
maxChars: number,
): string {
const physicalLines = contentStr.split('\n');
const totalPhysicalLines = physicalLines.length;
if (contentStr.length <= maxChars) return contentStr;
if (totalPhysicalLines > 1) {
// Multi-line case: show last N lines, but protect against "elephant" lines.
const lastLines = physicalLines.slice(-truncateLines);
let someLinesTruncatedInWidth = false;
const processedLines = lastLines.map((line) => {
if (line.length > MAX_TRUNCATED_LINE_WIDTH) {
someLinesTruncatedInWidth = true;
return (
line.substring(0, MAX_TRUNCATED_LINE_WIDTH) +
'... [LINE WIDTH TRUNCATED]'
);
}
return line;
});
const headChars = Math.floor(maxChars * 0.2);
const tailChars = maxChars - headChars;
const widthWarning = someLinesTruncatedInWidth
? ' (some long lines truncated)'
: '';
return `Output too large. Showing the last ${processedLines.length} of ${totalPhysicalLines} lines${widthWarning}. For full output see: ${outputFile}
...
${processedLines.join('\n')}`;
} else {
// Single massive line case: use character-based truncation description.
const snippet = contentStr.slice(-MAX_TRUNCATED_CHARS);
return `Output too large. Showing the last ${MAX_TRUNCATED_CHARS.toLocaleString()} characters of the output. For full output see: ${outputFile}
...${snippet}`;
}
const head = contentStr.slice(0, headChars);
const tail = contentStr.slice(-tailChars);
const omittedChars = contentStr.length - headChars - tailChars;
return `Output too large. Showing first ${headChars.toLocaleString()} and last ${tailChars.toLocaleString()} characters. For full output see: ${outputFile}
${head}
... [${omittedChars.toLocaleString()} characters omitted] ...
${tail}`;
}
/**
@@ -631,7 +614,7 @@ export async function saveTruncatedToolOutput(
id: string | number, // Accept string (callId) or number (truncationId)
projectTempDir: string,
sessionId?: string,
): Promise<{ outputFile: string; totalLines: number }> {
): Promise<{ outputFile: string }> {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeId = sanitizeFilenamePart(id.toString()).toLowerCase();
const fileName = `${safeToolName}_${safeId}.txt`;
@@ -646,9 +629,5 @@ export async function saveTruncatedToolOutput(
await fsPromises.mkdir(toolOutputDir, { recursive: true });
await fsPromises.writeFile(outputFile, content);
const lines = content.split('\n');
return {
outputFile,
totalLines: lines.length,
};
return { outputFile };
}