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
This commit is contained in:
@@ -81,6 +81,7 @@ type FileWriteTool struct {
|
||||
func NewFileWriteTool(workingDir string) *FileWriteTool {
|
||||
return &FileWriteTool{workingDir: workingDir}
|
||||
}
|
||||
func (*FileWriteTool) Mutates() bool { return true }
|
||||
|
||||
func (t *FileWriteTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileEditReplacesLineRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edit := NewFileEditTool(dir)
|
||||
out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","start_line":2,"end_line":2,"new":"TWO\n"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "replaced lines 2-2") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "one\nTWO\nthree\n" {
|
||||
t.Fatalf("file content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEditReplacesExactPattern(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha beta alpha"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edit := NewFileEditTool(dir)
|
||||
out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"beta","new":"BETA"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "replaced 1 occurrence") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "alpha BETA alpha" {
|
||||
t.Fatalf("file content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEditRejectsAmbiguousPattern(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(path, []byte("x x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edit := NewFileEditTool(dir)
|
||||
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"x","new":"y"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ambiguous pattern")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "matched 2 occurrences") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEditReplaceAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(path, []byte("x x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edit := NewFileEditTool(dir)
|
||||
out, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"x","new":"y","replace_all":true}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "replaced 2 occurrences") {
|
||||
t.Fatalf("output = %q", out)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "y y" {
|
||||
t.Fatalf("file content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEditRejectsEscapingPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
edit := NewFileEditTool(dir)
|
||||
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"../outside.txt","old":"x","new":"y"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for escaping path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "escapes") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEditRejectsMissingNew(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "notes.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edit := NewFileEditTool(dir)
|
||||
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"notes.txt","old":"hello"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing new")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "new is required") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEditRejectsNonExistentFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
edit := NewFileEditTool(dir)
|
||||
_, err := edit.Execute(context.Background(), json.RawMessage(`{"path":"missing.txt","old":"x","new":"y"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGitLogMaxCount = 20
|
||||
maxGitLogMaxCount = 100
|
||||
)
|
||||
|
||||
func runGit(ctx context.Context, workingDir string, args ...string) (string, error) {
|
||||
gitArgs := append([]string{"-C", workingDir}, args...)
|
||||
cmd := exec.CommandContext(ctx, "git", gitArgs...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
text := FormatResult(string(output))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "context deadline exceeded") || strings.Contains(err.Error(), "signal: killed") {
|
||||
if text != "" {
|
||||
return text, fmt.Errorf("git command timed out: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("git command timed out: %w", err)
|
||||
}
|
||||
if text != "" {
|
||||
return text, fmt.Errorf("git command failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("git command failed: %w", err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func gitPathspec(baseDir, path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", nil
|
||||
}
|
||||
resolved, err := resolvePath(baseDir, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(baseDir, resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.ToSlash(filepath.Clean(rel)), nil
|
||||
}
|
||||
|
||||
func normalizeGitLogMaxCount(value int) int {
|
||||
if value <= 0 {
|
||||
return defaultGitLogMaxCount
|
||||
}
|
||||
if value > maxGitLogMaxCount {
|
||||
return maxGitLogMaxCount
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// git_status
|
||||
|
||||
type GitStatusTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewGitStatusTool(workingDir string) *GitStatusTool {
|
||||
return &GitStatusTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *GitStatusTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "git_status",
|
||||
Description: "Show changed files using git status --short.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Optional path to limit status to. Relative paths are resolved from the configured agent working directory."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type gitStatusArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (t *GitStatusTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args gitStatusArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
}
|
||||
gitArgs := []string{"status", "--short"}
|
||||
if args.Path != "" {
|
||||
pathspec, err := gitPathspec(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gitArgs = append(gitArgs, "--", pathspec)
|
||||
}
|
||||
text, err := runGit(ctx, t.workingDir, gitArgs...)
|
||||
if err != nil {
|
||||
return text, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "Working tree clean.", nil
|
||||
}
|
||||
return "Changed files:\n" + text, nil
|
||||
}
|
||||
|
||||
// git_diff
|
||||
|
||||
type GitDiffTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewGitDiffTool(workingDir string) *GitDiffTool {
|
||||
return &GitDiffTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *GitDiffTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "git_diff",
|
||||
Description: "Show unstaged or staged git diff output, optionally as --stat.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Optional path to limit diff to. Relative paths are resolved from the configured agent working directory."},
|
||||
"staged": {"type": "boolean", "description": "Show staged diff instead of unstaged."},
|
||||
"stat": {"type": "boolean", "description": "Show diffstat summary instead of full diff."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type gitDiffArgs struct {
|
||||
Path string `json:"path"`
|
||||
Staged bool `json:"staged"`
|
||||
Stat bool `json:"stat"`
|
||||
}
|
||||
|
||||
func (t *GitDiffTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args gitDiffArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
}
|
||||
gitArgs := []string{"diff"}
|
||||
if args.Staged {
|
||||
gitArgs = append(gitArgs, "--cached")
|
||||
}
|
||||
if args.Stat {
|
||||
gitArgs = append(gitArgs, "--stat")
|
||||
}
|
||||
if args.Path != "" {
|
||||
pathspec, err := gitPathspec(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gitArgs = append(gitArgs, "--", pathspec)
|
||||
}
|
||||
text, err := runGit(ctx, t.workingDir, gitArgs...)
|
||||
if err != nil {
|
||||
return text, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
if args.Staged {
|
||||
return "No staged diff.", nil
|
||||
}
|
||||
return "No unstaged diff.", nil
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// git_log
|
||||
|
||||
type GitLogTool struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
func NewGitLogTool(workingDir string) *GitLogTool {
|
||||
return &GitLogTool{workingDir: workingDir}
|
||||
}
|
||||
|
||||
func (t *GitLogTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "git_log",
|
||||
Description: "Show recent git commits with git log --oneline --decorate.",
|
||||
Parameters: JSONSchema(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Optional path to limit log to. Relative paths are resolved from the configured agent working directory."},
|
||||
"max_count": {"type": "integer", "description": "Maximum number of commits to show. Defaults to 20, capped at 100."}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type gitLogArgs struct {
|
||||
Path string `json:"path"`
|
||||
MaxCount int `json:"max_count"`
|
||||
}
|
||||
|
||||
func (t *GitLogTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var args gitLogArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
}
|
||||
}
|
||||
maxCount := normalizeGitLogMaxCount(args.MaxCount)
|
||||
gitArgs := []string{"log", "--oneline", "--decorate", fmt.Sprintf("-n%d", maxCount)}
|
||||
if args.Path != "" {
|
||||
pathspec, err := gitPathspec(t.workingDir, args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gitArgs = append(gitArgs, "--", pathspec)
|
||||
}
|
||||
text, err := runGit(ctx, t.workingDir, gitArgs...)
|
||||
if err != nil {
|
||||
return text, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "No git history.", nil
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requireGit(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func runGitForTest(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
gitArgs := append([]string{"-C", dir}, args...)
|
||||
cmd := exec.Command("git", gitArgs...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func initTestRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
runGitForTest(t, dir, "init")
|
||||
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "initial")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestGitStatusToolShowsChangedFiles(t *testing.T) {
|
||||
requireGit(t)
|
||||
dir := initTestRepo(t)
|
||||
|
||||
path := filepath.Join(dir, "tracked.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForTest(t, dir, "add", "tracked.txt")
|
||||
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked")
|
||||
|
||||
if err := os.WriteFile(path, []byte("world"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tool := NewGitStatusTool(dir)
|
||||
out, err := tool.Execute(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "Changed files:") {
|
||||
t.Fatalf("output missing 'Changed files:': %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "M tracked.txt") {
|
||||
t.Fatalf("output missing 'M tracked.txt': %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDiffToolShowsUnstagedDiff(t *testing.T) {
|
||||
requireGit(t)
|
||||
dir := initTestRepo(t)
|
||||
|
||||
path := filepath.Join(dir, "tracked.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForTest(t, dir, "add", "tracked.txt")
|
||||
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked")
|
||||
|
||||
if err := os.WriteFile(path, []byte("world"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tool := NewGitDiffTool(dir)
|
||||
out, err := tool.Execute(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "diff --git") {
|
||||
t.Fatalf("output missing 'diff --git': %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDiffToolShowsStat(t *testing.T) {
|
||||
requireGit(t)
|
||||
dir := initTestRepo(t)
|
||||
|
||||
path := filepath.Join(dir, "tracked.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForTest(t, dir, "add", "tracked.txt")
|
||||
runGitForTest(t, dir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "add tracked")
|
||||
|
||||
if err := os.WriteFile(path, []byte("world"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tool := NewGitDiffTool(dir)
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"stat":true}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "tracked.txt") {
|
||||
t.Fatalf("output missing 'tracked.txt': %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLogToolShowsCommits(t *testing.T) {
|
||||
requireGit(t)
|
||||
dir := initTestRepo(t)
|
||||
|
||||
tool := NewGitLogTool(dir)
|
||||
out, err := tool.Execute(context.Background(), json.RawMessage(`{"max_count":1}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "initial") {
|
||||
t.Fatalf("output missing 'initial': %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitToolsRejectEscapingPath(t *testing.T) {
|
||||
requireGit(t)
|
||||
dir := initTestRepo(t)
|
||||
|
||||
tool := NewGitStatusTool(dir)
|
||||
_, err := tool.Execute(context.Background(), json.RawMessage(`{"path":"../outside"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for escaping path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "escapes") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ShellRunTool struct {
|
||||
func NewShellRunTool(workingDir string) *ShellRunTool {
|
||||
return &ShellRunTool{workingDir: workingDir}
|
||||
}
|
||||
func (*ShellRunTool) Mutates() bool { return true }
|
||||
|
||||
func (t *ShellRunTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
|
||||
+27
-3
@@ -19,6 +19,18 @@ type Tool interface {
|
||||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
||||
}
|
||||
|
||||
// MutatingTool is optionally implemented by tools that modify external state.
|
||||
// It is used by the agent to determine whether tool calls can run in parallel.
|
||||
type MutatingTool interface {
|
||||
Mutates() bool
|
||||
}
|
||||
|
||||
// IsMutating reports whether tool implements MutatingTool and returns true.
|
||||
func IsMutating(tool Tool) bool {
|
||||
mutating, ok := tool.(MutatingTool)
|
||||
return ok && mutating.Mutates()
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
primary []string
|
||||
@@ -66,30 +78,39 @@ func ReadOnlyBuiltins(workingDir string) *Registry {
|
||||
fileList := NewFileListTool(workingDir)
|
||||
fileSearch := NewFileSearchTool(workingDir)
|
||||
codeSymbols := NewCodeSymbolsTool(workingDir)
|
||||
gitStatus := NewGitStatusTool(workingDir)
|
||||
gitDiff := NewGitDiffTool(workingDir)
|
||||
gitLog := NewGitLogTool(workingDir)
|
||||
|
||||
registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols)
|
||||
registry := NewRegistry(fileRead, fileList, fileSearch, codeSymbols, gitStatus, gitDiff, gitLog)
|
||||
registry.RegisterAlias("file.read", fileRead)
|
||||
registry.RegisterAlias("file.list", fileList)
|
||||
registry.RegisterAlias("file.search", fileSearch)
|
||||
registry.RegisterAlias("code.symbols", codeSymbols)
|
||||
registry.RegisterAlias("git.status", gitStatus)
|
||||
registry.RegisterAlias("git.diff", gitDiff)
|
||||
registry.RegisterAlias("git.log", gitLog)
|
||||
return registry
|
||||
}
|
||||
|
||||
func Builtins(workingDir string) *Registry {
|
||||
registry := ReadOnlyBuiltins(workingDir)
|
||||
fileWrite := NewFileWriteTool(workingDir)
|
||||
fileEdit := NewFileEditTool(workingDir)
|
||||
shellRun := NewShellRunTool(workingDir)
|
||||
registry.Register(fileWrite)
|
||||
registry.Register(fileEdit)
|
||||
registry.Register(shellRun)
|
||||
registry.RegisterAlias("file.write", fileWrite)
|
||||
registry.RegisterAlias("file.edit", fileEdit)
|
||||
registry.RegisterAlias("shell.run", shellRun)
|
||||
return registry
|
||||
}
|
||||
|
||||
func AgentInstructions(workingDir string, yolo bool) string {
|
||||
searchInstructions := webSearchInstructions()
|
||||
readOnlyTools := "file_list, file_read, file_search, code_symbols, web_search, web_fetch"
|
||||
yoloTools := "file_list, file_read, file_search, code_symbols, file_write, shell_run, web_search, web_fetch"
|
||||
readOnlyTools := "file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch"
|
||||
yoloTools := "file_list, file_read, file_search, code_symbols, file_write, file_edit, shell_run, git_status, git_diff, git_log, web_search, web_fetch"
|
||||
if yolo {
|
||||
return fmt.Sprintf(`Local project context:
|
||||
- The project working directory is %q.
|
||||
@@ -99,7 +120,9 @@ func AgentInstructions(workingDir string, yolo bool) string {
|
||||
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
|
||||
- When the user asks about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole files.
|
||||
- When the user asks to run, execute, test, check, debug, or inspect a shell command, use shell_run. This includes local commands written in backticks, such as go, make, git, pwd, ls, ps, and similar terminal commands.
|
||||
- When editing existing files, prefer file_edit for small or localized changes; use file_write only for creating files, replacing whole files intentionally, or appending.
|
||||
- Do not use file tools as a substitute for a command execution request.
|
||||
- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run.
|
||||
- Do not start interactive or long-running sessions. Prefer bounded, non-interactive local commands with clear completion conditions.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
@@ -113,6 +136,7 @@ func AgentInstructions(workingDir string, yolo bool) string {
|
||||
- When the user asks to inspect, list, search, or read project files, use file_list, file_search, and file_read instead of saying you cannot access local files.
|
||||
- When the user asks about code structure, symbols, functions, methods, types, or definitions in Go files, use code_symbols before reading whole files.
|
||||
- Shell command execution is disabled in this mode. When the user asks to run, execute, test, check, debug, or inspect a shell command, do not use file tools as a substitute; explain that they need to restart agentu with --yolo to enable shell_run.
|
||||
- When the user asks about changed files, diffs, or git history, use git_status, git_diff, or git_log instead of shell_run.
|
||||
- %s
|
||||
- Tool paths should normally be relative to the project working directory.
|
||||
- Available tools: %s.`, workingDir, searchInstructions, readOnlyTools)
|
||||
|
||||
@@ -104,12 +104,12 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols"} {
|
||||
for _, want := range []string{"file_list", "file_read", "file_search", "code_symbols", "git_status", "git_diff", "git_log"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("definitions missing %s: %#v", want, names)
|
||||
}
|
||||
}
|
||||
for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols"} {
|
||||
for _, alias := range []string{"file.list", "file.read", "file.search", "code.symbols", "git.status", "git.diff", "git.log"} {
|
||||
if _, ok := registry.Get(alias); !ok {
|
||||
t.Fatalf("alias missing: %s", alias)
|
||||
}
|
||||
@@ -117,6 +117,9 @@ func TestReadOnlyBuiltinsExposeOpenAICompatibleNamesAndAliases(t *testing.T) {
|
||||
if _, ok := registry.Get("file_write"); ok {
|
||||
t.Fatal("read-only registry should not expose file_write")
|
||||
}
|
||||
if _, ok := registry.Get("file_edit"); ok {
|
||||
t.Fatal("read-only registry should not expose file_edit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeSymbolsToolListsGoSymbols(t *testing.T) {
|
||||
@@ -170,7 +173,7 @@ func TestRegistryCanExposeWebSearch(t *testing.T) {
|
||||
|
||||
func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||
registry := Builtins(t.TempDir())
|
||||
for _, name := range []string{"file_write", "shell_run", "file.write", "shell.run"} {
|
||||
for _, name := range []string{"file_write", "file_edit", "shell_run", "file.write", "file.edit", "shell.run"} {
|
||||
if _, ok := registry.Get(name); !ok {
|
||||
t.Fatalf("tool missing: %s", name)
|
||||
}
|
||||
@@ -179,14 +182,14 @@ func TestBuiltinsExposeYoloTools(t *testing.T) {
|
||||
|
||||
func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||
readOnly := AgentInstructions("/tmp/project", false)
|
||||
for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context"} {
|
||||
for _, want := range []string{"Shell command execution is disabled", "--yolo", "Do not call tools for greetings", "Do not explore the project preemptively", "Do not call file_list just to discover context", "git_status, git_diff, git_log"} {
|
||||
if !strings.Contains(readOnly, want) {
|
||||
t.Fatalf("read-only instructions missing %q:\n%s", want, readOnly)
|
||||
}
|
||||
}
|
||||
|
||||
yolo := AgentInstructions("/tmp/project", true)
|
||||
for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings"} {
|
||||
for _, want := range []string{"use shell_run", "commands written in backticks", "Do not start interactive", "non-interactive local commands", "Do not call tools for greetings", "prefer file_edit"} {
|
||||
if !strings.Contains(yolo, want) {
|
||||
t.Fatalf("yolo instructions missing %q:\n%s", want, yolo)
|
||||
}
|
||||
@@ -195,7 +198,7 @@ func TestAgentInstructionsExplainCommandModes(t *testing.T) {
|
||||
|
||||
func TestAgentInstructionsExplainWebSearch(t *testing.T) {
|
||||
withSearch := AgentInstructions("/tmp/project", false)
|
||||
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, web_search, web_fetch"} {
|
||||
for _, want := range []string{"use web_search", "Use web_fetch", "latest information", "source URLs", "Available tools: file_list, file_read, file_search, code_symbols, git_status, git_diff, git_log, web_search, web_fetch"} {
|
||||
if !strings.Contains(withSearch, want) {
|
||||
t.Fatalf("web search instructions missing %q:\n%s", want, withSearch)
|
||||
}
|
||||
@@ -257,3 +260,16 @@ func TestResolvePathRejectsDotDotThatEscapes(t *testing.T) {
|
||||
t.Fatal("expected error for path escaping via ..")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMutating(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if IsMutating(NewFileReadTool(dir)) {
|
||||
t.Fatal("FileReadTool should not be mutating")
|
||||
}
|
||||
if !IsMutating(NewFileWriteTool(dir)) {
|
||||
t.Fatal("FileWriteTool should be mutating")
|
||||
}
|
||||
if !IsMutating(NewShellRunTool(dir)) {
|
||||
t.Fatal("ShellRunTool should be mutating")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user