writeToFileTool.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import path from "path"
  2. import delay from "delay"
  3. import * as vscode from "vscode"
  4. import fs from "fs/promises"
  5. import { Task } from "../task/Task"
  6. import { ClineSayTool } from "../../shared/ExtensionMessage"
  7. import { formatResponse } from "../prompts/responses"
  8. import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
  9. import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
  10. import { fileExistsAtPath } from "../../utils/fs"
  11. import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text"
  12. import { getReadablePath } from "../../utils/path"
  13. import { isPathOutsideWorkspace } from "../../utils/pathUtils"
  14. import { detectCodeOmission } from "../../integrations/editor/detect-omission"
  15. import { unescapeHtmlEntities } from "../../utils/text-normalization"
  16. import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
  17. import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
  18. export async function writeToFileTool(
  19. cline: Task,
  20. block: ToolUse,
  21. askApproval: AskApproval,
  22. handleError: HandleError,
  23. pushToolResult: PushToolResult,
  24. removeClosingTag: RemoveClosingTag,
  25. ) {
  26. const relPath: string | undefined = block.params.path
  27. let newContent: string | undefined = block.params.content
  28. let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0")
  29. if (block.partial && (!relPath || newContent === undefined)) {
  30. // checking for newContent ensure relPath is complete
  31. // wait so we can determine if it's a new file or editing an existing file
  32. return
  33. }
  34. if (!relPath) {
  35. cline.consecutiveMistakeCount++
  36. cline.recordToolError("write_to_file")
  37. pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
  38. await cline.diffViewProvider.reset()
  39. return
  40. }
  41. if (newContent === undefined) {
  42. cline.consecutiveMistakeCount++
  43. cline.recordToolError("write_to_file")
  44. pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
  45. await cline.diffViewProvider.reset()
  46. return
  47. }
  48. const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
  49. if (!accessAllowed) {
  50. await cline.say("rooignore_error", relPath)
  51. pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
  52. return
  53. }
  54. // Check if file is write-protected
  55. const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false
  56. // Check if file exists using cached map or fs.access
  57. let fileExists: boolean
  58. if (cline.diffViewProvider.editType !== undefined) {
  59. fileExists = cline.diffViewProvider.editType === "modify"
  60. } else {
  61. const absolutePath = path.resolve(cline.cwd, relPath)
  62. fileExists = await fileExistsAtPath(absolutePath)
  63. cline.diffViewProvider.editType = fileExists ? "modify" : "create"
  64. }
  65. // pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini)
  66. if (newContent.startsWith("```")) {
  67. // cline handles cases where it includes language specifiers like ```python ```js
  68. newContent = newContent.split("\n").slice(1).join("\n")
  69. }
  70. if (newContent.endsWith("```")) {
  71. newContent = newContent.split("\n").slice(0, -1).join("\n")
  72. }
  73. if (!cline.api.getModel().id.includes("claude")) {
  74. newContent = unescapeHtmlEntities(newContent)
  75. }
  76. // Determine if the path is outside the workspace
  77. const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)) : ""
  78. const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
  79. const sharedMessageProps: ClineSayTool = {
  80. tool: fileExists ? "editedExistingFile" : "newFileCreated",
  81. path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
  82. content: newContent,
  83. isOutsideWorkspace,
  84. isProtected: isWriteProtected,
  85. }
  86. try {
  87. if (block.partial) {
  88. // Check if preventFocusDisruption experiment is enabled
  89. const provider = cline.providerRef.deref()
  90. const state = await provider?.getState()
  91. const isPreventFocusDisruptionEnabled = experiments.isEnabled(
  92. state?.experiments ?? {},
  93. EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
  94. )
  95. if (!isPreventFocusDisruptionEnabled) {
  96. // update gui message
  97. const partialMessage = JSON.stringify(sharedMessageProps)
  98. await cline.ask("tool", partialMessage, block.partial).catch(() => {})
  99. // update editor
  100. if (!cline.diffViewProvider.isEditing) {
  101. // open the editor and prepare to stream content in
  102. await cline.diffViewProvider.open(relPath)
  103. }
  104. // editor is open, stream content in
  105. await cline.diffViewProvider.update(
  106. everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
  107. false,
  108. )
  109. }
  110. return
  111. } else {
  112. if (predictedLineCount === undefined) {
  113. cline.consecutiveMistakeCount++
  114. cline.recordToolError("write_to_file")
  115. // Calculate the actual number of lines in the content
  116. const actualLineCount = newContent.split("\n").length
  117. // Check if this is a new file or existing file
  118. const isNewFile = !fileExists
  119. // Check if diffStrategy is enabled
  120. const diffStrategyEnabled = !!cline.diffStrategy
  121. // Use more specific error message for line_count that provides guidance based on the situation
  122. await cline.say(
  123. "error",
  124. `Roo tried to use write_to_file${
  125. relPath ? ` for '${relPath.toPosix()}'` : ""
  126. } but the required parameter 'line_count' was missing or truncated after ${actualLineCount} lines of content were written. Retrying...`,
  127. )
  128. pushToolResult(
  129. formatResponse.toolError(
  130. formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled),
  131. ),
  132. )
  133. await cline.diffViewProvider.revertChanges()
  134. return
  135. }
  136. cline.consecutiveMistakeCount = 0
  137. // Check if preventFocusDisruption experiment is enabled
  138. const provider = cline.providerRef.deref()
  139. const state = await provider?.getState()
  140. const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
  141. const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
  142. const isPreventFocusDisruptionEnabled = experiments.isEnabled(
  143. state?.experiments ?? {},
  144. EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
  145. )
  146. if (isPreventFocusDisruptionEnabled) {
  147. // Direct file write without diff view
  148. // Check for code omissions before proceeding
  149. if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
  150. if (cline.diffStrategy) {
  151. pushToolResult(
  152. formatResponse.toolError(
  153. `Content appears to be truncated (file has ${
  154. newContent.split("\n").length
  155. } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
  156. ),
  157. )
  158. return
  159. } else {
  160. vscode.window
  161. .showWarningMessage(
  162. "Potential code truncation detected. cline happens when the AI reaches its max output limit.",
  163. "Follow cline guide to fix the issue",
  164. )
  165. .then((selection) => {
  166. if (selection === "Follow cline guide to fix the issue") {
  167. vscode.env.openExternal(
  168. vscode.Uri.parse(
  169. "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
  170. ),
  171. )
  172. }
  173. })
  174. }
  175. }
  176. const completeMessage = JSON.stringify({
  177. ...sharedMessageProps,
  178. content: newContent,
  179. } satisfies ClineSayTool)
  180. const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
  181. if (!didApprove) {
  182. return
  183. }
  184. // Set up diffViewProvider properties needed for saveDirectly
  185. cline.diffViewProvider.editType = fileExists ? "modify" : "create"
  186. if (fileExists) {
  187. const absolutePath = path.resolve(cline.cwd, relPath)
  188. cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
  189. } else {
  190. cline.diffViewProvider.originalContent = ""
  191. }
  192. // Save directly without showing diff view or opening the file
  193. await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
  194. } else {
  195. // Original behavior with diff view
  196. // if isEditingFile false, that means we have the full contents of the file already.
  197. // it's important to note how cline function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So cline part of the logic will always be called.
  198. // in other words, you must always repeat the block.partial logic here
  199. if (!cline.diffViewProvider.isEditing) {
  200. // show gui message before showing edit animation
  201. const partialMessage = JSON.stringify(sharedMessageProps)
  202. await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor
  203. await cline.diffViewProvider.open(relPath)
  204. }
  205. await cline.diffViewProvider.update(
  206. everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
  207. true,
  208. )
  209. await delay(300) // wait for diff view to update
  210. cline.diffViewProvider.scrollToFirstDiff()
  211. // Check for code omissions before proceeding
  212. if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
  213. if (cline.diffStrategy) {
  214. await cline.diffViewProvider.revertChanges()
  215. pushToolResult(
  216. formatResponse.toolError(
  217. `Content appears to be truncated (file has ${
  218. newContent.split("\n").length
  219. } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`,
  220. ),
  221. )
  222. return
  223. } else {
  224. vscode.window
  225. .showWarningMessage(
  226. "Potential code truncation detected. cline happens when the AI reaches its max output limit.",
  227. "Follow cline guide to fix the issue",
  228. )
  229. .then((selection) => {
  230. if (selection === "Follow cline guide to fix the issue") {
  231. vscode.env.openExternal(
  232. vscode.Uri.parse(
  233. "https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
  234. ),
  235. )
  236. }
  237. })
  238. }
  239. }
  240. const completeMessage = JSON.stringify({
  241. ...sharedMessageProps,
  242. content: fileExists ? undefined : newContent,
  243. diff: fileExists
  244. ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
  245. : undefined,
  246. } satisfies ClineSayTool)
  247. const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
  248. if (!didApprove) {
  249. await cline.diffViewProvider.revertChanges()
  250. return
  251. }
  252. // Call saveChanges to update the DiffViewProvider properties
  253. await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
  254. }
  255. // Track file edit operation
  256. if (relPath) {
  257. await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
  258. }
  259. cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
  260. // Get the formatted response message
  261. const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists)
  262. pushToolResult(message)
  263. await cline.diffViewProvider.reset()
  264. return
  265. }
  266. } catch (error) {
  267. await handleError("writing file", error)
  268. await cline.diffViewProvider.reset()
  269. return
  270. }
  271. }