import z from "zod" import * as path from "path" import * as fs from "fs/promises" import { Tool } from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectory } from "./external-directory" import { trimDiff } from "./edit" import { LSP } from "../lsp" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), }) export const ApplyPatchTool = Tool.define("apply_patch", { description: DESCRIPTION, parameters: PatchParams, async execute(params, ctx) { if (!params.patchText) { throw new Error("patchText is required") } // Parse the patch to get hunks let hunks: Patch.Hunk[] try { const parseResult = Patch.parsePatch(params.patchText) hunks = parseResult.hunks } catch (error) { throw new Error(`apply_patch verification failed: ${error}`) } if (hunks.length === 0) { const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() if (normalized === "*** Begin Patch\n*** End Patch") { throw new Error("patch rejected: empty patch") } throw new Error("apply_patch verification failed: no hunks found") } // Validate file paths and check permissions const fileChanges: Array<{ filePath: string oldContent: string newContent: string type: "add" | "update" | "delete" | "move" movePath?: string diff: string additions: number deletions: number }> = [] let totalDiff = "" for (const hunk of hunks) { const filePath = path.resolve(Instance.directory, hunk.path) await assertExternalDirectory(ctx, filePath) switch (hunk.type) { case "add": { const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) let additions = 0 let deletions = 0 for (const change of diffLines(oldContent, newContent)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } fileChanges.push({ filePath, oldContent, newContent, type: "add", diff, additions, deletions, }) totalDiff += diff + "\n" break } case "update": { // Check if file exists for update const stats = await fs.stat(filePath).catch(() => null) if (!stats || stats.isDirectory()) { throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) } const oldContent = await fs.readFile(filePath, "utf-8") let newContent = oldContent // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content } catch (error) { throw new Error(`apply_patch verification failed: ${error}`) } const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) let additions = 0 let deletions = 0 for (const change of diffLines(oldContent, newContent)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined await assertExternalDirectory(ctx, movePath) fileChanges.push({ filePath, oldContent, newContent, type: hunk.move_path ? "move" : "update", movePath, diff, additions, deletions, }) totalDiff += diff + "\n" break } case "delete": { const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { throw new Error(`apply_patch verification failed: ${error}`) }) const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length fileChanges.push({ filePath, oldContent: contentToDelete, newContent: "", type: "delete", diff: deleteDiff, additions: 0, deletions, }) totalDiff += deleteDiff + "\n" break } } } // Build per-file metadata for UI rendering (used for both permission and result) const files = fileChanges.map((change) => ({ filePath: change.filePath, relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, diff: change.diff, before: change.oldContent, after: change.newContent, additions: change.additions, deletions: change.deletions, movePath: change.movePath, })) // Check permissions if needed const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) await ctx.ask({ permission: "edit", patterns: relativePaths, always: ["*"], metadata: { filepath: relativePaths.join(", "), diff: totalDiff, files, }, }) // Apply the changes const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] for (const change of fileChanges) { const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) switch (change.type) { case "add": // Create parent directories (recursive: true is safe on existing/root dirs) await fs.mkdir(path.dirname(change.filePath), { recursive: true }) await fs.writeFile(change.filePath, change.newContent, "utf-8") updates.push({ file: change.filePath, event: "add" }) break case "update": await fs.writeFile(change.filePath, change.newContent, "utf-8") updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) await fs.mkdir(path.dirname(change.movePath), { recursive: true }) await fs.writeFile(change.movePath, change.newContent, "utf-8") await fs.unlink(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) } break case "delete": await fs.unlink(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) break } if (edited) { await Bus.publish(File.Event.Edited, { file: edited, }) } } // Publish file change events for (const update of updates) { await Bus.publish(FileWatcher.Event.Updated, update) } // Notify LSP of file changes and collect diagnostics for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath await LSP.touchFile(target, true) } const diagnostics = await LSP.diagnostics() // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` } if (change.type === "delete") { return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` } const target = change.movePath ?? change.filePath return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` // Report LSP errors for changed files const MAX_DIAGNOSTICS_PER_FILE = 20 for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath const normalized = Filesystem.normalizePath(target) const issues = diagnostics[normalized] ?? [] const errors = issues.filter((item) => item.severity === 1) if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` } } return { title: output, metadata: { diff: totalDiff, files, diagnostics, }, output, } }, })