diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index f5faee40e..94859048e 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -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") diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index c648a231f..bc56a437e 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -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"], diff --git a/integration-tests/fixtures/settings-migration/workspaces.json b/integration-tests/fixtures/settings-migration/workspaces.json index af7a48f84..bd9798009 100644 --- a/integration-tests/fixtures/settings-migration/workspaces.json +++ b/integration-tests/fixtures/settings-migration/workspaces.json @@ -43,7 +43,6 @@ "maxSessionTurns": 50, "preferredEditor": "vscode", "sandbox": false, - "summarizeToolOutput": true, "telemetry": { "enabled": false }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 88153fe75..eab0470c6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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: { diff --git a/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts b/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts index c87fa4480..c63979f35 100644 --- a/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts +++ b/packages/cli/src/config/migration/versions/v1-to-v2-shared.ts @@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record = { 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', diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 0809cf090..dbd9a20ec 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -103,10 +103,6 @@ export interface CheckpointingSettings { enabled?: boolean; } -export interface SummarizeToolOutputSettings { - tokenBudget?: number; -} - export interface AccessibilitySettings { enableLoadingPhrases?: boolean; screenReader?: boolean; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 6e6782f47..373988d72 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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 - | 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', diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index bbebc1361..a5931119b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -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 = ({ contentWidth={innerWidth} /> )} - {tool.outputFile && ( - - - Output too long and was saved to: {tool.outputFile} - - - )} ); })} diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 56992f678..966c6adff 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -252,7 +252,6 @@ export function mapToDisplay( status: mapCoreStatusToDisplayStatus(trackedCall.status), resultDisplay: trackedCall.response.resultDisplay, confirmationDetails: undefined, - outputFile: trackedCall.response.outputFile, }; case 'error': return { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index d2483f371..8f4c41f6d 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay { confirmationDetails: ToolCallConfirmationDetails | undefined; renderOutputAsMarkdown?: boolean; ptyId?: number; - outputFile?: string; } export interface CompressionProps { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 828ef9c3e..30b24c086 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3663beb8f..3fcd3b9ca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; 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 - | 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 - | 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; } diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 145e8ace1..3411fff50 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -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; diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 63cae821a..7a8ab2895 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -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); diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 989b61c37..29bcf99b8 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -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: [ diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 08f379d68..2037081ff 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -109,7 +109,6 @@ export interface ToolCallResponseInfo { resultDisplay: ToolResultDisplay | undefined; error: Error | undefined; errorType: ToolErrorType | undefined; - outputFile?: string | undefined; contentLength?: number; } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index ab026304a..34d142c4f 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -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, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 91a413afe..30334751a 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -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, diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index 6cc0f230a..352d90e12 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -81,6 +81,11 @@ const makeFakeConfig = (overrides: Partial = {}): Config => { getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', getToolRegistry: () => undefined, + getTruncateToolOutputThreshold: () => 25000, + getTruncateToolOutputLines: () => 0, + getIdeMode: () => false, + getShouldUseNodePtyShell: () => false, + getHookSystem: () => undefined, ...overrides, }; return defaults as Config; diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index d816837aa..0d89d6b69 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -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, }, }); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 52f02c6eb..c9e6c2d53 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -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 diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index d03509451..e9aa4f850 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -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(() => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 01a9ac5cf..1de48b599 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -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, diff --git a/packages/core/src/utils/summarizer.test.ts b/packages/core/src/utils/summarizer.test.ts deleted file mode 100644 index 6098e77b7..000000000 --- a/packages/core/src/utils/summarizer.test.ts +++ /dev/null @@ -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 tags. The stack trace should be complete and not truncated. If there are warnings, you should include them in the summary within 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(); - }); - }); -}); diff --git a/packages/core/src/utils/summarizer.ts b/packages/core/src/utils/summarizer.ts deleted file mode 100644 index 8c2b391ea..000000000 --- a/packages/core/src/utils/summarizer.ts +++ /dev/null @@ -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; - -/** - * 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 tags. The stack trace should be complete and not truncated. If there are warnings, you should include them in the summary within 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 { - // 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; - } -} diff --git a/packages/core/src/utils/truncation.test.ts b/packages/core/src/utils/truncation.test.ts new file mode 100644 index 000000000..4fb4bb99e --- /dev/null +++ b/packages/core/src/utils/truncation.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts new file mode 100644 index 000000000..47a21ef60 --- /dev/null +++ b/packages/core/src/utils/truncation.ts @@ -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]`, + }; + } +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index bbd2df6b7..8f56a9c89 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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",