mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
- Add optional task_scoped parameter to write_to_file tool - Task-scoped files are stored within task context, not in project directory - Only .md and .markdown files can be task-scoped - Task-scoped files can be read using [task-scoped]/filename syntax - Add utilities for managing task-scoped files - Update tool documentation to explain task-scoped feature Fixes #9471
819 lines
31 KiB
TypeScript
819 lines
31 KiB
TypeScript
import path from "path"
|
|
import { isBinaryFile } from "isbinaryfile"
|
|
import type { FileEntry, LineRange } from "@roo-code/types"
|
|
import { isNativeProtocol } from "@roo-code/types"
|
|
|
|
import { Task } from "../task/Task"
|
|
import { ClineSayTool } from "../../shared/ExtensionMessage"
|
|
import { formatResponse } from "../prompts/responses"
|
|
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 { parseXml } from "../../utils/xml"
|
|
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
|
|
import {
|
|
DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
|
|
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
|
|
isSupportedImageFormat,
|
|
validateImageForProcessing,
|
|
processImageFile,
|
|
ImageMemoryTracker,
|
|
} from "./helpers/imageHelpers"
|
|
import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget"
|
|
import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions"
|
|
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
|
import type { ToolUse } from "../../shared/tools"
|
|
import { readTaskScopedFile } from "../task-persistence/taskScopedFiles"
|
|
|
|
interface FileResult {
|
|
path: string
|
|
status: "approved" | "denied" | "blocked" | "error" | "pending"
|
|
content?: string
|
|
error?: string
|
|
notice?: string
|
|
lineRanges?: LineRange[]
|
|
xmlContent?: string
|
|
nativeContent?: string
|
|
imageDataUrl?: string
|
|
feedbackText?: string
|
|
feedbackImages?: any[]
|
|
}
|
|
|
|
export class ReadFileTool extends BaseTool<"read_file"> {
|
|
readonly name = "read_file" as const
|
|
|
|
parseLegacy(params: Partial<Record<string, string>>): { files: FileEntry[] } {
|
|
const argsXmlTag = params.args
|
|
const legacyPath = params.path
|
|
const legacyStartLineStr = params.start_line
|
|
const legacyEndLineStr = params.end_line
|
|
|
|
const fileEntries: FileEntry[] = []
|
|
|
|
// XML args format
|
|
if (argsXmlTag) {
|
|
const parsed = parseXml(argsXmlTag) as any
|
|
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
|
|
|
|
for (const file of files) {
|
|
if (!file.path) continue
|
|
|
|
const fileEntry: FileEntry = {
|
|
path: file.path,
|
|
lineRanges: [],
|
|
}
|
|
|
|
if (file.line_range) {
|
|
const ranges = Array.isArray(file.line_range) ? file.line_range : [file.line_range]
|
|
for (const range of ranges) {
|
|
const match = String(range).match(/(\d+)-(\d+)/)
|
|
if (match) {
|
|
const [, start, end] = match.map(Number)
|
|
if (!isNaN(start) && !isNaN(end)) {
|
|
fileEntry.lineRanges?.push({ start, end })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fileEntries.push(fileEntry)
|
|
}
|
|
|
|
return { files: fileEntries }
|
|
}
|
|
|
|
// Legacy single file path
|
|
if (legacyPath) {
|
|
const fileEntry: FileEntry = {
|
|
path: legacyPath,
|
|
lineRanges: [],
|
|
}
|
|
|
|
if (legacyStartLineStr && legacyEndLineStr) {
|
|
const start = parseInt(legacyStartLineStr, 10)
|
|
const end = parseInt(legacyEndLineStr, 10)
|
|
if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) {
|
|
fileEntry.lineRanges?.push({ start, end })
|
|
}
|
|
}
|
|
fileEntries.push(fileEntry)
|
|
}
|
|
|
|
return { files: fileEntries }
|
|
}
|
|
|
|
async execute(params: { files: FileEntry[] }, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
|
const { handleError, pushToolResult, toolProtocol } = callbacks
|
|
const fileEntries = params.files
|
|
const modelInfo = task.api.getModel().info
|
|
const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo)
|
|
const useNative = isNativeProtocol(protocol)
|
|
|
|
if (!fileEntries || fileEntries.length === 0) {
|
|
task.consecutiveMistakeCount++
|
|
task.recordToolError("read_file")
|
|
const errorMsg = await task.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)")
|
|
const errorResult = useNative ? `Error: ${errorMsg}` : `<files><error>${errorMsg}</error></files>`
|
|
pushToolResult(errorResult)
|
|
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<FileResult>) => {
|
|
const index = fileResults.findIndex((result) => result.path === filePath)
|
|
if (index !== -1) {
|
|
fileResults[index] = { ...fileResults[index], ...updates }
|
|
}
|
|
}
|
|
|
|
try {
|
|
const filesToApprove: FileResult[] = []
|
|
|
|
for (const fileResult of fileResults) {
|
|
const relPath = fileResult.path
|
|
let fullPath: string
|
|
let isTaskScopedFile = false
|
|
|
|
// Check if this is a task-scoped file
|
|
if (relPath.startsWith("[task-scoped]/")) {
|
|
isTaskScopedFile = true
|
|
// Extract the actual file path
|
|
const actualPath = relPath.substring("[task-scoped]/".length)
|
|
fullPath = actualPath // For task-scoped files, we'll use the relative path
|
|
} else {
|
|
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,
|
|
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
|
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,
|
|
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
|
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") {
|
|
// Skip access checks for task-scoped files
|
|
if (!isTaskScopedFile) {
|
|
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,
|
|
xmlContent: `<file><path>${relPath}</path><error>${errorMsg}</error></file>`,
|
|
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",
|
|
xmlContent: `<file><path>${fileResult.path}</path><status>Denied by user</status></file>`,
|
|
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",
|
|
xmlContent: `<file><path>${fileResult.path}</path><status>Denied by user</status></file>`,
|
|
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",
|
|
xmlContent: `<file><path>${fileResult.path}</path><status>Denied by user</status></file>`,
|
|
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",
|
|
xmlContent: `<file><path>${relPath}</path><status>Denied by user</status></file>`,
|
|
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 })
|
|
}
|
|
}
|
|
|
|
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
|
|
let fullPath: string
|
|
let isTaskScopedFile = false
|
|
|
|
// Check if this is a task-scoped file
|
|
if (relPath.startsWith("[task-scoped]/")) {
|
|
isTaskScopedFile = true
|
|
// Extract the actual file path
|
|
const actualPath = relPath.substring("[task-scoped]/".length)
|
|
fullPath = actualPath // For task-scoped files, we'll use the relative path
|
|
|
|
// Read the task-scoped file
|
|
const provider = task.providerRef.deref()
|
|
if (!provider) {
|
|
updateFileResult(relPath, {
|
|
status: "error",
|
|
error: "Unable to access provider for task-scoped file",
|
|
xmlContent: `<file><path>${relPath}</path><error>Unable to access provider for task-scoped file</error></file>`,
|
|
nativeContent: `File: ${relPath}\nError: Unable to access provider for task-scoped file`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const globalStoragePath = provider.context.globalStorageUri.fsPath
|
|
const content = await readTaskScopedFile(globalStoragePath, task.taskId, actualPath)
|
|
|
|
if (content === null) {
|
|
updateFileResult(relPath, {
|
|
status: "error",
|
|
error: "Task-scoped file not found",
|
|
xmlContent: `<file><path>${relPath}</path><error>Task-scoped file not found</error></file>`,
|
|
nativeContent: `File: ${relPath}\nError: Task-scoped file not found`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Process the task-scoped file content
|
|
const lines = content.split("\n")
|
|
const totalLines = lines.length
|
|
|
|
if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
|
|
const rangeResults: string[] = []
|
|
const nativeRangeResults: string[] = []
|
|
|
|
for (const range of fileResult.lineRanges) {
|
|
const contentLines = lines.slice(range.start - 1, range.end)
|
|
const numberedContent = addLineNumbers(contentLines.join("\n"), range.start)
|
|
const lineRangeAttr = ` lines="${range.start}-${range.end}"`
|
|
rangeResults.push(`<content${lineRangeAttr}>\n${numberedContent}</content>`)
|
|
nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${numberedContent}`)
|
|
}
|
|
|
|
updateFileResult(relPath, {
|
|
xmlContent: `<file><path>${relPath}</path>\n${rangeResults.join("\n")}\n<notice>This is a task-scoped markdown file stored within the task context.</notice>\n</file>`,
|
|
nativeContent: `File: ${relPath}\n${nativeRangeResults.join("\n\n")}\n\nNote: This is a task-scoped markdown file stored within the task context.`,
|
|
})
|
|
} else {
|
|
const numberedContent = addLineNumbers(content, 1)
|
|
const lineRangeAttr = ` lines="1-${totalLines}"`
|
|
updateFileResult(relPath, {
|
|
xmlContent: `<file><path>${relPath}</path>\n<content${lineRangeAttr}>\n${numberedContent}</content>\n<notice>This is a task-scoped markdown file stored within the task context.</notice>\n</file>`,
|
|
nativeContent: `File: ${relPath}\nLines 1-${totalLines}:\n${numberedContent}\n\nNote: This is a task-scoped markdown file stored within the task context.`,
|
|
})
|
|
}
|
|
|
|
await task.fileContextTracker.trackFileContext(actualPath, "read_tool" as RecordSource)
|
|
continue
|
|
}
|
|
|
|
fullPath = path.resolve(task.cwd, relPath)
|
|
|
|
try {
|
|
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, {
|
|
xmlContent: `<file><path>${relPath}</path>\n<notice>${validationResult.notice}</notice>\n</file>`,
|
|
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, {
|
|
xmlContent: `<file><path>${relPath}</path>\n<notice>${imageResult.notice}</notice>\n</file>`,
|
|
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}`,
|
|
xmlContent: `<file><path>${relPath}</path><error>Error reading image file: ${errorMsg}</error></file>`,
|
|
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)) {
|
|
// Fall through to extractTextFromFile
|
|
} else {
|
|
const fileFormat = fileExtension.slice(1) || "bin"
|
|
updateFileResult(relPath, {
|
|
notice: `Binary file format: ${fileFormat}`,
|
|
xmlContent: `<file><path>${relPath}</path>\n<binary_file format="${fileFormat}">Binary file - content not displayed</binary_file>\n</file>`,
|
|
nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`,
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
|
|
const rangeResults: string[] = []
|
|
const nativeRangeResults: string[] = []
|
|
|
|
for (const range of fileResult.lineRanges) {
|
|
const content = addLineNumbers(
|
|
await readLines(fullPath, range.end - 1, range.start - 1),
|
|
range.start,
|
|
)
|
|
const lineRangeAttr = ` lines="${range.start}-${range.end}"`
|
|
rangeResults.push(`<content${lineRangeAttr}>\n${content}</content>`)
|
|
nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${content}`)
|
|
}
|
|
|
|
updateFileResult(relPath, {
|
|
xmlContent: `<file><path>${relPath}</path>\n${rangeResults.join("\n")}\n</file>`,
|
|
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, {
|
|
xmlContent: `<file><path>${relPath}</path>\n<list_code_definition_names>${defResult}</list_code_definition_names>\n<notice>${notice}</notice>\n</file>`,
|
|
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))
|
|
const lineRangeAttr = ` lines="1-${maxReadFileLine}"`
|
|
let xmlInfo = `<content${lineRangeAttr}>\n${content}</content>\n`
|
|
let nativeInfo = `Lines 1-${maxReadFileLine}:\n${content}\n`
|
|
|
|
try {
|
|
const defResult = await parseSourceCodeDefinitionsForFile(
|
|
fullPath,
|
|
task.rooIgnoreController,
|
|
)
|
|
if (defResult) {
|
|
const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine)
|
|
xmlInfo += `<list_code_definition_names>${truncatedDefs}</list_code_definition_names>\n`
|
|
nativeInfo += `\nCode Definitions:\n${truncatedDefs}\n`
|
|
}
|
|
|
|
const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines`
|
|
xmlInfo += `<notice>${notice}</notice>\n`
|
|
nativeInfo += `\nNote: ${notice}`
|
|
|
|
updateFileResult(relPath, {
|
|
xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
|
|
nativeContent: `File: ${relPath}\n${nativeInfo}`,
|
|
})
|
|
} 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 modelInfo = task.api.getModel().info
|
|
const { contextTokens } = task.getTokenUsage()
|
|
const contextWindow = modelInfo.contextWindow
|
|
|
|
const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0)
|
|
|
|
let content = await extractTextFromFile(fullPath)
|
|
let xmlInfo = ""
|
|
|
|
let nativeInfo = ""
|
|
|
|
if (budgetResult.shouldTruncate && budgetResult.maxChars !== undefined) {
|
|
const truncateResult = truncateFileContent(
|
|
content,
|
|
budgetResult.maxChars,
|
|
content.length,
|
|
budgetResult.isPreview,
|
|
)
|
|
content = truncateResult.content
|
|
|
|
let displayedLines = content.length === 0 ? 0 : content.split(/\r?\n/).length
|
|
if (displayedLines > 0 && content.endsWith("\n")) {
|
|
displayedLines--
|
|
}
|
|
const lineRangeAttr = displayedLines > 0 ? ` lines="1-${displayedLines}"` : ""
|
|
xmlInfo =
|
|
content.length > 0 ? `<content${lineRangeAttr}>\n${content}</content>\n` : `<content/>`
|
|
xmlInfo += `<notice>${truncateResult.notice}</notice>\n`
|
|
|
|
nativeInfo =
|
|
content.length > 0
|
|
? `Lines 1-${displayedLines}:\n${content}\n\nNote: ${truncateResult.notice}`
|
|
: `Note: ${truncateResult.notice}`
|
|
} else {
|
|
const lineRangeAttr = ` lines="1-${totalLines}"`
|
|
xmlInfo = totalLines > 0 ? `<content${lineRangeAttr}>\n${content}</content>\n` : `<content/>`
|
|
|
|
if (totalLines === 0) {
|
|
xmlInfo += `<notice>File is empty</notice>\n`
|
|
nativeInfo = "Note: File is empty"
|
|
} else {
|
|
nativeInfo = `Lines 1-${totalLines}:\n${content}`
|
|
}
|
|
}
|
|
|
|
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
|
|
|
|
updateFileResult(relPath, {
|
|
xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
|
|
nativeContent: `File: ${relPath}\n${nativeInfo}`,
|
|
})
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
updateFileResult(relPath, {
|
|
status: "error",
|
|
error: `Error reading file: ${errorMsg}`,
|
|
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
|
nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
|
|
})
|
|
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
|
|
}
|
|
}
|
|
|
|
// Build final result based on protocol
|
|
let finalResult: string
|
|
if (useNative) {
|
|
const nativeResults = fileResults
|
|
.filter((result) => result.nativeContent)
|
|
.map((result) => result.nativeContent)
|
|
finalResult = nativeResults.join("\n\n---\n\n")
|
|
} else {
|
|
const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent)
|
|
finalResult = `<files>\n${xmlResults.join("\n")}\n</files>`
|
|
}
|
|
|
|
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 || []
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
} 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}`,
|
|
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
|
|
nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
|
|
})
|
|
}
|
|
|
|
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
|
|
|
|
// Build final error result based on protocol
|
|
let errorResult: string
|
|
if (useNative) {
|
|
const nativeResults = fileResults
|
|
.filter((result) => result.nativeContent)
|
|
.map((result) => result.nativeContent)
|
|
errorResult = nativeResults.join("\n\n---\n\n")
|
|
} else {
|
|
const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent)
|
|
errorResult = `<files>\n${xmlResults.join("\n")}\n</files>`
|
|
}
|
|
|
|
pushToolResult(errorResult)
|
|
}
|
|
}
|
|
|
|
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]`
|
|
}
|
|
}
|
|
|
|
// Fallback to legacy/XML or synthesized params
|
|
const blockParams = second as any
|
|
|
|
if (blockParams?.args) {
|
|
try {
|
|
const parsed = parseXml(blockParams.args) as any
|
|
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
|
|
const paths = files.map((f: any) => 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]`
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to parse read_file args XML for description:", error)
|
|
return `[${blockName} with unparsable args]`
|
|
}
|
|
} else 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.]`
|
|
} else if (blockParams?.files) {
|
|
// Back-compat: some paths may still synthesize params.files; try to parse if present
|
|
try {
|
|
const files = JSON.parse(blockParams.files)
|
|
if (Array.isArray(files) && files.length > 0) {
|
|
const paths = files.map((f: any) => f?.path).filter(Boolean) as string[]
|
|
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]`
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to parse native files JSON for description:", error)
|
|
return `[${blockName} with unparsable files]`
|
|
}
|
|
}
|
|
|
|
return `[${blockName} with missing path/args/files]`
|
|
}
|
|
|
|
override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise<void> {
|
|
const argsXmlTag = block.params.args
|
|
const legacyPath = block.params.path
|
|
|
|
let filePath = ""
|
|
if (argsXmlTag) {
|
|
const match = argsXmlTag.match(/<file>.*?<path>([^<]+)<\/path>/s)
|
|
if (match) filePath = match[1]
|
|
}
|
|
if (!filePath && legacyPath) {
|
|
filePath = legacyPath
|
|
}
|
|
|
|
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(() => {})
|
|
}
|
|
}
|
|
|
|
export const readFileTool = new ReadFileTool()
|