From d1e3a3c3179f2832ec3966b24dffdfeed012693e Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Tue, 25 Aug 2026 15:32:51 +0300 Subject: [PATCH] Loop detector: cycles up to length 5, stop one rep after the nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live run evaded the length-3 guard within hours using a 4-tool cycle (terminate → launch → wait → screenshot) that also starves the per-signature window. Cycle guard now covers lengths 2-5 and stops at nudgeThreshold+1 verbatim repetitions — 5 reps of a 4-cycle would have meant 20 wasted calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/SwiftAgentKit/Core/LoopDetector.swift | 13 +++++-- .../LoopDetectorTests.swift | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftAgentKit/Core/LoopDetector.swift b/Sources/SwiftAgentKit/Core/LoopDetector.swift index d7a09f0..b1baa9f 100644 --- a/Sources/SwiftAgentKit/Core/LoopDetector.swift +++ b/Sources/SwiftAgentKit/Core/LoopDetector.swift @@ -28,8 +28,11 @@ public enum LoopAction: Sendable, Equatable { /// Detects a stalled agent: the same (tool + args) signature repeating within a /// recent window. Pure and deterministic — no LLM, no I/O. final class LoopDetector { - /// Longest repeating cycle the cycle guard looks for (A→B and A→B→C). - static let maxCycleLength = 3 + /// Longest repeating cycle the cycle guard looks for. Was 3; a live run + /// promptly evaded it with a 4-tool cycle (terminate → launch → wait → + /// screenshot, verbatim) — which ALSO starves the per-signature window + /// (6 calls hold 1.5 cycles → count 2, below even the nudge). + static let maxCycleLength = 5 private let config: LoopDetectionConfig private var history: [String] = [] @@ -89,6 +92,10 @@ final class LoopDetector { // endless rotate/screenshot loop the user had to interrupt by hand. // Detect the trailing block of length 2…maxCycleLength repeating // verbatim, and nudge/stop on the REPETITION count instead. + // A long block repeating VERBATIM is damning much sooner than a single + // repeated call: stop cycles one repetition after the nudge instead of + // waiting for stopThreshold (5 reps of a 4-cycle = 20 wasted calls). + let cycleStop = max(config.nudgeThreshold + 1, 4) for length in 2...Self.maxCycleLength { guard fullHistory.count >= length * config.nudgeThreshold else { continue } let block = Array(fullHistory.suffix(length)) @@ -103,7 +110,7 @@ final class LoopDetector { // matched signatures; the label is for the nudge message/event). let names = block.map { String($0.split(separator: ":", maxSplits: 1).first ?? Substring($0)) } let sig = "cycle[" + names.joined(separator: " → ") + "]" - if reps >= config.stopThreshold { + if reps >= cycleStop { return .stop(signature: sig, count: reps) } if !nudged.contains(sig), pendingNudge == nil { diff --git a/Tests/SwiftAgentKitTests/LoopDetectorTests.swift b/Tests/SwiftAgentKitTests/LoopDetectorTests.swift index ab10c56..7e522a9 100644 --- a/Tests/SwiftAgentKitTests/LoopDetectorTests.swift +++ b/Tests/SwiftAgentKitTests/LoopDetectorTests.swift @@ -157,3 +157,37 @@ struct LoopDetectorTests { #expect(stops >= 1) #expect(cycleActions == 0) } + +@Test func fourToolCycleFromLiveRunIsStopped() { + // The exact live evasion after the length-3 guard shipped: a 4-tool + // cycle (terminate → launch → wait → screenshot) that ALSO starves the + // per-signature window (6 calls = 1.5 cycles → count 2, no nudge). + let d = LoopDetector(config: .default) + let cycle = ["sim_terminate:{\"b\":\"x\"}", "sim_launch:{\"b\":\"x\"}", + "sim_wait:{\"b\":\"x\"}", "sim_screenshot"] + var nudgedAt: Int?, stoppedAt: Int? + for rep in 1...6 { + for sig in cycle { + switch d.record([sig]) { + case .nudge(let s, _) where s.hasPrefix("cycle["): nudgedAt = nudgedAt ?? rep + case .stop(let s, _) where s.hasPrefix("cycle["): stoppedAt = stoppedAt ?? rep + default: break + } + } + if stoppedAt != nil { break } + } + #expect(nudgedAt == 3) // warned at 3 verbatim repetitions + #expect(stoppedAt == 4) // stopped one repetition later, not at 5 +} + +@Test func fiveToolCycleDetected() { + let d = LoopDetector(config: .default) + let cycle = (0..<5).map { "t\($0):a" } + var stopped = false + for _ in 1...5 { + for sig in cycle { + if case .stop(let s, _) = d.record([sig]), s.hasPrefix("cycle[") { stopped = true } + } + } + #expect(stopped) +}