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.0 KB · 183 lines swift
Raw Blame History
1//2//  ToolRegistry.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The catalog of tools the agent can use. Hands the model the ToolSpecs,9//  resolves incoming ToolCalls by name, decodes and validates their JSON10//  arguments against the tool's schema, executes, and normalizes every11//  failure class (unknown tool, malformed JSON, missing parameters, policy12//  denial, cancellation) into an error ToolResult the model can act on.13//  Adding a tool = conform to `Tool` + append it here (or pass a custom list14//  to `init`).15//1617import Foundation1819struct ToolRegistry: Sendable {20    private let tools: [any Tool]21    private let toolsByName: [String: any Tool]2223    init(tools: [any Tool]) {24        self.tools = tools25        var index: [String: any Tool] = [:]26        for tool in tools {27            precondition(index[tool.name] == nil, "Duplicate tool name: \(tool.name)")28            index[tool.name] = tool29        }30        self.toolsByName = index31    }3233    /// The standard Zyquo Agent tool set: bash, osascript, and the five34    /// workspace file tools.35    static func standard(executor: ExecutionService) -> ToolRegistry {36        ToolRegistry(tools: [37            ShellTool(executor: executor),38            AppleScriptTool(executor: executor),39            ReadFileTool(),40            WriteFileTool(),41            EditFileTool(),42            ListDirTool(),43            SearchFilesTool(),44        ])45    }4647    /// Provider-neutral specs handed to the model on every request.48    var toolSpecs: [ToolSpec] {49        tools.map(\.toolSpec)50    }5152    /// All registered tool names, in registration order.53    var toolNames: [String] {54        tools.map(\.name)55    }5657    func tool(named name: String) -> (any Tool)? {58        toolsByName[name]59    }6061    /// Executes one model-issued tool call end to end and returns the62    /// ToolResult to thread back into the conversation. Never throws for63    /// tool-level failures — the model must see them as error results so it64    /// can self-correct; only Task cancellation escapes as a thrown error.65    func execute(call: ToolCall, context: ToolExecutionContext) async throws -> ToolResult {66        guard let tool = toolsByName[call.name] else {67            return ToolResult(68                toolCallID: call.id,69                content: "Unknown tool “\(call.name)”. Available tools: \(toolNames.joined(separator: ", ")).",70                isError: true71            )72        }7374        // Decode the raw argument JSON. An empty string counts as {} —75        // some providers omit arguments for parameterless calls.76        let rawJSON = call.argumentsJSON.trimmingCharacters(in: .whitespacesAndNewlines)77        let arguments: JSONValue78        if rawJSON.isEmpty {79            arguments = .object([:])80        } else if let parsed = JSONValue.parse(rawJSON) {81            arguments = parsed82        } else {83            return ToolResult(84                toolCallID: call.id,85                content: "The arguments for \(call.name) were not valid JSON. Re-issue the call with a well-formed JSON object.",86                isError: true87            )88        }89        guard case .object = arguments else {90            return ToolResult(91                toolCallID: call.id,92                content: "The arguments for \(call.name) must be a JSON object, got: \(rawJSON.prefix(100)).",93                isError: true94            )95        }9697        // Validate against the tool's schema (required keys + basic types).98        if let problem = Self.validate(arguments: arguments, against: tool.parametersSchema, toolName: call.name) {99            return ToolResult(toolCallID: call.id, content: problem, isError: true)100        }101102        do {103            let outcome = try await tool.execute(arguments: arguments, context: context)104            return ToolResult(toolCallID: call.id, content: outcome.content, isError: outcome.isError)105        } catch is CancellationError {106            throw CancellationError()107        } catch let denial as PolicyDenied {108            // Tools normally convert denials themselves; this is a backstop.109            return ToolResult(110                toolCallID: call.id,111                content: "Action denied by the safety policy: \(denial.reason)",112                isError: true113            )114        } catch {115            return ToolResult(116                toolCallID: call.id,117                content: "\(call.name) failed: \(error.localizedDescription)",118                isError: true119            )120        }121    }122123    // MARK: Schema validation124125    /// Checks `required` properties are present and that provided values126    /// match the schema's declared primitive types. Returns a model-actionable127    /// problem description, or nil when valid. (Full JSON-Schema validation128    /// is intentionally out of scope — schemas stay in the flat common129    /// subset per docs/AGENT-RESEARCH.md §2.1.)130    static func validate(arguments: JSONValue, against schema: JSONValue, toolName: String) -> String? {131        guard case .object(let args) = arguments,132              case .object(let schemaObject) = schema else { return nil }133134        if case .array(let required)? = schemaObject["required"] {135            for entry in required {136                if case .string(let key) = entry, args[key] == nil {137                    return "\(toolName): missing required parameter `\(key)`."138                }139            }140        }141142        if case .object(let properties)? = schemaObject["properties"] {143            for (key, value) in args {144                guard case .object(let property)? = properties[key] else {145                    // Unknown extra keys are tolerated (models add them).146                    continue147                }148                guard case .string(let expected)? = property["type"] else { continue }149                if let problem = typeMismatch(value: value, expected: expected, key: key, toolName: toolName) {150                    return problem151                }152            }153        }154        return nil155    }156157    private static func typeMismatch(value: JSONValue, expected: String, key: String, toolName: String) -> String? {158        let actual: String159        switch value {160        case .null: actual = "null"161        case .bool: actual = "boolean"162        case .number(let n): actual = n.truncatingRemainder(dividingBy: 1) == 0 ? "integer" : "number"163        case .string: actual = "string"164        case .array: actual = "array"165        case .object: actual = "object"166        }167        let compatible: Bool168        switch expected {169        case "integer": compatible = actual == "integer"170        case "number": compatible = actual == "integer" || actual == "number"171        case "boolean": compatible = actual == "boolean"172        case "string": compatible = actual == "string"173        case "array": compatible = actual == "array"174        case "object": compatible = actual == "object"175        default: compatible = true176        }177        if !compatible {178            return "\(toolName): parameter `\(key)` should be \(expected), got \(actual)."179        }180        return nil181    }182}183