zot/internal/agent/tools/edit.go
2026-04-17 20:36:38 +02:00

239 lines
6.1 KiB
Go

package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/patriceckhart/zot/internal/core"
"github.com/patriceckhart/zot/internal/provider"
)
// EditTool applies one or more exact-match replacements to a file.
type EditTool struct {
CWD string
Sandbox *Sandbox
}
type editOp struct {
OldText string `json:"oldText"`
NewText string `json:"newText"`
}
type editArgs struct {
Path string `json:"path"`
Edits []editOp `json:"edits"`
}
const editSchema = `{
"type":"object",
"properties":{
"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},
"edits":{
"type":"array",
"description":"One or more targeted replacements. Each oldText must match exactly once in the original file.",
"items":{
"type":"object",
"properties":{
"oldText":{"type":"string","description":"Exact text to replace. Must be unique in the file."},
"newText":{"type":"string","description":"Replacement text."}
},
"required":["oldText","newText"],
"additionalProperties":false
}
}
},
"required":["path","edits"],
"additionalProperties":false
}`
func (t *EditTool) Name() string { return "edit" }
func (t *EditTool) Description() string {
return "Edit an existing file via exact-match replacements. Preserves line endings and BOM."
}
func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
var a editArgs
if err := json.Unmarshal(raw, &a); err != nil {
return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
}
if a.Path == "" {
return core.ToolResult{}, fmt.Errorf("path is required")
}
if len(a.Edits) == 0 {
return core.ToolResult{}, fmt.Errorf("at least one edit is required")
}
path := resolvePath(t.CWD, a.Path)
if err := t.Sandbox.CheckPath(path); err != nil {
return core.ToolResult{}, err
}
orig, err := os.ReadFile(path)
if err != nil {
return core.ToolResult{}, err
}
// Detect BOM and line endings.
var bom []byte
if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
bom = orig[:3]
orig = orig[3:]
}
nl := detectLineEnding(orig)
// Normalize to \n for matching.
body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
// Validate all edits first (against original content, not sequentially).
for i, e := range a.Edits {
if e.OldText == "" {
return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
}
if e.OldText == e.NewText {
return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
}
count := strings.Count(body, e.OldText)
if count == 0 {
return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)
}
if count > 1 {
return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
}
}
// Apply atomically (sorted by position so offsets stay valid as we splice).
type span struct {
start, end int
replacement string
}
spans := make([]span, 0, len(a.Edits))
for _, e := range a.Edits {
idx := strings.Index(body, e.OldText)
spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
}
// Check for overlaps.
for i := 0; i < len(spans); i++ {
for j := i + 1; j < len(spans); j++ {
if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")
}
}
}
// Sort ascending.
for i := 1; i < len(spans); i++ {
for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
spans[j-1], spans[j] = spans[j], spans[j-1]
}
}
var out strings.Builder
prev := 0
for _, s := range spans {
out.WriteString(body[prev:s.start])
out.WriteString(s.replacement)
prev = s.end
}
out.WriteString(body[prev:])
newBody := out.String()
// Restore line endings.
if nl == "\r\n" {
newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
}
final := append([]byte{}, bom...)
final = append(final, []byte(newBody)...)
if err := os.WriteFile(path, final, 0o644); err != nil {
return core.ToolResult{}, err
}
diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path)
return core.ToolResult{
Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}},
Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
}, nil
}
func detectLineEnding(b []byte) string {
if bytes.Contains(b, []byte("\r\n")) {
return "\r\n"
}
return "\n"
}
// unifiedDiff is a minimal unified diff good enough for tool output.
func unifiedDiff(name, a, b string) string {
if a == b {
return ""
}
aLines := strings.Split(a, "\n")
bLines := strings.Split(b, "\n")
// Use simple LCS-based diff.
ops := diffLines(aLines, bLines)
var sb strings.Builder
fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name)
for _, op := range ops {
switch op.kind {
case ' ':
fmt.Fprintf(&sb, " %s\n", op.line)
case '-':
fmt.Fprintf(&sb, "-%s\n", op.line)
case '+':
fmt.Fprintf(&sb, "+%s\n", op.line)
}
}
return sb.String()
}
type diffOp struct {
kind byte
line string
}
func diffLines(a, b []string) []diffOp {
// LCS table.
m, n := len(a), len(b)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if a[i] == b[j] {
dp[i+1][j+1] = dp[i][j] + 1
} else if dp[i+1][j] >= dp[i][j+1] {
dp[i+1][j+1] = dp[i+1][j]
} else {
dp[i+1][j+1] = dp[i][j+1]
}
}
}
// Backtrack.
var ops []diffOp
i, j := m, n
for i > 0 && j > 0 {
if a[i-1] == b[j-1] {
ops = append([]diffOp{{' ', a[i-1]}}, ops...)
i--
j--
} else if dp[i][j-1] >= dp[i-1][j] {
ops = append([]diffOp{{'+', b[j-1]}}, ops...)
j--
} else {
ops = append([]diffOp{{'-', a[i-1]}}, ops...)
i--
}
}
for i > 0 {
ops = append([]diffOp{{'-', a[i-1]}}, ops...)
i--
}
for j > 0 {
ops = append([]diffOp{{'+', b[j-1]}}, ops...)
j--
}
return ops
}