diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index ef9e9fd7..bdc24a77 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -867,16 +867,6 @@ private func createMessageParams( // MARK: - Tool Invocation Handling -private struct ToolInvocationResult { - let call: Transcript.ToolCall - let output: Transcript.ToolOutput -} - -private enum ToolResolutionOutcome { - case stop(calls: [Transcript.ToolCall]) - case invocations([ToolInvocationResult]) -} - private func emptyResponseContent( for type: Content.Type ) throws -> (content: Content, rawContent: GeneratedContent) { @@ -916,105 +906,14 @@ private func resolveToolUses( _ toolUses: [AnthropicToolUse], session: LanguageModelSession ) async throws -> ToolResolutionOutcome { - if toolUses.isEmpty { return .invocations([]) } - - var toolsByName: [String: any Tool] = [:] - for tool in session.tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - - var transcriptCalls: [Transcript.ToolCall] = [] - transcriptCalls.reserveCapacity(toolUses.count) - for use in toolUses { - let args = try toGeneratedContent(use.input) - let callID = use.id - transcriptCalls.append( - Transcript.ToolCall( - id: callID, - toolName: use.name, - arguments: args - ) + let calls = try toolUses.map { use in + Transcript.ToolCall( + id: use.id, + toolName: use.name, + arguments: try toGeneratedContent(use.input) ) } - - if let delegate = session.toolExecutionDelegate { - await delegate.didGenerateToolCalls(transcriptCalls, in: session) - } - - guard !transcriptCalls.isEmpty else { return .invocations([]) } - - var decisions: [ToolExecutionDecision] = [] - decisions.reserveCapacity(transcriptCalls.count) - - if let delegate = session.toolExecutionDelegate { - for call in transcriptCalls { - let decision = await delegate.toolCallDecision(for: call, in: session) - if case .stop = decision { - return .stop(calls: transcriptCalls) - } - decisions.append(decision) - } - } else { - decisions = Array(repeating: .execute, count: transcriptCalls.count) - } - - var results: [ToolInvocationResult] = [] - results.reserveCapacity(transcriptCalls.count) - - for (index, call) in transcriptCalls.enumerated() { - switch decisions[index] { - case .stop: - // This branch should be unreachable because `.stop` returns during decision collection. - // Keep it as a defensive guard in case that logic changes. - return .stop(calls: transcriptCalls) - case .provideOutput(let segments): - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - case .execute: - guard let tool = toolsByName[call.toolName] else { - let message = Transcript.Segment.text(.init(content: "Tool not found: \(call.toolName)")) - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: [message] - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - continue - } - - do { - let segments = try await tool.makeOutputSegments(from: call.arguments) - let output = Transcript.ToolOutput( - id: call.id, - toolName: tool.name, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - } catch { - if let delegate = session.toolExecutionDelegate { - await delegate.didFailToolCall(call, error: error, in: session) - } - throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) - } - } - } - - return .invocations(results) + return try await resolveToolCalls(calls, session: session) } // Convert our GenerationSchema into Anthropic's expected JSON Schema payload diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index bf617afb..5724a449 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -581,119 +581,18 @@ private func createGenerateContentParams( return params } -private struct ToolInvocationResult { - let call: Transcript.ToolCall - let output: Transcript.ToolOutput -} - -private enum ToolResolutionOutcome { - case stop(calls: [Transcript.ToolCall]) - case invocations([ToolInvocationResult]) -} - private func resolveFunctionCalls( _ functionCalls: [GeminiFunctionCall], session: LanguageModelSession ) async throws -> ToolResolutionOutcome { - if functionCalls.isEmpty { return .invocations([]) } - - var toolsByName: [String: any Tool] = [:] - for tool in session.tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - - var transcriptCalls: [Transcript.ToolCall] = [] - transcriptCalls.reserveCapacity(functionCalls.count) - for call in functionCalls { - let args = try toGeneratedContent(call.args) - let callID = UUID().uuidString - transcriptCalls.append( - Transcript.ToolCall( - id: callID, - toolName: call.name, - arguments: args - ) + let calls = try functionCalls.map { call in + Transcript.ToolCall( + id: UUID().uuidString, + toolName: call.name, + arguments: try toGeneratedContent(call.args) ) } - - if let delegate = session.toolExecutionDelegate { - await delegate.didGenerateToolCalls(transcriptCalls, in: session) - } - - guard !transcriptCalls.isEmpty else { return .invocations([]) } - - var decisions: [ToolExecutionDecision] = [] - decisions.reserveCapacity(transcriptCalls.count) - - if let delegate = session.toolExecutionDelegate { - for call in transcriptCalls { - let decision = await delegate.toolCallDecision(for: call, in: session) - if case .stop = decision { - return .stop(calls: transcriptCalls) - } - decisions.append(decision) - } - } else { - decisions = Array(repeating: .execute, count: transcriptCalls.count) - } - - var results: [ToolInvocationResult] = [] - results.reserveCapacity(transcriptCalls.count) - - for (index, call) in transcriptCalls.enumerated() { - switch decisions[index] { - case .stop: - // This branch should be unreachable because `.stop` returns during decision collection. - // Keep it as a defensive guard in case that logic changes. - return .stop(calls: transcriptCalls) - case .provideOutput(let segments): - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - case .execute: - guard let tool = toolsByName[call.toolName] else { - let message = Transcript.Segment.text(.init(content: "Tool not found: \(call.toolName)")) - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: [message] - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - continue - } - - do { - let segments = try await tool.makeOutputSegments(from: call.arguments) - let output = Transcript.ToolOutput( - id: call.id, - toolName: tool.name, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - } catch { - if let delegate = session.toolExecutionDelegate { - await delegate.didFailToolCall(call, error: error, in: session) - } - throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) - } - } - } - - return .invocations(results) + return try await resolveToolCalls(calls, session: session) } private func emptyResponseContent( diff --git a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift index 62118c50..cc2ece85 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1002,7 +1002,7 @@ import Foundation } previousToolCallSignature = signature - let resolution = try await resolveToolCalls(collectedToolCalls, session: session) + let resolution = try await resolveMLXToolCalls(collectedToolCalls, session: session) switch resolution { case .stop(let calls): if !calls.isEmpty { @@ -1438,16 +1438,6 @@ import Foundation // MARK: - Tool Invocation Handling - private struct ToolInvocationResult { - let call: Transcript.ToolCall - let output: Transcript.ToolOutput - } - - private enum ToolResolutionOutcome { - case stop(calls: [Transcript.ToolCall]) - case invocations([ToolInvocationResult]) - } - private func makeTranscriptToolCalls( from toolCalls: [MLXLMCommon.ToolCall] ) throws -> [Transcript.ToolCall] { @@ -1467,97 +1457,12 @@ import Foundation return transcriptCalls } - private func resolveToolCalls( + private func resolveMLXToolCalls( _ toolCalls: [MLXLMCommon.ToolCall], session: LanguageModelSession ) async throws -> ToolResolutionOutcome { - if toolCalls.isEmpty { return .invocations([]) } - - var toolsByName: [String: any Tool] = [:] - for tool in session.tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - - let transcriptCalls = try makeTranscriptToolCalls(from: toolCalls) - - if let delegate = session.toolExecutionDelegate { - await delegate.didGenerateToolCalls(transcriptCalls, in: session) - } - - guard !transcriptCalls.isEmpty else { return .invocations([]) } - - var decisions: [ToolExecutionDecision] = [] - decisions.reserveCapacity(transcriptCalls.count) - - if let delegate = session.toolExecutionDelegate { - for call in transcriptCalls { - let decision = await delegate.toolCallDecision(for: call, in: session) - if case .stop = decision { - return .stop(calls: transcriptCalls) - } - decisions.append(decision) - } - } else { - decisions = Array(repeating: .execute, count: transcriptCalls.count) - } - - var results: [ToolInvocationResult] = [] - results.reserveCapacity(transcriptCalls.count) - - for (index, call) in transcriptCalls.enumerated() { - switch decisions[index] { - case .stop: - // This branch should be unreachable because `.stop` returns during decision collection. - // Keep it as a defensive guard in case that logic changes. - return .stop(calls: transcriptCalls) - case .provideOutput(let segments): - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - case .execute: - guard let tool = toolsByName[call.toolName] else { - let message = Transcript.Segment.text(.init(content: "Tool not found: \(call.toolName)")) - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: [message] - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - continue - } - - do { - let segments = try await tool.makeOutputSegments(from: call.arguments) - let output = Transcript.ToolOutput( - id: call.id, - toolName: tool.name, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - } catch { - if let delegate = session.toolExecutionDelegate { - await delegate.didFailToolCall(call, error: error, in: session) - } - throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) - } - } - } - - return .invocations(results) + let calls = try makeTranscriptToolCalls(from: toolCalls) + return try await AnyLanguageModel.resolveToolCalls(calls, session: session) } private func toGeneratedContent(_ args: [String: MLXLMCommon.JSONValue]) throws -> GeneratedContent { diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index c7671c47..f849a90f 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -124,7 +124,7 @@ public struct OllamaLanguageModel: LanguageModel { ) ) - let resolution = try await resolveToolCalls(toolCalls, session: session) + let resolution = try await resolveOllamaToolCalls(toolCalls, session: session) switch resolution { case .stop(let calls): if !calls.isEmpty { @@ -274,7 +274,7 @@ public struct OllamaLanguageModel: LanguageModel { ) ) - let resolution = try await resolveToolCalls(streamedToolCalls, session: session) + let resolution = try await resolveOllamaToolCalls(streamedToolCalls, session: session) switch resolution { case .stop(let calls): if !calls.isEmpty { @@ -324,121 +324,18 @@ public struct OllamaLanguageModel: LanguageModel { // MARK: - Tool Invocation Handling -private struct ToolInvocationResult { - let call: Transcript.ToolCall - let output: Transcript.ToolOutput -} - -private enum ToolResolutionOutcome { - case stop(calls: [Transcript.ToolCall]) - case invocations([ToolInvocationResult]) -} - -private func resolveToolCalls( +private func resolveOllamaToolCalls( _ toolCalls: [OllamaToolCall], session: LanguageModelSession ) async throws -> ToolResolutionOutcome { - if toolCalls.isEmpty { - return .invocations([]) - } - - var toolsByName: [String: any Tool] = [:] - for tool in session.tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - - var transcriptCalls: [Transcript.ToolCall] = [] - transcriptCalls.reserveCapacity(toolCalls.count) - for call in toolCalls { - let args = try toGeneratedContent(call.function.arguments) - let callID = call.id ?? UUID().uuidString - transcriptCalls.append( - Transcript.ToolCall( - id: callID, - toolName: call.function.name, - arguments: args - ) + let calls = try toolCalls.map { call in + Transcript.ToolCall( + id: call.id ?? UUID().uuidString, + toolName: call.function.name, + arguments: try toGeneratedContent(call.function.arguments) ) } - - if let delegate = session.toolExecutionDelegate { - await delegate.didGenerateToolCalls(transcriptCalls, in: session) - } - - guard !transcriptCalls.isEmpty else { return .invocations([]) } - - var decisions: [ToolExecutionDecision] = [] - decisions.reserveCapacity(transcriptCalls.count) - - if let delegate = session.toolExecutionDelegate { - for call in transcriptCalls { - let decision = await delegate.toolCallDecision(for: call, in: session) - if case .stop = decision { - return .stop(calls: transcriptCalls) - } - decisions.append(decision) - } - } else { - decisions = Array(repeating: .execute, count: transcriptCalls.count) - } - - var results: [ToolInvocationResult] = [] - results.reserveCapacity(transcriptCalls.count) - - for (index, call) in transcriptCalls.enumerated() { - switch decisions[index] { - case .stop: - // This branch should be unreachable because `.stop` returns during decision collection. - // Keep it as a defensive guard in case that logic changes. - return .stop(calls: transcriptCalls) - case .provideOutput(let segments): - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - case .execute: - guard let tool = toolsByName[call.toolName] else { - let message = Transcript.Segment.text(.init(content: "Tool not found: \(call.toolName)")) - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: [message] - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - continue - } - - do { - let segments = try await tool.makeOutputSegments(from: call.arguments) - let output = Transcript.ToolOutput( - id: call.id, - toolName: tool.name, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(ToolInvocationResult(call: call, output: output)) - } catch { - if let delegate = session.toolExecutionDelegate { - await delegate.didFailToolCall(call, error: error, in: session) - } - throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) - } - } - } - - return .invocations(results) + return try await resolveToolCalls(calls, session: session) } // MARK: - Conversions diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index b062065c..c32e0368 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -515,7 +515,7 @@ public struct OpenAILanguageModel: LanguageModel { if let value = try? JSONValue(toolCallMessage) { messages.append(OpenAIMessage(role: .raw(rawContent: value), content: .text(""))) } - let resolution = try await resolveToolCalls(toolCalls, session: session) + let resolution = try await resolveOpenAIToolCalls(toolCalls, session: session) switch resolution { case .stop(let calls): if !calls.isEmpty { @@ -610,7 +610,7 @@ public struct OpenAILanguageModel: LanguageModel { messages.append(OpenAIMessage(role: .raw(rawContent: msg), content: .text(""))) } } - let resolution = try await resolveToolCalls(toolCalls, session: session) + let resolution = try await resolveOpenAIToolCalls(toolCalls, session: session) switch resolution { case .stop(let calls): if !calls.isEmpty { @@ -1596,120 +1596,23 @@ private struct OpenAIChatCompletionsChunk: Decodable, Sendable { let choices: [Choice] } -private struct OpenAIToolInvocationResult { - let call: Transcript.ToolCall - let output: Transcript.ToolOutput -} - -private enum OpenAIToolResolutionOutcome { - case stop(calls: [Transcript.ToolCall]) - case invocations([OpenAIToolInvocationResult]) -} - -private func resolveToolCalls( +private func resolveOpenAIToolCalls( _ toolCalls: [OpenAIToolCall], session: LanguageModelSession -) async throws -> OpenAIToolResolutionOutcome { - if toolCalls.isEmpty { return .invocations([]) } - - var toolsByName: [String: any Tool] = [:] - for tool in session.tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - - var transcriptCalls: [Transcript.ToolCall] = [] - transcriptCalls.reserveCapacity(toolCalls.count) +) async throws -> ToolResolutionOutcome { + var calls: [Transcript.ToolCall] = [] + calls.reserveCapacity(toolCalls.count) for call in toolCalls { guard let function = call.function else { continue } - let args = try toGeneratedContent(function.arguments) - let callID = call.id ?? UUID().uuidString - transcriptCalls.append( + calls.append( Transcript.ToolCall( - id: callID, + id: call.id ?? UUID().uuidString, toolName: function.name, - arguments: args + arguments: try toGeneratedContent(function.arguments) ) ) } - - if let delegate = session.toolExecutionDelegate { - await delegate.didGenerateToolCalls(transcriptCalls, in: session) - } - - guard !transcriptCalls.isEmpty else { return .invocations([]) } - - var decisions: [ToolExecutionDecision] = [] - decisions.reserveCapacity(transcriptCalls.count) - - if let delegate = session.toolExecutionDelegate { - for call in transcriptCalls { - let decision = await delegate.toolCallDecision(for: call, in: session) - if case .stop = decision { - return .stop(calls: transcriptCalls) - } - decisions.append(decision) - } - } else { - decisions = Array(repeating: .execute, count: transcriptCalls.count) - } - - var results: [OpenAIToolInvocationResult] = [] - results.reserveCapacity(transcriptCalls.count) - - for (index, call) in transcriptCalls.enumerated() { - switch decisions[index] { - case .stop: - // This branch should be unreachable because `.stop` returns during decision collection. - // Keep it as a defensive guard in case that logic changes. - return .stop(calls: transcriptCalls) - case .provideOutput(let segments): - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(OpenAIToolInvocationResult(call: call, output: output)) - case .execute: - guard let tool = toolsByName[call.toolName] else { - let message = Transcript.Segment.text(.init(content: "Tool not found: \(call.toolName)")) - let output = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: [message] - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(OpenAIToolInvocationResult(call: call, output: output)) - continue - } - - do { - let segments = try await tool.makeOutputSegments(from: call.arguments) - let output = Transcript.ToolOutput( - id: call.id, - toolName: tool.name, - segments: segments - ) - if let delegate = session.toolExecutionDelegate { - await delegate.didExecuteToolCall(call, output: output, in: session) - } - results.append(OpenAIToolInvocationResult(call: call, output: output)) - } catch { - if let delegate = session.toolExecutionDelegate { - await delegate.didFailToolCall(call, error: error, in: session) - } - throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) - } - } - } - - return .invocations(results) + return try await resolveToolCalls(calls, session: session) } // MARK: - Converters diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 9198cb9d..5cc15d1c 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -520,7 +520,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { messages.append(OpenResponsesMessage(role: .raw(rawContent: item), content: .text(""))) } } - let resolution = try await resolveToolCalls(toolCalls, session: session) + let resolution = try await resolveOpenResponsesToolCalls(toolCalls, session: session) switch resolution { case .stop(let calls): if !calls.isEmpty { @@ -1034,74 +1034,19 @@ private func extractJSONFromOutput(_ output: [JSONValue]?) -> String? { return nil } -private struct OpenResponsesToolInvocationResult: Sendable { - let call: Transcript.ToolCall - let output: Transcript.ToolOutput -} - -private enum OpenResponsesToolResolutionOutcome: Sendable { - case stop(calls: [Transcript.ToolCall]) - case invocations([OpenResponsesToolInvocationResult]) -} - -private func resolveToolCalls( +private func resolveOpenResponsesToolCalls( _ toolCalls: [OpenResponsesToolCall], session: LanguageModelSession -) async throws -> OpenResponsesToolResolutionOutcome { - if toolCalls.isEmpty { return .invocations([]) } - var byName: [String: any Tool] = [:] - for t in session.tools { if byName[t.name] == nil { byName[t.name] = t } } - var transcriptCalls: [Transcript.ToolCall] = [] - for c in toolCalls { - let args = (c.arguments.flatMap { try? GeneratedContent(json: $0) } ?? GeneratedContent(properties: [:])) - transcriptCalls.append(Transcript.ToolCall(id: c.id, toolName: c.name, arguments: args)) - } - if let d = session.toolExecutionDelegate { - await d.didGenerateToolCalls(transcriptCalls, in: session) - } - guard !transcriptCalls.isEmpty else { return .invocations([]) } - var decisions: [ToolExecutionDecision] = [] - if let d = session.toolExecutionDelegate { - for call in transcriptCalls { - let dec = await d.toolCallDecision(for: call, in: session) - if case .stop = dec { return .stop(calls: transcriptCalls) } - decisions.append(dec) - } - } else { - decisions = Array(repeating: .execute, count: transcriptCalls.count) - } - var results: [OpenResponsesToolInvocationResult] = [] - for (i, call) in transcriptCalls.enumerated() { - switch decisions[i] { - case .stop: - return .stop(calls: transcriptCalls) - case .provideOutput(let segs): - let out = Transcript.ToolOutput(id: call.id, toolName: call.toolName, segments: segs) - if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) } - results.append(OpenResponsesToolInvocationResult(call: call, output: out)) - case .execute: - guard let tool = byName[call.toolName] else { - let out = Transcript.ToolOutput( - id: call.id, - toolName: call.toolName, - segments: [.text(.init(content: "Tool not found: \(call.toolName)"))] - ) - if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) } - results.append(OpenResponsesToolInvocationResult(call: call, output: out)) - continue - } - do { - let segs = try await tool.makeOutputSegments(from: call.arguments) - let out = Transcript.ToolOutput(id: call.id, toolName: tool.name, segments: segs) - if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) } - results.append(OpenResponsesToolInvocationResult(call: call, output: out)) - } catch { - if let d = session.toolExecutionDelegate { await d.didFailToolCall(call, error: error, in: session) } - throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) - } - } +) async throws -> ToolResolutionOutcome { + let calls = toolCalls.map { call in + Transcript.ToolCall( + id: call.id, + toolName: call.name, + arguments: call.arguments.flatMap { try? GeneratedContent(json: $0) } + ?? GeneratedContent(properties: [:]) + ) } - return .invocations(results) + return try await resolveToolCalls(calls, session: session) } // MARK: - Streaming events diff --git a/Sources/AnyLanguageModel/ToolResolution.swift b/Sources/AnyLanguageModel/ToolResolution.swift new file mode 100644 index 00000000..5141a84c --- /dev/null +++ b/Sources/AnyLanguageModel/ToolResolution.swift @@ -0,0 +1,113 @@ +/// A tool call paired with the output produced for it. +struct ToolInvocationResult: Sendable { + let call: Transcript.ToolCall + let output: Transcript.ToolOutput +} + +/// The outcome of resolving a batch of model-generated tool calls. +enum ToolResolutionOutcome: Sendable { + /// The session's delegate asked to stop before any of the calls ran. + case stop(calls: [Transcript.ToolCall]) + + /// The calls that were handled, along with their outputs. + case invocations([ToolInvocationResult]) +} + +/// Executes model-generated tool calls, consulting the session's tool execution delegate. +/// +/// Every model maps its provider-specific tool calls onto ``Transcript/ToolCall`` values and then +/// hands them here, so the delegate contract behaves identically no matter which model produced +/// the calls. See ``ToolExecutionDelegate`` for what a delegate can decide. +/// +/// - Parameters: +/// - calls: The tool calls the model generated, in the order it produced them. +/// - session: The session whose tools and delegate handle the calls. +/// - Returns: ``ToolResolutionOutcome/stop(calls:)`` when the delegate halts the session, or +/// ``ToolResolutionOutcome/invocations(_:)`` with one result per call otherwise. +/// - Throws: ``LanguageModelSession/ToolCallError`` when a tool throws. +func resolveToolCalls( + _ calls: [Transcript.ToolCall], + session: LanguageModelSession +) async throws -> ToolResolutionOutcome { + guard !calls.isEmpty else { return .invocations([]) } + + var toolsByName: [String: any Tool] = [:] + for tool in session.tools where toolsByName[tool.name] == nil { + toolsByName[tool.name] = tool + } + + if let delegate = session.toolExecutionDelegate { + await delegate.didGenerateToolCalls(calls, in: session) + } + + var decisions: [ToolExecutionDecision] = [] + decisions.reserveCapacity(calls.count) + + if let delegate = session.toolExecutionDelegate { + for call in calls { + let decision = await delegate.toolCallDecision(for: call, in: session) + if case .stop = decision { + return .stop(calls: calls) + } + decisions.append(decision) + } + } else { + decisions = Array(repeating: .execute, count: calls.count) + } + + var results: [ToolInvocationResult] = [] + results.reserveCapacity(calls.count) + + for (index, call) in calls.enumerated() { + switch decisions[index] { + case .stop: + // Unreachable: `.stop` returns while decisions are collected. Kept as a guard in case + // that logic changes. + return .stop(calls: calls) + case .provideOutput(let segments): + let output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: segments + ) + if let delegate = session.toolExecutionDelegate { + await delegate.didExecuteToolCall(call, output: output, in: session) + } + results.append(ToolInvocationResult(call: call, output: output)) + case .execute: + guard let tool = toolsByName[call.toolName] else { + let message = Transcript.Segment.text(.init(content: "Tool not found: \(call.toolName)")) + let output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: [message] + ) + if let delegate = session.toolExecutionDelegate { + await delegate.didExecuteToolCall(call, output: output, in: session) + } + results.append(ToolInvocationResult(call: call, output: output)) + continue + } + + do { + let segments = try await tool.makeOutputSegments(from: call.arguments) + let output = Transcript.ToolOutput( + id: call.id, + toolName: tool.name, + segments: segments + ) + if let delegate = session.toolExecutionDelegate { + await delegate.didExecuteToolCall(call, output: output, in: session) + } + results.append(ToolInvocationResult(call: call, output: output)) + } catch { + if let delegate = session.toolExecutionDelegate { + await delegate.didFailToolCall(call, error: error, in: session) + } + throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) + } + } + } + + return .invocations(results) +}