From feb657733ae5c5b328f8311b9f3cccc61da58bb9 Mon Sep 17 00:00:00 2001 From: Alex Mackay Date: Sun, 24 May 2026 15:42:31 +0100 Subject: [PATCH 1/2] feat(executor): Add parallel tool executor and dev agent Implements V2 of the software lifecycle harness: a custom ADK agent that dispatches tool calls concurrently using sync.WaitGroup, bypassing llmagent's sequential execution loop. New packages: - executor/: Custom agent.Agent backed by a parallel tool dispatch loop. Injects function declarations via genai.GenerateContentConfig.Tools, calls model.GenerateContent directly, and fans out all tool calls in a single LLM response as goroutines. - devtools/: 9 dev-focused tools (bash with sandwich truncation + process- group kill, read_file, write_file, edit_file with uniqueness guard, list_dir, glob, search_files, git_status/diff/commit/push/branch, spawn_agent). All paths resolved against an explicit workDir. - agents/dev/: NewDev() wires executor + devtools into an agent.Agent. System prompt enforces TDD-first workflow and conventional commits. CLI: `agent run dev --task "..." --work-dir .` added as a subcommand. Pipeline: adds "dev" stage type to runStage() switch, allowing the implementation agent to be orchestrated as part of a multi-stage pipeline. StageConfig gains an optional `task` field for dev stage instructions. Co-Authored-By: Claude Sonnet 4.6 --- agents/dev/config.go | 17 +++ agents/dev/config_test.go | 38 ++++++ agents/dev/dev.go | 31 +++++ agents/dev/prompt.go | 41 +++++++ cmd/dev.go | 110 ++++++++++++++++++ cmd/run.go | 2 +- devtools/bash.go | 98 ++++++++++++++++ devtools/bash_test.go | 98 ++++++++++++++++ devtools/edit.go | 76 ++++++++++++ devtools/edit_test.go | 106 +++++++++++++++++ devtools/git.go | 198 ++++++++++++++++++++++++++++++++ devtools/glob.go | 55 +++++++++ devtools/list.go | 81 +++++++++++++ devtools/read.go | 95 +++++++++++++++ devtools/search.go | 139 ++++++++++++++++++++++ devtools/spawn.go | 68 +++++++++++ devtools/tools.go | 20 ++++ devtools/write.go | 48 ++++++++ executor/executor.go | 236 ++++++++++++++++++++++++++++++++++++++ executor/executor_test.go | 188 ++++++++++++++++++++++++++++++ executor/tool.go | 31 +++++ pipeline/config.go | 5 +- pipeline/pipeline.go | 31 +++++ 23 files changed, 1809 insertions(+), 3 deletions(-) create mode 100644 agents/dev/config.go create mode 100644 agents/dev/config_test.go create mode 100644 agents/dev/dev.go create mode 100644 agents/dev/prompt.go create mode 100644 cmd/dev.go create mode 100644 devtools/bash.go create mode 100644 devtools/bash_test.go create mode 100644 devtools/edit.go create mode 100644 devtools/edit_test.go create mode 100644 devtools/git.go create mode 100644 devtools/glob.go create mode 100644 devtools/list.go create mode 100644 devtools/read.go create mode 100644 devtools/search.go create mode 100644 devtools/spawn.go create mode 100644 devtools/tools.go create mode 100644 devtools/write.go create mode 100644 executor/executor.go create mode 100644 executor/executor_test.go create mode 100644 executor/tool.go diff --git a/agents/dev/config.go b/agents/dev/config.go new file mode 100644 index 0000000..df3b685 --- /dev/null +++ b/agents/dev/config.go @@ -0,0 +1,17 @@ +package dev + +import "fmt" + +// Config holds configuration for the dev agent. +type Config struct { + WorkDir string + MaxSteps int +} + +// Validate checks that required fields are set. +func (c Config) Validate() error { + if c.WorkDir == "" { + return fmt.Errorf("dev agent: WorkDir is required") + } + return nil +} diff --git a/agents/dev/config_test.go b/agents/dev/config_test.go new file mode 100644 index 0000000..938733d --- /dev/null +++ b/agents/dev/config_test.go @@ -0,0 +1,38 @@ +package dev + +import ( + "testing" +) + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "valid config", + cfg: Config{WorkDir: "/tmp"}, + wantErr: false, + }, + { + name: "missing work dir", + cfg: Config{}, + wantErr: true, + }, + { + name: "valid with max steps", + cfg: Config{WorkDir: "/tmp", MaxSteps: 100}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/agents/dev/dev.go b/agents/dev/dev.go new file mode 100644 index 0000000..26bcbf8 --- /dev/null +++ b/agents/dev/dev.go @@ -0,0 +1,31 @@ +// Package dev provides a software development agent with parallel tool execution. +package dev + +import ( + "fmt" + + "google.golang.org/adk/agent" + adkmodel "google.golang.org/adk/model" + + "github.com/ATMackay/agent/devtools" + "github.com/ATMackay/agent/executor" +) + +// AgentName is the identifier used for this agent in the pipeline. +const AgentName = "dev" + +// NewDev constructs a dev agent backed by the parallel executor. +func NewDev(cfg *Config, mod adkmodel.LLM) (agent.Agent, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("dev: invalid config: %w", err) + } + tools := devtools.All(cfg.WorkDir) + return executor.New(executor.Config{ + Name: AgentName, + Description: "Software development agent with parallel tool execution", + Instruction: buildInstruction(), + Model: mod, + Tools: tools, + MaxSteps: cfg.MaxSteps, + }) +} diff --git a/agents/dev/prompt.go b/agents/dev/prompt.go new file mode 100644 index 0000000..b9a4d78 --- /dev/null +++ b/agents/dev/prompt.go @@ -0,0 +1,41 @@ +package dev + +import "google.golang.org/genai" + +const systemInstruction = `You are an expert software development agent with access to filesystem, shell, and git tools. You can work autonomously to implement, test, and commit code changes. + +## Core Principles + +- **TDD first**: Write failing tests before implementing. Run them to confirm they fail, then implement, then confirm they pass. +- **Verify continuously**: After every non-trivial edit, run the build (bash: go build ./...) and tests (bash: go test ./...). +- **Read before editing**: Use search_files and read_file to understand existing code before making changes. Never blindly overwrite. +- **Prefer edit_file over write_file**: For existing files, always use edit_file with precise old/new strings. +- **Commit logically**: Use git_commit after each coherent unit of work with a conventional commit message (type(scope): description). +- **Delegate sub-tasks**: Use spawn_agent for documentation, analysis, or isolated sub-tasks rather than doing them inline. + +## Tool Strategy + +1. Start with list_dir and search_files to understand the codebase structure +2. Use read_file to examine relevant files before editing +3. Use bash for build/test/run verification after changes +4. Use git_status and git_diff to review changes before committing +5. Use git_push only when explicitly asked to publish changes + +## Constraints + +- Do not force-push to main or master branches +- Prefer small, focused commits over large sweeping changes +- Always handle errors — never silently ignore them +- Follow the existing code style and conventions of the project` + +// UserMessage builds the initial user message for a dev task. +func UserMessage(task string) *genai.Content { + return &genai.Content{ + Role: genai.RoleUser, + Parts: []*genai.Part{{Text: task}}, + } +} + +func buildInstruction() string { + return systemInstruction +} diff --git a/cmd/dev.go b/cmd/dev.go new file mode 100644 index 0000000..b1eaf58 --- /dev/null +++ b/cmd/dev.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + + "github.com/ATMackay/agent/agents/dev" + "github.com/ATMackay/agent/model" + "github.com/ATMackay/agent/workflow" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/adk/session" +) + +func NewDevCmd() *cobra.Command { + var workDir, task, modelName, modelProvider string + var maxSteps int + + cmd := &cobra.Command{ + Use: "dev", + Short: "Run the software development agent", + Long: `Run the dev agent to implement code changes autonomously. + +The agent executes tool calls in parallel and supports: + - Filesystem operations (read, write, edit, glob, search) + - Shell commands with output capture + - Git operations (status, diff, commit, push) + - Sub-agent delegation via spawn_agent + +The agent follows TDD: writes tests first, implements to pass, then commits.`, + RunE: func(cmd *cobra.Command, args []string) error { + apiKey := viper.GetString("api-key") + if apiKey == "" { + return fmt.Errorf("api key is required; set --api-key or export API_KEY") + } + if task == "" { + return fmt.Errorf("--task is required") + } + + if workDir == "" { + var err error + workDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + } + + ctx := cmd.Context() + + modelCfg := &model.Config{ + Provider: model.Provider(modelProvider), + Model: modelName, + } + mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + + cfg := &dev.Config{ + WorkDir: workDir, + MaxSteps: maxSteps, + } + ag, err := dev.NewDev(cfg, mod) + if err != nil { + return fmt.Errorf("create dev agent: %w", err) + } + + slog.Info("starting dev agent", + "agent", ag.Name(), + "work_dir", workDir, + "model", modelName, + "provider", modelProvider, + "max_steps", maxSteps, + ) + + s, err := workflow.New( + ctx, + dev.AgentName, + session.InMemoryService(), + ag, + nil, + ) + if err != nil { + return fmt.Errorf("create workflow: %w", err) + } + + if err := s.Start(ctx, userCLI, dev.UserMessage(task)); err != nil { + return err + } + + slog.Info("dev agent complete") + return nil + }, + } + + cmd.Flags().StringVar(&workDir, "work-dir", "", "Working directory for file operations (default: current directory)") + cmd.Flags().StringVar(&task, "task", "", "Task description for the dev agent (required)") + cmd.Flags().StringVar(&modelName, "model", "claude-opus-4-5-20251101", "Language model to use") + cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider (claude or gemini)") + cmd.Flags().IntVar(&maxSteps, "max-steps", 50, "Maximum tool-use iterations") + + must(viper.BindPFlag("work-dir", cmd.Flags().Lookup("work-dir"))) + must(viper.BindPFlag("task", cmd.Flags().Lookup("task"))) + must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) + must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider"))) + must(viper.BindEnv("api-key", "API_KEY", "CLAUDE_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY")) + + return cmd +} diff --git a/cmd/run.go b/cmd/run.go index 2222334..2e3255a 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -37,7 +37,7 @@ func NewRunCmd() *cobra.Command { // Add subcommands cmd.AddCommand(NewDocumentorCmd()) cmd.AddCommand(NewAnalyzerCmd()) - // TODO - more agent types + cmd.AddCommand(NewDevCmd()) // Bind flags and ENV vars cmd.PersistentFlags().String("log-level", "info", "Log level (debug, info, warn, error, fatal, panic)") diff --git a/devtools/bash.go b/devtools/bash.go new file mode 100644 index 0000000..4cc71cd --- /dev/null +++ b/devtools/bash.go @@ -0,0 +1,98 @@ +package devtools + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "syscall" + "time" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +const ( + bashOutputCap = 200 * 1024 // 200 KB total cap + bashHeadBytes = 100 * 1024 // first 100 KB + bashTailBytes = 50 * 1024 // last 50 KB + defaultBashTimeout = 2 * time.Minute + killDrainDelay = 2 * time.Second // max wait for pipes to drain after kill +) + +func bashTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "bash", + Description: "Execute a shell command and return its stdout+stderr. Use for building, testing, and running programs.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "command": { + Type: genai.TypeString, + Description: "Shell command to execute.", + }, + "timeout_seconds": { + Type: genai.TypeInteger, + Description: "Max seconds to wait (default 120, max 600).", + }, + }, + Required: []string{"command"}, + }, + Run: func(ctx context.Context, args map[string]any) (map[string]any, error) { + cmd, _ := args["command"].(string) + if cmd == "" { + return nil, fmt.Errorf("bash: command is required") + } + + timeout := defaultBashTimeout + if secs, ok := args["timeout_seconds"].(float64); ok && secs > 0 { + timeout = min(time.Duration(secs)*time.Second, 10*time.Minute) + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd) + c.Dir = workDir + // Put the shell in its own process group so we can kill it and all + // its children at once, preventing pipe-drain deadlock on timeout. + c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + c.Cancel = func() error { + if c.Process == nil { + return nil + } + // Kill the entire process group (negative PID = pgid). + return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) + } + // After the process group is killed, allow at most killDrainDelay + // for any remaining I/O goroutines before forcing them closed. + c.WaitDelay = killDrainDelay + + var buf bytes.Buffer + c.Stdout = &buf + c.Stderr = &buf + runErr := c.Run() + + raw := buf.Bytes() + output := sandwichTruncate(raw) + + result := map[string]any{"output": output} + if runErr != nil { + result["error"] = runErr.Error() + } + return result, nil + }, + } +} + +// sandwichTruncate caps output at bashOutputCap, keeping head and tail. +func sandwichTruncate(data []byte) string { + if len(data) <= bashOutputCap { + return string(data) + } + head := data[:bashHeadBytes] + tail := data[len(data)-bashTailBytes:] + omitted := len(data) - bashHeadBytes - bashTailBytes + return string(head) + fmt.Sprintf("\n[...%d bytes truncated...]\n", omitted) + string(tail) +} diff --git a/devtools/bash_test.go b/devtools/bash_test.go new file mode 100644 index 0000000..8a52205 --- /dev/null +++ b/devtools/bash_test.go @@ -0,0 +1,98 @@ +package devtools + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestSandwichTruncate_ShortInput(t *testing.T) { + data := []byte("hello world") + got := sandwichTruncate(data) + if got != "hello world" { + t.Errorf("short input should be returned unchanged, got %q", got) + } +} + +func TestSandwichTruncate_LongInput(t *testing.T) { + // Build input larger than the cap. + data := make([]byte, bashOutputCap+1000) + for i := range data { + data[i] = 'x' + } + got := sandwichTruncate(data) + if !strings.Contains(got, "truncated") { + t.Error("long input should contain truncation marker") + } + if len(got) >= len(data) { + t.Errorf("truncated output (%d) should be shorter than input (%d)", len(got), len(data)) + } +} + +func TestBashTool_BasicCommand(t *testing.T) { + tool := bashTool(t.TempDir()) + result, err := tool.Run(context.Background(), map[string]any{ + "command": "echo hello", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output, _ := result["output"].(string) + if !strings.Contains(output, "hello") { + t.Errorf("want 'hello' in output, got %q", output) + } +} + +func TestBashTool_ExitCodeCaptured(t *testing.T) { + tool := bashTool(t.TempDir()) + result, err := tool.Run(context.Background(), map[string]any{ + "command": "exit 1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, hasErr := result["error"]; !hasErr { + t.Error("non-zero exit should set 'error' key in result") + } +} + +func TestBashTool_Timeout(t *testing.T) { + tool := bashTool(t.TempDir()) + ctx := context.Background() + start := time.Now() + result, _ := tool.Run(ctx, map[string]any{ + "command": "sleep 60", + "timeout_seconds": float64(1), + }) + elapsed := time.Since(start) + if elapsed > 5*time.Second { + t.Errorf("command should have been killed by timeout, took %v", elapsed) + } + if _, hasErr := result["error"]; !hasErr { + t.Error("timed-out command should have 'error' key in result") + } +} + +func TestBashTool_MissingCommand(t *testing.T) { + tool := bashTool(t.TempDir()) + _, err := tool.Run(context.Background(), map[string]any{}) + if err == nil { + t.Error("want error for missing command") + } +} + +func TestBashTool_WorkDir(t *testing.T) { + dir := t.TempDir() + tool := bashTool(dir) + result, err := tool.Run(context.Background(), map[string]any{ + "command": "pwd", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output, _ := result["output"].(string) + if !strings.Contains(output, dir) { + t.Errorf("want output to contain workDir %q, got %q", dir, output) + } +} diff --git a/devtools/edit.go b/devtools/edit.go new file mode 100644 index 0000000..261cbf8 --- /dev/null +++ b/devtools/edit.go @@ -0,0 +1,76 @@ +package devtools + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +func editFileTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "edit_file", + Description: "Replace an exact string in a file. Fails if old_string is not found or is not unique (unless replace_all is true).", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "path": { + Type: genai.TypeString, + Description: "File path (relative to work dir or absolute).", + }, + "old_string": { + Type: genai.TypeString, + Description: "Exact string to search for.", + }, + "new_string": { + Type: genai.TypeString, + Description: "Replacement string.", + }, + "replace_all": { + Type: genai.TypeBoolean, + Description: "Replace all occurrences (default false).", + }, + }, + Required: []string{"path", "old_string", "new_string"}, + }, + Run: func(_ context.Context, args map[string]any) (map[string]any, error) { + path := resolvePathArg(args, workDir) + if path == "" { + return nil, fmt.Errorf("edit_file: path is required") + } + oldStr, _ := args["old_string"].(string) + newStr, _ := args["new_string"].(string) + replaceAll, _ := args["replace_all"].(bool) + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("edit_file: %w", err) + } + + content := string(data) + count := strings.Count(content, oldStr) + if count == 0 { + return nil, fmt.Errorf("edit_file: old_string not found in %s", path) + } + if count > 1 && !replaceAll { + return nil, fmt.Errorf("edit_file: old_string found %d times in %s; use replace_all or provide more context", count, path) + } + + var updated string + if replaceAll { + updated = strings.ReplaceAll(content, oldStr, newStr) + } else { + updated = strings.Replace(content, oldStr, newStr, 1) + } + + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return nil, fmt.Errorf("edit_file: write: %w", err) + } + return map[string]any{"output": fmt.Sprintf("replaced %d occurrence(s) in %s", count, path)}, nil + }, + } +} diff --git a/devtools/edit_test.go b/devtools/edit_test.go new file mode 100644 index 0000000..e7bf38c --- /dev/null +++ b/devtools/edit_test.go @@ -0,0 +1,106 @@ +package devtools + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestEditFileTool_BasicReplacement(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + if err := os.WriteFile(path, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + tool := editFileTool(dir) + result, err := tool.Run(context.Background(), map[string]any{ + "path": "file.txt", + "old_string": "world", + "new_string": "Go", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := result["output"]; !ok { + t.Error("want 'output' key in result") + } + + data, _ := os.ReadFile(path) + if string(data) != "hello Go" { + t.Errorf("want 'hello Go', got %q", string(data)) + } +} + +func TestEditFileTool_NotFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + if err := os.WriteFile(path, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + tool := editFileTool(dir) + _, err := tool.Run(context.Background(), map[string]any{ + "path": "file.txt", + "old_string": "notpresent", + "new_string": "x", + }) + if err == nil { + t.Error("want error when old_string not found") + } +} + +func TestEditFileTool_UniquenessGuard(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + if err := os.WriteFile(path, []byte("foo foo foo"), 0o644); err != nil { + t.Fatal(err) + } + + tool := editFileTool(dir) + _, err := tool.Run(context.Background(), map[string]any{ + "path": "file.txt", + "old_string": "foo", + "new_string": "bar", + "replace_all": false, + }) + if err == nil { + t.Error("want error when old_string is not unique and replace_all is false") + } +} + +func TestEditFileTool_ReplaceAll(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + if err := os.WriteFile(path, []byte("foo foo foo"), 0o644); err != nil { + t.Fatal(err) + } + + tool := editFileTool(dir) + _, err := tool.Run(context.Background(), map[string]any{ + "path": "file.txt", + "old_string": "foo", + "new_string": "bar", + "replace_all": true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, _ := os.ReadFile(path) + if string(data) != "bar bar bar" { + t.Errorf("want 'bar bar bar', got %q", string(data)) + } +} + +func TestEditFileTool_MissingPath(t *testing.T) { + tool := editFileTool(t.TempDir()) + _, err := tool.Run(context.Background(), map[string]any{ + "old_string": "x", + "new_string": "y", + }) + if err == nil { + t.Error("want error for missing path") + } +} diff --git a/devtools/git.go b/devtools/git.go new file mode 100644 index 0000000..6c0ac67 --- /dev/null +++ b/devtools/git.go @@ -0,0 +1,198 @@ +package devtools + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +func gitTools(workDir string) []*executor.Tool { + return []*executor.Tool{ + gitStatusTool(workDir), + gitDiffTool(workDir), + gitBranchTool(workDir), + gitCommitTool(workDir), + gitPushTool(workDir), + } +} + +func runGit(ctx context.Context, workDir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = workDir + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + return buf.String(), fmt.Errorf("git %s: %w\n%s", args[0], err, buf.String()) + } + return buf.String(), nil +} + +func gitStatusTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "git_status", + Description: "Show the working tree status (git status --short).", + Parameters: &genai.Schema{Type: genai.TypeObject, Properties: map[string]*genai.Schema{}}, + Run: func(ctx context.Context, _ map[string]any) (map[string]any, error) { + out, err := runGit(ctx, workDir, "status", "--short") + if err != nil { + return nil, err + } + return map[string]any{"output": out}, nil + }, + } +} + +func gitDiffTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "git_diff", + Description: "Show staged and unstaged changes (git diff HEAD).", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "staged": { + Type: genai.TypeBoolean, + Description: "Show only staged changes (git diff --cached).", + }, + }, + }, + Run: func(ctx context.Context, args map[string]any) (map[string]any, error) { + gitArgs := []string{"diff", "HEAD"} + if staged, _ := args["staged"].(bool); staged { + gitArgs = []string{"diff", "--cached"} + } + out, err := runGit(ctx, workDir, gitArgs...) + if err != nil { + return nil, err + } + return map[string]any{"output": out}, nil + }, + } +} + +func gitBranchTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "git_branch", + Description: "List branches or create a new branch.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "name": { + Type: genai.TypeString, + Description: "New branch name to create. Omit to list branches.", + }, + }, + }, + Run: func(ctx context.Context, args map[string]any) (map[string]any, error) { + if name, _ := args["name"].(string); name != "" { + out, err := runGit(ctx, workDir, "checkout", "-b", name) + if err != nil { + return nil, err + } + return map[string]any{"output": out}, nil + } + out, err := runGit(ctx, workDir, "branch", "--list") + if err != nil { + return nil, err + } + return map[string]any{"output": out}, nil + }, + } +} + +func gitCommitTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "git_commit", + Description: "Stage all tracked changes and create a commit. Follows conventional commit format.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "message": { + Type: genai.TypeString, + Description: "Commit message (conventional commit format: 'type(scope): description').", + }, + "files": { + Type: genai.TypeString, + Description: "Space-separated file paths to stage. Default: '-A' (all tracked changes).", + }, + }, + Required: []string{"message"}, + }, + Run: func(ctx context.Context, args map[string]any) (map[string]any, error) { + msg, _ := args["message"].(string) + if msg == "" { + return nil, fmt.Errorf("git_commit: message is required") + } + + files := "-A" + if f, _ := args["files"].(string); f != "" { + files = f + } + + addArgs := []string{"add"} + addArgs = append(addArgs, strings.Fields(files)...) + if _, err := runGit(ctx, workDir, addArgs...); err != nil { + return nil, fmt.Errorf("git_commit: stage: %w", err) + } + + out, err := runGit(ctx, workDir, "commit", "-m", msg) + if err != nil { + return nil, err + } + return map[string]any{"output": out}, nil + }, + } +} + +func gitPushTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "git_push", + Description: "Push the current branch to origin. Refuses to force-push main or master.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "remote": { + Type: genai.TypeString, + Description: "Remote name (default: origin).", + }, + "set_upstream": { + Type: genai.TypeBoolean, + Description: "Set upstream tracking (-u flag).", + }, + }, + }, + Run: func(ctx context.Context, args map[string]any) (map[string]any, error) { + branch, err := runGit(ctx, workDir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return nil, err + } + branch = strings.TrimSpace(branch) + if branch == "main" || branch == "master" { + return nil, fmt.Errorf("git_push: refusing to push directly to %s; create a feature branch", branch) + } + + remote := "origin" + if r, _ := args["remote"].(string); r != "" { + remote = r + } + + pushArgs := []string{"push"} + if setUpstream, _ := args["set_upstream"].(bool); setUpstream { + pushArgs = append(pushArgs, "-u") + } + pushArgs = append(pushArgs, remote, branch) + + out, err := runGit(ctx, workDir, pushArgs...) + if err != nil { + return nil, err + } + return map[string]any{"output": out}, nil + }, + } +} diff --git a/devtools/glob.go b/devtools/glob.go new file mode 100644 index 0000000..5e8c753 --- /dev/null +++ b/devtools/glob.go @@ -0,0 +1,55 @@ +package devtools + +import ( + "context" + "fmt" + "path/filepath" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +func globTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "glob", + Description: "Find files matching a glob pattern. Returns paths relative to work dir.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "pattern": { + Type: genai.TypeString, + Description: "Glob pattern (e.g. '**/*.go', 'cmd/*.go'). Relative patterns are resolved from work dir.", + }, + }, + Required: []string{"pattern"}, + }, + Run: func(_ context.Context, args map[string]any) (map[string]any, error) { + pattern, _ := args["pattern"].(string) + if pattern == "" { + return nil, fmt.Errorf("glob: pattern is required") + } + + // resolve relative patterns against workDir + if !filepath.IsAbs(pattern) { + pattern = filepath.Join(workDir, pattern) + } + + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("glob: %w", err) + } + + // return relative paths + rel := make([]string, 0, len(matches)) + for _, m := range matches { + if r, err := filepath.Rel(workDir, m); err == nil { + rel = append(rel, r) + } else { + rel = append(rel, m) + } + } + return map[string]any{"matches": rel, "count": len(rel)}, nil + }, + } +} diff --git a/devtools/list.go b/devtools/list.go new file mode 100644 index 0000000..6973fe7 --- /dev/null +++ b/devtools/list.go @@ -0,0 +1,81 @@ +package devtools + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +var skipDirs = map[string]bool{ + ".git": true, "vendor": true, "node_modules": true, + ".cache": true, "dist": true, "build": true, +} + +func listDirTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "list_dir", + Description: "List files and directories recursively up to a given depth.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "path": { + Type: genai.TypeString, + Description: "Directory path (relative to work dir or absolute). Default: work dir.", + }, + "depth": { + Type: genai.TypeInteger, + Description: "Max recursion depth (default 3, max 10).", + }, + }, + }, + Run: func(_ context.Context, args map[string]any) (map[string]any, error) { + root := workDir + if p, ok := args["path"].(string); ok && p != "" { + root = resolvePathArg(args, workDir) + } + + depth := 3 + if d, ok := args["depth"].(float64); ok && d >= 1 { + depth = min(int(d), 10) + } + + var entries []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(root, path) + if rel == "." { + return nil + } + // depth check + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) > depth { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() && skipDirs[d.Name()] { + return fs.SkipDir + } + if d.IsDir() { + entries = append(entries, rel+"/") + } else { + entries = append(entries, rel) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("list_dir: %w", err) + } + return map[string]any{"entries": entries, "count": len(entries)}, nil + }, + } +} diff --git a/devtools/read.go b/devtools/read.go new file mode 100644 index 0000000..ad313db --- /dev/null +++ b/devtools/read.go @@ -0,0 +1,95 @@ +package devtools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +const ( + readDefaultLines = 500 + readMaxBytes = 100 * 1024 // 100 KB +) + +func readFileTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "read_file", + Description: "Read file contents. Supports optional line range (start_line, end_line).", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "path": { + Type: genai.TypeString, + Description: "File path (relative to work dir or absolute).", + }, + "start_line": { + Type: genai.TypeInteger, + Description: "First line to return (1-indexed, inclusive). Default: 1.", + }, + "end_line": { + Type: genai.TypeInteger, + Description: "Last line to return (inclusive). Default: start_line + 499.", + }, + }, + Required: []string{"path"}, + }, + Run: func(_ context.Context, args map[string]any) (map[string]any, error) { + path := resolvePathArg(args, workDir) + if path == "" { + return nil, fmt.Errorf("read_file: path is required") + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read_file: %w", err) + } + + lines := strings.Split(string(data), "\n") + total := len(lines) + + startLine := 1 + if v, ok := args["start_line"].(float64); ok && v >= 1 { + startLine = int(v) + } + endLine := startLine + readDefaultLines - 1 + if v, ok := args["end_line"].(float64); ok && int(v) >= startLine { + endLine = int(v) + } + if startLine > total { + startLine = total + } + if endLine > total { + endLine = total + } + + selected := strings.Join(lines[startLine-1:endLine], "\n") + if len(selected) > readMaxBytes { + selected = selected[:readMaxBytes] + fmt.Sprintf("\n[truncated: %d bytes omitted]", len(selected)-readMaxBytes) + } + + return map[string]any{ + "content": selected, + "start_line": startLine, + "end_line": endLine, + "total_lines": total, + }, nil + }, + } +} + +func resolvePathArg(args map[string]any, workDir string) string { + p, _ := args["path"].(string) + if p == "" { + return "" + } + if filepath.IsAbs(p) { + return p + } + return filepath.Join(workDir, p) +} diff --git a/devtools/search.go b/devtools/search.go new file mode 100644 index 0000000..dedcd09 --- /dev/null +++ b/devtools/search.go @@ -0,0 +1,139 @@ +package devtools + +import ( + "bufio" + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +const ( + defaultContextLines = 2 + defaultMaxResults = 50 +) + +func searchFilesTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "search_files", + Description: "Search for a string across files in a directory. Returns matching lines with context.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "query": { + Type: genai.TypeString, + Description: "String to search for (case-insensitive).", + }, + "path": { + Type: genai.TypeString, + Description: "Directory or file to search (relative to work dir or absolute). Default: work dir.", + }, + "context_lines": { + Type: genai.TypeInteger, + Description: fmt.Sprintf("Lines of context around each match (default %d).", defaultContextLines), + }, + "max_results": { + Type: genai.TypeInteger, + Description: fmt.Sprintf("Max number of matches to return (default %d).", defaultMaxResults), + }, + }, + Required: []string{"query"}, + }, + Run: func(_ context.Context, args map[string]any) (map[string]any, error) { + query, _ := args["query"].(string) + if query == "" { + return nil, fmt.Errorf("search_files: query is required") + } + queryLower := strings.ToLower(query) + + root := workDir + if p, ok := args["path"].(string); ok && p != "" { + root = resolvePathArg(args, workDir) + } + + ctxLines := defaultContextLines + if v, ok := args["context_lines"].(float64); ok && v >= 0 { + ctxLines = int(v) + } + + maxResults := defaultMaxResults + if v, ok := args["max_results"].(float64); ok && v > 0 { + maxResults = int(v) + } + + type match struct { + file string + line int + snippet string + } + var matches []match + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && skipDirs[d.Name()] { + return fs.SkipDir + } + if d.IsDir() { + return nil + } + if len(matches) >= maxResults { + return fs.SkipAll + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + fileLines := strings.Split(string(data), "\n") + for i, line := range fileLines { + if !strings.Contains(strings.ToLower(line), queryLower) { + continue + } + start := max(0, i-ctxLines) + end := min(len(fileLines), i+ctxLines+1) + + var sb strings.Builder + scanner := bufio.NewScanner(strings.NewReader(strings.Join(fileLines[start:end], "\n"))) + lineNum := start + 1 + for scanner.Scan() { + marker := " " + if lineNum == i+1 { + marker = "> " + } + fmt.Fprintf(&sb, "%s%d: %s\n", marker, lineNum, scanner.Text()) + lineNum++ + } + + rel, _ := filepath.Rel(workDir, path) + matches = append(matches, match{file: rel, line: i + 1, snippet: sb.String()}) + if len(matches) >= maxResults { + return nil + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("search_files: %w", err) + } + + results := make([]map[string]any, len(matches)) + for i, m := range matches { + results[i] = map[string]any{ + "file": m.file, + "line": m.line, + "snippet": m.snippet, + } + } + return map[string]any{"results": results, "count": len(results)}, nil + }, + } +} diff --git a/devtools/spawn.go b/devtools/spawn.go new file mode 100644 index 0000000..981954c --- /dev/null +++ b/devtools/spawn.go @@ -0,0 +1,68 @@ +package devtools + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +const defaultSpawnTimeout = 10 * time.Minute + +func spawnAgentTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "spawn_agent", + Description: "Delegate a sub-task to another agent instance. Runs 'agent run dev --task --work-dir ' as a subprocess.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "task": { + Type: genai.TypeString, + Description: "Task description for the sub-agent.", + }, + "timeout_seconds": { + Type: genai.TypeInteger, + Description: "Max seconds to wait (default 600).", + }, + }, + Required: []string{"task"}, + }, + Run: func(ctx context.Context, args map[string]any) (map[string]any, error) { + task, _ := args["task"].(string) + if task == "" { + return nil, fmt.Errorf("spawn_agent: task is required") + } + + timeout := defaultSpawnTimeout + if secs, ok := args["timeout_seconds"].(float64); ok && secs > 0 { + timeout = time.Duration(secs) * time.Second + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "agent", "run", "dev", + "--task", task, + "--work-dir", workDir, + ) + cmd.Dir = workDir + + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + if err := cmd.Run(); err != nil { + return map[string]any{ + "output": buf.String(), + "error": err.Error(), + }, nil + } + return map[string]any{"output": buf.String()}, nil + }, + } +} diff --git a/devtools/tools.go b/devtools/tools.go new file mode 100644 index 0000000..af7cb5e --- /dev/null +++ b/devtools/tools.go @@ -0,0 +1,20 @@ +// Package devtools provides filesystem, shell, and git tools for the dev agent. +package devtools + +import "github.com/ATMackay/agent/executor" + +// All returns every dev tool configured for the given working directory. +func All(workDir string) []*executor.Tool { + tools := []*executor.Tool{ + bashTool(workDir), + readFileTool(workDir), + writeFileTool(workDir), + editFileTool(workDir), + listDirTool(workDir), + globTool(workDir), + searchFilesTool(workDir), + spawnAgentTool(workDir), + } + tools = append(tools, gitTools(workDir)...) + return tools +} diff --git a/devtools/write.go b/devtools/write.go new file mode 100644 index 0000000..d2e0667 --- /dev/null +++ b/devtools/write.go @@ -0,0 +1,48 @@ +package devtools + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "google.golang.org/genai" + + "github.com/ATMackay/agent/executor" +) + +func writeFileTool(workDir string) *executor.Tool { + return &executor.Tool{ + Name: "write_file", + Description: "Create or overwrite a file with the given content. Creates parent directories automatically.", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "path": { + Type: genai.TypeString, + Description: "File path (relative to work dir or absolute).", + }, + "content": { + Type: genai.TypeString, + Description: "File content to write.", + }, + }, + Required: []string{"path", "content"}, + }, + Run: func(_ context.Context, args map[string]any) (map[string]any, error) { + path := resolvePathArg(args, workDir) + if path == "" { + return nil, fmt.Errorf("write_file: path is required") + } + content, _ := args["content"].(string) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("write_file: mkdir: %w", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return nil, fmt.Errorf("write_file: %w", err) + } + return map[string]any{"output": fmt.Sprintf("wrote %d bytes to %s", len(content), path)}, nil + }, + } +} diff --git a/executor/executor.go b/executor/executor.go new file mode 100644 index 0000000..8aa625a --- /dev/null +++ b/executor/executor.go @@ -0,0 +1,236 @@ +// Package executor provides a custom ADK agent that executes tool calls in parallel. +package executor + +import ( + "context" + "fmt" + "iter" + "log/slog" + "sync" + "time" + + "google.golang.org/adk/agent" + adkmodel "google.golang.org/adk/model" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +const defaultMaxSteps = 50 + +// Config holds configuration for an Executor-backed agent. +type Config struct { + Name string + Description string + Instruction string + Model adkmodel.LLM + Tools []*Tool + MaxSteps int +} + +// Executor runs tool calls in parallel inside a custom ADK agent run loop. +type Executor struct { + name string + description string + instruction string + model adkmodel.LLM + tools []*Tool + toolIndex map[string]*Tool + maxSteps int +} + +// New builds an agent.Agent whose run function dispatches tool calls concurrently. +func New(cfg Config) (agent.Agent, error) { + if cfg.Name == "" { + return nil, fmt.Errorf("executor: Name is required") + } + if cfg.Model == nil { + return nil, fmt.Errorf("executor: Model is required") + } + maxSteps := cfg.MaxSteps + if maxSteps <= 0 { + maxSteps = defaultMaxSteps + } + e := &Executor{ + name: cfg.Name, + description: cfg.Description, + instruction: cfg.Instruction, + model: cfg.Model, + tools: cfg.Tools, + toolIndex: make(map[string]*Tool, len(cfg.Tools)), + maxSteps: maxSteps, + } + for _, t := range cfg.Tools { + e.toolIndex[t.Name] = t + } + return agent.New(agent.Config{ + Name: cfg.Name, + Description: cfg.Description, + Run: e.run, + }) +} + +func (e *Executor) run(invCtx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + history := e.buildHistory(invCtx) + req := e.buildRequest(history) + + for step := 0; step < e.maxSteps; step++ { + resp, err := e.collect(invCtx, req) + if err != nil { + yield(nil, fmt.Errorf("executor: model call failed at step %d: %w", step+1, err)) + return + } + + modelEv := e.newModelEvent(invCtx, resp) + if !yield(modelEv, nil) { + return + } + + calls := extractFunctionCalls(resp.Content) + if len(calls) == 0 { + return + } + + slog.Info("dispatching tools", "count", len(calls), "step", step+1) + results := e.dispatchParallel(invCtx, calls) + + toolContent := buildFunctionResponses(calls, results) + toolEv := e.newToolEvent(invCtx, toolContent) + if !yield(toolEv, nil) { + return + } + + req.Contents = append(req.Contents, resp.Content, toolContent) + } + yield(nil, fmt.Errorf("executor: max steps (%d) reached", e.maxSteps)) + } +} + +func (e *Executor) buildHistory(invCtx agent.InvocationContext) []*genai.Content { + var contents []*genai.Content + for ev := range invCtx.Session().Events().All() { + if ev.LLMResponse.Content != nil { + contents = append(contents, ev.LLMResponse.Content) + } + } + if userMsg := invCtx.UserContent(); userMsg != nil { + contents = append(contents, userMsg) + } + return contents +} + +func (e *Executor) buildRequest(history []*genai.Content) *adkmodel.LLMRequest { + decls := make([]*genai.FunctionDeclaration, len(e.tools)) + for i, t := range e.tools { + decls[i] = t.declaration() + } + + cfg := &genai.GenerateContentConfig{} + if e.instruction != "" { + cfg.SystemInstruction = &genai.Content{ + Parts: []*genai.Part{{Text: e.instruction}}, + } + } + if len(decls) > 0 { + cfg.Tools = []*genai.Tool{{FunctionDeclarations: decls}} + } + + return &adkmodel.LLMRequest{ + Model: e.model.Name(), + Contents: history, + Config: cfg, + } +} + +// collect drains the GenerateContent iterator and returns the final response. +func (e *Executor) collect(ctx context.Context, req *adkmodel.LLMRequest) (*adkmodel.LLMResponse, error) { + var last *adkmodel.LLMResponse + for resp, err := range e.model.GenerateContent(ctx, req, false) { + if err != nil { + return nil, err + } + last = resp + } + if last == nil { + return nil, fmt.Errorf("executor: model returned no response") + } + return last, nil +} + +func (e *Executor) dispatchParallel(ctx context.Context, calls []*genai.FunctionCall) []toolResult { + out := make([]toolResult, len(calls)) + var wg sync.WaitGroup + for i, call := range calls { + wg.Add(1) + go func(i int, call *genai.FunctionCall) { + defer wg.Done() + t := e.toolIndex[call.Name] + if t == nil { + out[i] = toolResult{call: call, err: fmt.Errorf("unknown tool %q", call.Name)} + return + } + start := time.Now() + slog.Info("tool start", "name", call.Name, "id", call.ID) + result, err := t.Run(ctx, call.Args) + slog.Info("tool done", "name", call.Name, "id", call.ID, "duration", time.Since(start)) + out[i] = toolResult{call: call, result: result, err: err} + }(i, call) + } + wg.Wait() + return out +} + +func (e *Executor) newModelEvent(invCtx agent.InvocationContext, resp *adkmodel.LLMResponse) *session.Event { + ev := session.NewEvent(invCtx.InvocationID()) + ev.LLMResponse = *resp + ev.Author = e.name + ev.Branch = invCtx.Branch() + return ev +} + +func (e *Executor) newToolEvent(invCtx agent.InvocationContext, content *genai.Content) *session.Event { + ev := session.NewEvent(invCtx.InvocationID()) + ev.LLMResponse = adkmodel.LLMResponse{Content: content} + ev.Author = genai.RoleUser + ev.Branch = invCtx.Branch() + return ev +} + +func extractFunctionCalls(content *genai.Content) []*genai.FunctionCall { + if content == nil { + return nil + } + var calls []*genai.FunctionCall + for _, part := range content.Parts { + if part.FunctionCall != nil { + calls = append(calls, part.FunctionCall) + } + } + return calls +} + +func buildFunctionResponses(calls []*genai.FunctionCall, results []toolResult) *genai.Content { + parts := make([]*genai.Part, len(results)) + for i, r := range results { + var resp map[string]any + if r.err != nil { + resp = map[string]any{"error": r.err.Error()} + } else { + resp = r.result + if resp == nil { + resp = map[string]any{"output": "ok"} + } + } + parts[i] = &genai.Part{ + FunctionResponse: &genai.FunctionResponse{ + ID: calls[i].ID, + Name: calls[i].Name, + Response: resp, + }, + } + } + return &genai.Content{ + Role: genai.RoleUser, + Parts: parts, + } +} diff --git a/executor/executor_test.go b/executor/executor_test.go new file mode 100644 index 0000000..55fc76e --- /dev/null +++ b/executor/executor_test.go @@ -0,0 +1,188 @@ +package executor + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "google.golang.org/genai" +) + +// mockTool builds a Tool that records calls and returns after a given delay. +func mockTool(name string, delay time.Duration, result map[string]any, callCount *atomic.Int32) *Tool { + return &Tool{ + Name: name, + Description: "mock tool", + Parameters: &genai.Schema{Type: genai.TypeObject}, + Run: func(_ context.Context, _ map[string]any) (map[string]any, error) { + time.Sleep(delay) + callCount.Add(1) + return result, nil + }, + } +} + +func TestDispatchParallel_AllToolsCalled(t *testing.T) { + var countA, countB, countC atomic.Int32 + + e := &Executor{ + toolIndex: map[string]*Tool{ + "a": mockTool("a", 10*time.Millisecond, map[string]any{"a": "1"}, &countA), + "b": mockTool("b", 10*time.Millisecond, map[string]any{"b": "2"}, &countB), + "c": mockTool("c", 10*time.Millisecond, map[string]any{"c": "3"}, &countC), + }, + } + + calls := []*genai.FunctionCall{ + {ID: "1", Name: "a"}, + {ID: "2", Name: "b"}, + {ID: "3", Name: "c"}, + } + + results := e.dispatchParallel(context.Background(), calls) + + if len(results) != 3 { + t.Fatalf("want 3 results, got %d", len(results)) + } + if countA.Load() != 1 || countB.Load() != 1 || countC.Load() != 1 { + t.Errorf("not all tools called once: a=%d b=%d c=%d", countA.Load(), countB.Load(), countC.Load()) + } +} + +func TestDispatchParallel_RunsInParallel(t *testing.T) { + const toolDelay = 50 * time.Millisecond + const toolCount = 5 + + var count atomic.Int32 + tools := make(map[string]*Tool, toolCount) + calls := make([]*genai.FunctionCall, toolCount) + + for i := 0; i < toolCount; i++ { + name := fmt.Sprintf("tool%d", i) + tools[name] = mockTool(name, toolDelay, nil, &count) + calls[i] = &genai.FunctionCall{ID: fmt.Sprintf("%d", i), Name: name} + } + + e := &Executor{toolIndex: tools} + + start := time.Now() + results := e.dispatchParallel(context.Background(), calls) + elapsed := time.Since(start) + + if len(results) != toolCount { + t.Fatalf("want %d results, got %d", toolCount, len(results)) + } + + // With parallel execution, total time should be << toolCount * toolDelay. + // Allow 3× the single-tool delay as generous upper bound. + maxExpected := 3 * toolDelay + if elapsed > maxExpected { + t.Errorf("parallel dispatch took %v; expected < %v (suggesting sequential execution)", elapsed, maxExpected) + } +} + +func TestDispatchParallel_UnknownToolReturnsError(t *testing.T) { + e := &Executor{toolIndex: map[string]*Tool{}} + calls := []*genai.FunctionCall{{ID: "1", Name: "missing"}} + + results := e.dispatchParallel(context.Background(), calls) + + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } + if results[0].err == nil { + t.Error("want error for unknown tool, got nil") + } +} + +func TestDispatchParallel_NoDataRace(t *testing.T) { + // Run with -race to catch concurrent map writes. + const n = 20 + var count atomic.Int32 + tools := make(map[string]*Tool, n) + calls := make([]*genai.FunctionCall, n) + + for i := 0; i < n; i++ { + name := fmt.Sprintf("t%d", i) + tools[name] = mockTool(name, 0, map[string]any{"x": i}, &count) + calls[i] = &genai.FunctionCall{ID: fmt.Sprintf("%d", i), Name: name} + } + + e := &Executor{toolIndex: tools} + + var wg sync.WaitGroup + for iter := 0; iter < 5; iter++ { + wg.Add(1) + go func() { + defer wg.Done() + e.dispatchParallel(context.Background(), calls) + }() + } + wg.Wait() +} + +func TestBuildFunctionResponses_Success(t *testing.T) { + calls := []*genai.FunctionCall{ + {ID: "1", Name: "myTool"}, + } + results := []toolResult{ + {call: calls[0], result: map[string]any{"output": "hello"}}, + } + + content := buildFunctionResponses(calls, results) + + if content.Role != genai.RoleUser { + t.Errorf("want role %q, got %q", genai.RoleUser, content.Role) + } + if len(content.Parts) != 1 { + t.Fatalf("want 1 part, got %d", len(content.Parts)) + } + fr := content.Parts[0].FunctionResponse + if fr == nil { + t.Fatal("want FunctionResponse, got nil") + } + if fr.Name != "myTool" { + t.Errorf("want name %q, got %q", "myTool", fr.Name) + } + if fr.ID != "1" { + t.Errorf("want id %q, got %q", "1", fr.ID) + } +} + +func TestBuildFunctionResponses_Error(t *testing.T) { + calls := []*genai.FunctionCall{{ID: "1", Name: "failTool"}} + results := []toolResult{ + {call: calls[0], err: fmt.Errorf("something broke")}, + } + + content := buildFunctionResponses(calls, results) + + fr := content.Parts[0].FunctionResponse + if _, ok := fr.Response["error"]; !ok { + t.Error("want 'error' key in response map for failed tool") + } +} + +func TestExtractFunctionCalls_NilContent(t *testing.T) { + if calls := extractFunctionCalls(nil); calls != nil { + t.Errorf("want nil, got %v", calls) + } +} + +func TestExtractFunctionCalls_Mixed(t *testing.T) { + content := &genai.Content{ + Parts: []*genai.Part{ + {Text: "some text"}, + {FunctionCall: &genai.FunctionCall{Name: "tool1"}}, + {Text: "more text"}, + {FunctionCall: &genai.FunctionCall{Name: "tool2"}}, + }, + } + calls := extractFunctionCalls(content) + if len(calls) != 2 { + t.Errorf("want 2 calls, got %d", len(calls)) + } +} diff --git a/executor/tool.go b/executor/tool.go new file mode 100644 index 0000000..3de950d --- /dev/null +++ b/executor/tool.go @@ -0,0 +1,31 @@ +package executor + +import ( + "context" + + "google.golang.org/genai" +) + +// Tool is a plain, ADK-decoupled tool definition for use with Executor. +type Tool struct { + Name string + Description string + Parameters *genai.Schema + Run func(ctx context.Context, args map[string]any) (map[string]any, error) +} + +// declaration returns the genai function declaration for this tool. +func (t *Tool) declaration() *genai.FunctionDeclaration { + return &genai.FunctionDeclaration{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + } +} + +// toolResult holds the outcome of a single parallel tool dispatch. +type toolResult struct { + call *genai.FunctionCall + result map[string]any + err error +} diff --git a/pipeline/config.go b/pipeline/config.go index 4305a1f..8dfebaa 100644 --- a/pipeline/config.go +++ b/pipeline/config.go @@ -20,9 +20,10 @@ type PipelineConfig struct { // StageConfig defines a single pipeline stage. type StageConfig struct { ID string `yaml:"id"` - Agent string `yaml:"agent"` // "requirements" or "architect" + Agent string `yaml:"agent"` // "requirements", "architect", or "dev" + Task string `yaml:"task,omitempty"` // task description for dev agent Output string `yaml:"output"` // output file path (relative) - Gate *GateConfig `yaml:"gate,omitempty"` // optional human approval gate + Gate *GateConfig `yaml:"gate,omitempty"` // optional human approval gate Model string `yaml:"model,omitempty"` // optional per-stage model override } diff --git a/pipeline/pipeline.go b/pipeline/pipeline.go index 1ab1dc6..b76642e 100644 --- a/pipeline/pipeline.go +++ b/pipeline/pipeline.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ATMackay/agent/agents/architect" + devagent "github.com/ATMackay/agent/agents/dev" "github.com/ATMackay/agent/agents/requirements" "github.com/ATMackay/agent/state" "github.com/ATMackay/agent/workflow" @@ -145,6 +146,8 @@ func (p *Pipeline) runStage(ctx context.Context, stageCfg *StageConfig, mod adkm return p.runRequirementsStage(ctx, mod, outputPath) case "architect": return p.runArchitectStage(ctx, stageCfg, mod, outputPath) + case devagent.AgentName: + return p.runDevStage(ctx, stageCfg, mod) default: return fmt.Errorf("unknown agent %q", stageCfg.Agent) } @@ -220,6 +223,34 @@ func (p *Pipeline) runArchitectStage(ctx context.Context, stageCfg *StageConfig, return w.Start(ctx, "pipeline", architect.UserMessage(inputDoc, p.cfg.WorkDir, task)) } +func (p *Pipeline) runDevStage(ctx context.Context, stageCfg *StageConfig, mod adkmodel.LLM) error { + task := stageCfg.Task + if task == "" { + task = "Implement the tasks described in the project documentation in the work directory." + } + + workDir := p.cfg.WorkDir + if workDir == "" { + workDir = "." + } + + cfg := &devagent.Config{WorkDir: workDir} + if err := cfg.Validate(); err != nil { + return err + } + + ag, err := devagent.NewDev(cfg, mod) + if err != nil { + return fmt.Errorf("create dev agent: %w", err) + } + + w, err := workflow.New(ctx, devagent.AgentName, session.InMemoryService(), ag, nil) + if err != nil { + return err + } + return w.Start(ctx, "pipeline", devagent.UserMessage(task)) +} + // findLatestOutput finds the output path from the most recently completed stage // that used the given agent type. func (p *Pipeline) findLatestOutput(agentType string) string { From d18fefb7a103c532b9349f810ec5edbb43f8db57 Mon Sep 17 00:00:00 2001 From: Alex Mackay Date: Sun, 24 May 2026 15:58:09 +0100 Subject: [PATCH 2/2] remove embedded field ref --- executor/executor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/executor/executor.go b/executor/executor.go index 8aa625a..22cdd8c 100644 --- a/executor/executor.go +++ b/executor/executor.go @@ -109,8 +109,8 @@ func (e *Executor) run(invCtx agent.InvocationContext) iter.Seq2[*session.Event, func (e *Executor) buildHistory(invCtx agent.InvocationContext) []*genai.Content { var contents []*genai.Content for ev := range invCtx.Session().Events().All() { - if ev.LLMResponse.Content != nil { - contents = append(contents, ev.LLMResponse.Content) + if ev.Content != nil { + contents = append(contents, ev.Content) } } if userMsg := invCtx.UserContent(); userMsg != nil {