From 22bf8b1ad49edeb273aeea5e95a3fa2b1c4bd608 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Tue, 25 Aug 2026 15:21:54 +0300 Subject: [PATCH] =?UTF-8?q?Loop=20detector:=20repeating-cycle=20guard=20(A?= =?UTF-8?q?=E2=86=92B=E2=86=92A=E2=86=92B=20evaded=20stop=20forever)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-signature counting peaks at windowSize/cycleLen — for any 2-tool cycle in a window of 6 that's 3, below the stop threshold of 5, so an alternating loop could NEVER be stopped (observed live: endless sim_rotate → sim_screenshot the user had to interrupt). The detector now also finds the trailing block of length 2-3 repeating verbatim and nudges/stops on REPETITION count, with a names-only cycle label and a cycle-specific nudge message. Uniform runs keep the original path. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/SwiftAgentKit/Core/Agent.swift | 18 +++-- Sources/SwiftAgentKit/Core/LoopDetector.swift | 40 ++++++++++ .../LoopDetectorTests.swift | 73 +++++++++++++++++++ 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftAgentKit/Core/Agent.swift b/Sources/SwiftAgentKit/Core/Agent.swift index 061a466..c897920 100644 --- a/Sources/SwiftAgentKit/Core/Agent.swift +++ b/Sources/SwiftAgentKit/Core/Agent.swift @@ -1233,11 +1233,19 @@ public actor Agent { break case .nudge(let sig, let count): emit(.loopDetected(signature: sig, count: count, action: .nudged)) - let toolName = String(sig.split(separator: ":", maxSplits: 1).first ?? Substring(sig)) - conversation.append(.user( - "You've called `\(toolName)` with the same arguments \(count) times " - + "without new progress. Change your approach, or finish and summarize " - + "what you have. Do not repeat that call.")) + if sig.hasPrefix("cycle[") { + conversation.append(.user( + "You're repeating the tool cycle \(sig) — \(count) rounds with no " + + "new information. Break the cycle: the state will not change by " + + "looking again. Act on what you have, change approach, or finish " + + "and summarize.")) + } else { + let toolName = String(sig.split(separator: ":", maxSplits: 1).first ?? Substring(sig)) + conversation.append(.user( + "You've called `\(toolName)` with the same arguments \(count) times " + + "without new progress. Change your approach, or finish and summarize " + + "what you have. Do not repeat that call.")) + } case .stop(let sig, let count): emit(.loopDetected(signature: sig, count: count, action: .stopped)) let summary = makeRunSummary( diff --git a/Sources/SwiftAgentKit/Core/LoopDetector.swift b/Sources/SwiftAgentKit/Core/LoopDetector.swift index 81faf94..d7a09f0 100644 --- a/Sources/SwiftAgentKit/Core/LoopDetector.swift +++ b/Sources/SwiftAgentKit/Core/LoopDetector.swift @@ -28,8 +28,14 @@ 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 + private let config: LoopDetectionConfig private var history: [String] = [] + /// Longer trailing history for the cycle guard (needs stopThreshold + /// repetitions of the longest cycle to be visible at once). + private var fullHistory: [String] = [] private var nudged: Set = [] init(config: LoopDetectionConfig) { self.config = config } @@ -58,6 +64,11 @@ final class LoopDetector { if history.count > config.windowSize { history.removeFirst(history.count - config.windowSize) } + fullHistory.append(contentsOf: signatures) + let cap = config.stopThreshold * Self.maxCycleLength + Self.maxCycleLength + if fullHistory.count > cap { + fullHistory.removeFirst(fullHistory.count - cap) + } let window = history.suffix(config.windowSize) var pendingNudge: LoopAction? @@ -71,6 +82,35 @@ final class LoopDetector { pendingNudge = .nudge(signature: sig, count: count) } } + // Repeating-CYCLE guard. Alternating loops (rotate → screenshot → + // rotate → screenshot…) evade per-signature counting: in a window of + // 6, each signature peaks at 3 — the stop threshold (5) is + // mathematically unreachable for any 2-tool cycle. Observed live as an + // 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. + for length in 2...Self.maxCycleLength { + guard fullHistory.count >= length * config.nudgeThreshold else { continue } + let block = Array(fullHistory.suffix(length)) + if Set(block).count == 1 { continue } // uniform runs handled above + var reps = 1 + while fullHistory.count >= length * (reps + 1) { + let start = fullHistory.count - length * (reps + 1) + if Array(fullHistory[start ..< start + length]) == block { reps += 1 } else { break } + } + guard reps >= config.nudgeThreshold else { continue } + // Human-readable cycle label: tool names only (args stay in the + // 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 { + return .stop(signature: sig, count: reps) + } + if !nudged.contains(sig), pendingNudge == nil { + nudged.insert(sig) + pendingNudge = .nudge(signature: sig, count: reps) + } + } return pendingNudge ?? .none } } diff --git a/Tests/SwiftAgentKitTests/LoopDetectorTests.swift b/Tests/SwiftAgentKitTests/LoopDetectorTests.swift index 5aab2c5..ab10c56 100644 --- a/Tests/SwiftAgentKitTests/LoopDetectorTests.swift +++ b/Tests/SwiftAgentKitTests/LoopDetectorTests.swift @@ -84,3 +84,76 @@ struct LoopDetectorTests { #expect(suggestion?.contains("different approach") == true) } } + +// MARK: - Repeating-cycle guard (A→B→A→B evades per-signature counting) + +@Test func alternatingCycleNudgesThenStops() { + // The observed live failure: sim_rotate → sim_screenshot repeated + // endlessly. Per-signature counting peaks at windowSize/2 = 3 < stop(5), + // so the old detector could NEVER stop it. The cycle guard must. + let d = LoopDetector(config: .default) // nudge 3, stop 5 + let rotate = "sim_rotate:{\"orientation\":\"landscape_left\"}" + let shot = "sim_screenshot" + var actions: [LoopAction] = [] + for _ in 0..<6 { + actions.append(d.record([rotate])) + actions.append(d.record([shot])) + } + // A nudge fires once the block has repeated nudgeThreshold times… + #expect(actions.contains { if case .nudge(let s, _) = $0 { return s.hasPrefix("cycle[") } ; return false }) + // …and the run STOPS at stopThreshold repetitions instead of spinning forever. + #expect(actions.contains { if case .stop(let s, _) = $0 { return s.hasPrefix("cycle[") } ; return false }) +} + +@Test func cycleSignatureUsesToolNamesOnly() { + // Per-signature nudges legitimately fire first (each tool hits count 3); + // the CYCLE guard is what eventually STOPS the run — and its label must + // read as tool names, not raw signatures with JSON args. + let d = LoopDetector(config: .default) + let a = "tool_a:{\"x\":1}", b = "tool_b:{\"y\":2}" + var stopLabel: String? + for _ in 0..<8 { + if case .stop(let s, _) = d.record([a, b]) { stopLabel = s; break } + } + #expect(stopLabel == "cycle[tool_a → tool_b]") +} + +@Test func threeToolCycleDetected() { + let d = LoopDetector(config: .default) + var stopped = false + for _ in 0..<6 { + for sig in ["a:1", "b:2", "c:3"] { + if case .stop(let s, _) = d.record([sig]), s.hasPrefix("cycle[") { stopped = true } + } + } + #expect(stopped) +} + +@Test func variedWorkIsNotACycle() { + // Legitimate iterative work (read → patch → build with CHANGING args) + // must never trip the cycle guard. + let d = LoopDetector(config: .default) + var tripped = false + for i in 0..<12 { + let sigs = ["read_file:{\"path\":\"f\(i)\"}", "apply_patch:{\"n\":\(i)}", "run_shell:{\"c\":\(i)}"] + for sig in sigs { + if case .none = d.record([sig]) { continue } else { tripped = true } + } + } + #expect(!tripped) +} + +@Test func uniformRunsStillHandledByPerSignatureGuard() { + // AAAA… must keep its original nudge/stop shape (not double-fire as a cycle). + let d = LoopDetector(config: .default) + var stops = 0, cycleActions = 0 + for _ in 0..<8 { + switch d.record(["same:call"]) { + case .stop(let s, _): stops += 1; if s.hasPrefix("cycle[") { cycleActions += 1 } + case .nudge(let s, _): if s.hasPrefix("cycle[") { cycleActions += 1 } + case .none: break + } + } + #expect(stops >= 1) + #expect(cycleActions == 0) +}