feat: add recursion guard with depth limit and cycle detection

Prevent infinite handoff loops (A->B->A) and unbounded depth chains.
HandoffRequest now carries Depth/Visited/MaxDepth fields that are
propagated to target agents. Default max depth is 3.
This commit is contained in:
Leandro Barbosa 2026-02-18 15:00:36 -03:00
parent 4def7b497e
commit eb707ff724
2 changed files with 61 additions and 0 deletions

View file

@ -41,12 +41,18 @@ func FindAgentsByCapability(resolver AgentResolver, capability string) []AgentIn
return matches return matches
} }
// DefaultMaxHandoffDepth is the maximum handoff chain depth when not configured.
const DefaultMaxHandoffDepth = 3
// HandoffRequest describes a delegation from one agent to another. // HandoffRequest describes a delegation from one agent to another.
type HandoffRequest struct { type HandoffRequest struct {
FromAgentID string FromAgentID string
ToAgentID string ToAgentID string
Task string Task string
Context map[string]string // k-v to write to blackboard before handoff 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. // 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. // 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 { 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) target := resolver.GetAgentInfo(req.ToAgentID)
if target == nil { if target == nil {
return &HandoffResult{ 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 // Build system prompt incorporating agent role, system prompt, and blackboard
systemPrompt := buildHandoffSystemPrompt(target, board) systemPrompt := buildHandoffSystemPrompt(target, board)

View file

@ -15,6 +15,9 @@ type HandoffTool struct {
fromAgentID string fromAgentID string
originChannel string originChannel string
originChatID 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. // 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, ToAgentID: agentID,
Task: task, Task: task,
Context: contextMap, Context: contextMap,
Depth: t.depth,
Visited: t.visited,
MaxDepth: t.maxDepth,
}, t.originChannel, t.originChatID) }, t.originChannel, t.originChatID)
if !result.Success { if !result.Success {