3d403bd685
New tools: - file_edit: surgical line-range or pattern edits (yolo only) - git_status: show changed files (read-only) - git_diff: show unstaged/staged diff (read-only) - git_log: show recent commits (read-only) Agent improvements: - parallel execution for independent read-only tool calls - mutating tools (file_write, file_edit, shell_run) run sequentially - tool output preview in logs with [output] marker TUI improvements: - tool cards now show truncated output preview (up to 8 lines) - mergeToolLog updates placeholder cards with results - toolStatusText keeps activity bar single-line Documentation: - README updated with git tools and file_edit descriptions
179 lines
5.0 KiB
Go
179 lines
5.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"agentu/pkg/llm"
|
|
)
|
|
|
|
type FileEditTool struct {
|
|
workingDir string
|
|
}
|
|
|
|
func NewFileEditTool(workingDir string) *FileEditTool {
|
|
return &FileEditTool{workingDir: workingDir}
|
|
}
|
|
|
|
func (*FileEditTool) Mutates() bool { return true }
|
|
|
|
func (t *FileEditTool) Definition() llm.Tool {
|
|
return llm.Tool{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: "file_edit",
|
|
Description: "Edit an existing UTF-8 text file by replacing an inclusive 1-based line range or an exact old text pattern.",
|
|
Parameters: JSONSchema(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Existing file path relative to the configured agent working directory."},
|
|
"start_line": {"type": "integer", "description": "1-based first line for a line-range edit."},
|
|
"end_line": {"type": "integer", "description": "Optional 1-based final line; defaults to start_line when omitted."},
|
|
"old": {"type": "string", "description": "Exact text to replace for a pattern edit; not regex."},
|
|
"new": {"type": "string", "description": "Replacement text; may be empty string for deletion."},
|
|
"replace_all": {"type": "boolean", "description": "When true, replace every exact old occurrence; otherwise require exactly one occurrence."}
|
|
},
|
|
"required": ["path", "new"],
|
|
"additionalProperties": false
|
|
}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
type fileEditArgs struct {
|
|
Path string `json:"path"`
|
|
StartLine int `json:"start_line"`
|
|
EndLine int `json:"end_line"`
|
|
Old string `json:"old"`
|
|
New *string `json:"new"`
|
|
ReplaceAll bool `json:"replace_all"`
|
|
}
|
|
|
|
func (t *FileEditTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
var args fileEditArgs
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", fmt.Errorf("parse arguments: %w", err)
|
|
}
|
|
if strings.TrimSpace(args.Path) == "" {
|
|
return "", errors.New("path is required")
|
|
}
|
|
if args.New == nil {
|
|
return "", errors.New("new is required")
|
|
}
|
|
path, err := resolvePath(t.workingDir, args.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot access %s: %w", args.Path, err)
|
|
}
|
|
if info.IsDir() {
|
|
return "", fmt.Errorf("%s is a directory, not a file", args.Path)
|
|
}
|
|
perm := info.Mode().Perm()
|
|
|
|
hasLine := args.StartLine > 0 || args.EndLine > 0
|
|
hasOld := strings.TrimSpace(args.Old) != ""
|
|
|
|
if hasLine && hasOld {
|
|
return "", errors.New("provide either start_line/end_line or old, not both")
|
|
}
|
|
if !hasLine && !hasOld {
|
|
return "", errors.New("provide either start_line/end_line or old")
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
content := string(data)
|
|
|
|
var result string
|
|
if hasOld {
|
|
result, err = editByPattern(content, args.Path, args.Old, *args.New, args.ReplaceAll)
|
|
} else {
|
|
result, err = editByLineRange(content, args.Path, args.StartLine, args.EndLine, *args.New)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(path, []byte(result), perm); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
switch {
|
|
case hasOld:
|
|
count := strings.Count(content, args.Old)
|
|
if args.ReplaceAll {
|
|
return fmt.Sprintf("edited %s: replaced %d occurrences", args.Path, count), nil
|
|
}
|
|
return fmt.Sprintf("edited %s: replaced 1 occurrence", args.Path), nil
|
|
default:
|
|
endLine := args.EndLine
|
|
if endLine == 0 {
|
|
endLine = args.StartLine
|
|
}
|
|
return fmt.Sprintf("edited %s: replaced lines %d-%d", args.Path, args.StartLine, endLine), nil
|
|
}
|
|
}
|
|
|
|
func editByLineRange(content, displayPath string, startLine, endLine int, replacement string) (string, error) {
|
|
if startLine < 1 {
|
|
return "", errors.New("start_line must be >= 1")
|
|
}
|
|
if endLine == 0 {
|
|
endLine = startLine
|
|
}
|
|
if endLine < startLine {
|
|
return "", fmt.Errorf("end_line (%d) must be >= start_line (%d)", endLine, startLine)
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
if startLine > len(lines) {
|
|
return "", fmt.Errorf("line range %d-%d is outside file with %d lines", startLine, endLine, len(lines))
|
|
}
|
|
if endLine > len(lines) {
|
|
return "", fmt.Errorf("line range %d-%d is outside file with %d lines", startLine, endLine, len(lines))
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(strings.Join(lines[:startLine-1], "\n"))
|
|
if startLine > 1 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(replacement)
|
|
if endLine < len(lines) {
|
|
if !strings.HasSuffix(replacement, "\n") && replacement != "" {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(strings.Join(lines[endLine:], "\n"))
|
|
}
|
|
|
|
return b.String(), nil
|
|
}
|
|
|
|
func editByPattern(content, displayPath, old, replacement string, replaceAll bool) (string, error) {
|
|
count := strings.Count(content, old)
|
|
if count == 0 {
|
|
return "", errors.New("old text not found")
|
|
}
|
|
if !replaceAll && count != 1 {
|
|
return "", fmt.Errorf("old text matched %d occurrences; set replace_all=true or provide a more specific old value", count)
|
|
}
|
|
return strings.ReplaceAll(content, old, replacement), nil
|
|
}
|