test: add comprehensive loop detector tests
Coverage for all four detection engines: - Generic repeat: below/at/above warning and critical thresholds - Ping-pong: alternation with and without progress evidence - Circuit breaker: no-progress streak and progress-resets-streak - Session isolation, reset, sliding window eviction - Registry integration, context key, hash determinism
This commit is contained in:
parent
076f526ac6
commit
276523b10a
1 changed files with 526 additions and 0 deletions
526
pkg/tools/loop_detector_test.go
Normal file
526
pkg/tools/loop_detector_test.go
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loopTestTool is a minimal tool that returns configurable results.
|
||||
type loopTestTool struct {
|
||||
name string
|
||||
result string
|
||||
}
|
||||
|
||||
func (t *loopTestTool) Name() string { return t.name }
|
||||
func (t *loopTestTool) Description() string { return "loop test tool" }
|
||||
func (t *loopTestTool) Parameters() map[string]interface{} { return nil }
|
||||
func (t *loopTestTool) Execute(_ context.Context, _ map[string]interface{}) *ToolResult {
|
||||
return NewToolResult(t.result)
|
||||
}
|
||||
|
||||
// --- Context key tests ---
|
||||
|
||||
func TestWithSessionKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if got := sessionKeyFromContext(ctx); got != "_default" {
|
||||
t.Errorf("expected _default, got %q", got)
|
||||
}
|
||||
|
||||
ctx = WithSessionKey(ctx, "session-123")
|
||||
if got := sessionKeyFromContext(ctx); got != "session-123" {
|
||||
t.Errorf("expected session-123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hash tests ---
|
||||
|
||||
func TestHashArgs_Deterministic(t *testing.T) {
|
||||
args := map[string]interface{}{"path": "/tmp/file.txt", "content": "hello"}
|
||||
h1 := hashArgs(args)
|
||||
h2 := hashArgs(args)
|
||||
if h1 != h2 {
|
||||
t.Errorf("hashArgs not deterministic: %s != %s", h1, h2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashArgs_Empty(t *testing.T) {
|
||||
if got := hashArgs(nil); got != "empty" {
|
||||
t.Errorf("expected 'empty' for nil args, got %q", got)
|
||||
}
|
||||
if got := hashArgs(map[string]interface{}{}); got != "empty" {
|
||||
t.Errorf("expected 'empty' for empty args, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashArgs_DifferentArgs(t *testing.T) {
|
||||
h1 := hashArgs(map[string]interface{}{"a": "1"})
|
||||
h2 := hashArgs(map[string]interface{}{"a": "2"})
|
||||
if h1 == h2 {
|
||||
t.Error("expected different hashes for different args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashResult_NilResult(t *testing.T) {
|
||||
if got := hashResult(nil); got != "nil" {
|
||||
t.Errorf("expected 'nil', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashResult_Deterministic(t *testing.T) {
|
||||
r := &ToolResult{ForLLM: "output data"}
|
||||
h1 := hashResult(r)
|
||||
h2 := hashResult(r)
|
||||
if h1 != h2 {
|
||||
t.Errorf("hashResult not deterministic: %s != %s", h1, h2)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config validation tests ---
|
||||
|
||||
func TestNewLoopDetector_DefaultConfig(t *testing.T) {
|
||||
d := NewLoopDetector(DefaultLoopDetectorConfig())
|
||||
if d.config.HistorySize != 30 {
|
||||
t.Errorf("HistorySize = %d, want 30", d.config.HistorySize)
|
||||
}
|
||||
if d.config.WarningThreshold != 10 {
|
||||
t.Errorf("WarningThreshold = %d, want 10", d.config.WarningThreshold)
|
||||
}
|
||||
if d.config.CriticalThreshold != 20 {
|
||||
t.Errorf("CriticalThreshold = %d, want 20", d.config.CriticalThreshold)
|
||||
}
|
||||
if d.config.CircuitBreakerThreshold != 30 {
|
||||
t.Errorf("CircuitBreakerThreshold = %d, want 30", d.config.CircuitBreakerThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoopDetector_FixesZeroThresholds(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 0, // zero → default 10
|
||||
CriticalThreshold: -1, // negative → default 20
|
||||
CircuitBreakerThreshold: 0, // zero → default 30
|
||||
})
|
||||
if d.config.WarningThreshold != DefaultWarningThreshold {
|
||||
t.Errorf("WarningThreshold = %d, want %d", d.config.WarningThreshold, DefaultWarningThreshold)
|
||||
}
|
||||
if d.config.CriticalThreshold != DefaultCriticalThreshold {
|
||||
t.Errorf("CriticalThreshold = %d, want %d", d.config.CriticalThreshold, DefaultCriticalThreshold)
|
||||
}
|
||||
if d.config.CircuitBreakerThreshold != DefaultCircuitBreakerThreshold {
|
||||
t.Errorf("CircuitBreakerThreshold = %d, want %d", d.config.CircuitBreakerThreshold, DefaultCircuitBreakerThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoopDetector_RespectsPositiveThresholds(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 100,
|
||||
CriticalThreshold: 3,
|
||||
CircuitBreakerThreshold: 5,
|
||||
})
|
||||
// All positive values should be kept as-is
|
||||
if d.config.WarningThreshold != 100 {
|
||||
t.Errorf("WarningThreshold = %d, want 100", d.config.WarningThreshold)
|
||||
}
|
||||
if d.config.CriticalThreshold != 3 {
|
||||
t.Errorf("CriticalThreshold = %d, want 3", d.config.CriticalThreshold)
|
||||
}
|
||||
if d.config.CircuitBreakerThreshold != 5 {
|
||||
t.Errorf("CircuitBreakerThreshold = %d, want 5", d.config.CircuitBreakerThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generic repeat detection ---
|
||||
|
||||
func TestLoopDetector_BelowWarning_NoBlock(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 5,
|
||||
CriticalThreshold: 10,
|
||||
CircuitBreakerThreshold: 20,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
args := map[string]interface{}{"key": "val"}
|
||||
|
||||
// Call 4 times (below warning=5): all should pass
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := d.BeforeExecute(ctx, "read_file", args); err != nil {
|
||||
t.Fatalf("call %d: unexpected block: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDetector_AtWarning_NoBlock(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 6,
|
||||
CircuitBreakerThreshold: 12,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
args := map[string]interface{}{"key": "val"}
|
||||
|
||||
// Warning is informational — should NOT block
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := d.BeforeExecute(ctx, "read_file", args); err != nil {
|
||||
t.Fatalf("call %d: unexpected block at warning level: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDetector_AtCritical_Blocks(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 6,
|
||||
CircuitBreakerThreshold: 15,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
args := map[string]interface{}{"key": "val"}
|
||||
|
||||
// First 6 calls should pass (history counts 0..5 before each check)
|
||||
for i := 0; i < 6; i++ {
|
||||
if err := d.BeforeExecute(ctx, "read_file", args); err != nil {
|
||||
t.Fatalf("call %d: unexpected block: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 7th call: history has 6 entries, check sees count=6 >= critical=6 → block
|
||||
if err := d.BeforeExecute(ctx, "read_file", args); err == nil {
|
||||
t.Fatal("expected block at critical threshold, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDetector_DifferentTools_NoConflict(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 6,
|
||||
CircuitBreakerThreshold: 15,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
|
||||
// Alternate between two tools: neither should hit threshold
|
||||
for i := 0; i < 10; i++ {
|
||||
tool := "read_file"
|
||||
if i%2 == 1 {
|
||||
tool = "write_file"
|
||||
}
|
||||
if err := d.BeforeExecute(ctx, tool, nil); err != nil {
|
||||
t.Fatalf("call %d (%s): unexpected block: %v", i, tool, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDetector_GenericRepeatDisabled(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 6,
|
||||
CircuitBreakerThreshold: 100, // high so circuit breaker doesn't fire
|
||||
EnableGenericRepeat: false,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
|
||||
// Should never block from generic repeat when disabled
|
||||
for i := 0; i < 20; i++ {
|
||||
if err := d.BeforeExecute(ctx, "read_file", nil); err != nil {
|
||||
t.Fatalf("call %d: block with generic repeat disabled: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ping-pong detection ---
|
||||
|
||||
func TestLoopDetector_PingPong_Detected(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 4,
|
||||
CriticalThreshold: 8,
|
||||
CircuitBreakerThreshold: 100,
|
||||
EnableGenericRepeat: false, // isolate ping-pong
|
||||
EnablePingPong: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
argsA := map[string]interface{}{"file": "a.txt"}
|
||||
argsB := map[string]interface{}{"file": "b.txt"}
|
||||
|
||||
// Build alternation pattern: A, B, A, B, ...
|
||||
// With result tracking for no-progress evidence
|
||||
for i := 0; i < 20; i++ {
|
||||
var tool string
|
||||
var args map[string]interface{}
|
||||
if i%2 == 0 {
|
||||
tool = "read_file"
|
||||
args = argsA
|
||||
} else {
|
||||
tool = "write_file"
|
||||
args = argsB
|
||||
}
|
||||
err := d.BeforeExecute(ctx, tool, args)
|
||||
// Record identical result to establish no-progress
|
||||
d.AfterExecute(ctx, tool, args, &ToolResult{ForLLM: fmt.Sprintf("result_%s", tool)})
|
||||
|
||||
if err != nil {
|
||||
// Should eventually block
|
||||
if i < 8 {
|
||||
t.Fatalf("blocked too early at call %d: %v", i, err)
|
||||
}
|
||||
return // success: blocked at or after critical threshold
|
||||
}
|
||||
}
|
||||
t.Fatal("ping-pong was never blocked after 20 alternating calls")
|
||||
}
|
||||
|
||||
func TestLoopDetector_PingPong_WithProgress_NoBlock(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 4,
|
||||
CriticalThreshold: 8,
|
||||
CircuitBreakerThreshold: 100,
|
||||
EnableGenericRepeat: false,
|
||||
EnablePingPong: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
argsA := map[string]interface{}{"file": "a.txt"}
|
||||
argsB := map[string]interface{}{"file": "b.txt"}
|
||||
|
||||
// Build alternation but with CHANGING results (progress)
|
||||
for i := 0; i < 20; i++ {
|
||||
var tool string
|
||||
var args map[string]interface{}
|
||||
if i%2 == 0 {
|
||||
tool = "read_file"
|
||||
args = argsA
|
||||
} else {
|
||||
tool = "write_file"
|
||||
args = argsB
|
||||
}
|
||||
if err := d.BeforeExecute(ctx, tool, args); err != nil {
|
||||
t.Fatalf("blocked at call %d despite progress: %v", i, err)
|
||||
}
|
||||
// Different result each time = progress
|
||||
d.AfterExecute(ctx, tool, args, &ToolResult{ForLLM: fmt.Sprintf("result_%d", i)})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDetector_PingPongDisabled(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 6,
|
||||
CircuitBreakerThreshold: 100,
|
||||
EnableGenericRepeat: false,
|
||||
EnablePingPong: false,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
tool := "read_file"
|
||||
if i%2 == 1 {
|
||||
tool = "write_file"
|
||||
}
|
||||
if err := d.BeforeExecute(ctx, tool, nil); err != nil {
|
||||
t.Fatalf("call %d: block with ping-pong disabled: %v", i, err)
|
||||
}
|
||||
d.AfterExecute(ctx, tool, nil, &ToolResult{ForLLM: "same"})
|
||||
}
|
||||
}
|
||||
|
||||
// --- No-progress / circuit breaker ---
|
||||
|
||||
func TestLoopDetector_CircuitBreaker_NoProgress(t *testing.T) {
|
||||
threshold := 8
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 100, // high so generic repeat doesn't fire
|
||||
CriticalThreshold: 100,
|
||||
CircuitBreakerThreshold: threshold,
|
||||
EnableGenericRepeat: false,
|
||||
EnablePingPong: false,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
args := map[string]interface{}{"file": "/tmp/stuck"}
|
||||
|
||||
for i := 0; i < threshold+5; i++ {
|
||||
err := d.BeforeExecute(ctx, "read_file", args)
|
||||
// Record identical result each time
|
||||
d.AfterExecute(ctx, "read_file", args, &ToolResult{ForLLM: "same output"})
|
||||
|
||||
if err != nil {
|
||||
if i < threshold {
|
||||
t.Fatalf("circuit breaker fired too early at call %d", i)
|
||||
}
|
||||
return // success
|
||||
}
|
||||
}
|
||||
t.Fatal("circuit breaker never fired")
|
||||
}
|
||||
|
||||
func TestLoopDetector_CircuitBreaker_WithProgress_NoBlock(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 100,
|
||||
CriticalThreshold: 100,
|
||||
CircuitBreakerThreshold: 5,
|
||||
EnableGenericRepeat: false,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "test")
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
if err := d.BeforeExecute(ctx, "exec", nil); err != nil {
|
||||
t.Fatalf("call %d: blocked despite progress: %v", i, err)
|
||||
}
|
||||
// Different result each time
|
||||
d.AfterExecute(ctx, "exec", nil, &ToolResult{ForLLM: fmt.Sprintf("output_%d", i)})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session isolation ---
|
||||
|
||||
func TestLoopDetector_SessionIsolation(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 5,
|
||||
CircuitBreakerThreshold: 15,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
|
||||
ctxA := WithSessionKey(context.Background(), "session-A")
|
||||
ctxB := WithSessionKey(context.Background(), "session-B")
|
||||
|
||||
// Fill session A to near-critical
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := d.BeforeExecute(ctxA, "read_file", nil); err != nil {
|
||||
t.Fatalf("session A call %d: unexpected block: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Session B should be unaffected
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := d.BeforeExecute(ctxB, "read_file", nil); err != nil {
|
||||
t.Fatalf("session B call %d: blocked by session A state: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ResetSession ---
|
||||
|
||||
func TestLoopDetector_ResetSession(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 5,
|
||||
CircuitBreakerThreshold: 15,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "reset-test")
|
||||
|
||||
// Fill to near-critical
|
||||
for i := 0; i < 4; i++ {
|
||||
d.BeforeExecute(ctx, "read_file", nil)
|
||||
}
|
||||
|
||||
// Reset session
|
||||
d.ResetSession("reset-test")
|
||||
|
||||
// Should be able to call again without hitting threshold
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := d.BeforeExecute(ctx, "read_file", nil); err != nil {
|
||||
t.Fatalf("call %d after reset: unexpected block: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sliding window ---
|
||||
|
||||
func TestLoopDetector_SlidingWindow_EvictsOld(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
HistorySize: 5, // tiny window
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 5,
|
||||
CircuitBreakerThreshold: 10,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
ctx := WithSessionKey(context.Background(), "window-test")
|
||||
|
||||
// Fill 5 entries with read_file
|
||||
for i := 0; i < 5; i++ {
|
||||
d.BeforeExecute(ctx, "read_file", nil)
|
||||
}
|
||||
|
||||
// Now call different tools to push old entries out of window
|
||||
for i := 0; i < 5; i++ {
|
||||
d.BeforeExecute(ctx, fmt.Sprintf("tool_%d", i), nil)
|
||||
}
|
||||
|
||||
// read_file should no longer be in window — calling it should not trigger anything
|
||||
if err := d.BeforeExecute(ctx, "read_file", nil); err != nil {
|
||||
t.Fatalf("expected no block after window eviction, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integration with ToolRegistry ---
|
||||
|
||||
func TestLoopDetector_IntegrationWithRegistry(t *testing.T) {
|
||||
reg := NewToolRegistry()
|
||||
reg.Register(&loopTestTool{name: "stuck_tool", result: "same"})
|
||||
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 5,
|
||||
CircuitBreakerThreshold: 15,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
reg.AddHook(d)
|
||||
|
||||
ctx := WithSessionKey(context.Background(), "integration")
|
||||
|
||||
// Should succeed initially
|
||||
for i := 0; i < 5; i++ {
|
||||
result := reg.Execute(ctx, "stuck_tool", nil)
|
||||
if result.IsError {
|
||||
t.Fatalf("call %d: unexpected error: %s", i, result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th call should be blocked
|
||||
result := reg.Execute(ctx, "stuck_tool", nil)
|
||||
if !result.IsError {
|
||||
t.Fatal("expected block at critical threshold via registry integration")
|
||||
}
|
||||
}
|
||||
|
||||
// --- AfterExecute records result ---
|
||||
|
||||
func TestLoopDetector_AfterExecute_RecordsResult(t *testing.T) {
|
||||
d := NewLoopDetector(DefaultLoopDetectorConfig())
|
||||
ctx := WithSessionKey(context.Background(), "after-test")
|
||||
args := map[string]interface{}{"x": "1"}
|
||||
|
||||
d.BeforeExecute(ctx, "test_tool", args)
|
||||
d.AfterExecute(ctx, "test_tool", args, &ToolResult{ForLLM: "result"})
|
||||
|
||||
// Verify result was recorded
|
||||
state := d.getSession("after-test")
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
if len(state.history) != 1 {
|
||||
t.Fatalf("history len = %d, want 1", len(state.history))
|
||||
}
|
||||
if state.history[0].ResultHash == "" {
|
||||
t.Error("result hash not recorded by AfterExecute")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Default session key ---
|
||||
|
||||
func TestLoopDetector_DefaultSessionKey(t *testing.T) {
|
||||
d := NewLoopDetector(LoopDetectorConfig{
|
||||
WarningThreshold: 3,
|
||||
CriticalThreshold: 5,
|
||||
CircuitBreakerThreshold: 15,
|
||||
EnableGenericRepeat: true,
|
||||
})
|
||||
|
||||
// No session key in context — should use "_default"
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := d.BeforeExecute(ctx, "test", nil); err != nil {
|
||||
t.Fatalf("call %d: unexpected block: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue