Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Sources/SwiftAgentKit/Core/LoopDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions Tests/SwiftAgentKitTests/LoopDetectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading