Files
loveuer 3d403bd685 feat: Phase 1 code-agent capabilities — file_edit, git tools, parallel execution, tool output preview
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
2026-06-24 19:47:14 -07:00

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
}