// // AgentSettingsStore.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // App-wide agent preferences (Settings › Agent + Safety), persisted to // AgentSettings.json via PersistenceService and folded into the // AgentConfiguration each run starts with (RunController.start). // // Two Safety settings are persisted and surfaced but not yet enforced by // the PolicyEngine (it has no hook for them; documented gap): // - requireApprovalForAppleScript: the engine already asks in Manual and // Guarded; this toggle is meant to force asking in Autonomous too. // - workspaceEscapePolicy: the engine currently ALWAYS asks on any file // access outside the workspace (the strict default); the "deny" choice // is stored for a future engine hook. // import Combine import Foundation /// How file access outside the task workspace should be handled. enum WorkspaceEscapePolicy: String, Codable, CaseIterable, Identifiable { /// Every outside-workspace read/write asks for approval (built-in default). case alwaysAsk /// Outside-workspace access is denied without asking. case deny var id: String { rawValue } var displayName: String { switch self { case .alwaysAsk: return "Always ask" case .deny: return "Deny without asking" } } } /// The persisted settings document (AgentSettings.json). struct AgentSettings: Codable { // Agent tab var maxSteps: Int = LoopGuardConfiguration.default.maxSteps var tokenBudget: Int = LoopGuardConfiguration.default.tokenBudget /// Wall-clock budget in minutes. var timeBudgetMinutes: Int = Int(LoopGuardConfiguration.default.wallClockBudget / 60) /// Per-command timeout in seconds. var perCommandTimeoutSeconds: Int = Int(ExecutionConfiguration.default.defaultTimeout) var parallelToolCalls: Bool = false /// Context-window fraction that triggers memory compaction. var compactionThreshold: Double = MemoryConfiguration.default.compactionThreshold // Safety tab var defaultSafetyMode: SafetyMode = .guarded var requireApprovalForAppleScript: Bool = true var workspaceEscapePolicy: WorkspaceEscapePolicy = .alwaysAsk // Models tab: persisted default agent model ("provider|modelID"). var defaultAgentModelKey: String? } /// Observable wrapper the Settings tabs bind to; every change persists. @MainActor final class AgentSettingsStore: ObservableObject { static let fileName = "AgentSettings.json" @Published var settings: AgentSettings { didSet { persistence.save(settings, to: Self.fileName) } } private let persistence: PersistenceService init(persistence: PersistenceService = .shared) { self.persistence = persistence self.settings = persistence.load(AgentSettings.self, from: Self.fileName) ?? AgentSettings() } /// The engine configuration a new run starts with, built from the /// persisted settings (clamped to sane bounds). func agentConfiguration(personaAddendum: String? = nil) -> AgentConfiguration { var configuration = AgentConfiguration.default configuration.loopGuard.maxSteps = max(1, settings.maxSteps) configuration.loopGuard.tokenBudget = max(10_000, settings.tokenBudget) configuration.loopGuard.wallClockBudget = TimeInterval(max(1, settings.timeBudgetMinutes)) * 60 configuration.memory.compactionThreshold = min(0.95, max(0.5, settings.compactionThreshold)) configuration.parallelToolCalls = settings.parallelToolCalls configuration.perCommandTimeout = TimeInterval(max(5, settings.perCommandTimeoutSeconds)) configuration.personaAddendum = personaAddendum return configuration } /// The persisted default agent model resolved against the catalog. func defaultAgentModel(in catalog: ModelCatalog) -> AIModel? { if let key = settings.defaultAgentModelKey { let parts = key.split(separator: "|", maxSplits: 1) if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])), let model = catalog.model(id: String(parts[1]), provider: provider) { return model } } return catalog.defaultAgentModel } func setDefaultAgentModel(_ model: AIModel) { settings.defaultAgentModelKey = "\(model.provider.rawValue)|\(model.id)" } }