loop_detection.go 2.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. package agent
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "io"
  6. "charm.land/fantasy"
  7. )
  8. const (
  9. loopDetectionWindowSize = 10
  10. loopDetectionMaxRepeats = 5
  11. )
  12. // hasRepeatedToolCalls checks whether the agent is stuck in a loop by looking
  13. // at recent steps. It examines the last windowSize steps and returns true if
  14. // any tool-call signature appears more than maxRepeats times.
  15. func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int) bool {
  16. if len(steps) < windowSize {
  17. return false
  18. }
  19. window := steps[len(steps)-windowSize:]
  20. counts := make(map[string]int)
  21. for _, step := range window {
  22. sig := getToolInteractionSignature(step.Content)
  23. if sig == "" {
  24. continue
  25. }
  26. counts[sig]++
  27. if counts[sig] > maxRepeats {
  28. return true
  29. }
  30. }
  31. return false
  32. }
  33. // getToolInteractionSignature computes a hash signature for the tool
  34. // interactions in a single step's content. It pairs tool calls with their
  35. // results (matched by ToolCallID) and returns a hex-encoded SHA-256 hash.
  36. // If the step contains no tool calls, it returns "".
  37. func getToolInteractionSignature(content fantasy.ResponseContent) string {
  38. toolCalls := content.ToolCalls()
  39. if len(toolCalls) == 0 {
  40. return ""
  41. }
  42. // Index tool results by their ToolCallID for fast lookup.
  43. resultsByID := make(map[string]fantasy.ToolResultContent)
  44. for _, tr := range content.ToolResults() {
  45. resultsByID[tr.ToolCallID] = tr
  46. }
  47. h := sha256.New()
  48. for _, tc := range toolCalls {
  49. output := ""
  50. if tr, ok := resultsByID[tc.ToolCallID]; ok {
  51. output = toolResultOutputString(tr.Result)
  52. }
  53. io.WriteString(h, tc.ToolName)
  54. io.WriteString(h, "\x00")
  55. io.WriteString(h, tc.Input)
  56. io.WriteString(h, "\x00")
  57. io.WriteString(h, output)
  58. io.WriteString(h, "\x00")
  59. }
  60. return hex.EncodeToString(h.Sum(nil))
  61. }
  62. // toolResultOutputString converts a ToolResultOutputContent to a stable string
  63. // representation for signature comparison.
  64. func toolResultOutputString(result fantasy.ToolResultOutputContent) string {
  65. if result == nil {
  66. return ""
  67. }
  68. if text, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result); ok {
  69. return text.Text
  70. }
  71. if errResult, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result); ok {
  72. if errResult.Error != nil {
  73. return errResult.Error.Error()
  74. }
  75. return ""
  76. }
  77. if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result); ok {
  78. return media.Data
  79. }
  80. return ""
  81. }