diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 7ccd503ad1f0dce0d922c35df4f91873523ecd9c..2671577b85efef5ebf8b75fa6167bc0ec46eff3b 100644 --- a/internal/agent/agent.go +++ b/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) + }, }, }) diff --git a/internal/agent/loop_detection.go b/internal/agent/loop_detection.go new file mode 100644 index 0000000000000000000000000000000000000000..673a0c196c551f8cbc5355f2d89e08dfc73b986e --- /dev/null +++ b/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 "" +} diff --git a/internal/agent/loop_detection_test.go b/internal/agent/loop_detection_test.go new file mode 100644 index 0000000000000000000000000000000000000000..deb1a639ff19f7246c844a312e96466cd078b9bc --- /dev/null +++ b/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") + } + }) +}