ReadFileTool.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. import path from "path"
  2. import { isBinaryFile } from "isbinaryfile"
  3. import type { FileEntry, LineRange } from "@roo-code/types"
  4. import { isNativeProtocol, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
  5. import { Task } from "../task/Task"
  6. import { ClineSayTool } from "../../shared/ExtensionMessage"
  7. import { formatResponse } from "../prompts/responses"
  8. import { getModelMaxOutputTokens } from "../../shared/api"
  9. import { t } from "../../i18n"
  10. import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
  11. import { isPathOutsideWorkspace } from "../../utils/pathUtils"
  12. import { getReadablePath } from "../../utils/path"
  13. import { countFileLines } from "../../integrations/misc/line-counter"
  14. import { readLines } from "../../integrations/misc/read-lines"
  15. import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
  16. import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
  17. import { parseXml } from "../../utils/xml"
  18. import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
  19. import {
  20. DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
  21. DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
  22. isSupportedImageFormat,
  23. validateImageForProcessing,
  24. processImageFile,
  25. ImageMemoryTracker,
  26. } from "./helpers/imageHelpers"
  27. import { validateFileTokenBudget, truncateFileContent } from "./helpers/fileTokenBudget"
  28. import { truncateDefinitionsToLineLimit } from "./helpers/truncateDefinitions"
  29. import { BaseTool, ToolCallbacks } from "./BaseTool"
  30. import type { ToolUse } from "../../shared/tools"
  31. interface FileResult {
  32. path: string
  33. status: "approved" | "denied" | "blocked" | "error" | "pending"
  34. content?: string
  35. error?: string
  36. notice?: string
  37. lineRanges?: LineRange[]
  38. xmlContent?: string
  39. nativeContent?: string
  40. imageDataUrl?: string
  41. feedbackText?: string
  42. feedbackImages?: any[]
  43. }
  44. export class ReadFileTool extends BaseTool<"read_file"> {
  45. readonly name = "read_file" as const
  46. parseLegacy(params: Partial<Record<string, string>>): { files: FileEntry[] } {
  47. const argsXmlTag = params.args
  48. const legacyPath = params.path
  49. const legacyStartLineStr = params.start_line
  50. const legacyEndLineStr = params.end_line
  51. const fileEntries: FileEntry[] = []
  52. // XML args format
  53. if (argsXmlTag) {
  54. const parsed = parseXml(argsXmlTag) as any
  55. const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
  56. for (const file of files) {
  57. if (!file.path) continue
  58. const fileEntry: FileEntry = {
  59. path: file.path,
  60. lineRanges: [],
  61. }
  62. if (file.line_range) {
  63. const ranges = Array.isArray(file.line_range) ? file.line_range : [file.line_range]
  64. for (const range of ranges) {
  65. const match = String(range).match(/(\d+)-(\d+)/)
  66. if (match) {
  67. const [, start, end] = match.map(Number)
  68. if (!isNaN(start) && !isNaN(end)) {
  69. fileEntry.lineRanges?.push({ start, end })
  70. }
  71. }
  72. }
  73. }
  74. fileEntries.push(fileEntry)
  75. }
  76. return { files: fileEntries }
  77. }
  78. // Legacy single file path
  79. if (legacyPath) {
  80. const fileEntry: FileEntry = {
  81. path: legacyPath,
  82. lineRanges: [],
  83. }
  84. if (legacyStartLineStr && legacyEndLineStr) {
  85. const start = parseInt(legacyStartLineStr, 10)
  86. const end = parseInt(legacyEndLineStr, 10)
  87. if (!isNaN(start) && !isNaN(end) && start > 0 && end > 0) {
  88. fileEntry.lineRanges?.push({ start, end })
  89. }
  90. }
  91. fileEntries.push(fileEntry)
  92. }
  93. return { files: fileEntries }
  94. }
  95. async execute(params: { files: FileEntry[] }, task: Task, callbacks: ToolCallbacks): Promise<void> {
  96. const { handleError, pushToolResult, toolProtocol } = callbacks
  97. const fileEntries = params.files
  98. const modelInfo = task.api.getModel().info
  99. const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo)
  100. const useNative = isNativeProtocol(protocol)
  101. if (!fileEntries || fileEntries.length === 0) {
  102. task.consecutiveMistakeCount++
  103. task.recordToolError("read_file")
  104. const errorMsg = await task.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)")
  105. const errorResult = useNative ? `Error: ${errorMsg}` : `<files><error>${errorMsg}</error></files>`
  106. pushToolResult(errorResult)
  107. return
  108. }
  109. const supportsImages = modelInfo.supportsImages ?? false
  110. const fileResults: FileResult[] = fileEntries.map((entry) => ({
  111. path: entry.path,
  112. status: "pending",
  113. lineRanges: entry.lineRanges,
  114. }))
  115. const updateFileResult = (filePath: string, updates: Partial<FileResult>) => {
  116. const index = fileResults.findIndex((result) => result.path === filePath)
  117. if (index !== -1) {
  118. fileResults[index] = { ...fileResults[index], ...updates }
  119. }
  120. }
  121. try {
  122. const filesToApprove: FileResult[] = []
  123. for (const fileResult of fileResults) {
  124. const relPath = fileResult.path
  125. const fullPath = path.resolve(task.cwd, relPath)
  126. if (fileResult.lineRanges) {
  127. let hasRangeError = false
  128. for (const range of fileResult.lineRanges) {
  129. if (range.start > range.end) {
  130. const errorMsg = "Invalid line range: end line cannot be less than start line"
  131. updateFileResult(relPath, {
  132. status: "blocked",
  133. error: errorMsg,
  134. xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
  135. nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
  136. })
  137. await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
  138. hasRangeError = true
  139. break
  140. }
  141. if (isNaN(range.start) || isNaN(range.end)) {
  142. const errorMsg = "Invalid line range values"
  143. updateFileResult(relPath, {
  144. status: "blocked",
  145. error: errorMsg,
  146. xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
  147. nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
  148. })
  149. await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
  150. hasRangeError = true
  151. break
  152. }
  153. }
  154. if (hasRangeError) continue
  155. }
  156. if (fileResult.status === "pending") {
  157. const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
  158. if (!accessAllowed) {
  159. await task.say("rooignore_error", relPath)
  160. const errorMsg = formatResponse.rooIgnoreError(relPath)
  161. updateFileResult(relPath, {
  162. status: "blocked",
  163. error: errorMsg,
  164. xmlContent: `<file><path>${relPath}</path><error>${errorMsg}</error></file>`,
  165. nativeContent: `File: ${relPath}\nError: ${errorMsg}`,
  166. })
  167. continue
  168. }
  169. filesToApprove.push(fileResult)
  170. }
  171. }
  172. if (filesToApprove.length > 1) {
  173. const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {}
  174. const batchFiles = filesToApprove.map((fileResult) => {
  175. const relPath = fileResult.path
  176. const fullPath = path.resolve(task.cwd, relPath)
  177. const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
  178. let lineSnippet = ""
  179. if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
  180. const ranges = fileResult.lineRanges.map((range) =>
  181. t("tools:readFile.linesRange", { start: range.start, end: range.end }),
  182. )
  183. lineSnippet = ranges.join(", ")
  184. } else if (maxReadFileLine === 0) {
  185. lineSnippet = t("tools:readFile.definitionsOnly")
  186. } else if (maxReadFileLine > 0) {
  187. lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine })
  188. }
  189. const readablePath = getReadablePath(task.cwd, relPath)
  190. const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}`
  191. return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath }
  192. })
  193. const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool)
  194. const { response, text, images } = await task.ask("tool", completeMessage, false)
  195. if (response === "yesButtonClicked") {
  196. if (text) await task.say("user_feedback", text, images)
  197. filesToApprove.forEach((fileResult) => {
  198. updateFileResult(fileResult.path, {
  199. status: "approved",
  200. feedbackText: text,
  201. feedbackImages: images,
  202. })
  203. })
  204. } else if (response === "noButtonClicked") {
  205. if (text) await task.say("user_feedback", text, images)
  206. task.didRejectTool = true
  207. filesToApprove.forEach((fileResult) => {
  208. updateFileResult(fileResult.path, {
  209. status: "denied",
  210. xmlContent: `<file><path>${fileResult.path}</path><status>Denied by user</status></file>`,
  211. nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`,
  212. feedbackText: text,
  213. feedbackImages: images,
  214. })
  215. })
  216. } else {
  217. try {
  218. const individualPermissions = JSON.parse(text || "{}")
  219. let hasAnyDenial = false
  220. batchFiles.forEach((batchFile, index) => {
  221. const fileResult = filesToApprove[index]
  222. const approved = individualPermissions[batchFile.key] === true
  223. if (approved) {
  224. updateFileResult(fileResult.path, { status: "approved" })
  225. } else {
  226. hasAnyDenial = true
  227. updateFileResult(fileResult.path, {
  228. status: "denied",
  229. xmlContent: `<file><path>${fileResult.path}</path><status>Denied by user</status></file>`,
  230. nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`,
  231. })
  232. }
  233. })
  234. if (hasAnyDenial) task.didRejectTool = true
  235. } catch (error) {
  236. console.error("Failed to parse individual permissions:", error)
  237. task.didRejectTool = true
  238. filesToApprove.forEach((fileResult) => {
  239. updateFileResult(fileResult.path, {
  240. status: "denied",
  241. xmlContent: `<file><path>${fileResult.path}</path><status>Denied by user</status></file>`,
  242. nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`,
  243. })
  244. })
  245. }
  246. }
  247. } else if (filesToApprove.length === 1) {
  248. const fileResult = filesToApprove[0]
  249. const relPath = fileResult.path
  250. const fullPath = path.resolve(task.cwd, relPath)
  251. const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
  252. const { maxReadFileLine = -1 } = (await task.providerRef.deref()?.getState()) ?? {}
  253. let lineSnippet = ""
  254. if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
  255. const ranges = fileResult.lineRanges.map((range) =>
  256. t("tools:readFile.linesRange", { start: range.start, end: range.end }),
  257. )
  258. lineSnippet = ranges.join(", ")
  259. } else if (maxReadFileLine === 0) {
  260. lineSnippet = t("tools:readFile.definitionsOnly")
  261. } else if (maxReadFileLine > 0) {
  262. lineSnippet = t("tools:readFile.maxLines", { max: maxReadFileLine })
  263. }
  264. const completeMessage = JSON.stringify({
  265. tool: "readFile",
  266. path: getReadablePath(task.cwd, relPath),
  267. isOutsideWorkspace,
  268. content: fullPath,
  269. reason: lineSnippet,
  270. } satisfies ClineSayTool)
  271. const { response, text, images } = await task.ask("tool", completeMessage, false)
  272. if (response !== "yesButtonClicked") {
  273. if (text) await task.say("user_feedback", text, images)
  274. task.didRejectTool = true
  275. updateFileResult(relPath, {
  276. status: "denied",
  277. xmlContent: `<file><path>${relPath}</path><status>Denied by user</status></file>`,
  278. nativeContent: `File: ${relPath}\nStatus: Denied by user`,
  279. feedbackText: text,
  280. feedbackImages: images,
  281. })
  282. } else {
  283. if (text) await task.say("user_feedback", text, images)
  284. updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images })
  285. }
  286. }
  287. const imageMemoryTracker = new ImageMemoryTracker()
  288. const state = await task.providerRef.deref()?.getState()
  289. const {
  290. maxReadFileLine = -1,
  291. maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
  292. maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
  293. } = state ?? {}
  294. for (const fileResult of fileResults) {
  295. if (fileResult.status !== "approved") continue
  296. const relPath = fileResult.path
  297. const fullPath = path.resolve(task.cwd, relPath)
  298. try {
  299. const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)])
  300. if (isBinary) {
  301. const fileExtension = path.extname(relPath).toLowerCase()
  302. const supportedBinaryFormats = getSupportedBinaryFormats()
  303. if (isSupportedImageFormat(fileExtension)) {
  304. try {
  305. const validationResult = await validateImageForProcessing(
  306. fullPath,
  307. supportsImages,
  308. maxImageFileSize,
  309. maxTotalImageSize,
  310. imageMemoryTracker.getTotalMemoryUsed(),
  311. )
  312. if (!validationResult.isValid) {
  313. await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
  314. updateFileResult(relPath, {
  315. xmlContent: `<file><path>${relPath}</path>\n<notice>${validationResult.notice}</notice>\n</file>`,
  316. nativeContent: `File: ${relPath}\nNote: ${validationResult.notice}`,
  317. })
  318. continue
  319. }
  320. const imageResult = await processImageFile(fullPath)
  321. imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB)
  322. await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
  323. updateFileResult(relPath, {
  324. xmlContent: `<file><path>${relPath}</path>\n<notice>${imageResult.notice}</notice>\n</file>`,
  325. nativeContent: `File: ${relPath}\nNote: ${imageResult.notice}`,
  326. imageDataUrl: imageResult.dataUrl,
  327. })
  328. continue
  329. } catch (error) {
  330. const errorMsg = error instanceof Error ? error.message : String(error)
  331. updateFileResult(relPath, {
  332. status: "error",
  333. error: `Error reading image file: ${errorMsg}`,
  334. xmlContent: `<file><path>${relPath}</path><error>Error reading image file: ${errorMsg}</error></file>`,
  335. nativeContent: `File: ${relPath}\nError: Error reading image file: ${errorMsg}`,
  336. })
  337. await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`)
  338. continue
  339. }
  340. }
  341. if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) {
  342. // Fall through to extractTextFromFile
  343. } else {
  344. const fileFormat = fileExtension.slice(1) || "bin"
  345. updateFileResult(relPath, {
  346. notice: `Binary file format: ${fileFormat}`,
  347. xmlContent: `<file><path>${relPath}</path>\n<binary_file format="${fileFormat}">Binary file - content not displayed</binary_file>\n</file>`,
  348. nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`,
  349. })
  350. continue
  351. }
  352. }
  353. if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
  354. const rangeResults: string[] = []
  355. const nativeRangeResults: string[] = []
  356. for (const range of fileResult.lineRanges) {
  357. const content = addLineNumbers(
  358. await readLines(fullPath, range.end - 1, range.start - 1),
  359. range.start,
  360. )
  361. const lineRangeAttr = ` lines="${range.start}-${range.end}"`
  362. rangeResults.push(`<content${lineRangeAttr}>\n${content}</content>`)
  363. nativeRangeResults.push(`Lines ${range.start}-${range.end}:\n${content}`)
  364. }
  365. updateFileResult(relPath, {
  366. xmlContent: `<file><path>${relPath}</path>\n${rangeResults.join("\n")}\n</file>`,
  367. nativeContent: `File: ${relPath}\n${nativeRangeResults.join("\n\n")}`,
  368. })
  369. continue
  370. }
  371. if (maxReadFileLine === 0) {
  372. try {
  373. const defResult = await parseSourceCodeDefinitionsForFile(
  374. fullPath,
  375. task.rooIgnoreController,
  376. )
  377. if (defResult) {
  378. const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines`
  379. updateFileResult(relPath, {
  380. xmlContent: `<file><path>${relPath}</path>\n<list_code_definition_names>${defResult}</list_code_definition_names>\n<notice>${notice}</notice>\n</file>`,
  381. nativeContent: `File: ${relPath}\nCode Definitions:\n${defResult}\n\nNote: ${notice}`,
  382. })
  383. }
  384. } catch (error) {
  385. if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
  386. console.warn(`[read_file] Warning: ${error.message}`)
  387. } else {
  388. console.error(
  389. `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
  390. )
  391. }
  392. }
  393. continue
  394. }
  395. if (maxReadFileLine > 0 && totalLines > maxReadFileLine) {
  396. const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0))
  397. const lineRangeAttr = ` lines="1-${maxReadFileLine}"`
  398. let xmlInfo = `<content${lineRangeAttr}>\n${content}</content>\n`
  399. let nativeInfo = `Lines 1-${maxReadFileLine}:\n${content}\n`
  400. try {
  401. const defResult = await parseSourceCodeDefinitionsForFile(
  402. fullPath,
  403. task.rooIgnoreController,
  404. )
  405. if (defResult) {
  406. const truncatedDefs = truncateDefinitionsToLineLimit(defResult, maxReadFileLine)
  407. xmlInfo += `<list_code_definition_names>${truncatedDefs}</list_code_definition_names>\n`
  408. nativeInfo += `\nCode Definitions:\n${truncatedDefs}\n`
  409. }
  410. const notice = `Showing only ${maxReadFileLine} of ${totalLines} total lines. Use line_range if you need to read more lines`
  411. xmlInfo += `<notice>${notice}</notice>\n`
  412. nativeInfo += `\nNote: ${notice}`
  413. updateFileResult(relPath, {
  414. xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
  415. nativeContent: `File: ${relPath}\n${nativeInfo}`,
  416. })
  417. } catch (error) {
  418. if (error instanceof Error && error.message.startsWith("Unsupported language:")) {
  419. console.warn(`[read_file] Warning: ${error.message}`)
  420. } else {
  421. console.error(
  422. `[read_file] Unhandled error: ${error instanceof Error ? error.message : String(error)}`,
  423. )
  424. }
  425. }
  426. continue
  427. }
  428. const { id: modelId, info: modelInfo } = task.api.getModel()
  429. const { contextTokens } = task.getTokenUsage()
  430. const contextWindow = modelInfo.contextWindow
  431. const maxOutputTokens =
  432. getModelMaxOutputTokens({
  433. modelId,
  434. model: modelInfo,
  435. settings: task.apiConfiguration,
  436. }) ?? ANTHROPIC_DEFAULT_MAX_TOKENS
  437. const budgetResult = await validateFileTokenBudget(
  438. fullPath,
  439. contextWindow - maxOutputTokens,
  440. contextTokens || 0,
  441. )
  442. let content = await extractTextFromFile(fullPath)
  443. let xmlInfo = ""
  444. let nativeInfo = ""
  445. if (budgetResult.shouldTruncate && budgetResult.maxChars !== undefined) {
  446. const truncateResult = truncateFileContent(
  447. content,
  448. budgetResult.maxChars,
  449. content.length,
  450. budgetResult.isPreview,
  451. )
  452. content = truncateResult.content
  453. let displayedLines = content.length === 0 ? 0 : content.split(/\r?\n/).length
  454. if (displayedLines > 0 && content.endsWith("\n")) {
  455. displayedLines--
  456. }
  457. const lineRangeAttr = displayedLines > 0 ? ` lines="1-${displayedLines}"` : ""
  458. xmlInfo =
  459. content.length > 0 ? `<content${lineRangeAttr}>\n${content}</content>\n` : `<content/>`
  460. xmlInfo += `<notice>${truncateResult.notice}</notice>\n`
  461. nativeInfo =
  462. content.length > 0
  463. ? `Lines 1-${displayedLines}:\n${content}\n\nNote: ${truncateResult.notice}`
  464. : `Note: ${truncateResult.notice}`
  465. } else {
  466. const lineRangeAttr = ` lines="1-${totalLines}"`
  467. xmlInfo = totalLines > 0 ? `<content${lineRangeAttr}>\n${content}</content>\n` : `<content/>`
  468. if (totalLines === 0) {
  469. xmlInfo += `<notice>File is empty</notice>\n`
  470. nativeInfo = "Note: File is empty"
  471. } else {
  472. nativeInfo = `Lines 1-${totalLines}:\n${content}`
  473. }
  474. }
  475. await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
  476. updateFileResult(relPath, {
  477. xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
  478. nativeContent: `File: ${relPath}\n${nativeInfo}`,
  479. })
  480. } catch (error) {
  481. const errorMsg = error instanceof Error ? error.message : String(error)
  482. updateFileResult(relPath, {
  483. status: "error",
  484. error: `Error reading file: ${errorMsg}`,
  485. xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
  486. nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
  487. })
  488. await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
  489. }
  490. }
  491. // Check if any files had errors or were blocked and mark the turn as failed
  492. const hasErrors = fileResults.some((result) => result.status === "error" || result.status === "blocked")
  493. if (hasErrors) {
  494. task.didToolFailInCurrentTurn = true
  495. }
  496. // Build final result based on protocol
  497. let finalResult: string
  498. if (useNative) {
  499. const nativeResults = fileResults
  500. .filter((result) => result.nativeContent)
  501. .map((result) => result.nativeContent)
  502. finalResult = nativeResults.join("\n\n---\n\n")
  503. } else {
  504. const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent)
  505. finalResult = `<files>\n${xmlResults.join("\n")}\n</files>`
  506. }
  507. const fileImageUrls = fileResults
  508. .filter((result) => result.imageDataUrl)
  509. .map((result) => result.imageDataUrl as string)
  510. let statusMessage = ""
  511. let feedbackImages: any[] = []
  512. const deniedWithFeedback = fileResults.find((result) => result.status === "denied" && result.feedbackText)
  513. if (deniedWithFeedback && deniedWithFeedback.feedbackText) {
  514. statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText)
  515. feedbackImages = deniedWithFeedback.feedbackImages || []
  516. } else if (task.didRejectTool) {
  517. statusMessage = formatResponse.toolDenied()
  518. } else {
  519. const approvedWithFeedback = fileResults.find(
  520. (result) => result.status === "approved" && result.feedbackText,
  521. )
  522. if (approvedWithFeedback && approvedWithFeedback.feedbackText) {
  523. statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText)
  524. feedbackImages = approvedWithFeedback.feedbackImages || []
  525. }
  526. }
  527. const allImages = [...feedbackImages, ...fileImageUrls]
  528. const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false
  529. const imagesToInclude = finalModelSupportsImages ? allImages : []
  530. if (statusMessage || imagesToInclude.length > 0) {
  531. const result = formatResponse.toolResult(
  532. statusMessage || finalResult,
  533. imagesToInclude.length > 0 ? imagesToInclude : undefined,
  534. )
  535. if (typeof result === "string") {
  536. if (statusMessage) {
  537. pushToolResult(`${result}\n${finalResult}`)
  538. } else {
  539. pushToolResult(result)
  540. }
  541. } else {
  542. if (statusMessage) {
  543. const textBlock = { type: "text" as const, text: finalResult }
  544. pushToolResult([...result, textBlock])
  545. } else {
  546. pushToolResult(result)
  547. }
  548. }
  549. } else {
  550. pushToolResult(finalResult)
  551. }
  552. } catch (error) {
  553. const relPath = fileEntries[0]?.path || "unknown"
  554. const errorMsg = error instanceof Error ? error.message : String(error)
  555. if (fileResults.length > 0) {
  556. updateFileResult(relPath, {
  557. status: "error",
  558. error: `Error reading file: ${errorMsg}`,
  559. xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
  560. nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
  561. })
  562. }
  563. await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
  564. // Mark that a tool failed in this turn
  565. task.didToolFailInCurrentTurn = true
  566. // Build final error result based on protocol
  567. let errorResult: string
  568. if (useNative) {
  569. const nativeResults = fileResults
  570. .filter((result) => result.nativeContent)
  571. .map((result) => result.nativeContent)
  572. errorResult = nativeResults.join("\n\n---\n\n")
  573. } else {
  574. const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent)
  575. errorResult = `<files>\n${xmlResults.join("\n")}\n</files>`
  576. }
  577. pushToolResult(errorResult)
  578. }
  579. }
  580. getReadFileToolDescription(blockName: string, blockParams: any): string
  581. getReadFileToolDescription(blockName: string, nativeArgs: { files: FileEntry[] }): string
  582. getReadFileToolDescription(blockName: string, second: any): string {
  583. // If native typed args ({ files: FileEntry[] }) were provided
  584. if (second && typeof second === "object" && "files" in second && Array.isArray(second.files)) {
  585. const paths = (second.files as FileEntry[]).map((f) => f?.path).filter(Boolean) as string[]
  586. if (paths.length === 0) {
  587. return `[${blockName} with no valid paths]`
  588. } else if (paths.length === 1) {
  589. 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.]`
  590. } else if (paths.length <= 3) {
  591. const pathList = paths.map((p) => `'${p}'`).join(", ")
  592. return `[${blockName} for ${pathList}]`
  593. } else {
  594. return `[${blockName} for ${paths.length} files]`
  595. }
  596. }
  597. // Fallback to legacy/XML or synthesized params
  598. const blockParams = second as any
  599. if (blockParams?.args) {
  600. try {
  601. const parsed = parseXml(blockParams.args) as any
  602. const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
  603. const paths = files.map((f: any) => f?.path).filter(Boolean) as string[]
  604. if (paths.length === 0) {
  605. return `[${blockName} with no valid paths]`
  606. } else if (paths.length === 1) {
  607. 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.]`
  608. } else if (paths.length <= 3) {
  609. const pathList = paths.map((p) => `'${p}'`).join(", ")
  610. return `[${blockName} for ${pathList}]`
  611. } else {
  612. return `[${blockName} for ${paths.length} files]`
  613. }
  614. } catch (error) {
  615. console.error("Failed to parse read_file args XML for description:", error)
  616. return `[${blockName} with unparsable args]`
  617. }
  618. } else if (blockParams?.path) {
  619. 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.]`
  620. } else if (blockParams?.files) {
  621. // Back-compat: some paths may still synthesize params.files; try to parse if present
  622. try {
  623. const files = JSON.parse(blockParams.files)
  624. if (Array.isArray(files) && files.length > 0) {
  625. const paths = files.map((f: any) => f?.path).filter(Boolean) as string[]
  626. if (paths.length === 1) {
  627. 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.]`
  628. } else if (paths.length <= 3) {
  629. const pathList = paths.map((p) => `'${p}'`).join(", ")
  630. return `[${blockName} for ${pathList}]`
  631. } else {
  632. return `[${blockName} for ${paths.length} files]`
  633. }
  634. }
  635. } catch (error) {
  636. console.error("Failed to parse native files JSON for description:", error)
  637. return `[${blockName} with unparsable files]`
  638. }
  639. }
  640. return `[${blockName} with missing path/args/files]`
  641. }
  642. override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise<void> {
  643. const argsXmlTag = block.params.args
  644. const legacyPath = block.params.path
  645. let filePath = ""
  646. if (argsXmlTag) {
  647. const match = argsXmlTag.match(/<file>.*?<path>([^<]+)<\/path>/s)
  648. if (match) filePath = match[1]
  649. }
  650. if (!filePath && legacyPath) {
  651. filePath = legacyPath
  652. }
  653. if (!filePath && block.nativeArgs && "files" in block.nativeArgs && Array.isArray(block.nativeArgs.files)) {
  654. const files = block.nativeArgs.files
  655. if (files.length > 0 && files[0]?.path) {
  656. filePath = files[0].path
  657. }
  658. }
  659. const fullPath = filePath ? path.resolve(task.cwd, filePath) : ""
  660. const sharedMessageProps: ClineSayTool = {
  661. tool: "readFile",
  662. path: getReadablePath(task.cwd, filePath),
  663. isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false,
  664. }
  665. const partialMessage = JSON.stringify({
  666. ...sharedMessageProps,
  667. content: undefined,
  668. } satisfies ClineSayTool)
  669. await task.ask("tool", partialMessage, block.partial).catch(() => {})
  670. }
  671. }
  672. export const readFileTool = new ReadFileTool()