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