refactor(tool): convert apply_patch to Tool.defineEffect (#21938)

This commit is contained in:
Kit Langton
2026-04-10 19:42:14 -04:00
committed by GitHub
parent d9d5a0615e
commit 0556774097
5 changed files with 264 additions and 230 deletions
+56 -37
View File
@@ -1,16 +1,16 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import * as fs from "fs/promises" import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Bus } from "../bus" import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Patch } from "../patch" import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { trimDiff } from "./edit" import { trimDiff } from "./edit"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem" import { AppFileSystem } from "../filesystem"
import DESCRIPTION from "./apply_patch.txt" import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file" import { File } from "../file"
import { Format } from "../format" import { Format } from "../format"
@@ -19,12 +19,16 @@ const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"), patchText: z.string().describe("The full patch text that describes all changes to be made"),
}) })
export const ApplyPatchTool = Tool.define("apply_patch", { export const ApplyPatchTool = Tool.defineEffect(
description: DESCRIPTION, "apply_patch",
parameters: PatchParams, Effect.gen(function* () {
async execute(params, ctx) { const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
if (!params.patchText) { if (!params.patchText) {
throw new Error("patchText is required") return yield* Effect.fail(new Error("patchText is required"))
} }
// Parse the patch to get hunks // Parse the patch to get hunks
@@ -33,15 +37,15 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
const parseResult = Patch.parsePatch(params.patchText) const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks hunks = parseResult.hunks
} catch (error) { } catch (error) {
throw new Error(`apply_patch verification failed: ${error}`) return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
} }
if (hunks.length === 0) { if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") { if (normalized === "*** Begin Patch\n*** End Patch") {
throw new Error("patch rejected: empty patch") return yield* Effect.fail(new Error("patch rejected: empty patch"))
} }
throw new Error("apply_patch verification failed: no hunks found") return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
} }
// Validate file paths and check permissions // Validate file paths and check permissions
@@ -60,7 +64,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
for (const hunk of hunks) { for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path) const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath) yield* assertExternalDirectoryEffect(ctx, filePath)
switch (hunk.type) { switch (hunk.type) {
case "add": { case "add": {
@@ -92,12 +96,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
case "update": { case "update": {
// Check if file exists for update // Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null) const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stats || stats.isDirectory()) { if (!stats || stats.type === "Directory") {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) return yield* Effect.fail(
new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
)
} }
const oldContent = await fs.readFile(filePath, "utf-8") const oldContent = yield* afs.readFileString(filePath)
let newContent = oldContent let newContent = oldContent
// Apply the update chunks to get new content // Apply the update chunks to get new content
@@ -105,7 +111,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content newContent = fileUpdate.content
} catch (error) { } catch (error) {
throw new Error(`apply_patch verification failed: ${error}`) return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
} }
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
@@ -118,7 +124,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
} }
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath) yield* assertExternalDirectoryEffect(ctx, movePath)
fileChanges.push({ fileChanges.push({
filePath, filePath,
@@ -136,9 +142,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
} }
case "delete": { case "delete": {
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { const contentToDelete = yield* afs.readFileString(filePath).pipe(
throw new Error(`apply_patch verification failed: ${error}`) Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))),
}) )
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length const deletions = contentToDelete.split("\n").length
@@ -172,7 +178,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// Check permissions if needed // Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
await ctx.ask({ yield* Effect.promise(() =>
ctx.ask({
permission: "edit", permission: "edit",
patterns: relativePaths, patterns: relativePaths,
always: ["*"], always: ["*"],
@@ -181,7 +188,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
diff: totalDiff, diff: totalDiff,
files, files,
}, },
}) }),
)
// Apply the changes // Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
@@ -191,51 +199,51 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
switch (change.type) { switch (change.type) {
case "add": case "add":
// Create parent directories (recursive: true is safe on existing/root dirs) // 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") yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "add" }) updates.push({ file: change.filePath, event: "add" })
break break
case "update": case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8") yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "change" }) updates.push({ file: change.filePath, event: "change" })
break break
case "move": case "move":
if (change.movePath) { if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs) // 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") yield* afs.writeWithDirs(change.movePath!, change.newContent)
await fs.unlink(change.filePath) yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" }) updates.push({ file: change.movePath, event: "add" })
} }
break break
case "delete": case "delete":
await fs.unlink(change.filePath) yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.filePath, event: "unlink" })
break break
} }
if (edited) { if (edited) {
await Format.file(edited) yield* format.file(edited)
Bus.publish(File.Event.Edited, { file: edited }) Bus.publish(File.Event.Edited, { file: edited })
} }
} }
// Publish file change events // Publish file change events
for (const update of updates) { for (const update of updates) {
await Bus.publish(FileWatcher.Event.Updated, update) Bus.publish(FileWatcher.Event.Updated, update)
} }
// Notify LSP of file changes and collect diagnostics // Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) { for (const change of fileChanges) {
if (change.type === "delete") continue if (change.type === "delete") continue
const target = change.movePath ?? change.filePath const target = change.movePath ?? change.filePath
await LSP.touchFile(target, true) yield* lsp.touchFile(target, true)
} }
const diagnostics = await LSP.diagnostics() const diagnostics = yield* lsp.diagnostics()
// Generate output summary // Generate output summary
const summaryLines = fileChanges.map((change) => { const summaryLines = fileChanges.map((change) => {
@@ -255,13 +263,15 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
for (const change of fileChanges) { for (const change of fileChanges) {
if (change.type === "delete") continue if (change.type === "delete") continue
const target = change.movePath ?? change.filePath const target = change.movePath ?? change.filePath
const normalized = Filesystem.normalizePath(target) const normalized = AppFileSystem.normalizePath(target)
const issues = diagnostics[normalized] ?? [] const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1) const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) { if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix = const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" 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<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>` output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
} }
} }
@@ -275,5 +285,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}, },
output, output,
} }
},
}) })
return {
description: DESCRIPTION,
parameters: PatchParams,
async execute(params: z.infer<typeof PatchParams>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
}
}),
)
+5 -1
View File
@@ -34,6 +34,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@@ -91,6 +92,7 @@ export namespace ToolRegistry {
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner | ChildProcessSpawner
| Ripgrep.Service | Ripgrep.Service
| Format.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -113,6 +115,7 @@ export namespace ToolRegistry {
const writetool = yield* WriteTool const writetool = yield* WriteTool
const edit = yield* EditTool const edit = yield* EditTool
const greptool = yield* GrepTool const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -183,7 +186,7 @@ export namespace ToolRegistry {
search: Tool.init(websearch), search: Tool.init(websearch),
code: Tool.init(codesearch), code: Tool.init(codesearch),
skill: Tool.init(SkillTool), skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool), patch: Tool.init(patchtool),
question: Tool.init(question), question: Tool.init(question),
lsp: Tool.init(lsptool), lsp: Tool.init(lsptool),
plan: Tool.init(plan), plan: Tool.init(plan),
@@ -325,6 +328,7 @@ export namespace ToolRegistry {
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
), ),
@@ -38,6 +38,7 @@ import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server" import { reply, TestLLMServer } from "../lib/llm-server"
@@ -174,6 +175,7 @@ function makeHttp() {
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -54,6 +54,7 @@ import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
Log.init({ print: false }) Log.init({ print: false })
@@ -138,6 +139,7 @@ function makeHttp() {
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -1,11 +1,17 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import * as fs from "fs/promises" import * as fs from "fs/promises"
import { Effect, ManagedRuntime, Layer } from "effect"
import { ApplyPatchTool } from "../../src/tool/apply_patch" import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
const baseCtx = { const baseCtx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""), messageID: MessageID.make(""),
@@ -40,7 +46,8 @@ type ToolCtx = typeof baseCtx & {
} }
const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const tool = await ApplyPatchTool.init() const info = await runtime.runPromise(ApplyPatchTool)
const tool = await info.init()
return tool.execute(params, ctx) return tool.execute(params, ctx)
} }