first commit
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user