refactor(core): unify v2 tool architecture (#31168)
This commit is contained in:
parent
effd27b239
commit
660a00d317
46 changed files with 2297 additions and 2041 deletions
|
|
@ -7,12 +7,14 @@
|
|||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
|
|
@ -78,16 +80,6 @@ export const toModelOutput = (output: Success, oldString: string, newString: str
|
|||
"```",
|
||||
].join("\n")
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }),
|
||||
],
|
||||
})
|
||||
|
||||
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
|
|
@ -97,80 +89,112 @@ const definition = Tool.make({
|
|||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
})
|
||||
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
})
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
if (parameters.oldString === parameters.newString) {
|
||||
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
|
||||
}
|
||||
if (parameters.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(
|
||||
permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
yield* unableToEdit(
|
||||
permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
}),
|
||||
)
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(input.oldString, ending)
|
||||
const newString = convertToLineEnding(input.newString, ending)
|
||||
const replacements = countOccurrences(source.text, oldString)
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
input.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
target,
|
||||
expected: source.content,
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
|
||||
}
|
||||
|
||||
yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }))
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(parameters.oldString, ending)
|
||||
const newString = convertToLineEnding(parameters.newString, ending)
|
||||
const replacements = countOccurrences(source.text, oldString)
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && parameters.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
parameters.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
target,
|
||||
expected: source.content,
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue