Skip to content

Commit 99f9890

Browse files
Merge pull request #60 from priyanshujain/feat-delegate-task
feat: delegate tasks to external AI CLIs (Claude, Gemini)
2 parents 4704f1c + e705003 commit 99f9890

16 files changed

+2308
-14
lines changed

agent/tools/agent_runner.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package tools
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
"time"
11+
)
12+
13+
// AgentKind identifies an external AI CLI agent.
14+
type AgentKind string
15+
16+
const (
17+
AgentClaude AgentKind = "claude"
18+
AgentGemini AgentKind = "gemini"
19+
AgentCodex AgentKind = "codex"
20+
)
21+
22+
// AgentInfo describes a detected external CLI agent.
23+
type AgentInfo struct {
24+
Kind AgentKind
25+
Binary string // absolute path from LookPath
26+
}
27+
28+
// DetectAgents scans PATH for known AI CLI agents in priority order.
29+
func DetectAgents() []AgentInfo {
30+
candidates := []AgentKind{AgentClaude, AgentGemini, AgentCodex}
31+
var found []AgentInfo
32+
for _, kind := range candidates {
33+
if bin, err := exec.LookPath(string(kind)); err == nil {
34+
found = append(found, AgentInfo{Kind: kind, Binary: bin})
35+
}
36+
}
37+
return found
38+
}
39+
40+
// RunOption configures an agent run.
41+
type RunOption func(*runOptions)
42+
43+
type runOptions struct {
44+
maxBudgetUSD float64
45+
}
46+
47+
// WithMaxBudget sets the maximum API cost budget (Claude only).
48+
func WithMaxBudget(usd float64) RunOption {
49+
return func(o *runOptions) { o.maxBudgetUSD = usd }
50+
}
51+
52+
// AgentRunnerInterface abstracts agent CLI execution for testability.
53+
type AgentRunnerInterface interface {
54+
Run(ctx context.Context, prompt string, timeout time.Duration, opts ...RunOption) (string, error)
55+
}
56+
57+
// AgentRunner executes an external AI CLI agent.
58+
type AgentRunner struct {
59+
info AgentInfo
60+
}
61+
62+
// NewAgentRunner creates a runner for the given agent.
63+
func NewAgentRunner(info AgentInfo) *AgentRunner {
64+
return &AgentRunner{info: info}
65+
}
66+
67+
// Run executes the CLI with the given prompt and timeout, returning stdout.
68+
func (r *AgentRunner) Run(ctx context.Context, prompt string, timeout time.Duration, opts ...RunOption) (string, error) {
69+
ctx, cancel := context.WithTimeout(ctx, timeout)
70+
defer cancel()
71+
72+
var ro runOptions
73+
for _, o := range opts {
74+
o(&ro)
75+
}
76+
args := r.buildArgs(ro)
77+
// Gemini takes prompt as -p argument; others use stdin.
78+
if r.info.Kind == AgentGemini {
79+
args = append(args, prompt)
80+
}
81+
cmd := exec.CommandContext(ctx, r.info.Binary, args...)
82+
cmd.Env = r.buildEnv()
83+
if r.info.Kind != AgentGemini {
84+
cmd.Stdin = strings.NewReader(prompt)
85+
}
86+
87+
var stdout, stderr bytes.Buffer
88+
cmd.Stdout = &stdout
89+
cmd.Stderr = &stderr
90+
91+
if err := cmd.Run(); err != nil {
92+
if ctx.Err() == context.DeadlineExceeded {
93+
return "", fmt.Errorf("agent %s timed out after %s", r.info.Kind, timeout)
94+
}
95+
combined := stdout.String() + stderr.String()
96+
if combined != "" {
97+
return "", fmt.Errorf("agent %s: %s", r.info.Kind, combined)
98+
}
99+
return "", fmt.Errorf("agent %s: %w", r.info.Kind, err)
100+
}
101+
return stdout.String(), nil
102+
}
103+
104+
func (r *AgentRunner) buildArgs(opts runOptions) []string {
105+
switch r.info.Kind {
106+
case AgentClaude:
107+
args := []string{"--print", "--output-format", "text"}
108+
if opts.maxBudgetUSD > 0 {
109+
args = append(args, "--max-budget-usd", fmt.Sprintf("%.2f", opts.maxBudgetUSD))
110+
}
111+
return args
112+
case AgentGemini:
113+
return []string{"-p"}
114+
default:
115+
return nil
116+
}
117+
}
118+
119+
func (r *AgentRunner) buildEnv() []string {
120+
env := os.Environ()
121+
if r.info.Kind == AgentClaude {
122+
return filterEnv(env, "CLAUDECODE")
123+
}
124+
return env
125+
}
126+
127+
// filterEnv returns env with entries matching the given key prefix removed.
128+
func filterEnv(env []string, key string) []string {
129+
prefix := key + "="
130+
filtered := make([]string, 0, len(env))
131+
for _, e := range env {
132+
if !strings.HasPrefix(e, prefix) {
133+
filtered = append(filtered, e)
134+
}
135+
}
136+
return filtered
137+
}

agent/tools/agent_runner_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"os/exec"
6+
"strings"
7+
"testing"
8+
"time"
9+
)
10+
11+
// mockAgentRunner is a test double for AgentRunnerInterface.
12+
type mockAgentRunner struct {
13+
output string
14+
err error
15+
called bool
16+
prompt string
17+
timeout time.Duration
18+
}
19+
20+
func (m *mockAgentRunner) Run(_ context.Context, prompt string, timeout time.Duration, _ ...RunOption) (string, error) {
21+
m.called = true
22+
m.prompt = prompt
23+
m.timeout = timeout
24+
if m.err != nil {
25+
return "", m.err
26+
}
27+
return m.output, nil
28+
}
29+
30+
// blockingAgentRunner blocks until released or context is cancelled.
31+
type blockingAgentRunner struct {
32+
output string
33+
err error
34+
release chan struct{}
35+
called chan struct{}
36+
}
37+
38+
func newBlockingRunner(output string, err error) *blockingAgentRunner {
39+
return &blockingAgentRunner{
40+
output: output,
41+
err: err,
42+
release: make(chan struct{}),
43+
called: make(chan struct{}),
44+
}
45+
}
46+
47+
func (b *blockingAgentRunner) Run(ctx context.Context, _ string, _ time.Duration, _ ...RunOption) (string, error) {
48+
close(b.called)
49+
select {
50+
case <-b.release:
51+
if b.err != nil {
52+
return "", b.err
53+
}
54+
return b.output, nil
55+
case <-ctx.Done():
56+
return "", ctx.Err()
57+
}
58+
}
59+
60+
func TestDetectAgents_Priority(t *testing.T) {
61+
agents := DetectAgents()
62+
if len(agents) == 0 {
63+
t.Skip("no AI CLIs found on PATH")
64+
}
65+
// Verify priority: claude < gemini < codex by index.
66+
order := map[AgentKind]int{AgentClaude: 0, AgentGemini: 1, AgentCodex: 2}
67+
prev := -1
68+
for _, a := range agents {
69+
idx, ok := order[a.Kind]
70+
if !ok {
71+
t.Errorf("unexpected agent kind: %s", a.Kind)
72+
continue
73+
}
74+
if idx <= prev {
75+
t.Errorf("agent %s (idx %d) came after idx %d — wrong priority", a.Kind, idx, prev)
76+
}
77+
prev = idx
78+
if a.Binary == "" {
79+
t.Errorf("agent %s has empty binary path", a.Kind)
80+
}
81+
}
82+
}
83+
84+
func TestAgentRunner_BuildsClaudeArgs(t *testing.T) {
85+
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "/usr/local/bin/claude"})
86+
args := r.buildArgs(runOptions{})
87+
want := []string{"--print", "--output-format", "text"}
88+
if len(args) != len(want) {
89+
t.Fatalf("args = %v, want %v", args, want)
90+
}
91+
for i, a := range args {
92+
if a != want[i] {
93+
t.Errorf("args[%d] = %q, want %q", i, a, want[i])
94+
}
95+
}
96+
}
97+
98+
func TestAgentRunner_BuildsGeminiArgs(t *testing.T) {
99+
r := NewAgentRunner(AgentInfo{Kind: AgentGemini, Binary: "/usr/local/bin/gemini"})
100+
args := r.buildArgs(runOptions{})
101+
want := []string{"-p"}
102+
if len(args) != len(want) {
103+
t.Fatalf("args = %v, want %v", args, want)
104+
}
105+
if args[0] != "-p" {
106+
t.Errorf("args[0] = %q, want %q", args[0], "-p")
107+
}
108+
}
109+
110+
func TestAgentRunner_StripsCLAUDECODE(t *testing.T) {
111+
t.Setenv("CLAUDECODE", "1")
112+
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "/usr/local/bin/claude"})
113+
env := r.buildEnv()
114+
for _, e := range env {
115+
if e == "CLAUDECODE=1" {
116+
t.Error("CLAUDECODE should be stripped from child env")
117+
}
118+
}
119+
}
120+
121+
func TestAgentRunner_GeminiKeepsCLAUDECODE(t *testing.T) {
122+
t.Setenv("CLAUDECODE", "1")
123+
r := NewAgentRunner(AgentInfo{Kind: AgentGemini, Binary: "/usr/local/bin/gemini"})
124+
env := r.buildEnv()
125+
found := false
126+
for _, e := range env {
127+
if e == "CLAUDECODE=1" {
128+
found = true
129+
break
130+
}
131+
}
132+
if !found {
133+
t.Error("CLAUDECODE should NOT be stripped for gemini")
134+
}
135+
}
136+
137+
func TestAgentRunner_Timeout(t *testing.T) {
138+
r := NewAgentRunner(AgentInfo{Kind: AgentClaude, Binary: "sleep"})
139+
_, err := r.Run(context.Background(), "", 100*time.Millisecond)
140+
if err == nil {
141+
t.Fatal("expected timeout error")
142+
}
143+
}
144+
145+
func TestFilterEnv(t *testing.T) {
146+
env := []string{"HOME=/home/user", "CLAUDECODE=1", "PATH=/usr/bin"}
147+
got := filterEnv(env, "CLAUDECODE")
148+
if len(got) != 2 {
149+
t.Fatalf("got %d entries, want 2", len(got))
150+
}
151+
for _, e := range got {
152+
if e == "CLAUDECODE=1" {
153+
t.Error("CLAUDECODE not filtered")
154+
}
155+
}
156+
}
157+
158+
func TestAgentRunner_RealClaude(t *testing.T) {
159+
if _, err := exec.LookPath("claude"); err != nil {
160+
t.Skip("claude not on PATH")
161+
}
162+
agents := DetectAgents()
163+
var info AgentInfo
164+
for _, a := range agents {
165+
if a.Kind == AgentClaude {
166+
info = a
167+
break
168+
}
169+
}
170+
r := NewAgentRunner(info)
171+
out, err := r.Run(context.Background(), "Say hello in exactly one word.", 30*time.Second)
172+
if err != nil {
173+
t.Fatalf("Run: %v", err)
174+
}
175+
if out == "" {
176+
t.Error("expected non-empty output")
177+
}
178+
}
179+
180+
func TestAgentRunner_RealGemini(t *testing.T) {
181+
if _, err := exec.LookPath("gemini"); err != nil {
182+
t.Skip("gemini not on PATH")
183+
}
184+
agents := DetectAgents()
185+
var info AgentInfo
186+
for _, a := range agents {
187+
if a.Kind == AgentGemini {
188+
info = a
189+
break
190+
}
191+
}
192+
r := NewAgentRunner(info)
193+
out, err := r.Run(context.Background(), "Say hello in exactly one word.", 30*time.Second)
194+
if err != nil {
195+
if strings.Contains(err.Error(), "Permission") || strings.Contains(err.Error(), "denied") || strings.Contains(err.Error(), "auth") {
196+
t.Skipf("gemini auth not configured: %v", err)
197+
}
198+
t.Fatalf("Run: %v", err)
199+
}
200+
if out == "" {
201+
t.Error("expected non-empty output")
202+
}
203+
}

0 commit comments

Comments
 (0)