Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Sources/LocalLLMClientCore/Tools/AnyLLMTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions Sources/LocalLLMClientCore/Tools/DynamicLLMTool.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 2 additions & 2 deletions Sources/LocalLLMClientLlama/LLMSession+Llama.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
Expand All @@ -51,7 +51,7 @@ public extension LLMSession.LocalModel {
url: url,
mmprojURL: mmprojURL,
parameter: parameter,
tools: tools.map { $0.underlyingTool }
erasedTools: tools
)
)
}
Expand Down
44 changes: 40 additions & 4 deletions Sources/LocalLLMClientLlama/LlamaClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading