on main help sign in
fix: detect and stop tool call infinite loops (#2130) (#2214)

Add smart loop detection as a stop condition for agent execution. When the same tool call signature (name + input + output) appears more than 5 times within a 10-step window, the agent stops instead of running until context window exhaustion.

commitaf86738e0d6edfeb0ae7fb239b8bdabd4c162fca authorhuaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> dateSat 14 Feb 2026, 05:20 (8 months ago) parentddf2fad
3 files changed+300
internal/agent/agent.go+3 internal/agent/loop_detection.go+92 internal/agent/loop_detection_test.go+205
internal/agent/agent.go+3
@@ -420,6 +420,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
420420 }
421421 return false
422422 },
423+ func(steps []fantasy.StepResult) bool {
424+ return hasRepeatedToolCalls(steps, loopDetectionWindowSize, loopDetectionMaxRepeats)
425+ },
423426 },
424427 })
425428
internal/agent/loop_detection.gonew file+92
@@ -0,0 +1,92 @@
1+package agent
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "io"
7+
8+ "charm.land/fantasy"
9+)
10+
11+const (
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.
19+func 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 "".
45+func 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.
75+func 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+}
internal/agent/loop_detection_test.gonew file+205
@@ -0,0 +1,205 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "charm.land/fantasy"
8+)
9+
10+// makeStep creates a StepResult with the given tool calls and results in its Content.
11+func makeStep(calls []fantasy.ToolCallContent, results []fantasy.ToolResultContent) fantasy.StepResult {
12+ var content fantasy.ResponseContent
13+ for _, c := range calls {
14+ content = append(content, c)
15+ }
16+ for _, r := range results {
17+ content = append(content, r)
18+ }
19+ return fantasy.StepResult{
20+ Response: fantasy.Response{
21+ Content: content,
22+ },
23+ }
24+}
25+
26+// makeToolStep creates a step with a single tool call and matching text result.
27+func makeToolStep(name, input, output string) fantasy.StepResult {
28+ callID := fmt.Sprintf("call_%s_%s", name, input)
29+ return makeStep(
30+ []fantasy.ToolCallContent{
31+ {ToolCallID: callID, ToolName: name, Input: input},
32+ },
33+ []fantasy.ToolResultContent{
34+ {ToolCallID: callID, ToolName: name, Result: fantasy.ToolResultOutputContentText{Text: output}},
35+ },
36+ )
37+}
38+
39+// makeEmptyStep creates a step with no tool calls (e.g. a text-only response).
40+func makeEmptyStep() fantasy.StepResult {
41+ return fantasy.StepResult{
42+ Response: fantasy.Response{
43+ Content: fantasy.ResponseContent{
44+ fantasy.TextContent{Text: "thinking..."},
45+ },
46+ },
47+ }
48+}
49+
50+func TestHasRepeatedToolCalls(t *testing.T) {
51+ t.Run("no steps", func(t *testing.T) {
52+ result := hasRepeatedToolCalls(nil, 10, 5)
53+ if result {
54+ t.Error("expected false for empty steps")
55+ }
56+ })
57+
58+ t.Run("fewer steps than window", func(t *testing.T) {
59+ steps := make([]fantasy.StepResult, 5)
60+ for i := range steps {
61+ steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
62+ }
63+ result := hasRepeatedToolCalls(steps, 10, 5)
64+ if result {
65+ t.Error("expected false when fewer steps than window size")
66+ }
67+ })
68+
69+ t.Run("all different signatures", func(t *testing.T) {
70+ steps := make([]fantasy.StepResult, 10)
71+ for i := range steps {
72+ steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
73+ }
74+ result := hasRepeatedToolCalls(steps, 10, 5)
75+ if result {
76+ t.Error("expected false when all signatures are different")
77+ }
78+ })
79+
80+ t.Run("exact repeat at threshold not detected", func(t *testing.T) {
81+ // maxRepeats=5 means > 5 is needed, so exactly 5 should return false
82+ steps := make([]fantasy.StepResult, 10)
83+ for i := 0; i < 5; i++ {
84+ steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
85+ }
86+ for i := 5; i < 10; i++ {
87+ steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
88+ }
89+ result := hasRepeatedToolCalls(steps, 10, 5)
90+ if result {
91+ t.Error("expected false when count equals maxRepeats (threshold is >)")
92+ }
93+ })
94+
95+ t.Run("loop detected", func(t *testing.T) {
96+ // 6 identical steps in a window of 10 with maxRepeats=5 → detected
97+ steps := make([]fantasy.StepResult, 10)
98+ for i := 0; i < 6; i++ {
99+ steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
100+ }
101+ for i := 6; i < 10; i++ {
102+ steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
103+ }
104+ result := hasRepeatedToolCalls(steps, 10, 5)
105+ if !result {
106+ t.Error("expected true when same signature appears more than maxRepeats times")
107+ }
108+ })
109+
110+ t.Run("steps without tool calls are skipped", func(t *testing.T) {
111+ // Mix of tool steps and empty steps — empty ones should not affect counts
112+ steps := make([]fantasy.StepResult, 10)
113+ for i := 0; i < 4; i++ {
114+ steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
115+ }
116+ for i := 4; i < 8; i++ {
117+ steps[i] = makeEmptyStep()
118+ }
119+ for i := 8; i < 10; i++ {
120+ steps[i] = makeToolStep("write", `{"file":"b.go"}`, "ok")
121+ }
122+ result := hasRepeatedToolCalls(steps, 10, 5)
123+ if result {
124+ t.Error("expected false: only 4 repeated tool calls, empty steps should be skipped")
125+ }
126+ })
127+
128+ t.Run("multiple different patterns alternating", func(t *testing.T) {
129+ // Two patterns alternating: each appears 5 times — not above threshold
130+ steps := make([]fantasy.StepResult, 10)
131+ for i := range steps {
132+ if i%2 == 0 {
133+ steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content-a")
134+ } else {
135+ steps[i] = makeToolStep("write", `{"file":"b.go"}`, "content-b")
136+ }
137+ }
138+ result := hasRepeatedToolCalls(steps, 10, 5)
139+ if result {
140+ t.Error("expected false: two patterns each appearing 5 times (not > 5)")
141+ }
142+ })
143+}
144+
145+func TestGetToolInteractionSignature(t *testing.T) {
146+ t.Run("empty content returns empty string", func(t *testing.T) {
147+ sig := getToolInteractionSignature(fantasy.ResponseContent{})
148+ if sig != "" {
149+ t.Errorf("expected empty string, got %q", sig)
150+ }
151+ })
152+
153+ t.Run("text only content returns empty string", func(t *testing.T) {
154+ content := fantasy.ResponseContent{
155+ fantasy.TextContent{Text: "hello"},
156+ }
157+ sig := getToolInteractionSignature(content)
158+ if sig != "" {
159+ t.Errorf("expected empty string, got %q", sig)
160+ }
161+ })
162+
163+ t.Run("tool call with result produces signature", func(t *testing.T) {
164+ content := fantasy.ResponseContent{
165+ fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
166+ fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
167+ }
168+ sig := getToolInteractionSignature(content)
169+ if sig == "" {
170+ t.Error("expected non-empty signature")
171+ }
172+ })
173+
174+ t.Run("same interactions produce same signature", func(t *testing.T) {
175+ content1 := fantasy.ResponseContent{
176+ fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
177+ fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
178+ }
179+ content2 := fantasy.ResponseContent{
180+ fantasy.ToolCallContent{ToolCallID: "2", ToolName: "read", Input: `{"file":"a.go"}`},
181+ fantasy.ToolResultContent{ToolCallID: "2", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
182+ }
183+ sig1 := getToolInteractionSignature(content1)
184+ sig2 := getToolInteractionSignature(content2)
185+ if sig1 != sig2 {
186+ t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
187+ }
188+ })
189+
190+ t.Run("different inputs produce different signatures", func(t *testing.T) {
191+ content1 := fantasy.ResponseContent{
192+ fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
193+ fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
194+ }
195+ content2 := fantasy.ResponseContent{
196+ fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"b.go"}`},
197+ fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
198+ }
199+ sig1 := getToolInteractionSignature(content1)
200+ sig2 := getToolInteractionSignature(content2)
201+ if sig1 == sig2 {
202+ t.Error("expected different signatures for different inputs")
203+ }
204+ })
205+}