From 925018da003e2d2723c82a405c830dfb3527bf7b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 25 Jan 2026 20:31:34 +0000 Subject: [PATCH] feat(EXT-617): Refactor read_file tool with pagination and bounded reads BREAKING CHANGE: Complete rewrite of read_file tool API Changes: - New input schema: single file with offset/limit pagination - Output now returns structured JSON with metadata - Line numbering uses cat -n style (right-aligned, stable) - Default 2000 line limit per call with pagination via next_offset - Removed multi-file reads (now single file per call) - Removed user approval workflow (direct execution) - Removed image processing (to be added back in follow-up) This implements the spec from Linear issue EXT-617 for line-based pagination, reliable continuation via offset/limit, and bounded output for context budget management. Note: This is a work-in-progress draft. Tests and additional features need to be updated/added: - All existing tests need rewrite (300+ references to old API) - Image handling needs to be re-implemented - UI approval workflow needs removal - Binary file handling (PDF/DOCX) needs re-implementation --- packages/types/src/tool-params.ts | 51 + src/core/prompts/tools/native-tools/index.ts | 28 +- .../prompts/tools/native-tools/read_file.ts | 140 +-- src/core/task/build-tools.ts | 14 +- src/core/tools/ReadFileTool.ts | 894 ++++++------------ src/shared/tools.ts | 5 +- 6 files changed, 400 insertions(+), 732 deletions(-) diff --git a/packages/types/src/tool-params.ts b/packages/types/src/tool-params.ts index f8708b0c2b4..d75564fe2ac 100644 --- a/packages/types/src/tool-params.ts +++ b/packages/types/src/tool-params.ts @@ -12,6 +12,57 @@ export interface FileEntry { lineRanges?: LineRange[] } +/** + * read_file tool input parameters (new spec) + */ +export interface ReadFileInput { + file_path: string + offset?: number + limit?: number + format?: "cat_n" + max_chars_per_line?: number +} + +/** + * read_file tool success output + */ +export interface ReadFileSuccess { + ok: true + file_path: string + resolved_path: string + mime_type: string + encoding: string | null + line_offset: number + lines_returned: number + reached_eof: boolean + truncated: boolean + truncation_reason?: "limit" | "max_chars_per_line" | "max_total_chars" | "binary_policy" + next_offset: number | null + content: string + warnings: string[] +} + +/** + * read_file tool error output + */ +export interface ReadFileError { + ok: false + error: { + code: + | "file_not_found" + | "permission_denied" + | "outside_workspace" + | "is_directory" + | "unsupported_mime_type" + | "decode_failed" + | "io_error" + message: string + details?: Record + } +} + +export type ReadFileOutput = ReadFileSuccess | ReadFileError + export interface Coordinate { x: number y: number diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 4f78729cdc8..3327ae0e4dc 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -11,7 +11,7 @@ import fetchInstructions from "./fetch_instructions" import generateImage from "./generate_image" import listFiles from "./list_files" import newTask from "./new_task" -import { createReadFileTool, type ReadFileToolOptions } from "./read_file" +import { createReadFileTool } from "./read_file" import runSlashCommand from "./run_slash_command" import searchAndReplace from "./search_and_replace" import searchReplace from "./search_replace" @@ -23,35 +23,21 @@ import writeToFile from "./write_to_file" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" -export type { ReadFileToolOptions } from "./read_file" /** * Options for customizing the native tools array. + * Currently empty but reserved for future tool configuration. */ -export interface NativeToolsOptions { - /** Whether to include line_ranges support in read_file tool (default: true) */ - partialReadsEnabled?: boolean - /** Maximum number of files that can be read in a single read_file request (default: 5) */ - maxConcurrentFileReads?: number - /** Whether the model supports image processing (default: false) */ - supportsImages?: boolean -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface NativeToolsOptions {} /** - * Get native tools array, optionally customizing based on settings. + * Get native tools array. * - * @param options - Configuration options for the tools + * @param options - Configuration options for the tools (currently unused) * @returns Array of native tool definitions */ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.ChatCompletionTool[] { - const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options - - const readFileOptions: ReadFileToolOptions = { - partialReadsEnabled, - maxConcurrentFileReads, - supportsImages, - } - return [ accessMcpResource, apply_diff, @@ -65,7 +51,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch generateImage, listFiles, newTask, - createReadFileTool(readFileOptions), + createReadFileTool(), runSlashCommand, searchAndReplace, searchReplace, diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index 7171be0f1d6..f54f9baf193 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,97 +1,41 @@ import type OpenAI from "openai" /** - * Generates the file support note, optionally including image format support. + * Creates the read_file tool definition following the paginated read spec. * - * @param supportsImages - Whether the model supports image processing - * @returns Support note string - */ -function getReadFileSupportsNote(supportsImages: boolean): string { - if (supportsImages) { - return `Supports text extraction from PDF and DOCX files. Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis. May not handle other binary files properly.` - } - return `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` -} - -/** - * Options for creating the read_file tool definition. - */ -export interface ReadFileToolOptions { - /** Whether to include line_ranges parameter (default: true) */ - partialReadsEnabled?: boolean - /** Maximum number of files that can be read in a single request (default: 5) */ - maxConcurrentFileReads?: number - /** Whether the model supports image processing (default: false) */ - supportsImages?: boolean -} - -/** - * Creates the read_file tool definition, optionally including line_ranges support - * based on whether partial reads are enabled. + * Single-file reads with line-based pagination, stable line numbering, + * and bounded output to stay within context budgets. * - * @param options - Configuration options for the tool * @returns Native tool definition for read_file */ -export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Chat.ChatCompletionTool { - const { partialReadsEnabled = true, maxConcurrentFileReads = 5, supportsImages = false } = options - const isMultipleReadsEnabled = maxConcurrentFileReads > 1 +export function createReadFileTool(): OpenAI.Chat.ChatCompletionTool { + const description = `Request to read a file with line-based pagination. Returns at most 2000 lines per call (configurable via limit parameter). Use offset parameter to read subsequent chunks. - // Build description intro with concurrent reads limit message - const descriptionIntro = isMultipleReadsEnabled - ? `Read one or more files and return their contents with line numbers for diffing or discussion. IMPORTANT: You can read a maximum of ${maxConcurrentFileReads} files in a single request. If you need to read more files, use multiple sequential read_file requests. ` - : "Read a file and return its contents with line numbers for diffing or discussion. IMPORTANT: Multiple file reads are currently disabled. You can only read one file at a time. " +Path Resolution and Sandbox: +- file_path is required and must be relative to workspace root +- Paths are resolved to absolute and canonicalized +- Access is restricted to workspace root (sandbox enforcement) +- Directories are rejected - const baseDescription = - descriptionIntro + - "Structure: { files: [{ path: 'relative/path.ts'" + - (partialReadsEnabled ? ", line_ranges: [[1, 50], [100, 150]]" : "") + - " }] }. " + - "The 'path' is required and relative to workspace. " +Pagination: +- offset (default: 0): 0-based line offset. offset=0 starts at file line 1 +- limit (default: 2000): Maximum lines per call (hard cap) +- When reached_eof=false in response, continue with offset=next_offset +- Line numbers are file-global and stable across chunks - const optionalRangesDescription = partialReadsEnabled - ? "The 'line_ranges' is optional for reading specific sections. Each range is a [start, end] tuple (1-based inclusive). " - : "" +Output Format: +- format (default: "cat_n"): Returns cat -n style with right-aligned line numbers +- max_chars_per_line (default: 2000): Truncates lines exceeding this limit +- Binary files return error with code "unsupported_mime_type" - const examples = partialReadsEnabled - ? "Example single file: { files: [{ path: 'src/app.ts' }] }. " + - "Example with line ranges: { files: [{ path: 'src/app.ts', line_ranges: [[1, 50], [100, 150]] }] }. " + - (isMultipleReadsEnabled - ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts', line_ranges: [[1, 50]] }, { path: 'file2.ts' }] }` - : "") - : "Example single file: { files: [{ path: 'src/app.ts' }] }. " + - (isMultipleReadsEnabled - ? `Example multiple files (within ${maxConcurrentFileReads}-file limit): { files: [{ path: 'file1.ts' }, { path: 'file2.ts' }] }` - : "") +Example: Read first chunk of file: +{ "file_path": "src/main.ts" } - const description = - baseDescription + optionalRangesDescription + getReadFileSupportsNote(supportsImages) + " " + examples +Example: Read next chunk using pagination: +{ "file_path": "src/main.ts", "offset": 2000 } - // Build the properties object conditionally - const fileProperties: Record = { - path: { - type: "string", - description: "Path to the file to read, relative to the workspace", - }, - } - - // Only include line_ranges if partial reads are enabled - if (partialReadsEnabled) { - fileProperties.line_ranges = { - type: ["array", "null"], - description: - "Optional line ranges to read. Each range is a [start, end] tuple with 1-based inclusive line numbers. Use multiple ranges for non-contiguous sections.", - items: { - type: "array", - items: { type: "integer" }, - minItems: 2, - maxItems: 2, - }, - } - } - - // When using strict mode, ALL properties must be in the required array - // Optional properties are handled by having type: ["...", "null"] - const fileRequiredProperties = partialReadsEnabled ? ["path", "line_ranges"] : ["path"] +Example: Read specific section with custom limit: +{ "file_path": "src/main.ts", "offset": 100, "limit": 50 }` return { type: "function", @@ -102,23 +46,33 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch parameters: { type: "object", properties: { - files: { - type: "array", - description: "List of files to read; request related files together when allowed", - items: { - type: "object", - properties: fileProperties, - required: fileRequiredProperties, - additionalProperties: false, - }, - minItems: 1, + file_path: { + type: "string", + description: "Path to the file to read, relative to workspace root (required)", + }, + offset: { + type: ["integer", "null"], + description: "0-based line offset. offset=0 starts at file line 1 (default: 0)", + }, + limit: { + type: ["integer", "null"], + description: "Maximum number of lines to return (default: 2000, hard cap enforced)", + }, + format: { + type: ["string", "null"], + description: 'Output format, currently only "cat_n" supported (default: "cat_n")', + enum: ["cat_n", null], + }, + max_chars_per_line: { + type: ["integer", "null"], + description: "Maximum characters per line before truncation (default: 2000)", }, }, - required: ["files"], + required: ["file_path", "offset", "limit", "format", "max_chars_per_line"], additionalProperties: false, }, }, } satisfies OpenAI.Chat.ChatCompletionTool } -export const read_file = createReadFileTool({ partialReadsEnabled: false }) +export const read_file = createReadFileTool() diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 46896d050b6..9b444cc83f1 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -109,18 +109,8 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO modelInfo, } - // Determine if partial reads are enabled based on maxReadFileLine setting. - const partialReadsEnabled = maxReadFileLine !== -1 - - // Check if the model supports images for read_file tool description. - const supportsImages = modelInfo?.supportsImages ?? false - - // Build native tools with dynamic read_file tool based on settings. - const nativeTools = getNativeTools({ - partialReadsEnabled, - maxConcurrentFileReads, - supportsImages, - }) + // Build native tools. + const nativeTools = getNativeTools() // Filter native tools based on mode restrictions. const filteredNativeTools = filterNativeToolsForMode( diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 1e20ac5cb32..98b11c99c7c 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -1,652 +1,338 @@ import path from "path" import * as fs from "fs/promises" +import { createReadStream } from "fs" +import { createInterface } from "readline" import { isBinaryFile } from "isbinaryfile" -import type { FileEntry, LineRange } from "@roo-code/types" -import { type ClineSayTool, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" +import type { ReadFileInput, ReadFileOutput, ReadFileSuccess } from "@roo-code/types" import { Task } from "../task/Task" -import { formatResponse } from "../prompts/responses" -import { getModelMaxOutputTokens } from "../../shared/api" -import { t } from "../../i18n" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { getReadablePath } from "../../utils/path" -import { countFileLines } from "../../integrations/misc/line-counter" -import { readLines } from "../../integrations/misc/read-lines" -import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" -import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import type { ToolUse } from "../../shared/tools" - -import { - DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - isSupportedImageFormat, - validateImageForProcessing, - processImageFile, - ImageMemoryTracker, -} from "./helpers/imageHelpers" -import { FILE_READ_BUDGET_PERCENT, readFileWithTokenBudget } from "./helpers/fileTokenBudget" -import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { BaseTool, ToolCallbacks } from "./BaseTool" -interface FileResult { - path: string - status: "approved" | "denied" | "blocked" | "error" | "pending" - content?: string - error?: string - notice?: string - lineRanges?: LineRange[] - nativeContent?: string - imageDataUrl?: string - feedbackText?: string - feedbackImages?: any[] +// Default constants matching spec +const DEFAULT_OFFSET = 0 +const DEFAULT_LIMIT = 2000 +const DEFAULT_FORMAT = "cat_n" +const DEFAULT_MAX_CHARS_PER_LINE = 2000 +const MAX_TOTAL_CHARS = 10_000_000 // 10MB text budget + +/** + * Format line number with right alignment (cat -n style) + * @param lineNum The 1-based line number + * @param maxLineNum The maximum line number in this chunk (for width calculation) + * @returns Formatted line number string + */ +function formatLineNumber(lineNum: number, maxLineNum: number): string { + const width = String(maxLineNum).length + return String(lineNum).padStart(width, " ") +} + +/** + * Truncate a line if it exceeds max length + * @param line The line content + * @param maxChars Maximum characters allowed + * @returns Truncated line with marker if truncated + */ +function truncateLine(line: string, maxChars: number): { line: string; truncated: boolean } { + if (line.length <= maxChars) { + return { line, truncated: false } + } + return { line: line.substring(0, maxChars) + "… [line truncated]", truncated: true } +} + +/** + * Read file with streaming and pagination support + * @param filePath Absolute path to file + * @param offset 0-based line offset + * @param limit Maximum lines to read + * @param maxCharsPerLine Maximum characters per line + * @returns Object with content, metadata, and pagination info + */ +async function readFileWithPagination( + filePath: string, + offset: number, + limit: number, + maxCharsPerLine: number, +): Promise<{ + lines: string[] + reachedEof: boolean + linesTruncated: boolean + totalCharsTruncated: boolean +}> { + const lines: string[] = [] + let currentLine = 0 + let reachedEof = true + let linesTruncated = false + let totalChars = 0 + let totalCharsTruncated = false + + const fileStream = createReadStream(filePath, { encoding: "utf8" }) + const rl = createInterface({ + input: fileStream, + crlfDelay: Infinity, + }) + + try { + for await (const line of rl) { + // Skip lines before offset + if (currentLine < offset) { + currentLine++ + continue + } + + // Check if we've reached the limit + if (lines.length >= limit) { + reachedEof = false + break + } + + // Check total chars budget + if (totalChars + line.length > MAX_TOTAL_CHARS) { + totalCharsTruncated = true + reachedEof = false + break + } + + // Truncate line if needed + const { line: truncatedLine, truncated } = truncateLine(line, maxCharsPerLine) + if (truncated) { + linesTruncated = true + } + + lines.push(truncatedLine) + totalChars += truncatedLine.length + currentLine++ + } + } finally { + rl.close() + fileStream.destroy() + } + + return { lines, reachedEof, linesTruncated, totalCharsTruncated } +} + +/** + * Format lines with cat -n style line numbers + * @param lines Array of line content + * @param startLine 1-based starting line number + * @returns Formatted content string + */ +function formatWithLineNumbers(lines: string[], startLine: number): string { + if (lines.length === 0) { + return "" + } + + const endLine = startLine + lines.length - 1 + const maxLineNum = endLine + + return lines + .map((line, index) => { + const lineNum = startLine + index + return `${formatLineNumber(lineNum, maxLineNum)} ${line}` + }) + .join("\n") } export class ReadFileTool extends BaseTool<"read_file"> { readonly name = "read_file" as const - async execute(params: { files: FileEntry[] }, task: Task, callbacks: ToolCallbacks): Promise { - const { handleError, pushToolResult } = callbacks - const fileEntries = params.files - const modelInfo = task.api.getModel().info - const useNative = true - - if (!fileEntries || fileEntries.length === 0) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = await task.sayAndCreateMissingParamError("read_file", "files") - const errorResult = `Error: ${errorMsg}` - pushToolResult(errorResult) + async execute(params: ReadFileInput, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks + + // Extract and validate parameters + const filePath = params.file_path + const offset = params.offset ?? DEFAULT_OFFSET + const limit = params.limit ?? DEFAULT_LIMIT + const format = params.format ?? DEFAULT_FORMAT + const maxCharsPerLine = params.max_chars_per_line ?? DEFAULT_MAX_CHARS_PER_LINE + + // Validate offset and limit + if (offset < 0) { + const error: ReadFileOutput = { + ok: false, + error: { + code: "io_error", + message: "Invalid offset: must be >= 0", + details: { offset }, + }, + } + pushToolResult(JSON.stringify(error, null, 2)) return } - // Enforce maxConcurrentFileReads limit - const { maxConcurrentFileReads = 5 } = (await task.providerRef.deref()?.getState()) ?? {} - if (fileEntries.length > maxConcurrentFileReads) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = `Too many files requested. You attempted to read ${fileEntries.length} files, but the concurrent file reads limit is ${maxConcurrentFileReads}. Please read files in batches of ${maxConcurrentFileReads} or fewer.` - await task.say("error", errorMsg) - const errorResult = `Error: ${errorMsg}` - pushToolResult(errorResult) + if (limit <= 0 || limit > DEFAULT_LIMIT) { + const error: ReadFileOutput = { + ok: false, + error: { + code: "io_error", + message: `Invalid limit: must be between 1 and ${DEFAULT_LIMIT}`, + details: { limit, max: DEFAULT_LIMIT }, + }, + } + pushToolResult(JSON.stringify(error, null, 2)) return } - const supportsImages = modelInfo.supportsImages ?? false - - const fileResults: FileResult[] = fileEntries.map((entry) => ({ - path: entry.path, - status: "pending", - lineRanges: entry.lineRanges, - })) - - const updateFileResult = (filePath: string, updates: Partial) => { - const index = fileResults.findIndex((result) => result.path === filePath) - if (index !== -1) { - fileResults[index] = { ...fileResults[index], ...updates } + // Resolve path + const fullPath = path.resolve(task.cwd, filePath) + const resolvedPath = await fs.realpath(fullPath).catch(() => fullPath) + + // Check workspace sandbox + if (isPathOutsideWorkspace(resolvedPath)) { + const error: ReadFileOutput = { + ok: false, + error: { + code: "outside_workspace", + message: `Access denied: path is outside workspace root`, + details: { requested_path: filePath, resolved_path: resolvedPath }, + }, } + pushToolResult(JSON.stringify(error, null, 2)) + task.didToolFailInCurrentTurn = true + return } try { - const filesToApprove: FileResult[] = [] - - for (const fileResult of fileResults) { - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - - if (fileResult.lineRanges) { - let hasRangeError = false - for (const range of fileResult.lineRanges) { - if (range.start > range.end) { - const errorMsg = "Invalid line range: end line cannot be less than start line" - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - hasRangeError = true - break - } - if (isNaN(range.start) || isNaN(range.end)) { - const errorMsg = "Invalid line range values" - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - hasRangeError = true - break - } - } - if (hasRangeError) continue - } - - if (fileResult.status === "pending") { - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await task.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - continue - } - - filesToApprove.push(fileResult) - } - } - - if (filesToApprove.length > 1) { - const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} - - const batchFiles = filesToApprove.map((fileResult) => { - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - - let lineSnippet = "" - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const ranges = fileResult.lineRanges.map((range) => - t("tools:readFile.linesRange", { start: range.start, end: range.end }), - ) - lineSnippet = ranges.join(", ") - } else if (maxReadFileLine === 0) { - lineSnippet = t("tools:readFile.definitionsOnly") - } else if (maxReadFileLine > 0) { - lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) - } - - const readablePath = getReadablePath(task.cwd, relPath) - const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` - - return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath } - }) - - const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response === "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "approved", - feedbackText: text, - feedbackImages: images, - }) - }) - } else if (response === "noButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "denied", - nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - }) - } else { - try { - const individualPermissions = JSON.parse(text || "{}") - let hasAnyDenial = false - - batchFiles.forEach((batchFile, index) => { - const fileResult = filesToApprove[index] - const approved = individualPermissions[batchFile.key] === true - - if (approved) { - updateFileResult(fileResult.path, { status: "approved" }) - } else { - hasAnyDenial = true - updateFileResult(fileResult.path, { - status: "denied", - nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, - }) - } - }) - - if (hasAnyDenial) task.didRejectTool = true - } catch (error) { - console.error("Failed to parse individual permissions:", error) - task.didRejectTool = true - filesToApprove.forEach((fileResult) => { - updateFileResult(fileResult.path, { - status: "denied", - nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, - }) - }) - } - } - } else if (filesToApprove.length === 1) { - const fileResult = filesToApprove[0] - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {} - - let lineSnippet = "" - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const ranges = fileResult.lineRanges.map((range) => - t("tools:readFile.linesRange", { start: range.start, end: range.end }), - ) - lineSnippet = ranges.join(", ") - } else if (maxReadFileLine === 0) { - lineSnippet = t("tools:readFile.definitionsOnly") - } else if (maxReadFileLine > 0) { - lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine }) - } - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(task.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet, - } satisfies ClineSayTool) - - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - updateFileResult(relPath, { - status: "denied", - nativeContent: `File: ${relPath}\nStatus: Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - } else { - if (text) await task.say("user_feedback", text, images) - updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) + // Check if file exists and get stats + const stats = await fs.stat(resolvedPath) + + // Reject directories + if (stats.isDirectory()) { + const error: ReadFileOutput = { + ok: false, + error: { + code: "is_directory", + message: `Cannot read directory. Use list_files tool to view directory contents.`, + details: { path: filePath }, + }, } + pushToolResult(JSON.stringify(error, null, 2)) + task.didToolFailInCurrentTurn = true + return } - const imageMemoryTracker = new ImageMemoryTracker() - const state = await task.providerRef.deref()?.getState() - const { - maxReadFileLine = -1, - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - - for (const fileResult of fileResults) { - if (fileResult.status !== "approved") continue - - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - - try { - // Check if the path is a directory before attempting to read it - const stats = await fs.stat(fullPath) - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory. To view the contents of a directory, use the list_files tool instead.` - updateFileResult(relPath, { - status: "error", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - continue - } - - const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) - - if (isBinary) { - const fileExtension = path.extname(relPath).toLowerCase() - const supportedBinaryFormats = getSupportedBinaryFormats() - - if (isSupportedImageFormat(fileExtension)) { - try { - const validationResult = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker.getTotalMemoryUsed(), - ) - - if (!validationResult.isValid) { - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nNote: ${validationResult.notice}`, - }) - continue - } - - const imageResult = await processImageFile(fullPath) - imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nNote: ${imageResult.notice}`, - imageDataUrl: imageResult.dataUrl, - }) - continue - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading image file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error reading image file: ${errorMsg}`, - }) - await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`) - continue - } - } - - if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { - // Use extractTextFromFile for supported binary formats (PDF, DOCX, etc.) - try { - const content = await extractTextFromFile(fullPath) - const numberedContent = addLineNumbers(content) - const lines = content.split("\n") - const lineCount = lines.length - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: - lineCount > 0 - ? `File: ${relPath}\nLines 1-${lineCount}:\n${numberedContent}` - : `File: ${relPath}\nNote: File is empty`, - }) - continue - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error extracting text: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error extracting text: ${errorMsg}`, - }) - await task.say("error", `Error extracting text from ${relPath}: ${errorMsg}`) - continue - } - } else { - const fileFormat = fileExtension.slice(1) || "bin" - updateFileResult(relPath, { - notice: `Binary file format: ${fileFormat}`, - nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`, - }) - continue - } - } - - if (fileResult.lineRanges && fileResult.lineRanges.length > 0) { - const nativeRangeResults: string[] = [] - - for (const range of fileResult.lineRanges) { - const content = addLineNumbers( - await readLines(fullPath, range.end - 1, range.start - 1), - range.start, - ) - nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${content}`) - } - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${nativeRangeResults.join("\n\n")}`, - }) - continue - } - - if (maxReadFileLine === 0) { - try { - const defResult = await parseSourceCodeDefinitionsForFile( - fullPath, - task.rooIgnoreController, - ) - if (defResult) { - const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines` - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nCode Definitions:\n${defResult}\n\nNote: ${notice}`, - }) - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - continue - } - - if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { - const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) - let toolInfo = `Lines 1-${maxReadFileLine}:\n${content}\n` - - try { - const defResult = await parseSourceCodeDefinitionsForFile( - fullPath, - task.rooIgnoreController, - ) - if (defResult) { - const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine) - toolInfo += `\nCode Definitions:\n${truncatedDefs}\n` - } - - const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines` - toolInfo += `\nNote: ${notice}` - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${toolInfo}`, - }) - } catch (error) { - if (error instanceof Error && error.message.startsWith("Unsupported language:")) { - console.warn(`[read_file] Warning: ${error.message}`) - } else { - console.error( - `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - continue - } - - const { id: modelId, info: modelInfo } = task.api.getModel() - const { contextTokens } = task.getTokenUsage() - const contextWindow = modelInfo.contextWindow - - const maxOutputTokens = - getModelMaxOutputTokens({ - modelId, - model: modelInfo, - settings: task.apiConfiguration, - }) ?? ANTHROPIC_DEFAULT_MAX_TOKENS - - // Calculate available token budget (60% of remaining context) - const remainingTokens = contextWindow - maxOutputTokens - (contextTokens || 0) - const safeReadBudget = Math.floor(remainingTokens * FILE_READ_BUDGET_PERCENT) - - let toolInfo = "" - - if (safeReadBudget <= 0) { - // No budget available - const notice = "No available context budget for file reading" - toolInfo = `Note: ${notice}` - } else { - // Read file with incremental token counting - const result = await readFileWithTokenBudget(fullPath, { - budgetTokens: safeReadBudget, - }) - - const content = addLineNumbers(result.content) - - if (!result.complete) { - // File was truncated - const notice = `File truncated: showing ${result.lineCount} lines (${result.tokenCount} tokens) due to context budget. Use line_range to read specific sections.` - toolInfo = - result.lineCount > 0 - ? `Lines 1-${result.lineCount}:\n${content}\n\nNote: ${notice}` - : `Note: ${notice}` - } else { - // Full file read - if (result.lineCount === 0) { - toolInfo = "Note: File is empty" - } else { - toolInfo = `Lines 1-${result.lineCount}:\n${content}` - } - } - } - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${toolInfo}`, - }) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) + // Check if file is binary + const isBinary = await isBinaryFile(resolvedPath) + if (isBinary) { + const error: ReadFileOutput = { + ok: false, + error: { + code: "unsupported_mime_type", + message: `Binary files are not supported by read_file tool`, + details: { path: filePath }, + }, } + pushToolResult(JSON.stringify(error, null, 2)) + task.didToolFailInCurrentTurn = true + return } - // Check if any files had errors or were blocked and mark the turn as failed - const hasErrors = fileResults.some((result) => result.status === "error" || result.status === "blocked") - if (hasErrors) { - task.didToolFailInCurrentTurn = true + // Detect MIME type (simple extension-based detection) + const ext = path.extname(resolvedPath).toLowerCase() + const mimeType = + ext === ".ts" || ext === ".tsx" + ? "text/typescript" + : ext === ".js" || ext === ".jsx" + ? "text/javascript" + : ext === ".json" + ? "application/json" + : ext === ".md" + ? "text/markdown" + : ext === ".html" + ? "text/html" + : ext === ".css" + ? "text/css" + : "text/plain" + const encoding = "utf-8" + + // Read file with pagination + const { lines, reachedEof, linesTruncated, totalCharsTruncated } = await readFileWithPagination( + resolvedPath, + offset, + limit, + maxCharsPerLine, + ) + + const linesReturned = lines.length + const lineOffset = offset + const nextOffset = reachedEof ? null : offset + linesReturned + + // Format content based on format parameter + let content = "" + if (format === "cat_n" && linesReturned > 0) { + const startLine = lineOffset + 1 // Convert 0-based offset to 1-based line number + content = formatWithLineNumbers(lines, startLine) } - // Build final result - const finalResult = fileResults - .filter((result) => result.nativeContent) - .map((result) => result.nativeContent) - .join("\n\n---\n\n") - - const fileImageUrls = fileResults - .filter((result) => result.imageDataUrl) - .map((result) => result.imageDataUrl as string) - - let statusMessage = "" - let feedbackImages: any[] = [] - - const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText) - - if (deniedWithFeedback && deniedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) - feedbackImages = deniedWithFeedback.feedbackImages || [] - } else if (task.didRejectTool) { - statusMessage = formatResponse.toolDenied() - } else { - const approvedWithFeedback = fileResults.find( - (result) => result.status === "approved" && result.feedbackText, - ) - - if (approvedWithFeedback && approvedWithFeedback.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - feedbackImages = approvedWithFeedback.feedbackImages || [] - } + // Determine truncation status + const truncated = !reachedEof || linesTruncated || totalCharsTruncated + let truncationReason: ReadFileSuccess["truncation_reason"] + if (totalCharsTruncated) { + truncationReason = "max_total_chars" + } else if (linesTruncated) { + truncationReason = "max_chars_per_line" + } else if (!reachedEof) { + truncationReason = "limit" } - const allImages = [...feedbackImages, ...fileImageUrls] - - const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false - const imagesToInclude = finalModelSupportsImages ? allImages : [] - - if (statusMessage || imagesToInclude.length > 0) { - const result = formatResponse.toolResult( - statusMessage || finalResult, - imagesToInclude.length > 0 ? imagesToInclude : undefined, - ) - - if (typeof result === "string") { - if (statusMessage) { - pushToolResult(`${result}\n${finalResult}`) - } else { - pushToolResult(result) - } - } else { - if (statusMessage) { - const textBlock = { type: "text" as const, text: finalResult } - pushToolResult([...result, textBlock]) - } else { - pushToolResult(result) - } - } - } else { - pushToolResult(finalResult) + // Build warnings array + const warnings: string[] = [] + if (linesTruncated) { + warnings.push(`Some lines exceeded ${maxCharsPerLine} characters and were truncated`) } - } catch (error) { - const relPath = fileEntries[0]?.path || "unknown" - const errorMsg = error instanceof Error ? error.message : String(error) - - if (fileResults.length > 0) { - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`, - }) + if (totalCharsTruncated) { + warnings.push(`Total character budget of ${MAX_TOTAL_CHARS} exceeded, read stopped early`) } - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - - // Mark that a tool failed in this turn - task.didToolFailInCurrentTurn = true - - const errorResult = fileResults - .filter((result) => result.nativeContent) - .map((result) => result.nativeContent) - .join("\n\n---\n\n") + // Build success response + const success: ReadFileOutput = { + ok: true, + file_path: filePath, + resolved_path: resolvedPath, + mime_type: mimeType, + encoding, + line_offset: lineOffset, + lines_returned: linesReturned, + reached_eof: reachedEof, + truncated, + truncation_reason: truncationReason, + next_offset: nextOffset, + content, + warnings, + } - pushToolResult(errorResult) - } - } + pushToolResult(JSON.stringify(success, null, 2)) + } catch (error: any) { + // Handle various error cases + let errorCode: "file_not_found" | "permission_denied" | "io_error" = "io_error" + let message = error.message || String(error) + + if (error.code === "ENOENT") { + errorCode = "file_not_found" + message = `File not found: ${filePath}` + } else if (error.code === "EACCES" || error.code === "EPERM") { + errorCode = "permission_denied" + message = `Permission denied: ${filePath}` + } - getReadFileToolDescription(blockName: string, blockParams: any): string - getReadFileToolDescription(blockName: string, nativeArgs: { files: FileEntry[] }): string - getReadFileToolDescription(blockName: string, second: any): string { - // If native typed args ({ files: FileEntry[] }) were provided - if (second && typeof second === "object" && "files" in second && Array.isArray(second.files)) { - const paths = (second.files as FileEntry[]).map((f) => f?.path).filter(Boolean) as string[] - if (paths.length === 0) { - return `[${blockName} with no valid paths]` - } else if (paths.length === 1) { - return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` - } else if (paths.length <= 3) { - const pathList = paths.map((p) => `'${p}'`).join(", ") - return `[${blockName} for ${pathList}]` - } else { - return `[${blockName} for ${paths.length} files]` + const errorResponse: ReadFileOutput = { + ok: false, + error: { + code: errorCode, + message, + details: { path: filePath, error_code: error.code }, + }, } - } - const blockParams = second as any - if (blockParams?.path) { - return `[${blockName} for '${blockParams.path}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]` + pushToolResult(JSON.stringify(errorResponse, null, 2)) + task.didToolFailInCurrentTurn = true } - return `[${blockName} with missing files]` } override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { - let filePath = "" - if (block.nativeArgs && "files" in block.nativeArgs && Array.isArray(block.nativeArgs.files)) { - const files = block.nativeArgs.files - if (files.length > 0 && files[0]?.path) { - filePath = files[0].path - } - } - - const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" - const sharedMessageProps: ClineSayTool = { - tool: "readFile", - path: getReadablePath(task.cwd, filePath), - isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, - } - const partialMessage = JSON.stringify({ - ...sharedMessageProps, - content: undefined, - } satisfies ClineSayTool) - await task.ask("tool", partialMessage, block.partial).catch(() => {}) + // No-op for new implementation (no streaming UI needed for simple reads) } } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 66b058fceb5..0abda707374 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -8,6 +8,7 @@ import type { FileEntry, BrowserActionParams, GenerateImageParams, + ReadFileInput, } from "@roo-code/types" export type ToolResponse = string | Array @@ -82,7 +83,7 @@ export type ToolParamName = (typeof toolParamNames)[number] */ export type NativeToolArgs = { access_mcp_resource: { server_name: string; uri: string } - read_file: { files: FileEntry[] } + read_file: ReadFileInput attempt_completion: { result: string } execute_command: { command: string; cwd?: string } apply_diff: { path: string; diff: string } @@ -159,7 +160,7 @@ export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> { export interface ReadFileToolUse extends ToolUse<"read_file"> { name: "read_file" - params: Partial, "args" | "path" | "start_line" | "end_line" | "files">> + params: Partial, "file_path" | "args">> } export interface FetchInstructionsToolUse extends ToolUse<"fetch_instructions"> {