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