233 lines
7.3 KiB
Go
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
|
|
}
|