diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index e175002ff6..1c4dea7133 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -9,8 +9,12 @@ import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, } from '@a2a-js/sdk'; -import { ApprovalMode } from '@google/gemini-cli-core'; -import type { Config } from '@google/gemini-cli-core'; +import { + ApprovalMode, + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, +} from '@google/gemini-cli-core'; +import type { Config, Storage } from '@google/gemini-cli-core'; import { expect, vi } from 'vitest'; export function createMockConfig( @@ -28,6 +32,12 @@ export function createMockConfig( isPathWithinWorkspace: () => true, }), getTargetDir: () => '/test', + storage: { + getProjectTempDir: () => '/tmp', + } as Storage, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getGeminiClient: vi.fn(), getDebugMode: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }), diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3b187f3f56..1018cb1dc0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -622,6 +622,8 @@ export async function loadCliConfig( shouldUseNodePtyShell: settings.tools?.usePty, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, + truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, + truncateToolOutputLines: settings.tools?.truncateToolOutputLines, eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 6795065f59..8784346a6c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -11,6 +11,10 @@ import type { AuthType, ChatCompressionSettings, } from '@google/gemini-cli-core'; +import { + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, +} from '@google/gemini-cli-core'; import type { CustomTheme } from '../ui/themes/theme.js'; export enum MergeStrategy { @@ -654,6 +658,25 @@ export const SETTINGS_SCHEMA = { 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', showInDialog: true, }, + truncateToolOutputThreshold: { + type: 'number', + label: 'Tool Output Truncation Threshold', + category: 'General', + requiresRestart: false, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + description: + 'Truncate tool output if it is larger than this many characters. Set to -1 to disable.', + showInDialog: true, + }, + truncateToolOutputLines: { + type: 'number', + label: 'Tool Output Truncation Lines', + category: 'General', + requiresRestart: false, + default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + description: 'The number of lines to keep when truncating tool output.', + showInDialog: true, + }, }, }, diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 3558f926f4..9876b703a2 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 } from 'ink'; +import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; @@ -121,6 +121,11 @@ export const ToolGroupMessage: React.FC = ({ terminalWidth={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 1bfbc42766..267c45b7a8 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -247,6 +247,7 @@ 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/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 3be6b13294..1dd37a910a 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -26,6 +26,8 @@ import type { AnyToolInvocation, } from '@google/gemini-cli-core'; import { + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, ToolConfirmationOutcome, ApprovalMode, MockTool, @@ -54,6 +56,11 @@ const mockConfig = { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getDebugMode: () => false, + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getAllowedTools: vi.fn(() => []), getContentGeneratorConfig: () => ({ model: 'test-model', diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ca222f4d6b..8d3f851786 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -61,6 +61,7 @@ export interface IndividualToolCallDisplay { status: ToolCallStatus; confirmationDetails: ToolCallConfirmationDetails | undefined; renderOutputAsMarkdown?: boolean; + outputFile?: string; } export interface CompressionProps { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9d8861ef95..f7f3e6a177 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -110,6 +110,10 @@ export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = { respectGitIgnore: true, respectGeminiIgnore: true, }; + +export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 4_000_000; +export const DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES = 1000; + export class MCPServerConfig { constructor( // For stdio transport @@ -210,6 +214,8 @@ export interface ConfigParameters { skipNextSpeakerCheck?: boolean; extensionManagement?: boolean; enablePromptCompletion?: boolean; + truncateToolOutputThreshold?: number; + truncateToolOutputLines?: number; eventEmitter?: EventEmitter; useSmartEdit?: boolean; } @@ -284,6 +290,8 @@ export class Config { private readonly skipNextSpeakerCheck: boolean; private readonly extensionManagement: boolean = true; private readonly enablePromptCompletion: boolean = false; + private readonly truncateToolOutputThreshold: number; + private readonly truncateToolOutputLines: number; private initialized: boolean = false; readonly storage: Storage; private readonly fileExclusions: FileExclusions; @@ -359,6 +367,11 @@ export class Config { this.useRipgrep = params.useRipgrep ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false; + this.truncateToolOutputThreshold = + params.truncateToolOutputThreshold ?? + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; + this.truncateToolOutputLines = + params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES; this.useSmartEdit = params.useSmartEdit ?? true; this.extensionManagement = params.extensionManagement ?? true; this.storage = new Storage(this.targetDir); @@ -809,6 +822,14 @@ export class Config { return this.enablePromptCompletion; } + getTruncateToolOutputThreshold(): number { + return this.truncateToolOutputThreshold; + } + + getTruncateToolOutputLines(): number { + return this.truncateToolOutputLines; + } + getUseSmartEdit(): boolean { return this.useSmartEdit; } diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 1bea8745e7..0f85cc5bd2 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Mock } from 'vitest'; import type { ToolCall, WaitingToolCall } from './coreToolScheduler.js'; import { CoreToolScheduler, convertToFunctionResponse, + truncateAndSaveToFile, } from './coreToolScheduler.js'; import type { ToolCallConfirmationDetails, @@ -20,6 +21,8 @@ import type { ToolRegistry, } from '../index.js'; import { + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, BaseDeclarativeTool, BaseToolInvocation, ToolConfirmationOutcome, @@ -28,6 +31,12 @@ import { } from '../index.js'; import type { Part, PartListUnion } from '@google/genai'; import { MockModifiableTool, MockTool } from '../test-utils/tools.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'; @@ -167,6 +176,12 @@ describe('CoreToolScheduler', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests @@ -268,6 +283,12 @@ describe('CoreToolScheduler with payload', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests @@ -306,7 +327,11 @@ describe('CoreToolScheduler with payload', () => { ); } - expect(onAllToolCallsComplete).toHaveBeenCalled(); + // Wait for the tool execution to complete + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + const completedCalls = onAllToolCallsComplete.mock .calls[0][0] as ToolCall[]; expect(completedCalls[0].status).toBe('success'); @@ -576,6 +601,9 @@ describe('CoreToolScheduler edit cancellation', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, getToolRegistry: () => mockToolRegistry, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests @@ -669,7 +697,13 @@ describe('CoreToolScheduler YOLO mode', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, getToolRegistry: () => mockToolRegistry, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests } as unknown as Config; @@ -694,6 +728,11 @@ describe('CoreToolScheduler YOLO mode', () => { // Act await scheduler.schedule([request], abortController.signal); + // Wait for the tool execution to complete + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + // Assert // 1. The tool's execute method was called directly. expect(mockTool.executeFn).toHaveBeenCalledWith({ param: 'value' }); @@ -711,7 +750,6 @@ describe('CoreToolScheduler YOLO mode', () => { ]); // 3. The final callback indicates the tool call was successful. - expect(onAllToolCallsComplete).toHaveBeenCalled(); const completedCalls = onAllToolCallsComplete.mock .calls[0][0] as ToolCall[]; expect(completedCalls).toHaveLength(1); @@ -761,6 +799,12 @@ describe('CoreToolScheduler request queueing', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests @@ -880,6 +924,12 @@ describe('CoreToolScheduler request queueing', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests } as unknown as Config; @@ -904,6 +954,11 @@ describe('CoreToolScheduler request queueing', () => { // Act await scheduler.schedule([request], abortController.signal); + // Wait for the tool execution to complete + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + // Assert // 1. The tool's execute method was called directly. expect(mockTool.executeFn).toHaveBeenCalledWith({ param: 'value' }); @@ -961,6 +1016,12 @@ describe('CoreToolScheduler request queueing', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => mockToolRegistry, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests @@ -1023,6 +1084,12 @@ describe('CoreToolScheduler request queueing', () => { setApprovalMode: (mode: ApprovalMode) => { approvalMode = mode; }, + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests } as unknown as Config; @@ -1143,3 +1210,224 @@ describe('CoreToolScheduler request queueing', () => { expect(approvalMode).toBe(ApprovalMode.AUTO_EDIT); }); }); + +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( + 'read_file tool with the absolute file path above', + ); + expect(result.content).toContain('read_file tool with offset=0, limit=100'); + expect(result.content).toContain( + 'read_file tool with offset=N to skip N lines', + ); + expect(result.content).toContain( + 'read_file tool with limit=M to read only M lines', + ); + }); + + 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, + ); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 9af10a82f8..5cdb3d5e9c 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -21,6 +21,7 @@ import { ToolConfirmationOutcome, ApprovalMode, logToolCall, + ReadFileTool, ToolErrorType, ToolCallEvent, } from '../index.js'; @@ -32,6 +33,8 @@ 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'; @@ -243,6 +246,70 @@ const createErrorResponse = ( errorType, }); +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. For large files, you can use the offset and limit parameters to read specific sections: +- ${ReadFileTool.Name} tool with offset=0, limit=100 to see the first 100 lines +- ${ReadFileTool.Name} tool with offset=N to skip N lines from the beginning +- ${ReadFileTool.Name} tool with limit=M to read only M lines at a time +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; @@ -905,10 +972,25 @@ export class CoreToolScheduler { } if (toolResult.error === undefined) { + let content = toolResult.llmContent; + let outputFile: string | undefined = undefined; + if ( + typeof content === 'string' && + this.config.getTruncateToolOutputThreshold() > 0 + ) { + ({ content, outputFile } = await truncateAndSaveToFile( + content, + callId, + this.config.storage.getProjectTempDir(), + this.config.getTruncateToolOutputThreshold(), + this.config.getTruncateToolOutputLines(), + )); + } + const response = convertToFunctionResponse( toolName, callId, - toolResult.llmContent, + content, ); const successResponse: ToolCallResponseInfo = { callId, @@ -916,6 +998,7 @@ export class CoreToolScheduler { resultDisplay: toolResult.returnDisplay, error: undefined, errorType: undefined, + outputFile, }; this.setStatusInternal(callId, 'success', successResponse); } else { diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 24dcc1ac1b..864b78a9cf 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -12,7 +12,12 @@ import type { ToolResult, Config, } from '../index.js'; -import { ToolErrorType, ApprovalMode } from '../index.js'; +import { + DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + ToolErrorType, + ApprovalMode, +} from '../index.js'; import type { Part } from '@google/genai'; import { MockTool } from '../test-utils/tools.js'; @@ -41,6 +46,12 @@ describe('executeToolCall', () => { model: 'test-model', authType: 'oauth-personal', }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests } as unknown as Config; @@ -75,6 +86,7 @@ describe('executeToolCall', () => { callId: 'call1', error: undefined, errorType: undefined, + outputFile: undefined, resultDisplay: 'Success!', responseParts: [ { @@ -274,6 +286,7 @@ describe('executeToolCall', () => { callId: 'call6', error: undefined, errorType: undefined, + outputFile: undefined, resultDisplay: 'Image processed', responseParts: [ { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 95ffa8e761..5ca3ee05cc 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -91,6 +91,7 @@ export interface ToolCallResponseInfo { resultDisplay: ToolResultDisplay | undefined; error: Error | undefined; errorType: ToolErrorType | undefined; + outputFile?: string | undefined; } export interface ServerToolCallConfirmationDetails { diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 18bfc7e9a5..74b94e8af9 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -38,6 +38,9 @@ describe('ReadFileTool', () => { getFileSystemService: () => new StandardFileSystemService(), getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), + storage: { + getProjectTempDir: () => path.join(tempRootDir, '.temp'), + }, } as unknown as Config; tool = new ReadFileTool(mockConfigInstance); }); @@ -76,6 +79,24 @@ describe('ReadFileTool', () => { ); }); + it('should allow access to files in project temp directory', () => { + const tempDir = path.join(tempRootDir, '.temp'); + const params: ReadFileToolParams = { + absolute_path: path.join(tempDir, 'temp-file.txt'), + }; + const result = tool.build(params); + expect(typeof result).not.toBe('string'); + }); + + it('should show temp directory in error message when path is outside workspace and temp dir', () => { + const params: ReadFileToolParams = { + absolute_path: '/completely/outside/path.txt', + }; + expect(() => tool.build(params)).toThrow( + /File path must be within one of the workspace directories.*or within the project temp directory/, + ); + }); + it('should throw error if path is empty', () => { const params: ReadFileToolParams = { absolute_path: '', @@ -409,6 +430,24 @@ describe('ReadFileTool', () => { ); }); + it('should successfully read files from project temp directory', async () => { + const tempDir = path.join(tempRootDir, '.temp'); + await fsp.mkdir(tempDir, { recursive: true }); + const tempFilePath = path.join(tempDir, 'temp-output.txt'); + const tempFileContent = 'This is temporary output content'; + await fsp.writeFile(tempFilePath, tempFileContent, 'utf-8'); + + const params: ReadFileToolParams = { absolute_path: tempFilePath }; + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; + + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toBe(tempFileContent); + expect(result.returnDisplay).toBe(''); + }); + describe('with .geminiignore', () => { beforeEach(async () => { await fsp.writeFile( diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 21c84964e0..9f14c1af6c 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -180,9 +180,16 @@ export class ReadFileTool extends BaseDeclarativeTool< } const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(filePath)) { + const projectTempDir = this.config.storage.getProjectTempDir(); + const resolvedFilePath = path.resolve(filePath); + const resolvedProjectTempDir = path.resolve(projectTempDir); + const isWithinTempDir = + resolvedFilePath.startsWith(resolvedProjectTempDir + path.sep) || + resolvedFilePath === resolvedProjectTempDir; + + if (!workspaceContext.isPathWithinWorkspace(filePath) && !isWithinTempDir) { const directories = workspaceContext.getDirectories(); - return `File path must be within one of the workspace directories: ${directories.join(', ')}`; + return `File path must be within one of the workspace directories: ${directories.join(', ')} or within the project temp directory: ${projectTempDir}`; } if (params.offset !== undefined && params.offset < 0) { return 'Offset must be a non-negative number';