Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions agents/dev/config.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions agents/dev/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
31 changes: 31 additions & 0 deletions agents/dev/dev.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
41 changes: 41 additions & 0 deletions agents/dev/prompt.go
Original file line number Diff line number Diff line change
@@ -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
}
110 changes: 110 additions & 0 deletions cmd/dev.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
98 changes: 98 additions & 0 deletions devtools/bash.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading