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%
7.9 KB · 190 lines swift
Raw Blame History
1//2//  Planner.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The externalized todo list (docs/AGENT-RESEARCH.md §5.1): the model9//  creates and maintains the plan by calling the `update_plan` TOOL — the10//  reliable pattern (Claude Code's TodoWrite) versus parsing a plan out of11//  prose. `update_plan` is intercepted by AgentLoop and handled here; it is12//  NOT a side effect, so it never passes the PolicyEngine and never reaches13//  the ToolRegistry. The plan persists to `.zyquo/plan.json` on every change14//  and is re-injected into context after each compaction, so it survives by15//  construction (§3.3 rule 1).16//1718import Foundation1920/// State of one plan item, mirrored by the Plan panel in Phase 6.21enum PlanItemStatus: String, Codable, Sendable {22    case pending23    case active24    case done25    case failed26}2728/// One checklist entry of the task plan.29struct PlanItem: Codable, Identifiable, Hashable, Sendable {30    var id: UUID = UUID()31    var title: String32    var status: PlanItemStatus = .pending33    /// Short model-provided note (why it failed, what changed…).34    var note: String?35}3637/// The live task plan the agent maintains.38struct TaskPlan: Codable, Hashable, Sendable {39    var items: [PlanItem] = []40    var updatedAt: Date = Date()4142    var doneCount: Int { items.filter { $0.status == .done }.count }43    var failedCount: Int { items.filter { $0.status == .failed }.count }4445    /// Human-readable checklist (context injection, CLI, exports).46    func rendered() -> String {47        guard !items.isEmpty else { return "(empty plan)" }48        return items.map { item in49            let box: String50            switch item.status {51            case .pending: box = "[ ]"52            case .active: box = "[>]"53            case .done: box = "[x]"54            case .failed: box = "[!]"55            }56            let note = item.note.map { " — \($0)" } ?? ""57            return "\(box) \(item.title)\(note)"58        }.joined(separator: "\n")59    }60}6162/// Owns the plan for one run: applies `update_plan` calls, persists to63/// `.zyquo/plan.json`, and renders the plan for context injection. Plain64/// struct — mutated only inside the AgentLoop actor.65struct Planner {66    /// Wire name of the internal plan tool (intercepted by AgentLoop).67    static let toolName = "update_plan"6869    /// The `update_plan` spec offered to the model alongside the real tools.70    static let toolSpec = ToolSpec(71        name: toolName,72        description: """73            Create or update your task plan (a short ordered checklist the \74            user watches live). Call this FIRST on any non-trivial task to \75            draft the plan, then again whenever an item's status changes or \76            the approach changes — resend the COMPLETE list every time (it \77            replaces the previous plan). Keep exactly one item "active" \78            while working; mark items "done" immediately when verified and \79            "failed" (with a note) when abandoned. This tool only records \80            the plan — it has no other effect and needs no approval.81            """,82        parametersJSONSchema: #"""83            {"type":"object","properties":{"items":{"type":"array","description":"The complete current plan, in execution order.","items":{"type":"object","properties":{"title":{"type":"string","description":"Short imperative step description."},"status":{"type":"string","enum":["pending","active","done","failed"],"description":"Current state of this step."},"note":{"type":"string","description":"Optional short note (failure reason, change of approach…)."}},"required":["title","status"]}}},"required":["items"]}84            """#85    )8687    private(set) var plan: TaskPlan?88    private let fileURL: URL8990    /// `workspaceRoot/.zyquo/plan.json`; reattaching an existing workspace91    /// reloads its persisted plan.92    init(workspaceRoot: URL) {93        self.fileURL = workspaceRoot94            .appendingPathComponent(".zyquo")95            .appendingPathComponent("plan.json")96        if let data = try? Data(contentsOf: fileURL),97           let existing = try? Self.decoder.decode(TaskPlan.self, from: data) {98            self.plan = existing99        }100    }101102    /// Applies one `update_plan` call: parses the items, preserves stable103    /// item IDs by position where titles match, persists, and returns the104    /// ToolResult to thread back plus whether the plan actually changed105    /// (LoopGuard progress signal).106    mutating func apply(call: ToolCall) -> (result: ToolResult, changed: Bool) {107        guard let arguments = call.argumentsDictionary,108              let rawItems = arguments["items"] as? [[String: Any]] else {109            return (ToolResult(110                toolCallID: call.id,111                content: "update_plan: expected {\"items\":[{\"title\":…,\"status\":…}]} — arguments were not a valid items array. Re-issue with the full plan.",112                isError: true113            ), false)114        }115116        var newItems: [PlanItem] = []117        for (index, raw) in rawItems.enumerated() {118            guard let title = raw["title"] as? String, !title.isEmpty else {119                return (ToolResult(120                    toolCallID: call.id,121                    content: "update_plan: item \(index + 1) is missing a non-empty `title`.",122                    isError: true123                ), false)124            }125            let status = (raw["status"] as? String).flatMap(PlanItemStatus.init(rawValue:)) ?? .pending126            let note = raw["note"] as? String127            // Keep the item ID stable when the same title sits at the same128            // position (the Plan panel animates state changes, not churn).129            let previous = plan?.items.indices.contains(index) == true ? plan?.items[index] : nil130            let id = (previous?.title == title) ? previous!.id : UUID()131            newItems.append(PlanItem(id: id, title: title, status: status, note: note?.isEmpty == true ? nil : note))132        }133134        func signature(_ items: [PlanItem]) -> String {135            items.map { "\($0.title)|\($0.status.rawValue)|\($0.note ?? "")" }.joined(separator: "\n")136        }137        let changed = plan.map { signature($0.items) != signature(newItems) } ?? true138139        plan = TaskPlan(items: newItems, updatedAt: Date())140        persist()141142        let counts = statusCounts(of: newItems)143        return (ToolResult(144            toolCallID: call.id,145            content: "Plan recorded (\(newItems.count) item\(newItems.count == 1 ? "" : "s"): \(counts))."146        ), changed)147    }148149    /// The plan rendered for context re-injection after compaction; nil when150    /// no plan exists yet.151    func renderedForContext() -> String? {152        plan?.rendered()153    }154155    // MARK: - Helpers156157    private func statusCounts(of items: [PlanItem]) -> String {158        var counts: [PlanItemStatus: Int] = [:]159        for item in items { counts[item.status, default: 0] += 1 }160        return [PlanItemStatus.done, .active, .pending, .failed]161            .compactMap { status in counts[status].map { "\($0) \(status.rawValue)" } }162            .joined(separator: ", ")163    }164165    private func persist() {166        guard let plan else { return }167        do {168            try FileManager.default.createDirectory(169                at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true170            )171            try Self.encoder.encode(plan).write(to: fileURL, options: .atomic)172        } catch {173            FileHandle.standardError.write(Data("Planner: plan.json save failed: \(error)\n".utf8))174        }175    }176177    private static let encoder: JSONEncoder = {178        let encoder = JSONEncoder()179        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]180        encoder.dateEncodingStrategy = .iso8601181        return encoder182    }()183184    private static let decoder: JSONDecoder = {185        let decoder = JSONDecoder()186        decoder.dateDecodingStrategy = .iso8601187        return decoder188    }()189}190