apply-patch.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. export * as ApplyPatchTool from "./apply-patch"
  2. import { ToolFailure } from "@opencode-ai/llm"
  3. import { FileDiff } from "@opencode-ai/schema/file-diff"
  4. import { createTwoFilesPatch, diffLines } from "diff"
  5. import { Effect, Layer, Schema } from "effect"
  6. import { makeLocationNode } from "../effect/app-node"
  7. import { FileMutation } from "../file-mutation"
  8. import { FSUtil } from "../fs-util"
  9. import { LocationMutation } from "../location-mutation"
  10. import { Patch } from "../patch"
  11. import { PermissionV2 } from "../permission"
  12. import { ToolRegistry } from "./registry"
  13. import { Tool } from "./tool"
  14. import { Tools } from "./tools"
  15. export const name = "apply_patch"
  16. export const Input = Schema.Struct({
  17. patchText: Schema.String.annotate({
  18. description: "The full patch text describing add, update, and delete operations",
  19. }),
  20. })
  21. export const Applied = Schema.Struct({
  22. type: Schema.Literals(["add", "update", "delete"]),
  23. resource: Schema.String,
  24. target: Schema.String,
  25. })
  26. export const Output = Schema.Struct({
  27. applied: Schema.Array(Applied),
  28. files: Schema.Array(FileDiff.Info),
  29. })
  30. export type Output = typeof Output.Type
  31. export const toModelOutput = (output: Output) =>
  32. [
  33. "Applied patch sequentially:",
  34. ...output.applied.map(
  35. (item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`,
  36. ),
  37. ].join("\n")
  38. type Prepared =
  39. | (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
  40. readonly target: LocationMutation.Target
  41. readonly before: string
  42. readonly after: string
  43. })
  44. | (Extract<Patch.Hunk, { readonly type: "update" }> & {
  45. readonly target: LocationMutation.Target
  46. readonly source: Uint8Array
  47. readonly content: string
  48. readonly before: string
  49. readonly after: string
  50. })
  51. const layer = Layer.effectDiscard(
  52. Effect.gen(function* () {
  53. const tools = yield* Tools.Service
  54. const mutation = yield* LocationMutation.Service
  55. const files = yield* FileMutation.Service
  56. const fs = yield* FSUtil.Service
  57. const permission = yield* PermissionV2.Service
  58. yield* tools
  59. .register({
  60. [name]: Tool.withPermission(
  61. Tool.make({
  62. description:
  63. "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
  64. input: Input,
  65. output: Output,
  66. toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
  67. execute: (input, context) => {
  68. const applied: Array<typeof Applied.Type> = []
  69. const fail = (path: string) => {
  70. const prefix =
  71. applied.length === 0
  72. ? `Unable to apply patch at ${path}`
  73. : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
  74. return new ToolFailure({ message: prefix })
  75. }
  76. return Effect.gen(function* () {
  77. const source = {
  78. type: "tool" as const,
  79. messageID: context.assistantMessageID,
  80. callID: context.toolCallID,
  81. }
  82. if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
  83. const hunks = yield* Effect.try({
  84. try: () => Patch.parse(input.patchText),
  85. catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
  86. })
  87. if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
  88. const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
  89. if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
  90. const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
  91. for (const hunk of hunks)
  92. targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
  93. const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
  94. for (const { target } of targets) {
  95. const external = target.externalDirectory
  96. if (external) externalDirectories.set(external.resource, external)
  97. }
  98. for (const external of externalDirectories.values()) {
  99. yield* permission.assert({
  100. ...LocationMutation.externalDirectoryPermission(external),
  101. sessionID: context.sessionID,
  102. agent: context.agent,
  103. source,
  104. })
  105. }
  106. yield* permission.assert({
  107. action: "edit",
  108. resources: [...new Set(targets.map(({ target }) => target.resource))],
  109. save: ["*"],
  110. sessionID: context.sessionID,
  111. agent: context.agent,
  112. source,
  113. })
  114. const prepared: Prepared[] = []
  115. for (const { hunk, target } of targets) {
  116. yield* Effect.gen(function* () {
  117. if (hunk.type === "add") {
  118. prepared.push({
  119. ...hunk,
  120. target,
  121. before: "",
  122. after:
  123. hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
  124. })
  125. return
  126. }
  127. if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
  128. const source = yield* fs.readFile(target.canonical)
  129. const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
  130. const before = original.replace(/^\uFEFF/, "")
  131. if (hunk.type === "delete") {
  132. prepared.push({ ...hunk, target, before, after: "" })
  133. return
  134. }
  135. const update = Patch.derive(hunk.path, hunk.chunks, original)
  136. prepared.push({
  137. ...hunk,
  138. target,
  139. source,
  140. content: Patch.joinBom(update.content, update.bom),
  141. before,
  142. after: update.content,
  143. })
  144. }).pipe(Effect.mapError(() => fail(hunk.path)))
  145. }
  146. const patchFiles = prepared.map(patchFile)
  147. yield* Effect.forEach(
  148. prepared,
  149. (change) =>
  150. Effect.gen(function* () {
  151. if (change.type === "add") {
  152. const result = yield* files.create({
  153. target: change.target,
  154. content:
  155. change.contents.endsWith("\n") || change.contents === ""
  156. ? change.contents
  157. : `${change.contents}\n`,
  158. })
  159. applied.push({ type: change.type, resource: result.resource, target: result.target })
  160. return
  161. }
  162. if (change.type === "delete") {
  163. const result = yield* files.remove({ target: change.target })
  164. applied.push({ type: change.type, resource: result.resource, target: result.target })
  165. return
  166. }
  167. const result = yield* files.writeIfUnchanged({
  168. target: change.target,
  169. expected: change.source,
  170. content: change.content,
  171. })
  172. applied.push({ type: change.type, resource: result.resource, target: result.target })
  173. }).pipe(Effect.mapError(() => fail(change.path))),
  174. { discard: true },
  175. )
  176. return { applied, files: patchFiles }
  177. }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
  178. },
  179. }),
  180. "edit",
  181. ),
  182. })
  183. .pipe(Effect.orDie)
  184. }),
  185. )
  186. export const node = makeLocationNode({
  187. name: "tool/apply-patch",
  188. layer,
  189. deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
  190. })
  191. function patchFile(change: Prepared): typeof FileDiff.Info.Type {
  192. const counts = diffLines(change.before, change.after).reduce(
  193. (result, item) => ({
  194. additions: result.additions + (item.added ? (item.count ?? 0) : 0),
  195. deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
  196. }),
  197. { additions: 0, deletions: 0 },
  198. )
  199. return {
  200. file: change.target.resource,
  201. patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
  202. status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
  203. ...counts,
  204. }
  205. }