loop_detection_test.go 6.7 KB

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