feat(read_file): implement model-specific behavior for 2.5 vs 3.0 models

- Use dynamic schema to hide offset/limit for 3.0 models.
- Update truncation guidance for 3.0 models to encourage shell tools.
- Maintain legacy behavior for 2.5 models.
- Add TODO for signature cleanup in fileUtils.ts.
This commit is contained in:
Adam Weidman
2026-02-10 10:39:51 -05:00
parent da66c7c0d1
commit 041c3526b5
3 changed files with 133 additions and 12 deletions

View File

@@ -40,6 +40,7 @@ describe('ReadFileTool', () => {
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getActiveModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
@@ -77,6 +78,40 @@ describe('ReadFileTool', () => {
}
});
describe('schema', () => {
it('should include pagination parameters for Gemini 2.5', () => {
vi.mocked(tool['config'].getActiveModel).mockReturnValue(
'gemini-2.5-pro',
);
const schema = tool.schema;
const properties = (
schema.parametersJsonSchema as {
properties: Record<string, unknown>;
}
).properties;
expect(properties).toHaveProperty('offset');
expect(properties).toHaveProperty('limit');
expect(schema.description).toContain('offset');
expect(schema.description).toContain('limit');
});
it('should NOT include pagination parameters for Gemini 3', () => {
vi.mocked(tool['config'].getActiveModel).mockReturnValue(
'gemini-3-pro-preview',
);
const schema = tool.schema;
const properties = (
schema.parametersJsonSchema as {
properties: Record<string, unknown>;
}
).properties;
expect(properties).not.toHaveProperty('offset');
expect(properties).not.toHaveProperty('limit');
expect(schema.description).toContain('grep');
expect(schema.description).toContain('sed');
});
});
describe('build', () => {
it('should return an invocation for valid params (absolute path within root)', () => {
const params: ReadFileToolParams = {
@@ -421,6 +456,28 @@ describe('ReadFileTool', () => {
);
});
it('should use first-2000-lines truncation and shell-tool guidance for Gemini 3', async () => {
vi.mocked(tool['config'].getActiveModel).mockReturnValue(
'gemini-3-pro-preview',
);
const filePath = path.join(tempRootDir, 'large.txt');
const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`);
await fsp.writeFile(filePath, lines.join('\n'), 'utf-8');
const params: ReadFileToolParams = {
file_path: filePath,
offset: 10, // Should be ignored
limit: 5, // Should be ignored
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Showing the first 2000 lines');
expect(result.llmContent).toContain("use the 'grep_search' tool");
expect(result.llmContent).toContain('Line 1');
expect(result.llmContent).not.toContain('Line 2001');
});
it('should successfully read files from project temp directory', async () => {
const tempDir = path.join(tempRootDir, '.temp');
await fsp.mkdir(tempDir, { recursive: true });
@@ -447,6 +504,7 @@ describe('ReadFileTool', () => {
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => new WorkspaceContext(tempRootDir),
getActiveModel: () => 'gemini-2.5-pro',
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
@@ -520,6 +578,7 @@ describe('ReadFileTool', () => {
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => new WorkspaceContext(tempRootDir),
getActiveModel: () => 'gemini-2.5-pro',
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: false,

View File

@@ -10,8 +10,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import type { PartUnion } from '@google/genai';
import type { FunctionDeclaration, PartUnion } from '@google/genai';
import {
processSingleFileContent,
getSpecificMimeType,
@@ -23,6 +22,10 @@ import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { READ_FILE_TOOL_NAME } from './tool-names.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import {
isPreviewModel,
supportsMultimodalFunctionResponse,
} from '../config/models.js';
/**
* Parameters for the ReadFile tool
@@ -76,6 +79,11 @@ class ReadFileToolInvocation extends BaseToolInvocation<
}
async execute(): Promise<ToolResult> {
const activeModel = this.config.getActiveModel();
const isGemini3 =
supportsMultimodalFunctionResponse(activeModel) ||
isPreviewModel(activeModel);
const validationError = this.config.validatePathAccess(this.resolvedPath);
if (validationError) {
return {
@@ -92,8 +100,8 @@ class ReadFileToolInvocation extends BaseToolInvocation<
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.offset,
this.params.limit,
isGemini3 ? undefined : this.params.offset,
isGemini3 ? undefined : this.params.limit,
);
if (result.error) {
@@ -109,18 +117,32 @@ class ReadFileToolInvocation extends BaseToolInvocation<
let llmContent: PartUnion;
if (result.isTruncated) {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
const nextOffset = this.params.offset
? this.params.offset + end - start + 1
: end;
llmContent = `
if (isGemini3) {
const total = result.originalLineCount!;
llmContent = `
IMPORTANT: The file content has been truncated.
Status: Showing the first 2000 lines of ${total} total lines.
Action:
- To find specific patterns, use the 'grep_search' tool.
- To read a specific line range, use 'run_shell_command' with 'sed'. For example: 'sed -n "500,600p" ${this.params.file_path}'.
- You can also use other Unix utilities like 'awk', 'head', or 'tail' via 'run_shell_command'.
--- FILE CONTENT (truncated) ---
${result.llmContent}`;
} else {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
const nextOffset = this.params.offset
? this.params.offset + end - start + 1
: end;
llmContent = `
IMPORTANT: The file content has been truncated.
Status: Showing lines ${start}-${end} of ${total} total lines.
Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}.
--- FILE CONTENT (truncated) ---
${result.llmContent}`;
}
} else {
llmContent = result.llmContent || '';
}
@@ -169,7 +191,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
super(
ReadFileTool.Name,
'ReadFile',
`Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
'', // Description is dynamic in getter
Kind.Read,
{
properties: {
@@ -201,6 +223,45 @@ export class ReadFileTool extends BaseDeclarativeTool<
);
}
override get schema(): FunctionDeclaration {
const activeModel = this.config.getActiveModel();
const isGemini3 =
supportsMultimodalFunctionResponse(activeModel) ||
isPreviewModel(activeModel);
const properties: Record<string, unknown> = {
file_path: {
description: 'The path to the file to read.',
type: 'string',
},
};
if (!isGemini3) {
properties.offset = {
description:
"Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
type: 'number',
};
properties.limit = {
description:
"Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).",
type: 'number',
};
}
return {
name: this.name,
description: isGemini3
? `Reads and returns the content of a specified file. If the file is large (exceeding 2000 lines), the content will be truncated to the first 2000 lines. The tool's response will clearly indicate if truncation has occurred. To examine specific sections of large files, use the 'run_shell_command' tool with standard Unix utilities like 'grep', 'sed', 'awk', 'head', or 'tail'. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.`
: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`,
parametersJsonSchema: {
properties,
required: ['file_path'],
type: 'object',
},
};
}
protected override validateToolParamValues(
params: ReadFileToolParams,
): string | null {

View File

@@ -406,7 +406,8 @@ export interface ProcessedFileReadResult {
export async function processSingleFileContent(
filePath: string,
rootDirectory: string,
fileSystemService: FileSystemService,
// TODO: remove unused vars from other areas
_fileSystemService?: FileSystemService,
offset?: number,
limit?: number,
): Promise<ProcessedFileReadResult> {