spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// LoopGuard.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Bounds every run (docs/AGENT-RESEARCH.md §6.1): max steps, cumulative9// token budget, wall-clock budget, repetition detection (the same tool10// call failing N times), and stall detection (M consecutive steps with no11// plan change, no file change, and no new distinct tool call). A trip never12// aborts the run — the loop pauses and hands control back to the user13// ("pause, ask" is the correct trip behavior, per Anthropic's own14// pause_turn design), who can continue with raised budgets or stop.15//1617import Foundation1819/// Budgets and detection thresholds, user-tunable in Settings › Agent.20struct LoopGuardConfiguration: Sendable {21 /// Hard cap on loop iterations per run.22 var maxSteps = 5023 /// Cumulative token budget (input + output + reasoning) per run.24 var tokenBudget = 500_00025 /// Wall-clock budget per run.26 var wallClockBudget: TimeInterval = 30 * 6027 /// Same tool + normalized args failing this many times ⇒ repetition trip.28 var repetitionThreshold = 329 /// Consecutive no-progress steps ⇒ stall trip.30 var stallThreshold = 63132 static let `default` = LoopGuardConfiguration()33}3435/// Why the guard tripped.36enum LoopGuardTripReason: String, Codable, Sendable {37 case maxSteps38 case tokenBudget39 case wallClockBudget40 case repetition41 case stall42}4344/// One trip, surfaced to the UI/CLI so the user can decide.45struct LoopGuardTrip: Codable, Sendable {46 var reason: LoopGuardTripReason47 /// Human-readable explanation shown on the pause card.48 var message: String49 /// Step at which the trip occurred.50 var stepIndex: Int51}5253/// Run-bounding state machine. Plain struct — mutated only inside the54/// AgentLoop actor.55struct LoopGuard {56 private(set) var configuration: LoopGuardConfiguration57 private let startedAt = Date()58 private(set) var totalTokens = 05960 /// Consecutive failure count per tool-call signature (reset on success).61 private var failingCallCounts: [String: Int] = [:]62 /// Every distinct tool-call signature seen this run (novelty = progress).63 private var seenCallSignatures: Set<String> = []64 private var stepsWithoutProgress = 06566 init(configuration: LoopGuardConfiguration = .default) {67 self.configuration = configuration68 }6970 var elapsed: TimeInterval { Date().timeIntervalSince(startedAt) }7172 // MARK: Budgets (checked before each step)7374 /// Nil when the next step may proceed; a trip otherwise.75 mutating func checkBeforeStep(index: Int) -> LoopGuardTrip? {76 if index > configuration.maxSteps {77 return LoopGuardTrip(78 reason: .maxSteps,79 message: "Reached the step limit (\(configuration.maxSteps) steps).",80 stepIndex: index81 )82 }83 if totalTokens >= configuration.tokenBudget {84 return LoopGuardTrip(85 reason: .tokenBudget,86 message: "Reached the token budget (\(totalTokens) of \(configuration.tokenBudget) tokens used).",87 stepIndex: index88 )89 }90 if elapsed >= configuration.wallClockBudget {91 return LoopGuardTrip(92 reason: .wallClockBudget,93 message: "Reached the time budget (\(Int(elapsed / 60)) of \(Int(configuration.wallClockBudget / 60)) minutes).",94 stepIndex: index95 )96 }97 return nil98 }99100 mutating func recordUsage(_ usage: TokenUsage) {101 totalTokens += usage.totalTokens + (usage.reasoningTokens ?? 0)102 }103104 // MARK: Repetition & novelty (recorded per tool invocation)105106 /// Records one executed tool call. Returns true when the call was a NEW107 /// distinct signature (a progress signal for stall detection).108 mutating func recordInvocation(call: ToolCall, isError: Bool) -> Bool {109 let signature = Self.signature(of: call)110 let isNew = seenCallSignatures.insert(signature).inserted111 if isError {112 failingCallCounts[signature, default: 0] += 1113 } else {114 failingCallCounts[signature] = 0115 }116 return isNew117 }118119 // MARK: Post-step verdict120121 /// Checks repetition and stall after a tool-executing step. `planChanged`,122 /// `filesChanged`, and `sawNewToolCall` are the progress signals.123 mutating func recordStepOutcome(124 stepIndex: Int,125 planChanged: Bool,126 filesChanged: Bool,127 sawNewToolCall: Bool128 ) -> LoopGuardTrip? {129 if let (signature, count) = failingCallCounts.first(where: { $0.value >= configuration.repetitionThreshold }) {130 // Reset so a user-resumed run isn't instantly re-tripped.131 failingCallCounts[signature] = 0132 let display = signature.count > 160 ? String(signature.prefix(160)) + "…" : signature133 return LoopGuardTrip(134 reason: .repetition,135 message: "The same tool call has failed \(count) times: \(display)",136 stepIndex: stepIndex137 )138 }139140 if planChanged || filesChanged || sawNewToolCall {141 stepsWithoutProgress = 0142 } else {143 stepsWithoutProgress += 1144 }145 if stepsWithoutProgress >= configuration.stallThreshold {146 stepsWithoutProgress = 0147 return LoopGuardTrip(148 reason: .stall,149 message: "No progress detected for \(configuration.stallThreshold) consecutive steps (no plan change, no file change, no new tool call).",150 stepIndex: stepIndex151 )152 }153 return nil154 }155156 // MARK: Resume support157158 /// Raises every budget by half its current value (with sensible floors)159 /// and clears the repetition/stall state — called when the user answers160 /// a trip with "continue".161 mutating func raiseBudgets() {162 configuration.maxSteps += max(10, configuration.maxSteps / 2)163 configuration.tokenBudget += max(100_000, configuration.tokenBudget / 2)164 configuration.wallClockBudget += max(600, configuration.wallClockBudget / 2)165 failingCallCounts = [:]166 stepsWithoutProgress = 0167 }168169 // MARK: Signature normalization170171 /// "name|canonical-args": arguments parsed and re-serialized with sorted172 /// keys so cosmetic JSON differences don't defeat repetition detection.173 static func signature(of call: ToolCall) -> String {174 "\(call.name)|\(normalizedArguments(call.argumentsJSON))"175 }176177 static func normalizedArguments(_ argumentsJSON: String) -> String {178 guard let value = JSONValue.parse(argumentsJSON) else {179 return argumentsJSON.trimmingCharacters(in: .whitespacesAndNewlines)180 }181 let encoder = JSONEncoder()182 encoder.outputFormatting = [.sortedKeys]183 guard let data = try? encoder.encode(value),184 let canonical = String(data: data, encoding: .utf8) else {185 return argumentsJSON.trimmingCharacters(in: .whitespacesAndNewlines)186 }187 return canonical188 }189}190