Roo-Code/src/core/tools/ReadFileTool.ts
Hannes Rudolph b2b77809ff
Reapply Batch 1: 22 clean non-AI-SDK cherry-picks (#11473)
* fix: add image content support to MCP tool responses (#10874)

Co-authored-by: Roo Code <roomote@roocode.com>

* fix: transform tool blocks to text before condensing (EXT-624) (#10975)

* refactor(read_file): Codex-inspired read_file refactor EXT-617 (#10981)

* feat: allow import settings in initial welcome screen (#10994)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>

* fix(code-index): remove deprecated text-embedding-004 and migrate to gemini-embedding-001 (#11038)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>

* chore: treat extension .env as optional (#11116)

* fix: sanitize tool_use_id in tool_result blocks to match API history (#11131)

Tool IDs from providers like Gemini/OpenRouter contain special characters
(e.g., 'functions.read_file:0') that are sanitized when saving tool_use
blocks to API history. However, tool_result blocks were using the original
unsanitized IDs, causing ToolResultIdMismatchError.

This fix ensures tool_result blocks use sanitizeToolUseId() to match the
sanitized tool_use IDs in conversation history.

Fixes EXT-711

* fix: queue messages during command execution instead of losing them (#11140)

* IPC fixes for task cancellation and queued messages (#11162)

* feat: add support for AGENTS.local.md personal override files (#11183)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>

* fix(cli): resolve race condition causing provider switch during mode changes (#11205)

When using slash commands with `mode:` frontmatter (e.g., `/cli-release`
with `mode: code`), the CLI would fail with "Could not resolve
authentication method" from the Anthropic SDK, even when using a
non-Anthropic provider like `--provider roo`.

Root cause: In `markWebviewReady()`, the `webviewDidLaunch` message was
sent before `updateSettings`, creating a race condition. The
`webviewDidLaunch` handler's "first-time init" sync would read
`getState()` before CLI-provided settings were applied to the context
proxy. Since `getState()` defaults `apiProvider` to "anthropic" when
unset, this default was saved to the provider profile. When a slash
command triggered `handleModeSwitch()`, it found this corrupted profile
with `apiProvider: "anthropic"` (but no API key) and activated it,
overwriting the CLI's working roo provider configuration.

Fix:
1. Reorder `markWebviewReady()` to send `updateSettings` before
   `webviewDidLaunch`, ensuring the context proxy has CLI-provided
   values when the initialization handler runs.
2. Guard the first-time init sync with `checkExistKey(apiConfiguration)`
   to prevent saving a profile with only the default "anthropic"
   fallback and no actual API keys configured.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove dead toolFormat code from getEnvironmentDetails (#11207)

Remove the toolFormat constant and <tool_format> line from environment
details output. Native tool calling is now the only supported protocol,
making this code unnecessary.

Fixes #11206

Co-authored-by: Roo Code <roomote@roocode.com>

* feat: extract translation and merge resolver modes into reusable skills (#11215)

* feat: extract translation and merge resolver modes into reusable skills

- Add roo-translation skill with comprehensive i18n guidelines
- Add roo-conflict-resolution skill for intelligent merge conflict resolution
- Add /roo-translate slash command as shortcut for translation skill
- Add /roo-resolve-conflicts slash command as shortcut for conflict resolution skill

The existing translate and merge-resolver modes are preserved. These new skills
and commands provide reusable access to the same functionality.

Closes CLO-722

* feat: add guidances directory with translator guidance file

- Add .roo/guidances/roo-translator.md for brand voice, tone, and word choice guidance
- Update roo-translation skill to reference the guidance file

The guidance file serves as a placeholder for translation style guidelines
that will be interpolated at runtime.

* fix: rename guidances directory to guidance (singular)

* fix: remove language-specific section from translator guidance

The guidance file should focus on brand voice, tone, and word choice only.

* fix: remove language-specific guidelines section from skill file

* Update .roo/skills/roo-translation/SKILL.md

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Bruno Bergher <bruno@roocode.com>
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>

* feat: add Claude Opus 4.6 support across all providers (#11224)

* feat: add Claude Opus 4.6 support across all providers

Add Claude Opus 4.6 (claude-opus-4-6) model definitions and 1M context
support across Anthropic, Bedrock, Vertex AI, OpenRouter, and Vercel AI
Gateway providers.

- Anthropic: 128K max output, /5 pricing, 1M context tiers
- Bedrock: anthropic.claude-opus-4-6-v1:0 with 1M context + global inference
- Vertex: claude-opus-4-6 with 1M context tiers
- OpenRouter: prompt caching + reasoning budget sets
- Vercel AI Gateway: Opus 4.5 and 4.6 added to capability sets
- UI: 1M context checkbox for Opus 4.6 on all providers
- i18n: Updated 1M context descriptions across 18 locales

Also adds Opus 4.5 to Vercel AI Gateway (previously missing) and
OpenRouter maxTokens overrides for Opus 4.5/4.6.

Closes #11223

* fix: apply tier pricing when 1M context is enabled on Bedrock

When awsBedrock1MContext is enabled for tiered models like Opus 4.6,
also apply the 1M tier pricing (inputPrice, outputPrice, cache prices)
instead of only updating contextWindow. This ensures cost calculations
and UI display use the correct >200K rates.

* feat: add gpt-5.3-codex model to OpenAI Codex provider (#11225)

feat: add gpt-5.3-codex model and make it default for OpenAI Codex provider

Co-authored-by: Roo Code <roomote@roocode.com>

* fix: prevent parent task state loss during orchestrator delegation (#11281)

* fix: make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (#11302)

* fix: make removeClineFromStack() delegation-aware to prevent orphaned parent tasks

When a delegated child task is removed via removeClineFromStack() (e.g., Clear
Task, navigate to history, start new task), the parent task was left orphaned
in "delegated" status with a stale awaitingChildId. This made the parent
unresumable without manual history repair.

This fix captures parentTaskId and childTaskId before abort/dispose, then
repairs the parent metadata (status -> active, clear awaitingChildId) when
the popped task is a delegated child and awaitingChildId matches.

Parent lookup + updateTaskHistory are wrapped in try/catch so failures are
non-fatal (logged but do not block the pop).

Closes #11301

* fix: add skipDelegationRepair opt-out to removeClineFromStack() for nested delegation

---------

Co-authored-by: Roo Code <roomote@roocode.com>

* fix(reliability): prevent webview postMessage crashes and make dispose idempotent (#11313)

* fix(reliability): prevent webview postMessage crashes and make dispose idempotent

Closes: #11311

1. postMessageToWebview() now catches rejections from
   webview.postMessage() so that messages sent after the webview is
   disposed do not surface as unhandled promise rejections.

2. dispose() is guarded by a _disposed flag so that repeated calls
   (e.g. during rapid extension deactivation) are no-ops.

3. CloudService mock in ClineProvider.spec.ts updated to include
   off() — a pre-existing gap exposed by the new dispose test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add early _disposed check in postMessageToWebview

Skip the postMessage call entirely when the provider is already disposed,
avoiding unnecessary try/catch execution. Added test coverage for this path.

* chore: trigger CI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>

* fix: resolve race condition in new_task delegation that loses parent task history (#11331)

* fix: resolve race condition in new_task delegation that loses parent task history

When delegateParentAndOpenChild creates a child task via createTask(), the
Task constructor fires startTask() as a fire-and-forget async call. The child
immediately begins its task loop and eventually calls saveClineMessages() →
updateTaskHistory(), which reads globalState, modifies it, and writes back.

Meanwhile, delegateParentAndOpenChild persists the parent's delegation
metadata (status: 'delegated', delegatedToId, awaitingChildId, childIds) via
a separate updateTaskHistory() call AFTER createTask() returns.

These two concurrent read-modify-write operations on globalState race: the
last writer wins, overwriting the other's changes. When the child's write
lands last, the parent's delegation fields are lost, making the parent task
unresumable when the child finishes.

Fix: create the child task with startTask: false, persist the parent's
delegation metadata first, then manually call child.start(). This ensures
the parent metadata is safely in globalState before the child begins writing.

* docs: clarify Task.start() only handles new tasks, not history resume

* fix: serialize taskHistory writes and fix delegation status overwrite race (#11335)

Add a promise-chain mutex (withTaskHistoryLock) to serialize all
read-modify-write operations on taskHistory, preventing concurrent
interleaving from silently dropping entries.

Reorder reopenParentFromDelegation to close the child instance
before marking it completed, so the abort path's stale 'active'
status write no longer overwrites the 'completed' state.

Covered by new tests: RPD-04/05/06, UTH-02/04, and a full mutex
concurrency suite.

* Fix task resumption in the API module (#11369)

* chore: clean up repo-facing mode rules (#11410)

* fix: add maxReadFileLine to ExtensionState type for webview compatibility

---------

Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
Co-authored-by: Chris Estreich <cestreich@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Bruno Bergher <bruno@roocode.com>
Co-authored-by: 0xMink <dennis@dennismink.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
2026-02-14 13:47:12 -07:00

813 lines
27 KiB
TypeScript

/**
* ReadFileTool - Codex-inspired file reading with indentation mode support.
*
* Supports two modes:
* 1. Slice mode (default): Read contiguous lines with offset/limit
* 2. Indentation mode: Extract semantic code blocks based on indentation hierarchy
*
* Also supports legacy format for backward compatibility:
* - Legacy format: { files: [{ path: string, lineRanges?: [...] }] }
*/
import path from "path"
import * as fs from "fs/promises"
import { isBinaryFile } from "isbinaryfile"
import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineRange } from "@roo-code/types"
import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { getReadablePath } from "../../utils/path"
import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader"
import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
import type { ToolUse, PushToolResult } from "../../shared/tools"
import {
DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
isSupportedImageFormat,
validateImageForProcessing,
processImageFile,
ImageMemoryTracker,
} from "./helpers/imageHelpers"
import { BaseTool, ToolCallbacks } from "./BaseTool"
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Internal entry structure for tracking file read parameters.
*/
interface InternalFileEntry {
path: string
mode?: ReadFileMode
offset?: number
limit?: number
anchor_line?: number
max_levels?: number
include_siblings?: boolean
include_header?: boolean
max_lines?: number
}
interface FileResult {
path: string
status: "approved" | "denied" | "blocked" | "error" | "pending"
content?: string
error?: string
notice?: string
nativeContent?: string
imageDataUrl?: string
feedbackText?: string
feedbackImages?: string[]
// Store the original entry for mode processing
entry?: InternalFileEntry
}
// ─── Tool Implementation ──────────────────────────────────────────────────────
export class ReadFileTool extends BaseTool<"read_file"> {
readonly name = "read_file" as const
async execute(params: ReadFileToolParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
// Dispatch to legacy or new execution path based on format
if (isLegacyReadFileParams(params)) {
return this.executeLegacy(params.files, task, callbacks)
}
return this.executeNew(params, task, callbacks)
}
/**
* Execute new single-file format with slice/indentation mode support.
*/
private async executeNew(params: ReadFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { pushToolResult } = callbacks
const modelInfo = task.api.getModel().info
const filePath = params.path
// Validate input
if (!filePath) {
task.consecutiveMistakeCount++
task.recordToolError("read_file")
const errorMsg = await task.sayAndCreateMissingParamError("read_file", "path")
pushToolResult(`Error: ${errorMsg}`)
return
}
const supportsImages = modelInfo.supportsImages ?? false
// Initialize file results tracking
// Validate line number parameters (must be 1-indexed positive integers)
if (params.offset !== undefined && params.offset < 1) {
const errorMsg = `offset must be a 1-indexed line number (got ${params.offset}). Line numbers start at 1.`
pushToolResult(`Error: ${errorMsg}`)
return
}
if (params.indentation?.anchor_line !== undefined && params.indentation.anchor_line < 1) {
const errorMsg = `anchor_line must be a 1-indexed line number (got ${params.indentation.anchor_line}). Line numbers start at 1.`
pushToolResult(`Error: ${errorMsg}`)
return
}
const fileEntry: InternalFileEntry = {
path: filePath,
mode: params.mode,
offset: params.offset,
limit: params.limit,
anchor_line: params.indentation?.anchor_line,
max_levels: params.indentation?.max_levels,
include_siblings: params.indentation?.include_siblings,
include_header: params.indentation?.include_header,
max_lines: params.indentation?.max_lines,
}
const fileResults: FileResult[] = [
{
path: filePath,
status: "pending" as const,
entry: fileEntry,
},
]
const updateFileResult = (filePath: string, updates: Partial<FileResult>) => {
const index = fileResults.findIndex((result) => result.path === filePath)
if (index !== -1) {
fileResults[index] = { ...fileResults[index], ...updates }
}
}
try {
// Phase 1: Validate and filter files for approval
const filesToApprove: FileResult[] = []
for (const fileResult of fileResults) {
const relPath = fileResult.path
// RooIgnore validation
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)
}
// Phase 2: Request user approval
await this.requestApproval(task, filesToApprove, updateFileResult)
// Phase 3: Process approved files
const imageMemoryTracker = new ImageMemoryTracker()
const state = await task.providerRef.deref()?.getState()
const {
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)
const entry = fileResult.entry!
try {
// Check if path is a directory
const stats = await fs.stat(fullPath)
if (stats.isDirectory()) {
const errorMsg = `Cannot read '${relPath}' because it is a directory. Use list_files tool instead.`
updateFileResult(relPath, {
status: "error",
error: errorMsg,
nativeContent: `File: ${relPath}\nError: ${errorMsg}`,
})
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
continue
}
// Check for binary file
const isBinary = await isBinaryFile(fullPath)
if (isBinary) {
await this.handleBinaryFile(
task,
relPath,
fullPath,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
imageMemoryTracker,
updateFileResult,
)
continue
}
// Read text file content with lossy UTF-8 conversion
// Reading as Buffer first allows graceful handling of non-UTF8 bytes
// (they become U+FFFD replacement characters instead of throwing)
const buffer = await fs.readFile(fullPath)
const fileContent = buffer.toString("utf-8")
const result = this.processTextFile(fileContent, entry)
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
updateFileResult(relPath, {
nativeContent: `File: ${relPath}\n${result}`,
})
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
updateFileResult(relPath, {
status: "error",
error: `Error reading file: ${errorMsg}`,
nativeContent: `File: ${relPath}\nError: ${errorMsg}`,
})
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
}
}
// Phase 4: Build and return result
const hasErrors = fileResults.some((r) => r.status === "error" || r.status === "blocked")
if (hasErrors) {
task.didToolFailInCurrentTurn = true
}
this.buildAndPushResult(task, fileResults, pushToolResult)
} catch (error) {
const relPath = filePath || "unknown"
const errorMsg = error instanceof Error ? error.message : String(error)
updateFileResult(relPath, {
status: "error",
error: `Error reading file: ${errorMsg}`,
nativeContent: `File: ${relPath}\nError: ${errorMsg}`,
})
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
task.didToolFailInCurrentTurn = true
const errorResult = fileResults
.filter((r) => r.nativeContent)
.map((r) => r.nativeContent)
.join("\n\n---\n\n")
pushToolResult(errorResult || `Error: ${errorMsg}`)
}
}
/**
* Process a text file according to the requested mode.
*/
private processTextFile(content: string, entry: InternalFileEntry): string {
const mode = entry.mode || "slice"
if (mode === "indentation") {
// Indentation mode: semantic block extraction
// When anchor_line is not provided, default to offset (which defaults to 1)
const anchorLine = entry.anchor_line ?? entry.offset ?? 1
const result = readWithIndentation(content, {
anchorLine,
maxLevels: entry.max_levels,
includeSiblings: entry.include_siblings,
includeHeader: entry.include_header,
limit: entry.limit ?? DEFAULT_LINE_LIMIT,
maxLines: entry.max_lines,
})
let output = result.content
if (result.wasTruncated && result.includedRanges.length > 0) {
const [start, end] = result.includedRanges[0]
const nextOffset = end + 1
const effectiveLimit = entry.limit ?? DEFAULT_LINE_LIMIT
// Put truncation warning at TOP (before content) to match @ mention format
output = `IMPORTANT: File content truncated.
Status: Showing lines ${start}-${end} of ${result.totalLines} total lines.
To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}.
${result.content}`
} else if (result.includedRanges.length > 0) {
const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ")
output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)`
}
return output
}
// Slice mode (default): simple offset/limit reading
// NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice.
const offset1 = entry.offset ?? 1
const offset0 = Math.max(0, offset1 - 1)
const limit = entry.limit ?? DEFAULT_LINE_LIMIT
const result = readWithSlice(content, offset0, limit)
let output = result.content
if (result.wasTruncated) {
const startLine = offset1
const endLine = offset1 + result.returnedLines - 1
const nextOffset = endLine + 1
// Put truncation warning at TOP (before content) to match @ mention format
output = `IMPORTANT: File content truncated.
Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines.
To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}.
${result.content}`
} else if (result.returnedLines === 0) {
output = "Note: File is empty"
}
return output
}
/**
* Handle binary file processing (images, PDF, DOCX, etc.).
*/
private async handleBinaryFile(
task: Task,
relPath: string,
fullPath: string,
supportsImages: boolean,
maxImageFileSize: number,
maxTotalImageSize: number,
imageMemoryTracker: ImageMemoryTracker,
updateFileResult: (path: string, updates: Partial<FileResult>) => void,
): Promise<void> {
const fileExtension = path.extname(relPath).toLowerCase()
const supportedBinaryFormats = getSupportedBinaryFormats()
// Handle image files
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}`,
})
return
}
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,
})
return
} 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: ${errorMsg}`,
})
await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`)
return
}
}
// Handle other supported binary formats (PDF, DOCX, etc.)
if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) {
try {
const content = await extractTextFromFile(fullPath)
const numberedContent = addLineNumbers(content)
const lineCount = content.split("\n").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`,
})
return
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
updateFileResult(relPath, {
status: "error",
error: `Error extracting text: ${errorMsg}`,
nativeContent: `File: ${relPath}\nError: ${errorMsg}`,
})
await task.say("error", `Error extracting text from ${relPath}: ${errorMsg}`)
return
}
}
// Unsupported binary format
const fileFormat = fileExtension.slice(1) || "bin"
updateFileResult(relPath, {
notice: `Binary file format: ${fileFormat}`,
nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`,
})
}
/**
* Request user approval for file reads.
*/
private async requestApproval(
task: Task,
filesToApprove: FileResult[],
updateFileResult: (path: string, updates: Partial<FileResult>) => void,
): Promise<void> {
if (filesToApprove.length === 0) return
if (filesToApprove.length > 1) {
// Batch approval
const batchFiles = filesToApprove.map((fileResult) => {
const relPath = fileResult.path
const fullPath = path.resolve(task.cwd, relPath)
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
const readablePath = getReadablePath(task.cwd, relPath)
const lineSnippet = this.getLineSnippet(fileResult.entry!)
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((fr) => {
updateFileResult(fr.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((fr) => {
updateFileResult(fr.path, {
status: "denied",
nativeContent: `File: ${fr.path}\nStatus: Denied by user`,
feedbackText: text,
feedbackImages: images,
})
})
} else {
// Individual permissions
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 {
task.didRejectTool = true
filesToApprove.forEach((fr) => {
updateFileResult(fr.path, {
status: "denied",
nativeContent: `File: ${fr.path}\nStatus: Denied by user`,
})
})
}
}
} else {
// Single file approval
const fileResult = filesToApprove[0]
const relPath = fileResult.path
const fullPath = path.resolve(task.cwd, relPath)
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
const lineSnippet = this.getLineSnippet(fileResult.entry!)
const startLine = this.getStartLine(fileResult.entry!)
const completeMessage = JSON.stringify({
tool: "readFile",
path: getReadablePath(task.cwd, relPath),
isOutsideWorkspace,
content: fullPath,
reason: lineSnippet,
startLine,
} 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 })
}
}
}
/**
* Get the starting line number for navigation purposes.
*/
private getStartLine(entry: InternalFileEntry): number | undefined {
if (entry.mode === "indentation") {
// For indentation mode, always return the effective anchor line
return entry.anchor_line ?? entry.offset ?? 1
}
const offset = entry.offset ?? 1
return offset > 1 ? offset : undefined
}
/**
* Generate a human-readable line snippet for approval messages.
*/
private getLineSnippet(entry: InternalFileEntry): string {
if (entry.mode === "indentation") {
// Always show indentation mode with the effective anchor line
const effectiveAnchor = entry.anchor_line ?? entry.offset ?? 1
return `(indentation mode at line ${effectiveAnchor})`
}
const limit = entry.limit ?? DEFAULT_LINE_LIMIT
const offset1 = entry.offset ?? 1
if (offset1 > 1) {
return `(lines ${offset1}-${offset1 + limit - 1})`
}
// Always show the line limit, even when using the default
return `(up to ${limit} lines)`
}
/**
* Build and push the final result to the tool output.
*/
private buildAndPushResult(task: Task, fileResults: FileResult[], pushToolResult: PushToolResult): void {
const finalResult = fileResults
.filter((r) => r.nativeContent)
.map((r) => r.nativeContent)
.join("\n\n---\n\n")
const fileImageUrls = fileResults.filter((r) => r.imageDataUrl).map((r) => r.imageDataUrl as string)
let statusMessage = ""
let feedbackImages: string[] = []
const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText)
if (deniedWithFeedback?.feedbackText) {
statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText)
feedbackImages = deniedWithFeedback.feedbackImages || []
} else if (task.didRejectTool) {
statusMessage = formatResponse.toolDenied()
} else {
const approvedWithFeedback = fileResults.find((r) => r.status === "approved" && r.feedbackText)
if (approvedWithFeedback?.feedbackText) {
statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText)
feedbackImages = approvedWithFeedback.feedbackImages || []
}
}
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") {
pushToolResult(statusMessage ? `${result}\n${finalResult}` : result)
} else {
if (statusMessage) {
const textBlock = { type: "text" as const, text: finalResult }
pushToolResult([...result, textBlock] as any)
} else {
pushToolResult(result as any)
}
}
} else {
pushToolResult(finalResult)
}
}
getReadFileToolDescription(blockName: string, blockParams: { path?: string }): string
getReadFileToolDescription(blockName: string, nativeArgs: ReadFileParams): string
getReadFileToolDescription(blockName: string, second: unknown): string {
// If native typed args were provided
if (second && typeof second === "object" && "path" in second && typeof (second as any).path === "string") {
return `[${blockName} for '${(second as any).path}']`
}
const blockParams = second as Record<string, unknown>
if (blockParams?.path) {
return `[${blockName} for '${blockParams.path}']`
}
return `[${blockName} with missing path]`
}
override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise<void> {
// Handle both legacy and new format for partial display
let filePath = ""
if (block.nativeArgs) {
if (isLegacyReadFileParams(block.nativeArgs)) {
// Legacy format - show first file
filePath = block.nativeArgs.files[0]?.path ?? ""
} else {
filePath = block.nativeArgs.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(() => {})
}
/**
* Execute legacy multi-file format for backward compatibility.
* This handles the old format: { files: [{ path: string, lineRanges?: [...] }] }
*/
private async executeLegacy(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise<void> {
const { pushToolResult } = callbacks
const modelInfo = task.api.getModel().info
// Temporary indicator for testing legacy format detection
console.warn("[read_file] Legacy format detected - using backward compatibility path")
if (!fileEntries || fileEntries.length === 0) {
task.consecutiveMistakeCount++
task.recordToolError("read_file")
const errorMsg = await task.sayAndCreateMissingParamError("read_file", "files")
pushToolResult(`Error: ${errorMsg}`)
return
}
const supportsImages = modelInfo.supportsImages ?? false
// Process each file sequentially (legacy behavior)
const results: string[] = []
for (const entry of fileEntries) {
const relPath = entry.path
const fullPath = path.resolve(task.cwd, relPath)
// RooIgnore validation
const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await task.say("rooignore_error", relPath)
const errorMsg = formatResponse.rooIgnoreError(relPath)
results.push(`File: ${relPath}\nError: ${errorMsg}`)
continue
}
// Request approval for single file
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
let lineSnippet = ""
if (entry.lineRanges && entry.lineRanges.length > 0) {
const ranges = entry.lineRanges.map((range: LineRange) => `(lines ${range.start}-${range.end})`)
lineSnippet = ranges.join(", ")
}
const completeMessage = JSON.stringify({
tool: "readFile",
path: getReadablePath(task.cwd, relPath),
isOutsideWorkspace,
content: fullPath,
reason: lineSnippet || undefined,
} 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
results.push(`File: ${relPath}\nStatus: Denied by user`)
continue
}
if (text) await task.say("user_feedback", text, images)
try {
// Check if the path is a directory
const stats = await fs.stat(fullPath)
if (stats.isDirectory()) {
const errorMsg = `Cannot read '${relPath}' because it is a directory.`
results.push(`File: ${relPath}\nError: ${errorMsg}`)
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
continue
}
const isBinary = await isBinaryFile(fullPath).catch(() => false)
if (isBinary) {
// Handle binary files (images)
const fileExtension = path.extname(relPath).toLowerCase()
if (supportsImages && isSupportedImageFormat(fileExtension)) {
const state = await task.providerRef.deref()?.getState()
const {
maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
} = state ?? {}
const validation = await validateImageForProcessing(
fullPath,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
0, // Legacy path doesn't track cumulative memory
)
if (!validation.isValid) {
results.push(`File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}`)
continue
}
const imageResult = await processImageFile(fullPath)
if (imageResult) {
results.push(`File: ${relPath}\n[Image file - content processed for vision model]`)
}
} else {
results.push(`File: ${relPath}\nError: Cannot read binary file`)
}
continue
}
// Read text file
const rawContent = await fs.readFile(fullPath, "utf8")
// Handle line ranges if specified
let content: string
if (entry.lineRanges && entry.lineRanges.length > 0) {
const lines = rawContent.split("\n")
const selectedLines: string[] = []
for (const range of entry.lineRanges) {
// Convert to 0-based index, ranges are 1-based inclusive
const startIdx = Math.max(0, range.start - 1)
const endIdx = Math.min(lines.length - 1, range.end - 1)
for (let i = startIdx; i <= endIdx; i++) {
selectedLines.push(`${i + 1} | ${lines[i]}`)
}
}
content = selectedLines.join("\n")
} else {
// Read with default limits using slice mode
const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT)
content = result.content
if (result.wasTruncated) {
content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]`
}
}
results.push(`File: ${relPath}\n${content}`)
// Track file in context
await task.fileContextTracker.trackFileContext(relPath, "read_tool")
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
results.push(`File: ${relPath}\nError: ${errorMsg}`)
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
}
}
// Push combined results
pushToolResult(results.join("\n\n---\n\n"))
}
}
export const readFileTool = new ReadFileTool()