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
18 changes: 13 additions & 5 deletions Sources/SwiftAgentKit/Core/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions Sources/SwiftAgentKit/Core/LoopDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []

init(config: LoopDetectionConfig) { self.config = config }
Expand Down Expand Up @@ -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?
Expand All @@ -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
}
}
73 changes: 73 additions & 0 deletions Tests/SwiftAgentKitTests/LoopDetectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading