Browse Source

Move write_to_file to a tool file (#2093)

Matt Rubens 9 months ago
parent
commit
26c3fe6535
2 changed files with 215 additions and 207 deletions
  1. 6 207
      src/core/Cline.ts
  2. 209 0
      src/core/tools/writeToFileTool.ts

+ 6 - 207
src/core/Cline.ts

@@ -85,6 +85,7 @@ import { telemetryService } from "../services/telemetry/TelemetryService"
 import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
 import { parseXml } from "../utils/xml"
 import { getWorkspacePath } from "../utils/path"
+import { writeToFileTool } from "./tools/writeToFileTool"
 
 export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
 type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@@ -135,7 +136,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 	api: ApiHandler
 	private urlContentFetcher: UrlContentFetcher
 	private browserSession: BrowserSession
-	private didEditFile: boolean = false
+	didEditFile: boolean = false
 	customInstructions?: string
 	diffStrategy?: DiffStrategy
 	diffEnabled: boolean = false
@@ -156,7 +157,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 	private abort: boolean = false
 	didFinishAbortingStream = false
 	abandoned = false
-	private diffViewProvider: DiffViewProvider
+	diffViewProvider: DiffViewProvider
 	private lastApiRequestTime?: number
 	isInitialized = false
 
@@ -1564,211 +1565,9 @@ export class Cline extends EventEmitter<ClineEvents> {
 				}
 
 				switch (block.name) {
-					case "write_to_file": {
-						const relPath: string | undefined = block.params.path
-						let newContent: string | undefined = block.params.content
-						let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0")
-						if (!relPath || !newContent) {
-							// checking for newContent ensure relPath is complete
-							// wait so we can determine if it's a new file or editing an existing file
-							break
-						}
-
-						const accessAllowed = this.rooIgnoreController?.validateAccess(relPath)
-						if (!accessAllowed) {
-							await this.say("rooignore_error", relPath)
-							pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
-
-							break
-						}
-
-						// Check if file exists using cached map or fs.access
-						let fileExists: boolean
-						if (this.diffViewProvider.editType !== undefined) {
-							fileExists = this.diffViewProvider.editType === "modify"
-						} else {
-							const absolutePath = path.resolve(this.cwd, relPath)
-							fileExists = await fileExistsAtPath(absolutePath)
-							this.diffViewProvider.editType = fileExists ? "modify" : "create"
-						}
-
-						// pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini)
-						if (newContent.startsWith("```")) {
-							// this handles cases where it includes language specifiers like ```python ```js
-							newContent = newContent.split("\n").slice(1).join("\n").trim()
-						}
-						if (newContent.endsWith("```")) {
-							newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
-						}
-
-						if (!this.api.getModel().id.includes("claude")) {
-							// it seems not just llama models are doing this, but also gemini and potentially others
-							if (
-								newContent.includes("&gt;") ||
-								newContent.includes("&lt;") ||
-								newContent.includes("&quot;")
-							) {
-								newContent = newContent
-									.replace(/&gt;/g, ">")
-									.replace(/&lt;/g, "<")
-									.replace(/&quot;/g, '"')
-							}
-						}
-
-						// Determine if the path is outside the workspace
-						const fullPath = relPath ? path.resolve(this.cwd, removeClosingTag("path", relPath)) : ""
-						const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
-
-						const sharedMessageProps: ClineSayTool = {
-							tool: fileExists ? "editedExistingFile" : "newFileCreated",
-							path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
-							isOutsideWorkspace,
-						}
-						try {
-							if (block.partial) {
-								// update gui message
-								const partialMessage = JSON.stringify(sharedMessageProps)
-								await this.ask("tool", partialMessage, block.partial).catch(() => {})
-								// update editor
-								if (!this.diffViewProvider.isEditing) {
-									// open the editor and prepare to stream content in
-									await this.diffViewProvider.open(relPath)
-								}
-								// editor is open, stream content in
-								await this.diffViewProvider.update(
-									everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
-									false,
-								)
-								break
-							} else {
-								if (!relPath) {
-									this.consecutiveMistakeCount++
-									pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "path"))
-									await this.diffViewProvider.reset()
-									break
-								}
-								if (!newContent) {
-									this.consecutiveMistakeCount++
-									pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content"))
-									await this.diffViewProvider.reset()
-									break
-								}
-								if (!predictedLineCount) {
-									this.consecutiveMistakeCount++
-									pushToolResult(
-										await this.sayAndCreateMissingParamError("write_to_file", "line_count"),
-									)
-									await this.diffViewProvider.reset()
-									break
-								}
-								this.consecutiveMistakeCount = 0
-
-								// if isEditingFile false, that means we have the full contents of the file already.
-								// it's important to note how this 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 this part of the logic will always be called.
-								// in other words, you must always repeat the block.partial logic here
-								if (!this.diffViewProvider.isEditing) {
-									// show gui message before showing edit animation
-									const partialMessage = JSON.stringify(sharedMessageProps)
-									await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
-									await this.diffViewProvider.open(relPath)
-								}
-								await this.diffViewProvider.update(
-									everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
-									true,
-								)
-								await delay(300) // wait for diff view to update
-								this.diffViewProvider.scrollToFirstDiff()
-
-								// Check for code omissions before proceeding
-								if (
-									detectCodeOmission(
-										this.diffViewProvider.originalContent || "",
-										newContent,
-										predictedLineCount,
-									)
-								) {
-									if (this.diffStrategy) {
-										await this.diffViewProvider.revertChanges()
-										pushToolResult(
-											formatResponse.toolError(
-												`Content appears to be truncated (file has ${
-													newContent.split("\n").length
-												} 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.`,
-											),
-										)
-										break
-									} else {
-										vscode.window
-											.showWarningMessage(
-												"Potential code truncation detected. This happens when the AI reaches its max output limit.",
-												"Follow this guide to fix the issue",
-											)
-											.then((selection) => {
-												if (selection === "Follow this guide to fix the issue") {
-													vscode.env.openExternal(
-														vscode.Uri.parse(
-															"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
-														),
-													)
-												}
-											})
-									}
-								}
-
-								const completeMessage = JSON.stringify({
-									...sharedMessageProps,
-									content: fileExists ? undefined : newContent,
-									diff: fileExists
-										? formatResponse.createPrettyPatch(
-												relPath,
-												this.diffViewProvider.originalContent,
-												newContent,
-											)
-										: undefined,
-								} satisfies ClineSayTool)
-								const didApprove = await askApproval("tool", completeMessage)
-								if (!didApprove) {
-									await this.diffViewProvider.revertChanges()
-									break
-								}
-								const { newProblemsMessage, userEdits, finalContent } =
-									await this.diffViewProvider.saveChanges()
-								this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
-								if (userEdits) {
-									await this.say(
-										"user_feedback_diff",
-										JSON.stringify({
-											tool: fileExists ? "editedExistingFile" : "newFileCreated",
-											path: getReadablePath(this.cwd, relPath),
-											diff: userEdits,
-										} satisfies ClineSayTool),
-									)
-									pushToolResult(
-										`The user made the following updates to your content:\n\n${userEdits}\n\n` +
-											`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
-											`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
-												finalContent || "",
-											)}\n</final_file_content>\n\n` +
-											`Please note:\n` +
-											`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
-											`2. Proceed with the task using this updated file content as the new baseline.\n` +
-											`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
-											`${newProblemsMessage}`,
-									)
-								} else {
-									pushToolResult(
-										`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`,
-									)
-								}
-								await this.diffViewProvider.reset()
-								break
-							}
-						} catch (error) {
-							await handleError("writing file", error)
-							await this.diffViewProvider.reset()
-							break
-						}
-					}
+					case "write_to_file":
+						await writeToFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
+						break
 					case "apply_diff": {
 						const relPath: string | undefined = block.params.path
 						const diffContent: string | undefined = block.params.diff

+ 209 - 0
src/core/tools/writeToFileTool.ts

@@ -0,0 +1,209 @@
+import * as vscode from "vscode"
+
+import { Cline } from "../Cline"
+import { ClineSayTool } from "../../shared/ExtensionMessage"
+import { ToolUse } from "../assistant-message"
+import { formatResponse } from "../prompts/responses"
+import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types"
+import path from "path"
+import { fileExistsAtPath } from "../../utils/fs"
+import { addLineNumbers, stripLineNumbers } from "../../integrations/misc/extract-text"
+import { getReadablePath } from "../../utils/path"
+import { isPathOutsideWorkspace } from "../../utils/pathUtils"
+import { everyLineHasLineNumbers } from "../../integrations/misc/extract-text"
+import delay from "delay"
+import { detectCodeOmission } from "../../integrations/editor/detect-omission"
+
+export async function writeToFileTool(
+	cline: Cline,
+	block: ToolUse,
+	askApproval: AskApproval,
+	handleError: HandleError,
+	pushToolResult: PushToolResult,
+	removeClosingTag: RemoveClosingTag,
+) {
+	const relPath: string | undefined = block.params.path
+	let newContent: string | undefined = block.params.content
+	let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0")
+	if (!relPath || !newContent) {
+		// checking for newContent ensure relPath is complete
+		// wait so we can determine if it's a new file or editing an existing file
+		return
+	}
+
+	const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
+	if (!accessAllowed) {
+		await cline.say("rooignore_error", relPath)
+		pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
+
+		return
+	}
+
+	// Check if file exists using cached map or fs.access
+	let fileExists: boolean
+	if (cline.diffViewProvider.editType !== undefined) {
+		fileExists = cline.diffViewProvider.editType === "modify"
+	} else {
+		const absolutePath = path.resolve(cline.cwd, relPath)
+		fileExists = await fileExistsAtPath(absolutePath)
+		cline.diffViewProvider.editType = fileExists ? "modify" : "create"
+	}
+
+	// pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini)
+	if (newContent.startsWith("```")) {
+		// cline handles cases where it includes language specifiers like ```python ```js
+		newContent = newContent.split("\n").slice(1).join("\n").trim()
+	}
+	if (newContent.endsWith("```")) {
+		newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
+	}
+
+	if (!cline.api.getModel().id.includes("claude")) {
+		// it seems not just llama models are doing cline, but also gemini and potentially others
+		if (newContent.includes("&gt;") || newContent.includes("&lt;") || newContent.includes("&quot;")) {
+			newContent = newContent
+				.replace(/&gt;/g, ">")
+				.replace(/&lt;/g, "<")
+				.replace(/&quot;/g, '"')
+		}
+	}
+
+	// Determine if the path is outside the workspace
+	const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)) : ""
+	const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
+
+	const sharedMessageProps: ClineSayTool = {
+		tool: fileExists ? "editedExistingFile" : "newFileCreated",
+		path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
+		isOutsideWorkspace,
+	}
+	try {
+		if (block.partial) {
+			// update gui message
+			const partialMessage = JSON.stringify(sharedMessageProps)
+			await cline.ask("tool", partialMessage, block.partial).catch(() => {})
+			// update editor
+			if (!cline.diffViewProvider.isEditing) {
+				// open the editor and prepare to stream content in
+				await cline.diffViewProvider.open(relPath)
+			}
+			// editor is open, stream content in
+			await cline.diffViewProvider.update(
+				everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
+				false,
+			)
+			return
+		} else {
+			if (!relPath) {
+				cline.consecutiveMistakeCount++
+				pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
+				await cline.diffViewProvider.reset()
+				return
+			}
+			if (!newContent) {
+				cline.consecutiveMistakeCount++
+				pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
+				await cline.diffViewProvider.reset()
+				return
+			}
+			if (!predictedLineCount) {
+				cline.consecutiveMistakeCount++
+				pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "line_count"))
+				await cline.diffViewProvider.reset()
+				return
+			}
+			cline.consecutiveMistakeCount = 0
+
+			// if isEditingFile false, that means we have the full contents of the file already.
+			// 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.
+			// in other words, you must always repeat the block.partial logic here
+			if (!cline.diffViewProvider.isEditing) {
+				// show gui message before showing edit animation
+				const partialMessage = JSON.stringify(sharedMessageProps)
+				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
+				await cline.diffViewProvider.open(relPath)
+			}
+			await cline.diffViewProvider.update(
+				everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
+				true,
+			)
+			await delay(300) // wait for diff view to update
+			cline.diffViewProvider.scrollToFirstDiff()
+
+			// Check for code omissions before proceeding
+			if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
+				if (cline.diffStrategy) {
+					await cline.diffViewProvider.revertChanges()
+					pushToolResult(
+						formatResponse.toolError(
+							`Content appears to be truncated (file has ${
+								newContent.split("\n").length
+							} 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.`,
+						),
+					)
+					return
+				} else {
+					vscode.window
+						.showWarningMessage(
+							"Potential code truncation detected. cline happens when the AI reaches its max output limit.",
+							"Follow cline guide to fix the issue",
+						)
+						.then((selection) => {
+							if (selection === "Follow cline guide to fix the issue") {
+								vscode.env.openExternal(
+									vscode.Uri.parse(
+										"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
+									),
+								)
+							}
+						})
+				}
+			}
+
+			const completeMessage = JSON.stringify({
+				...sharedMessageProps,
+				content: fileExists ? undefined : newContent,
+				diff: fileExists
+					? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
+					: undefined,
+			} satisfies ClineSayTool)
+			const didApprove = await askApproval("tool", completeMessage)
+			if (!didApprove) {
+				await cline.diffViewProvider.revertChanges()
+				return
+			}
+			const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
+			cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
+			if (userEdits) {
+				await cline.say(
+					"user_feedback_diff",
+					JSON.stringify({
+						tool: fileExists ? "editedExistingFile" : "newFileCreated",
+						path: getReadablePath(cline.cwd, relPath),
+						diff: userEdits,
+					} satisfies ClineSayTool),
+				)
+				pushToolResult(
+					`The user made the following updates to your content:\n\n${userEdits}\n\n` +
+						`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
+						`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(
+							finalContent || "",
+						)}\n</final_file_content>\n\n` +
+						`Please note:\n` +
+						`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
+						`2. Proceed with the task using cline updated file content as the new baseline.\n` +
+						`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
+						`${newProblemsMessage}`,
+				)
+			} else {
+				pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`)
+			}
+			await cline.diffViewProvider.reset()
+			return
+		}
+	} catch (error) {
+		await handleError("writing file", error)
+		await cline.diffViewProvider.reset()
+		return
+	}
+}