mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 22:51:00 -07:00
- The tl;dr; is that GC couldn't see what the user was saying when tool call events happened in response. The rason why this was happening was because we were instantly invoking tools that the model told us to invoke and then instantly re-requesting. This resulted in the bug because the genai APIs can't update the chat history before a full response has been completed (doesn't know how to update if it's incomplete). - To address the above issue I had to do quite the large refactor. The gist is that now turns truly drive everything on the server (vs. a server client split). This ensured that when we got tool invocations we could control when/how re-requesting would happen and then also ensure that history was updated. This change also meant that the server would act as an event publisher to enable the client to react to events rather than try and weave in complex logic between the events. - A BIG change that this changeset incudes is the removal of all of the CLI tools in favor of the server tools. - Removed some dead code as part of this - **NOTE: Confirmations are still broken (they were broken prior to this); however, I've set them up to be able to work in the future, I'll dot hat in a follow up to be less breaking to others.** Fixes https://b.corp.google.com/issues/412320087
278 lines
8.3 KiB
TypeScript
278 lines
8.3 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
|
import { makeRelative, shortenPath } from '../utils/paths.js';
|
|
import { BaseTool, ToolResult } from './tools.js';
|
|
|
|
/**
|
|
* Parameters for the ReadFile tool
|
|
*/
|
|
export interface ReadFileToolParams {
|
|
/**
|
|
* The absolute path to the file to read
|
|
*/
|
|
path: string;
|
|
|
|
/**
|
|
* The line number to start reading from (optional)
|
|
*/
|
|
offset?: number;
|
|
|
|
/**
|
|
* The number of lines to read (optional)
|
|
*/
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* Implementation of the ReadFile tool logic
|
|
*/
|
|
export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
|
|
static readonly Name: string = 'read_file';
|
|
private static readonly DEFAULT_MAX_LINES = 2000;
|
|
private static readonly MAX_LINE_LENGTH = 2000;
|
|
private rootDirectory: string;
|
|
|
|
constructor(rootDirectory: string) {
|
|
super(
|
|
ReadFileTool.Name,
|
|
'ReadFile',
|
|
'Reads and returns the content of a specified file from the local filesystem. Handles large files by allowing reading specific line ranges.',
|
|
{
|
|
properties: {
|
|
path: {
|
|
description:
|
|
"The absolute path to the file to read (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
|
|
type: 'string',
|
|
},
|
|
offset: {
|
|
description:
|
|
"Optional: The 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.",
|
|
type: 'number',
|
|
},
|
|
limit: {
|
|
description:
|
|
"Optional: Maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible).",
|
|
type: 'number',
|
|
},
|
|
},
|
|
required: ['path'],
|
|
type: 'object',
|
|
},
|
|
);
|
|
this.rootDirectory = path.resolve(rootDirectory);
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is within the root directory
|
|
* @param pathToCheck The path to check
|
|
* @returns True if the path is within the root directory, false otherwise
|
|
*/
|
|
private isWithinRoot(pathToCheck: string): boolean {
|
|
const normalizedPath = path.normalize(pathToCheck);
|
|
const normalizedRoot = path.normalize(this.rootDirectory);
|
|
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
|
? normalizedRoot
|
|
: normalizedRoot + path.sep;
|
|
return (
|
|
normalizedPath === normalizedRoot ||
|
|
normalizedPath.startsWith(rootWithSep)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates the parameters for the ReadFile tool
|
|
* @param params Parameters to validate
|
|
* @returns True if parameters are valid, false otherwise
|
|
*/
|
|
validateToolParams(params: ReadFileToolParams): string | null {
|
|
if (
|
|
this.schema.parameters &&
|
|
!SchemaValidator.validate(
|
|
this.schema.parameters as Record<string, unknown>,
|
|
params,
|
|
)
|
|
) {
|
|
return 'Parameters failed schema validation.';
|
|
}
|
|
const filePath = params.path;
|
|
if (!path.isAbsolute(filePath)) {
|
|
return `File path must be absolute: ${filePath}`;
|
|
}
|
|
if (!this.isWithinRoot(filePath)) {
|
|
return `File path must be within the root directory (${this.rootDirectory}): ${filePath}`;
|
|
}
|
|
if (params.offset !== undefined && params.offset < 0) {
|
|
return 'Offset must be a non-negative number';
|
|
}
|
|
if (params.limit !== undefined && params.limit <= 0) {
|
|
return 'Limit must be a positive number';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Determines if a file is likely binary based on content sampling
|
|
* @param filePath Path to the file
|
|
* @returns True if the file appears to be binary
|
|
*/
|
|
private isBinaryFile(filePath: string): boolean {
|
|
try {
|
|
// Read the first 4KB of the file
|
|
const fd = fs.openSync(filePath, 'r');
|
|
const buffer = Buffer.alloc(4096);
|
|
const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
|
|
fs.closeSync(fd);
|
|
|
|
// Check for null bytes or high concentration of non-printable characters
|
|
let nonPrintableCount = 0;
|
|
for (let i = 0; i < bytesRead; i++) {
|
|
// Null byte is a strong indicator of binary data
|
|
if (buffer[i] === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Count non-printable characters
|
|
if (buffer[i] < 9 || (buffer[i] > 13 && buffer[i] < 32)) {
|
|
nonPrintableCount++;
|
|
}
|
|
}
|
|
|
|
// If more than 30% are non-printable, likely binary
|
|
return nonPrintableCount / bytesRead > 0.3;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects the type of file based on extension and content
|
|
* @param filePath Path to the file
|
|
* @returns File type description
|
|
*/
|
|
private detectFileType(filePath: string): string {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
|
|
// Common image formats
|
|
if (
|
|
['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(ext)
|
|
) {
|
|
return 'image';
|
|
}
|
|
|
|
// Other known binary formats
|
|
if (['.pdf', '.zip', '.tar', '.gz', '.exe', '.dll', '.so'].includes(ext)) {
|
|
return 'binary';
|
|
}
|
|
|
|
// Check content for binary indicators
|
|
if (this.isBinaryFile(filePath)) {
|
|
return 'binary';
|
|
}
|
|
|
|
return 'text';
|
|
}
|
|
|
|
/**
|
|
* Gets a description of the file reading operation
|
|
* @param params Parameters for the file reading
|
|
* @returns A string describing the file being read
|
|
*/
|
|
getDescription(params: ReadFileToolParams): string {
|
|
const relativePath = makeRelative(params.path, this.rootDirectory);
|
|
return shortenPath(relativePath);
|
|
}
|
|
|
|
/**
|
|
* Reads a file and returns its contents with line numbers
|
|
* @param params Parameters for the file reading
|
|
* @returns Result with file contents
|
|
*/
|
|
async execute(params: ReadFileToolParams): Promise<ToolResult> {
|
|
const validationError = this.validateToolParams(params);
|
|
if (validationError) {
|
|
return {
|
|
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
|
returnDisplay: validationError,
|
|
};
|
|
}
|
|
|
|
const filePath = params.path;
|
|
try {
|
|
if (!fs.existsSync(filePath)) {
|
|
return {
|
|
llmContent: `File not found: ${filePath}`,
|
|
returnDisplay: `File not found.`,
|
|
};
|
|
}
|
|
|
|
const stats = fs.statSync(filePath);
|
|
if (stats.isDirectory()) {
|
|
return {
|
|
llmContent: `Path is a directory, not a file: ${filePath}`,
|
|
returnDisplay: `File is directory.`,
|
|
};
|
|
}
|
|
|
|
const fileType = this.detectFileType(filePath);
|
|
if (fileType !== 'text') {
|
|
return {
|
|
llmContent: `Binary file: ${filePath} (${fileType})`,
|
|
// For binary files, maybe returnDisplay should be empty or indicate binary?
|
|
// Keeping it empty for now.
|
|
returnDisplay: ``,
|
|
};
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split('\n');
|
|
|
|
const startLine = params.offset || 0;
|
|
const endLine = params.limit
|
|
? startLine + params.limit
|
|
: Math.min(startLine + ReadFileTool.DEFAULT_MAX_LINES, lines.length);
|
|
const selectedLines = lines.slice(startLine, endLine);
|
|
|
|
let truncated = false;
|
|
const formattedLines = selectedLines.map((line) => {
|
|
let processedLine = line;
|
|
if (line.length > ReadFileTool.MAX_LINE_LENGTH) {
|
|
processedLine =
|
|
line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]';
|
|
truncated = true;
|
|
}
|
|
|
|
return processedLine;
|
|
});
|
|
|
|
const contentTruncated = endLine < lines.length || truncated;
|
|
|
|
let llmContent = '';
|
|
if (contentTruncated) {
|
|
llmContent += `[File truncated: showing lines ${startLine + 1}-${endLine} of ${lines.length} total lines. Use offset parameter to view more.]\n`;
|
|
}
|
|
llmContent += formattedLines.join('\n');
|
|
|
|
// Here, returnDisplay could potentially be enhanced, but for now,
|
|
// it's kept empty as the LLM content itself is descriptive.
|
|
return {
|
|
llmContent,
|
|
returnDisplay: '',
|
|
};
|
|
} catch (error) {
|
|
const errorMsg = `Error reading file: ${error instanceof Error ? error.message : String(error)}`;
|
|
|
|
return {
|
|
llmContent: `Error reading file ${filePath}: ${errorMsg}`,
|
|
returnDisplay: `Failed to read file: ${errorMsg}`,
|
|
};
|
|
}
|
|
}
|
|
}
|