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%

phase2: DesignSystem components/appearance (violet accents), Tool protocol, AgentStep, PolicyEngine+AuditLog skeletons

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 7c15eb2

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