Merge pull request #2388 from QwenLM/fix/remove-enableToolOutputTruncation-setting

fix(core): improve shell tool truncation, simplify tool output handling, and remove summarization
This commit is contained in:
tanzhenxin 2026-03-16 09:51:37 +08:00 committed by GitHub
commit 58bee3dec9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 520 additions and 824 deletions

View file

@ -139,16 +139,16 @@ Logs are timestamped records of specific events. The following events are logged
- `qwen-code.config`: This event occurs once at startup with the CLI's configuration.
- **Attributes**:
- `model` (string)
- `embedding_model` (string)
- `sandbox_enabled` (boolean)
- `core_tools_enabled` (string)
- `approval_mode` (string)
- `api_key_enabled` (boolean)
- `vertex_ai_enabled` (boolean)
- `code_assist_enabled` (boolean)
- `log_prompts_enabled` (boolean)
- `file_filtering_respect_git_ignore` (boolean)
- `debug_mode` (boolean)
- `truncate_tool_output_threshold` (number)
- `truncate_tool_output_lines` (number)
- `hooks` (string, comma-separated hook event types, omitted if hooks disabled)
- `ide_enabled` (boolean)
- `interactive_shell_enabled` (boolean)
- `mcp_servers` (string)
- `output_format` (string: "text" or "json")

View file

@ -129,7 +129,6 @@ Settings are organized into categories. All settings should be placed within the
| -------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
@ -221,7 +220,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | |
| `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | |
| `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | |
| `tools.enableToolOutputTruncation` | boolean | Enable truncation of large tool outputs. | `true` | Requires restart: Yes |
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
@ -350,11 +348,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o
"maxSessionTurns": 10,
"enableOpenAILogging": false,
"openAILoggingDir": "~/qwen-logs",
"summarizeToolOutput": {
"run_shell_command": {
"tokenBudget": 100
}
}
},
"context": {
"fileName": ["CONTEXT.md", "QWEN.md"],

View file

@ -43,7 +43,6 @@
"maxSessionTurns": 50,
"preferredEditor": "vscode",
"sandbox": false,
"summarizeToolOutput": true,
"telemetry": {
"enabled": false
},

View file

@ -1013,7 +1013,6 @@ export async function loadCliConfig(
warnings: resolvedCliConfig.warnings,
cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,
folderTrust,
@ -1027,7 +1026,6 @@ export async function loadCliConfig(
skipStartupContext: settings.model?.skipStartupContext ?? false,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
gitCoAuthor: settings.general?.gitCoAuthor,
output: {

View file

@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
summarizeToolOutput: 'model.summarizeToolOutput',
telemetry: 'telemetry',
theme: 'ui.theme',
toolDiscoveryCommand: 'tools.discoveryCommand',
@ -157,7 +156,6 @@ export const V1_INDICATOR_KEYS = [
'shellPager',
'shellShowColor',
'skipNextSpeakerCheck',
'summarizeToolOutput',
'toolDiscoveryCommand',
'toolCallCommand',
'usageStatisticsEnabled',

View file

@ -103,10 +103,6 @@ export interface CheckpointingSettings {
enabled?: boolean;
}
export interface SummarizeToolOutputSettings {
tokenBudget?: number;
}
export interface AccessibilitySettings {
enableLoadingPhrases?: boolean;
screenReader?: boolean;

View file

@ -632,17 +632,6 @@ const SETTINGS_SCHEMA = {
'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.',
showInDialog: false,
},
summarizeToolOutput: {
type: 'object',
label: 'Summarize Tool Output',
category: 'Model',
requiresRestart: false,
default: undefined as
| Record<string, { tokenBudget?: number }>
| undefined,
description: 'Settings for summarizing tool output.',
showInDialog: false,
},
chatCompression: {
type: 'object',
label: 'Chat Compression',
@ -1027,15 +1016,6 @@ const SETTINGS_SCHEMA = {
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
showInDialog: false,
},
enableToolOutputTruncation: {
type: 'boolean',
label: 'Enable Tool Output Truncation',
category: 'General',
requiresRestart: true,
default: true,
description: 'Enable truncation of large tool outputs.',
showInDialog: false,
},
truncateToolOutputThreshold: {
type: 'number',
label: 'Tool Output Truncation Threshold',

View file

@ -6,7 +6,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
contentWidth={innerWidth}
/>
)}
{tool.outputFile && (
<Box marginX={1}>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
);
})}

View file

@ -252,7 +252,6 @@ export function mapToDisplay(
status: mapCoreStatusToDisplayStatus(trackedCall.status),
resultDisplay: trackedCall.response.resultDisplay,
confirmationDetails: undefined,
outputFile: trackedCall.response.outputFile,
};
case 'error':
return {

View file

@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay {
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
}
export interface CompressionProps {

View file

@ -1047,10 +1047,10 @@ describe('Server Config (config.ts)', () => {
expect(config.getTruncateToolOutputThreshold()).toBe(50000);
});
it('should return infinity when truncation is disabled', () => {
it('should return infinity when threshold is zero or negative', () => {
const customParams = {
...baseParams,
enableToolOutputTruncation: false,
truncateToolOutputThreshold: 0,
};
const config = new Config(customParams);
expect(config.getTruncateToolOutputThreshold()).toBe(

View file

@ -195,10 +195,6 @@ export interface ChatCompressionSettings {
contextPercentageThreshold?: number;
}
export interface SummarizeToolOutputSettings {
tokenBudget?: number;
}
export interface TelemetrySettings {
enabled?: boolean;
target?: TelemetryTarget;
@ -339,7 +335,6 @@ export interface ConfigParameters {
allowedMcpServers?: string[];
excludedMcpServers?: string[];
noBrowser?: boolean;
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
folderTrustFeature?: boolean;
folderTrust?: boolean;
ideMode?: boolean;
@ -375,7 +370,6 @@ export interface ConfigParameters {
skipLoopDetection?: boolean;
truncateToolOutputThreshold?: number;
truncateToolOutputLines?: number;
enableToolOutputTruncation?: boolean;
eventEmitter?: EventEmitter;
output?: OutputSettings;
inputFormat?: InputFormat;
@ -498,9 +492,6 @@ export class Config {
private readonly listExtensions: boolean;
private readonly overrideExtensions?: string[];
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
| undefined;
private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false;
private readonly chatRecordingEnabled: boolean;
@ -530,7 +521,6 @@ export class Config {
private readonly fileExclusions: FileExclusions;
private readonly truncateToolOutputThreshold: number;
private readonly truncateToolOutputLines: number;
private readonly enableToolOutputTruncation: boolean;
private readonly eventEmitter?: EventEmitter;
private readonly channel: string | undefined;
private readonly defaultFileEncoding: FileEncodingType;
@ -614,7 +604,6 @@ export class Config {
this.listExtensions = params.listExtensions ?? false;
this.overrideExtensions = params.overrideExtensions;
this.noBrowser = params.noBrowser ?? false;
this.summarizeToolOutput = params.summarizeToolOutput;
this.folderTrustFeature = params.folderTrustFeature ?? false;
this.folderTrust = params.folderTrust ?? false;
this.ideMode = params.ideMode ?? false;
@ -651,7 +640,6 @@ export class Config {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
this.truncateToolOutputLines =
params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES;
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
this.channel = params.channel;
this.defaultFileEncoding = params.defaultFileEncoding ?? FileEncoding.UTF8;
this.storage = new Storage(this.targetDir);
@ -1599,12 +1587,6 @@ export class Config {
return this.getNoBrowser() || !shouldAttemptBrowserLaunch();
}
getSummarizeToolOutputConfig():
| Record<string, SummarizeToolOutputSettings>
| undefined {
return this.summarizeToolOutput;
}
// Web search provider configuration
getWebSearchConfig() {
return this.webSearch;
@ -1733,15 +1715,8 @@ export class Config {
return this.skipStartupContext;
}
getEnableToolOutputTruncation(): boolean {
return this.enableToolOutputTruncation;
}
getTruncateToolOutputThreshold(): number {
if (
!this.enableToolOutputTruncation ||
this.truncateToolOutputThreshold <= 0
) {
if (this.truncateToolOutputThreshold <= 0) {
return Number.POSITIVE_INFINITY;
}
@ -1749,7 +1724,7 @@ export class Config {
}
getTruncateToolOutputLines(): number {
if (!this.enableToolOutputTruncation || this.truncateToolOutputLines <= 0) {
if (this.truncateToolOutputLines <= 0) {
return Number.POSITIVE_INFINITY;
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { Mock } from 'vitest';
import type {
Config,
@ -29,7 +29,6 @@ import type { ToolCall, WaitingToolCall } from './coreToolScheduler.js';
import {
CoreToolScheduler,
convertToFunctionResponse,
truncateAndSaveToFile,
} from './coreToolScheduler.js';
import type { Part, PartListUnion } from '@google/genai';
import {
@ -37,13 +36,6 @@ import {
MockTool,
MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
} from '../test-utils/mock-tool.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
}));
class TestApprovalTool extends BaseDeclarativeTool<{ id: string }, ToolResult> {
static readonly Name = 'testApprovalTool';
@ -2290,227 +2282,6 @@ describe('CoreToolScheduler Sequential Execution', () => {
});
});
describe('truncateAndSaveToFile', () => {
const mockWriteFile = vi.mocked(fs.writeFile);
const THRESHOLD = 40_000;
const TRUNCATE_LINES = 1000;
beforeEach(() => {
vi.clearAllMocks();
});
it('should return content unchanged if below threshold', async () => {
const content = 'Short content';
const callId = 'test-call-id';
const projectTempDir = '/tmp';
const result = await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result).toEqual({ content });
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('should truncate content by lines when content has many lines', async () => {
// Create content that exceeds 100,000 character threshold with many lines
const lines = Array(2000).fill('x'.repeat(100)); // 100 chars per line * 2000 lines = 200,000 chars
const content = lines.join('\n');
const callId = 'test-call-id';
const projectTempDir = '/tmp';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBe(
path.join(projectTempDir, `${callId}.output`),
);
expect(mockWriteFile).toHaveBeenCalledWith(
path.join(projectTempDir, `${callId}.output`),
content,
);
// Should contain the first and last lines with 1/5 head and 4/5 tail
const head = Math.floor(TRUNCATE_LINES / 5);
const beginning = lines.slice(0, head);
const end = lines.slice(-(TRUNCATE_LINES - head));
const expectedTruncated =
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain('Truncated part of the output:');
expect(result.content).toContain(expectedTruncated);
});
it('should wrap and truncate content when content has few but long lines', async () => {
const content = 'a'.repeat(200_000); // A single very long line
const callId = 'test-call-id';
const projectTempDir = '/tmp';
const wrapWidth = 120;
mockWriteFile.mockResolvedValue(undefined);
// Manually wrap the content to generate the expected file content
const wrappedLines: string[] = [];
for (let i = 0; i < content.length; i += wrapWidth) {
wrappedLines.push(content.substring(i, i + wrapWidth));
}
const expectedFileContent = wrappedLines.join('\n');
const result = await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBe(
path.join(projectTempDir, `${callId}.output`),
);
// Check that the file was written with the wrapped content
expect(mockWriteFile).toHaveBeenCalledWith(
path.join(projectTempDir, `${callId}.output`),
expectedFileContent,
);
// Should contain the first and last lines with 1/5 head and 4/5 tail of the wrapped content
const head = Math.floor(TRUNCATE_LINES / 5);
const beginning = wrappedLines.slice(0, head);
const end = wrappedLines.slice(-(TRUNCATE_LINES - head));
const expectedTruncated =
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain('Truncated part of the output:');
expect(result.content).toContain(expectedTruncated);
});
it('should handle file write errors gracefully', async () => {
const content = 'a'.repeat(2_000_000);
const callId = 'test-call-id';
const projectTempDir = '/tmp';
mockWriteFile.mockRejectedValue(new Error('File write failed'));
const result = await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBeUndefined();
expect(result.content).toContain(
'[Note: Could not save full output to file]',
);
expect(mockWriteFile).toHaveBeenCalled();
});
it('should save to correct file path with call ID', async () => {
const content = 'a'.repeat(200_000);
const callId = 'unique-call-123';
const projectTempDir = '/custom/temp/dir';
const wrapWidth = 120;
mockWriteFile.mockResolvedValue(undefined);
// Manually wrap the content to generate the expected file content
const wrappedLines: string[] = [];
for (let i = 0; i < content.length; i += wrapWidth) {
wrappedLines.push(content.substring(i, i + wrapWidth));
}
const expectedFileContent = wrappedLines.join('\n');
const result = await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
const expectedPath = path.join(projectTempDir, `${callId}.output`);
expect(result.outputFile).toBe(expectedPath);
expect(mockWriteFile).toHaveBeenCalledWith(
expectedPath,
expectedFileContent,
);
});
it('should include helpful instructions in truncated message', async () => {
const content = 'a'.repeat(2_000_000);
const callId = 'test-call-id';
const projectTempDir = '/tmp';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain('The full output has been saved to:');
expect(result.content).toContain(
'To read the complete output, use the read_file tool with the absolute file path above',
);
expect(result.content).toContain(
'The truncated output below shows the beginning and end of the content',
);
});
it('should sanitize callId to prevent path traversal', async () => {
const content = 'a'.repeat(200_000);
const callId = '../../../../../etc/passwd';
const projectTempDir = '/tmp/safe_dir';
const wrapWidth = 120;
mockWriteFile.mockResolvedValue(undefined);
// Manually wrap the content to generate the expected file content
const wrappedLines: string[] = [];
for (let i = 0; i < content.length; i += wrapWidth) {
wrappedLines.push(content.substring(i, i + wrapWidth));
}
const expectedFileContent = wrappedLines.join('\n');
await truncateAndSaveToFile(
content,
callId,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
const expectedPath = path.join(projectTempDir, 'passwd.output');
expect(mockWriteFile).toHaveBeenCalledWith(
expectedPath,
expectedFileContent,
);
});
});
describe('CoreToolScheduler plan mode with ask_user_question', () => {
function createAskUserQuestionMockTool() {
let wasAnswered = false;

View file

@ -25,12 +25,8 @@ import {
ToolConfirmationOutcome,
ApprovalMode,
logToolCall,
ReadFileTool,
ToolErrorType,
ToolCallEvent,
ShellTool,
logToolOutputTruncated,
ToolOutputTruncatedEvent,
InputFormat,
Kind,
SkillTool,
@ -49,8 +45,6 @@ import {
modifyWithEditor,
} from '../tools/modifiable-tool.js';
import * as Diff from 'diff';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { doesToolInvocationMatch } from '../utils/tool-utils.js';
import levenshtein from 'fast-levenshtein';
import { getPlanModeSystemReminder } from './prompts.js';
@ -306,67 +300,6 @@ const createErrorResponse = (
contentLength: error.message.length,
});
export async function truncateAndSaveToFile(
content: string,
callId: string,
projectTempDir: string,
threshold: number,
truncateLines: number,
): Promise<{ content: string; outputFile?: string }> {
if (content.length <= threshold) {
return { content };
}
let lines = content.split('\n');
let fileContent = content;
// If the content is long but has few lines, wrap it to enable line-based truncation.
if (lines.length <= truncateLines) {
const wrapWidth = 120; // A reasonable width for wrapping.
const wrappedLines: string[] = [];
for (const line of lines) {
if (line.length > wrapWidth) {
for (let i = 0; i < line.length; i += wrapWidth) {
wrappedLines.push(line.substring(i, i + wrapWidth));
}
} else {
wrappedLines.push(line);
}
}
lines = wrappedLines;
fileContent = lines.join('\n');
}
const head = Math.floor(truncateLines / 5);
const beginning = lines.slice(0, head);
const end = lines.slice(-(truncateLines - head));
const truncatedContent =
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
// Sanitize callId to prevent path traversal.
const safeFileName = `${path.basename(callId)}.output`;
const outputFile = path.join(projectTempDir, safeFileName);
try {
await fs.writeFile(outputFile, fileContent);
return {
content: `Tool output was too large and has been truncated.
The full output has been saved to: ${outputFile}
To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above.
The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed.
This allows you to efficiently examine different parts of the output without loading the entire file.
Truncated part of the output:
${truncatedContent}`,
outputFile,
};
} catch (_error) {
return {
content:
truncatedContent + `\n[Note: Could not save full output to file]`,
};
}
}
interface CoreToolSchedulerOptions {
config: Config;
outputUpdateHandler?: OutputUpdateHandler;
@ -1221,43 +1154,9 @@ export class CoreToolScheduler {
}
if (toolResult.error === undefined) {
let content = toolResult.llmContent;
let outputFile: string | undefined = undefined;
const content = toolResult.llmContent;
const contentLength =
typeof content === 'string' ? content.length : undefined;
if (
typeof content === 'string' &&
toolName === ShellTool.Name &&
this.config.getEnableToolOutputTruncation() &&
this.config.getTruncateToolOutputThreshold() > 0 &&
this.config.getTruncateToolOutputLines() > 0
) {
const originalContentLength = content.length;
const threshold = this.config.getTruncateToolOutputThreshold();
const lines = this.config.getTruncateToolOutputLines();
const truncatedResult = await truncateAndSaveToFile(
content,
callId,
this.config.storage.getProjectTempDir(),
threshold,
lines,
);
content = truncatedResult.content;
outputFile = truncatedResult.outputFile;
if (outputFile) {
logToolOutputTruncated(
this.config,
new ToolOutputTruncatedEvent(scheduledCall.request.prompt_id, {
toolName,
originalContentLength,
truncatedContentLength: content.length,
threshold,
lines,
}),
);
}
}
const response = convertToFunctionResponse(toolName, callId, content);
const successResponse: ToolCallResponseInfo = {
@ -1266,7 +1165,6 @@ export class CoreToolScheduler {
resultDisplay: toolResult.returnDisplay,
error: undefined,
errorType: undefined,
outputFile,
contentLength,
};
this.setStatusInternal(callId, 'success', successResponse);

View file

@ -94,7 +94,6 @@ describe('executeToolCall', () => {
callId: 'call1',
error: undefined,
errorType: undefined,
outputFile: undefined,
resultDisplay: 'Success!',
contentLength:
typeof toolResult.llmContent === 'string'
@ -299,7 +298,6 @@ describe('executeToolCall', () => {
callId: 'call6',
error: undefined,
errorType: undefined,
outputFile: undefined,
resultDisplay: 'Image processed',
contentLength: undefined,
responseParts: [

View file

@ -109,7 +109,6 @@ export interface ToolCallResponseInfo {
resultDisplay: ToolResultDisplay | undefined;
error: Error | undefined;
errorType: ToolErrorType | undefined;
outputFile?: string | undefined;
contentLength?: number;
}

View file

@ -148,15 +148,11 @@ describe('loggers', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
getModel: () => 'test-model',
getEmbeddingModel: () => 'test-embedding-model',
getSandbox: () => true,
getCoreTools: () => ['ls', 'read-file'],
getApprovalMode: () => 'default',
getContentGeneratorConfig: () => ({
model: 'test-model',
apiKey: 'test-api-key',
authType: AuthType.USE_VERTEX_AI,
}),
getTruncateToolOutputThreshold: () => 25000,
getTruncateToolOutputLines: () => 1000,
getTelemetryEnabled: () => true,
getUsageStatisticsEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
@ -174,6 +170,9 @@ describe('loggers', () => {
getOutputFormat: () => OutputFormat.JSON,
getToolRegistry: () => undefined,
getChatRecordingService: () => undefined,
getHookSystem: () => undefined,
getIdeMode: () => false,
getShouldUseNodePtyShell: () => true,
} as unknown as Config;
const startSessionEvent = new StartSessionEvent(mockConfig);
@ -186,19 +185,20 @@ describe('loggers', () => {
'event.name': EVENT_CLI_CONFIG,
'event.timestamp': '2025-01-01T00:00:00.000Z',
model: 'test-model',
embedding_model: 'test-embedding-model',
sandbox_enabled: true,
core_tools_enabled: 'ls,read-file',
approval_mode: 'default',
api_key_enabled: true,
vertex_ai_enabled: true,
log_user_prompts_enabled: true,
truncate_tool_output_threshold: 25000,
truncate_tool_output_lines: 1000,
file_filtering_respect_git_ignore: true,
debug_mode: true,
mcp_servers: 'test-server',
mcp_servers_count: 1,
mcp_tools: undefined,
mcp_tools_count: undefined,
hooks: undefined,
ide_enabled: false,
interactive_shell_enabled: true,
output_format: 'json',
skills: undefined,
subagents: undefined,

View file

@ -117,19 +117,20 @@ export function logStartSession(
'event.name': EVENT_CLI_CONFIG,
'event.timestamp': new Date().toISOString(),
model: event.model,
embedding_model: event.embedding_model,
sandbox_enabled: event.sandbox_enabled,
core_tools_enabled: event.core_tools_enabled,
approval_mode: event.approval_mode,
api_key_enabled: event.api_key_enabled,
vertex_ai_enabled: event.vertex_ai_enabled,
log_user_prompts_enabled: event.telemetry_log_user_prompts_enabled,
file_filtering_respect_git_ignore: event.file_filtering_respect_git_ignore,
debug_mode: event.debug_enabled,
truncate_tool_output_threshold: event.truncate_tool_output_threshold,
truncate_tool_output_lines: event.truncate_tool_output_lines,
mcp_servers: event.mcp_servers,
mcp_servers_count: event.mcp_servers_count,
mcp_tools: event.mcp_tools,
mcp_tools_count: event.mcp_tools_count,
hooks: event.hooks,
ide_enabled: event.ide_enabled,
interactive_shell_enabled: event.interactive_shell_enabled,
output_format: event.output_format,
skills: event.skills,
subagents: event.subagents,

View file

@ -81,6 +81,11 @@ const makeFakeConfig = (overrides: Partial<Config> = {}): Config => {
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getToolRegistry: () => undefined,
getTruncateToolOutputThreshold: () => 25000,
getTruncateToolOutputLines: () => 0,
getIdeMode: () => false,
getShouldUseNodePtyShell: () => false,
getHookSystem: () => undefined,
...overrides,
};
return defaults as Config;

View file

@ -416,20 +416,20 @@ export class QwenLogger {
const applicationEvent = this.createViewEvent('session', 'session_start', {
properties: {
model: event.model,
approval_mode: event.approval_mode,
embedding_model: event.embedding_model,
sandbox_enabled: event.sandbox_enabled,
core_tools_enabled: event.core_tools_enabled,
api_key_enabled: event.api_key_enabled,
vertex_ai_enabled: event.vertex_ai_enabled,
debug_enabled: event.debug_enabled,
hooks: event.hooks,
ide_enabled: event.ide_enabled,
interactive_shell_enabled: event.interactive_shell_enabled,
mcp_servers: event.mcp_servers,
telemetry_enabled: event.telemetry_enabled,
telemetry_log_user_prompts_enabled:
event.telemetry_log_user_prompts_enabled,
model: event.model,
sandbox_enabled: event.sandbox_enabled,
skills: event.skills,
subagents: event.subagents,
telemetry_enabled: event.telemetry_enabled,
truncate_tool_output_lines: event.truncate_tool_output_lines,
truncate_tool_output_threshold: event.truncate_tool_output_threshold,
},
});

View file

@ -10,7 +10,7 @@ import type { ApprovalMode } from '../config/config.js';
import type { CompletedToolCall } from '../core/coreToolScheduler.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import type { FileDiff } from '../tools/tools.js';
import { AuthType } from '../core/contentGenerator.js';
import type { AuthType } from '../core/contentGenerator.js';
import {
getDecisionFromOutcome,
ToolCallDecision,
@ -35,55 +35,60 @@ export class StartSessionEvent implements BaseTelemetryEvent {
'event.timestamp': string;
session_id: string;
model: string;
embedding_model: string;
sandbox_enabled: boolean;
core_tools_enabled: string;
core_tools_enabled?: string;
approval_mode: string;
api_key_enabled: boolean;
vertex_ai_enabled: boolean;
debug_enabled: boolean;
truncate_tool_output_threshold: number;
truncate_tool_output_lines: number;
mcp_servers: string;
telemetry_enabled: boolean;
telemetry_log_user_prompts_enabled: boolean;
file_filtering_respect_git_ignore: boolean;
mcp_servers_count: number;
mcp_tools_count?: number;
mcp_tools?: string;
output_format: OutputFormat;
hooks?: string;
ide_enabled: boolean;
interactive_shell_enabled: boolean;
skills?: string;
subagents?: string;
constructor(config: Config) {
const generatorConfig = config.getContentGeneratorConfig();
const mcpServers = config.getMcpServers();
const toolRegistry = config.getToolRegistry();
let useGemini = false;
let useVertex = false;
if (generatorConfig && generatorConfig.authType) {
useGemini = generatorConfig.authType === AuthType.USE_GEMINI;
useVertex = generatorConfig.authType === AuthType.USE_VERTEX_AI;
}
this['event.name'] = 'cli_config';
this.session_id = config.getSessionId();
this.model = config.getModel();
this.embedding_model = config.getEmbeddingModel();
this.sandbox_enabled =
typeof config.getSandbox() === 'string' || !!config.getSandbox();
this.core_tools_enabled = (config.getCoreTools() ?? []).join(',');
const coreTools = (config.getCoreTools() ?? []).join(',');
if (coreTools) {
this.core_tools_enabled = coreTools;
}
this.approval_mode = config.getApprovalMode();
this.api_key_enabled = useGemini || useVertex;
this.vertex_ai_enabled = useVertex;
this.debug_enabled = config.getDebugMode();
this.truncate_tool_output_threshold =
config.getTruncateToolOutputThreshold();
this.truncate_tool_output_lines = config.getTruncateToolOutputLines();
this.mcp_servers = mcpServers ? Object.keys(mcpServers).join(',') : '';
this.telemetry_enabled = config.getTelemetryEnabled();
this.telemetry_log_user_prompts_enabled =
config.getTelemetryLogPromptsEnabled();
this.file_filtering_respect_git_ignore =
config.getFileFilteringRespectGitIgnore();
this.mcp_servers_count = mcpServers ? Object.keys(mcpServers).length : 0;
this.output_format = config.getOutputFormat();
this.ide_enabled = config.getIdeMode();
this.interactive_shell_enabled = config.getShouldUseNodePtyShell();
const hookSystem = config.getHookSystem();
if (hookSystem) {
const allHooks = hookSystem.getAllHooks();
const uniqueEventNames = [...new Set(allHooks.map((h) => h.eventName))];
if (uniqueEventNames.length > 0) {
this.hooks = uniqueEventNames.join(',');
}
}
if (toolRegistry) {
const mcpTools = toolRegistry

View file

@ -21,7 +21,6 @@ vi.mock('../services/shellExecutionService.js', () => ({
vi.mock('fs');
vi.mock('os');
vi.mock('crypto');
vi.mock('../utils/summarizer.js');
import { isCommandAllowed } from '../utils/shell-utils.js';
import { ShellTool } from './shell.js';
@ -35,7 +34,6 @@ import * as os from 'node:os';
import { EOL } from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import * as summarizer from '../utils/summarizer.js';
import { ToolErrorType } from './tool-error.js';
import { ToolConfirmationOutcome } from './tools.js';
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
@ -55,13 +53,15 @@ describe('ShellTool', () => {
getExcludeTools: vi.fn().mockReturnValue([]),
getDebugMode: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
getWorkspaceContext: vi
.fn()
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
storage: {
getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'),
getProjectTempDir: vi.fn().mockReturnValue('/tmp/qwen-temp'),
},
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(0),
getTruncateToolOutputLines: vi.fn().mockReturnValue(0),
getGeminiClient: vi.fn(),
getGitCoAuthor: vi.fn().mockReturnValue({
enabled: true,
@ -476,42 +476,6 @@ describe('ShellTool', () => {
).toThrow('Directory must be an absolute path.');
});
it('should summarize output when configured', async () => {
(mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({
[shellTool.name]: { tokenBudget: 1000 },
});
vi.mocked(summarizer.summarizeToolOutput).mockResolvedValue(
'summarized output',
);
const invocation = shellTool.build({
command: 'ls',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
output: 'long output',
rawOutput: Buffer.from('long output'),
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
const result = await promise;
expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith(
expect.any(String),
mockConfig.getGeminiClient(),
expect.any(AbortSignal),
1000,
);
expect(result.llmContent).toBe('summarized output');
expect(result.returnDisplay).toBe('long output');
});
it('should clean up the temp file on synchronous execution error', async () => {
const error = new Error('sync spawn error');
mockShellExecutionService.mockImplementation(() => {

View file

@ -26,7 +26,9 @@ import {
Kind,
} from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import { truncateAndSaveToFile } from '../utils/truncation.js';
import { logToolOutputTruncated } from '../telemetry/loggers.js';
import { ToolOutputTruncatedEvent } from '../telemetry/types.js';
import type {
ShellExecutionConfig,
ShellOutputEvent,
@ -378,7 +380,43 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
const summarizeConfig = this.config.getSummarizeToolOutputConfig();
// Truncate large output and save full content to a temp file.
const truncateThreshold = this.config.getTruncateToolOutputThreshold();
const truncateLines = this.config.getTruncateToolOutputLines();
if (
typeof llmContent === 'string' &&
truncateThreshold > 0 &&
truncateLines > 0
) {
const originalContentLength = llmContent.length;
const fileName = `shell_${crypto.randomBytes(6).toString('hex')}`;
const truncatedResult = await truncateAndSaveToFile(
llmContent,
fileName,
this.config.storage.getProjectTempDir(),
truncateThreshold,
truncateLines,
);
if (truncatedResult.outputFile) {
llmContent = truncatedResult.content;
returnDisplayMessage +=
(returnDisplayMessage ? '\n' : '') +
`Output too long and was saved to: ${truncatedResult.outputFile}`;
logToolOutputTruncated(
this.config,
new ToolOutputTruncatedEvent('', {
toolName: ShellTool.Name,
originalContentLength,
truncatedContentLength: truncatedResult.content.length,
threshold: truncateThreshold,
lines: truncateLines,
}),
);
}
}
const executionError = result.error
? {
error: {
@ -388,20 +426,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
: {};
if (summarizeConfig && summarizeConfig[ShellTool.Name]) {
const summary = await summarizeToolOutput(
llmContent,
this.config.getGeminiClient(),
signal,
summarizeConfig[ShellTool.Name].tokenBudget,
);
return {
llmContent: summary,
returnDisplay: returnDisplayMessage,
...executionError,
};
}
return {
llmContent,
returnDisplay: returnDisplayMessage,

View file

@ -1,202 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeminiClient } from '../core/client.js';
import { Config } from '../config/config.js';
import {
summarizeToolOutput,
llmSummarizer,
defaultSummarizer,
} from './summarizer.js';
import type { ToolResult } from '../tools/tools.js';
// Mock GeminiClient and Config constructor
vi.mock('../core/client.js');
vi.mock('../config/config.js');
describe('summarizers', () => {
let mockGeminiClient: GeminiClient;
let MockConfig: Mock;
const abortSignal = new AbortController().signal;
beforeEach(() => {
MockConfig = vi.mocked(Config);
const mockConfigInstance = new MockConfig(
'test-api-key',
'gemini-pro',
false,
'.',
false,
undefined,
false,
undefined,
undefined,
undefined,
);
mockGeminiClient = new GeminiClient(mockConfigInstance);
(mockGeminiClient.generateContent as Mock) = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('summarizeToolOutput', () => {
it('should return original text if it is shorter than maxLength', async () => {
const shortText = 'This is a short text.';
const result = await summarizeToolOutput(
shortText,
mockGeminiClient,
abortSignal,
2000,
);
expect(result).toBe(shortText);
expect(mockGeminiClient.generateContent).not.toHaveBeenCalled();
});
it('should return original text if it is empty', async () => {
const emptyText = '';
const result = await summarizeToolOutput(
emptyText,
mockGeminiClient,
abortSignal,
2000,
);
expect(result).toBe(emptyText);
expect(mockGeminiClient.generateContent).not.toHaveBeenCalled();
});
it('should call generateContent if text is longer than maxLength', async () => {
const longText = 'This is a very long text.'.repeat(200);
const summary = 'This is a summary.';
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
candidates: [{ content: { parts: [{ text: summary }] } }],
});
const result = await summarizeToolOutput(
longText,
mockGeminiClient,
abortSignal,
2000,
);
expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result).toBe(summary);
});
it('should return original text if generateContent throws an error', async () => {
const longText = 'This is a very long text.'.repeat(200);
const error = new Error('API Error');
(mockGeminiClient.generateContent as Mock).mockRejectedValue(error);
const result = await summarizeToolOutput(
longText,
mockGeminiClient,
abortSignal,
2000,
);
expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result).toBe(longText);
});
it('should construct the correct prompt for summarization', async () => {
const longText = 'This is a very long text.'.repeat(200);
const summary = 'This is a summary.';
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
candidates: [{ content: { parts: [{ text: summary }] } }],
});
await summarizeToolOutput(longText, mockGeminiClient, abortSignal, 1000);
const expectedPrompt = `Summarize the following tool output to be a maximum of 1000 tokens. The summary should be concise and capture the main points of the tool output.
The summarization should be done based on the content that is provided. Here are the basic rules to follow:
1. If the text is a directory listing or any output that is structural, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return that as a response.
2. If the text is text content and there is nothing structural that we need, summarize the text.
3. If the text is the output of a shell command, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return a summarization along with the stack trace of any error within the <error></error> tags. The stack trace should be complete and not truncated. If there are warnings, you should include them in the summary within <warning></warning> tags.
Text to summarize:
"${longText}"
Return the summary string which should first contain an overall summarization of text followed by the full stack trace of errors and warnings in the tool output.
`;
const calledWith = (mockGeminiClient.generateContent as Mock).mock
.calls[0];
const contents = calledWith[0];
expect(contents[0].parts[0].text).toBe(expectedPrompt);
});
});
describe('llmSummarizer', () => {
it('should summarize tool output using summarizeToolOutput', async () => {
const toolResult: ToolResult = {
llmContent: 'This is a very long text.'.repeat(200),
returnDisplay: '',
};
const summary = 'This is a summary.';
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
candidates: [{ content: { parts: [{ text: summary }] } }],
});
const result = await llmSummarizer(
toolResult,
mockGeminiClient,
abortSignal,
);
expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1);
expect(result).toBe(summary);
});
it('should handle different llmContent types', async () => {
const longText = 'This is a very long text.'.repeat(200);
const toolResult: ToolResult = {
llmContent: [{ text: longText }],
returnDisplay: '',
};
const summary = 'This is a summary.';
(mockGeminiClient.generateContent as Mock).mockResolvedValue({
candidates: [{ content: { parts: [{ text: summary }] } }],
});
const result = await llmSummarizer(
toolResult,
mockGeminiClient,
abortSignal,
);
expect(mockGeminiClient.generateContent).toHaveBeenCalledTimes(1);
const calledWith = (mockGeminiClient.generateContent as Mock).mock
.calls[0];
const contents = calledWith[0];
expect(contents[0].parts[0].text).toContain(`"${longText}"`);
expect(result).toBe(summary);
});
});
describe('defaultSummarizer', () => {
it('should stringify the llmContent', async () => {
const toolResult: ToolResult = {
llmContent: { text: 'some data' },
returnDisplay: '',
};
const result = await defaultSummarizer(
toolResult,
mockGeminiClient,
abortSignal,
);
expect(result).toBe(JSON.stringify({ text: 'some data' }));
expect(mockGeminiClient.generateContent).not.toHaveBeenCalled();
});
});
});

View file

@ -1,99 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolResult } from '../tools/tools.js';
import type {
Content,
GenerateContentConfig,
GenerateContentResponse,
} from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
import { getResponseText, partToString } from './partUtils.js';
import { createDebugLogger } from './debugLogger.js';
const debugLogger = createDebugLogger('SUMMARIZER');
/**
* A function that summarizes the result of a tool execution.
*
* @param result The result of the tool execution.
* @returns The summary of the result.
*/
export type Summarizer = (
result: ToolResult,
geminiClient: GeminiClient,
abortSignal: AbortSignal,
) => Promise<string>;
/**
* The default summarizer for tool results.
*
* @param result The result of the tool execution.
* @param geminiClient The Gemini client to use for summarization.
* @param abortSignal The abort signal to use for summarization.
* @returns The summary of the result.
*/
export const defaultSummarizer: Summarizer = (
result: ToolResult,
_geminiClient: GeminiClient,
_abortSignal: AbortSignal,
) => Promise.resolve(JSON.stringify(result.llmContent));
const SUMMARIZE_TOOL_OUTPUT_PROMPT = `Summarize the following tool output to be a maximum of {maxOutputTokens} tokens. The summary should be concise and capture the main points of the tool output.
The summarization should be done based on the content that is provided. Here are the basic rules to follow:
1. If the text is a directory listing or any output that is structural, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return that as a response.
2. If the text is text content and there is nothing structural that we need, summarize the text.
3. If the text is the output of a shell command, use the history of the conversation to understand the context. Using this context try to understand what information we need from the tool output and return a summarization along with the stack trace of any error within the <error></error> tags. The stack trace should be complete and not truncated. If there are warnings, you should include them in the summary within <warning></warning> tags.
Text to summarize:
"{textToSummarize}"
Return the summary string which should first contain an overall summarization of text followed by the full stack trace of errors and warnings in the tool output.
`;
export const llmSummarizer: Summarizer = (result, geminiClient, abortSignal) =>
summarizeToolOutput(
partToString(result.llmContent),
geminiClient,
abortSignal,
);
export async function summarizeToolOutput(
textToSummarize: string,
geminiClient: GeminiClient,
abortSignal: AbortSignal,
maxOutputTokens: number = 2000,
): Promise<string> {
// There is going to be a slight difference here since we are comparing length of string with maxOutputTokens.
// This is meant to be a ballpark estimation of if we need to summarize the tool output.
if (!textToSummarize || textToSummarize.length < maxOutputTokens) {
return textToSummarize;
}
const prompt = SUMMARIZE_TOOL_OUTPUT_PROMPT.replace(
'{maxOutputTokens}',
String(maxOutputTokens),
).replace('{textToSummarize}', textToSummarize);
const contents: Content[] = [{ role: 'user', parts: [{ text: prompt }] }];
const toolOutputSummarizerConfig: GenerateContentConfig = {
maxOutputTokens,
};
try {
const parsedResponse = (await geminiClient.generateContent(
contents,
toolOutputSummarizerConfig,
abortSignal,
DEFAULT_QWEN_FLASH_MODEL,
)) as unknown as GenerateContentResponse;
return getResponseText(parsedResponse) || textToSummarize;
} catch (error) {
debugLogger.error('Failed to summarize tool output.', error);
return textToSummarize;
}
}

View file

@ -0,0 +1,310 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { truncateAndSaveToFile } from './truncation.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
vi.mock('node:fs/promises');
describe('truncateAndSaveToFile', () => {
const mockWriteFile = vi.mocked(fs.writeFile);
const THRESHOLD = 40_000;
const TRUNCATE_LINES = 1000;
beforeEach(() => {
vi.clearAllMocks();
});
it('should return content unchanged if below both threshold and line limit', async () => {
const content = 'Short content';
const fileName = 'test-file';
const projectTempDir = '/tmp';
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result).toEqual({ content });
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('should truncate when line limit exceeded even if under character threshold', async () => {
// 2000 short lines, well under the 40,000 char threshold
const lines = Array(2000).fill('short');
const content = lines.join('\n'); // ~12,000 chars, under THRESHOLD
const fileName = 'test-file';
const projectTempDir = '/tmp';
expect(content.length).toBeLessThan(THRESHOLD);
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBe(
path.join(projectTempDir, `${fileName}.output`),
);
const head = Math.floor(TRUNCATE_LINES / 5);
const beginning = lines.slice(0, head);
const end = lines.slice(-(TRUNCATE_LINES - head));
const expectedTruncated =
beginning.join('\n') +
'\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n' +
end.join('\n');
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain(expectedTruncated);
});
it('should reduce effective lines when line content would exceed character threshold', async () => {
// 2000 lines of 100 chars each = 200,000 chars, well over THRESHOLD (40,000)
// Even after truncating to TRUNCATE_LINES (1000), that's 100,000 chars — still over.
// The effective line count should be reduced to fit within the threshold.
const lines = Array(2000).fill('x'.repeat(100));
const content = lines.join('\n');
const fileName = 'test-file';
const projectTempDir = '/tmp';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBeDefined();
expect(result.content).toContain('... [CONTENT TRUNCATED] ...');
// Extract just the truncated part (after the instructions)
const truncatedPart = result.content.split(
'Truncated part of the output:\n',
)[1];
// The truncated content (excluding the instructions header) should
// be roughly within the character threshold.
expect(truncatedPart.length).toBeLessThan(THRESHOLD * 1.5);
// With 100 chars/line and 40,000 threshold, effective lines ≈ 400.
// Verify we have fewer lines than the default TRUNCATE_LINES.
const truncatedLines = truncatedPart.split('\n');
expect(truncatedLines.length).toBeLessThan(TRUNCATE_LINES);
});
it('should truncate content by lines when line limit is the binding constraint', async () => {
// 2000 lines of 5 chars each = ~12,000 chars, well under THRESHOLD (40,000)
// so the line limit (1000) is the binding constraint, not the char threshold.
const lines = Array(2000).fill('hello');
const content = lines.join('\n');
const fileName = 'test-file';
const projectTempDir = '/tmp';
expect(content.length).toBeLessThan(THRESHOLD);
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBe(
path.join(projectTempDir, `${fileName}.output`),
);
expect(mockWriteFile).toHaveBeenCalledWith(
path.join(projectTempDir, `${fileName}.output`),
content,
);
// Effective lines = min(1000, 40000/5) = 1000 (line limit is binding)
const head = Math.floor(TRUNCATE_LINES / 5);
const beginning = lines.slice(0, head);
const end = lines.slice(-(TRUNCATE_LINES - head));
const expectedTruncated =
beginning.join('\n') +
'\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n' +
end.join('\n');
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain('Truncated part of the output:');
expect(result.content).toContain(expectedTruncated);
});
it('should truncate content with few but very long lines', async () => {
const content = 'a'.repeat(200_000); // A single very long line
const fileName = 'test-file';
const projectTempDir = '/tmp';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBe(
path.join(projectTempDir, `${fileName}.output`),
);
// Full original content is saved to file (no wrapping)
expect(mockWriteFile).toHaveBeenCalledWith(
path.join(projectTempDir, `${fileName}.output`),
content,
);
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain('... [CONTENT TRUNCATED] ...');
// The truncated content should stay near the character threshold
const truncatedPart = result.content.split(
'Truncated part of the output:\n',
)[1];
expect(truncatedPart.length).toBeLessThan(THRESHOLD * 1.5);
});
it('should stay near char threshold even when line lengths vary widely', async () => {
// Mix of short and very long lines — the old average-based approach
// would undercount because long lines in the tail blow past the budget.
const lines: string[] = [];
for (let i = 0; i < 2000; i++) {
lines.push(i % 10 === 0 ? 'x'.repeat(5000) : 'short');
}
const content = lines.join('\n');
const fileName = 'test-file';
const projectTempDir = '/tmp';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.content).toContain('... [CONTENT TRUNCATED] ...');
const truncatedPart = result.content.split(
'Truncated part of the output:\n',
)[1];
// Should stay within ~1.5x the threshold even with variable line lengths
expect(truncatedPart.length).toBeLessThan(THRESHOLD * 1.5);
});
it('should handle file write errors gracefully', async () => {
const content = 'a'.repeat(2_000_000);
const fileName = 'test-file';
const projectTempDir = '/tmp';
mockWriteFile.mockRejectedValue(new Error('File write failed'));
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.outputFile).toBeUndefined();
expect(result.content).toContain(
'[Note: Could not save full output to file]',
);
expect(mockWriteFile).toHaveBeenCalled();
});
it('should save to correct file path with file name', async () => {
const content = 'a'.repeat(200_000);
const fileName = 'unique-file-123';
const projectTempDir = '/custom/temp/dir';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
const expectedPath = path.join(projectTempDir, `${fileName}.output`);
expect(result.outputFile).toBe(expectedPath);
expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, content);
});
it('should include helpful instructions in truncated message', async () => {
const content = 'a'.repeat(2_000_000);
const fileName = 'test-file';
const projectTempDir = '/tmp';
mockWriteFile.mockResolvedValue(undefined);
const result = await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
expect(result.content).toContain(
'Tool output was too large and has been truncated',
);
expect(result.content).toContain('The full output has been saved to:');
expect(result.content).toContain(
'To read the complete output, use the read_file tool with the absolute file path above',
);
expect(result.content).toContain(
'The truncated output below shows the beginning and end of the content',
);
});
it('should sanitize fileName to prevent path traversal', async () => {
const content = 'a'.repeat(200_000);
const fileName = '../../../../../etc/passwd';
const projectTempDir = '/tmp/safe_dir';
mockWriteFile.mockResolvedValue(undefined);
await truncateAndSaveToFile(
content,
fileName,
projectTempDir,
THRESHOLD,
TRUNCATE_LINES,
);
const expectedPath = path.join(projectTempDir, 'passwd.output');
expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, content);
});
});

View file

@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { ReadFileTool } from '../tools/read-file.js';
/**
* Truncates large tool output and saves the full content to a temp file.
* Used by the shell tool to prevent excessively large outputs from being
* sent to the LLM context.
*
* If content length is within the threshold, returns it unchanged.
* Otherwise, saves full content to a file and returns a truncated version
* with head/tail lines and a pointer to the saved file.
*/
export async function truncateAndSaveToFile(
content: string,
fileName: string,
projectTempDir: string,
threshold: number,
truncateLines: number,
): Promise<{ content: string; outputFile?: string }> {
const lines = content.split('\n');
// Check both constraints: character threshold and line limit.
if (content.length <= threshold && lines.length <= truncateLines) {
return { content };
}
// Build head and tail within both line and character budgets.
const effectiveLines = Math.min(truncateLines, lines.length);
const headCount = Math.max(Math.floor(effectiveLines / 5), 1);
const tailCount = effectiveLines - headCount;
const separator = '\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n';
const ellipsis = '...';
// Collect head lines within budget. If a single line exceeds the
// remaining budget, include a truncated slice of it.
const headBudget = Math.floor(threshold / 5);
const beginning: string[] = [];
let headChars = 0;
for (let i = 0; i < Math.min(headCount, lines.length); i++) {
const remaining = headBudget - headChars;
if (remaining <= 0) break;
if (lines[i].length + 1 > remaining) {
const sliceLen = Math.max(remaining - ellipsis.length, 0);
beginning.push(lines[i].slice(0, sliceLen) + ellipsis);
headChars = headBudget;
break;
}
beginning.push(lines[i]);
headChars += lines[i].length + 1; // +1 for newline
}
// Collect tail lines within remaining budget. If a single line exceeds
// the remaining budget, include a truncated slice of it.
const tailBudget = Math.max(threshold - headChars - separator.length, 0);
const end: string[] = [];
let tailChars = 0;
const tailStart = Math.max(lines.length - tailCount, beginning.length);
for (let i = lines.length - 1; i >= tailStart; i--) {
const remaining = tailBudget - tailChars;
if (remaining <= 0) break;
if (lines[i].length + 1 > remaining) {
const sliceLen = Math.max(remaining - ellipsis.length, 0);
end.unshift(ellipsis + lines[i].slice(-sliceLen));
tailChars = tailBudget;
break;
}
end.unshift(lines[i]);
tailChars += lines[i].length + 1;
}
const truncatedContent = beginning.join('\n') + separator + end.join('\n');
// Sanitize fileName to prevent path traversal.
const safeFileName = `${path.basename(fileName)}.output`;
const outputFile = path.join(projectTempDir, safeFileName);
try {
await fs.writeFile(outputFile, content);
return {
content: `Tool output was too large and has been truncated.
The full output has been saved to: ${outputFile}
To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above.
The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed.
Truncated part of the output:
${truncatedContent}`,
outputFile,
};
} catch (_error) {
return {
content:
truncatedContent + `\n[Note: Could not save full output to file]`,
};
}
}

View file

@ -242,11 +242,6 @@
"type": "number",
"default": -1
},
"summarizeToolOutput": {
"description": "Settings for summarizing tool output.",
"type": "object",
"additionalProperties": true
},
"chatCompression": {
"description": "Chat compression settings.",
"type": "object",
@ -450,11 +445,6 @@
"type": "boolean",
"default": true
},
"enableToolOutputTruncation": {
"description": "Enable truncation of large tool outputs.",
"type": "boolean",
"default": true
},
"truncateToolOutputThreshold": {
"description": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.",
"type": "number",