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 workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# Go build cache
|
||||||
|
pkg/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.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