phase2: DesignSystem components/appearance (violet accents), Tool protocol, AgentStep, PolicyEngine+AuditLog skeletons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 6 changed files with +633 and −0
added
Sources/ZyquoAgent/Agent/AgentStep.swift
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +// | |
| 2 | +// AgentStep.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// One iteration of the plan→act→observe→reflect loop: the assistant's thought | |
| 9 | +// and text, the tool call(s) it issued, and the observed result(s). Steps are | |
| 10 | +// the unit the UI renders as cards, the Transcript persists, and the | |
| 11 | +// MemoryManager compacts. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +/// Lifecycle of a single step. | |
| 17 | +enum AgentStepStatus: String, Codable, Sendable { | |
| 18 | + /// Model output still streaming in. | |
| 19 | + case streaming | |
| 20 | + /// Tool call held at the policy gate, waiting for the user. | |
| 21 | + case awaitingApproval | |
| 22 | + /// Tool call(s) executing. | |
| 23 | + case executing | |
| 24 | + case completed | |
| 25 | + case failed | |
| 26 | + /// User denied the action or cancelled mid-step. | |
| 27 | + case cancelled | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// One executed (or in-flight) tool call inside a step, with its observation. | |
| 31 | +struct AgentToolInvocation: Codable, Identifiable, Sendable { | |
| 32 | + var id: String { call.id } | |
| 33 | + var call: ToolCall | |
| 34 | + var result: ToolResult? | |
| 35 | + /// Exit code for process-backed tools (bash, osascript). | |
| 36 | + var exitCode: Int32? | |
| 37 | + /// How the policy gate resolved this action. | |
| 38 | + var policyDecision: PolicyDecisionRecord? | |
| 39 | + var startedAt: Date? | |
| 40 | + var finishedAt: Date? | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// Snapshot of the gate's ruling on an action, kept for the step card and audit. | |
| 44 | +struct PolicyDecisionRecord: Codable, Sendable { | |
| 45 | + enum Ruling: String, Codable, Sendable { | |
| 46 | + case autoAllowed | |
| 47 | + case approvedByUser | |
| 48 | + case editedAndApproved | |
| 49 | + case denied | |
| 50 | + } | |
| 51 | + var ruling: Ruling | |
| 52 | + var riskLabel: String? | |
| 53 | + /// Explanation shown on the approval card. | |
| 54 | + var rationale: String? | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// One iteration of the agent loop. | |
| 58 | +struct AgentStep: Codable, Identifiable, Sendable { | |
| 59 | + var id: UUID = UUID() | |
| 60 | + /// 1-based position in the run. | |
| 61 | + var index: Int | |
| 62 | + var status: AgentStepStatus = .streaming | |
| 63 | + /// Reasoning-model thinking (collapsible in UI); nil for non-reasoning models. | |
| 64 | + var thinking: String? | |
| 65 | + /// The assistant's visible text for this turn (thought line and/or final answer). | |
| 66 | + var text: String | |
| 67 | + var toolInvocations: [AgentToolInvocation] = [] | |
| 68 | + var startedAt: Date = Date() | |
| 69 | + var finishedAt: Date? | |
| 70 | + /// Tokens consumed by this step's model turn (for LoopGuard budgets). | |
| 71 | + var inputTokens: Int? | |
| 72 | + var outputTokens: Int? | |
| 73 | + | |
| 74 | + /// True when the model produced a final answer (no tool calls) — the loop's | |
| 75 | + /// termination signal. | |
| 76 | + var isFinal: Bool { toolInvocations.isEmpty && status == .completed } | |
| 77 | +} | |
added
Sources/ZyquoAgent/DesignSystem/AppearanceStore.swift
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +// | |
| 2 | +// AppearanceStore.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// User appearance preferences: theme mode, accent choice, chat font size. | |
| 9 | +// Persisted to settings.json via PersistenceService. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +/// Accent color choices offered in Settings → Appearance. Violet is the | |
| 15 | +/// Agent flagship default; alternates keep the same subtle-tint relationship | |
| 16 | +/// (graphite, sky, emerald, amber per the Phase 4 spec). | |
| 17 | +enum AccentChoice: String, Codable, CaseIterable, Identifiable { | |
| 18 | + case violet | |
| 19 | + case graphite | |
| 20 | + case sky | |
| 21 | + case emerald | |
| 22 | + case amber | |
| 23 | + | |
| 24 | + var id: String { rawValue } | |
| 25 | + | |
| 26 | + var displayName: String { | |
| 27 | + switch self { | |
| 28 | + case .violet: return "Violet" | |
| 29 | + case .graphite: return "Graphite" | |
| 30 | + case .sky: return "Sky" | |
| 31 | + case .emerald: return "Emerald" | |
| 32 | + case .amber: return "Amber" | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// (light, dark) accent hex pair. | |
| 37 | + var accentHex: (UInt32, UInt32) { | |
| 38 | + switch self { | |
| 39 | + case .violet: return (0x7A5AF0, 0x9B82F6) | |
| 40 | + case .graphite: return (0x5B6270, 0x8A93A6) | |
| 41 | + case .sky: return (0x4E6AF0, 0x6D84F5) | |
| 42 | + case .emerald: return (0x1D9A6B, 0x3BB58A) | |
| 43 | + case .amber: return (0xC77D1D, 0xDD9B45) | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// (light, dark) subtle-tint hex pair (selected rows, user bubbles). | |
| 48 | + var subtleHex: (UInt32, UInt32) { | |
| 49 | + switch self { | |
| 50 | + case .violet: return (0xEFEBFD, 0x2E2749) | |
| 51 | + case .graphite: return (0xEEF0F4, 0x2A2E3A) | |
| 52 | + case .sky: return (0xEBEFFD, 0x28304C) | |
| 53 | + case .emerald: return (0xE6F6EF, 0x1F3A2F) | |
| 54 | + case .amber: return (0xFBF1E3, 0x3D3222) | |
| 55 | + } | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +enum ThemeMode: String, Codable, CaseIterable, Identifiable { | |
| 60 | + case system, light, dark | |
| 61 | + var id: String { rawValue } | |
| 62 | + | |
| 63 | + var displayName: String { | |
| 64 | + switch self { | |
| 65 | + case .system: return "System" | |
| 66 | + case .light: return "Light" | |
| 67 | + case .dark: return "Dark" | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + var colorScheme: ColorScheme? { | |
| 72 | + switch self { | |
| 73 | + case .system: return nil | |
| 74 | + case .light: return .light | |
| 75 | + case .dark: return .dark | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// Observable appearance preferences, persisted as part of app settings. | |
| 81 | +@MainActor | |
| 82 | +final class AppearanceStore: ObservableObject { | |
| 83 | + struct Stored: Codable { | |
| 84 | + var themeMode: ThemeMode = .system | |
| 85 | + var accent: AccentChoice = .violet | |
| 86 | + var chatFontSize: Double = 13.5 | |
| 87 | + } | |
| 88 | + | |
| 89 | + static let fileName = "appearance.json" | |
| 90 | + | |
| 91 | + @Published var themeMode: ThemeMode { didSet { save() } } | |
| 92 | + @Published var accent: AccentChoice { didSet { save() } } | |
| 93 | + /// Chat body font size, clamped to the spec's 12–18pt range. | |
| 94 | + @Published var chatFontSize: Double { | |
| 95 | + didSet { | |
| 96 | + let clamped = min(18, max(12, chatFontSize)) | |
| 97 | + if clamped != chatFontSize { chatFontSize = clamped } | |
| 98 | + save() | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + private let persistence: PersistenceService | |
| 103 | + | |
| 104 | + init(persistence: PersistenceService = .shared) { | |
| 105 | + self.persistence = persistence | |
| 106 | + let stored = persistence.load(Stored.self, from: Self.fileName) ?? Stored() | |
| 107 | + themeMode = stored.themeMode | |
| 108 | + accent = stored.accent | |
| 109 | + chatFontSize = stored.chatFontSize | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Current accent color resolved for the active appearance. | |
| 113 | + var accentColor: Color { | |
| 114 | + let (light, dark) = accent.accentHex | |
| 115 | + return Color(nsColor: NSColor(name: nil) { appearance in | |
| 116 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 117 | + return NSColor(hex: hex) | |
| 118 | + }) | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Current subtle accent tint resolved for the active appearance. | |
| 122 | + var accentSubtleColor: Color { | |
| 123 | + let (light, dark) = accent.subtleHex | |
| 124 | + return Color(nsColor: NSColor(name: nil) { appearance in | |
| 125 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 126 | + return NSColor(hex: hex) | |
| 127 | + }) | |
| 128 | + } | |
| 129 | + | |
| 130 | + private func save() { | |
| 131 | + persistence.save( | |
| 132 | + Stored(themeMode: themeMode, accent: accent, chatFontSize: chatFontSize), | |
| 133 | + to: Self.fileName | |
| 134 | + ) | |
| 135 | + } | |
| 136 | +} | |
added
Sources/ZyquoAgent/DesignSystem/Components.swift
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +// | |
| 2 | +// Components.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Small reusable design-system components and interaction modifiers shared | |
| 9 | +// across all screens: hover states, press scaling, badges, provider glyphs. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +// MARK: - Interaction modifiers | |
| 15 | + | |
| 16 | +/// Standard hover feedback: background tint fades in over 80ms. | |
| 17 | +struct HoverHighlight: ViewModifier { | |
| 18 | + var cornerRadius: CGFloat = ZyquoRadius.small | |
| 19 | + @State private var hovering = false | |
| 20 | + | |
| 21 | + func body(content: Content) -> some View { | |
| 22 | + content | |
| 23 | + .background( | |
| 24 | + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) | |
| 25 | + .fill(hovering ? ZyquoColor.surfaceSecondary : .clear) | |
| 26 | + ) | |
| 27 | + .onHover { inside in | |
| 28 | + withAnimation(ZyquoMotion.hover) { hovering = inside } | |
| 29 | + } | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Button press feedback: scale to 0.97. | |
| 34 | +struct PressableButtonStyle: ButtonStyle { | |
| 35 | + func makeBody(configuration: Configuration) -> some View { | |
| 36 | + configuration.label | |
| 37 | + .scaleEffect(configuration.isPressed ? ZyquoMotion.pressedScale : 1) | |
| 38 | + .animation(ZyquoMotion.hover, value: configuration.isPressed) | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +extension View { | |
| 43 | + func zyquoHoverHighlight(cornerRadius: CGFloat = ZyquoRadius.small) -> some View { | |
| 44 | + modifier(HoverHighlight(cornerRadius: cornerRadius)) | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +// MARK: - Badges | |
| 49 | + | |
| 50 | +/// Small capsule badge (model chips metadata, capability tags). | |
| 51 | +struct ZyquoBadge: View { | |
| 52 | + let text: String | |
| 53 | + var color: Color = ZyquoColor.textSecondary | |
| 54 | + | |
| 55 | + var body: some View { | |
| 56 | + Text(text) | |
| 57 | + .font(ZyquoFont.caption) | |
| 58 | + .foregroundStyle(color) | |
| 59 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 60 | + .padding(.vertical, 2) | |
| 61 | + .background( | |
| 62 | + Capsule().fill(ZyquoColor.surfaceSecondary) | |
| 63 | + ) | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// Colored status dot (provider key state: verified / unset / failed). | |
| 68 | +struct StatusDot: View { | |
| 69 | + enum Status { | |
| 70 | + case verified, unset, failed | |
| 71 | + | |
| 72 | + var color: Color { | |
| 73 | + switch self { | |
| 74 | + case .verified: return ZyquoColor.success | |
| 75 | + case .unset: return ZyquoColor.textTertiary | |
| 76 | + case .failed: return ZyquoColor.danger | |
| 77 | + } | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + let status: Status | |
| 82 | + | |
| 83 | + var body: some View { | |
| 84 | + Circle() | |
| 85 | + .fill(status.color) | |
| 86 | + .frame(width: 8, height: 8) | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +// MARK: - Provider glyphs | |
| 91 | + | |
| 92 | +extension ProviderID { | |
| 93 | + /// SF Symbol used as the provider's glyph in chips, avatars, and Settings. | |
| 94 | + /// (Custom vector logos can replace these later without touching call sites.) | |
| 95 | + var symbolName: String { | |
| 96 | + switch self { | |
| 97 | + case .openai: return "sparkle" | |
| 98 | + case .anthropic: return "asterisk" | |
| 99 | + case .xai: return "xmark.diamond" | |
| 100 | + case .mistral: return "wind" | |
| 101 | + case .gemini: return "diamond.lefthalf.filled" | |
| 102 | + case .qwen: return "q.circle" | |
| 103 | + case .deepseek: return "water.waves" | |
| 104 | + case .kimi: return "moon.stars" | |
| 105 | + case .perplexity: return "magnifyingglass.circle" | |
| 106 | + case .together: return "circle.hexagongrid" | |
| 107 | + case .deepinfra: return "cube.transparent" | |
| 108 | + case .cerebras: return "bolt.circle" | |
| 109 | + case .custom: return "wrench.and.screwdriver" | |
| 110 | + } | |
| 111 | + } | |
| 112 | +} | |
added
Sources/ZyquoAgent/Execution/AuditLog.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// | |
| 2 | +// AuditLog.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Append-only record of every executed action: timestamp, kind, exact | |
| 9 | +// payload, cwd, policy ruling, exit code, truncated output. Stored as JSONL | |
| 10 | +// next to the task so nothing the agent does is invisible; viewable in the | |
| 11 | +// Audit tab and exportable. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +/// One audited action. | |
| 17 | +struct AuditEntry: Codable, Identifiable, Sendable { | |
| 18 | + var id: UUID = UUID() | |
| 19 | + var timestamp: Date = Date() | |
| 20 | + var taskID: UUID? | |
| 21 | + /// "bash", "osascript", "write_file", … | |
| 22 | + var actionKind: String | |
| 23 | + /// The exact command/script/path that ran. | |
| 24 | + var payload: String | |
| 25 | + var cwd: String | |
| 26 | + /// How the gate cleared it (autoAllowed / approvedByUser / …). | |
| 27 | + var ruling: String | |
| 28 | + var exitCode: Int32? | |
| 29 | + /// Output truncated to `AuditLog.outputLimit` characters. | |
| 30 | + var outputExcerpt: String? | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Append-only JSONL writer. Actor: serializes file appends. | |
| 34 | +actor AuditLog { | |
| 35 | + static let outputLimit = 2000 | |
| 36 | + | |
| 37 | + private let fileURL: URL | |
| 38 | + private let encoder: JSONEncoder | |
| 39 | + | |
| 40 | + /// Default log lives in the app's data folder; tasks may pass a | |
| 41 | + /// workspace-local URL instead. | |
| 42 | + init(fileURL: URL) { | |
| 43 | + self.fileURL = fileURL | |
| 44 | + let encoder = JSONEncoder() | |
| 45 | + encoder.dateEncodingStrategy = .iso8601 | |
| 46 | + self.encoder = encoder | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Appends one entry; creates the file on first write. Failures are | |
| 50 | + /// reported to stderr but never crash the agent — losing an audit line is | |
| 51 | + /// bad, killing the run is worse. | |
| 52 | + func append(_ entry: AuditEntry) { | |
| 53 | + var entry = entry | |
| 54 | + if let excerpt = entry.outputExcerpt, excerpt.count > Self.outputLimit { | |
| 55 | + entry.outputExcerpt = String(excerpt.prefix(Self.outputLimit)) + "… [truncated]" | |
| 56 | + } | |
| 57 | + do { | |
| 58 | + let data = try encoder.encode(entry) | |
| 59 | + guard var line = String(data: data, encoding: .utf8) else { return } | |
| 60 | + line += "\n" | |
| 61 | + let dir = fileURL.deletingLastPathComponent() | |
| 62 | + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | |
| 63 | + if !FileManager.default.fileExists(atPath: fileURL.path) { | |
| 64 | + try line.write(to: fileURL, atomically: true, encoding: .utf8) | |
| 65 | + } else { | |
| 66 | + let handle = try FileHandle(forWritingTo: fileURL) | |
| 67 | + defer { try? handle.close() } | |
| 68 | + try handle.seekToEnd() | |
| 69 | + try handle.write(contentsOf: Data(line.utf8)) | |
| 70 | + } | |
| 71 | + } catch { | |
| 72 | + FileHandle.standardError.write(Data("AuditLog append failed: \(error)\n".utf8)) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// All entries, oldest first (for the Audit tab and export). | |
| 77 | + func entries() -> [AuditEntry] { | |
| 78 | + guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return [] } | |
| 79 | + let decoder = JSONDecoder() | |
| 80 | + decoder.dateDecodingStrategy = .iso8601 | |
| 81 | + return content.split(separator: "\n").compactMap { line in | |
| 82 | + try? decoder.decode(AuditEntry.self, from: Data(line.utf8)) | |
| 83 | + } | |
| 84 | + } | |
| 85 | +} | |
added
Sources/ZyquoAgent/Execution/PolicyEngine.swift
+138 −0
@@ -0,0 +1,138 @@ | ||
| 1 | +// | |
| 2 | +// PolicyEngine.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The safety gate every action passes through — no shell command, AppleScript, | |
| 9 | +// or out-of-workspace file write runs without a ruling from here. Precedence | |
| 10 | +// is deny → ask → allow (Claude-Code style): destructive patterns always ask | |
| 11 | +// regardless of mode, curated read-only commands may auto-run in Guarded and | |
| 12 | +// Autonomous modes, everything else follows the active SafetyMode. | |
| 13 | +// | |
| 14 | +// Phase 2 skeleton: types + gate plumbing. The full rule engine (compound | |
| 15 | +// command parsing, risk classifier, remembered rules) lands in Phase 3.C. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import Foundation | |
| 19 | + | |
| 20 | +/// User-selectable safety posture, per task. | |
| 21 | +enum SafetyMode: String, Codable, CaseIterable, Identifiable, Sendable { | |
| 22 | + /// Approve every action. | |
| 23 | + case manual | |
| 24 | + /// Auto-run read-only/safe actions, ask for anything mutating or risky. | |
| 25 | + case guarded | |
| 26 | + /// Run freely within budget — clearly labeled, off by default, still | |
| 27 | + /// audited, and destructive patterns STILL require approval. | |
| 28 | + case autonomous | |
| 29 | + | |
| 30 | + var id: String { rawValue } | |
| 31 | + | |
| 32 | + var displayName: String { | |
| 33 | + switch self { | |
| 34 | + case .manual: return "Manual" | |
| 35 | + case .guarded: return "Guarded" | |
| 36 | + case .autonomous: return "Autonomous" | |
| 37 | + } | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/// An action submitted to the gate for classification. | |
| 42 | +struct ActionRequest: Sendable { | |
| 43 | + enum Kind: String, Codable, Sendable { | |
| 44 | + case shellCommand | |
| 45 | + case appleScript | |
| 46 | + case fileWrite | |
| 47 | + case fileWriteOutsideWorkspace | |
| 48 | + } | |
| 49 | + var kind: Kind | |
| 50 | + /// The exact command / script / path the user will see verbatim. | |
| 51 | + var payload: String | |
| 52 | + var cwd: URL | |
| 53 | + /// Model-provided explanation of intent, shown on the approval card. | |
| 54 | + var explanation: String? | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// The gate's ruling for one action. | |
| 58 | +enum PolicyRuling: Sendable { | |
| 59 | + /// Safe under the active mode — run without asking. | |
| 60 | + case allow(reason: String) | |
| 61 | + /// Hold for user approval (approval card), with a risk label. | |
| 62 | + case ask(risk: RiskAssessment) | |
| 63 | + /// Never run (hard denylist). | |
| 64 | + case deny(reason: String) | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// Risk classification attached to approval requests. | |
| 68 | +struct RiskAssessment: Sendable { | |
| 69 | + enum Level: String, Codable, Sendable { | |
| 70 | + case safe, mutating, destructive, elevated | |
| 71 | + } | |
| 72 | + var level: Level | |
| 73 | + /// Human-readable reason ("deletes files recursively", "requires sudo"…). | |
| 74 | + var reason: String | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// How the user (or mode) resolved an approval request. | |
| 78 | +enum ApprovalResolution: Sendable { | |
| 79 | + case approve | |
| 80 | + /// Approve and remember an allow rule for this safe class. | |
| 81 | + case approveAndRemember | |
| 82 | + /// User edited the payload, then approved; carries the edited payload. | |
| 83 | + case approveEdited(String) | |
| 84 | + case deny | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// Asynchronous bridge to whoever answers approval requests (UI card or CLI | |
| 88 | +/// prompt). The loop blocks on this until resolved. | |
| 89 | +protocol ApprovalPresenting: Sendable { | |
| 90 | + func requestApproval(for action: ActionRequest, risk: RiskAssessment) async -> ApprovalResolution | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// The gate. Actor: rulings and remembered rules mutate shared state. | |
| 94 | +actor PolicyEngine { | |
| 95 | + private(set) var mode: SafetyMode | |
| 96 | + private let approvals: ApprovalPresenting | |
| 97 | + | |
| 98 | + init(mode: SafetyMode, approvals: ApprovalPresenting) { | |
| 99 | + self.mode = mode | |
| 100 | + self.approvals = approvals | |
| 101 | + } | |
| 102 | + | |
| 103 | + func setMode(_ newMode: SafetyMode) { | |
| 104 | + mode = newMode | |
| 105 | + } | |
| 106 | + | |
| 107 | + /// Classifies the action, asks the user when required, and returns what may | |
| 108 | + /// actually run (payload may have been edited). Throws `PolicyDenied` when | |
| 109 | + /// the action must not run. Full classifier lands in Phase 3.C — until | |
| 110 | + /// then, everything asks (fail-closed). | |
| 111 | + func clear(_ action: ActionRequest) async throws -> ClearedAction { | |
| 112 | + let risk = RiskAssessment(level: .mutating, reason: "Risk classifier not built yet (Phase 3.C) — asking for everything.") | |
| 113 | + let resolution = await approvals.requestApproval(for: action, risk: risk) | |
| 114 | + switch resolution { | |
| 115 | + case .approve: | |
| 116 | + return ClearedAction(payload: action.payload, decision: .init(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason)) | |
| 117 | + case .approveAndRemember: | |
| 118 | + return ClearedAction(payload: action.payload, decision: .init(ruling: .approvedByUser, riskLabel: risk.level.rawValue, rationale: risk.reason)) | |
| 119 | + case .approveEdited(let edited): | |
| 120 | + return ClearedAction(payload: edited, decision: .init(ruling: .editedAndApproved, riskLabel: risk.level.rawValue, rationale: risk.reason)) | |
| 121 | + case .deny: | |
| 122 | + throw PolicyDenied(reason: "User denied the action.") | |
| 123 | + } | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +/// An action that passed the gate, ready to execute. | |
| 128 | +struct ClearedAction: Sendable { | |
| 129 | + /// What actually runs (user may have edited it). | |
| 130 | + var payload: String | |
| 131 | + var decision: PolicyDecisionRecord | |
| 132 | +} | |
| 133 | + | |
| 134 | +/// Thrown when the gate refuses an action; surfaces to the model as an | |
| 135 | +/// error tool result so it can adapt. | |
| 136 | +struct PolicyDenied: Error, Sendable { | |
| 137 | + var reason: String | |
| 138 | +} | |
added
Sources/ZyquoAgent/Tools/Tool.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// | |
| 2 | +// Tool.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The tool contract. A Tool exposes a JSON-Schema described capability to the | |
| 9 | +// model and executes validated calls inside a task's workspace. Execution is | |
| 10 | +// streaming (chunks surface live in the UI/terminal drawer), cancellable, and | |
| 11 | +// — for anything that touches the system — always pre-cleared by the | |
| 12 | +// PolicyEngine before `execute` runs a side effect. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +/// Live output emitted while a tool runs (streamed to the transcript and the | |
| 18 | +/// Activity/Terminal drawer line by line). | |
| 19 | +enum ToolOutputChunk: Sendable { | |
| 20 | + case stdout(String) | |
| 21 | + case stderr(String) | |
| 22 | + /// Informational progress that is neither stdout nor stderr (e.g. "wrote 3 files"). | |
| 23 | + case note(String) | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// Everything a tool needs from its surroundings to run one call. | |
| 27 | +struct ToolExecutionContext: Sendable { | |
| 28 | + /// The task's working directory — cwd for shell commands, root for file tools. | |
| 29 | + let workspaceURL: URL | |
| 30 | + /// Safety gate: every side-effecting action is classified and, if needed, | |
| 31 | + /// held for user approval before it runs. | |
| 32 | + let policy: PolicyEngine | |
| 33 | + /// Append-only record of every executed action. | |
| 34 | + let audit: AuditLog | |
| 35 | + /// Streams live output chunks to the UI as they happen. | |
| 36 | + let onOutput: @Sendable (ToolOutputChunk) -> Void | |
| 37 | +} | |
| 38 | + | |
| 39 | +/// Outcome of one tool call, before it is threaded back to the model as a | |
| 40 | +/// `ToolResult`. | |
| 41 | +struct ToolExecutionResult: Sendable { | |
| 42 | + /// Text handed back to the model (stdout+stderr, file contents, listings…). | |
| 43 | + var content: String | |
| 44 | + /// True when the tool failed (non-zero exit, missing file, denied action). | |
| 45 | + var isError: Bool = false | |
| 46 | + | |
| 47 | + static func success(_ content: String) -> ToolExecutionResult { | |
| 48 | + ToolExecutionResult(content: content) | |
| 49 | + } | |
| 50 | + | |
| 51 | + static func failure(_ message: String) -> ToolExecutionResult { | |
| 52 | + ToolExecutionResult(content: message, isError: true) | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// A capability the agent can invoke. Conform and register in `ToolRegistry` | |
| 57 | +/// to make a new tool available to every agent-capable model. | |
| 58 | +protocol Tool: Sendable { | |
| 59 | + /// Wire name the model calls (snake_case, stable across releases). | |
| 60 | + var name: String { get } | |
| 61 | + /// Model-facing usage documentation — written like onboarding docs: what it | |
| 62 | + /// does, when to use it, constraints, failure modes. | |
| 63 | + var description: String { get } | |
| 64 | + /// JSON Schema for the arguments object. | |
| 65 | + var parametersSchema: JSONValue { get } | |
| 66 | + | |
| 67 | + /// Runs one validated call. Must route any system side effect through | |
| 68 | + /// `context.policy` first and honor Task cancellation promptly. | |
| 69 | + func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult | |
| 70 | +} | |
| 71 | + | |
| 72 | +extension Tool { | |
| 73 | + /// The provider-neutral spec handed to models via the normalized interface | |
| 74 | + /// (ToolSpec carries the schema as a serialized JSON string). | |
| 75 | + var toolSpec: ToolSpec { | |
| 76 | + let schemaString: String | |
| 77 | + if let data = try? JSONEncoder().encode(parametersSchema), | |
| 78 | + let encoded = String(data: data, encoding: .utf8) { | |
| 79 | + schemaString = encoded | |
| 80 | + } else { | |
| 81 | + schemaString = #"{"type":"object"}"# | |
| 82 | + } | |
| 83 | + return ToolSpec(name: name, description: description, parametersJSONSchema: schemaString) | |
| 84 | + } | |
| 85 | +} | |
| 86 | ||