Răsfoiți Sursa

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.
huaiyuWangh 1 zi în urmă
părinte
comite
af86738e0d

+ 3 - 0
internal/agent/agent.go

@@ -420,6 +420,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				}
 				return false
 			},
+			func(steps []fantasy.StepResult) bool {
+				return hasRepeatedToolCalls(steps, loopDetectionWindowSize, loopDetectionMaxRepeats)
+			},
 		},
 	})
 

+ 92 - 0
internal/agent/loop_detection.go

@@ -0,0 +1,92 @@
+package agent
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+
+	"charm.land/fantasy"
+)
+
+const (
+	loopDetectionWindowSize = 10
+	loopDetectionMaxRepeats = 5
+)
+
+// hasRepeatedToolCalls checks whether the agent is stuck in a loop by looking
+// at recent steps. It examines the last windowSize steps and returns true if
+// any tool-call signature appears more than maxRepeats times.
+func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int) bool {
+	if len(steps) < windowSize {
+		return false
+	}
+
+	window := steps[len(steps)-windowSize:]
+	counts := make(map[string]int)
+
+	for _, step := range window {
+		sig := getToolInteractionSignature(step.Content)
+		if sig == "" {
+			continue
+		}
+		counts[sig]++
+		if counts[sig] > maxRepeats {
+			return true
+		}
+	}
+
+	return false
+}
+
+// getToolInteractionSignature computes a hash signature for the tool
+// interactions in a single step's content. It pairs tool calls with their
+// results (matched by ToolCallID) and returns a hex-encoded SHA-256 hash.
+// If the step contains no tool calls, it returns "".
+func getToolInteractionSignature(content fantasy.ResponseContent) string {
+	toolCalls := content.ToolCalls()
+	if len(toolCalls) == 0 {
+		return ""
+	}
+
+	// Index tool results by their ToolCallID for fast lookup.
+	resultsByID := make(map[string]fantasy.ToolResultContent)
+	for _, tr := range content.ToolResults() {
+		resultsByID[tr.ToolCallID] = tr
+	}
+
+	h := sha256.New()
+	for _, tc := range toolCalls {
+		output := ""
+		if tr, ok := resultsByID[tc.ToolCallID]; ok {
+			output = toolResultOutputString(tr.Result)
+		}
+		io.WriteString(h, tc.ToolName)
+		io.WriteString(h, "\x00")
+		io.WriteString(h, tc.Input)
+		io.WriteString(h, "\x00")
+		io.WriteString(h, output)
+		io.WriteString(h, "\x00")
+	}
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// toolResultOutputString converts a ToolResultOutputContent to a stable string
+// representation for signature comparison.
+func toolResultOutputString(result fantasy.ToolResultOutputContent) string {
+	if result == nil {
+		return ""
+	}
+	if text, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result); ok {
+		return text.Text
+	}
+	if errResult, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result); ok {
+		if errResult.Error != nil {
+			return errResult.Error.Error()
+		}
+		return ""
+	}
+	if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result); ok {
+		return media.Data
+	}
+	return ""
+}

+ 205 - 0
internal/agent/loop_detection_test.go

@@ -0,0 +1,205 @@
+package agent
+
+import (
+	"fmt"
+	"testing"
+
+	"charm.land/fantasy"
+)
+
+// makeStep creates a StepResult with the given tool calls and results in its Content.
+func makeStep(calls []fantasy.ToolCallContent, results []fantasy.ToolResultContent) fantasy.StepResult {
+	var content fantasy.ResponseContent
+	for _, c := range calls {
+		content = append(content, c)
+	}
+	for _, r := range results {
+		content = append(content, r)
+	}
+	return fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: content,
+		},
+	}
+}
+
+// makeToolStep creates a step with a single tool call and matching text result.
+func makeToolStep(name, input, output string) fantasy.StepResult {
+	callID := fmt.Sprintf("call_%s_%s", name, input)
+	return makeStep(
+		[]fantasy.ToolCallContent{
+			{ToolCallID: callID, ToolName: name, Input: input},
+		},
+		[]fantasy.ToolResultContent{
+			{ToolCallID: callID, ToolName: name, Result: fantasy.ToolResultOutputContentText{Text: output}},
+		},
+	)
+}
+
+// makeEmptyStep creates a step with no tool calls (e.g. a text-only response).
+func makeEmptyStep() fantasy.StepResult {
+	return fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.TextContent{Text: "thinking..."},
+			},
+		},
+	}
+}
+
+func TestHasRepeatedToolCalls(t *testing.T) {
+	t.Run("no steps", func(t *testing.T) {
+		result := hasRepeatedToolCalls(nil, 10, 5)
+		if result {
+			t.Error("expected false for empty steps")
+		}
+	})
+
+	t.Run("fewer steps than window", func(t *testing.T) {
+		steps := make([]fantasy.StepResult, 5)
+		for i := range steps {
+			steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
+		}
+		result := hasRepeatedToolCalls(steps, 10, 5)
+		if result {
+			t.Error("expected false when fewer steps than window size")
+		}
+	})
+
+	t.Run("all different signatures", func(t *testing.T) {
+		steps := make([]fantasy.StepResult, 10)
+		for i := range steps {
+			steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
+		}
+		result := hasRepeatedToolCalls(steps, 10, 5)
+		if result {
+			t.Error("expected false when all signatures are different")
+		}
+	})
+
+	t.Run("exact repeat at threshold not detected", func(t *testing.T) {
+		// maxRepeats=5 means > 5 is needed, so exactly 5 should return false
+		steps := make([]fantasy.StepResult, 10)
+		for i := 0; i < 5; i++ {
+			steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
+		}
+		for i := 5; i < 10; i++ {
+			steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
+		}
+		result := hasRepeatedToolCalls(steps, 10, 5)
+		if result {
+			t.Error("expected false when count equals maxRepeats (threshold is >)")
+		}
+	})
+
+	t.Run("loop detected", func(t *testing.T) {
+		// 6 identical steps in a window of 10 with maxRepeats=5 → detected
+		steps := make([]fantasy.StepResult, 10)
+		for i := 0; i < 6; i++ {
+			steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
+		}
+		for i := 6; i < 10; i++ {
+			steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
+		}
+		result := hasRepeatedToolCalls(steps, 10, 5)
+		if !result {
+			t.Error("expected true when same signature appears more than maxRepeats times")
+		}
+	})
+
+	t.Run("steps without tool calls are skipped", func(t *testing.T) {
+		// Mix of tool steps and empty steps — empty ones should not affect counts
+		steps := make([]fantasy.StepResult, 10)
+		for i := 0; i < 4; i++ {
+			steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
+		}
+		for i := 4; i < 8; i++ {
+			steps[i] = makeEmptyStep()
+		}
+		for i := 8; i < 10; i++ {
+			steps[i] = makeToolStep("write", `{"file":"b.go"}`, "ok")
+		}
+		result := hasRepeatedToolCalls(steps, 10, 5)
+		if result {
+			t.Error("expected false: only 4 repeated tool calls, empty steps should be skipped")
+		}
+	})
+
+	t.Run("multiple different patterns alternating", func(t *testing.T) {
+		// Two patterns alternating: each appears 5 times — not above threshold
+		steps := make([]fantasy.StepResult, 10)
+		for i := range steps {
+			if i%2 == 0 {
+				steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content-a")
+			} else {
+				steps[i] = makeToolStep("write", `{"file":"b.go"}`, "content-b")
+			}
+		}
+		result := hasRepeatedToolCalls(steps, 10, 5)
+		if result {
+			t.Error("expected false: two patterns each appearing 5 times (not > 5)")
+		}
+	})
+}
+
+func TestGetToolInteractionSignature(t *testing.T) {
+	t.Run("empty content returns empty string", func(t *testing.T) {
+		sig := getToolInteractionSignature(fantasy.ResponseContent{})
+		if sig != "" {
+			t.Errorf("expected empty string, got %q", sig)
+		}
+	})
+
+	t.Run("text only content returns empty string", func(t *testing.T) {
+		content := fantasy.ResponseContent{
+			fantasy.TextContent{Text: "hello"},
+		}
+		sig := getToolInteractionSignature(content)
+		if sig != "" {
+			t.Errorf("expected empty string, got %q", sig)
+		}
+	})
+
+	t.Run("tool call with result produces signature", func(t *testing.T) {
+		content := fantasy.ResponseContent{
+			fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
+			fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
+		}
+		sig := getToolInteractionSignature(content)
+		if sig == "" {
+			t.Error("expected non-empty signature")
+		}
+	})
+
+	t.Run("same interactions produce same signature", func(t *testing.T) {
+		content1 := fantasy.ResponseContent{
+			fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
+			fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
+		}
+		content2 := fantasy.ResponseContent{
+			fantasy.ToolCallContent{ToolCallID: "2", ToolName: "read", Input: `{"file":"a.go"}`},
+			fantasy.ToolResultContent{ToolCallID: "2", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
+		}
+		sig1 := getToolInteractionSignature(content1)
+		sig2 := getToolInteractionSignature(content2)
+		if sig1 != sig2 {
+			t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
+		}
+	})
+
+	t.Run("different inputs produce different signatures", func(t *testing.T) {
+		content1 := fantasy.ResponseContent{
+			fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
+			fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
+		}
+		content2 := fantasy.ResponseContent{
+			fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"b.go"}`},
+			fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
+		}
+		sig1 := getToolInteractionSignature(content1)
+		sig2 := getToolInteractionSignature(content2)
+		if sig1 == sig2 {
+			t.Error("expected different signatures for different inputs")
+		}
+	})
+}