From 0818f9868070b363897bccd43c1c64c386f03015 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Mon, 3 Aug 2026 11:56:36 -0400 Subject: [PATCH 1/4] Return FoundationModels tool entries from SystemLanguageModel.respond SystemLanguageModel.respond always returned an empty transcriptEntries slice, so tool calls and tool outputs that FoundationModels recorded were dropped instead of reaching session.transcript. Every other provider returns the tool-related entries it produced, and LanguageModelSession.respond appends them ahead of the response entry. FoundationModels does expose this: LanguageModelSession.Response carries transcriptEntries, and its Transcript.ToolCall/ToolOutput values hold the ids FoundationModels assigned. Convert those into their AnyLanguageModel equivalents and return them. Only .toolCalls and .toolOutput are converted; instructions, prompts, and responses are owned by LanguageModelSession and mirroring them would duplicate entries. This makes the previously #if false'd assertion in withTools real. Its old expectation was also wrong: it compared ToolOutput.id against "getWeather", but the tool name lives in toolName while id is the call identifier. Co-Authored-By: Claude Opus 5 (1M context) --- .../Models/SystemLanguageModel.swift | 67 ++++++++++++++++++- .../SystemLanguageModelTests.swift | 15 ++--- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index ff2e8f25..8398d25a 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -92,7 +92,9 @@ return LanguageModelSession.Response( content: fmResponse.content as! Content, rawContent: generatedContent, - transcriptEntries: [] + transcriptEntries: ArraySlice( + fmResponse.transcriptEntries.compactMap { toolTranscriptEntry(from: $0) } + ) ) } else { // For non-String types, use schema-based generation @@ -104,6 +106,10 @@ options: fmOptions ) + let toolEntries = ArraySlice( + fmResponse.transcriptEntries.compactMap { toolTranscriptEntry(from: $0) } + ) + func finalize(content: Content) -> LanguageModelSession.Response { let normalizedRaw = content.generatedContent if let jsonValue = try? JSONValue(normalizedRaw), @@ -114,13 +120,13 @@ return LanguageModelSession.Response( content: placeholder.content, rawContent: placeholder.rawContent, - transcriptEntries: [] + transcriptEntries: toolEntries ) } return LanguageModelSession.Response( content: content, rawContent: normalizedRaw, - transcriptEntries: [] + transcriptEntries: toolEntries ) } @@ -762,6 +768,61 @@ } } + // MARK: - FoundationModels to AnyLanguageModel Conversions + + /// Converts the tool-related entries of a FoundationModels transcript into their + /// AnyLanguageModel equivalents. + /// + /// Returns `nil` for every other entry kind. Instructions, prompts, and responses are owned by + /// ``LanguageModelSession`` itself, so mirroring them here would duplicate transcript entries. + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + private func toolTranscriptEntry(from entry: FoundationModels.Transcript.Entry) -> Transcript.Entry? { + if case .toolCalls(let fmToolCalls) = entry { + let calls = fmToolCalls.compactMap { call -> Transcript.ToolCall? in + guard let arguments = try? AnyLanguageModel.GeneratedContent(call.arguments) else { return nil } + return Transcript.ToolCall(id: call.id, toolName: call.toolName, arguments: arguments) + } + guard !calls.isEmpty else { return nil } + return .toolCalls(Transcript.ToolCalls(id: fmToolCalls.id, calls)) + } + + if case .toolOutput(let fmToolOutput) = entry { + return .toolOutput( + Transcript.ToolOutput( + id: fmToolOutput.id, + toolName: fmToolOutput.toolName, + segments: transcriptSegments(from: fmToolOutput.segments) + ) + ) + } + + return nil + } + + /// Converts FoundationModels transcript segments into their AnyLanguageModel equivalents. + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + private func transcriptSegments( + from segments: [FoundationModels.Transcript.Segment] + ) -> [Transcript.Segment] { + segments.compactMap { segment -> Transcript.Segment? in + if case .text(let textSegment) = segment { + return .text(.init(id: textSegment.id, content: textSegment.content)) + } + if case .structure(let structuredSegment) = segment, + let content = try? AnyLanguageModel.GeneratedContent(structuredSegment.content) + { + return .structure( + .init( + id: structuredSegment.id, + source: structuredSegment.source, + content: content + ) + ) + } + return nil + } + } + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension Array where Element == Transcript.ToolDefinition { fileprivate func toFoundationModels() -> [FoundationModels.Transcript.ToolDefinition] { diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index e8973549..da845ef4 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -174,14 +174,13 @@ import Testing let response = try await session.respond(to: "How's the weather in San Francisco?") - #if false // Disabled for now because transcript entries are not converted from FoundationModels for now - var foundToolOutput = false - for case let .toolOutput(toolOutput) in response.transcriptEntries { - #expect(toolOutput.id == "getWeather") - foundToolOutput = true - } - #expect(foundToolOutput) - #endif + var foundToolOutput = false + for case let .toolOutput(toolOutput) in response.transcriptEntries { + #expect(!toolOutput.id.isEmpty) + #expect(toolOutput.toolName == "getWeather") + foundToolOutput = true + } + #expect(foundToolOutput) let content = response.content #expect(content.contains("San Francisco")) From 2825b8ded8dd4cbcf80bf21dd413523ce2db2c6f Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Mon, 3 Aug 2026 11:57:06 -0400 Subject: [PATCH 2/4] Surface tool calls and outputs in the transcript while SystemLanguageModel streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers of streamResponse expect tool calls and their outputs to land in session.transcript while the stream is still running, with .toolCalls ahead of the matching .toolOutput. SystemLanguageModel surfaced neither: it never touched session.transcript during a stream, so a Transcript-driven UI saw nothing until LanguageModelSession appended the final response. FoundationModels gives us no tool channel on the stream itself — ResponseStream.Snapshot carries only content and rawContent. Its LanguageModelSession.transcript is the only place tool activity shows up, and it grows as the turn progresses, so mirror new tool entries out of it into session.transcript. Mirroring in the order FoundationModels recorded them is what gives the .toolCalls before .toolOutput ordering; the ids are the ones FoundationModels assigned rather than anything synthesized here. Response text now also grows the transcript as it streams, mirroring after tool entries so the turn reads .toolCalls, .toolOutput, .response. Mirroring happens on snapshot boundaries instead of the instant a call is recorded, because polling concurrently would share a non-Sendable FoundationModels.LanguageModelSession across tasks. Tool entries therefore appear with the first snapshot after the tool ran, still mid-stream, plus a final sweep before the stream finishes. session.toolExecutionDelegate remains unconsulted, and AnyToolWrapper documents why. FoundationModels owns the tool loop and hands a tool only its arguments — no call id, no session, no view of sibling calls — so didGenerateToolCalls has no batch to report, the callbacks taking Transcript.ToolCall have no real id to pass, and .stop has no expression at all. Honoring it partially would silently execute a tool the caller asked to stop. Co-Authored-By: Claude Opus 5 (1M context) --- .../Models/SystemLanguageModel.swift | 74 +++++++++++++++++++ .../SystemLanguageModelTests.swift | 55 ++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 8398d25a..1b4971e0 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -173,9 +173,38 @@ ) ) + // Entries the session was seeded with. Anything FoundationModels records beyond this + // point belongs to the turn we are about to stream. + let seededEntryCount = fmSession.transcript.count + let stream: AsyncThrowingStream.Snapshot, Error> = AsyncThrowingStream { continuation in + /// Copies newly recorded FoundationModels tool entries into `session.transcript`. + /// + /// FoundationModels runs the tool-calling loop itself, and its + /// `ResponseStream.Snapshot` carries only `content` and `rawContent` — it has no + /// tool channel at all. The session's own transcript is therefore the only place + /// tool activity is observable, so we replay new entries from it as the turn + /// progresses. Replaying in FoundationModels' recorded order is what keeps a + /// `.toolCalls` entry ahead of its matching `.toolOutput`. + /// + /// - Note: This runs on snapshot boundaries rather than the instant + /// FoundationModels records a call, because polling the session concurrently + /// would mean sharing a non-`Sendable` `FoundationModels.LanguageModelSession` + /// across tasks. Tool entries therefore land with the first snapshot generated + /// after the tool ran, which is still while the stream is in progress. + func mirrorToolEntries(mirroredEntryCount: inout Int) { + let fmEntries = Array(fmSession.transcript) + guard fmEntries.count > mirroredEntryCount else { return } + for entry in fmEntries[mirroredEntryCount...] { + if let toolEntry = toolTranscriptEntry(from: entry) { + session.appendTranscriptEntry(toolEntry) + } + } + mirroredEntryCount = fmEntries.count + } + func accumulateText( _ chunkText: String, accumulatedText: inout String, @@ -203,6 +232,7 @@ fmSession.streamResponse(to: fmPrompt, options: fmOptions) var accumulatedText = "" + var mirroredEntryCount = seededEntryCount do { var lastLength = 0 for try await snapshot in fmStream { @@ -219,10 +249,18 @@ lastLength: &lastLength ) + // Surface tool activity before any response text so the transcript + // keeps `.toolCalls` -> `.toolOutput` -> `.response` ordering. + mirrorToolEntries(mirroredEntryCount: &mirroredEntryCount) + if !accumulatedText.isEmpty { + session.growStreamingTranscript(text: accumulatedText) + } + let raw = GeneratedContent(accumulatedText) let snapshotContent = (accumulatedText as! Content).asPartiallyGenerated() continuation.yield(.init(content: snapshotContent, rawContent: raw)) } + mirrorToolEntries(mirroredEntryCount: &mirroredEntryCount) continuation.finish() } catch { continuation.finish(throwing: error) @@ -239,6 +277,8 @@ options: fmOptions ) + var mirroredEntryCount = seededEntryCount + func processTextFallback() async { let fmTextStream: FoundationModels.LanguageModelSession.ResponseStream = fmSession.streamResponse(to: fmPrompt, options: fmOptions) @@ -259,6 +299,11 @@ lastLength: &lastLength ) + mirrorToolEntries(mirroredEntryCount: &mirroredEntryCount) + if !accumulatedText.isEmpty { + session.growStreamingTranscript(text: accumulatedText) + } + let jsonString = accumulatedText if let partialContent = try? partialDecoder.decode( GeneratedContent.self, @@ -277,6 +322,7 @@ .init(content: placeholder.content, rawContent: placeholder.rawContent) ) } + mirrorToolEntries(mirroredEntryCount: &mirroredEntryCount) continuation.finish() } catch { if !didYield, let placeholder = placeholderPartialContent(for: type) { @@ -292,6 +338,12 @@ do { for try await snapshot in fmStream { let jsonString = snapshot.content.jsonString + + mirrorToolEntries(mirroredEntryCount: &mirroredEntryCount) + if !jsonString.isEmpty, jsonString != "null" { + session.growStreamingTranscript(text: jsonString) + } + let raw = (try? GeneratedContent(snapshot.content)) ?? (try? GeneratedContent(json: jsonString)) @@ -323,6 +375,7 @@ .init(content: placeholder.content, rawContent: placeholder.rawContent) ) } + mirrorToolEntries(mirroredEntryCount: &mirroredEntryCount) continuation.finish() } catch { if didYield { @@ -448,6 +501,27 @@ } /// A type-erased wrapper that bridges any `Tool` to `FoundationModels.Tool`. + /// + /// - Note: Unlike every other provider, this bridge does not consult + /// `session.toolExecutionDelegate`, and that is a deliberate gap rather than an oversight. + /// FoundationModels owns the tool-calling loop and invokes a tool through + /// `FoundationModels.Tool.call(arguments:)`, which receives the decoded arguments and nothing + /// else — no call identifier, no session, no view of the sibling calls in the same batch. + /// That makes the delegate contract unrepresentable here: + /// + /// - `didGenerateToolCalls` is defined over the whole batch of calls the model produced, + /// before any of them run. FoundationModels only ever surfaces a single invocation, and + /// only once it has already decided to make it. + /// - `toolCallDecision`, `didExecuteToolCall`, and `didFailToolCall` all take a + /// `Transcript.ToolCall`, whose `id` is assigned by FoundationModels and is not visible + /// from inside the tool. Synthesizing an id would hand the delegate an identifier that + /// never matches the `.toolCalls` entry that later appears in the transcript. + /// - `.stop` has no expression at all. FoundationModels offers no way to halt a turn from + /// inside a tool; throwing would surface an error to the caller instead of the contract's + /// clean stop with the tool calls recorded. + /// + /// Honoring the delegate partially would be worse than not honoring it: a caller returning + /// `.stop` would have its tool executed anyway, silently. So the delegate is not consulted. @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) private struct AnyToolWrapper: FoundationModels.Tool { typealias Arguments = FoundationModels.GeneratedContent diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index da845ef4..4146f9d5 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -187,6 +187,61 @@ import Testing #expect(content.contains("72°F")) } + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func streamWithTools() async throws { + let weatherTool = WeatherTool() + let session = LanguageModelSession(model: SystemLanguageModel.default, tools: [weatherTool]) + + let stream = session.streamResponse(to: "How's the weather in San Francisco?") + + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + + var toolAppearedInTranscript: Bool = false + var toolResponseAppearedInTranscript: Bool = false + + for try await snapshot in stream { + snapshots.append(snapshot) + + for entry in session.transcript { + switch entry { + case .toolCalls: + toolAppearedInTranscript = true + case .toolOutput: + toolResponseAppearedInTranscript = true + default: break + } + } + } + + #expect(!snapshots.isEmpty) + #expect(toolAppearedInTranscript, "Expected a tool call to appear in the transcript during streaming.") + #expect( + toolResponseAppearedInTranscript, + "Expected a tool output to appear in the transcript during streaming." + ) + + // The tool call must be recorded before the output it produced. + let toolCallIndex = session.transcript.firstIndex { entry in + if case .toolCalls = entry { return true } else { return false } + } + let toolOutputIndex = session.transcript.firstIndex { entry in + if case .toolOutput = entry { return true } else { return false } + } + #expect(toolCallIndex != nil) + #expect(toolOutputIndex != nil) + if let toolCallIndex, let toolOutputIndex { + #expect(toolCallIndex < toolOutputIndex, "Expected .toolCalls to precede .toolOutput.") + } + + var foundToolOutput = false + for case let .toolOutput(toolOutput) in session.transcript { + #expect(!toolOutput.id.isEmpty) + #expect(toolOutput.toolName == "getWeather") + foundToolOutput = true + } + #expect(foundToolOutput, "Expected the 'getWeather' tool to exist in the final transcript.") + } + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test func conversationContext() async throws { let model: SystemLanguageModel = SystemLanguageModel() From 56ca70cede513cb6a5334bdcb52e7b8da63dbe8d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 4 Aug 2026 11:44:23 -0400 Subject: [PATCH 3/4] Filter FoundationModels entries explicitly and explain the failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a reader shouldn't have to infer. `toolTranscriptEntry` returned `nil` both for entries that aren't tool activity and for entries whose contents fail to convert, and a bare `compactMap` at the call sites made the first case look like an accidental over-filter — as if prompts and responses were being dropped by mistake. They are dropped deliberately: `LanguageModelSession` appends the prompt before calling the model and the response after, so carrying FoundationModels' own copies back would duplicate them. `isToolActivity(_:)` now states that at each call site, leaving `compactMap` to mean only "conversion failed". The tool tests also asserted with bare `#expect`s, so a failure reported a boolean with no indication of what was expected. Each now carries the reason, matching the ones that already did. --- .../Models/SystemLanguageModel.swift | 32 +++++++++++++++---- .../SystemLanguageModelTests.swift | 23 +++++++------ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 1b4971e0..f048e6fa 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -93,7 +93,9 @@ content: fmResponse.content as! Content, rawContent: generatedContent, transcriptEntries: ArraySlice( - fmResponse.transcriptEntries.compactMap { toolTranscriptEntry(from: $0) } + fmResponse.transcriptEntries + .filter(isToolActivity) + .compactMap { toolTranscriptEntry(from: $0) } ) ) } else { @@ -107,7 +109,9 @@ ) let toolEntries = ArraySlice( - fmResponse.transcriptEntries.compactMap { toolTranscriptEntry(from: $0) } + fmResponse.transcriptEntries + .filter(isToolActivity) + .compactMap { toolTranscriptEntry(from: $0) } ) func finalize(content: Content) -> LanguageModelSession.Response { @@ -197,7 +201,7 @@ func mirrorToolEntries(mirroredEntryCount: inout Int) { let fmEntries = Array(fmSession.transcript) guard fmEntries.count > mirroredEntryCount else { return } - for entry in fmEntries[mirroredEntryCount...] { + for entry in fmEntries[mirroredEntryCount...] where isToolActivity(entry) { if let toolEntry = toolTranscriptEntry(from: entry) { session.appendTranscriptEntry(toolEntry) } @@ -844,11 +848,25 @@ // MARK: - FoundationModels to AnyLanguageModel Conversions - /// Converts the tool-related entries of a FoundationModels transcript into their - /// AnyLanguageModel equivalents. + /// Whether a FoundationModels entry records tool activity. /// - /// Returns `nil` for every other entry kind. Instructions, prompts, and responses are owned by - /// ``LanguageModelSession`` itself, so mirroring them here would duplicate transcript entries. + /// Only these entries are carried back. Instructions, prompts, and responses are owned by + /// ``LanguageModelSession`` itself — it appends the prompt before calling the model and the + /// response after — so mirroring them here would duplicate transcript entries. + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + private func isToolActivity(_ entry: FoundationModels.Transcript.Entry) -> Bool { + switch entry { + case .toolCalls, .toolOutput: return true + default: return false + } + } + + /// Converts a tool-related FoundationModels transcript entry into its AnyLanguageModel + /// equivalent. + /// + /// Returns `nil` when the entry's contents can't be converted, and for entries that aren't + /// tool activity — callers filter with ``isToolActivity(_:)`` first so that the two cases + /// stay distinguishable. @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) private func toolTranscriptEntry(from entry: FoundationModels.Transcript.Entry) -> Transcript.Entry? { if case .toolCalls(let fmToolCalls) = entry { diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index 4146f9d5..50ad3ed7 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -176,15 +176,18 @@ import Testing var foundToolOutput = false for case let .toolOutput(toolOutput) in response.transcriptEntries { - #expect(!toolOutput.id.isEmpty) - #expect(toolOutput.toolName == "getWeather") + #expect(!toolOutput.id.isEmpty, "Expected the tool output to carry the id FoundationModels assigned.") + #expect(toolOutput.toolName == "getWeather", "Expected the output to name the tool that produced it.") foundToolOutput = true } - #expect(foundToolOutput) + #expect(foundToolOutput, "Expected a tool output among the response's transcript entries.") let content = response.content - #expect(content.contains("San Francisco")) - #expect(content.contains("72°F")) + #expect( + content.contains("San Francisco"), + "Expected the answer to mention the city the tool was asked about." + ) + #expect(content.contains("72°F"), "Expected the tool's result to reach the model's answer.") } @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) @@ -213,7 +216,7 @@ import Testing } } - #expect(!snapshots.isEmpty) + #expect(!snapshots.isEmpty, "Expected the stream to yield at least one snapshot.") #expect(toolAppearedInTranscript, "Expected a tool call to appear in the transcript during streaming.") #expect( toolResponseAppearedInTranscript, @@ -227,16 +230,16 @@ import Testing let toolOutputIndex = session.transcript.firstIndex { entry in if case .toolOutput = entry { return true } else { return false } } - #expect(toolCallIndex != nil) - #expect(toolOutputIndex != nil) + #expect(toolCallIndex != nil, "Expected a .toolCalls entry in the final transcript.") + #expect(toolOutputIndex != nil, "Expected a .toolOutput entry in the final transcript.") if let toolCallIndex, let toolOutputIndex { #expect(toolCallIndex < toolOutputIndex, "Expected .toolCalls to precede .toolOutput.") } var foundToolOutput = false for case let .toolOutput(toolOutput) in session.transcript { - #expect(!toolOutput.id.isEmpty) - #expect(toolOutput.toolName == "getWeather") + #expect(!toolOutput.id.isEmpty, "Expected the tool output to carry the id FoundationModels assigned.") + #expect(toolOutput.toolName == "getWeather", "Expected the output to name the tool that produced it.") foundToolOutput = true } #expect(foundToolOutput, "Expected the 'getWeather' tool to exist in the final transcript.") From b1006202d6bfa1768628ac4e4dbd78f189d6585b Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 4 Aug 2026 12:59:02 -0400 Subject: [PATCH 4/4] Name the FoundationModels entry conversion for what it returns `toolTranscriptEntry` read as though it converted any transcript entry, which made the `compactMap` at its call sites look like it was discarding prompts and responses by accident. It only ever yields tool activity, so `toolActivityEntry(from:)` says that, and the `nil` result reads as "not tool activity" rather than a dropped conversion. The doc comment keeps the reason those entries are skipped: `LanguageModelSession` appends the prompt before calling the model and the response after, so mirroring FoundationModels' copies would duplicate them. --- .../Models/SystemLanguageModel.swift | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index f048e6fa..bd73d285 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -93,9 +93,7 @@ content: fmResponse.content as! Content, rawContent: generatedContent, transcriptEntries: ArraySlice( - fmResponse.transcriptEntries - .filter(isToolActivity) - .compactMap { toolTranscriptEntry(from: $0) } + fmResponse.transcriptEntries.compactMap { toolActivityEntry(from: $0) } ) ) } else { @@ -109,9 +107,7 @@ ) let toolEntries = ArraySlice( - fmResponse.transcriptEntries - .filter(isToolActivity) - .compactMap { toolTranscriptEntry(from: $0) } + fmResponse.transcriptEntries.compactMap { toolActivityEntry(from: $0) } ) func finalize(content: Content) -> LanguageModelSession.Response { @@ -201,8 +197,8 @@ func mirrorToolEntries(mirroredEntryCount: inout Int) { let fmEntries = Array(fmSession.transcript) guard fmEntries.count > mirroredEntryCount else { return } - for entry in fmEntries[mirroredEntryCount...] where isToolActivity(entry) { - if let toolEntry = toolTranscriptEntry(from: entry) { + for entry in fmEntries[mirroredEntryCount...] { + if let toolEntry = toolActivityEntry(from: entry) { session.appendTranscriptEntry(toolEntry) } } @@ -848,27 +844,14 @@ // MARK: - FoundationModels to AnyLanguageModel Conversions - /// Whether a FoundationModels entry records tool activity. + /// Converts a FoundationModels transcript entry into its AnyLanguageModel equivalent if it + /// records tool activity. /// - /// Only these entries are carried back. Instructions, prompts, and responses are owned by + /// Returns `nil` for every other entry kind. Instructions, prompts, and responses are owned by /// ``LanguageModelSession`` itself — it appends the prompt before calling the model and the /// response after — so mirroring them here would duplicate transcript entries. @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) - private func isToolActivity(_ entry: FoundationModels.Transcript.Entry) -> Bool { - switch entry { - case .toolCalls, .toolOutput: return true - default: return false - } - } - - /// Converts a tool-related FoundationModels transcript entry into its AnyLanguageModel - /// equivalent. - /// - /// Returns `nil` when the entry's contents can't be converted, and for entries that aren't - /// tool activity — callers filter with ``isToolActivity(_:)`` first so that the two cases - /// stay distinguishable. - @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) - private func toolTranscriptEntry(from entry: FoundationModels.Transcript.Entry) -> Transcript.Entry? { + private func toolActivityEntry(from entry: FoundationModels.Transcript.Entry) -> Transcript.Entry? { if case .toolCalls(let fmToolCalls) = entry { let calls = fmToolCalls.compactMap { call -> Transcript.ToolCall? in guard let arguments = try? AnyLanguageModel.GeneratedContent(call.arguments) else { return nil }