mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
813 lines
27 KiB
TypeScript
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()
|