diff --git a/pkg/multiagent/handoff.go b/pkg/multiagent/handoff.go index 44ad707da..50d342885 100644 --- a/pkg/multiagent/handoff.go +++ b/pkg/multiagent/handoff.go @@ -41,12 +41,18 @@ func FindAgentsByCapability(resolver AgentResolver, capability string) []AgentIn return matches } +// DefaultMaxHandoffDepth is the maximum handoff chain depth when not configured. +const DefaultMaxHandoffDepth = 3 + // HandoffRequest describes a delegation from one agent to another. type HandoffRequest struct { FromAgentID string ToAgentID string Task string Context map[string]string // k-v to write to blackboard before handoff + Depth int // current depth level (0 = top-level) + Visited []string // agent IDs already in the call chain + MaxDepth int // max allowed depth (0 = use DefaultMaxHandoffDepth) } // HandoffResult contains the outcome of a handoff execution. @@ -59,7 +65,32 @@ type HandoffResult struct { } // ExecuteHandoff delegates a task to a target agent, injecting blackboard context. +// It enforces recursion guards: depth limit and cycle detection. func ExecuteHandoff(ctx context.Context, resolver AgentResolver, board *Blackboard, req HandoffRequest, channel, chatID string) *HandoffResult { + // Recursion guard: depth limit + maxDepth := req.MaxDepth + if maxDepth == 0 { + maxDepth = DefaultMaxHandoffDepth + } + if req.Depth >= maxDepth { + return &HandoffResult{ + AgentID: req.ToAgentID, + Success: false, + Error: fmt.Sprintf("handoff depth limit reached (%d/%d): %v -> %s", req.Depth, maxDepth, req.Visited, req.ToAgentID), + } + } + + // Recursion guard: cycle detection + for _, v := range req.Visited { + if v == req.ToAgentID { + return &HandoffResult{ + AgentID: req.ToAgentID, + Success: false, + Error: fmt.Sprintf("handoff cycle detected: %q already in chain %v", req.ToAgentID, req.Visited), + } + } + } + target := resolver.GetAgentInfo(req.ToAgentID) if target == nil { return &HandoffResult{ @@ -76,6 +107,30 @@ func ExecuteHandoff(ctx context.Context, resolver AgentResolver, board *Blackboa } } + // Propagate depth and visited to target agent's handoff tool + newVisited := make([]string, len(req.Visited)+1) + copy(newVisited, req.Visited) + newVisited[len(req.Visited)] = req.ToAgentID + + if target.Tools != nil { + // Wire session blackboard to target's tools + if tool, ok := target.Tools.Get("blackboard"); ok { + if ba, ok := tool.(BoardAware); ok { + ba.SetBoard(board) + } + } + if tool, ok := target.Tools.Get("handoff"); ok { + if ba, ok := tool.(BoardAware); ok { + ba.SetBoard(board) + } + if ht, ok := tool.(*HandoffTool); ok { + ht.depth = req.Depth + 1 + ht.visited = newVisited + ht.maxDepth = maxDepth + } + } + } + // Build system prompt incorporating agent role, system prompt, and blackboard systemPrompt := buildHandoffSystemPrompt(target, board) diff --git a/pkg/multiagent/handoff_tool.go b/pkg/multiagent/handoff_tool.go index 75276f3d2..e0d344d91 100644 --- a/pkg/multiagent/handoff_tool.go +++ b/pkg/multiagent/handoff_tool.go @@ -15,6 +15,9 @@ type HandoffTool struct { fromAgentID string originChannel string originChatID string + depth int // current handoff depth (0 = top-level) + visited []string // agent IDs already in the call chain + maxDepth int // max allowed depth (0 = use DefaultMaxHandoffDepth) } // NewHandoffTool creates a handoff tool bound to a specific source agent. @@ -133,6 +136,9 @@ func (t *HandoffTool) Execute(ctx context.Context, args map[string]any) *tools.T ToAgentID: agentID, Task: task, Context: contextMap, + Depth: t.depth, + Visited: t.visited, + MaxDepth: t.maxDepth, }, t.originChannel, t.originChatID) if !result.Success {