feat: add loop detector with hash-based repeat and ping-pong detection
Implements LoopDetector as a ToolHook with four detection engines: - Generic repeat: blocks after N identical tool+args calls (default 20) - Ping-pong: detects A,B,A,B alternation with no-progress evidence - No-progress: tracks result hashes to distinguish stuck from progressing - Circuit breaker: emergency stop for any tool with identical outcomes Per-session isolation via context key, sliding window (default 30), configurable thresholds matching OpenClaw production values.
This commit is contained in:
parent
751c7f4839
commit
239dcea724
1 changed files with 377 additions and 0 deletions
377
pkg/tools/loop_detector.go
Normal file
377
pkg/tools/loop_detector.go
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session context key for per-session loop detection isolation.
|
||||||
|
|
||||||
|
type loopDetectorContextKey struct{}
|
||||||
|
|
||||||
|
// WithSessionKey returns a context carrying the given session key for loop detection.
|
||||||
|
func WithSessionKey(ctx context.Context, key string) context.Context {
|
||||||
|
return context.WithValue(ctx, loopDetectorContextKey{}, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionKeyFromContext(ctx context.Context) string {
|
||||||
|
if key, ok := ctx.Value(loopDetectorContextKey{}).(string); ok {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return "_default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default thresholds matching OpenClaw's production values.
|
||||||
|
const (
|
||||||
|
DefaultHistorySize = 30
|
||||||
|
DefaultWarningThreshold = 10
|
||||||
|
DefaultCriticalThreshold = 20
|
||||||
|
DefaultCircuitBreakerThreshold = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoopDetectorConfig configures loop detection thresholds.
|
||||||
|
type LoopDetectorConfig struct {
|
||||||
|
HistorySize int // sliding window size (default 30)
|
||||||
|
WarningThreshold int // generic repeat warn level (default 10)
|
||||||
|
CriticalThreshold int // block execution level (default 20)
|
||||||
|
CircuitBreakerThreshold int // global emergency stop (default 30)
|
||||||
|
EnableGenericRepeat bool // detect any repeated tool+args
|
||||||
|
EnablePingPong bool // detect alternating A,B,A,B patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLoopDetectorConfig returns production-ready defaults.
|
||||||
|
func DefaultLoopDetectorConfig() LoopDetectorConfig {
|
||||||
|
return LoopDetectorConfig{
|
||||||
|
HistorySize: DefaultHistorySize,
|
||||||
|
WarningThreshold: DefaultWarningThreshold,
|
||||||
|
CriticalThreshold: DefaultCriticalThreshold,
|
||||||
|
CircuitBreakerThreshold: DefaultCircuitBreakerThreshold,
|
||||||
|
EnableGenericRepeat: true,
|
||||||
|
EnablePingPong: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoopVerdict is the result of loop analysis.
|
||||||
|
type LoopVerdict struct {
|
||||||
|
Blocked bool
|
||||||
|
Warning bool
|
||||||
|
Reason string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolCallRecord represents a single tool call in the sliding window.
|
||||||
|
type toolCallRecord struct {
|
||||||
|
ToolName string
|
||||||
|
ArgsHash string
|
||||||
|
ResultHash string // filled after execution via AfterExecute
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionState holds per-session detection state.
|
||||||
|
type sessionState struct {
|
||||||
|
history []toolCallRecord
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoopDetector implements ToolHook for detecting repetitive tool call patterns.
|
||||||
|
// It uses per-session state keyed by the session key in context.Context.
|
||||||
|
type LoopDetector struct {
|
||||||
|
config LoopDetectorConfig
|
||||||
|
sessions sync.Map // sessionKey -> *sessionState
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoopDetector creates a loop detector with the given configuration.
|
||||||
|
// Zero or negative thresholds are replaced with production defaults.
|
||||||
|
func NewLoopDetector(config LoopDetectorConfig) *LoopDetector {
|
||||||
|
if config.HistorySize <= 0 {
|
||||||
|
config.HistorySize = DefaultHistorySize
|
||||||
|
}
|
||||||
|
if config.WarningThreshold <= 0 {
|
||||||
|
config.WarningThreshold = DefaultWarningThreshold
|
||||||
|
}
|
||||||
|
if config.CriticalThreshold <= 0 {
|
||||||
|
config.CriticalThreshold = DefaultCriticalThreshold
|
||||||
|
}
|
||||||
|
if config.CircuitBreakerThreshold <= 0 {
|
||||||
|
config.CircuitBreakerThreshold = DefaultCircuitBreakerThreshold
|
||||||
|
}
|
||||||
|
return &LoopDetector{config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *LoopDetector) getSession(key string) *sessionState {
|
||||||
|
if v, ok := d.sessions.Load(key); ok {
|
||||||
|
return v.(*sessionState)
|
||||||
|
}
|
||||||
|
s := &sessionState{}
|
||||||
|
actual, _ := d.sessions.LoadOrStore(key, s)
|
||||||
|
return actual.(*sessionState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeExecute checks for loops before tool execution.
|
||||||
|
// Returns an error to block execution if a critical loop is detected.
|
||||||
|
func (d *LoopDetector) BeforeExecute(ctx context.Context, toolName string, args map[string]interface{}) error {
|
||||||
|
sessionKey := sessionKeyFromContext(ctx)
|
||||||
|
state := d.getSession(sessionKey)
|
||||||
|
argsHash := hashArgs(args)
|
||||||
|
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
|
||||||
|
// Check for loops before recording this call
|
||||||
|
verdict := d.detect(state, toolName, argsHash)
|
||||||
|
|
||||||
|
// Record this call
|
||||||
|
state.history = append(state.history, toolCallRecord{
|
||||||
|
ToolName: toolName,
|
||||||
|
ArgsHash: argsHash,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
// Trim sliding window
|
||||||
|
if len(state.history) > d.config.HistorySize {
|
||||||
|
state.history = state.history[len(state.history)-d.config.HistorySize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if verdict.Blocked {
|
||||||
|
logger.WarnCF("loop_detector", "Loop blocked",
|
||||||
|
map[string]interface{}{
|
||||||
|
"tool": toolName,
|
||||||
|
"reason": verdict.Reason,
|
||||||
|
"count": verdict.Count,
|
||||||
|
"session": sessionKey,
|
||||||
|
})
|
||||||
|
return fmt.Errorf("loop detected: %s (count: %d)", verdict.Reason, verdict.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verdict.Warning {
|
||||||
|
logger.WarnCF("loop_detector", "Loop warning",
|
||||||
|
map[string]interface{}{
|
||||||
|
"tool": toolName,
|
||||||
|
"reason": verdict.Reason,
|
||||||
|
"count": verdict.Count,
|
||||||
|
"session": sessionKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AfterExecute records the tool result hash for no-progress detection.
|
||||||
|
func (d *LoopDetector) AfterExecute(ctx context.Context, toolName string, args map[string]interface{}, result *ToolResult) {
|
||||||
|
sessionKey := sessionKeyFromContext(ctx)
|
||||||
|
state := d.getSession(sessionKey)
|
||||||
|
argsHash := hashArgs(args)
|
||||||
|
resultHash := hashResult(result)
|
||||||
|
|
||||||
|
state.mu.Lock()
|
||||||
|
defer state.mu.Unlock()
|
||||||
|
|
||||||
|
// Find the most recent matching record without a result hash
|
||||||
|
for i := len(state.history) - 1; i >= 0; i-- {
|
||||||
|
rec := &state.history[i]
|
||||||
|
if rec.ToolName == toolName && rec.ArgsHash == argsHash && rec.ResultHash == "" {
|
||||||
|
rec.ResultHash = resultHash
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetSession clears the detection state for a session.
|
||||||
|
func (d *LoopDetector) ResetSession(sessionKey string) {
|
||||||
|
d.sessions.Delete(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detect runs all detection engines and returns the highest-severity verdict.
|
||||||
|
func (d *LoopDetector) detect(state *sessionState, toolName, argsHash string) LoopVerdict {
|
||||||
|
// 1. Global circuit breaker: no-progress on same tool+args
|
||||||
|
noProgressStreak := d.getNoProgressStreak(state, toolName, argsHash)
|
||||||
|
if noProgressStreak >= d.config.CircuitBreakerThreshold {
|
||||||
|
return LoopVerdict{
|
||||||
|
Blocked: true,
|
||||||
|
Reason: "circuit breaker: repeated calls with identical results",
|
||||||
|
Count: noProgressStreak,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generic repeat detection (any tool+args combination)
|
||||||
|
if d.config.EnableGenericRepeat {
|
||||||
|
count := d.countRepeats(state, toolName, argsHash)
|
||||||
|
if count >= d.config.CriticalThreshold {
|
||||||
|
return LoopVerdict{
|
||||||
|
Blocked: true,
|
||||||
|
Reason: "tool call repeated too many times",
|
||||||
|
Count: count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count >= d.config.WarningThreshold {
|
||||||
|
return LoopVerdict{
|
||||||
|
Warning: true,
|
||||||
|
Reason: "possible loop: tool call repeated",
|
||||||
|
Count: count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ping-pong detection (alternating A,B,A,B pattern)
|
||||||
|
if d.config.EnablePingPong {
|
||||||
|
streak := d.getPingPongStreak(state, toolName, argsHash)
|
||||||
|
if streak >= d.config.CriticalThreshold && d.hasPingPongNoProgress(state, streak) {
|
||||||
|
return LoopVerdict{
|
||||||
|
Blocked: true,
|
||||||
|
Reason: "ping-pong loop with no progress",
|
||||||
|
Count: streak,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if streak >= d.config.WarningThreshold {
|
||||||
|
return LoopVerdict{
|
||||||
|
Warning: true,
|
||||||
|
Reason: "possible ping-pong pattern",
|
||||||
|
Count: streak,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoopVerdict{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRepeats counts how many times this tool+args combination appears in history.
|
||||||
|
func (d *LoopDetector) countRepeats(state *sessionState, toolName, argsHash string) int {
|
||||||
|
count := 0
|
||||||
|
for _, rec := range state.history {
|
||||||
|
if rec.ToolName == toolName && rec.ArgsHash == argsHash {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPingPongStreak detects alternating A,B,A,B patterns.
|
||||||
|
// Returns the alternation streak length (number of entries in the alternating tail).
|
||||||
|
func (d *LoopDetector) getPingPongStreak(state *sessionState, toolName, argsHash string) int {
|
||||||
|
h := state.history
|
||||||
|
if len(h) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSig := toolName + ":" + argsHash
|
||||||
|
lastSig := h[len(h)-1].ToolName + ":" + h[len(h)-1].ArgsHash
|
||||||
|
|
||||||
|
// Same signature as last call — not alternation
|
||||||
|
if currentSig == lastSig {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count alternating tail backwards from the end.
|
||||||
|
// History ends with ...lastSig. Current call would be currentSig.
|
||||||
|
// Pattern: ...currentSig, lastSig, currentSig, lastSig
|
||||||
|
streak := 1 // count the last entry
|
||||||
|
for i := len(h) - 2; i >= 0; i-- {
|
||||||
|
sig := h[i].ToolName + ":" + h[i].ArgsHash
|
||||||
|
// Positions from end: even indices should match lastSig, odd should match currentSig
|
||||||
|
distFromEnd := len(h) - 1 - i
|
||||||
|
var expected string
|
||||||
|
if distFromEnd%2 == 0 {
|
||||||
|
expected = lastSig
|
||||||
|
} else {
|
||||||
|
expected = currentSig
|
||||||
|
}
|
||||||
|
if sig != expected {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
streak++
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNoProgressStreak counts consecutive tail calls with same tool+args AND same result.
|
||||||
|
func (d *LoopDetector) getNoProgressStreak(state *sessionState, toolName, argsHash string) int {
|
||||||
|
h := state.history
|
||||||
|
if len(h) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recent matching entry with a result hash
|
||||||
|
var referenceHash string
|
||||||
|
for i := len(h) - 1; i >= 0; i-- {
|
||||||
|
if h[i].ToolName == toolName && h[i].ArgsHash == argsHash && h[i].ResultHash != "" {
|
||||||
|
referenceHash = h[i].ResultHash
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if referenceHash == "" {
|
||||||
|
return 0 // no completed calls to compare
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count consecutive matching entries from the tail
|
||||||
|
streak := 0
|
||||||
|
for i := len(h) - 1; i >= 0; i-- {
|
||||||
|
rec := h[i]
|
||||||
|
if rec.ToolName != toolName || rec.ArgsHash != argsHash {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rec.ResultHash == "" {
|
||||||
|
// Call recorded but not yet completed — include in streak (conservative)
|
||||||
|
streak++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec.ResultHash != referenceHash {
|
||||||
|
break // result changed = progress
|
||||||
|
}
|
||||||
|
streak++
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPingPongNoProgress checks that both sides of a ping-pong have stable (unchanged) results.
|
||||||
|
func (d *LoopDetector) hasPingPongNoProgress(state *sessionState, streak int) bool {
|
||||||
|
h := state.history
|
||||||
|
if len(h) < 4 || streak < 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the last 4 entries: [A, B, A, B]
|
||||||
|
// Side A = indices -4, -2; Side B = indices -3, -1
|
||||||
|
tail := h[len(h)-4:]
|
||||||
|
if tail[0].ResultHash == "" || tail[1].ResultHash == "" ||
|
||||||
|
tail[2].ResultHash == "" || tail[3].ResultHash == "" {
|
||||||
|
return false // need result hashes on all 4
|
||||||
|
}
|
||||||
|
|
||||||
|
sideAStable := tail[0].ResultHash == tail[2].ResultHash
|
||||||
|
sideBStable := tail[1].ResultHash == tail[3].ResultHash
|
||||||
|
return sideAStable && sideBStable
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashArgs produces a deterministic hash of tool arguments.
|
||||||
|
// Go's json.Marshal sorts map keys alphabetically, ensuring stability.
|
||||||
|
func hashArgs(args map[string]interface{}) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "empty"
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return fmt.Sprintf("%x", h[:8]) // 16 hex chars — sufficient for dedup
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashResult produces a hash of the tool result for no-progress detection.
|
||||||
|
func hashResult(result *ToolResult) string {
|
||||||
|
if result == nil {
|
||||||
|
return "nil"
|
||||||
|
}
|
||||||
|
content := result.ForLLM
|
||||||
|
if len(content) > 1024 {
|
||||||
|
content = content[:1024] // cap for hashing performance
|
||||||
|
}
|
||||||
|
h := sha256.Sum256([]byte(content))
|
||||||
|
return fmt.Sprintf("%x", h[:8])
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue