Add Go agent example with Agent Mail support
Amp-Thread-ID: https://ampcode.com/threads/T-b50a2f5e-cad0-43f0-b3ec-afe9bd5fa654 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,9 @@
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Go build cache
|
||||
pkg/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
97
examples/go-agent/README.md
Normal file
97
examples/go-agent/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Go Agent Example
|
||||
|
||||
Example Go agent that uses bd with optional Agent Mail coordination for multi-agent workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- Uses native Go Agent Mail client (`pkg/agentmail`)
|
||||
- Graceful degradation when Agent Mail unavailable
|
||||
- Handles reservation conflicts
|
||||
- Discovers and links new work
|
||||
- Environment-based configuration
|
||||
|
||||
## Usage
|
||||
|
||||
### Git-only mode (no Agent Mail)
|
||||
|
||||
```bash
|
||||
cd examples/go-agent
|
||||
go run main.go --agent-name agent-alpha --max-iterations 5
|
||||
```
|
||||
|
||||
### With Agent Mail coordination
|
||||
|
||||
```bash
|
||||
# Start Agent Mail server (in separate terminal)
|
||||
cd integrations/agent-mail
|
||||
python server.py
|
||||
|
||||
# Run agent
|
||||
cd examples/go-agent
|
||||
go run main.go \
|
||||
--agent-name agent-alpha \
|
||||
--project-id my-project \
|
||||
--agent-mail-url http://127.0.0.1:8765 \
|
||||
--max-iterations 10
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export BEADS_AGENT_NAME=agent-alpha
|
||||
export BEADS_PROJECT_ID=my-project
|
||||
export BEADS_AGENT_MAIL_URL=http://127.0.0.1:8765
|
||||
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## Multi-Agent Demo
|
||||
|
||||
Run multiple agents concurrently with Agent Mail:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start Agent Mail server
|
||||
cd integrations/agent-mail
|
||||
python server.py
|
||||
|
||||
# Terminal 2: Agent Alpha
|
||||
cd examples/go-agent
|
||||
go run main.go --agent-name agent-alpha --agent-mail-url http://127.0.0.1:8765
|
||||
|
||||
# Terminal 3: Agent Beta
|
||||
go run main.go --agent-name agent-beta --agent-mail-url http://127.0.0.1:8765
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Initialization**: Creates Agent Mail client with health check
|
||||
2. **Find work**: Queries `bd ready` for unblocked issues
|
||||
3. **Claim issue**: Reserves via Agent Mail (if enabled) and updates status to `in_progress`
|
||||
4. **Work simulation**: Processes the issue (sleeps 1s in this example)
|
||||
5. **Discover work**: 33% chance to create linked issue via `discovered-from` dependency
|
||||
6. **Complete**: Closes issue and releases Agent Mail reservation
|
||||
|
||||
## Collision Handling
|
||||
|
||||
When Agent Mail is enabled:
|
||||
- Issues are reserved before claiming (prevents race conditions)
|
||||
- Conflicts return immediately (<100ms latency)
|
||||
- Agents gracefully skip reserved issues
|
||||
|
||||
Without Agent Mail:
|
||||
- Relies on git-based eventual consistency
|
||||
- Higher latency (2-5s for sync)
|
||||
- Collision detection via git merge conflicts
|
||||
|
||||
## Comparison with Python Agent
|
||||
|
||||
The Go implementation mirrors the Python agent (`examples/python-agent/agent_with_mail.py`):
|
||||
- ✅ Same API surface (ReserveIssue, ReleaseIssue, Notify, CheckInbox)
|
||||
- ✅ Same graceful degradation behavior
|
||||
- ✅ Same environment variable configuration
|
||||
- ✅ Native Go types and idioms (no shell exec for Agent Mail)
|
||||
|
||||
Key differences:
|
||||
- Go uses `pkg/agentmail.Client` instead of `lib/beads_mail_adapter.py`
|
||||
- Go struct methods vs Python class methods
|
||||
- Type safety with Go structs
|
||||
238
examples/go-agent/main.go
Normal file
238
examples/go-agent/main.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/pkg/agentmail"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
IssueType string `json:"issue_type"`
|
||||
}
|
||||
|
||||
type BeadsAgent struct {
|
||||
agentName string
|
||||
projectID string
|
||||
agentMailURL string
|
||||
mailClient *agentmail.Client
|
||||
maxIterations int
|
||||
}
|
||||
|
||||
func NewBeadsAgent(agentName, projectID, agentMailURL string, maxIterations int) *BeadsAgent {
|
||||
agent := &BeadsAgent{
|
||||
agentName: agentName,
|
||||
projectID: projectID,
|
||||
agentMailURL: agentMailURL,
|
||||
maxIterations: maxIterations,
|
||||
}
|
||||
|
||||
if agentMailURL != "" {
|
||||
_ = os.Setenv("BEADS_AGENT_MAIL_URL", agentMailURL)
|
||||
_ = os.Setenv("BEADS_AGENT_NAME", agentName)
|
||||
_ = os.Setenv("BEADS_PROJECT_ID", projectID)
|
||||
agent.mailClient = agentmail.NewClient(
|
||||
agentmail.WithURL(agentMailURL),
|
||||
agentmail.WithAgentName(agentName),
|
||||
)
|
||||
if agent.mailClient.Enabled {
|
||||
fmt.Printf("✨ Agent Mail enabled: %s @ %s\n", agentName, agentMailURL)
|
||||
} else {
|
||||
fmt.Printf("📝 Git-only mode: %s (Agent Mail unavailable)\n", agentName)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("📝 Git-only mode: %s\n", agentName)
|
||||
}
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) runBD(args ...string) ([]byte, error) {
|
||||
args = append(args, "--json")
|
||||
cmd := exec.Command("bd", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(string(output), "already reserved") || strings.Contains(string(output), "reservation conflict") {
|
||||
return output, fmt.Errorf("reservation_conflict")
|
||||
}
|
||||
return output, err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) getReadyWork() ([]Issue, error) {
|
||||
output, err := a.runBD("ready")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var issues []Issue
|
||||
if err := json.Unmarshal(output, &issues); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse ready work: %w", err)
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) claimIssue(issueID string) bool {
|
||||
fmt.Printf("📋 Claiming issue: %s\n", issueID)
|
||||
|
||||
if a.mailClient != nil && a.mailClient.Enabled {
|
||||
if !a.mailClient.ReserveIssue(issueID, 3600) {
|
||||
fmt.Printf(" ⚠️ Issue %s already claimed by another agent\n", issueID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.runBD("update", issueID, "--status", "in_progress")
|
||||
if err != nil {
|
||||
if err.Error() == "reservation_conflict" {
|
||||
fmt.Printf(" ⚠️ Issue %s already claimed by another agent\n", issueID)
|
||||
return false
|
||||
}
|
||||
fmt.Printf(" ❌ Failed to claim %s: %v\n", issueID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Successfully claimed %s\n", issueID)
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) completeIssue(issueID, reason string) bool {
|
||||
fmt.Printf("✅ Completing issue: %s\n", issueID)
|
||||
|
||||
_, err := a.runBD("close", issueID, "--reason", reason)
|
||||
if err != nil {
|
||||
fmt.Printf(" ❌ Failed to complete %s: %v\n", issueID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if a.mailClient != nil && a.mailClient.Enabled {
|
||||
a.mailClient.ReleaseIssue(issueID)
|
||||
a.mailClient.Notify("issue_completed", map[string]interface{}{
|
||||
"issue_id": issueID,
|
||||
"agent": a.agentName,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Issue %s completed\n", issueID)
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) createDiscoveredIssue(title, parentID string, priority int, issueType string) string {
|
||||
fmt.Printf("💡 Creating discovered issue: %s\n", title)
|
||||
|
||||
output, err := a.runBD("create", title,
|
||||
"-t", issueType,
|
||||
"-p", fmt.Sprintf("%d", priority),
|
||||
"--deps", fmt.Sprintf("discovered-from:%s", parentID),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf(" ❌ Failed to create issue: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
fmt.Printf(" ❌ Failed to parse created issue: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
fmt.Printf(" ✅ Created %s\n", result.ID)
|
||||
return result.ID
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) simulateWork(issue Issue) {
|
||||
fmt.Printf("🤖 Working on: %s (%s)\n", issue.Title, issue.ID)
|
||||
fmt.Printf(" Priority: %d, Type: %s\n", issue.Priority, issue.IssueType)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
func (a *BeadsAgent) run() {
|
||||
fmt.Printf("\n🚀 Agent '%s' starting...\n", a.agentName)
|
||||
fmt.Printf(" Project: %s\n", a.projectID)
|
||||
if a.agentMailURL != "" {
|
||||
fmt.Printf(" Agent Mail: Enabled\n\n")
|
||||
} else {
|
||||
fmt.Printf(" Agent Mail: Disabled (git-only mode)\n\n")
|
||||
}
|
||||
|
||||
for iteration := 1; iteration <= a.maxIterations; iteration++ {
|
||||
fmt.Println(strings.Repeat("=", 60))
|
||||
fmt.Printf("Iteration %d/%d\n", iteration, a.maxIterations)
|
||||
fmt.Println(strings.Repeat("=", 60))
|
||||
|
||||
readyIssues, err := a.getReadyWork()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get ready work: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(readyIssues) == 0 {
|
||||
fmt.Println("📭 No ready work available. Stopping.")
|
||||
break
|
||||
}
|
||||
|
||||
claimed := false
|
||||
for _, issue := range readyIssues {
|
||||
if a.claimIssue(issue.ID) {
|
||||
claimed = true
|
||||
|
||||
a.simulateWork(issue)
|
||||
|
||||
// 33% chance to discover new work
|
||||
if rand.Float32() < 0.33 {
|
||||
discoveredTitle := fmt.Sprintf("Follow-up work for %s", issue.Title)
|
||||
newID := a.createDiscoveredIssue(discoveredTitle, issue.ID, issue.Priority, "task")
|
||||
if newID != "" {
|
||||
fmt.Printf("🔗 Linked %s ← discovered-from ← %s\n", newID, issue.ID)
|
||||
}
|
||||
}
|
||||
|
||||
a.completeIssue(issue.ID, "Implemented successfully")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !claimed {
|
||||
fmt.Println("⚠️ All ready issues are reserved by other agents. Waiting...")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("🏁 Agent '%s' finished\n", a.agentName)
|
||||
}
|
||||
|
||||
func main() {
|
||||
agentName := flag.String("agent-name", getEnv("BEADS_AGENT_NAME", fmt.Sprintf("agent-%d", os.Getpid())), "Unique agent identifier")
|
||||
projectID := flag.String("project-id", getEnv("BEADS_PROJECT_ID", "default"), "Project namespace for Agent Mail")
|
||||
agentMailURL := flag.String("agent-mail-url", os.Getenv("BEADS_AGENT_MAIL_URL"), "Agent Mail server URL")
|
||||
maxIterations := flag.Int("max-iterations", 10, "Maximum number of issues to process")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
agent := NewBeadsAgent(*agentName, *projectID, *agentMailURL, *maxIterations)
|
||||
agent.run()
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
Reference in New Issue
Block a user