diff --git a/pkg/agent/anti_loop.go b/pkg/agent/anti_loop.go index d638139..28b78ec 100644 --- a/pkg/agent/anti_loop.go +++ b/pkg/agent/anti_loop.go @@ -20,16 +20,20 @@ const loopWarnThreshold = 3 // pattern the detector cares about while keeping the struct cache-friendly. const maxRecentCalls = 30 -// loopWarnMarker is the prefix of the anti-loop warning that runToolCall -// appends to a tool result before persisting it (loop_execute.go). The live -// AddCall path hashes the raw result (the warning is appended afterwards), but -// loopDetectorFromHistory re-reads the persisted content, which carries the +// loopWarnPrefix opens every anti-loop warning Detect emits. Both the warning +// format strings below and loopWarnMarker derive from it so the two can never +// drift apart — editing the wording can't silently break the strip. +const loopWarnPrefix = "[SYSTEM WARNING:" + +// loopWarnMarker is the boundary at which runToolCall's appended warning begins +// in a persisted tool result (loop_execute.go appends "\n\n" + warning). The +// live AddCall path hashes the raw result (the warning is appended afterwards), +// but loopDetectorFromHistory re-reads the persisted content, which carries the // warning. Because the warning embeds the consecutive count ("3 times" vs // "4 times"), each persisted result would otherwise hash differently, so the // kill threshold could never be reached across turns. Stripping at this marker -// restores byte-identity with the live path. Keep in sync with the Detect -// warning format below and the append in loop_execute.go. -const loopWarnMarker = "\n\n[SYSTEM WARNING:" +// restores byte-identity with the live path. +const loopWarnMarker = "\n\n" + loopWarnPrefix // stripLoopWarning removes the anti-loop warning suffix appended to a persisted // tool result so its hash matches the live raw result the model first saw. @@ -180,13 +184,13 @@ func (ld *loopDetector) Detect() (warning string, killErr error) { if identicalCount >= loopKillThreshold { return "", fmt.Errorf("agent stuck in identical loop calling %s with same args", lastCall.ToolName) } else if identicalCount >= loopWarnThreshold { - return fmt.Sprintf("[SYSTEM WARNING: You have called %s with the exact same arguments %d times consecutively. STOP doing this and try a different approach.]", lastCall.ToolName, identicalCount), nil + return fmt.Sprintf(loopWarnPrefix+" You have called %s with the exact same arguments %d times consecutively. STOP doing this and try a different approach.]", lastCall.ToolName, identicalCount), nil } if sameResultCount >= loopKillThreshold { return "", fmt.Errorf("agent stuck in identical-result loop calling %s", lastCall.ToolName) } else if sameResultCount >= loopWarnThreshold { - return fmt.Sprintf("[SYSTEM WARNING: You have called %s with different arguments, but the outcome is identically unhelpful %d times in a row. Re-evaluate your overall strategy.]", lastCall.ToolName, sameResultCount), nil + return fmt.Sprintf(loopWarnPrefix+" You have called %s with different arguments, but the outcome is identically unhelpful %d times in a row. Re-evaluate your overall strategy.]", lastCall.ToolName, sameResultCount), nil } return "", nil diff --git a/pkg/agent/anti_loop_test.go b/pkg/agent/anti_loop_test.go index 2ce466d..37aafe5 100644 --- a/pkg/agent/anti_loop_test.go +++ b/pkg/agent/anti_loop_test.go @@ -224,6 +224,29 @@ func TestStripLoopWarning(t *testing.T) { } } +func TestStripLoopWarning_MatchesRealDetectOutput(t *testing.T) { + // Drift guard: the strip marker must remove a warning Detect actually + // emits, not just a hardcoded literal. If the warning format is reworded + // so it no longer begins with loopWarnPrefix, this fails — catching the + // silent regression a literal-only test would miss. + ld := newLoopDetector() + for range loopWarnThreshold { + ld.AddCall("tool1", `{"a":1}`, "same_result") + } + warn, err := ld.Detect() + if err != nil { + t.Fatalf("expected warn, got kill: %v", err) + } + if warn == "" { + t.Fatal("expected a warning to guard against") + } + const raw = "raw result" + persisted := raw + "\n\n" + warn // mirrors the append in loop_execute.go + if got := stripLoopWarning(persisted); got != raw { + t.Fatalf("real Detect warning not stripped — marker drifted from warning format; got %q", got) + } +} + func BenchmarkLoopDetector_DetectNoLoop(b *testing.B) { ld := newLoopDetector() for i := 0; i < 10; i++ {