yao/agent/caller/orchestrator.go
Max f6b917afe1 Remove TUI support from start command
The bubbletea-based TUI for agent request visualization was not
practical and added significant binary size. This removes the TUI
entirely: the --tui flag, the agent context TUI model/messages, and
all SendTUI/GetTUIProgram call sites. Development-mode logging now
always prints ANSI-colored output to stdout.

Drops direct dependencies on charmbracelet/bubbletea and lipgloss.

Made-with: Cursor
2026-03-06 20:55:57 +08:00

326 lines
8.4 KiB
Go

package caller
import (
"fmt"
"sync"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/trace/types"
)
// Orchestrator handles parallel agent calls with different concurrency patterns
// Modeled after JavaScript Promise patterns (all, any, race)
type Orchestrator struct {
ctx *agentContext.Context
}
// NewOrchestrator creates a new Orchestrator for parallel agent calls
func NewOrchestrator(ctx *agentContext.Context) *Orchestrator {
return &Orchestrator{ctx: ctx}
}
// callResult is used internally to pass results through channels
type callResult struct {
idx int
result *Result
}
// All executes all agent calls and waits for all to complete (like Promise.all)
// Returns results in the same order as requests, regardless of completion order
// Each call uses a forked context to avoid race conditions on shared state
func (o *Orchestrator) All(reqs []*Request) []*Result {
if len(reqs) == 0 {
return []*Result{}
}
results := make([]*Result, len(reqs))
var wg sync.WaitGroup
var mu sync.Mutex
for i, req := range reqs {
wg.Add(1)
go func(idx int, r *Request) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
mu.Lock()
results[idx] = &Result{
AgentID: r.AgentID,
Error: "agent call panic recovered",
}
mu.Unlock()
}
}()
// Use forked context to avoid race conditions
result := o.callAgentWithForkedContext(r)
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, req)
}
wg.Wait()
return results
}
// Any returns as soon as any agent call succeeds (has non-error result) (like Promise.any)
// Other calls continue in background but results are discarded after first success
// Returns all results received so far when first success is found
// Each call uses a forked context to avoid race conditions on shared state
func (o *Orchestrator) Any(reqs []*Request) []*Result {
if len(reqs) == 0 {
return []*Result{}
}
results := make([]*Result, len(reqs))
resultChan := make(chan callResult, len(reqs))
var wg sync.WaitGroup
done := make(chan struct{})
for i, req := range reqs {
wg.Add(1)
go func(idx int, r *Request) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
// Send panic result through channel
select {
case <-done:
case resultChan <- callResult{idx: idx, result: &Result{
AgentID: r.AgentID,
Error: "agent call panic recovered",
}}:
}
}
}()
// Check if done before starting
select {
case <-done:
return
default:
}
// Use forked context to avoid race conditions
result := o.callAgentWithForkedContext(r)
// Try to send result
select {
case <-done:
// Already found a successful result
case resultChan <- callResult{idx: idx, result: result}:
}
}(i, req)
}
// Close channel when all goroutines complete
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results until we find one with success (no error and has content)
var foundSuccess bool
for res := range resultChan {
results[res.idx] = res.result
// Check if this result is successful (no error)
if !foundSuccess && res.result != nil && res.result.Error == "" {
foundSuccess = true
close(done) // Signal other goroutines to stop
}
}
return results
}
// Race returns as soon as any agent call completes (like Promise.race)
// Returns immediately when first result arrives, regardless of success/failure
// Note: Still waits for all goroutines to complete before returning to avoid resource leaks
// Each call uses a forked context to avoid race conditions on shared state
func (o *Orchestrator) Race(reqs []*Request) []*Result {
if len(reqs) == 0 {
return []*Result{}
}
results := make([]*Result, len(reqs))
resultChan := make(chan callResult, len(reqs))
var wg sync.WaitGroup
done := make(chan struct{})
for i, req := range reqs {
wg.Add(1)
go func(idx int, r *Request) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
// Send panic result through channel
select {
case <-done:
case resultChan <- callResult{idx: idx, result: &Result{
AgentID: r.AgentID,
Error: "agent call panic recovered",
}}:
}
}
}()
// Check if done before starting
select {
case <-done:
return
default:
}
// Use forked context to avoid race conditions
result := o.callAgentWithForkedContext(r)
// Try to send result
select {
case <-done:
// Already got first result
case resultChan <- callResult{idx: idx, result: result}:
}
}(i, req)
}
// Close channel when all goroutines complete
go func() {
wg.Wait()
close(resultChan)
}()
// Get first result and signal others to stop
var gotFirst bool
for res := range resultChan {
results[res.idx] = res.result
if !gotFirst {
gotFirst = true
close(done) // Signal other goroutines to stop
}
}
return results
}
// callAgent executes a single agent call using the AgentGetterFunc
// This method handles context sharing and result extraction
func (o *Orchestrator) callAgent(req *Request) *Result {
return o.callAgentWithContext(o.ctx, req)
}
// callAgentWithForkedContext executes a single agent call with a forked context
// This is used by batch operations (All/Any/Race) to avoid race conditions
// when multiple goroutines modify shared context state (Stack, Logger, etc.)
func (o *Orchestrator) callAgentWithForkedContext(req *Request) *Result {
// Fork the context to get independent Stack and Logger
forkedCtx := o.ctx.Fork()
return o.callAgentWithContext(forkedCtx, req)
}
// callAgentWithContext executes a single agent call with the given context
// This is the core implementation used by both callAgent and callAgentWithForkedContext
func (o *Orchestrator) callAgentWithContext(ctx *agentContext.Context, req *Request) *Result {
if req == nil {
return &Result{Error: "nil request"}
}
// Get the agent using the getter function
if AgentGetterFunc == nil {
return NewResult(req.AgentID, nil, fmt.Errorf("agent getter not initialized"))
}
agent, err := AgentGetterFunc(req.AgentID)
if err != nil {
return NewResult(req.AgentID, nil, fmt.Errorf("failed to get agent: %w", err))
}
// Mark this as an agent-to-agent fork call for proper source tracking
// RefererAgentFork distinguishes ctx.agent.Call from delegate calls
ctx.Referer = agentContext.RefererAgentFork
// Build context options for the call
var ctxOpts *agentContext.Options
if req.Options != nil {
ctxOpts = req.Options.ToContextOptions()
} else {
ctxOpts = &agentContext.Options{}
}
// If request has a handler, set OnMessage callback
if req.Handler != nil {
if ctxOpts == nil {
ctxOpts = &agentContext.Options{}
}
// Set OnMessage to receive SSE messages
ctxOpts.OnMessage = req.Handler
}
// Add trace node for A2A call using the ORIGINAL parent context's trace
// (forked contexts have nil trace and nil Stack, so ctx.Trace() would create a new orphan trace)
parentTrace, _ := o.ctx.Trace()
var a2aNode types.Node
if parentTrace != nil {
a2aNode, _ = parentTrace.Add(
map[string]any{
"agent_id": req.AgentID,
"referer": string(ctx.Referer),
},
types.TraceNodeOption{
Label: fmt.Sprintf("Agent: %s", req.AgentID),
Type: "agent_call",
Icon: "smart_toy",
Description: fmt.Sprintf("A2A call to '%s'", req.AgentID),
},
)
}
resp, err := agent.Stream(ctx, req.Messages, ctxOpts)
if err != nil {
if a2aNode != nil {
a2aNode.Fail(err)
}
return NewResult(req.AgentID, nil, fmt.Errorf("agent call failed: %w", err))
}
if a2aNode != nil {
a2aNode.Complete(map[string]any{
"agent_id": req.AgentID,
"status": "completed",
})
}
return NewResult(req.AgentID, resp, nil)
}
// extractContentFromCompletion extracts the text content from a completion response
func extractContentFromCompletion(completion *agentContext.CompletionResponse) string {
if completion == nil {
return ""
}
// Content can be string or []ContentPart
switch content := completion.Content.(type) {
case string:
return content
case []interface{}:
// Handle array of content parts - extract text parts
var texts []string
for _, part := range content {
if partMap, ok := part.(map[string]interface{}); ok {
if partType, ok := partMap["type"].(string); ok && partType == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
if len(texts) > 0 {
return texts[0] // Return first text content
}
}
return ""
}