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:
Steve Yegge
2025-11-08 19:32:44 -08:00
parent 6ca26ed71b
commit 4fd9e1c9cc
4 changed files with 455 additions and 128 deletions

File diff suppressed because one or more lines are too long

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@
# Go workspace file
go.work
# Go build cache
pkg/
# IDE
.vscode/
.idea/

View 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
View 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
}