diff options
Diffstat (limited to 'packages/cli/src/tools/read-file.tool.ts')
| -rw-r--r-- | packages/cli/src/tools/read-file.tool.ts | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/packages/cli/src/tools/read-file.tool.ts b/packages/cli/src/tools/read-file.tool.ts new file mode 100644 index 00000000..7cbacd96 --- /dev/null +++ b/packages/cli/src/tools/read-file.tool.ts @@ -0,0 +1,296 @@ +import fs from 'fs'; +import path from 'path'; +import { ToolResult } from './ToolResult.js'; +import { BaseTool } from './BaseTool.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { makeRelative, shortenPath } from '../utils/paths.js'; + +/** + * Parameters for the ReadFile tool + */ +export interface ReadFileToolParams { + /** + * The absolute path to the file to read + */ + file_path: string; + + /** + * The line number to start reading from (optional) + */ + offset?: number; + + /** + * The number of lines to read (optional) + */ + limit?: number; +} + +/** + * Standardized result from the ReadFile tool + */ +export interface ReadFileToolResult extends ToolResult { +} + +/** + * Implementation of the ReadFile tool that reads files from the filesystem + */ +export class ReadFileTool extends BaseTool<ReadFileToolParams, ReadFileToolResult> { + public static readonly Name: string = 'read_file'; + + // Maximum number of lines to read by default + private static readonly DEFAULT_MAX_LINES = 2000; + + // Maximum length of a line before truncating + private static readonly MAX_LINE_LENGTH = 2000; + + /** + * The root directory that this tool is grounded in. + * All file operations will be restricted to this directory. + */ + private rootDirectory: string; + + /** + * Creates a new instance of the ReadFileTool + * @param rootDirectory Root directory to ground this tool in. All operations will be restricted to this directory. + */ + 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: { + file_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: ['file_path'], + type: 'object' + } + ); + + // Set the root directory + 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); + + // Ensure the normalizedRoot ends with a path separator for proper path comparison + 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 + */ + invalidParams(params: ReadFileToolParams): string | null { + if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters as Record<string, unknown>, params)) { + return "Parameters failed schema validation."; + } + + // Ensure path is absolute + if (!path.isAbsolute(params.file_path)) { + return `File path must be absolute: ${params.file_path}`; + } + + // Ensure path is within the root directory + if (!this.isWithinRoot(params.file_path)) { + return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`; + } + + // Validate offset and limit if provided + 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 (error) { + 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.file_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<ReadFileToolResult> { + const validationError = this.invalidParams(params); + if (validationError) { + return { + llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, + returnDisplay: "**Error:** Failed to execute tool." + }; + } + + try { + // Check if file exists + if (!fs.existsSync(params.file_path)) { + return { + llmContent: `File not found: ${params.file_path}`, + returnDisplay: `File not found.`, + }; + } + + // Check if it's a directory + const stats = fs.statSync(params.file_path); + if (stats.isDirectory()) { + return { + llmContent: `Path is a directory, not a file: ${params.file_path}`, + returnDisplay: `File is directory.`, + }; + } + + // Detect file type + const fileType = this.detectFileType(params.file_path); + + // Handle binary files differently + if (fileType !== 'text') { + return { + llmContent: `Binary file: ${params.file_path} (${fileType})`, + returnDisplay: ``, + }; + } + + // Read and process text file + const content = fs.readFileSync(params.file_path, 'utf8'); + const lines = content.split('\n'); + + // Apply offset and limit + const startLine = params.offset || 0; + // Use the default max lines if no limit is provided + const endLine = params.limit + ? startLine + params.limit + : Math.min(startLine + ReadFileTool.DEFAULT_MAX_LINES, lines.length); + const selectedLines = lines.slice(startLine, endLine); + + // Format with line numbers and handle line truncation + let truncated = false; + const formattedLines = selectedLines.map((line) => { + // Calculate actual line number (1-based) + // Truncate long lines + let processedLine = line; + if (line.length > ReadFileTool.MAX_LINE_LENGTH) { + processedLine = line.substring(0, ReadFileTool.MAX_LINE_LENGTH) + '... [truncated]'; + truncated = true; + } + + return processedLine; + }); + + // Check if content was truncated due to line limit or max lines limit + const contentTruncated = (endLine < lines.length) || truncated; + + // Create llmContent with truncation info if needed + 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'); + + return { + llmContent, + returnDisplay: '', + }; + } catch (error) { + const errorMsg = `Error reading file: ${error instanceof Error ? error.message : String(error)}`; + + return { + llmContent: `Error reading file ${params.file_path}: ${errorMsg}`, + returnDisplay: `Failed to read file: ${errorMsg}`, + }; + } + } +}
\ No newline at end of file |
