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:
parent
4def7b497e
commit
eb707ff724
2 changed files with 61 additions and 0 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue