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// 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