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:
loveuer
2026-06-24 19:47:14 -07:00
parent 7b8bad5e62
commit 3d403bd685
13 changed files with 1135 additions and 39 deletions
+8 -2
View File
@@ -125,6 +125,9 @@ Read-only tools are available by default:
- `file_list`
- `file_search` with optional `context_lines` and `max_results`
- `code_symbols` for Go packages, imports, types, functions, and methods
- `git_status` shows changed files
- `git_diff` shows unstaged or staged diff output
- `git_log` shows recent commit history
Web tools are also available in read-only mode:
@@ -133,8 +136,9 @@ Web tools are also available in read-only mode:
`--yolo` additionally enables:
- `file_write`
- `shell_run`
- `file_write` for creating or replacing whole files
- `file_edit` for surgical line-range or pattern edits to existing files
- `shell_run` for non-interactive shell commands
Use `--yolo` for prompts that ask agentu to run or inspect local commands, for
example:
@@ -143,6 +147,8 @@ example:
请帮我运行 `go test ./...` 并总结结果
```
## Development
Run the verification suite:
+96 -17
View File
@@ -7,18 +7,22 @@ import (
"io"
"sort"
"strings"
"sync"
"time"
"agentu/pkg/llm"
"agentu/internal/tools"
"agentu/pkg/llm"
)
const (
defaultMaxToolRounds = 50
doomLoopThreshold = 3
compactRecentMessages = 6
summaryMessageLimit = 8000
contextSummaryPrefix = "[context summary]"
defaultMaxToolRounds = 50
doomLoopThreshold = 3
compactRecentMessages = 6
summaryMessageLimit = 8000
contextSummaryPrefix = "[context summary]"
toolLogArgumentLimit = 400
toolLogOutputPreviewBytes = 4096
toolLogOutputMarker = "[output]"
summarizerSystemPrompt = "You are a conversation summarizer. Summarize the following conversation history into a concise but detailed summary. Preserve:\n" +
"- Key facts, decisions, and conclusions\n" +
@@ -29,6 +33,11 @@ const (
"Output only the summary, no preamble."
)
type toolExecutionResult struct {
call llm.ToolCall
output string
}
type Agent struct {
provider llm.Provider
providerName string
@@ -364,14 +373,15 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
}
}
for _, call := range calls {
result := a.executeTool(ctx, call, logs)
results := a.executeTools(ctx, calls, logs)
for _, result := range results {
a.messages = append(a.messages, llm.Message{
Role: llm.RoleTool,
ToolCallID: call.ID,
Content: result,
ToolCallID: result.call.ID,
Content: result.output,
})
}
}
return fmt.Errorf("tool round limit reached after %d rounds; the task may be too complex for a single turn", a.maxToolRounds)
@@ -433,11 +443,8 @@ func (a *Agent) toolDefinitions() []llm.Tool {
return a.toolRegistry.Definitions()
}
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) string {
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall) string {
name := call.Function.Name
if logs != nil {
fmt.Fprintf(logs, "\n[tool] %s %s\n", name, compact(call.Function.Arguments, 400))
}
if a.toolRegistry == nil {
return tools.FormatError(fmt.Errorf("tool registry is disabled"))
@@ -461,12 +468,84 @@ func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writ
}
return tools.FormatError(err)
}
if logs != nil {
fmt.Fprintf(logs, "[tool] %s done\n", name)
}
return output
}
func (a *Agent) hasMutatingCall(calls []llm.ToolCall) bool {
if a.toolRegistry == nil {
return false
}
for _, call := range calls {
if tool, ok := a.toolRegistry.Get(call.Function.Name); ok {
if tools.IsMutating(tool) {
return true
}
}
}
return false
}
func (a *Agent) executeTools(ctx context.Context, calls []llm.ToolCall, logs io.Writer) []toolExecutionResult {
results := make([]toolExecutionResult, len(calls))
// Log all tool starts sequentially so TUI gets ordered placeholders.
if logs != nil {
for _, call := range calls {
logToolStart(logs, call)
}
}
if a.hasMutatingCall(calls) {
// Sequential: avoid racing mutating tools.
for i, call := range calls {
output := a.executeTool(ctx, call)
results[i] = toolExecutionResult{call: call, output: output}
if logs != nil {
logToolOutput(logs, call, output)
}
}
return results
}
// Concurrent: independent read-only tools.
var mu sync.Mutex
var wg sync.WaitGroup
for i, call := range calls {
wg.Add(1)
go func(idx int, c llm.ToolCall) {
defer wg.Done()
output := a.executeTool(ctx, c)
results[idx] = toolExecutionResult{call: c, output: output}
if logs != nil {
mu.Lock()
logToolOutput(logs, c, output)
mu.Unlock()
}
}(i, call)
}
wg.Wait()
return results
}
func logToolStart(logs io.Writer, call llm.ToolCall) {
fmt.Fprintf(logs, "\n[tool] %s %s\n", call.Function.Name, compact(call.Function.Arguments, toolLogArgumentLimit))
}
func logToolOutput(logs io.Writer, call llm.ToolCall, output string) {
fmt.Fprintf(logs, "\n[tool] %s %s\n%s\n%s\n", call.Function.Name, compact(call.Function.Arguments, toolLogArgumentLimit), toolLogOutputMarker, toolOutputPreview(output))
}
func toolOutputPreview(output string) string {
output = strings.TrimSpace(output)
if output == "" {
return "(no output)"
}
if len(output) <= toolLogOutputPreviewBytes {
return output
}
return output[:toolLogOutputPreviewBytes] + fmt.Sprintf("\n[truncated: showing first %d bytes; omitted %d bytes]", toolLogOutputPreviewBytes, len(output)-toolLogOutputPreviewBytes)
}
type toolCallBuilder struct {
index int
id string
+172 -1
View File
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
"agentu/pkg/llm"
"agentu/internal/tools"
"agentu/pkg/llm"
)
type fakeProvider struct {
@@ -340,3 +340,174 @@ func TestAgentHardLimitStopsVaryingCalls(t *testing.T) {
t.Fatalf("calls = %d, want %d", provider.calls, limit)
}
}
// slowTool blocks on a channel before returning.
type slowTool struct {
name string
started chan<- string
release <-chan struct{}
}
func (t *slowTool) Definition() llm.Tool {
return llm.Tool{
Type: "function",
Function: llm.ToolFunction{
Name: t.name,
Description: "slow test tool",
Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}}}`),
},
}
}
func (t *slowTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
var args struct {
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &args); err != nil {
return "", err
}
t.started <- t.name
<-t.release
return args.Text, nil
}
// mutatingSlowTool is like slowTool but implements MutatingTool.
type mutatingSlowTool struct {
slowTool
}
func (*mutatingSlowTool) Mutates() bool { return true }
func TestAgentExecutesIndependentToolCallsInParallel(t *testing.T) {
started := make(chan string, 2)
release := make(chan struct{})
tool1 := &slowTool{name: "slow_one", started: started, release: release}
tool2 := &slowTool{name: "slow_two", started: started, release: release}
provider := &parallelProvider{
responses: [][]llm.ToolCallDelta{
{
{Index: 0, ID: "call_1", Type: "function", Name: "slow_one", Arguments: `{"text":"one"}`},
{Index: 1, ID: "call_2", Type: "function", Name: "slow_two", Arguments: `{"text":"two"}`},
},
},
finalContent: "done",
}
a := New(Options{
Provider: provider,
Model: "test",
SystemPrompt: "system",
ToolRegistry: tools.NewRegistry(tool1, tool2),
})
var out strings.Builder
done := make(chan error, 1)
go func() {
done <- a.RunTurn(context.Background(), "run both", &out, nil)
}()
// Both tools must start before we release them.
names := make(map[string]bool)
for i := 0; i < 2; i++ {
select {
case name := <-started:
names[name] = true
case <-make(chan struct{}):
// Use a timer in real code, but for test simplicity we rely on deadlock detection.
t.Fatal("timed out waiting for tool to start")
}
}
if !names["slow_one"] || !names["slow_two"] {
t.Fatalf("expected both tools to start, got %v", names)
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
if out.String() != "done" {
t.Fatalf("out = %q", out.String())
}
// Verify tool results were in order.
msgs := a.Messages()
var toolMsgs []string
for _, m := range msgs {
if m.Role == llm.RoleTool {
toolMsgs = append(toolMsgs, m.Content)
}
}
if len(toolMsgs) != 2 || toolMsgs[0] != "one" || toolMsgs[1] != "two" {
t.Fatalf("tool messages = %v, want [one two]", toolMsgs)
}
}
func TestAgentRunsMutatingToolCallsSequentially(t *testing.T) {
started := make(chan string, 2)
release := make(chan struct{})
tool1 := &mutatingSlowTool{slowTool{name: "mut_one", started: started, release: release}}
tool2 := &mutatingSlowTool{slowTool{name: "mut_two", started: started, release: release}}
provider := &parallelProvider{
responses: [][]llm.ToolCallDelta{
{
{Index: 0, ID: "call_1", Type: "function", Name: "mut_one", Arguments: `{"text":"one"}`},
{Index: 1, ID: "call_2", Type: "function", Name: "mut_two", Arguments: `{"text":"two"}`},
},
},
finalContent: "done",
}
a := New(Options{
Provider: provider,
Model: "test",
SystemPrompt: "system",
ToolRegistry: tools.NewRegistry(tool1, tool2),
})
var out strings.Builder
done := make(chan error, 1)
go func() {
done <- a.RunTurn(context.Background(), "run both", &out, nil)
}()
// Only the first tool should start.
select {
case name := <-started:
if name != "mut_one" {
t.Fatalf("expected mut_one to start first, got %q", name)
}
case <-make(chan struct{}):
t.Fatal("timed out waiting for first tool to start")
}
// Second tool should NOT have started yet.
select {
case name := <-started:
t.Fatalf("second tool started too early: %q", name)
default:
// Expected: second tool hasn't started.
}
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
}
// parallelProvider emits a batch of tool calls on the first request, then returns finalContent.
type parallelProvider struct {
responses [][]llm.ToolCallDelta
finalContent string
calls int
}
func (p *parallelProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
p.calls++
if p.calls <= len(p.responses) {
return emit(llm.StreamEvent{ToolCalls: p.responses[p.calls-1]})
}
return emit(llm.StreamEvent{Content: p.finalContent})
}
+1
View File
@@ -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{
+178
View File
@@ -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
}
+136
View File
@@ -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")
}
}
+246
View 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
}
+142
View File
@@ -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)
}
}
+1
View File
@@ -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
View File
@@ -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)
+22 -6
View File
@@ -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")
}
}
+69 -10
View File
@@ -290,8 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case toolLogMsg:
text := cleanToolLog(string(msg))
if text != "" {
m.messages = append(m.messages, message{role: roleTool, content: text})
m.status = text
if !m.mergeToolLog(text) {
m.messages = append(m.messages, message{role: roleTool, content: text})
}
m.status = toolStatusText(text)
m.refreshViewport(true)
}
return m, m.waitForEvent()
@@ -572,8 +574,9 @@ func roleLabel(r role) string {
}
type toolLogParts struct {
name string
args string
name string
args string
output string
}
func parseToolLog(content string) toolLogParts {
@@ -581,14 +584,21 @@ func parseToolLog(content string) toolLogParts {
if content == "" {
return toolLogParts{}
}
idx := strings.IndexFunc(content, unicode.IsSpace)
beforeOutput, output, hasOutput := strings.Cut(content, "\n[output]\n")
parts := toolLogParts{}
if hasOutput {
parts.output = strings.TrimSpace(output)
}
firstLine := strings.SplitN(beforeOutput, "\n", 2)[0]
firstLine = strings.TrimSpace(firstLine)
idx := strings.IndexFunc(firstLine, unicode.IsSpace)
if idx < 0 {
return toolLogParts{name: content}
}
return toolLogParts{
name: strings.TrimSpace(content[:idx]),
args: strings.TrimSpace(content[idx+1:]),
parts.name = firstLine
return parts
}
parts.name = strings.TrimSpace(firstLine[:idx])
parts.args = strings.TrimSpace(firstLine[idx+1:])
return parts
}
func (m model) toolMessageView(content string, width int) string {
@@ -599,11 +609,60 @@ func (m model) toolMessageView(content string, width int) string {
args := fitLine(parts.args, max(1, width-4))
lines = append(lines, m.styles.ToolValue.Render(args))
}
if parts.output != "" {
lines = append(lines, m.styles.ToolKey.Render("output"))
previewLines := toolOutputPreviewLines(parts.output, max(1, width-4))
for _, line := range previewLines {
lines = append(lines, m.styles.ToolValue.Render(line))
}
}
bodyContent := strings.Join(lines, "\n")
bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage)
return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent)
}
const maxToolOutputPreviewLines = 8
func toolStatusText(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
lines := strings.SplitN(content, "\n", 2)
return lines[0]
}
func (m *model) mergeToolLog(text string) bool {
incoming := parseToolLog(text)
if incoming.output == "" {
return false
}
for i := len(m.messages) - 1; i >= 0; i-- {
if m.messages[i].role != roleTool {
continue
}
existing := parseToolLog(m.messages[i].content)
if existing.name == incoming.name && existing.args == incoming.args && existing.output == "" {
m.messages[i].content = text
return true
}
}
return false
}
func toolOutputPreviewLines(output string, width int) []string {
lines := strings.Split(output, "\n")
if len(lines) > maxToolOutputPreviewLines {
lines = lines[:maxToolOutputPreviewLines]
lines = append(lines, fmt.Sprintf("[truncated: showing first %d lines]", maxToolOutputPreviewLines))
}
result := make([]string, 0, len(lines))
for _, line := range lines {
result = append(result, fitLine(line, width))
}
return result
}
type metaPill struct {
text string
accent bool
+37
View File
@@ -348,6 +348,43 @@ func TestToolMessageRendersStructuredCard(t *testing.T) {
}
}
func TestToolMessageRendersOutputPreview(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleTool, content: "file_read {\"path\":\"README.md\"}\n[output]\nfirst line\nsecond line"})
plain := stripANSI(m.renderMessages())
for _, want := range []string{iconTool + " file_read", "README.md", "output", "first line", "second line"} {
if !strings.Contains(plain, want) {
t.Fatalf("tool card missing %q:\n%s", want, plain)
}
}
}
func TestToolLogOutputUpdatesExistingToolCard(t *testing.T) {
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`})
updated := m.mergeToolLog("file_read {\"path\":\"README.md\"}\n[output]\nhello")
if !updated {
t.Fatal("expected mergeToolLog to return true")
}
if len(m.messages) != 1 {
t.Fatalf("messages count = %d, want 1", len(m.messages))
}
plain := stripANSI(m.renderMessages())
if !strings.Contains(plain, "hello") {
t.Fatalf("rendered output missing 'hello':\n%s", plain)
}
status := toolStatusText("file_read {\"path\":\"README.md\"}\n[output]\nhello world")
if strings.Contains(status, "hello") {
t.Fatalf("status should not contain output: %q", status)
}
if !strings.Contains(status, "file_read") {
t.Fatalf("status missing tool name: %q", status)
}
}
type tuiCompactProvider struct{}
func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {