From 81746736746607956ca7f66efee120257425fa3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Burel?= Date: Tue, 25 Aug 2026 12:19:36 +0200 Subject: [PATCH] Allow tools whose schema is only known at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host that gets its tools from a registry — an agent runtime, say — discovers them while running, as data: a name, a description and a JSON Schema string. There is no Swift type to hang `Arguments.argumentsSchema` on, so `AnyLLMTool.init(_:)` cannot express such a tool at all. Everything `AnyLLMTool` stores is already dynamic — `_argumentsSchema` is a dictionary, `_call` takes the raw arguments JSON — so this only adds the missing way in: an initializer taking those values directly, plus `DynamicLLMTool` to answer `underlyingTool` for a tool that has no type of its own. `LlamaClient` needed the same door. It took `[any LLMTool]` and erased them itself, which routes back through the static-schema initializer, so a caller holding an `AnyLLMTool` could not get it through. It now delegates to an `erasedTools:` variant that accepts tools already wrapped, and `LocalLLMClient.llama(…)` gained the matching overload. `LLMSession+Llama` used that same round trip: `makeClient` already receives `[AnyLLMTool]`, and mapping them back through `underlyingTool` re-erased them — emptying the schema of any tool built from runtime data. Both model factories now pass the erased tools straight through. Everything is additive: no existing call site changes. --- .../LocalLLMClientCore/Tools/AnyLLMTool.swift | 26 +++++++++++ .../Tools/DynamicLLMTool.swift | 28 ++++++++++++ .../LLMSession+Llama.swift | 4 +- Sources/LocalLLMClientLlama/LlamaClient.swift | 44 +++++++++++++++++-- 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 Sources/LocalLLMClientCore/Tools/DynamicLLMTool.swift diff --git a/Sources/LocalLLMClientCore/Tools/AnyLLMTool.swift b/Sources/LocalLLMClientCore/Tools/AnyLLMTool.swift index 891570e..0ea7514 100644 --- a/Sources/LocalLLMClientCore/Tools/AnyLLMTool.swift +++ b/Sources/LocalLLMClientCore/Tools/AnyLLMTool.swift @@ -61,6 +61,32 @@ public struct AnyLLMTool: Sendable { } } + /// Creates a type-erased tool whose name, description and JSON schema are + /// all supplied at runtime. + /// + /// The schema-from-a-static-type route above cannot serve a host whose tools + /// come from a registry — an agent runtime discovers them while running, as + /// data, so there is no Swift type to hang `argumentsSchema` on. Every stored + /// field is already dynamic, so this is the same wrapper reached another way. + /// + /// - Parameters: + /// - name: The tool name the model will call. + /// - description: What the tool does. + /// - argumentsSchema: JSON Schema for the parameters, as a dictionary. + /// - call: Receives the raw arguments JSON the model produced. + public init( + name: String, + description: String, + argumentsSchema: [String: any Sendable], + call: @escaping @Sendable (String) async throws -> ToolOutput + ) { + self._name = name + self._description = description + self._argumentsSchema = argumentsSchema + self._tool = DynamicLLMTool(name: name, description: description) + self._call = call + } + /// Executes the tool with JSON-encoded arguments /// - Parameter argumentsJSON: JSON string containing the arguments /// - Returns: The tool output diff --git a/Sources/LocalLLMClientCore/Tools/DynamicLLMTool.swift b/Sources/LocalLLMClientCore/Tools/DynamicLLMTool.swift new file mode 100644 index 0000000..9766a11 --- /dev/null +++ b/Sources/LocalLLMClientCore/Tools/DynamicLLMTool.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The `LLMTool` standing behind a tool built from runtime data. +/// +/// `AnyLLMTool` keeps a concrete tool around for `underlyingTool`, but a tool +/// declared at runtime has no Swift type of its own: its schema is data, not a +/// `ToolSchemaGeneratable` conformance. This carries the name and description so +/// that accessor still answers, and never runs — the dynamic initializer stores +/// the caller's closure directly. +public struct DynamicLLMTool: LLMTool { + public struct Arguments: Decodable, ToolSchemaGeneratable { + public static var argumentsSchema: LLMToolArgumentsSchema { [:] } + } + + public let name: String + public let description: String + + public init(name: String, description: String) { + self.name = name + self.description = description + } + + public func call(arguments: Arguments) async throws -> ToolOutput { + throw ToolError.executionFailed( + toolName: name, + underlyingError: CocoaError(.featureUnsupported)) + } +} diff --git a/Sources/LocalLLMClientLlama/LLMSession+Llama.swift b/Sources/LocalLLMClientLlama/LLMSession+Llama.swift index 38d0a18..00298f9 100644 --- a/Sources/LocalLLMClientLlama/LLMSession+Llama.swift +++ b/Sources/LocalLLMClientLlama/LLMSession+Llama.swift @@ -27,7 +27,7 @@ public extension LLMSession.DownloadModel { url: downloadDestination.appending(component: model), mmprojURL: mmproj.map { downloadDestination.appending(component: $0) }, parameter: parameter, - tools: tools.map { $0.underlyingTool } + erasedTools: tools ) ) } @@ -51,7 +51,7 @@ public extension LLMSession.LocalModel { url: url, mmprojURL: mmprojURL, parameter: parameter, - tools: tools.map { $0.underlyingTool } + erasedTools: tools ) ) } diff --git a/Sources/LocalLLMClientLlama/LlamaClient.swift b/Sources/LocalLLMClientLlama/LlamaClient.swift index fc37f2a..6e53884 100644 --- a/Sources/LocalLLMClientLlama/LlamaClient.swift +++ b/Sources/LocalLLMClientLlama/LlamaClient.swift @@ -26,12 +26,31 @@ public final class LlamaClient: LLMClient { /// - messageProcessor: The message processor to use for chat messages (optional). /// - tools: An array of tools that can be used by the model for function calling. /// - Throws: An error if the client fails to initialize. - public init( + public convenience init( url: URL, mmprojURL: URL?, parameter: Parameter, messageProcessor: MessageProcessor?, tools: [any LLMTool] = [] + ) throws { + try self.init( + url: url, mmprojURL: mmprojURL, parameter: parameter, + messageProcessor: messageProcessor, + erasedTools: tools.map { AnyLLMTool($0) }) + } + + /// Initializes a client with tools that are already type-erased. + /// + /// A host whose tools come from a runtime registry has no Swift type to + /// derive a schema from, so it builds `AnyLLMTool` values directly. Erasing + /// here would be the second erasure, and the first one is the one that + /// cannot happen. + public init( + url: URL, + mmprojURL: URL?, + parameter: Parameter, + messageProcessor: MessageProcessor?, + erasedTools: [AnyLLMTool] ) throws { context = try Context(url: url, parameter: parameter) if let mmprojURL { @@ -40,9 +59,8 @@ public final class LlamaClient: LLMClient { multimodal = nil } self.messageProcessor = messageProcessor ?? MessageProcessorFactory.createAutoProcessor(chatTemplate: context.model.chatTemplate) - let wrappedTools = tools.map { AnyLLMTool($0) } - self.tools = wrappedTools - self.chatParamsPtr = context.model.buildChatParams(tools: wrappedTools) + self.tools = erasedTools + self.chatParamsPtr = context.model.buildChatParams(tools: erasedTools) } deinit { @@ -205,6 +223,24 @@ public extension LocalLLMClient { tools: tools ) } + + /// Creates a new Llama client from tools that are already type-erased. + static func llama( + url: URL, + mmprojURL: URL? = nil, + parameter: LlamaClient.Parameter = .default, + messageProcessor: MessageProcessor? = nil, + erasedTools: [AnyLLMTool] + ) async throws -> LlamaClient { + setLlamaVerbose(parameter.options.verbose) + return try LlamaClient( + url: url, + mmprojURL: mmprojURL, + parameter: parameter, + messageProcessor: messageProcessor, + erasedTools: erasedTools + ) + } } #if DEBUG