From af86738e0d6edfeb0ae7fb239b8bdabd4c162fca Mon Sep 17 00:00:00 2001 From: huaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:20:41 +0800 Subject: [PATCH 1/4] 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. --- internal/agent/agent.go | 3 + internal/agent/loop_detection.go | 92 ++++++++++++ internal/agent/loop_detection_test.go | 205 ++++++++++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 internal/agent/loop_detection.go create mode 100644 internal/agent/loop_detection_test.go 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") + } + }) +} From abf15f5ffbc08e269ce778afea50f0b2235f9c43 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:14:34 -0300 Subject: [PATCH 2/4] chore(legal): @maxbrunet has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 2fcd5045c25b332a7c728de6830a787db131094f..0ff3d4ade95c616b9eda7a51a511f76ae590c88a 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1247,6 +1247,14 @@ "created_at": "2026-02-12T21:34:20Z", "repoId": 987670088, "pullRequestNo": 2212 + }, + { + "name": "maxbrunet", + "id": 32458727, + "comment_id": 3905030983, + "created_at": "2026-02-15T19:14:26Z", + "repoId": 987670088, + "pullRequestNo": 2229 } ] } \ No newline at end of file From 7aa191d43006ac825ab4019d64e8a31a79f1f39a Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:17:07 -0300 Subject: [PATCH 3/4] chore(legal): @0xarcher has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 0ff3d4ade95c616b9eda7a51a511f76ae590c88a..cffebabffd242a4897a07d79e1e0d051c7662cc2 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1255,6 +1255,14 @@ "created_at": "2026-02-15T19:14:26Z", "repoId": 987670088, "pullRequestNo": 2229 + }, + { + "name": "0xarcher", + "id": 18182408, + "comment_id": 3907297580, + "created_at": "2026-02-16T09:16:57Z", + "repoId": 987670088, + "pullRequestNo": 2236 } ] } \ No newline at end of file From e471e75e7d732e2d88088f60d1abbe81aa0c897c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 16 Feb 2026 14:08:01 +0300 Subject: [PATCH 4/4] fix(ui): early exit AtBottom() when totalHeight exceeds viewport height Related: https://github.com/charmbracelet/crush/issues/2226 --- internal/ui/list/list.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 32a8e661a5caaba2c8f36235eb554a2044ee14e0..f6c3fdc44501923f37a1943fa69d4432a6d4720c 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -84,6 +84,10 @@ func (l *List) AtBottom() bool { // Calculate the height from offsetIdx to the end. var totalHeight int for idx := l.offsetIdx; idx < len(l.items); idx++ { + if totalHeight > l.height { + // No need to calculate further, we're already past the viewport height + return false + } item := l.getItem(idx) itemHeight := item.height if l.gap > 0 && idx > l.offsetIdx {