mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 03:54:43 -07:00
feat(core): Save large tool outputs to a file and return truncated lines (#6240)
This commit is contained in:
@@ -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