mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 04:24:51 -07:00
feat(core): Save large tool outputs to a file and return truncated lines (#6240)
This commit is contained in:
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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<ToolGroupMessageProps> = ({
|
||||
terminalWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box marginX={1}>
|
||||
<Text>Output too long and was saved to: {tool.outputFile}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -247,6 +247,7 @@ export function mapToDisplay(
|
||||
status: mapCoreStatusToDisplayStatus(trackedCall.status),
|
||||
resultDisplay: trackedCall.response.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
outputFile: trackedCall.response.outputFile,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface IndividualToolCallDisplay {
|
||||
status: ToolCallStatus;
|
||||
confirmationDetails: ToolCallConfirmationDetails | undefined;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
outputFile?: string;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface ToolCallResponseInfo {
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
error: Error | undefined;
|
||||
errorType: ToolErrorType | undefined;
|
||||
outputFile?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ServerToolCallConfirmationDetails {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user