Files
2026-08-14 23:41:57 +08:00

233 lines
7.3 KiB
Go

// This file implements the edit tool (US-017): exact string replacement within
// a file. old_string must match exactly; if it is not unique (and replace_all
// is false) the edit is rejected. A unified-style diff of the change is returned
// for the UI to render. Paths resolve against a Root with the same traversal
// guard as the read/write tools.
package agenttool
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// EditTool performs exact string replacements in files under Root.
type EditTool struct {
// Root bounds all edits; a path resolving outside Root is rejected. Empty
// Root defaults to the current working directory.
Root string
// ExtraRoots are additional trusted directories an edit may target even though
// they lie outside Root. It exists for the skills directory so the model can
// modify existing skills that live outside the workspace.
ExtraRoots []string
// Snap, when non-nil, records the file's prior content before it is edited so
// the /rewind command can roll the change back. It is shared with the write tool.
Snap *FileSnapshotRecorder
}
// editToolArgs is the decoded argument shape for EditTool.
type editToolArgs struct {
Path string `json:"path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all,omitempty"`
}
// Name implements AgentTool.
func (t *EditTool) Name() string { return "edit" }
// Description implements AgentTool.
func (t *EditTool) Description() string {
return "Replace an exact string in a file. old_string must be unique unless " +
"replace_all is set. Returns a diff of the change."
}
// Schema implements AgentTool.
func (t *EditTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to edit, relative to the workspace root."},
"old_string": {"type": "string", "description": "Exact text to replace."},
"new_string": {"type": "string", "description": "Replacement text."},
"replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring a unique match."}
},
"required": ["path", "old_string", "new_string"],
"additionalProperties": false
}`)
}
// ExecutionMode implements AgentTool. Edits mutate the filesystem → sequential.
func (t *EditTool) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionSequential
}
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
// resolveWithin boundary policy, so every file tool enforces the same
// workspace-escape guard while edits can also reach trusted extra roots.
func (t *EditTool) resolvePath(p string) (string, error) {
if len(t.ExtraRoots) == 0 {
return resolveWithin(t.Root, p)
}
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
}
// Execute implements AgentTool. Edit failures (no match, non-unique match,
// missing file, out-of-root) are encoded as error results.
func (t *EditTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
a, bad := decodeArgs[editToolArgs](args, "edit")
if bad != nil {
return *bad, nil
}
if a.Path == "" {
return errorResult("edit: path is required"), nil
}
if a.OldString == a.NewString {
return errorResult("edit: old_string and new_string are identical; nothing to change"), nil
}
full, err := t.resolvePath(a.Path)
if err != nil {
return errorResult("edit: " + err.Error()), nil
}
data, err := os.ReadFile(full)
if err != nil {
if os.IsNotExist(err) {
return errorResult(fmt.Sprintf("edit: file %q does not exist", a.Path)), nil
}
return errorResult(fmt.Sprintf("edit: cannot read %q: %v", a.Path, err)), nil
}
original := string(data)
count := strings.Count(original, a.OldString)
if count == 0 {
return errorResult(fmt.Sprintf("edit: old_string not found in %q", a.Path)), nil
}
if count > 1 && !a.ReplaceAll {
return errorResult(fmt.Sprintf("edit: old_string is not unique in %q (%d matches); provide more context or set replace_all", a.Path, count)), nil
}
var updated string
if a.ReplaceAll {
updated = strings.ReplaceAll(original, a.OldString, a.NewString)
} else {
updated = strings.Replace(original, a.OldString, a.NewString, 1)
}
// Snapshot the prior state before mutating so /rewind can restore it.
t.Snap.Record(full)
if err := os.WriteFile(full, []byte(updated), filePerm); err != nil {
return errorResult(fmt.Sprintf("edit: cannot write %q: %v", a.Path, err)), nil
}
diff := unifiedDiff(a.Path, original, updated)
replaced := 1
if a.ReplaceAll {
replaced = count
}
msg := fmt.Sprintf("Edited %s (%d replacement(s))\n%s", a.Path, replaced, diff)
return agentcore.AgentToolResult{
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
Details: map[string]any{"path": a.Path, "replacements": replaced, "diff": diff},
}, nil
}
// unifiedDiff produces a minimal line-based diff between old and new content.
// It is not a full unified-diff implementation (no hunk coalescing); it emits a
// header plus per-line -/+ markers, which is enough for a UI to render the
// change. Unchanged lines are shown with a leading space for context.
func unifiedDiff(path, oldContent, newContent string) string {
oldLines := splitLinesKeep(oldContent)
newLines := splitLinesKeep(newContent)
var b strings.Builder
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)
// Longest common subsequence over lines drives the -/+ markers.
ops := diffLines(oldLines, newLines)
for _, op := range ops {
switch op.kind {
case diffEqual:
fmt.Fprintf(&b, " %s\n", op.text)
case diffDelete:
fmt.Fprintf(&b, "-%s\n", op.text)
case diffInsert:
fmt.Fprintf(&b, "+%s\n", op.text)
}
}
return b.String()
}
// splitLinesKeep splits s into lines, dropping a single trailing newline so an
// empty final element is not produced for the common "ends with \n" case.
func splitLinesKeep(s string) []string {
if s == "" {
return nil
}
lines := strings.Split(s, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
type diffKind int
const (
diffEqual diffKind = iota
diffDelete
diffInsert
)
type diffOp struct {
kind diffKind
text string
}
// diffLines computes a line diff via a standard LCS dynamic-programming table,
// then backtracks to emit equal/delete/insert ops in order.
func diffLines(a, b []string) []diffOp {
n, m := len(a), len(b)
// lcs[i][j] = length of LCS of a[i:] and b[j:].
lcs := make([][]int, n+1)
for i := range lcs {
lcs[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
var ops []diffOp
i, j := 0, 0
for i < n && j < m {
if a[i] == b[j] {
ops = append(ops, diffOp{diffEqual, a[i]})
i++
j++
} else if lcs[i+1][j] >= lcs[i][j+1] {
ops = append(ops, diffOp{diffDelete, a[i]})
i++
} else {
ops = append(ops, diffOp{diffInsert, b[j]})
j++
}
}
for ; i < n; i++ {
ops = append(ops, diffOp{diffDelete, a[i]})
}
for ; j < m; j++ {
ops = append(ops, diffOp{diffInsert, b[j]})
}
return ops
}