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.3 KB · 713 lines swift
Raw Blame History
1//2//  RunController.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Owns the live run for one task: assembles the engine (provider client +9//  key, workspace, tools, policy gate with the UI approval presenter, audit10//  log, AgentLoop) and consumes the AsyncThrowingStream<AgentEvent>,11//  publishing everything the command-center UI renders — live step cards12//  (streaming text/thinking/tool arguments/tool output), the plan, budgets,13//  the pending approval, guard trips, compactions, the terminal feed, audit14//  entries, and workspace files. AgentEvents arrive off the main thread; the15//  consuming Task is MainActor-bound so every mutation happens on main.16//1718import Combine19import Foundation2021// MARK: - Live timeline model2223/// One tool invocation as it renders live inside a step card.24struct LiveInvocation: Identifiable {25    enum Phase {26        /// Arguments still streaming from the model.27        case streaming28        /// Cleared (or clearing) the policy gate and executing.29        case executing30        case finished31    }3233    var id: String34    var streamIndex: Int?35    var name: String36    /// Accumulating raw argument JSON (may be partial while streaming).37    var argumentsJSON: String38    var phase: Phase = .streaming39    var outputLines: [TerminalLine] = []40    var result: ToolResult?41    var exitCode: Int32?42    var policyDecision: PolicyDecisionRecord?4344    /// The human-facing payload: the shell command / script / path when the45    /// arguments parse, else the raw JSON.46    var displayPayload: String {47        guard let data = argumentsJSON.data(using: .utf8),48              let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {49            return argumentsJSON50        }51        for key in ["command", "script", "path"] {52            if let value = object[key] as? String { return value }53        }54        return argumentsJSON55    }56}5758/// One agent step as it renders live (mirrors AgentStep, mutable per event).59struct LiveStep: Identifiable {60    var id: UUID61    var index: Int62    var status: AgentStepStatus63    var thinking: String64    var text: String65    var invocations: [LiveInvocation]66    var startedAt: Date67    var finishedAt: Date?68    var inputTokens: Int?69    var outputTokens: Int?7071    /// A step with no tool calls whose run completed = the final answer.72    var isFinalAnswer: Bool { invocations.isEmpty && status == .completed && !text.isEmpty }7374    init(step: AgentStep) {75        self.id = step.id76        self.index = step.index77        self.status = step.status78        self.thinking = step.thinking ?? ""79        self.text = step.text80        self.invocations = step.toolInvocations.map { LiveInvocation(invocation: $0) }81        self.startedAt = step.startedAt82        self.finishedAt = step.finishedAt83        self.inputTokens = step.inputTokens84        self.outputTokens = step.outputTokens85    }86}8788extension LiveInvocation {89    /// Builds a finished invocation view from a persisted AgentToolInvocation90    /// (past-run re-rendering and stepCompleted reconciliation).91    init(invocation: AgentToolInvocation) {92        self.id = invocation.call.id93        self.streamIndex = nil94        self.name = invocation.call.name95        self.argumentsJSON = invocation.call.argumentsJSON96        self.phase = .finished97        self.result = invocation.result98        self.exitCode = invocation.exitCode99        self.policyDecision = invocation.policyDecision100    }101}102103/// One item of the live run timeline (step cards interleaved with104/// compaction notices, in event order).105enum RunEntry: Identifiable {106    case step(LiveStep)107    case compaction(CompactionRecord)108109    var id: UUID {110        switch self {111        case .step(let step): return step.id112        case .compaction(let record): return record.id113        }114    }115}116117/// One line of the Activity/Terminal drawer's live feed.118struct TerminalLine: Identifiable {119    enum Kind {120        case command121        case stdout122        case stderr123        case note124        /// cwd banner / run lifecycle marker.125        case meta126    }127128    let id = UUID()129    var kind: Kind130    var text: String131    var timestamp: Date = Date()132}133134// MARK: - RunController135136@MainActor137final class RunController: ObservableObject {138    // Live run state139    @Published private(set) var entries: [RunEntry] = []140    @Published private(set) var plan: TaskPlan?141    @Published private(set) var runStatus: AgentRunStatus?142    @Published private(set) var outcome: AgentRunOutcome?143    @Published private(set) var pendingApproval: PendingApproval?144    @Published private(set) var guardTrip: LoopGuardTrip?145    @Published private(set) var isRunning = false146    @Published private(set) var lastError: String?147148    // Budgets & usage (Plan panel meters)149    @Published private(set) var tokensUsed = 0150    @Published private(set) var stepsUsed = 0151    @Published private(set) var runStartedAt: Date?152    @Published private(set) var loopGuardConfiguration = LoopGuardConfiguration.default153154    // Drawer feeds155    @Published private(set) var terminalLines: [TerminalLine] = []156    @Published private(set) var auditEntries: [AuditEntry] = []157    @Published private(set) var workspaceFiles: [WorkspaceFileEntry] = []158159    // Info popover160    @Published private(set) var systemPromptPreview: String?161162    let taskID: AgentTask.ID163    private let store: TaskStore164    private let policyPersistence: PersistenceService165    /// App-wide agent settings (budgets, timeouts, persona-independent166    /// tunables). Nil in headless smoke tests → AgentConfiguration.default.167    private weak var settings: AgentSettingsStore?168169    private var loop: AgentLoop?170    private var policy: PolicyEngine?171    private var audit: AuditLog?172    private var workspace: WorkspaceManager?173    private var consumeTask: Task<Void, Never>?174    /// Retained for the post-run auto-title call (app runs only).175    private var titleContext: (model: AIModel, client: any ProviderClient, apiKey: String)?176    private var titleTask: Task<Void, Never>?177178    /// Terminal feed cap — append-only ring so hours-long runs stay light.179    private static let terminalLineCap = 4000180181    init(182        taskID: AgentTask.ID,183        store: TaskStore,184        policyPersistence: PersistenceService = .shared,185        settings: AgentSettingsStore? = nil186    ) {187        self.taskID = taskID188        self.store = store189        self.policyPersistence = policyPersistence190        self.settings = settings191        if let workspaceURL = store.task(id: taskID)?.workspaceURL,192           let attached = try? WorkspaceManager(existingAt: workspaceURL) {193            self.workspace = attached194            self.audit = AuditLog(fileURL: attached.internalDirectory.appendingPathComponent("audit.jsonl"))195            self.plan = store.task(id: taskID)?.messages.last(where: { $0.plan != nil })?.plan196            refreshWorkspaceState()197        }198    }199200    private var task: AgentTask? { store.task(id: taskID) }201202    // MARK: - Starting a run203204    /// Starts a run for the given prompt. `client`/`apiKey` are injectable205    /// for the offline UI smoke test; app runs resolve them from the206    /// ProviderRegistry and the encrypted vault / environment.207    func start(208        prompt: String,209        model: AIModel,210        persona: Persona? = nil,211        client injectedClient: (any ProviderClient)? = nil,212        apiKey injectedKey: String? = nil213    ) {214        guard !isRunning, var task else { return }215        let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)216        guard !trimmedPrompt.isEmpty else { return }217218        guard model.capabilities.tools else {219            lastError = "\(model.displayName) does not support tool calling and cannot run agent tasks. Pick an agent-capable model."220            return221        }222223        let client = injectedClient ?? ProviderRegistry.client(for: model)224        let apiKey: String225        if let injectedKey {226            apiKey = injectedKey227        } else if let resolved = AgentCLI.resolveAPIKey(for: model.provider) {228            apiKey = resolved229        } else {230            lastError = ProviderError.missingAPIKey(model.provider).localizedDescription231            return232        }233234        // Workspace: reattach the task's existing one, or create it now.235        let workspace: WorkspaceManager236        do {237            if let url = task.workspaceURL {238                workspace = try WorkspaceManager(existingAt: url)239            } else {240                let title = task.title == "New Task" ? AgentTask.title(fromPrompt: trimmedPrompt) : task.title241                workspace = try WorkspaceManager(taskTitle: title)242                task.workspacePath = workspace.root.path243            }244        } catch {245            lastError = "Could not prepare the workspace: \(error.localizedDescription)"246            return247        }248        self.workspace = workspace249250        // First prompt names the task.251        if task.title == "New Task" {252            task.title = AgentTask.title(fromPrompt: trimmedPrompt)253        }254        task.modelID = model.id255        task.providerID = model.provider256        task.messages.append(TaskMessage(kind: .user, text: trimmedPrompt))257        task.status = .planning258        store.update(task)259260        // Engine assembly — every action passes the policy gate + audit log.261        let presenter = UIApprovalPresenter()262        presenter.onRequest = { [weak self] pending in263            Task { @MainActor in264                self?.presentApproval(pending)265            }266        }267        let policy = PolicyEngine(mode: task.safetyMode, approvals: presenter, persistence: policyPersistence)268        let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl"))269270        // Settings › Agent budgets/tunables + the persona's prompt addendum.271        let personaAddendum = persona?.systemPrompt272        let configuration = settings?.agentConfiguration(personaAddendum: personaAddendum)273            ?? { var c = AgentConfiguration.default; c.personaAddendum = personaAddendum; return c }()274        var executionConfiguration = ExecutionConfiguration.default275        if let timeout = configuration.perCommandTimeout {276            executionConfiguration.defaultTimeout = timeout277        }278        self.loopGuardConfiguration = configuration.loopGuard279280        let executor = ExecutionService(configuration: executionConfiguration)281        let tools = ToolRegistry.standard(executor: executor)282        let loop = AgentLoop(283            model: model,284            client: client,285            apiKey: apiKey,286            tools: tools,287            policy: policy,288            audit: audit,289            workspace: workspace,290            configuration: configuration291        )292        self.policy = policy293        self.audit = audit294        self.loop = loop295        // Auto-title uses the injected client only for real runs — smoke296        // tests inject a scripted client and must stay deterministic.297        self.titleContext = injectedClient == nil ? (model, client, apiKey) : nil298        self.systemPromptPreview = AgentSystemPrompt.build(299            workspacePath: workspace.root.path,300            toolNames: tools.toolNames,301            safetyMode: task.safetyMode,302            personaAddendum: personaAddendum303        )304305        // Reset live state.306        entries = []307        outcome = nil308        guardTrip = nil309        pendingApproval = nil310        lastError = nil311        tokensUsed = 0312        stepsUsed = 0313        isRunning = true314        runStartedAt = Date()315        appendTerminal(.meta, "▶ run started — \(model.displayName) · \(task.safetyMode.displayName) mode")316        appendTerminal(.meta, "cwd \(workspace.root.path)")317318        // Consume the event stream on the MainActor (the stream itself is319        // produced inside the AgentLoop actor; only the handling hops here).320        consumeTask = Task { [weak self] in321            do {322                for try await event in await loop.run(task: trimmedPrompt) {323                    self?.handle(event)324                }325            } catch {326                self?.finish(with: .failed(reason: error.localizedDescription))327            }328        }329    }330331    // MARK: - Run control332333    /// Stops the run (⌘. / Stop button): kills the in-flight model stream334    /// and any executing process.335    func cancel() {336        pendingApproval?.resolve(.deny)337        pendingApproval = nil338        let loop = loop339        Task { await loop?.cancel() }340    }341342    /// Answers a guard trip with "continue" (optionally raising budgets).343    func resumeAfterTrip(raisingBudget: Bool = true) {344        guardTrip = nil345        updateTaskStatus(.running)346        let loop = loop347        Task { await loop?.resume(raisingBudget: raisingBudget) }348    }349350    /// Answers a guard trip with "stop".351    func stopAfterTrip() {352        guardTrip = nil353        let loop = loop354        Task { await loop?.stop() }355    }356357    /// Resolves the pending approval card.358    func resolveApproval(_ resolution: ApprovalResolution) {359        guard let pending = pendingApproval else { return }360        pendingApproval = nil361        mutateLastStep { step in362            if step.status == .awaitingApproval { step.status = .executing }363        }364        if isRunning {365            updateTaskStatus(.running)366        }367        pending.resolve(resolution)368    }369370    /// Switches the safety mode for this task (live runs switch immediately).371    func setSafetyMode(_ mode: SafetyMode) {372        guard var task, task.safetyMode != mode else { return }373        task.safetyMode = mode374        store.update(task, touch: false)375        let policy = policy376        Task { await policy?.setMode(mode) }377    }378379    // MARK: - Event handling (MainActor)380381    private func handle(_ event: AgentEvent) {382        switch event {383        case .statusChanged(let status):384            runStatus = status385            switch status {386            case .preparing:387                updateTaskStatus(.planning)388            case .running, .executingTools, .compacting:389                if pendingApproval == nil { updateTaskStatus(.running) }390            case .awaitingUser:391                updateTaskStatus(.awaitingInput)392            case .finished:393                break // runFinished carries the outcome394            }395396        case .stepStarted(let step):397            entries.append(.step(LiveStep(step: step)))398            stepsUsed = max(stepsUsed, step.index)399400        case .thinkingDelta(let stepID, let delta):401            mutateStep(id: stepID) { $0.thinking += delta }402403        case .textDelta(let stepID, let delta):404            mutateStep(id: stepID) { $0.text += delta }405406        case .toolCallStreaming(let stepID, let index, let id, let name):407            mutateStep(id: stepID) { step in408                var invocation = LiveInvocation(id: id, streamIndex: index, name: name, argumentsJSON: "")409                invocation.phase = .streaming410                step.invocations.append(invocation)411            }412413        case .toolCallArgumentsDelta(let stepID, let index, let delta):414            mutateStep(id: stepID) { step in415                if let i = step.invocations.lastIndex(where: { $0.streamIndex == index }) {416                    step.invocations[i].argumentsJSON += delta417                }418            }419420        case .toolCallStarted(let stepID, let invocation):421            mutateStep(id: stepID) { step in422                if let i = step.invocations.firstIndex(where: { $0.id == invocation.call.id }) {423                    step.invocations[i].argumentsJSON = invocation.call.argumentsJSON424                    step.invocations[i].phase = .executing425                } else {426                    var live = LiveInvocation(427                        id: invocation.call.id,428                        streamIndex: nil,429                        name: invocation.call.name,430                        argumentsJSON: invocation.call.argumentsJSON431                    )432                    live.phase = .executing433                    step.invocations.append(live)434                }435                step.status = .executing436            }437            if let payload = payloadPreview(of: invocation.call) {438                appendTerminal(.command, "$ \(payload)")439            }440441        case .toolOutput(let stepID, let callID, let chunk):442            let line: TerminalLine443            switch chunk {444            case .stdout(let text): line = TerminalLine(kind: .stdout, text: text)445            case .stderr(let text): line = TerminalLine(kind: .stderr, text: text)446            case .note(let text): line = TerminalLine(kind: .note, text: text)447            }448            mutateStep(id: stepID) { step in449                if let i = step.invocations.firstIndex(where: { $0.id == callID }) {450                    step.invocations[i].outputLines.append(line)451                }452            }453            appendTerminal(line.kind, line.text)454455        case .toolCallFinished(let stepID, let invocation):456            mutateStep(id: stepID) { step in457                if let i = step.invocations.firstIndex(where: { $0.id == invocation.call.id }) {458                    step.invocations[i].phase = .finished459                    step.invocations[i].result = invocation.result460                    step.invocations[i].exitCode = invocation.exitCode461                    step.invocations[i].policyDecision = invocation.policyDecision462                } else {463                    step.invocations.append(LiveInvocation(invocation: invocation))464                }465            }466467        case .stepCompleted(let step):468            mutateStep(id: step.id) { live in469                live.status = step.status470                live.finishedAt = step.finishedAt471                live.inputTokens = step.inputTokens472                live.outputTokens = step.outputTokens473                if !step.text.isEmpty { live.text = step.text }474                if let thinking = step.thinking { live.thinking = thinking }475                for invocation in step.toolInvocations {476                    if let i = live.invocations.firstIndex(where: { $0.id == invocation.call.id }) {477                        live.invocations[i].result = invocation.result478                        live.invocations[i].exitCode = invocation.exitCode479                        live.invocations[i].policyDecision = invocation.policyDecision480                        live.invocations[i].phase = .finished481                    }482                }483            }484            tokensUsed += (step.inputTokens ?? 0) + (step.outputTokens ?? 0)485            refreshWorkspaceState()486487        case .planUpdated(let updated):488            plan = updated489490        case .guardTripped(let trip):491            guardTrip = trip492            appendTerminal(.meta, "⏸ loop guard [\(trip.reason.rawValue)] — \(trip.message)")493494        case .compactionPerformed(let record):495            entries.append(.compaction(record))496            appendTerminal(.meta, "⟳ compacted \(record.summarizedSteps) step(s): ~\(record.beforeTokens) → ~\(record.afterTokens) tokens")497498        case .runFinished(let outcome):499            finish(with: outcome)500        }501    }502503    private func presentApproval(_ pending: PendingApproval) {504        pendingApproval = pending505        mutateLastStep { $0.status = .awaitingApproval }506        updateTaskStatus(.awaitingApproval)507        appendTerminal(.meta, "⚠ approval required: \(pending.action.payload)")508    }509510    private func finish(with outcome: AgentRunOutcome) {511        self.outcome = outcome512        isRunning = false513        consumeTask = nil514        loop = nil515516        // Fold the finished run into the task's persisted history so past517        // runs re-render exactly, then clear the live timeline.518        var steps: [AgentStep] = []519        if let workspaceURL = task?.workspaceURL,520           let transcript = Transcript.load(from: workspaceURL) {521            steps = transcript.steps522        }523524        let finalText: String525        let status: AgentTaskStatus526        switch outcome {527        case .completed(let answer):528            finalText = answer529            status = .done530            appendTerminal(.meta, "✔ run completed")531        case .failed(let reason):532            finalText = reason533            status = .failed534            appendTerminal(.meta, "✘ run failed — \(reason)")535        case .cancelled:536            finalText = "Run cancelled."537            status = .idle538            appendTerminal(.meta, "■ run cancelled")539        case .stoppedByUser(let reason):540            finalText = "Run stopped — \(reason)"541            status = .idle542            appendTerminal(.meta, "■ run stopped — \(reason)")543        }544545        if var task {546            task.messages.append(TaskMessage(547                kind: .agentRun,548                text: finalText,549                steps: steps,550                plan: plan,551                outcome: outcome552            ))553            task.status = status554            store.update(task)555        }556        entries = []557        pendingApproval = nil558        guardTrip = nil559        refreshWorkspaceState()560        refreshAudit()561562        if case .completed = outcome {563            generateTitleIfStillDefault()564        }565    }566567    // MARK: - Auto-title568569    /// After a successful run, if the title is still the prompt-derived570    /// default, fire a cheap background call (the task's own provider/model,571    /// ≤20 tokens) to produce a 4–6 word title. Silent failure by design.572    private func generateTitleIfStillDefault() {573        guard titleTask == nil, let context = titleContext, let task else { return }574        guard let firstPrompt = task.messages.first(where: { $0.kind == .user })?.text,575              task.title == AgentTask.title(fromPrompt: firstPrompt) else { return }576        let request = ChatRequest(577            model: context.model,578            systemPrompt: "You write concise task titles.",579            messages: [Message(580                role: .user,581                text: "Summarize this task as a 4-6 word title. Reply with the title only — no quotes, no trailing punctuation.\n\nTask: \(firstPrompt.prefix(600))"582            )],583            parameters: ChatParameters(maxTokens: 20),584            stream: false585        )586        let taskID = taskID587        let store = store588        titleTask = Task { [weak self] in589            defer { self?.titleTask = nil }590            guard let reply = try? await context.client.complete(request, apiKey: context.apiKey) else { return }591            let title = reply.text592                .trimmingCharacters(in: .whitespacesAndNewlines)593                .trimmingCharacters(in: CharacterSet(charactersIn: "\"'“”.\n"))594            guard !title.isEmpty, title.count <= 80, !title.contains("\n") else { return }595            store.rename(taskID, to: title)596        }597    }598599    // MARK: - Drawer refresh600601    /// Reloads the Files tab (agent-touched files with badges).602    func refreshWorkspaceState() {603        guard let workspace else { return }604        Task { [weak self] in605            await workspace.refreshScan()606            let files = await workspace.files()607            self?.workspaceFiles = files608        }609    }610611    /// Reloads the Audit tab from the workspace's append-only JSONL log.612    func refreshAudit() {613        guard let audit else { return }614        Task { [weak self] in615            let entries = await audit.entries()616            self?.auditEntries = entries617        }618    }619620    /// The workspace root, when one exists (Files tab, workspace chip).621    var workspaceRoot: URL? { workspace?.root ?? task?.workspaceURL }622623    /// Renames a plan item from the Plan panel. Display-side only: the agent624    /// re-reads the plan on its next `update_plan` call, and the edit is625    /// carried in the plan attached to the run's history entry.626    func renamePlanItem(id: UUID, to title: String) {627        guard var plan else { return }628        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)629        guard !trimmed.isEmpty,630              let index = plan.items.firstIndex(where: { $0.id == id }) else { return }631        plan.items[index].title = trimmed632        plan.updatedAt = Date()633        self.plan = plan634    }635636    // MARK: - Helpers637638    private func mutateStep(id: UUID, _ mutate: (inout LiveStep) -> Void) {639        for index in entries.indices.reversed() {640            if case .step(var step) = entries[index], step.id == id {641                mutate(&step)642                entries[index] = .step(step)643                return644            }645        }646    }647648    private func mutateLastStep(_ mutate: (inout LiveStep) -> Void) {649        for index in entries.indices.reversed() {650            if case .step(var step) = entries[index] {651                mutate(&step)652                entries[index] = .step(step)653                return654            }655        }656    }657658    private func updateTaskStatus(_ status: AgentTaskStatus) {659        store.setStatus(status, for: taskID)660    }661662    private func appendTerminal(_ kind: TerminalLine.Kind, _ text: String) {663        terminalLines.append(TerminalLine(kind: kind, text: text))664        if terminalLines.count > Self.terminalLineCap {665            terminalLines.removeFirst(terminalLines.count - Self.terminalLineCap)666        }667    }668669    private func payloadPreview(of call: ToolCall) -> String? {670        guard let arguments = call.argumentsDictionary else { return call.name }671        for key in ["command", "script", "path"] {672            if let value = arguments[key] as? String {673                return call.name == "bash" ? value : "\(call.name): \(value)"674            }675        }676        return call.name677    }678}679680// MARK: - RunHub681682/// Keeps one RunController per task alive for the app session, so a run keeps683/// streaming (and stays cancellable) while the user browses other tasks.684@MainActor685final class RunHub: ObservableObject {686    private var controllers: [AgentTask.ID: RunController] = [:]687    private let store: TaskStore688    private let settings: AgentSettingsStore?689690    init(store: TaskStore, settings: AgentSettingsStore? = nil) {691        self.store = store692        self.settings = settings693    }694695    func controller(for taskID: AgentTask.ID) -> RunController {696        if let existing = controllers[taskID] { return existing }697        let controller = RunController(taskID: taskID, store: store, settings: settings)698        controllers[taskID] = controller699        return controller700    }701702    /// Drops the controller for a deleted task (cancelling any live run).703    func remove(taskID: AgentTask.ID) {704        controllers[taskID]?.cancel()705        controllers[taskID] = nil706    }707708    /// The controller of any task currently running (menu-bar/⌘. targets).709    var runningControllers: [RunController] {710        controllers.values.filter(\.isRunning)711    }712}713