feat(core): Save large tool outputs to a file and return truncated lines (#6240)

This commit is contained in:
Sandy Tao
2025-09-05 15:37:29 -07:00
committed by GitHub
parent 7239c5cd9a
commit dd23c77469
14 changed files with 511 additions and 10 deletions
+12 -2
View File
@@ -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' }),
+2
View File
@@ -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,
});
+23
View File
@@ -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',
+1
View File
@@ -61,6 +61,7 @@ export interface IndividualToolCallDisplay {
status: ToolCallStatus;
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
outputFile?: string;
}
export interface CompressionProps {
+21
View File
@@ -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,
);
});
});
+84 -1
View File
@@ -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: [
{
+1
View File
@@ -91,6 +91,7 @@ export interface ToolCallResponseInfo {
resultDisplay: ToolResultDisplay | undefined;
error: Error | undefined;
errorType: ToolErrorType | undefined;
outputFile?: string | undefined;
}
export interface ServerToolCallConfirmationDetails {
+39
View File
@@ -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(
+9 -2
View File
@@ -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';