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// AgentCLI.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Headless command-line modes:9//10// --run "<task>" [--model <id | provider/id>] [--mode manual|guarded|autonomous]11// [--workspace <path>] [--max-steps N] [--yes] [--allow-destructive]12// The Phase 3 agent POC: runs a real agent task end-to-end with live13// rendering, a stdin approval presenter, and a transcript summary.14// API keys come from the environment (ANTHROPIC_API_KEY, …) or the15// encrypted vault.16//17// --run-mock ["<task>"]18// Hidden CI smoke test: same engine, scripted MockProviderClient,19// scratch workspace, no network/keys.20//21// --verify [--provider <id>] [--model <id | provider/id>]22// Phase 7.1: live provider tool-calling verification over every23// agent-capable model (see Verify/VerifyHarness.swift). Writes24// docs/VERIFICATION.md.25//26// --load-vault Imports environment API keys into the encrypted vault.27// --verify-policy PolicyEngine safety self-check (Phase 3.C).28//29// Hidden scripted-eval flag (Phase 7.2): --compact-threshold <0..1>30// overrides the memory compaction threshold AND relaxes the compaction31// thrash guard (keepRecentSteps→2, minStepsBetweenCompactions→3) so an32// evaluation scenario can force a compaction inside a small step budget.33//34// `--yes` auto-approves mode-driven approvals for scripted runs, but NEVER35// silently approves the always-ask class (destructive/elevated actions,36// file access outside the workspace): those are auto-DENIED with a message37// unless `--allow-destructive` is also passed. Guard trips auto-stop under38// `--yes` (a scripted run must not raise its own budgets forever).39//4041import Foundation4243enum AgentCLI {4445 // MARK: - Entry4647 static func run(arguments: [String]) async -> Int32 {48 if arguments.contains("--verify-policy") {49 let allPassed = await PolicyEngineSelfCheck.run()50 return allPassed ? 0 : 151 }52 if arguments.contains("--verify") {53 return await VerifyHarness.run(arguments: arguments)54 }55 if arguments.contains("--run") || arguments.contains("--run-mock") {56 switch parseRunOptions(arguments) {57 case .failure(let error):58 FileHandle.standardError.write(Data((error.message + "\n" + usage + "\n").utf8))59 return 6460 case .success(let options):61 return await runAgent(options: options)62 }63 }64 FileHandle.standardError.write(Data((usage + "\n").utf8))65 return 6466 }6768 /// `--load-vault`: seeds the encrypted vault from environment variables.69 /// Prints stored/skipped per provider; NEVER prints any part of a key.70 static func loadVault() {71 let store = SecureKeyStore()72 let environment = ProcessInfo.processInfo.environment73 print("Zyquo Agent — importing API keys from the environment into the vault")74 for provider in ProviderID.builtIn {75 let names = environmentKeyNames(for: provider)76 guard let name = names.first(where: { !(environment[$0] ?? "").isEmpty }),77 let key = environment[name] else {78 print(" skipped \(provider.rawValue) — no \(names.joined(separator: "/")) set")79 continue80 }81 do {82 try store.setKey(key, for: provider)83 print(" stored \(provider.rawValue) (from \(name))")84 } catch {85 print(" FAILED \(provider.rawValue) — \(error.localizedDescription)")86 }87 }88 print("Vault: \(store.vaultURL.path)")89 }9091 private static let usage = """92 Usage: ZyquoAgent --run "<task>" [--model <id | provider/id>] [--mode manual|guarded|autonomous]93 [--workspace <path>] [--max-steps N] [--yes] [--allow-destructive]94 """9596 // MARK: - Options9798 private struct CLIUsageError: Error {99 let message: String100 }101102 private struct RunOptions {103 var task: String104 var modelSpec: String?105 var mode: SafetyMode = .guarded106 var workspacePath: String?107 var maxSteps: Int?108 var autoApprove = false109 var allowDestructive = false110 var mock = false111 /// Hidden eval flag — see the header comment.112 var compactionThreshold: Double?113 }114115 private static func parseRunOptions(_ arguments: [String]) -> Result<RunOptions, CLIUsageError> {116 func fail(_ message: String) -> Result<RunOptions, CLIUsageError> {117 .failure(CLIUsageError(message: message))118 }119120 var options = RunOptions(task: "")121 var index = 1122 var sawRunFlag = false123 let args = arguments124125 while index < args.count {126 let arg = args[index]127 func value(for flag: String) -> String? {128 guard index + 1 < args.count else { return nil }129 index += 1130 return args[index]131 }132 switch arg {133 case "--run":134 sawRunFlag = true135 guard let task = value(for: arg), !task.hasPrefix("--"), !task.isEmpty else {136 return fail("--run requires a task string.")137 }138 options.task = task139 case "--run-mock":140 sawRunFlag = true141 options.mock = true142 options.autoApprove = true // scripted, non-interactive by design143 if index + 1 < args.count, !args[index + 1].hasPrefix("--") {144 index += 1145 options.task = args[index]146 }147 if options.task.isEmpty {148 options.task = "Create a demo folder in the workspace with a shell command and verify it exists."149 }150 case "--model":151 guard let spec = value(for: arg) else { return fail("--model requires a model id.") }152 options.modelSpec = spec153 case "--mode":154 guard let raw = value(for: arg), let mode = SafetyMode(rawValue: raw) else {155 return fail("--mode must be manual, guarded, or autonomous.")156 }157 options.mode = mode158 case "--workspace":159 guard let path = value(for: arg) else { return fail("--workspace requires a path.") }160 options.workspacePath = path161 case "--max-steps":162 guard let raw = value(for: arg), let steps = Int(raw), steps > 0 else {163 return fail("--max-steps requires a positive integer.")164 }165 options.maxSteps = steps166 case "--compact-threshold":167 guard let raw = value(for: arg), let threshold = Double(raw),168 threshold > 0, threshold < 1 else {169 return fail("--compact-threshold requires a fraction strictly between 0 and 1.")170 }171 options.compactionThreshold = threshold172 case "--yes":173 options.autoApprove = true174 case "--allow-destructive":175 options.allowDestructive = true176 default:177 break // tolerate unrelated flags (e.g. process serial numbers)178 }179 index += 1180 }181182 guard sawRunFlag, !options.task.isEmpty else {183 return fail("No task given.")184 }185 return .success(options)186 }187188 // MARK: - Run orchestration189190 private static func runAgent(options: RunOptions) async -> Int32 {191 let ansi = Ansi()192193 // ---- Model + client + key -----------------------------------------194 let model: AIModel195 let client: any ProviderClient196 let apiKey: String197 if options.mock {198 model = MockProviderClient.model199 client = MockProviderClient()200 apiKey = "mock"201 } else {202 guard let resolved = resolveModel(spec: options.modelSpec) else {203 FileHandle.standardError.write(Data("No model matches “\(options.modelSpec ?? "<default>")”. Use --model <id> or <provider>/<id> from the shared catalog.\n".utf8))204 return 64205 }206 model = resolved207 guard model.agentCapable else {208 FileHandle.standardError.write(Data("\(model.displayName) is not in the agent-capable subset (docs/PROVIDER-REUSE.md §3) — it cannot run agent tasks reliably.\n".utf8))209 return 64210 }211 client = ProviderRegistry.client(for: model)212 guard let key = resolveAPIKey(for: model.provider) else {213 let names = environmentKeyNames(for: model.provider).joined(separator: " or ")214 FileHandle.standardError.write(Data("No API key for \(model.provider.displayName). Set \(names), or store one in the vault.\n".utf8))215 return 64216 }217 apiKey = key218 }219220 // ---- Workspace -----------------------------------------------------221 let workspace: WorkspaceManager222 do {223 if let path = options.workspacePath {224 let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL225 try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)226 workspace = try WorkspaceManager(existingAt: url)227 } else if options.mock {228 workspace = try WorkspaceManager.scratch(label: "mockrun")229 } else {230 workspace = try WorkspaceManager(taskTitle: options.task)231 }232 } catch {233 FileHandle.standardError.write(Data("Could not prepare the workspace: \(error.localizedDescription)\n".utf8))234 return 1235 }236237 // ---- Engine assembly ------------------------------------------------238 var configuration = AgentConfiguration.default239 if let maxSteps = options.maxSteps {240 configuration.loopGuard.maxSteps = maxSteps241 }242 if let threshold = options.compactionThreshold {243 // Scripted-eval override: force compaction within a small run.244 configuration.memory.compactionThreshold = threshold245 configuration.memory.keepRecentSteps = min(configuration.memory.keepRecentSteps, 2)246 configuration.memory.minStepsBetweenCompactions = min(configuration.memory.minStepsBetweenCompactions, 3)247 }248249 var executionConfiguration = ExecutionConfiguration.default250 if let timeout = configuration.perCommandTimeout {251 executionConfiguration.defaultTimeout = timeout252 }253 let executor = ExecutionService(configuration: executionConfiguration)254 let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl"))255 let approvals = CLIApprovalPresenter(256 autoApprove: options.autoApprove,257 allowDestructive: options.allowDestructive,258 ansi: ansi259 )260 // Mock runs use temp-rooted persistence so remembered rules never261 // touch the user's real policy-rules.json.262 let persistence: PersistenceService = options.mock263 ? PersistenceService(rootDirectory: FileManager.default.temporaryDirectory264 .appendingPathComponent("ZyquoAgent-mockrun-\(UUID().uuidString.prefix(8))"))265 : .shared266 let policy = PolicyEngine(mode: options.mode, approvals: approvals, persistence: persistence)267 let tools = ToolRegistry.standard(executor: executor)268 let loop = AgentLoop(269 model: model,270 client: client,271 apiKey: apiKey,272 tools: tools,273 policy: policy,274 audit: audit,275 workspace: workspace,276 configuration: configuration277 )278279 // ---- Graceful Ctrl-C / kill ------------------------------------------280 // SIGINT/SIGTERM cancel the run through AgentLoop.cancel(): the loop281 // kills any in-flight child process (SIGTERM→SIGKILL) and the282 // transcript records a `cancelled` outcome — the CLI must never die283 // abruptly and orphan a running command.284 signal(SIGINT, SIG_IGN)285 signal(SIGTERM, SIG_IGN)286 let signalSources: [DispatchSourceSignal] = [SIGINT, SIGTERM].map { number in287 let source = DispatchSource.makeSignalSource(signal: number, queue: .global())288 source.setEventHandler {289 Task { await loop.cancel() }290 }291 source.resume()292 return source293 }294 defer { signalSources.forEach { $0.cancel() } }295296 // ---- Banner ----------------------------------------------------------297 print(ansi.bold("Zyquo Agent") + " — headless run")298 print(" task: \(options.task)")299 print(" model: \(model.displayName) (\(model.provider.displayName))")300 print(" mode: \(options.mode.displayName)\(options.autoApprove ? " [--yes]" : "")")301 print(" workspace: \(workspace.root.path)")302303 // ---- Consume the event stream ---------------------------------------304 let renderer = CLIRenderer(ansi: ansi)305 var finalOutcome: AgentRunOutcome?306 do {307 for try await event in await loop.run(task: options.task) {308 renderer.render(event)309 switch event {310 case .guardTripped:311 if options.autoApprove {312 print(ansi.yellow(" Guard trip auto-answered with STOP (--yes runs never raise their own budgets)."))313 await loop.stop()314 } else {315 print(ansi.yellow(" [c] continue with raised budget / [s] stop > "), terminator: "")316 fflush(stdout)317 let answer = readLine()?.lowercased() ?? "s"318 if answer.hasPrefix("c") {319 await loop.resume(raisingBudget: true)320 } else {321 await loop.stop()322 }323 }324 case .runFinished(let outcome):325 finalOutcome = outcome326 default:327 break328 }329 }330 } catch {331 print(ansi.red("\nRun stream failed: \(error.localizedDescription)"))332 return 1333 }334335 renderer.printSummary(workspacePath: workspace.root.path)336337 if case .completed = finalOutcome {338 return 0339 }340 return 1341 }342343 // MARK: - Model & key resolution344345 /// Accepts a bare model id or "provider/id" ("anthropic/claude-sonnet-5").346 /// Bare ids that exist under several providers prefer the agent-capable,347 /// then recommended entry.348 private static func resolveModel(spec: String?) -> AIModel? {349 let catalog = ModelCatalogData.all350 guard let spec, !spec.isEmpty else {351 return catalog.first {352 $0.provider == AgentModelSupport.defaultModelProvider && $0.id == AgentModelSupport.defaultModelID353 } ?? catalog.first(where: \.agentCapable)354 }355 if let slash = spec.firstIndex(of: "/"),356 let provider = ProviderID(rawValue: String(spec[spec.startIndex..<slash])) {357 let id = String(spec[spec.index(after: slash)...])358 if let exact = catalog.first(where: { $0.provider == provider && $0.id == id }) {359 return exact360 }361 }362 let matches = catalog.filter { $0.id == spec }363 return matches.first(where: \.agentCapable)364 ?? matches.first(where: \.isRecommended)365 ?? matches.first366 }367368 /// Environment variable names checked (in order) for each provider,369 /// before falling back to the encrypted vault. Internal: the UI's370 /// RunController resolves keys the same way.371 static func environmentKeyNames(for provider: ProviderID) -> [String] {372 switch provider {373 case .openai: return ["OPENAI_API_KEY"]374 case .anthropic: return ["ANTHROPIC_API_KEY"]375 case .xai: return ["XAI_API_KEY"]376 case .mistral: return ["MISTRAL_API_KEY"]377 case .gemini: return ["GEMINI_API_KEY", "GOOGLE_API_KEY"]378 case .qwen: return ["QWEN_API_KEY", "DASHSCOPE_API_KEY"]379 case .deepseek: return ["DEEPSEEK_API_KEY"]380 case .kimi: return ["KIMI_API_KEY", "MOONSHOT_API_KEY"]381 case .perplexity: return ["PERPLEXITY_API_KEY"]382 case .together: return ["TOGETHER_API_KEY"]383 case .deepinfra: return ["DEEPINFRA_API_KEY"]384 case .cerebras: return ["CEREBRAS_API_KEY"]385 case .custom: return ["ZYQUO_CUSTOM_API_KEY"]386 }387 }388389 static func resolveAPIKey(for provider: ProviderID) -> String? {390 let environment = ProcessInfo.processInfo.environment391 for name in environmentKeyNames(for: provider) {392 if let value = environment[name], !value.isEmpty {393 return value394 }395 }396 return ((try? SecureKeyStore().key(for: provider)) ?? nil)397 }398}399400// MARK: - Approval presenter (stdin)401402/// Prints the approval card and reads the decision from stdin. With403/// `autoApprove` (--yes): mode-driven asks are approved, but the always-ask404/// class (destructive/elevated risk, any file access outside the workspace)405/// is auto-DENIED unless `allowDestructive` (--allow-destructive) is set —406/// a scripted run must never silently authorize a destructive action.407struct CLIApprovalPresenter: ApprovalPresenting {408 let autoApprove: Bool409 let allowDestructive: Bool410 let ansi: Ansi411412 func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution {413 printCard(for: action, risk: risk)414415 if autoApprove {416 let alwaysAskClass = risk.level == .destructive || risk.level == .elevated417 || action.kind == .fileWriteOutsideWorkspace418 || action.kind == .fileReadOutsideWorkspace419 if alwaysAskClass && !allowDestructive {420 print(ansi.red(" ✗ auto-DENIED under --yes: \(risk.reason) (pass --allow-destructive to permit)"))421 return .deny422 }423 print(ansi.green(" ✓ auto-approved (--yes)"))424 return .approve425 }426427 while true {428 print(ansi.bold(" [a]pprove / [e]dit / [d]eny > "), terminator: "")429 fflush(stdout)430 guard let answer = readLine()?.lowercased() else { return .deny }431 if answer.hasPrefix("a") { return .approve }432 if answer.hasPrefix("d") { return .deny }433 if answer.hasPrefix("e") {434 print(" edited payload > ", terminator: "")435 fflush(stdout)436 guard let edited = readLine(), !edited.trimmingCharacters(in: .whitespaces).isEmpty else {437 print(ansi.red(" empty edit — denied."))438 return .deny439 }440 return .approveEdited(edited)441 }442 }443 }444445 private func printCard(for action: ActionRequest, risk: RiskAssessment) {446 let kind: String447 switch action.kind {448 case .shellCommand: kind = "shell command"449 case .appleScript: kind = "AppleScript"450 case .fileWrite: kind = "file write (workspace)"451 case .fileWriteOutsideWorkspace: kind = "file write OUTSIDE the workspace"452 case .fileReadOutsideWorkspace: kind = "file read OUTSIDE the workspace"453 }454 print("")455 print(ansi.yellow(" ┌─ APPROVAL REQUIRED ─────────────────────────────"))456 print(ansi.yellow(" │ ") + "kind: \(kind)")457 print(ansi.yellow(" │ ") + "risk: \(risk.level.rawValue) — \(risk.reason)")458 print(ansi.yellow(" │ ") + "cwd: \(action.cwd.path)")459 for line in action.payload.split(separator: "\n", omittingEmptySubsequences: false) {460 print(ansi.yellow(" │ ") + ansi.bold(" \(line)"))461 }462 if let explanation = action.explanation, !explanation.isEmpty {463 print(ansi.yellow(" │ ") + "why: \(explanation)")464 }465 print(ansi.yellow(" └──────────────────────────────────────────────────"))466 }467}468469// MARK: - Live renderer470471/// Renders the AgentEvent stream to the terminal: step headers, streamed472/// text, dimmed thinking, tool chips with payloads, live stdout/stderr,473/// plan checklists, compactions, and the highlighted final answer.474final class CLIRenderer {475 private let ansi: Ansi476 private let startedAt = Date()477 private var stepsCompleted = 0478 private var totalInputTokens = 0479 private var totalOutputTokens = 0480 private var midLine = false481482 init(ansi: Ansi) {483 self.ansi = ansi484 }485486 func render(_ event: AgentEvent) {487 switch event {488 case .statusChanged(let status):489 if status == .compacting {490 breakLine()491 print(ansi.dim(" ⟳ compacting context…"))492 }493494 case .stepStarted(let step):495 breakLine()496 print("\n" + ansi.bold("── Step \(step.index) ") + ansi.dim(String(repeating: "─", count: 40)))497498 case .thinkingDelta(_, let delta):499 print(ansi.dim(delta), terminator: "")500 midLine = true501 fflush(stdout)502503 case .textDelta(_, let delta):504 print(delta, terminator: "")505 midLine = true506 fflush(stdout)507508 case .toolCallStreaming(_, _, _, let name):509 breakLine()510 print(ansi.violet(" ⚙ \(name) "), terminator: "")511 midLine = true512 fflush(stdout)513514 case .toolCallArgumentsDelta(_, _, let delta):515 print(ansi.dim(delta), terminator: "")516 midLine = true517 fflush(stdout)518519 case .toolCallStarted(_, let invocation):520 breakLine()521 print(ansi.violet(" ▶ \(invocation.call.name)") + ansi.dim(" \(truncate(invocation.call.argumentsJSON, to: 200))"))522523 case .toolOutput(_, _, let chunk):524 breakLine()525 switch chunk {526 case .stdout(let line): print(" │ \(line)")527 case .stderr(let line): print(ansi.red(" │ \(line)"))528 case .note(let line): print(ansi.dim(" · \(line)"))529 }530531 case .toolCallFinished(_, let invocation):532 breakLine()533 if let result = invocation.result {534 if result.isError {535 print(ansi.red(" ✘ \(invocation.call.name) failed: \(truncate(firstLine(of: result.content), to: 160))"))536 } else {537 print(ansi.green(" ✔ \(invocation.call.name)") + ansi.dim(" \(truncate(firstLine(of: result.content), to: 120))"))538 }539 }540541 case .stepCompleted(let step):542 breakLine()543 stepsCompleted = max(stepsCompleted, step.index)544 totalInputTokens += step.inputTokens ?? 0545 totalOutputTokens += step.outputTokens ?? 0546547 case .planUpdated(let plan):548 breakLine()549 print(ansi.bold(" Plan (\(plan.doneCount)/\(plan.items.count) done):"))550 for line in plan.rendered().split(separator: "\n") {551 print(" \(line)")552 }553554 case .guardTripped(let trip):555 breakLine()556 print(ansi.yellow("\n ⏸ LOOP GUARD [\(trip.reason.rawValue)] at step \(trip.stepIndex): \(trip.message)"))557558 case .compactionPerformed(let record):559 breakLine()560 print(ansi.dim(" ⟳ compacted \(record.summarizedSteps) step(s) through step \(record.throughStep): ~\(record.beforeTokens) → ~\(record.afterTokens) tokens"))561562 case .runFinished(let outcome):563 breakLine()564 switch outcome {565 case .completed(let finalAnswer):566 print("\n" + ansi.green(ansi.bold("✔ Task complete")))567 let answer = finalAnswer.trimmingCharacters(in: .whitespacesAndNewlines)568 if !answer.isEmpty {569 for line in answer.split(separator: "\n", omittingEmptySubsequences: false) {570 print(" \(line)")571 }572 }573 case .failed(let reason):574 print("\n" + ansi.red(ansi.bold("✘ Task failed")) + " — \(reason)")575 case .cancelled:576 print("\n" + ansi.yellow(ansi.bold("■ Task cancelled")))577 case .stoppedByUser(let reason):578 print("\n" + ansi.yellow(ansi.bold("■ Task stopped")) + " — \(reason)")579 }580 }581 }582583 func printSummary(workspacePath: String) {584 let seconds = Date().timeIntervalSince(startedAt)585 print(ansi.dim("\n steps: \(stepsCompleted) tokens: \(totalInputTokens) in / \(totalOutputTokens) out duration: \(String(format: "%.1f", seconds))s"))586 print(ansi.dim(" workspace: \(workspacePath)"))587 print(ansi.dim(" transcript: \(workspacePath)/.zyquo/transcript.json"))588 }589590 private func breakLine() {591 if midLine {592 print("")593 midLine = false594 }595 }596597 private func truncate(_ text: String, to limit: Int) -> String {598 text.count > limit ? String(text.prefix(limit)) + "…" : text599 }600601 private func firstLine(of text: String) -> String {602 text.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init) ?? text603 }604}605606// MARK: - ANSI colors607608/// Minimal ANSI styling, disabled when stdout is not a TTY or NO_COLOR is set.609struct Ansi: Sendable {610 let enabled: Bool611612 init() {613 self.enabled = isatty(STDOUT_FILENO) == 1614 && ProcessInfo.processInfo.environment["NO_COLOR"] == nil615 }616617 private func wrap(_ text: String, _ code: String) -> String {618 enabled ? "\u{1B}[\(code)m\(text)\u{1B}[0m" : text619 }620621 func bold(_ text: String) -> String { wrap(text, "1") }622 func dim(_ text: String) -> String { wrap(text, "2") }623 func red(_ text: String) -> String { wrap(text, "31") }624 func green(_ text: String) -> String { wrap(text, "32") }625 func yellow(_ text: String) -> String { wrap(text, "33") }626 func violet(_ text: String) -> String { wrap(text, "35") }627}628