loop_detection.go

 1package agent
 2
 3import (
 4	"crypto/sha256"
 5	"encoding/hex"
 6	"io"
 7
 8	"charm.land/fantasy"
 9)
10
11const (
12	loopDetectionWindowSize = 10
13	loopDetectionMaxRepeats = 5
14)
15
16// hasRepeatedToolCalls checks whether the agent is stuck in a loop by looking
17// at recent steps. It examines the last windowSize steps and returns true if
18// any tool-call signature appears more than maxRepeats times.
19func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int) bool {
20	if len(steps) < windowSize {
21		return false
22	}
23
24	window := steps[len(steps)-windowSize:]
25	counts := make(map[string]int)
26
27	for _, step := range window {
28		sig := getToolInteractionSignature(step.Content)
29		if sig == "" {
30			continue
31		}
32		counts[sig]++
33		if counts[sig] > maxRepeats {
34			return true
35		}
36	}
37
38	return false
39}
40
41// getToolInteractionSignature computes a hash signature for the tool
42// interactions in a single step's content. It pairs tool calls with their
43// results (matched by ToolCallID) and returns a hex-encoded SHA-256 hash.
44// If the step contains no tool calls, it returns "".
45func getToolInteractionSignature(content fantasy.ResponseContent) string {
46	toolCalls := content.ToolCalls()
47	if len(toolCalls) == 0 {
48		return ""
49	}
50
51	// Index tool results by their ToolCallID for fast lookup.
52	resultsByID := make(map[string]fantasy.ToolResultContent)
53	for _, tr := range content.ToolResults() {
54		resultsByID[tr.ToolCallID] = tr
55	}
56
57	h := sha256.New()
58	for _, tc := range toolCalls {
59		output := ""
60		if tr, ok := resultsByID[tc.ToolCallID]; ok {
61			output = toolResultOutputString(tr.Result)
62		}
63		io.WriteString(h, tc.ToolName)
64		io.WriteString(h, "\x00")
65		io.WriteString(h, tc.Input)
66		io.WriteString(h, "\x00")
67		io.WriteString(h, output)
68		io.WriteString(h, "\x00")
69	}
70	return hex.EncodeToString(h.Sum(nil))
71}
72
73// toolResultOutputString converts a ToolResultOutputContent to a stable string
74// representation for signature comparison.
75func toolResultOutputString(result fantasy.ToolResultOutputContent) string {
76	if result == nil {
77		return ""
78	}
79	if text, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result); ok {
80		return text.Text
81	}
82	if errResult, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result); ok {
83		if errResult.Error != nil {
84			return errResult.Error.Error()
85		}
86		return ""
87	}
88	if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result); ok {
89		return media.Data
90	}
91	return ""
92}