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
+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})
}