SPB Git

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%
7.0 KB · 190 lines swift
Raw Blame History
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