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}