From 760fe11aeb3cb35903ec7f021e149089d861ed3c Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 9 Oct 2026 16:00:01 +0800 Subject: [PATCH 01/53] Add Foundation Models 27 prompt attachments --- README.md | 31 ++- Sources/AnyLanguageModel/Attachment.swift | 107 +++++++++++ .../LanguageModelSession.swift | 18 +- .../Models/FoundationLanguageModel.swift | 10 +- .../Models/SystemLanguageModel.swift | 52 ++++- Sources/AnyLanguageModel/Prompt.swift | 104 ++++++++-- .../PromptAttachmentTests.swift | 181 ++++++++++++++++++ 7 files changed, 476 insertions(+), 27 deletions(-) create mode 100644 Sources/AnyLanguageModel/Attachment.swift create mode 100644 Tests/AnyLanguageModelTests/PromptAttachmentTests.swift diff --git a/README.md b/README.md index 5a969f0b..6826b869 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,31 @@ pass the model's multimodal projector with `mmprojPath:`. > Image inputs are an AnyLanguageModel extension. > See [Differences from Foundation Models](#differences-from-foundation-models). +### Prompt attachments + +Image attachments use the Foundation Models 27 prompt construction syntax: + +```swift +let prompt = Prompt { + "Describe this image" + Attachment(imageURL: fileURL) +} +``` + +On Apple platforms, `Attachment(CGImage, orientation:)` retains typed image +content and orientation. Prompt builders preserve the order of text and images, +and the OS 26 compatibility transcript continues to store images using its +existing `Transcript.Segment.image` Codable representation. + +`SystemLanguageModel` and `FoundationLanguageModel` bridge prompt attachments +to Foundation Models on OS 27. Earlier system runtimes reject them explicitly. +The system bridge requires local file URLs; other provider adapters retain URL +images in their existing transcript representation. + +This first compatibility surface covers prompt `CGImage` and `imageURL` +attachments. Tool outputs, instruction attachments, labels, `CIImage`, pixel +buffers, and newer data attachments remain outside this change. + ### Tool Calling Tool calling is supported by all providers. @@ -611,8 +636,8 @@ say which API they follow. described in that provider's section under [Providers](#providers). - `respond(to:images:)`, `Transcript.ImageSegment`, and `Transcript.Segment.image`: [image inputs](#image-inputs). - Foundation Models 27 adds prompt attachments, - and AnyLanguageModel 2.0 will change to match them. + Foundation Models 27-style [prompt attachments](#prompt-attachments) are also + available for ordered text and image prompt construction. - `ToolExecutionDelegate`, `ToolExecutionDecision`, and `toolExecutionDelegate`: [observing and controlling tool calls](#tool-calling). - `transcriptErrorHandlingPolicy` and `waitForResponseCompletion()`: @@ -645,7 +670,7 @@ say which API they follow. ### Behavior differences - A session adds the prompt to its transcript before the model responds, - as a single text segment. + preserving its text and image segment order. - A structured response is recorded in the transcript as JSON text, not as a structured segment. - A session created from a transcript restores `instructions` diff --git a/Sources/AnyLanguageModel/Attachment.swift b/Sources/AnyLanguageModel/Attachment.swift new file mode 100644 index 00000000..a0cc88c0 --- /dev/null +++ b/Sources/AnyLanguageModel/Attachment.swift @@ -0,0 +1,107 @@ +import Foundation + +#if canImport(CoreGraphics) && canImport(ImageIO) + import CoreGraphics + import ImageIO +#endif + +/// A typed prompt attachment, following Foundation Models 27's image surface. +/// +/// AnyLanguageModel lowers image attachments into its existing compatibility +/// transcript image segments on deployment targets before OS 27. +public struct Attachment { + let content: Content +} + +/// Image content retained until a prompt is lowered into the compatibility transcript. +public struct ImageAttachmentContent: Sendable, Equatable { + enum Source: Sendable { + case url(URL, orientation: UInt32?) + #if canImport(CoreGraphics) && canImport(ImageIO) + case image(CGImage, orientation: CGImagePropertyOrientation?) + #endif + } + + let source: Source + + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs.source, rhs.source) { + case (.url(let a, let ao), .url(let b, let bo)): + return a == b && ao == bo + #if canImport(CoreGraphics) && canImport(ImageIO) + case (.image(let a, let ao), .image(let b, let bo)): + return a === b && ao == bo + #endif + default: + return false + } + } + + func makeSegment(id: String) throws -> Transcript.ImageSegment { + switch source { + case .url(let url, let orientation): + #if canImport(CoreGraphics) && canImport(ImageIO) + if let orientation { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + throw Transcript.ImageEncodingError.imageConversionFailed + } + return try Self( + source: .image( + image, + orientation: CGImagePropertyOrientation(rawValue: orientation) + ) + ).makeSegment(id: id) + } + #endif + return .init(id: id, url: url) + #if canImport(CoreGraphics) && canImport(ImageIO) + case .image(let image, let orientation): + let bytes = NSMutableData() + guard + let destination = CGImageDestinationCreateWithData( + bytes, + "public.png" as CFString, + 1, + nil + ) + else { + throw Transcript.ImageEncodingError.imageConversionFailed + } + var properties: [CFString: Any] = [:] + if let orientation { + properties[kCGImagePropertyOrientation] = orientation.rawValue + } + CGImageDestinationAddImage(destination, image, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + throw Transcript.ImageEncodingError.imageConversionFailed + } + return .init(id: id, data: bytes as Data, mimeType: "image/png") + #endif + } + } +} + +extension Attachment where Content == ImageAttachmentContent { + #if canImport(CoreGraphics) && canImport(ImageIO) + public init(_ image: CGImage, orientation: CGImagePropertyOrientation? = nil) { + content = .init(source: .image(image, orientation: orientation)) + } + + public init(imageURL: URL, orientation: CGImagePropertyOrientation? = nil) { + // Preserve the URL itself; its bytes are not read merely by constructing a prompt. + content = .init(source: .url(imageURL, orientation: orientation?.rawValue)) + } + #else + public init(imageURL: URL) { + content = .init(source: .url(imageURL, orientation: nil)) + } + #endif +} + +extension Attachment: PromptRepresentable where Content == ImageAttachmentContent { + public var promptRepresentation: Prompt { + Prompt(components: [.image(id: UUID().uuidString, content: content)]) + } +} diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 4f08efc6..b7775ce3 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -728,7 +728,7 @@ public final class LanguageModelSession: @unchecked Sendable { let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( id: promptID, - segments: [.text(.init(content: prompt.description))], + segments: try prompt.makeTranscriptSegments(), options: options, responseFormat: responseFormat ) @@ -776,9 +776,15 @@ public final class LanguageModelSession: @unchecked Sendable { options: GenerationOptions = GenerationOptions() ) -> sending ResponseStream where Content: Generable { // Add prompt to transcript + let segments: [Transcript.Segment] + do { + segments = try prompt.makeTranscriptSegments() + } catch { + return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) + } let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( - segments: [.text(.init(content: prompt.description))], + segments: segments, options: options, responseFormat: type == String.self ? nil : .init(type: type) ) @@ -924,9 +930,15 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) -> sending ResponseStream { + let segments: [Transcript.Segment] + do { + segments = try prompt.makeTranscriptSegments() + } catch { + return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) + } let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( - segments: [.text(.init(content: prompt.description))], + segments: segments, options: options, responseFormat: .init(schema: schema) ) diff --git a/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift b/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift index b5bf8599..6c54fcec 100644 --- a/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/FoundationLanguageModel.swift @@ -159,7 +159,7 @@ ) async throws -> LanguageModelSession.Response where Content: Generable { return try await fmRespond( makeSession: { try await self.makeSession(for: session, prompt: prompt) }, - fmPrompt: prompt.toFoundationModels(), + fmPrompt: try prompt.toFoundationModels(), fmOptions: options.toFoundationModels(), type: type, schema: schema, @@ -209,9 +209,15 @@ includeSchemaInPrompt: Bool, options: GenerationOptions ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let fmPrompt: FoundationModels.Prompt + do { + fmPrompt = try prompt.toFoundationModels() + } catch { + return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) }) + } return fmStreamResponse( makeSession: { try await self.makeSession(for: session, prompt: prompt) }, - fmPrompt: prompt.toFoundationModels(), + fmPrompt: fmPrompt, fmOptions: options.toFoundationModels(), type: type, schema: schema, diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 87763ed6..d66ab692 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -194,7 +194,7 @@ includeSchemaInPrompt: Bool, options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { - let fmPrompt = prompt.toFoundationModels() + let fmPrompt = try prompt.toFoundationModels() let fmOptions = options.toFoundationModels() return try await fmRespond( @@ -249,7 +249,12 @@ includeSchemaInPrompt: Bool, options: GenerationOptions ) -> sending LanguageModelSession.ResponseStream where Content: Generable { - let fmPrompt = prompt.toFoundationModels() + let fmPrompt: FoundationModels.Prompt + do { + fmPrompt = try prompt.toFoundationModels() + } catch { + return .init(stream: AsyncThrowingStream { $0.finish(throwing: error) }) + } let fmOptions = options.toFoundationModels() return fmStreamResponse( @@ -430,8 +435,42 @@ @available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) extension Prompt { - func toFoundationModels() -> FoundationModels.Prompt { - FoundationModels.Prompt(self.description) + func toFoundationModels() throws -> FoundationModels.Prompt { + let prompts: [FoundationModels.Prompt] = try components.map { component in + switch component { + case .text(let text): + return FoundationModels.Prompt(text.content) + case .image(_, let image): + #if compiler(>=6.4) && !os(tvOS) && !os(watchOS) + if #available(macOS 27, iOS 27, visionOS 27, *) { + switch image.source { + case .url(let url, let orientation): + guard url.isFileURL else { + throw PromptBridgeError.nonFileImageURL + } + return FoundationModels.Prompt( + FoundationModels.Attachment( + imageURL: url, + orientation: orientation.flatMap( + CGImagePropertyOrientation.init(rawValue:) + ) + ) + ) + case .image(let value, let orientation): + return FoundationModels.Prompt( + FoundationModels.Attachment(value, orientation: orientation) + ) + } + } + #endif + throw PromptBridgeError.attachmentsUnavailable + } + } + return FoundationModels.Prompt { + for prompt in prompts { + prompt + } + } } } @@ -517,6 +556,11 @@ } } + enum PromptBridgeError: Error { + case attachmentsUnavailable + case nonFileImageURL + } + /// A type-erased wrapper that bridges any `Tool` to `FoundationModels.Tool`. @available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) private struct AnyToolWrapper: FoundationModels.Tool { diff --git a/Sources/AnyLanguageModel/Prompt.swift b/Sources/AnyLanguageModel/Prompt.swift index 7e099134..5d215051 100644 --- a/Sources/AnyLanguageModel/Prompt.swift +++ b/Sources/AnyLanguageModel/Prompt.swift @@ -1,3 +1,5 @@ +import Foundation + /// A prompt from a person to the model. /// /// Prompts can contain content written by you, an outside source, or input directly from people using @@ -24,7 +26,12 @@ /// own prompt to better steer the model's response. For more information on handling inputs in your /// prompts, see . public struct Prompt: Sendable { - private let content: String + enum Component: Sendable { + case text(Transcript.TextSegment) + case image(id: String, content: ImageAttachmentContent) + } + + let components: [Component] /// Creates an instance with the content you specify. public init(_ representable: some PromptRepresentable) { @@ -34,12 +41,76 @@ public struct Prompt: Sendable { case let string as String: self.init(content: string) default: - self.init(content: representable.promptRepresentation.content) + self = representable.promptRepresentation } } init(content: String) { - self.content = content + components = [.text(.init(content: content))] + } + + init(components: [Component]) { + self.components = components + } + + func makeTranscriptSegments() throws -> [Transcript.Segment] { + try components.map { component in + switch component { + case .text(let text): + return .text(text) + case .image(let id, let content): + return .image(try content.makeSegment(id: id)) + } + } + } + + static func joining(_ prompts: [Prompt], trim: Bool = false) -> Prompt { + var components: [Component] = [] + for prompt in prompts { + for component in prompt.components { + if case .text(let text) = component, let last = components.last, + case .text(let previous) = last + { + components[components.count - 1] = .text( + .init( + id: previous.id, + content: previous.content + "\n" + text.content + ) + ) + } else { + components.append(component) + } + } + } + + if trim { + if let first = components.first, case .text(let text) = first { + components[0] = .text( + .init( + id: text.id, + content: text.content.replacingOccurrences( + of: #"^\s+"#, + with: "", + options: .regularExpression + ) + ) + ) + } + if let last = components.last, case .text(let text) = last { + components[components.count - 1] = .text( + .init( + id: text.id, + content: text.content.replacingOccurrences( + of: #"\s+$"#, + with: "", + options: .regularExpression + ) + ) + ) + } + } + + return Prompt(components: components) } public init(@PromptBuilder _ content: () throws -> Prompt) rethrows { @@ -50,7 +121,16 @@ public struct Prompt: Sendable { // MARK: - CustomStringConvertible extension Prompt: CustomStringConvertible { - public var description: String { content } + public var description: String { + components.map { component in + switch component { + case .text(let text): + return text.content + case .image: + return "" + } + }.joined(separator: "\n") + } } // MARK: - PromptBuilder @@ -59,10 +139,9 @@ extension Prompt: CustomStringConvertible { public struct PromptBuilder { public static func buildBlock(_ components: repeat each P) -> Prompt where repeat each P: PromptRepresentable { - var parts: [String] = [] - repeat parts.append((each components).promptRepresentation.description) - let combinedText = parts.joined(separator: "\n") - return Prompt(content: combinedText.trimmingCharacters(in: .whitespacesAndNewlines)) + var parts: [Prompt] = [] + repeat parts.append((each components).promptRepresentation) + return Prompt.joining(parts, trim: true) } public static func buildExpression

(_ expression: P) -> P where P: PromptRepresentable { @@ -70,10 +149,7 @@ public struct PromptBuilder { } public static func buildArray(_ prompts: [some PromptRepresentable]) -> Prompt { - let combinedText = prompts.map { - $0.promptRepresentation.description - }.joined(separator: "\n") - return Prompt(content: combinedText) + Prompt.joining(prompts.map(\.promptRepresentation)) } public static func buildOptional(_ component: Prompt?) -> Prompt { @@ -120,8 +196,6 @@ extension String: PromptRepresentable { extension Array: PromptRepresentable where Element: PromptRepresentable { /// An instance that represents a prompt. public var promptRepresentation: Prompt { - let combined = self.map { $0.promptRepresentation.description } - .joined(separator: "\n") - return Prompt(content: combined) + Prompt.joining(map(\.promptRepresentation)) } } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift new file mode 100644 index 00000000..df184ca5 --- /dev/null +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +#if canImport(CoreGraphics) && canImport(ImageIO) + import CoreGraphics + import ImageIO +#endif +#if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + import FoundationModels +#endif + +@Suite("Prompt attachments") +struct PromptAttachmentTests { + private let imageURL = URL(fileURLWithPath: "/tmp/attachment-fixture.png") + + private func prompt() -> AnyLanguageModel.Prompt { + AnyLanguageModel.Prompt { + "before" + AnyLanguageModel.Attachment(imageURL: imageURL) + "after" + } + } + + @Test func buildersAndRepresentablesPreserveOrderedImages() throws { + let original = prompt() + struct Representable: AnyLanguageModel.PromptRepresentable { + let promptRepresentation: AnyLanguageModel.Prompt + } + let wrapped = AnyLanguageModel.Prompt( + Representable(promptRepresentation: original) + ) + let composed = AnyLanguageModel.Prompt { + if true { + wrapped + } + for item in [original] { + item + } + } + + let expected = try original.makeTranscriptSegments() + #expect(try wrapped.makeTranscriptSegments() == expected) + let combined = try composed.makeTranscriptSegments() + #expect(combined.count == 5) + let sources: [AnyLanguageModel.Transcript.ImageSegment.Source] = combined.compactMap { + if case .image(let value) = $0 { + return value.source + } + return nil + } + let texts: [String] = combined.compactMap { + if case .text(let value) = $0 { + return value.content + } + return nil + } + #expect(sources == [.url(imageURL), .url(imageURL)]) + #expect(texts == ["before", "after\nbefore", "after"]) + #expect(original.description == "before\n\nafter") + #expect( + try AnyLanguageModel.Prompt([original, original]).makeTranscriptSegments() + == combined + ) + } + + @Test func directSessionPromptsRetainImagesInBothModes() async throws { + for streaming in [false, true] { + let session = AnyLanguageModel.LanguageModelSession(model: MockLanguageModel()) + let input = prompt() + if streaming { + _ = try await session.streamResponse(to: input).collect() + } else { + _ = try await session.respond(to: input) + } + + let first = try #require(session.transcript.first) + guard case .prompt(let recorded) = first else { + Issue.record("Missing prompt") + return + } + #expect(recorded.segments == (try input.makeTranscriptSegments())) + } + } + + #if canImport(CoreGraphics) && canImport(ImageIO) + private func image() throws -> CGImage { + let context = try #require( + CGContext( + data: nil, + width: 2, + height: 3, + bitsPerComponent: 8, + bytesPerRow: 8, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + ) + return try #require(context.makeImage()) + } + + @Test func imageEncodingRetainsPixelsAndOrientation() throws { + let value = try image() + let input = AnyLanguageModel.Prompt { + "image note" + AnyLanguageModel.Attachment(value, orientation: .right) + } + let segments = try input.makeTranscriptSegments() + guard case .image(let segment) = segments.last, + case .data(let bytes, let mime) = segment.source + else { + Issue.record("No encoded image") + return + } + #expect(mime == "image/png") + let source = try #require( + CGImageSourceCreateWithData(bytes as CFData, nil) + ) + let decoded = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + #expect(decoded.width == value.width) + #expect(decoded.height == value.height) + let properties = try #require( + CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + ) + #expect( + (properties[kCGImagePropertyOrientation] as? NSNumber)?.uint32Value + == CGImagePropertyOrientation.right.rawValue + ) + } + #endif + + #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + @available(macOS 27, iOS 27, visionOS 27, *) + @Test func nativeBridgeRetainsAttachment() async throws { + let input = try AnyLanguageModel.Prompt { + "before" + AnyLanguageModel.Attachment(try image(), orientation: .right) + "after" + } + let session = FoundationModels.LanguageModelSession(model: BridgeModel()) + _ = try await session.respond(to: input.toFoundationModels()) + guard case .prompt(let recorded) = try #require(session.transcript.first) else { + Issue.record("Missing prompt") + return + } + #expect(recorded.segments.count == 3) + guard case .attachment(let segment) = recorded.segments[1], + case .image(let image) = segment.content + else { + Issue.record("Native bridge lost attachment") + return + } + #expect(image.orientation == .right) + } + + @available(macOS 27, iOS 27, visionOS 27, *) + private struct BridgeModel: FoundationModels.LanguageModel { + typealias Executor = BridgeExecutor + var capabilities: FoundationModels.LanguageModelCapabilities { + .init([.vision]) + } + var executorConfiguration: String { + "bridge-fixture" + } + } + + @available(macOS 27, iOS 27, visionOS 27, *) + private struct BridgeExecutor: FoundationModels.LanguageModelExecutor { + init(configuration: String) {} + + func respond( + to request: FoundationModels.LanguageModelExecutorGenerationRequest, + model: BridgeModel, + streamingInto channel: FoundationModels.LanguageModelExecutorGenerationChannel + ) async throws { + await channel.send(.response(action: .appendText("OK", tokenCount: 1))) + } + } + #endif +} From f2de31966ad6dc1bdeddf0c0a07da9454ae7b004 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 9 Oct 2026 16:06:55 +0800 Subject: [PATCH 02/53] Add Foundation Models 27 request options --- README.md | 27 +++++++ Sources/AnyLanguageModel/ContextOptions.swift | 27 +++++++ .../AnyLanguageModel/GenerationOptions.swift | 80 ++++++++++++++++++- .../Models/SystemLanguageModel.swift | 15 ++++ ...undationModelsGenerationOptionsTests.swift | 36 +++++++++ .../RequestOptionsTests.swift | 66 +++++++++++++++ 6 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 Sources/AnyLanguageModel/ContextOptions.swift create mode 100644 Tests/AnyLanguageModelTests/RequestOptionsTests.swift diff --git a/README.md b/README.md index 5a969f0b..37d7d245 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,28 @@ but never become part of the session's transcript. > Otherwise, it throws `SystemLanguageModel.Error.dynamicInstructionsUnavailable` > for a session with dynamic instructions. +### Foundation Models 27 request options + +`ContextOptions` mirrors Foundation Models 27's schema-in-prompt and reasoning-level +settings for compatibility-layer request construction. `GenerationOptions` also +provides the OS 27 `samplingMode` spelling and `ToolCallingMode` values: + +```swift +let context = ContextOptions( + includeSchemaInPrompt: true, + reasoningLevel: .moderate +) +let options = GenerationOptions( + samplingMode: .greedy, + toolCallingMode: .required +) +``` + +The older `sampling` property and initializer remain source-compatible. +Tool-calling mode is preserved in Codable transcripts and forwarded by the +Foundation Models bridge on OS 27. Other model providers can read the same +option from their request's `GenerationOptions`. + ### Reasoning in the transcript Reasoning is transcript content, separate from the answer in `response.content`. @@ -626,6 +648,11 @@ say which API they follow. - `DynamicInstructions`, its builder, and `LanguageModelSession.init(model:dynamicInstructions:history:)`: [dynamic instructions](#dynamic-instructions), which follow the Foundation Models 27 API. +- `ContextOptions`, `GenerationOptions.samplingMode`, and + `GenerationOptions.ToolCallingMode`: + [request options](#foundation-models-27-request-options), which follow the + Foundation Models 27 API while remaining available on the package's existing + deployment targets. - `Transcript.Entry.reasoning` and `Transcript.Reasoning`: [reasoning in the transcript](#reasoning-in-the-transcript), which follows the Foundation Models 27 API. diff --git a/Sources/AnyLanguageModel/ContextOptions.swift b/Sources/AnyLanguageModel/ContextOptions.swift new file mode 100644 index 00000000..f63020c3 --- /dev/null +++ b/Sources/AnyLanguageModel/ContextOptions.swift @@ -0,0 +1,27 @@ +/// Options that configure a model's request context. +/// +/// This mirrors the Foundation Models 27 request-context surface while keeping +/// the package available on its existing deployment targets. +public struct ContextOptions: Sendable, Equatable { + /// The amount of reasoning the model should perform for a request. + public enum ReasoningLevel: Sendable, Equatable { + case light + case moderate + case deep + case custom(String) + } + + /// Whether a structured-generation schema should also appear in the prompt. + public var includeSchemaInPrompt: Bool? + + /// The amount of reasoning the model should perform. + public var reasoningLevel: ReasoningLevel? + + public init( + includeSchemaInPrompt: Bool? = nil, + reasoningLevel: ReasoningLevel? = nil + ) { + self.includeSchemaInPrompt = includeSchemaInPrompt + self.reasoningLevel = reasoningLevel + } +} diff --git a/Sources/AnyLanguageModel/GenerationOptions.swift b/Sources/AnyLanguageModel/GenerationOptions.swift index cd2352a9..f02e7fb6 100644 --- a/Sources/AnyLanguageModel/GenerationOptions.swift +++ b/Sources/AnyLanguageModel/GenerationOptions.swift @@ -93,6 +93,15 @@ public struct GenerationOptions: Sendable, Equatable { /// a reasonable default on your behalf. public var sampling: SamplingMode? + /// The sampling strategy for generation. + /// + /// This is the Foundation Models 27 spelling. ``sampling`` remains for + /// source compatibility with earlier AnyLanguageModel releases. + public var samplingMode: SamplingMode? { + get { sampling } + set { sampling = newValue } + } + /// Temperature influences the confidence of the models response. /// /// The value of this property must be a number between `0` and `1` inclusive. @@ -121,6 +130,9 @@ public struct GenerationOptions: Sendable, Equatable { /// an error will be thrown. public var maximumResponseTokens: Int? + /// Whether tool calling is allowed, required, or disallowed. + public var toolCallingMode: ToolCallingMode? + /// Storage for model-specific custom options. private var customOptionsStorage: CustomOptionsStorage = .init() @@ -169,6 +181,43 @@ public struct GenerationOptions: Sendable, Equatable { self.sampling = sampling self.temperature = temperature self.maximumResponseTokens = maximumResponseTokens + self.toolCallingMode = nil + } + + /// Creates Foundation Models 27-style generation options. + public init( + samplingMode: SamplingMode? = nil, + temperature: Double? = nil, + maximumResponseTokens: Int? = nil, + toolCallingMode: ToolCallingMode? + ) { + self.init( + sampling: samplingMode, + temperature: temperature, + maximumResponseTokens: maximumResponseTokens + ) + self.toolCallingMode = toolCallingMode + } +} + +extension GenerationOptions { + /// A tool-calling policy for a generation request. + public struct ToolCallingMode: Sendable, Equatable { + public enum Kind: Sendable, Equatable, Hashable { + case allowed + case required + case disallowed + } + + public var kind: Kind + + private init(kind: Kind) { + self.kind = kind + } + + public static let allowed = Self(kind: .allowed) + public static let required = Self(kind: .required) + public static let disallowed = Self(kind: .disallowed) } } @@ -180,7 +229,8 @@ extension GenerationOptions { /// `GenerationOptions` isn't `Codable`, matching Foundation Models, /// but ``Transcript/Prompt`` is, so it codes its options through this type. /// It codes the sampling mode, temperature, and maximum response tokens - /// in the same format as earlier releases. + /// in the same format as earlier releases, and adds the optional OS 27 + /// tool-calling mode without changing older transcript payloads. /// Custom options aren't coded, /// because decoding them would need a registry of every model's option types. struct TranscriptCoding: Codable { @@ -188,21 +238,43 @@ extension GenerationOptions { var mode: SamplingMode.Mode } + enum ToolCallingModeCoding: String, Codable { + case allowed + case required + case disallowed + } + var sampling: Sampling? var temperature: Double? var maximumResponseTokens: Int? + var toolCallingMode: ToolCallingModeCoding? init(_ options: GenerationOptions) { self.sampling = options.sampling.map { Sampling(mode: $0.mode) } self.temperature = options.temperature self.maximumResponseTokens = options.maximumResponseTokens + self.toolCallingMode = + switch options.toolCallingMode?.kind { + case .allowed: .allowed + case .required: .required + case .disallowed: .disallowed + case nil: nil + } } var options: GenerationOptions { - GenerationOptions( - sampling: sampling.map { SamplingMode(mode: $0.mode) }, + let toolCallingMode: GenerationOptions.ToolCallingMode? = + switch toolCallingMode { + case .allowed: .allowed + case .required: .required + case .disallowed: .disallowed + case nil: nil + } + return GenerationOptions( + samplingMode: sampling.map { SamplingMode(mode: $0.mode) }, temperature: temperature, - maximumResponseTokens: maximumResponseTokens + maximumResponseTokens: maximumResponseTokens, + toolCallingMode: toolCallingMode ) } } diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 87763ed6..86746df1 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -458,6 +458,21 @@ } #if compiler(>=6.4) && !os(tvOS) + if #available(macOS 27, iOS 27, visionOS 27, watchOS 27, *) { + let toolCallingMode: FoundationModels.GenerationOptions.ToolCallingMode? = + switch self.toolCallingMode?.kind { + case .allowed: .allowed + case .required: .required + case .disallowed: .disallowed + case nil: nil + } + return FoundationModels.GenerationOptions( + samplingMode: sampling, + temperature: temperature, + maximumResponseTokens: maximumResponseTokens, + toolCallingMode: toolCallingMode + ) + } return FoundationModels.GenerationOptions( samplingMode: sampling, temperature: temperature, diff --git a/Tests/AnyLanguageModelTests/FoundationModelsGenerationOptionsTests.swift b/Tests/AnyLanguageModelTests/FoundationModelsGenerationOptionsTests.swift index 06c723fc..976903f5 100644 --- a/Tests/AnyLanguageModelTests/FoundationModelsGenerationOptionsTests.swift +++ b/Tests/AnyLanguageModelTests/FoundationModelsGenerationOptionsTests.swift @@ -67,6 +67,42 @@ import Testing #endif #expect(converted.temperature == nil) #expect(converted.maximumResponseTokens == nil) + #if compiler(>=6.4) && !os(tvOS) + if #available(macOS 27, iOS 27, visionOS 27, watchOS 27, *) { + #expect(converted.toolCallingMode == nil) + } + #endif } + + #if compiler(>=6.4) && !os(tvOS) + @available(macOS 27, iOS 27, visionOS 27, watchOS 27, *) + @Test( + "Forwards tool calling modes", + arguments: [ + ( + AnyLanguageModel.GenerationOptions.ToolCallingMode.allowed, + FoundationModels.GenerationOptions.ToolCallingMode.Kind.allowed + ), + ( + AnyLanguageModel.GenerationOptions.ToolCallingMode.required, + FoundationModels.GenerationOptions.ToolCallingMode.Kind.required + ), + ( + AnyLanguageModel.GenerationOptions.ToolCallingMode.disallowed, + FoundationModels.GenerationOptions.ToolCallingMode.Kind.disallowed + ), + ] + ) + func forwardsToolCallingMode( + mode: AnyLanguageModel.GenerationOptions.ToolCallingMode, + expected: FoundationModels.GenerationOptions.ToolCallingMode.Kind + ) { + let converted = AnyLanguageModel.GenerationOptions( + toolCallingMode: mode + ).toFoundationModels() + + #expect(converted.toolCallingMode?.kind == expected) + } + #endif } #endif diff --git a/Tests/AnyLanguageModelTests/RequestOptionsTests.swift b/Tests/AnyLanguageModelTests/RequestOptionsTests.swift new file mode 100644 index 00000000..7822a614 --- /dev/null +++ b/Tests/AnyLanguageModelTests/RequestOptionsTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("Foundation Models 27 request options") +struct RequestOptionsTests { + @Test func contextOptionsRetainValues() { + #expect(ContextOptions() == ContextOptions()) + #expect( + ContextOptions(includeSchemaInPrompt: true, reasoningLevel: .deep) + == ContextOptions(includeSchemaInPrompt: true, reasoningLevel: .deep) + ) + #expect( + ContextOptions(reasoningLevel: .custom("provider-level")) + != ContextOptions(reasoningLevel: .moderate) + ) + } + + @Test( + arguments: [ + (GenerationOptions.ToolCallingMode.allowed, GenerationOptions.ToolCallingMode.Kind.allowed), + (.required, .required), + (.disallowed, .disallowed), + ] + ) + func toolCallingModesExposeTheirKind( + mode: GenerationOptions.ToolCallingMode, + expected: GenerationOptions.ToolCallingMode.Kind + ) { + #expect(mode.kind == expected) + } + + @Test func generationOptionsKeepOldAndNewSamplingSpellingsInSync() { + var options = GenerationOptions(samplingMode: .greedy, toolCallingMode: .required) + #expect(options.sampling == .greedy) + #expect(options.toolCallingMode == .required) + + options.sampling = .random(top: 4, seed: 7) + #expect(options.samplingMode == .random(top: 4, seed: 7)) + + options.samplingMode = .random(probabilityThreshold: 0.8, seed: 9) + #expect(options.sampling == .random(probabilityThreshold: 0.8, seed: 9)) + } + + @Test func transcriptRoundTripsToolCallingMode() throws { + let options = GenerationOptions( + samplingMode: .greedy, + temperature: 0.5, + maximumResponseTokens: 64, + toolCallingMode: .required + ) + let prompt = Transcript.Prompt( + id: "prompt", + segments: [.text(.init(id: "text", content: "Hello"))], + options: options + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(prompt) + #expect(String(decoding: data, as: UTF8.self).contains(#""toolCallingMode":"required""#)) + let decoded = try JSONDecoder().decode(Transcript.Prompt.self, from: data) + #expect(decoded.options == options) + } +} From 379ee3cfca6dd026159d08b507baaecf6453480b Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 11 Oct 2026 22:07:15 +0800 Subject: [PATCH 03/53] Fix attachment prompt replay --- .../Models/SystemLanguageModel.swift | 18 ++++++--- .../PromptAttachmentTests.swift | 39 ++++++++++++++++++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index d66ab692..9e17d200 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -341,11 +341,9 @@ guard let lastEntry = transcript.last, case .prompt(let lastPrompt) = lastEntry else { return transcript } - let lastText = lastPrompt.segments.compactMap { segment -> String? in - if case .text(let textSegment) = segment { return textSegment.content } - return nil - }.joined() - guard lastText == prompt.description else { + guard let promptSegments = try? prompt.makeTranscriptSegments(), + lastPrompt.segments == promptSegments + else { return transcript } return Transcript(entries: transcript.dropLast()) @@ -968,7 +966,15 @@ else { return nil } - self.init(cgImage) + let properties = + CGImageSourceCopyPropertiesAtIndex( + imageSource, + 0, + nil + ) as? [CFString: Any] + let orientation = (properties?[kCGImagePropertyOrientation] as? NSNumber) + .flatMap { CGImagePropertyOrientation(rawValue: $0.uint32Value) } + self.init(cgImage, orientation: orientation) } } } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index df184ca5..9d6c6a2a 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -154,6 +154,22 @@ struct PromptAttachmentTests { #expect(image.orientation == .right) } + @available(macOS 27, iOS 27, visionOS 27, *) + @Test func foundationWrapperDoesNotDuplicatePromptsAndReplaysOrientation() async throws { + let model = AnyLanguageModel.FoundationLanguageModel(BridgeModel()) + let session = AnyLanguageModel.LanguageModelSession(model: model) + let input = try AnyLanguageModel.Prompt { + "before" + AnyLanguageModel.Attachment(try image(), orientation: .right) + "after" + } + let first = try await session.respond(to: input) + #expect(first.content == "prompts:1 images:1 right:1") + + let second = try await session.respond(to: "follow-up") + #expect(second.content == "prompts:2 images:1 right:1") + } + @available(macOS 27, iOS 27, visionOS 27, *) private struct BridgeModel: FoundationModels.LanguageModel { typealias Executor = BridgeExecutor @@ -174,7 +190,28 @@ struct PromptAttachmentTests { model: BridgeModel, streamingInto channel: FoundationModels.LanguageModelExecutorGenerationChannel ) async throws { - await channel.send(.response(action: .appendText("OK", tokenCount: 1))) + let prompts = request.transcript.compactMap { entry -> FoundationModels.Transcript.Prompt? in + guard case .prompt(let prompt) = entry else { return nil } + return prompt + } + let images = prompts.flatMap { prompt in + prompt.segments.compactMap { + segment -> FoundationModels.Transcript.ImageAttachment? in + guard case .attachment(let attachment) = segment, + case .image(let image) = attachment.content + else { return nil } + return image + } + } + let right = images.count { $0.orientation == .right } + await channel.send( + .response( + action: .appendText( + "prompts:\(prompts.count) images:\(images.count) right:\(right)", + tokenCount: 1 + ) + ) + ) } } #endif From 2d1a32b7352d38c111005fc1537fc32282fe8c3f Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 11 Oct 2026 22:10:10 +0800 Subject: [PATCH 04/53] Preserve attachments in tool outputs --- .../Models/SystemLanguageModel.swift | 4 +- Sources/AnyLanguageModel/Tool.swift | 8 +- .../DynamicInstructionsTests.swift | 2 +- .../PromptAttachmentTests.swift | 92 ++++++++++++++++++- .../ToolExecutionDelegateTests.swift | 2 +- 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 9e17d200..bb451d50 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -563,7 +563,7 @@ @available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) private struct AnyToolWrapper: FoundationModels.Tool { typealias Arguments = FoundationModels.GeneratedContent - typealias Output = String + typealias Output = FoundationModels.Prompt let name: String let description: String @@ -582,7 +582,7 @@ func call(arguments: FoundationModels.GeneratedContent) async throws -> Output { let output = try await wrappedTool.callFunction(arguments: arguments) - return output.promptRepresentation.description + return try output.promptRepresentation.toFoundationModels() } } diff --git a/Sources/AnyLanguageModel/Tool.swift b/Sources/AnyLanguageModel/Tool.swift index 368a9961..59f5b800 100644 --- a/Sources/AnyLanguageModel/Tool.swift +++ b/Sources/AnyLanguageModel/Tool.swift @@ -110,7 +110,10 @@ extension Tool where Self.Arguments: Generable { // MARK: - Helpers extension Tool { - internal func makeOutputSegments(from arguments: GeneratedContent) async throws -> [Transcript.Segment] { + /// Compatibility integration SPI for provider adapters outside this module. + /// This is not part of Apple's public Tool contract. + @_spi(Compatibility) + public func makeOutputSegments(from arguments: GeneratedContent) async throws -> [Transcript.Segment] { let parsedArguments = try Arguments(arguments) let output = try await call(arguments: parsedArguments) @@ -124,7 +127,6 @@ extension Tool { return [Transcript.Segment.text(.init(content: stringOutput))] } - let fallback = output.promptRepresentation.description - return [Transcript.Segment.text(.init(content: fallback))] + return try output.promptRepresentation.makeTranscriptSegments() } } diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift index af43ad37..baba7aa1 100644 --- a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel @Suite("Dynamic instructions") struct DynamicInstructionsTests { diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 9d6c6a2a..ea85a885 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel #if canImport(CoreGraphics) && canImport(ImageIO) import CoreGraphics @@ -84,6 +84,29 @@ struct PromptAttachmentTests { } } + @Test func ordinaryToolReturnsOrderedImageSegments() async throws { + let segments = try await ImageTool(output: prompt()).makeOutputSegments( + from: AnyLanguageModel.GeneratedContent("read") + ) + #expect(segments.count == 3) + guard case .text(let before) = segments[0], + case .image(let image) = segments[1], + case .text(let after) = segments[2] + else { + Issue.record("Tool output lost its ordered image") + return + } + #expect(before.content == "before") + #expect(after.content == "after") + #expect(image.source == .url(imageURL)) + + let transcript = AnyLanguageModel.Transcript(entries: [ + .toolOutput(.init(id: "image-call", toolName: "image", segments: segments)) + ]) + let encoded = try JSONEncoder().encode(transcript) + #expect(try JSONDecoder().decode(AnyLanguageModel.Transcript.self, from: encoded) == transcript) + } + #if canImport(CoreGraphics) && canImport(ImageIO) private func image() throws -> CGImage { let context = try #require( @@ -170,11 +193,41 @@ struct PromptAttachmentTests { #expect(second.content == "prompts:2 images:1 right:1") } + @available(macOS 27, iOS 27, visionOS 27, *) + @Test func nativeToolWrapperRetainsOrdinaryImageOutput() async throws { + let output = try AnyLanguageModel.Prompt { + "tool note" + AnyLanguageModel.Attachment(try image(), orientation: .right) + } + let tools: [any AnyLanguageModel.Tool] = [ImageTool(output: output)] + let session = FoundationModels.LanguageModelSession( + model: BridgeModel(callsTools: true), + tools: tools.toFoundationModels() + ) + _ = try await session.respond(to: "read image") + let recorded = try #require( + session.transcript.compactMap { entry -> FoundationModels.Transcript.ToolOutput? in + guard case .toolOutput(let output) = entry else { return nil } + return output + }.first + ) + #expect(recorded.id == "image-call") + #expect(recorded.segments.count == 2) + guard case .attachment(let segment) = recorded.segments[1], + case .image(let image) = segment.content + else { + Issue.record("Native Tool wrapper flattened image output") + return + } + #expect(image.orientation == .right) + } + @available(macOS 27, iOS 27, visionOS 27, *) private struct BridgeModel: FoundationModels.LanguageModel { typealias Executor = BridgeExecutor + var callsTools = false var capabilities: FoundationModels.LanguageModelCapabilities { - .init([.vision]) + .init([.vision, .toolCalling]) } var executorConfiguration: String { "bridge-fixture" @@ -190,6 +243,27 @@ struct PromptAttachmentTests { model: BridgeModel, streamingInto channel: FoundationModels.LanguageModelExecutorGenerationChannel ) async throws { + if model.callsTools, + !request.transcript.contains(where: { + if case .toolOutput = $0 { return true } + return false + }) + { + await channel.send( + .toolCalls( + action: .toolCall( + id: "image-call", + name: "image", + action: .appendArguments(#""read""#, tokenCount: 1) + ) + ) + ) + return + } + if model.callsTools { + await channel.send(.response(action: .appendText("OK", tokenCount: 1))) + return + } let prompts = request.transcript.compactMap { entry -> FoundationModels.Transcript.Prompt? in guard case .prompt(let prompt) = entry else { return nil } return prompt @@ -216,3 +290,17 @@ struct PromptAttachmentTests { } #endif } + +private struct ImageTool: AnyLanguageModel.Tool { + let name = "image" + let description = "Return an image" + let output: AnyLanguageModel.Prompt + + var parameters: AnyLanguageModel.GenerationSchema { + String.generationSchema + } + + func call(arguments: String) async throws -> AnyLanguageModel.Prompt { + output + } +} diff --git a/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift b/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift index 1b258e32..383cca86 100644 --- a/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift +++ b/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift @@ -1,6 +1,6 @@ import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel private actor ToolExecutionDelegateSpy: ToolExecutionDelegate { private(set) var generatedToolCalls: [Transcript.ToolCall] = [] From cd1d6fcbcf8fe45ec8b7d779cdc3fe4727337e68 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 11 Oct 2026 22:17:19 +0800 Subject: [PATCH 05/53] Add session properties to dynamic instructions --- .../LanguageModelSession.swift | 32 ++++- .../AnyLanguageModel/SessionProperties.swift | 114 ++++++++++++++++ Sources/AnyLanguageModelMacros/Plugin.swift | 1 + .../SessionPropertyEntryMacro.swift | 53 ++++++++ ...PICompatibilityAnyLanguageModelTests.swift | 12 +- ...PICompatibilityFoundationModelsTests.swift | 12 +- .../SessionPropertyTests.swift | 127 ++++++++++++++++++ 7 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 Sources/AnyLanguageModel/SessionProperties.swift create mode 100644 Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift create mode 100644 Tests/AnyLanguageModelTests/SessionPropertyTests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 4f08efc6..10627f0d 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -110,6 +110,9 @@ public final class LanguageModelSession: @unchecked Sendable { private let dynamicInstructions: AnyDynamicInstructions? private let dynamicInstructionsLock = NSLock() + /// Session-scoped values available to dynamic instructions. + public let properties: SessionPropertyValues + /// Whether the session resolves its instructions and tools before each request. nonisolated var usesDynamicInstructions: Bool { dynamicInstructions != nil @@ -172,8 +175,13 @@ public final class LanguageModelSession: @unchecked Sendable { // Evaluate the body for this request. // The resolved instructions go into the request's transcript, // never into the session's. + let canonicalHistory = Array(transcript) let resolved = dynamicInstructionsLock.withLock { - dynamicInstructions.resolveForRequest() + SessionPropertyBinding.$values.withValue(properties) { + SessionPropertyBinding.$history.withValue(canonicalHistory) { + dynamicInstructions.resolveForRequest() + } + } } var requestTranscript = transcript if let instructions = resolved.instructions { @@ -322,7 +330,27 @@ public final class LanguageModelSession: @unchecked Sendable { } } - self.state = .init(.init(finalTranscript)) + let state = Locked(State(finalTranscript)) + self.state = state + self.properties = SessionPropertyValues( + historyGetter: { + state.withLock { locked in + locked.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } + } + }, + historySetter: { history in + state.withLock { locked in + let instructions = locked.transcript.prefix { + if case .instructions = $0 { return true } + return false + } + locked.transcript = Transcript(entries: Array(instructions) + history) + } + } + ) } private static func instructions(from transcript: Transcript) -> Instructions? { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift new file mode 100644 index 00000000..9b503bff --- /dev/null +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -0,0 +1,114 @@ +import Foundation +import Observation + +extension Transcript { + /// The mutable, instruction-free history exposed through session properties. + /// + /// - Note: This API is exclusive to AnyLanguageModel on OS 26. It mirrors + /// Foundation Models 27's `Transcript.HistoryView` role with value semantics. + public typealias HistoryView = [Entry] +} + +/// A type-keyed session property. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors the +/// Foundation Models 27 session-property contract. +public protocol SessionPropertyKey: SendableMetatype { + associatedtype Value + static var defaultValue: Value { get } +} + +/// Declares a stored value on ``SessionPropertyValues``. +@attached(accessor) +@attached(peer, names: prefixed(__Key_)) +public macro SessionPropertyEntry() = + #externalMacro(module: "AnyLanguageModelMacros", type: "SessionPropertyEntryMacro") + +/// Session-scoped storage available while dynamic instructions are evaluated. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors +/// Foundation Models 27. +@Observable +public final class SessionPropertyValues: @unchecked Sendable { + @ObservationIgnored private let customValues = Locked<[ObjectIdentifier: Any]>([:]) + @ObservationIgnored private let historyGetter: @Sendable () -> Transcript.HistoryView + @ObservationIgnored private let historySetter: @Sendable (Transcript.HistoryView) -> Void + + init( + historyGetter: @escaping @Sendable () -> Transcript.HistoryView, + historySetter: @escaping @Sendable (Transcript.HistoryView) -> Void + ) { + self.historyGetter = historyGetter + self.historySetter = historySetter + } + + /// The session history without an instructions entry. + public var history: Transcript.HistoryView { + get { SessionPropertyBinding.history ?? historyGetter() } + set { + precondition( + SessionPropertyBinding.history == nil, + "Session history is read-only while dynamic instructions are active" + ) + historySetter(newValue) + } + } + + public subscript(key: Key.Type) -> Key.Value where Key: SessionPropertyKey { + get { + customValues.withLock { values in + if let value = values[ObjectIdentifier(key)] as? Key.Value { return value } + let value = Key.defaultValue + values[ObjectIdentifier(key)] = value + return value + } + } + set { + customValues.withLock { values in + values[ObjectIdentifier(key)] = newValue + } + } + } +} + +enum SessionPropertyBinding { + @TaskLocal static var values: SessionPropertyValues? + @TaskLocal static var history: Transcript.HistoryView? +} + +extension LanguageModelSession { + /// Accesses a value in the currently evaluating session. + @propertyWrapper + public struct SessionProperty { + private let keyPath: ReferenceWritableKeyPath + + public init(_ keyPath: ReferenceWritableKeyPath) { + self.keyPath = keyPath + } + + public var wrappedValue: Value { + get { + guard let values = SessionPropertyBinding.values else { + preconditionFailure( + "SessionProperty can only be accessed while dynamic instructions are active" + ) + } + return values[keyPath: keyPath] + } + nonmutating set { + guard let values = SessionPropertyBinding.values else { + preconditionFailure( + "SessionProperty can only be accessed while dynamic instructions are active" + ) + } + values[keyPath: keyPath] = newValue + } + } + } +} + +extension LanguageModelSession.SessionProperty: @unchecked Sendable where Value: Sendable {} + +extension DynamicInstructions { + public typealias SessionProperty = LanguageModelSession.SessionProperty +} diff --git a/Sources/AnyLanguageModelMacros/Plugin.swift b/Sources/AnyLanguageModelMacros/Plugin.swift index 917d2deb..dee43755 100644 --- a/Sources/AnyLanguageModelMacros/Plugin.swift +++ b/Sources/AnyLanguageModelMacros/Plugin.swift @@ -6,5 +6,6 @@ struct AnyLanguageModelMacrosPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ GenerableMacro.self, GuideMacro.self, + SessionPropertyEntryMacro.self, ] } diff --git a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift new file mode 100644 index 00000000..1aae689b --- /dev/null +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -0,0 +1,53 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Synthesizes a `SessionPropertyKey` and forwards the declared property through +/// `SessionPropertyValues`' type-keyed storage. +public struct SessionPropertyEntryMacro: AccessorMacro, PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let name = propertyName(in: declaration) else { return [] } + return [ + "get { self[__Key_\(raw: name).self] }", + "set { self[__Key_\(raw: name).self] = newValue }", + ] + } + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard + let variable = declaration.as(VariableDeclSyntax.self), + variable.bindings.count == 1, + let binding = variable.bindings.first, + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text, + let initializer = binding.initializer?.value + else { return [] } + + return [ + """ + private struct __Key_\(raw: name): SessionPropertyKey { + static let defaultValue = \(initializer) + } + """ + ] + } + + private static func propertyName(in declaration: some DeclSyntaxProtocol) -> String? { + guard + let variable = declaration.as(VariableDeclSyntax.self), + variable.bindings.count == 1, + let binding = variable.bindings.first, + binding.accessorBlock == nil, + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text, + binding.initializer != nil + else { return nil } + return name + } +} diff --git a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift index 18bf1cba..2afe9d3e 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -10,12 +10,18 @@ import Testing return false }() + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + extension SessionPropertyValues { + @SessionPropertyEntry var compatibilityRevision = 0 + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) private struct CompatibilityDynamicInstructions: DynamicInstructions { let includeDetail: Bool + @SessionProperty(\.compatibilityRevision) private var revision var body: some DynamicInstructions { - Instructions("You are a helpful assistant.") + Instructions("You are a helpful assistant. Revision \(revision).") if includeDetail { Instructions("Include useful detail.") } @@ -32,11 +38,13 @@ import Testing ) if #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) { - _ = LanguageModelSession( + let dynamicSession = LanguageModelSession( model: model, dynamicInstructions: CompatibilityDynamicInstructions(includeDetail: true), history: session.transcript ) + dynamicSession.properties.compatibilityRevision = 1 + _ = dynamicSession.properties.history } let options = GenerationOptions(temperature: 0.7) diff --git a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift index 8142648e..d8d0c51e 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -12,12 +12,18 @@ import Testing #if compiler(>=6.4) #if os(macOS) || os(iOS) || os(visionOS) + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + extension SessionPropertyValues { + @SessionPropertyEntry var compatibilityRevision = 0 + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) private struct CompatibilityDynamicInstructions: DynamicInstructions { let includeDetail: Bool + @SessionProperty(\.compatibilityRevision) private var revision var body: some DynamicInstructions { - Instructions("You are a helpful assistant.") + Instructions("You are a helpful assistant. Revision \(revision).") if includeDetail { Instructions("Include useful detail.") } @@ -41,11 +47,13 @@ import Testing #if compiler(>=6.4) #if os(macOS) || os(iOS) || os(visionOS) if #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) { - _ = LanguageModelSession( + let dynamicSession = LanguageModelSession( model: model, dynamicInstructions: CompatibilityDynamicInstructions(includeDetail: true), history: session.transcript ) + dynamicSession.properties.compatibilityRevision = 1 + _ = dynamicSession.properties.history } #endif #endif diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift new file mode 100644 index 00000000..d35425b8 --- /dev/null +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Observation +import Testing + +@testable import AnyLanguageModel + +extension SessionPropertyValues { + @SessionPropertyEntry var revision = 0 +} + +@Suite("Session properties") +struct SessionPropertyTests { + @Test func dynamicInstructionsReadAndUpdateSessionProperties() { + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: SessionPropertyFixtureInstructions() + ) + + #expect(session.properties.revision == 0) + let first = session.resolvedRequestContext() + let second = session.resolvedRequestContext() + + #expect(first.instructions?.description == "Revision 0") + #expect(second.instructions?.description == "Revision 1") + #expect(session.properties.revision == 2) + } + + @Test func historyIsInstructionFreeAndUsesTheCurrentRequestSnapshot() { + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Earlier"))]) + ) + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: SessionPropertyHistoryInstructions(), + history: [prompt] + ) + + let context = session.resolvedRequestContext() + + #expect(context.instructions?.description == "History count 1") + #expect(context.transcript.count == 2) + #expect(session.properties.history == [prompt]) + } + + @Test func propertiesAndHistoryAreIsolatedBetweenSessions() { + let first = LanguageModelSession(model: SessionPropertyFixtureModel()) + let second = LanguageModelSession(model: SessionPropertyFixtureModel()) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + first.properties.revision = 7 + first.properties.history = [prompt] + + #expect(first.properties.revision == 7) + #expect(first.transcript == Transcript(entries: [prompt])) + #expect(second.properties.revision == 0) + #expect(second.transcript.isEmpty) + let _: any Observable = first.properties + } + + @Test func replacingHistoryPreservesStaticInstructions() { + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + instructions: "Keep these instructions" + ) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + session.properties.history = [prompt] + + #expect(session.properties.history == [prompt]) + #expect(session.transcript.count == 2) + guard case .instructions = session.transcript[0] else { + Issue.record("Expected the static instructions entry to remain first") + return + } + #expect(session.transcript[1] == prompt) + } +} + +private struct SessionPropertyFixtureInstructions: DynamicInstructions { + @SessionProperty(\.revision) private var revision + + var body: some DynamicInstructions { + let currentRevision = revision + revision += 1 + return Instructions("Revision \(currentRevision)") + } +} + +private struct SessionPropertyHistoryInstructions: DynamicInstructions { + @SessionProperty(\.history) private var history + + var body: some DynamicInstructions { + Instructions("History count \(history.count)") + } +} + +private struct SessionPropertyFixtureModel: LanguageModel { + typealias UnavailableReason = Never + + var availability: Availability { .available } + + func supports(locale: Locale) -> Bool { true } + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + fatalError("SessionPropertyFixtureModel does not generate responses") + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + fatalError("SessionPropertyFixtureModel does not generate responses") + } +} From 94813809b5f362e93cec22f5ff2e36ff5ae6fd29 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 11 Oct 2026 22:17:19 +0800 Subject: [PATCH 06/53] Add session properties to dynamic instructions --- .../LanguageModelSession.swift | 32 ++++- .../AnyLanguageModel/SessionProperties.swift | 114 ++++++++++++++++ Sources/AnyLanguageModelMacros/Plugin.swift | 1 + .../SessionPropertyEntryMacro.swift | 53 ++++++++ ...PICompatibilityAnyLanguageModelTests.swift | 12 +- ...PICompatibilityFoundationModelsTests.swift | 12 +- .../SessionPropertyTests.swift | 127 ++++++++++++++++++ 7 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 Sources/AnyLanguageModel/SessionProperties.swift create mode 100644 Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift create mode 100644 Tests/AnyLanguageModelTests/SessionPropertyTests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 4f08efc6..10627f0d 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -110,6 +110,9 @@ public final class LanguageModelSession: @unchecked Sendable { private let dynamicInstructions: AnyDynamicInstructions? private let dynamicInstructionsLock = NSLock() + /// Session-scoped values available to dynamic instructions. + public let properties: SessionPropertyValues + /// Whether the session resolves its instructions and tools before each request. nonisolated var usesDynamicInstructions: Bool { dynamicInstructions != nil @@ -172,8 +175,13 @@ public final class LanguageModelSession: @unchecked Sendable { // Evaluate the body for this request. // The resolved instructions go into the request's transcript, // never into the session's. + let canonicalHistory = Array(transcript) let resolved = dynamicInstructionsLock.withLock { - dynamicInstructions.resolveForRequest() + SessionPropertyBinding.$values.withValue(properties) { + SessionPropertyBinding.$history.withValue(canonicalHistory) { + dynamicInstructions.resolveForRequest() + } + } } var requestTranscript = transcript if let instructions = resolved.instructions { @@ -322,7 +330,27 @@ public final class LanguageModelSession: @unchecked Sendable { } } - self.state = .init(.init(finalTranscript)) + let state = Locked(State(finalTranscript)) + self.state = state + self.properties = SessionPropertyValues( + historyGetter: { + state.withLock { locked in + locked.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } + } + }, + historySetter: { history in + state.withLock { locked in + let instructions = locked.transcript.prefix { + if case .instructions = $0 { return true } + return false + } + locked.transcript = Transcript(entries: Array(instructions) + history) + } + } + ) } private static func instructions(from transcript: Transcript) -> Instructions? { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift new file mode 100644 index 00000000..9b503bff --- /dev/null +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -0,0 +1,114 @@ +import Foundation +import Observation + +extension Transcript { + /// The mutable, instruction-free history exposed through session properties. + /// + /// - Note: This API is exclusive to AnyLanguageModel on OS 26. It mirrors + /// Foundation Models 27's `Transcript.HistoryView` role with value semantics. + public typealias HistoryView = [Entry] +} + +/// A type-keyed session property. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors the +/// Foundation Models 27 session-property contract. +public protocol SessionPropertyKey: SendableMetatype { + associatedtype Value + static var defaultValue: Value { get } +} + +/// Declares a stored value on ``SessionPropertyValues``. +@attached(accessor) +@attached(peer, names: prefixed(__Key_)) +public macro SessionPropertyEntry() = + #externalMacro(module: "AnyLanguageModelMacros", type: "SessionPropertyEntryMacro") + +/// Session-scoped storage available while dynamic instructions are evaluated. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors +/// Foundation Models 27. +@Observable +public final class SessionPropertyValues: @unchecked Sendable { + @ObservationIgnored private let customValues = Locked<[ObjectIdentifier: Any]>([:]) + @ObservationIgnored private let historyGetter: @Sendable () -> Transcript.HistoryView + @ObservationIgnored private let historySetter: @Sendable (Transcript.HistoryView) -> Void + + init( + historyGetter: @escaping @Sendable () -> Transcript.HistoryView, + historySetter: @escaping @Sendable (Transcript.HistoryView) -> Void + ) { + self.historyGetter = historyGetter + self.historySetter = historySetter + } + + /// The session history without an instructions entry. + public var history: Transcript.HistoryView { + get { SessionPropertyBinding.history ?? historyGetter() } + set { + precondition( + SessionPropertyBinding.history == nil, + "Session history is read-only while dynamic instructions are active" + ) + historySetter(newValue) + } + } + + public subscript(key: Key.Type) -> Key.Value where Key: SessionPropertyKey { + get { + customValues.withLock { values in + if let value = values[ObjectIdentifier(key)] as? Key.Value { return value } + let value = Key.defaultValue + values[ObjectIdentifier(key)] = value + return value + } + } + set { + customValues.withLock { values in + values[ObjectIdentifier(key)] = newValue + } + } + } +} + +enum SessionPropertyBinding { + @TaskLocal static var values: SessionPropertyValues? + @TaskLocal static var history: Transcript.HistoryView? +} + +extension LanguageModelSession { + /// Accesses a value in the currently evaluating session. + @propertyWrapper + public struct SessionProperty { + private let keyPath: ReferenceWritableKeyPath + + public init(_ keyPath: ReferenceWritableKeyPath) { + self.keyPath = keyPath + } + + public var wrappedValue: Value { + get { + guard let values = SessionPropertyBinding.values else { + preconditionFailure( + "SessionProperty can only be accessed while dynamic instructions are active" + ) + } + return values[keyPath: keyPath] + } + nonmutating set { + guard let values = SessionPropertyBinding.values else { + preconditionFailure( + "SessionProperty can only be accessed while dynamic instructions are active" + ) + } + values[keyPath: keyPath] = newValue + } + } + } +} + +extension LanguageModelSession.SessionProperty: @unchecked Sendable where Value: Sendable {} + +extension DynamicInstructions { + public typealias SessionProperty = LanguageModelSession.SessionProperty +} diff --git a/Sources/AnyLanguageModelMacros/Plugin.swift b/Sources/AnyLanguageModelMacros/Plugin.swift index 917d2deb..dee43755 100644 --- a/Sources/AnyLanguageModelMacros/Plugin.swift +++ b/Sources/AnyLanguageModelMacros/Plugin.swift @@ -6,5 +6,6 @@ struct AnyLanguageModelMacrosPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ GenerableMacro.self, GuideMacro.self, + SessionPropertyEntryMacro.self, ] } diff --git a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift new file mode 100644 index 00000000..1aae689b --- /dev/null +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -0,0 +1,53 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Synthesizes a `SessionPropertyKey` and forwards the declared property through +/// `SessionPropertyValues`' type-keyed storage. +public struct SessionPropertyEntryMacro: AccessorMacro, PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let name = propertyName(in: declaration) else { return [] } + return [ + "get { self[__Key_\(raw: name).self] }", + "set { self[__Key_\(raw: name).self] = newValue }", + ] + } + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard + let variable = declaration.as(VariableDeclSyntax.self), + variable.bindings.count == 1, + let binding = variable.bindings.first, + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text, + let initializer = binding.initializer?.value + else { return [] } + + return [ + """ + private struct __Key_\(raw: name): SessionPropertyKey { + static let defaultValue = \(initializer) + } + """ + ] + } + + private static func propertyName(in declaration: some DeclSyntaxProtocol) -> String? { + guard + let variable = declaration.as(VariableDeclSyntax.self), + variable.bindings.count == 1, + let binding = variable.bindings.first, + binding.accessorBlock == nil, + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text, + binding.initializer != nil + else { return nil } + return name + } +} diff --git a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift index 18bf1cba..2afe9d3e 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -10,12 +10,18 @@ import Testing return false }() + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + extension SessionPropertyValues { + @SessionPropertyEntry var compatibilityRevision = 0 + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) private struct CompatibilityDynamicInstructions: DynamicInstructions { let includeDetail: Bool + @SessionProperty(\.compatibilityRevision) private var revision var body: some DynamicInstructions { - Instructions("You are a helpful assistant.") + Instructions("You are a helpful assistant. Revision \(revision).") if includeDetail { Instructions("Include useful detail.") } @@ -32,11 +38,13 @@ import Testing ) if #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) { - _ = LanguageModelSession( + let dynamicSession = LanguageModelSession( model: model, dynamicInstructions: CompatibilityDynamicInstructions(includeDetail: true), history: session.transcript ) + dynamicSession.properties.compatibilityRevision = 1 + _ = dynamicSession.properties.history } let options = GenerationOptions(temperature: 0.7) diff --git a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift index 8142648e..d8d0c51e 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -12,12 +12,18 @@ import Testing #if compiler(>=6.4) #if os(macOS) || os(iOS) || os(visionOS) + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + extension SessionPropertyValues { + @SessionPropertyEntry var compatibilityRevision = 0 + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) private struct CompatibilityDynamicInstructions: DynamicInstructions { let includeDetail: Bool + @SessionProperty(\.compatibilityRevision) private var revision var body: some DynamicInstructions { - Instructions("You are a helpful assistant.") + Instructions("You are a helpful assistant. Revision \(revision).") if includeDetail { Instructions("Include useful detail.") } @@ -41,11 +47,13 @@ import Testing #if compiler(>=6.4) #if os(macOS) || os(iOS) || os(visionOS) if #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) { - _ = LanguageModelSession( + let dynamicSession = LanguageModelSession( model: model, dynamicInstructions: CompatibilityDynamicInstructions(includeDetail: true), history: session.transcript ) + dynamicSession.properties.compatibilityRevision = 1 + _ = dynamicSession.properties.history } #endif #endif diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift new file mode 100644 index 00000000..d35425b8 --- /dev/null +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Observation +import Testing + +@testable import AnyLanguageModel + +extension SessionPropertyValues { + @SessionPropertyEntry var revision = 0 +} + +@Suite("Session properties") +struct SessionPropertyTests { + @Test func dynamicInstructionsReadAndUpdateSessionProperties() { + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: SessionPropertyFixtureInstructions() + ) + + #expect(session.properties.revision == 0) + let first = session.resolvedRequestContext() + let second = session.resolvedRequestContext() + + #expect(first.instructions?.description == "Revision 0") + #expect(second.instructions?.description == "Revision 1") + #expect(session.properties.revision == 2) + } + + @Test func historyIsInstructionFreeAndUsesTheCurrentRequestSnapshot() { + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Earlier"))]) + ) + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: SessionPropertyHistoryInstructions(), + history: [prompt] + ) + + let context = session.resolvedRequestContext() + + #expect(context.instructions?.description == "History count 1") + #expect(context.transcript.count == 2) + #expect(session.properties.history == [prompt]) + } + + @Test func propertiesAndHistoryAreIsolatedBetweenSessions() { + let first = LanguageModelSession(model: SessionPropertyFixtureModel()) + let second = LanguageModelSession(model: SessionPropertyFixtureModel()) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + first.properties.revision = 7 + first.properties.history = [prompt] + + #expect(first.properties.revision == 7) + #expect(first.transcript == Transcript(entries: [prompt])) + #expect(second.properties.revision == 0) + #expect(second.transcript.isEmpty) + let _: any Observable = first.properties + } + + @Test func replacingHistoryPreservesStaticInstructions() { + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + instructions: "Keep these instructions" + ) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + session.properties.history = [prompt] + + #expect(session.properties.history == [prompt]) + #expect(session.transcript.count == 2) + guard case .instructions = session.transcript[0] else { + Issue.record("Expected the static instructions entry to remain first") + return + } + #expect(session.transcript[1] == prompt) + } +} + +private struct SessionPropertyFixtureInstructions: DynamicInstructions { + @SessionProperty(\.revision) private var revision + + var body: some DynamicInstructions { + let currentRevision = revision + revision += 1 + return Instructions("Revision \(currentRevision)") + } +} + +private struct SessionPropertyHistoryInstructions: DynamicInstructions { + @SessionProperty(\.history) private var history + + var body: some DynamicInstructions { + Instructions("History count \(history.count)") + } +} + +private struct SessionPropertyFixtureModel: LanguageModel { + typealias UnavailableReason = Never + + var availability: Availability { .available } + + func supports(locale: Locale) -> Bool { true } + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + fatalError("SessionPropertyFixtureModel does not generate responses") + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + fatalError("SessionPropertyFixtureModel does not generate responses") + } +} From a03fd6beeb0aef9b23bffd90af896156ec3c2238 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 03:06:31 +0800 Subject: [PATCH 07/53] Add Foundation Models 27 dynamic profiles --- .../DynamicInstructions.swift | 2 +- Sources/AnyLanguageModel/DynamicProfile.swift | 556 ++++++++++++++ .../LanguageModelSession.swift | 695 ++++++++++++++++-- .../AnyLanguageModel/SessionProperties.swift | 42 +- ...PICompatibilityAnyLanguageModelTests.swift | 24 + ...PICompatibilityFoundationModelsTests.swift | 24 + .../DynamicProfileTests.swift | 491 +++++++++++++ 7 files changed, 1753 insertions(+), 81 deletions(-) create mode 100644 Sources/AnyLanguageModel/DynamicProfile.swift create mode 100644 Tests/AnyLanguageModelTests/DynamicProfileTests.swift diff --git a/Sources/AnyLanguageModel/DynamicInstructions.swift b/Sources/AnyLanguageModel/DynamicInstructions.swift index 1f4fe4bf..e83fda4d 100644 --- a/Sources/AnyLanguageModel/DynamicInstructions.swift +++ b/Sources/AnyLanguageModel/DynamicInstructions.swift @@ -337,7 +337,7 @@ extension Instructions: PrimitiveDynamicInstructions { } } -private func resolveDynamicInstructions( +func resolveDynamicInstructions( _ dynamicInstructions: any DynamicInstructions ) -> ResolvedDynamicInstructions { func resolve(_ content: Content) -> ResolvedDynamicInstructions diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift new file mode 100644 index 00000000..6e4c0668 --- /dev/null +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -0,0 +1,556 @@ +extension LanguageModelSession { + /// A declarative, per-model-request session profile. + /// + /// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors + /// Foundation Models 27. + @_typeEraser(AnyDynamicProfile) + public protocol DynamicProfile { + associatedtype Body: DynamicProfile + + @DynamicProfileBuilder + var body: Body { get } + } + + /// A concrete profile containing dynamic instructions and Tools. + public struct Profile: DynamicProfile { + public typealias Body = Never + + fileprivate let dynamicInstructions: AnyDynamicInstructions + fileprivate let identity: String + + public init( + @DynamicInstructionsBuilder _ dynamicInstructions: () -> some DynamicInstructions + ) { + let value = dynamicInstructions() + self.dynamicInstructions = AnyDynamicInstructions(value) + self.identity = String(reflecting: type(of: value)) + } + + public var body: Never { + fatalError("LanguageModelSession.Profile has no body") + } + } + + /// A type-erased dynamic profile. + public struct AnyDynamicProfile: DynamicProfile { + public typealias Body = Never + + fileprivate let resolveValue: () -> ResolvedDynamicProfile + + public init(_ dynamicProfile: any DynamicProfile) { + resolveValue = { resolveDynamicProfile(dynamicProfile) } + } + + public init(erasing dynamicProfile: some DynamicProfile) { + self.init(dynamicProfile) + } + + public var body: Never { + fatalError("LanguageModelSession.AnyDynamicProfile has no body") + } + + func resolveForRequest() -> ResolvedDynamicProfile { + resolveValue() + } + } + + public struct ConditionalDynamicProfile: DynamicProfile + where TrueContent: DynamicProfile, FalseContent: DynamicProfile { + public enum Branch { + case trueContent(TrueContent) + case falseContent(FalseContent) + } + + public typealias Body = Never + fileprivate let branch: Branch + + public init(_ branch: Branch) { + self.branch = branch + } + + public var body: Never { + fatalError("ConditionalDynamicProfile has no body") + } + } + + @resultBuilder + public struct DynamicProfileBuilder { + public static func buildBlock(_ content: Content) -> Content + where Content: DynamicProfile { + content + } + + public static func buildEither( + first content: TrueContent + ) -> ConditionalDynamicProfile + where TrueContent: DynamicProfile, FalseContent: DynamicProfile { + ConditionalDynamicProfile(.trueContent(content)) + } + + public static func buildEither( + second content: FalseContent + ) -> ConditionalDynamicProfile + where TrueContent: DynamicProfile, FalseContent: DynamicProfile { + ConditionalDynamicProfile(.falseContent(content)) + } + + @available(*, unavailable, message: "The body of a 'DynamicProfile' must evaluate to a single active profile") + public static func buildBlock(_ contents: repeat each Content) -> Never + where repeat each Content: DynamicProfile { + fatalError("A DynamicProfile must have one active profile") + } + + public static func buildLimitedAvailability( + _ component: some DynamicProfile + ) -> AnyDynamicProfile { + AnyDynamicProfile(component) + } + } + + public protocol DynamicProfileModifier { + associatedtype Body: DynamicProfile + typealias Content = DynamicProfileModifierContent + + @DynamicProfileBuilder + func body(content: Content) -> Body + } + + public struct DynamicProfileModifierContent: DynamicProfile + where Modifier: DynamicProfileModifier { + public typealias Body = Never + fileprivate let content: AnyDynamicProfile + + fileprivate init(_ content: Content) where Content: DynamicProfile { + self.content = AnyDynamicProfile(content) + } + + public var body: Never { + fatalError("DynamicProfileModifierContent has no body") + } + } + + public struct ModifiedDynamicProfile: DynamicProfile + where Content: DynamicProfile, Modifier: DynamicProfileModifier { + public typealias Body = Never + fileprivate let content: Content + fileprivate let modifier: Modifier + + fileprivate init(content: Content, modifier: Modifier) { + self.content = content + self.modifier = modifier + } + + public var body: Never { + fatalError("ModifiedDynamicProfile has no body") + } + } +} + +extension LanguageModelSession.DynamicProfile { + public typealias Profile = LanguageModelSession.Profile + public typealias DynamicProfile = LanguageModelSession.DynamicProfile + public typealias SessionProperty = LanguageModelSession.SessionProperty + + public func modifier( + _ modifier: Modifier + ) -> some LanguageModelSession.DynamicProfile + where Modifier: LanguageModelSession.DynamicProfileModifier { + LanguageModelSession.ModifiedDynamicProfile(content: self, modifier: modifier) + } + + public func model( + _ model: any LanguageModel + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .model(model)) + } + + public func model( + _ model: Model + ) -> some LanguageModelSession.DynamicProfile where Model: LanguageModel { + BuiltinModifiedDynamicProfile(content: self, modifier: .model(model)) + } + + public func temperature(_ temperature: Double?) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .temperature(temperature)) + } + + public func samplingMode( + _ samplingMode: GenerationOptions.SamplingMode? + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .samplingMode(samplingMode)) + } + + public func maximumResponseTokens( + _ maximumResponseTokens: Int? + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile( + content: self, + modifier: .maximumResponseTokens(maximumResponseTokens) + ) + } + + public func reasoningLevel( + _ reasoningLevel: ContextOptions.ReasoningLevel? + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .reasoningLevel(reasoningLevel)) + } + + public func toolCallingMode( + _ toolCallingMode: GenerationOptions.ToolCallingMode? + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .toolCallingMode(toolCallingMode)) + } + + public func historyTransform( + _ transform: @escaping ([Transcript.Entry]) -> [Transcript.Entry] + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .historyTransform(transform)) + } + + public func transcriptErrorHandlingPolicy( + _ policy: TranscriptErrorHandlingPolicy? + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .transcriptErrorHandlingPolicy(policy)) + } + + public func onPrompt( + perform action: @Sendable @escaping (Transcript.Prompt) async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onPrompt(action)) + } + + public func onPrompt( + perform action: @Sendable @escaping () async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + onPrompt { _ in try await action() } + } + + public func onResponse( + perform action: @Sendable @escaping (Transcript.Response) async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onResponse(action)) + } + + public func onResponse( + perform action: @Sendable @escaping () async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + onResponse { _ in try await action() } + } + + public func onReasoning( + perform action: @Sendable @escaping (Transcript.Reasoning) async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onReasoning(action)) + } + + public func onReasoning( + perform action: @Sendable @escaping () async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + onReasoning { _ in try await action() } + } + + public func onToolCall( + perform action: @Sendable @escaping (Transcript.ToolCall) async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onToolCall(action)) + } + + public func onToolCall( + perform action: @Sendable @escaping () async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + onToolCall { _ in try await action() } + } + + public func onToolOutput( + perform action: @Sendable @escaping (Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onToolOutput(action)) + } + + public func onToolOutput( + perform action: @Sendable @escaping () async throws -> Void + ) -> some LanguageModelSession.DynamicProfile { + onToolOutput { _, _ in try await action() } + } + + public func onActivate( + perform action: @Sendable @escaping () async -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onActivate(action)) + } + + public func onDeactivate( + perform action: @Sendable @escaping () async -> Void + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile(content: self, modifier: .onDeactivate(action)) + } +} + +extension LanguageModelSession.DynamicProfileModifier { + public typealias DynamicProfile = LanguageModelSession.DynamicProfile + public typealias SessionProperty = LanguageModelSession.SessionProperty +} + +extension Never: LanguageModelSession.DynamicProfile {} + +private protocol PrimitiveDynamicProfile { + func resolve() -> ResolvedDynamicProfile +} + +struct ResolvedDynamicProfile: @unchecked Sendable { + var identity: String + var model: (any LanguageModel)? + var instructions: Instructions? + var tools: [any Tool] + var samplingMode: GenerationOptions.SamplingMode? + var hasSamplingMode = false + var temperature: Double? + var hasTemperature = false + var maximumResponseTokens: Int? + var hasMaximumResponseTokens = false + var reasoningLevel: ContextOptions.ReasoningLevel? + var hasReasoningLevel = false + var toolCallingMode: GenerationOptions.ToolCallingMode? + var hasToolCallingMode = false + var transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? + var hasTranscriptErrorHandlingPolicy = false + var historyTransforms: [([Transcript.Entry]) -> [Transcript.Entry]] = [] + var onPrompt: [@Sendable (Transcript.Prompt) async throws -> Void] = [] + var onResponse: [@Sendable (Transcript.Response) async throws -> Void] = [] + var onReasoning: [@Sendable (Transcript.Reasoning) async throws -> Void] = [] + var onToolCall: [@Sendable (Transcript.ToolCall) async throws -> Void] = [] + var onToolOutput: [@Sendable (Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void] = [] + var onActivate: [@Sendable () async -> Void] = [] + var onDeactivate: [@Sendable () async -> Void] = [] +} + +extension LanguageModelSession.Profile: PrimitiveDynamicProfile { + fileprivate func resolve() -> ResolvedDynamicProfile { + let dynamic = dynamicInstructions.resolveForRequest() + return ResolvedDynamicProfile( + identity: identity, + model: nil, + instructions: dynamic.instructions, + tools: dynamic.tools + ) + } +} + +extension LanguageModelSession.AnyDynamicProfile: PrimitiveDynamicProfile { + fileprivate func resolve() -> ResolvedDynamicProfile { + resolveForRequest() + } +} + +extension LanguageModelSession.ConditionalDynamicProfile: PrimitiveDynamicProfile { + fileprivate func resolve() -> ResolvedDynamicProfile { + var resolved: ResolvedDynamicProfile + switch branch { + case .trueContent(let content): + resolved = resolveDynamicProfile(content) + resolved.identity = "true/" + resolved.identity + case .falseContent(let content): + resolved = resolveDynamicProfile(content) + resolved.identity = "false/" + resolved.identity + } + return resolved + } +} + +extension LanguageModelSession.DynamicProfileModifierContent: PrimitiveDynamicProfile { + fileprivate func resolve() -> ResolvedDynamicProfile { + content.resolveForRequest() + } +} + +extension LanguageModelSession.ModifiedDynamicProfile: PrimitiveDynamicProfile { + fileprivate func resolve() -> ResolvedDynamicProfile { + resolveDynamicProfile(modifier.body(content: .init(content))) + } +} + +private struct BuiltinModifiedDynamicProfile: LanguageModelSession.DynamicProfile, + PrimitiveDynamicProfile +where Content: LanguageModelSession.DynamicProfile { + typealias Body = Never + let content: Content + let modifier: BuiltinDynamicProfileModifier + + var body: Never { + fatalError("BuiltinModifiedDynamicProfile has no body") + } + + func resolve() -> ResolvedDynamicProfile { + var resolved = resolveDynamicProfile(content) + modifier.apply(to: &resolved) + return resolved + } +} + +private enum BuiltinDynamicProfileModifier: @unchecked Sendable { + case model(any LanguageModel) + case temperature(Double?) + case samplingMode(GenerationOptions.SamplingMode?) + case maximumResponseTokens(Int?) + case reasoningLevel(ContextOptions.ReasoningLevel?) + case toolCallingMode(GenerationOptions.ToolCallingMode?) + case historyTransform(([Transcript.Entry]) -> [Transcript.Entry]) + case transcriptErrorHandlingPolicy(TranscriptErrorHandlingPolicy?) + case onPrompt(@Sendable (Transcript.Prompt) async throws -> Void) + case onResponse(@Sendable (Transcript.Response) async throws -> Void) + case onReasoning(@Sendable (Transcript.Reasoning) async throws -> Void) + case onToolCall(@Sendable (Transcript.ToolCall) async throws -> Void) + case onToolOutput(@Sendable (Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void) + case onActivate(@Sendable () async -> Void) + case onDeactivate(@Sendable () async -> Void) + + func apply(to resolved: inout ResolvedDynamicProfile) { + switch self { + case .model(let model): + if resolved.model == nil { resolved.model = model } + case .temperature(let value): + if !resolved.hasTemperature { + resolved.temperature = value + resolved.hasTemperature = true + } + case .samplingMode(let value): + if !resolved.hasSamplingMode { + resolved.samplingMode = value + resolved.hasSamplingMode = true + } + case .maximumResponseTokens(let value): + if !resolved.hasMaximumResponseTokens { + resolved.maximumResponseTokens = value + resolved.hasMaximumResponseTokens = true + } + case .reasoningLevel(let value): + if !resolved.hasReasoningLevel { + resolved.reasoningLevel = value + resolved.hasReasoningLevel = true + } + case .toolCallingMode(let value): + if !resolved.hasToolCallingMode { + resolved.toolCallingMode = value + resolved.hasToolCallingMode = true + } + case .transcriptErrorHandlingPolicy(let value): + if !resolved.hasTranscriptErrorHandlingPolicy { + resolved.transcriptErrorHandlingPolicy = value + resolved.hasTranscriptErrorHandlingPolicy = true + } + case .historyTransform(let transform): + resolved.historyTransforms.insert(transform, at: 0) + case .onPrompt(let action): + resolved.onPrompt.insert(action, at: 0) + case .onResponse(let action): + resolved.onResponse.insert(action, at: 0) + case .onReasoning(let action): + resolved.onReasoning.insert(action, at: 0) + case .onToolCall(let action): + resolved.onToolCall.insert(action, at: 0) + case .onToolOutput(let action): + resolved.onToolOutput.insert(action, at: 0) + case .onActivate(let action): + resolved.onActivate.insert(action, at: 0) + case .onDeactivate(let action): + resolved.onDeactivate.append(action) + } + } +} + +private func resolveDynamicProfile( + _ dynamicProfile: any LanguageModelSession.DynamicProfile +) -> ResolvedDynamicProfile { + func resolve(_ content: Content) -> ResolvedDynamicProfile + where Content: LanguageModelSession.DynamicProfile { + if let primitive = content as? any PrimitiveDynamicProfile { + return primitive.resolve() + } + return resolve(content.body) + } + + return resolve(dynamicProfile) +} + +actor ProfileLifecycle { + private var activeProfile: ResolvedDynamicProfile? + private var lastPromptID: String? + + func prepare( + _ profile: ResolvedDynamicProfile, + properties: SessionPropertyValues, + history: [Transcript.Entry] + ) async throws { + if activeProfile?.identity != profile.identity { + if let previous = activeProfile { + await withBindings(properties: properties, history: history) { + for action in previous.onDeactivate { await action() } + } + } + await withBindings(properties: properties, history: history) { + for action in profile.onActivate { await action() } + } + activeProfile = profile + } else { + activeProfile = profile + } + + guard + let prompt = history.reversed().compactMap({ entry -> Transcript.Prompt? in + if case .prompt(let prompt) = entry { return prompt } + return nil + }).first, + prompt.id != lastPromptID + else { return } + + try await withBindings(properties: properties, history: history) { + for action in profile.onPrompt { try await action(prompt) } + } + lastPromptID = prompt.id + } + + private func withBindings( + properties: SessionPropertyValues, + history: [Transcript.Entry], + operation: @Sendable () async throws -> Result + ) async rethrows -> Result { + let history = SessionHistoryBinding(history, isWritable: true) + return try await SessionPropertyBinding.$values.withValue(properties) { + try await SessionPropertyBinding.$history.withValue(history) { + try await operation() + } + } + } +} + +struct MissingDynamicProfileModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + throw MissingDynamicProfileModelError.missingModelModifier + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let stream = AsyncThrowingStream.Snapshot, any Error> { + continuation in + continuation.finish(throwing: MissingDynamicProfileModelError.missingModelModifier) + } + return LanguageModelSession.ResponseStream(stream: stream) + } +} + +enum MissingDynamicProfileModelError: Error { + case missingModelModifier +} diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 10627f0d..e6163b30 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -108,14 +108,17 @@ public final class LanguageModelSession: @unchecked Sendable { public let instructions: Instructions? private let dynamicInstructions: AnyDynamicInstructions? + private let dynamicProfile: AnyDynamicProfile? private let dynamicInstructionsLock = NSLock() + private let dynamicProfileLock = NSLock() + private let profileLifecycle = ProfileLifecycle() - /// Session-scoped values available to dynamic instructions. + /// Session-scoped values available to profiles and dynamic instructions. public let properties: SessionPropertyValues /// Whether the session resolves its instructions and tools before each request. nonisolated var usesDynamicInstructions: Bool { - dynamicInstructions != nil + dynamicInstructions != nil || dynamicProfile != nil } /// A delegate that observes and controls tool execution. @@ -151,10 +154,53 @@ public final class LanguageModelSession: @unchecked Sendable { /// The tools that the model can call in response to the request. public let tools: [any Tool] - fileprivate init(transcript: Transcript, instructions: Instructions?, tools: [any Tool]) { + /// The model selected for this request. + public let model: any LanguageModel + + /// Generation options after profile defaults are combined with call-site options. + public let options: GenerationOptions + + /// Context options selected by the active profile. + public let contextOptions: ContextOptions + + fileprivate let profile: ResolvedDynamicProfile? + fileprivate let canonicalTranscript: [Transcript.Entry] + fileprivate let callbackState: ProfileCallbackState + + fileprivate init( + transcript: Transcript, + instructions: Instructions?, + tools: [any Tool], + model: any LanguageModel, + options: GenerationOptions = .init(), + contextOptions: ContextOptions = .init(), + profile: ResolvedDynamicProfile? = nil, + canonicalTranscript: [Transcript.Entry]? = nil, + callbackState: ProfileCallbackState = ProfileCallbackState() + ) { self.transcript = transcript self.instructions = instructions self.tools = tools + self.model = model + self.options = options + self.contextOptions = contextOptions + self.profile = profile + self.canonicalTranscript = canonicalTranscript ?? Array(transcript) + self.callbackState = callbackState + } + + fileprivate func sharingCallbackState(with context: RequestContext) -> RequestContext { + RequestContext( + transcript: transcript, + instructions: instructions, + tools: tools, + model: model, + options: options, + contextOptions: contextOptions, + profile: profile, + canonicalTranscript: canonicalTranscript, + callbackState: context.callbackState + ) } } @@ -168,34 +214,254 @@ public final class LanguageModelSession: @unchecked Sendable { /// It's public so that language models outside this module /// can read the inputs for each request. nonisolated public func resolvedRequestContext() -> RequestContext { - guard let dynamicInstructions else { - return RequestContext(transcript: transcript, instructions: instructions, tools: tools) + if let prepared = ProfileRequestBinding.prepared, + prepared.session === self + { + return prepared.context + } + return resolveRequestContext(additionalEntries: [], options: .init()) + } + + /// Resolves the active profile for one actual model request. + /// + /// `additionalEntries` supplies canonical entries produced in the current Tool + /// round but not yet committed to the durable Session transcript. A profile's + /// history transform sees those entries before selecting the request projection. + /// + /// - Note: This overload is exclusive to AnyLanguageModel. Compatibility + /// executors use it to preserve Foundation Models 27 request timing while + /// AnyLanguageModel still exposes its legacy model interface. + nonisolated public func resolvedRequestContext( + including additionalEntries: [Transcript.Entry], + options: GenerationOptions = .init() + ) async throws -> RequestContext { + let prepared = ProfileRequestBinding.prepared + if additionalEntries.isEmpty, let prepared, prepared.session === self { + return prepared.context + } + let callSiteOptions = + if let prepared, prepared.session === self { + prepared.callSiteOptions + } else { + options + } + var context = resolveRequestContext( + additionalEntries: additionalEntries, + options: callSiteOptions + ) + if let prepared, prepared.session === self { + context = context.sharingCallbackState(with: prepared.context) + } + if let profile = context.profile { + try await profileLifecycle.prepare( + profile, + properties: properties, + history: context.canonicalTranscript + ) + } + if let prepared, prepared.session === self { + prepared.context = context + } + return context + } + + nonisolated private func resolveRequestContext( + additionalEntries: [Transcript.Entry], + options: GenerationOptions + ) -> RequestContext { + var canonical = Array(transcript) + additionalEntries + guard let dynamicProfile else { + guard let dynamicInstructions else { + return RequestContext( + transcript: Transcript(entries: canonical), + instructions: instructions, + tools: tools, + model: model, + options: options, + canonicalTranscript: canonical + ) + } + + // Dynamic instructions are request-only. Their body can observe + // session properties and canonical history, but cannot rewrite history. + let history = SessionHistoryBinding(canonical, isWritable: false) + let resolved = dynamicInstructionsLock.withLock { + SessionPropertyBinding.$values.withValue(properties) { + SessionPropertyBinding.$history.withValue(history) { + dynamicInstructions.resolveForRequest() + } + } + } + var requestTranscript = Transcript(entries: canonical) + if let currentInstructions = resolved.instructions { + requestTranscript = Transcript( + entries: [instructionsEntry(currentInstructions, tools: resolved.tools)] + canonical + ) + } + return RequestContext( + transcript: requestTranscript, + instructions: resolved.instructions, + tools: resolved.tools, + model: model, + options: options, + canonicalTranscript: canonical + ) } - // Evaluate the body for this request. - // The resolved instructions go into the request's transcript, - // never into the session's. - let canonicalHistory = Array(transcript) - let resolved = dynamicInstructionsLock.withLock { + let history = SessionHistoryBinding(canonical, isWritable: true) + let resolved = dynamicProfileLock.withLock { SessionPropertyBinding.$values.withValue(properties) { - SessionPropertyBinding.$history.withValue(canonicalHistory) { - dynamicInstructions.resolveForRequest() + SessionPropertyBinding.$history.withValue(history) { + dynamicProfile.resolveForRequest() } } } - var requestTranscript = transcript - if let instructions = resolved.instructions { - let instructionsEntry = Transcript.Entry.instructions( - Transcript.Instructions( - segments: [.text(Transcript.TextSegment(content: instructions.description))], - toolDefinitions: resolved.tools - .filter(\.includesSchemaInInstructions) - .map { Transcript.ToolDefinition(tool: $0) } - ) + canonical = history.entries + var projected = canonical + for transform in resolved.historyTransforms { + let transformHistory = SessionHistoryBinding(canonical, isWritable: true) + projected = SessionPropertyBinding.$values.withValue(properties) { + SessionPropertyBinding.$history.withValue(transformHistory) { + transform(projected) + } + } + canonical = transformHistory.entries + } + var requestTranscript = Transcript(entries: projected) + if let currentInstructions = resolved.instructions { + requestTranscript = Transcript( + entries: [instructionsEntry(currentInstructions, tools: resolved.tools)] + projected + ) + } + + var resolvedOptions = options + if resolvedOptions.sampling == nil, resolved.hasSamplingMode { + resolvedOptions.sampling = resolved.samplingMode + } + if resolvedOptions.temperature == nil, resolved.hasTemperature { + resolvedOptions.temperature = resolved.temperature + } + if resolvedOptions.maximumResponseTokens == nil, resolved.hasMaximumResponseTokens { + resolvedOptions.maximumResponseTokens = resolved.maximumResponseTokens + } + if resolvedOptions.toolCallingMode == nil, resolved.hasToolCallingMode { + resolvedOptions.toolCallingMode = resolved.toolCallingMode + } + if resolved.hasTranscriptErrorHandlingPolicy { + state.withLock { $0.transcriptErrorHandlingPolicy = resolved.transcriptErrorHandlingPolicy } + } + return RequestContext( + transcript: requestTranscript, + instructions: resolved.instructions, + tools: resolved.tools, + model: resolved.model ?? model, + options: resolvedOptions, + contextOptions: .init(reasoningLevel: resolved.reasoningLevel), + profile: resolved, + canonicalTranscript: canonical + ) + } + + nonisolated private func instructionsEntry( + _ instructions: Instructions, + tools: [any Tool] + ) -> Transcript.Entry { + .instructions( + .init( + segments: [.text(.init(content: instructions.description))], + toolDefinitions: + tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } ) - requestTranscript = Transcript(entries: [instructionsEntry] + requestTranscript) + ) + } + + /// Runs the active request snapshot's Tool-call callbacks. + nonisolated public func profileWillExecuteToolCall( + _ call: Transcript.ToolCall, + requestContext: RequestContext, + currentRoundEntries: [Transcript.Entry] + ) async throws { + guard let profile = requestContext.profile else { return } + let history = Array(transcript) + currentRoundEntries + try await withSessionProperties(history: history, isHistoryWritable: true) { + for action in profile.onToolCall { try await action(call) } + } + } + + /// Runs the active request snapshot's Tool-output callbacks. + nonisolated public func profileDidProduceToolOutput( + for call: Transcript.ToolCall, + output: Transcript.ToolOutput, + requestContext: RequestContext, + currentRoundEntries: [Transcript.Entry] + ) async throws { + guard let profile = requestContext.profile else { return } + let history = Array(transcript) + currentRoundEntries + try await withSessionProperties(history: history, isHistoryWritable: true) { + for action in profile.onToolOutput { try await action(call, output) } + } + } + + /// Runs reasoning and response callbacks from the profile snapshot that + /// produced the entries. Each stable entry ID is dispatched at most once + /// for the active request, including when streaming snapshots repeat it. + /// + /// - Note: This API is exclusive to AnyLanguageModel's compatibility model + /// seam. Ordinary callers compose callbacks through ``DynamicProfile``. + nonisolated public func profileDidProduceEntries( + _ entries: [Transcript.Entry], + requestContext: RequestContext, + currentRoundEntries: [Transcript.Entry] + ) async throws { + guard let profile = requestContext.profile else { return } + for entry in entries { + if requestContext.callbackState.wasDispatched(entry.id) { + continue + } + let history = Array(transcript) + currentRoundEntries + switch entry { + case .reasoning(let reasoning): + try await withSessionProperties(history: history, isHistoryWritable: true) { + for action in profile.onReasoning { try await action(reasoning) } + } + case .response(let response): + try await withSessionProperties(history: history, isHistoryWritable: true) { + for action in profile.onResponse { try await action(response) } + } + default: + continue + } + requestContext.callbackState.markDispatched(entry.id) + } + } + + /// Executes compatibility-model Tool work with session properties bound to + /// the producing request. Custom properties remain mutable, while the + /// canonical history is read-only in a Tool, matching Foundation Models 27. + nonisolated public func withProfileToolExecution( + requestContext: RequestContext, + currentRoundEntries: [Transcript.Entry], + operation: @Sendable () async throws -> Result + ) async rethrows -> Result { + let history = Array(transcript) + currentRoundEntries + return try await withSessionProperties(history: history, isHistoryWritable: false) { + try await operation() + } + } + + nonisolated private func withSessionProperties( + history: [Transcript.Entry], + isHistoryWritable: Bool, + operation: @Sendable () async throws -> Result + ) async rethrows -> Result { + let history = SessionHistoryBinding(history, isWritable: isHistoryWritable) + return try await SessionPropertyBinding.$values.withValue(properties) { + try await SessionPropertyBinding.$history.withValue(history) { + try await operation() + } } - return RequestContext(transcript: requestTranscript, instructions: resolved.instructions, tools: resolved.tools) } /// Creates a session with a model, tools, @@ -290,7 +556,28 @@ public final class LanguageModelSession: @unchecked Sendable { tools: [], instructions: nil, transcript: Transcript(entries: history), - dynamicInstructions: AnyDynamicInstructions(dynamicInstructions) + dynamicInstructions: AnyDynamicInstructions(dynamicInstructions), + dynamicProfile: nil + ) + } + + /// Creates a session whose active model, instructions, Tools, and request + /// projection are resolved before every model request. + public convenience init( + profile: sending some DynamicProfile, + history: some Collection = [] + ) { + let history = history.filter { entry in + if case .instructions = entry { return false } + return true + } + self.init( + model: MissingDynamicProfileModel(), + tools: [], + instructions: nil, + transcript: Transcript(entries: history), + dynamicInstructions: nil, + dynamicProfile: AnyDynamicProfile(profile) ) } @@ -299,11 +586,13 @@ public final class LanguageModelSession: @unchecked Sendable { tools: [any Tool], instructions: Instructions?, transcript: Transcript, - dynamicInstructions: AnyDynamicInstructions? = nil + dynamicInstructions: AnyDynamicInstructions? = nil, + dynamicProfile: AnyDynamicProfile? = nil ) { self.model = model self.tools = tools self.dynamicInstructions = dynamicInstructions + self.dynamicProfile = dynamicProfile let resolvedInstructions = instructions ?? Self.instructions(from: transcript) self.instructions = resolvedInstructions @@ -433,7 +722,8 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func wrapStream( _ upstream: sending ResponseStream, - promptEntry: Transcript.Entry + promptEntry: Transcript.Entry, + profileTracker: PreparedProfileRequestTracker? = nil ) -> ResponseStream where Content: Generable, Content.PartiallyGenerated: Sendable { let session = self let relay = AsyncThrowingStream.Snapshot, any Error> { continuation in @@ -447,6 +737,7 @@ public final class LanguageModelSession: @unchecked Sendable { session.beginResponding() var lastSnapshot: ResponseStream.Snapshot? var accountedUsage = Usage.zero + var committedEntryIDs: Set = [] do { for try await snapshot in stream { lastSnapshot = snapshot @@ -504,11 +795,28 @@ public final class LanguageModelSession: @unchecked Sendable { $0.transcript.append(responseEntry) } } + committedEntryIDs = Set( + lastSnapshot.transcriptEntries.map(\.id) + [responseEntry.id] + ) + if let prepared = profileTracker?.prepared { + try await session.profileDidProduceEntries( + Array(lastSnapshot.transcriptEntries) + [responseEntry], + requestContext: prepared.context, + currentRoundEntries: [] + ) + } session.endResponding() continuation.finish() } catch { session.withMutation(keyPath: \.transcript) { session.state.withLock { state in + if !committedEntryIDs.isEmpty { + state.transcript = Transcript( + entries: state.transcript.filter { + !committedEntryIDs.contains($0.id) + } + ) + } if state.transcriptErrorHandlingPolicy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) } else if state.transcriptErrorHandlingPolicy == .revertTranscript { @@ -735,21 +1043,50 @@ public final class LanguageModelSession: @unchecked Sendable { responseFormat: type == String.self ? nil : .init(type: type), options: options ) { - try await model.respond( - within: self, - to: prompt, - generating: type, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options + try await profiledResponse(options: options) { model, resolvedOptions, contextOptions in + try await model.respond( + within: self, + to: prompt, + generating: type, + includeSchemaInPrompt: contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: resolvedOptions + ) + } + } + } + + nonisolated private func profiledResponse( + options: GenerationOptions, + generate: ( + any LanguageModel, + GenerationOptions, + ContextOptions + ) async throws -> Response + ) async throws -> ProfiledModelResponse where Content: Generable { + guard dynamicProfile != nil else { + return ProfiledModelResponse( + response: try await generate(model, options, .init()), + prepared: nil ) } + let context = try await resolvedRequestContext(including: [], options: options) + let prepared = PreparedProfileRequest( + session: self, + context: context, + callSiteOptions: options + ) + let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { + try await generate(context.model, context.options, context.contextOptions) + } + return ProfiledModelResponse(response: response, prepared: prepared) } nonisolated private func respond( to prompt: Prompt, responseFormat: Transcript.ResponseFormat?, options: GenerationOptions, - generate: () async throws -> Response + generate: () async throws -> ProfiledModelResponse ) async throws -> Response { try await wrapRespond { promptID in // Add prompt to transcript @@ -765,7 +1102,11 @@ public final class LanguageModelSession: @unchecked Sendable { state.withLock { $0.transcript.append(promptEntry) } } - let response = Self.representingNilExplicitly(in: try await generate(), responseFormat: responseFormat) + let profiled = try await generate() + let response = Self.representingNilExplicitly( + in: profiled.response, + responseFormat: responseFormat + ) recordUsage(response.usage) @@ -785,15 +1126,45 @@ public final class LanguageModelSession: @unchecked Sendable { ) ) - // Add tool entries and response to transcript + try await commitResponse( + response, + entry: responseEntry, + prepared: profiled.prepared + ) + + return response + } + } + + nonisolated private func commitResponse( + _ response: Response, + entry responseEntry: Transcript.Entry, + prepared: PreparedProfileRequest? + ) async throws where Content: Generable { + withMutation(keyPath: \.transcript) { + state.withLock { lockedState in + lockedState.transcript.append(contentsOf: response.transcriptEntries) + lockedState.transcript.append(responseEntry) + } + } + + guard let prepared else { return } + do { + try await profileDidProduceEntries( + Array(response.transcriptEntries) + [responseEntry], + requestContext: prepared.context, + currentRoundEntries: [] + ) + } catch { + let ids = Set(response.transcriptEntries.map(\.id) + [responseEntry.id]) withMutation(keyPath: \.transcript) { state.withLock { lockedState in - lockedState.transcript.append(contentsOf: response.transcriptEntries) - lockedState.transcript.append(responseEntry) + lockedState.transcript = Transcript( + entries: lockedState.transcript.filter { !ids.contains($0.id) } + ) } } - - return response + throw error } } @@ -815,17 +1186,92 @@ public final class LanguageModelSession: @unchecked Sendable { state.withLock { $0.transcript.append(promptEntry) } } - return wrapStream( - model.streamResponse( + let upstream: ResponseStream + let profileTracker: PreparedProfileRequestTracker? + if dynamicProfile != nil { + let tracker = PreparedProfileRequestTracker() + profileTracker = tracker + upstream = profileDispatchedStream( + prompt: prompt, + type: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: tracker + ) + } else { + profileTracker = nil + upstream = model.streamResponse( within: self, to: prompt, generating: type, includeSchemaInPrompt: includeSchemaInPrompt, options: options - ), - promptEntry: promptEntry + ) + } + return wrapStream( + upstream, + promptEntry: promptEntry, + profileTracker: profileTracker ) } + + nonisolated private func profileDispatchedStream( + prompt: Prompt, + type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions, + tracker: PreparedProfileRequestTracker + ) -> ResponseStream where Content: Generable { + profileDispatchedStream(options: options, tracker: tracker) { + model, + resolvedOptions, + contextOptions in + model.streamResponse( + within: self, + to: prompt, + generating: type, + includeSchemaInPrompt: contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: resolvedOptions + ) + } + } + + nonisolated private func profileDispatchedStream( + options: GenerationOptions, + tracker: PreparedProfileRequestTracker, + generate: + @Sendable @escaping ( + any LanguageModel, + GenerationOptions, + ContextOptions + ) -> ResponseStream + ) -> ResponseStream where Content: Generable { + let stream = AsyncThrowingStream.Snapshot, any Error> { continuation in + let task = Task { + do { + let context = try await self.resolvedRequestContext(including: [], options: options) + let prepared = PreparedProfileRequest( + session: self, + context: context, + callSiteOptions: options + ) + tracker.prepared = prepared + let upstream = ProfileRequestBinding.$prepared.withValue(prepared) { + generate(context.model, context.options, context.contextOptions) + } + for try await snapshot in upstream { + continuation.yield(snapshot) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return ResponseStream(stream: stream) + } } // MARK: - String Response Convenience Methods @@ -900,13 +1346,16 @@ extension LanguageModelSession { options: GenerationOptions = GenerationOptions() ) async throws -> Response { try await respond(to: prompt, responseFormat: .init(schema: schema), options: options) { - try await model.respond( - within: self, - to: prompt, - schema: schema, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options - ) + try await profiledResponse(options: options) { model, resolvedOptions, contextOptions in + try await model.respond( + within: self, + to: prompt, + schema: schema, + includeSchemaInPrompt: contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: resolvedOptions + ) + } } } @@ -963,15 +1412,38 @@ extension LanguageModelSession { state.withLock { $0.transcript.append(promptEntry) } } - return wrapStream( - model.streamResponse( + let upstream: ResponseStream + let profileTracker: PreparedProfileRequestTracker? + if dynamicProfile != nil { + let tracker = PreparedProfileRequestTracker() + profileTracker = tracker + upstream = profileDispatchedStream(options: options, tracker: tracker) { + model, + resolvedOptions, + contextOptions in + model.streamResponse( + within: self, + to: prompt, + schema: schema, + includeSchemaInPrompt: contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: resolvedOptions + ) + } + } else { + profileTracker = nil + upstream = model.streamResponse( within: self, to: prompt, schema: schema, includeSchemaInPrompt: includeSchemaInPrompt, options: options - ), - promptEntry: promptEntry + ) + } + return wrapStream( + upstream, + promptEntry: promptEntry, + profileTracker: profileTracker ) } @@ -1165,14 +1637,21 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - let response = Self.representingNilExplicitly( - in: try await model.respond( + let profiled = try await profiledResponse(options: options) { + model, + resolvedOptions, + contextOptions in + try await model.respond( within: self, to: textPrompt, generating: type, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options - ), + includeSchemaInPrompt: contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: resolvedOptions + ) + } + let response = Self.representingNilExplicitly( + in: profiled.response, responseFormat: type == String.self ? nil : .init(type: type) ) @@ -1194,13 +1673,11 @@ extension LanguageModelSession { ) ) - // Add tool entries and response to transcript - withMutation(keyPath: \.transcript) { - state.withLock { lockedState in - lockedState.transcript.append(contentsOf: response.transcriptEntries) - lockedState.transcript.append(responseEntry) - } - } + try await commitResponse( + response, + entry: responseEntry, + prepared: profiled.prepared + ) return response } @@ -1297,15 +1774,38 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - return wrapStream( - model.streamResponse( + let upstream: ResponseStream + let profileTracker: PreparedProfileRequestTracker? + if dynamicProfile != nil { + let tracker = PreparedProfileRequestTracker() + profileTracker = tracker + upstream = profileDispatchedStream(options: options, tracker: tracker) { + model, + resolvedOptions, + contextOptions in + model.streamResponse( + within: self, + to: textPrompt, + generating: type, + includeSchemaInPrompt: contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: resolvedOptions + ) + } + } else { + profileTracker = nil + upstream = model.streamResponse( within: self, to: textPrompt, generating: type, includeSchemaInPrompt: includeSchemaInPrompt, options: options - ), - promptEntry: promptEntry + ) + } + return wrapStream( + upstream, + promptEntry: promptEntry, + profileTracker: profileTracker ) } } @@ -1688,6 +2188,61 @@ private enum ResponseStreamError: Error, LocalizedError { } } +private struct ProfiledModelResponse where Content: Generable { + let response: LanguageModelSession.Response + let prepared: PreparedProfileRequest? +} + +fileprivate final class ProfileCallbackState: @unchecked Sendable { + private let dispatchedEntryIDs = Locked>([]) + + func wasDispatched(_ id: String) -> Bool { + dispatchedEntryIDs.withLock { $0.contains(id) } + } + + func markDispatched(_ id: String) { + dispatchedEntryIDs.withLock { _ = $0.insert(id) } + } +} + +private final class PreparedProfileRequest: @unchecked Sendable { + let session: LanguageModelSession + let callSiteOptions: GenerationOptions + private let state: Locked + + init( + session: LanguageModelSession, + context: LanguageModelSession.RequestContext, + callSiteOptions: GenerationOptions + ) { + self.session = session + self.callSiteOptions = callSiteOptions + state = Locked(State(context: context)) + } + + var context: LanguageModelSession.RequestContext { + get { state.withLock { $0.context } } + set { state.withLock { $0.context = newValue } } + } + + private struct State: Sendable { + var context: LanguageModelSession.RequestContext + } +} + +private final class PreparedProfileRequestTracker: @unchecked Sendable { + private let storage = Locked(nil) + + var prepared: PreparedProfileRequest? { + get { storage.withLock { $0 } } + set { storage.withLock { $0 = newValue } } + } +} + +private enum ProfileRequestBinding { + @TaskLocal static var prepared: PreparedProfileRequest? +} + // MARK: - private struct State: Equatable, Sendable { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 9b503bff..27b22188 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -24,7 +24,7 @@ public protocol SessionPropertyKey: SendableMetatype { public macro SessionPropertyEntry() = #externalMacro(module: "AnyLanguageModelMacros", type: "SessionPropertyEntryMacro") -/// Session-scoped storage available while dynamic instructions are evaluated. +/// Session-scoped storage shared by profiles, dynamic instructions, and tools. /// /// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors /// Foundation Models 27. @@ -42,14 +42,17 @@ public final class SessionPropertyValues: @unchecked Sendable { self.historySetter = historySetter } - /// The session history without an instructions entry. + /// The canonical session history after dynamic instructions. public var history: Transcript.HistoryView { - get { SessionPropertyBinding.history ?? historyGetter() } + get { SessionPropertyBinding.history?.entries ?? historyGetter() } set { - precondition( - SessionPropertyBinding.history == nil, - "Session history is read-only while dynamic instructions are active" - ) + if let history = SessionPropertyBinding.history { + precondition( + history.isWritable, + "Session history is read-only while dynamic instructions or a Tool is active" + ) + history.entries = newValue + } historySetter(newValue) } } @@ -73,7 +76,22 @@ public final class SessionPropertyValues: @unchecked Sendable { enum SessionPropertyBinding { @TaskLocal static var values: SessionPropertyValues? - @TaskLocal static var history: Transcript.HistoryView? + @TaskLocal static var history: SessionHistoryBinding? +} + +final class SessionHistoryBinding: @unchecked Sendable { + private let storage: Locked + let isWritable: Bool + + init(_ entries: Transcript.HistoryView, isWritable: Bool) { + storage = Locked(entries) + self.isWritable = isWritable + } + + var entries: Transcript.HistoryView { + get { storage.withLock { $0 } } + set { storage.withLock { $0 = newValue } } + } } extension LanguageModelSession { @@ -90,7 +108,7 @@ extension LanguageModelSession { get { guard let values = SessionPropertyBinding.values else { preconditionFailure( - "SessionProperty can only be accessed while dynamic instructions are active" + "SessionProperty can only be accessed while a session profile, dynamic instructions, or a Tool is active" ) } return values[keyPath: keyPath] @@ -98,7 +116,7 @@ extension LanguageModelSession { nonmutating set { guard let values = SessionPropertyBinding.values else { preconditionFailure( - "SessionProperty can only be accessed while dynamic instructions are active" + "SessionProperty can only be accessed while a session profile, dynamic instructions, or a Tool is active" ) } values[keyPath: keyPath] = newValue @@ -112,3 +130,7 @@ extension LanguageModelSession.SessionProperty: @unchecked Sendable where Value: extension DynamicInstructions { public typealias SessionProperty = LanguageModelSession.SessionProperty } + +extension Tool { + public typealias SessionProperty = LanguageModelSession.SessionProperty +} diff --git a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift index 2afe9d3e..c1e27220 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -28,6 +28,26 @@ import Testing } } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + private struct CompatibilityDynamicProfile: LanguageModelSession.DynamicProfile { + let model: any LanguageModel + @SessionProperty(\.compatibilityRevision) private var revision + + var body: some LanguageModelSession.DynamicProfile { + Profile { + Instructions("Revision \(revision)") + } + .model(model) + .samplingMode(.greedy) + .temperature(0.2) + .maximumResponseTokens(32) + .reasoningLevel(.light) + .toolCallingMode(.allowed) + .historyTransform { $0 } + .transcriptErrorHandlingPolicy(nil) + } + } + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test("AnyLanguageModel Drop-In Compatibility", .enabled(if: isSystemLanguageModelAvailable)) func anyLanguageModelCompatibility() async throws { @@ -45,6 +65,10 @@ import Testing ) dynamicSession.properties.compatibilityRevision = 1 _ = dynamicSession.properties.history + _ = LanguageModelSession( + profile: CompatibilityDynamicProfile(model: model), + history: session.transcript + ) } let options = GenerationOptions(temperature: 0.7) diff --git a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift index d8d0c51e..532f90ec 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -29,6 +29,26 @@ import Testing } } } + + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + private struct CompatibilityDynamicProfile: LanguageModelSession.DynamicProfile { + let model: any LanguageModel + @SessionProperty(\.compatibilityRevision) private var revision + + var body: some LanguageModelSession.DynamicProfile { + Profile { + Instructions("Revision \(revision)") + } + .model(model) + .samplingMode(.greedy) + .temperature(0.2) + .maximumResponseTokens(32) + .reasoningLevel(.light) + .toolCallingMode(.allowed) + .historyTransform { $0 } + .transcriptErrorHandlingPolicy(nil) + } + } #endif #endif @@ -54,6 +74,10 @@ import Testing ) dynamicSession.properties.compatibilityRevision = 1 _ = dynamicSession.properties.history + _ = LanguageModelSession( + profile: CompatibilityDynamicProfile(model: model), + history: session.transcript + ) } #endif #endif diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift new file mode 100644 index 00000000..64ec740f --- /dev/null +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -0,0 +1,491 @@ +import Foundation +import Testing + +@_spi(Compatibility) @testable import AnyLanguageModel + +extension SessionPropertyValues { + @SessionPropertyEntry var profileRevision = 0 +} + +@Suite("Dynamic profiles") +struct DynamicProfileTests { + @Test func sameSessionReevaluatesModelInstructionsOptionsAndProperties() async throws { + let state = ProfileFixtureState() + let first = ProfileFixtureModel(id: "model-a", state: state) + let second = ProfileFixtureModel(id: "model-b", state: state) + let session = LanguageModelSession( + profile: FixtureProfile(state: state, first: first, second: second) + ) + + _ = try await session.respond(to: "First") + state.selectSecond() + session.properties.profileRevision = 7 + _ = try await session.respond(to: "Second") + + let requests = state.requests.withLock { $0 } + #expect(requests.map(\.modelID) == ["model-a", "model-b"]) + #expect(requests.map(\.instructions) == ["Profile A revision 0", "Profile B revision 7"]) + #expect(requests.map(\.temperature) == [0.2, 0.8]) + #expect(requests.map(\.maximumResponseTokens) == [64, 128]) + #expect(requests.map(\.reasoningLevel) == [.light, .deep]) + #expect(session.transcript.count == 4) + #expect(!session.transcript.contains { if case .instructions = $0 { true } else { false } }) + } + + @Test func callSiteOptionsOverrideProfileDefaults() async throws { + let state = ProfileFixtureState() + let model = ProfileFixtureModel(id: "model", state: state) + let session = LanguageModelSession( + profile: FixtureProfile(state: state, first: model, second: model) + ) + + _ = try await session.respond( + to: "Override", + options: .init( + temperature: 0.45, + maximumResponseTokens: 17, + toolCallingMode: .required + ) + ) + + let request = try #require(state.requests.withLock { $0.last }) + #expect(request.temperature == 0.45) + #expect(request.maximumResponseTokens == 17) + #expect(request.toolCallingMode == .required) + #expect(request.reasoningLevel == .light) + } + + @Test func lifecycleCallbacksAccumulateInOrderForResponseAndStream() async throws { + let state = LifecycleFixtureState() + let model = LifecycleFixtureModel() + let session = LanguageModelSession(profile: LifecycleFixtureProfile(state: state, model: model)) + + _ = try await session.respond(to: "One") + _ = try await session.streamResponse(to: "Two").collect() + state.useSecond.withLock { $0 = true } + _ = try await session.respond(to: "Three") + + #expect( + state.events.withLock { $0 } == [ + "outer-activate:A", "inner-activate:A", "outer-prompt:A", "inner-prompt:A", + "outer-reasoning:A", "inner-reasoning:A", "outer-response:A", "inner-response:A", + "outer-prompt:A", "inner-prompt:A", "outer-reasoning:A", "inner-reasoning:A", + "outer-response:A", "inner-response:A", "inner-deactivate:A", "outer-deactivate:A", + "outer-activate:B", "inner-activate:B", "outer-prompt:B", "inner-prompt:B", + "outer-reasoning:B", "inner-reasoning:B", "outer-response:B", "inner-response:B", + ] + ) + } + + @Test(arguments: [false, true]) + func callbackErrorsPropagateWithoutCommittingAResponse(streaming: Bool) async throws { + let model = LifecycleFixtureModel() + let session = LanguageModelSession(profile: ThrowingResponseProfile(model: model)) + + if streaming { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.streamResponse(to: "Fail").collect() + } + } else { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.respond(to: "Fail") + } + } + + #expect(session.transcript.count == 1) + #expect(session.transcript.allSatisfy { if case .prompt = $0 { true } else { false } }) + } + + @Test func dynamicInstructionsAndToolsShareSessionProperties() async throws { + let model = ProfileFixtureModel(id: "model", state: ProfileFixtureState()) + let session = LanguageModelSession( + model: model, + dynamicInstructions: PropertyInstructions() + ) + session.properties.profileRevision = 4 + + let context = session.resolvedRequestContext() + #expect(context.instructions?.description == "Revision 4") + + let tool = PropertyFixtureTool() + _ = try await session.withProfileToolExecution( + requestContext: context, + currentRoundEntries: [] + ) { + try await tool.makeOutputSegments(from: GeneratedContent(properties: [:])) + } + #expect(session.properties.profileRevision == 5) + } + + @Test func rehydrationKeepsCanonicalHistoryAndUsesTheCurrentProfile() async throws { + let oldState = ProfileFixtureState() + let model = ProfileFixtureModel(id: "model", state: oldState) + let oldSession = LanguageModelSession( + profile: FixtureProfile(state: oldState, first: model, second: model) + ) + _ = try await oldSession.respond(to: "Old") + let saved = oldSession.transcript + + let restoredState = ProfileFixtureState() + restoredState.selectSecond() + let restoredModel = ProfileFixtureModel(id: "restored", state: restoredState) + let restored = LanguageModelSession( + profile: FixtureProfile( + state: restoredState, + first: restoredModel, + second: restoredModel + ), + history: saved + ) + _ = try await restored.respond(to: "New") + + #expect(restoredState.requests.withLock { $0.last?.instructions } == "Profile B revision 0") + #expect(restored.transcript.count == saved.count + 2) + #expect(!restored.transcript.contains { if case .instructions = $0 { true } else { false } }) + } + + @Test func transformedCurrentRoundDoesNotReplaceCanonicalTranscript() async throws { + let state = ProfileFixtureState() + let model = ProfileFixtureModel(id: "model-a", state: state) + let session = LanguageModelSession( + profile: FixtureProfile(state: state, first: model, second: model) + ) + _ = try await session.respond(to: "First") + let durableBefore = session.transcript + let call = Transcript.ToolCall( + id: "call-1", + toolName: "fixture", + arguments: GeneratedContent(properties: [:]) + ) + let output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: [.text(.init(content: "output"))] + ) + let inFlight: [Transcript.Entry] = [ + .toolCalls(.init([call])), + .toolOutput(output), + ] + + let context = try await session.resolvedRequestContext( + including: inFlight, + options: .init() + ) + + #expect(context.transcript.contains { $0.id == output.id }) + #expect(state.transformedHistory.withLock { $0.last?.contains(output.id) } == true) + #expect(session.transcript == durableBefore) + } + + @Test func toolCallbacksUseProducingSnapshotAndSessionPropertiesStayIsolated() async throws { + let state = ProfileFixtureState() + let model = ProfileFixtureModel(id: "model-a", state: state) + let first = LanguageModelSession( + profile: FixtureProfile(state: state, first: model, second: model) + ) + let second = LanguageModelSession( + profile: FixtureProfile(state: state, first: model, second: model) + ) + let call = Transcript.ToolCall( + id: "call", + toolName: "fixture", + arguments: GeneratedContent(properties: [:]) + ) + let output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: [.text(.init(content: "done"))] + ) + let request = try await first.resolvedRequestContext(including: [], options: .init()) + + try await first.profileWillExecuteToolCall( + call, + requestContext: request, + currentRoundEntries: [.toolCalls(.init([call]))] + ) + try await first.profileDidProduceToolOutput( + for: call, + output: output, + requestContext: request, + currentRoundEntries: [.toolCalls(.init([call])), .toolOutput(output)] + ) + + #expect(first.properties.profileRevision == 1) + #expect(second.properties.profileRevision == 0) + #expect(state.callbackEvents.withLock { $0 } == ["call:call", "output:call"]) + } +} + +private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let state: ProfileFixtureState + let first: ProfileFixtureModel + let second: ProfileFixtureModel + @SessionProperty(\.profileRevision) private var revision + + var body: some LanguageModelSession.DynamicProfile { + if state.usesSecond { + LanguageModelSession.Profile { + Instructions("Profile B revision \(revision)") + FixtureProfileTool() + } + .model(second) + .temperature(0.8) + .maximumResponseTokens(128) + .reasoningLevel(.deep) + .historyTransform(recordingTransform) + .onToolCall(perform: recordCall) + .onToolOutput(perform: recordOutput) + } else { + LanguageModelSession.Profile { + Instructions("Profile A revision \(revision)") + FixtureProfileTool() + } + .model(first) + .temperature(0.2) + .maximumResponseTokens(64) + .reasoningLevel(.light) + .historyTransform(recordingTransform) + .onToolCall(perform: recordCall) + .onToolOutput(perform: recordOutput) + } + } + + private func recordingTransform(_ entries: [Transcript.Entry]) -> [Transcript.Entry] { + state.transformedHistory.withLock { $0.append(entries.map(\.id)) } + return entries + } + + private func recordCall(_ call: Transcript.ToolCall) async throws { + state.callbackEvents.withLock { $0.append("call:\(call.id)") } + } + + private func recordOutput( + _ call: Transcript.ToolCall, + _ output: Transcript.ToolOutput + ) async throws { + revision += 1 + state.callbackEvents.withLock { $0.append("output:\(output.id)") } + } +} + +private struct FixtureProfileTool: Tool { + let name = "fixture" + let description = "Fixture" + + @Generable + struct Arguments {} + + func call(arguments: Arguments) async throws -> String { "done" } +} + +private struct ProfileFixtureModel: LanguageModel { + typealias UnavailableReason = Never + let id: String + let state: ProfileFixtureState + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let context = try await session.resolvedRequestContext(including: [], options: options) + state.requests.withLock { + $0.append( + .init( + modelID: id, + instructions: context.instructions?.description, + temperature: context.options.temperature, + maximumResponseTokens: context.options.maximumResponseTokens, + toolCallingMode: context.options.toolCallingMode, + reasoningLevel: context.contextOptions.reasoningLevel + ) + ) + } + let raw = GeneratedContent("response") + return .init(content: try Content(raw), rawContent: raw, transcriptEntries: []) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let stream = AsyncThrowingStream.Snapshot, any Error> { + continuation in + let task = Task { + do { + let response: LanguageModelSession.Response = try await respond( + within: session, + to: prompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + continuation.yield( + .init( + content: response.content.asPartiallyGenerated(), + rawContent: response.rawContent + ) + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return .init(stream: stream) + } +} + +private final class ProfileFixtureState: @unchecked Sendable { + struct Request: Equatable { + let modelID: String + let instructions: String? + let temperature: Double? + let maximumResponseTokens: Int? + let toolCallingMode: GenerationOptions.ToolCallingMode? + let reasoningLevel: ContextOptions.ReasoningLevel? + } + + private let selection = Locked(false) + let requests = Locked<[Request]>([]) + let transformedHistory = Locked<[[String]]>([]) + let callbackEvents = Locked<[String]>([]) + + var usesSecond: Bool { selection.withLock { $0 } } + func selectSecond() { selection.withLock { $0 = true } } +} + +private struct PropertyInstructions: DynamicInstructions, @unchecked Sendable { + @SessionProperty(\.profileRevision) private var revision + + var body: some DynamicInstructions { + Instructions("Revision \(revision)") + } +} + +private struct PropertyFixtureTool: Tool, @unchecked Sendable { + let name = "property" + let description = "Updates a session property" + @SessionProperty(\.profileRevision) private var revision + + @Generable struct Arguments {} + + func call(arguments: Arguments) async throws -> String { + revision += 1 + return "updated" + } +} + +private enum LifecycleFixtureError: Error { + case rejected +} + +private final class LifecycleFixtureState: @unchecked Sendable { + let useSecond = Locked(false) + let events = Locked<[String]>([]) + + func record(_ value: String) { + events.withLock { $0.append(value) } + } +} + +private struct LifecycleFixtureProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let state: LifecycleFixtureState + let model: LifecycleFixtureModel + + var body: some LanguageModelSession.DynamicProfile { + if state.useSecond.withLock({ $0 }) { + profile(label: "B") + } else { + profile(label: "A") + } + } + + private func profile(label: String) -> some LanguageModelSession.DynamicProfile { + LanguageModelSession.Profile { Instructions("Lifecycle \(label)") } + .model(model) + .onActivate { state.record("inner-activate:\(label)") } + .onDeactivate { state.record("inner-deactivate:\(label)") } + .onPrompt { state.record("inner-prompt:\(label)") } + .onReasoning { state.record("inner-reasoning:\(label)") } + .onResponse { state.record("inner-response:\(label)") } + .onActivate { state.record("outer-activate:\(label)") } + .onDeactivate { state.record("outer-deactivate:\(label)") } + .onPrompt { state.record("outer-prompt:\(label)") } + .onReasoning { state.record("outer-reasoning:\(label)") } + .onResponse { state.record("outer-response:\(label)") } + } +} + +private struct ThrowingResponseProfile: LanguageModelSession.DynamicProfile { + let model: LifecycleFixtureModel + + var body: some LanguageModelSession.DynamicProfile { + LanguageModelSession.Profile { Instructions("Throw after response") } + .model(model) + .onResponse { throw LifecycleFixtureError.rejected } + } +} + +private struct LifecycleFixtureModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let raw = GeneratedContent("response") + let reasoning = Transcript.Entry.reasoning( + .init(segments: [.text(.init(content: "reasoning"))]) + ) + return .init( + content: try Content(raw), + rawContent: raw, + transcriptEntries: [reasoning] + ) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let stream = AsyncThrowingStream.Snapshot, any Error> { + continuation in + let task = Task { + do { + let response: LanguageModelSession.Response = try await respond( + within: session, + to: prompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + continuation.yield( + .init( + content: response.content.asPartiallyGenerated(), + rawContent: response.rawContent, + transcriptEntries: response.transcriptEntries + ) + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return .init(stream: stream) + } +} From 8cc3a74353afc8b22b2c8a3f6c41ab7691db072f Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 03:08:32 +0800 Subject: [PATCH 08/53] Preserve dynamic profiles across tool continuations --- Sources/AnyLanguageModel/DynamicProfile.swift | 102 +++-- .../LanguageModelSession.swift | 103 ++++-- .../Models/AnthropicLanguageModel.swift | 349 ++++++++++-------- .../Models/GeminiLanguageModel.swift | 186 ++++------ .../Models/OllamaLanguageModel.swift | 314 ++++++++-------- .../Models/OpenAILanguageModel.swift | 223 ++++++----- .../Models/OpenResponsesLanguageModel.swift | 153 +++++--- .../ProfileToolExecution.swift | 246 ++++++++++++ .../AnyLanguageModel/SessionProperties.swift | 36 +- .../AnthropicReasoningTests.swift | 24 +- .../DynamicProfileTests.swift | 122 ++++++ .../ProviderUsageTests.swift | 236 +++++++++++- 12 files changed, 1430 insertions(+), 664 deletions(-) create mode 100644 Sources/AnyLanguageModel/ProfileToolExecution.swift diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 6e4c0668..8b7c2fc2 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -214,75 +214,93 @@ extension LanguageModelSession.DynamicProfile { } public func onPrompt( - perform action: @Sendable @escaping (Transcript.Prompt) async throws -> Void + @_inheritActorContext perform action: + nonisolated(nonsending) sending @escaping ( + Transcript.Prompt + ) async throws -> Void ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .onPrompt(action)) } public func onPrompt( - perform action: @Sendable @escaping () async throws -> Void + @_inheritActorContext perform action: nonisolated(nonsending) sending @escaping () async throws -> Void ) -> some LanguageModelSession.DynamicProfile { onPrompt { _ in try await action() } } public func onResponse( - perform action: @Sendable @escaping (Transcript.Response) async throws -> Void + @_inheritActorContext perform action: + nonisolated(nonsending) sending @escaping ( + Transcript.Response + ) async throws -> Void ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .onResponse(action)) } public func onResponse( - perform action: @Sendable @escaping () async throws -> Void + @_inheritActorContext perform action: nonisolated(nonsending) sending @escaping () async throws -> Void ) -> some LanguageModelSession.DynamicProfile { onResponse { _ in try await action() } } public func onReasoning( - perform action: @Sendable @escaping (Transcript.Reasoning) async throws -> Void + @_inheritActorContext perform action: + nonisolated(nonsending) sending @escaping ( + Transcript.Reasoning + ) async throws -> Void ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .onReasoning(action)) } public func onReasoning( - perform action: @Sendable @escaping () async throws -> Void + @_inheritActorContext perform action: nonisolated(nonsending) sending @escaping () async throws -> Void ) -> some LanguageModelSession.DynamicProfile { onReasoning { _ in try await action() } } public func onToolCall( - perform action: @Sendable @escaping (Transcript.ToolCall) async throws -> Void + @_inheritActorContext perform action: + nonisolated(nonsending) sending @escaping ( + Transcript.ToolCall + ) async throws -> Void ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .onToolCall(action)) } public func onToolCall( - perform action: @Sendable @escaping () async throws -> Void + @_inheritActorContext perform action: nonisolated(nonsending) sending @escaping () async throws -> Void ) -> some LanguageModelSession.DynamicProfile { onToolCall { _ in try await action() } } public func onToolOutput( - perform action: @Sendable @escaping (Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void + @_inheritActorContext perform action: + nonisolated(nonsending) sending @escaping ( + Transcript.ToolCall, + Transcript.ToolOutput + ) async throws -> Void ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .onToolOutput(action)) } public func onToolOutput( - perform action: @Sendable @escaping () async throws -> Void + @_inheritActorContext perform action: nonisolated(nonsending) sending @escaping () async throws -> Void ) -> some LanguageModelSession.DynamicProfile { onToolOutput { _, _ in try await action() } } public func onActivate( - perform action: @Sendable @escaping () async -> Void + @_inheritActorContext perform action: sending @escaping @isolated(any) () async -> Void ) -> some LanguageModelSession.DynamicProfile { - BuiltinModifiedDynamicProfile(content: self, modifier: .onActivate(action)) + let wrapped: () async -> Void = { await action() } + return BuiltinModifiedDynamicProfile(content: self, modifier: .onActivate(wrapped)) } public func onDeactivate( - perform action: @Sendable @escaping () async -> Void + @_inheritActorContext perform action: sending @escaping @isolated(any) () async -> Void ) -> some LanguageModelSession.DynamicProfile { - BuiltinModifiedDynamicProfile(content: self, modifier: .onDeactivate(action)) + let wrapped: () async -> Void = { await action() } + return BuiltinModifiedDynamicProfile(content: self, modifier: .onDeactivate(wrapped)) } } @@ -315,13 +333,13 @@ struct ResolvedDynamicProfile: @unchecked Sendable { var transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? var hasTranscriptErrorHandlingPolicy = false var historyTransforms: [([Transcript.Entry]) -> [Transcript.Entry]] = [] - var onPrompt: [@Sendable (Transcript.Prompt) async throws -> Void] = [] - var onResponse: [@Sendable (Transcript.Response) async throws -> Void] = [] - var onReasoning: [@Sendable (Transcript.Reasoning) async throws -> Void] = [] - var onToolCall: [@Sendable (Transcript.ToolCall) async throws -> Void] = [] - var onToolOutput: [@Sendable (Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void] = [] - var onActivate: [@Sendable () async -> Void] = [] - var onDeactivate: [@Sendable () async -> Void] = [] + var onPrompt: [(Transcript.Prompt) async throws -> Void] = [] + var onResponse: [(Transcript.Response) async throws -> Void] = [] + var onReasoning: [(Transcript.Reasoning) async throws -> Void] = [] + var onToolCall: [(Transcript.ToolCall) async throws -> Void] = [] + var onToolOutput: [(Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void] = [] + var onActivate: [() async -> Void] = [] + var onDeactivate: [() async -> Void] = [] } extension LanguageModelSession.Profile: PrimitiveDynamicProfile { @@ -396,13 +414,13 @@ private enum BuiltinDynamicProfileModifier: @unchecked Sendable { case toolCallingMode(GenerationOptions.ToolCallingMode?) case historyTransform(([Transcript.Entry]) -> [Transcript.Entry]) case transcriptErrorHandlingPolicy(TranscriptErrorHandlingPolicy?) - case onPrompt(@Sendable (Transcript.Prompt) async throws -> Void) - case onResponse(@Sendable (Transcript.Response) async throws -> Void) - case onReasoning(@Sendable (Transcript.Reasoning) async throws -> Void) - case onToolCall(@Sendable (Transcript.ToolCall) async throws -> Void) - case onToolOutput(@Sendable (Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void) - case onActivate(@Sendable () async -> Void) - case onDeactivate(@Sendable () async -> Void) + case onPrompt((Transcript.Prompt) async throws -> Void) + case onResponse((Transcript.Response) async throws -> Void) + case onReasoning((Transcript.Reasoning) async throws -> Void) + case onToolCall((Transcript.ToolCall) async throws -> Void) + case onToolOutput((Transcript.ToolCall, Transcript.ToolOutput) async throws -> Void) + case onActivate(() async -> Void) + case onDeactivate(() async -> Void) func apply(to resolved: inout ResolvedDynamicProfile) { switch self { @@ -479,15 +497,24 @@ actor ProfileLifecycle { func prepare( _ profile: ResolvedDynamicProfile, properties: SessionPropertyValues, - history: [Transcript.Entry] + history: [Transcript.Entry], + protectedEntryIDs: Set = [] ) async throws { if activeProfile?.identity != profile.identity { if let previous = activeProfile { - await withBindings(properties: properties, history: history) { + await withBindings( + properties: properties, + history: history, + protectedEntryIDs: protectedEntryIDs + ) { for action in previous.onDeactivate { await action() } } } - await withBindings(properties: properties, history: history) { + await withBindings( + properties: properties, + history: history, + protectedEntryIDs: protectedEntryIDs + ) { for action in profile.onActivate { await action() } } activeProfile = profile @@ -503,7 +530,11 @@ actor ProfileLifecycle { prompt.id != lastPromptID else { return } - try await withBindings(properties: properties, history: history) { + try await withBindings( + properties: properties, + history: history, + protectedEntryIDs: protectedEntryIDs + ) { for action in profile.onPrompt { try await action(prompt) } } lastPromptID = prompt.id @@ -512,9 +543,14 @@ actor ProfileLifecycle { private func withBindings( properties: SessionPropertyValues, history: [Transcript.Entry], + protectedEntryIDs: Set, operation: @Sendable () async throws -> Result ) async rethrows -> Result { - let history = SessionHistoryBinding(history, isWritable: true) + let history = properties.historyBinding( + history, + isWritable: true, + protecting: protectedEntryIDs + ) return try await SessionPropertyBinding.$values.withValue(properties) { try await SessionPropertyBinding.$history.withValue(history) { try await operation() diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index e6163b30..0e8a3290 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -163,6 +163,9 @@ public final class LanguageModelSession: @unchecked Sendable { /// Context options selected by the active profile. public let contextOptions: ContextOptions + /// Transcript failure behavior captured for this request. + public let transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? + fileprivate let profile: ResolvedDynamicProfile? fileprivate let canonicalTranscript: [Transcript.Entry] fileprivate let callbackState: ProfileCallbackState @@ -174,6 +177,7 @@ public final class LanguageModelSession: @unchecked Sendable { model: any LanguageModel, options: GenerationOptions = .init(), contextOptions: ContextOptions = .init(), + transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? = nil, profile: ResolvedDynamicProfile? = nil, canonicalTranscript: [Transcript.Entry]? = nil, callbackState: ProfileCallbackState = ProfileCallbackState() @@ -184,6 +188,7 @@ public final class LanguageModelSession: @unchecked Sendable { self.model = model self.options = options self.contextOptions = contextOptions + self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy self.profile = profile self.canonicalTranscript = canonicalTranscript ?? Array(transcript) self.callbackState = callbackState @@ -197,6 +202,7 @@ public final class LanguageModelSession: @unchecked Sendable { model: model, options: options, contextOptions: contextOptions, + transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy, profile: profile, canonicalTranscript: canonicalTranscript, callbackState: context.callbackState @@ -256,7 +262,8 @@ public final class LanguageModelSession: @unchecked Sendable { try await profileLifecycle.prepare( profile, properties: properties, - history: context.canonicalTranscript + history: context.canonicalTranscript, + protectedEntryIDs: Set(additionalEntries.map(\.id)) ) } if let prepared, prepared.session === self { @@ -270,6 +277,7 @@ public final class LanguageModelSession: @unchecked Sendable { options: GenerationOptions ) -> RequestContext { var canonical = Array(transcript) + additionalEntries + let sessionErrorPolicy = transcriptErrorHandlingPolicy guard let dynamicProfile else { guard let dynamicInstructions else { return RequestContext( @@ -278,13 +286,14 @@ public final class LanguageModelSession: @unchecked Sendable { tools: tools, model: model, options: options, + transcriptErrorHandlingPolicy: sessionErrorPolicy, canonicalTranscript: canonical ) } // Dynamic instructions are request-only. Their body can observe // session properties and canonical history, but cannot rewrite history. - let history = SessionHistoryBinding(canonical, isWritable: false) + let history = properties.historyBinding(canonical, isWritable: false) let resolved = dynamicInstructionsLock.withLock { SessionPropertyBinding.$values.withValue(properties) { SessionPropertyBinding.$history.withValue(history) { @@ -304,11 +313,17 @@ public final class LanguageModelSession: @unchecked Sendable { tools: resolved.tools, model: model, options: options, + transcriptErrorHandlingPolicy: sessionErrorPolicy, canonicalTranscript: canonical ) } - let history = SessionHistoryBinding(canonical, isWritable: true) + let protectedEntryIDs = Set(additionalEntries.map(\.id)) + let history = properties.historyBinding( + canonical, + isWritable: true, + protecting: protectedEntryIDs + ) let resolved = dynamicProfileLock.withLock { SessionPropertyBinding.$values.withValue(properties) { SessionPropertyBinding.$history.withValue(history) { @@ -319,7 +334,11 @@ public final class LanguageModelSession: @unchecked Sendable { canonical = history.entries var projected = canonical for transform in resolved.historyTransforms { - let transformHistory = SessionHistoryBinding(canonical, isWritable: true) + let transformHistory = properties.historyBinding( + canonical, + isWritable: true, + protecting: protectedEntryIDs + ) projected = SessionPropertyBinding.$values.withValue(properties) { SessionPropertyBinding.$history.withValue(transformHistory) { transform(projected) @@ -347,9 +366,6 @@ public final class LanguageModelSession: @unchecked Sendable { if resolvedOptions.toolCallingMode == nil, resolved.hasToolCallingMode { resolvedOptions.toolCallingMode = resolved.toolCallingMode } - if resolved.hasTranscriptErrorHandlingPolicy { - state.withLock { $0.transcriptErrorHandlingPolicy = resolved.transcriptErrorHandlingPolicy } - } return RequestContext( transcript: requestTranscript, instructions: resolved.instructions, @@ -357,6 +373,9 @@ public final class LanguageModelSession: @unchecked Sendable { model: resolved.model ?? model, options: resolvedOptions, contextOptions: .init(reasoningLevel: resolved.reasoningLevel), + transcriptErrorHandlingPolicy: resolved.hasTranscriptErrorHandlingPolicy + ? resolved.transcriptErrorHandlingPolicy + : sessionErrorPolicy, profile: resolved, canonicalTranscript: canonical ) @@ -385,7 +404,11 @@ public final class LanguageModelSession: @unchecked Sendable { ) async throws { guard let profile = requestContext.profile else { return } let history = Array(transcript) + currentRoundEntries - try await withSessionProperties(history: history, isHistoryWritable: true) { + try await withSessionProperties( + history: history, + isHistoryWritable: true, + protectedEntryIDs: Set(currentRoundEntries.map(\.id)) + ) { for action in profile.onToolCall { try await action(call) } } } @@ -399,7 +422,11 @@ public final class LanguageModelSession: @unchecked Sendable { ) async throws { guard let profile = requestContext.profile else { return } let history = Array(transcript) + currentRoundEntries - try await withSessionProperties(history: history, isHistoryWritable: true) { + try await withSessionProperties( + history: history, + isHistoryWritable: true, + protectedEntryIDs: Set(currentRoundEntries.map(\.id)) + ) { for action in profile.onToolOutput { try await action(call, output) } } } @@ -423,11 +450,19 @@ public final class LanguageModelSession: @unchecked Sendable { let history = Array(transcript) + currentRoundEntries switch entry { case .reasoning(let reasoning): - try await withSessionProperties(history: history, isHistoryWritable: true) { + try await withSessionProperties( + history: history, + isHistoryWritable: true, + protectedEntryIDs: Set(currentRoundEntries.map(\.id)) + ) { for action in profile.onReasoning { try await action(reasoning) } } case .response(let response): - try await withSessionProperties(history: history, isHistoryWritable: true) { + try await withSessionProperties( + history: history, + isHistoryWritable: true, + protectedEntryIDs: Set(currentRoundEntries.map(\.id)) + ) { for action in profile.onResponse { try await action(response) } } default: @@ -446,7 +481,11 @@ public final class LanguageModelSession: @unchecked Sendable { operation: @Sendable () async throws -> Result ) async rethrows -> Result { let history = Array(transcript) + currentRoundEntries - return try await withSessionProperties(history: history, isHistoryWritable: false) { + return try await withSessionProperties( + history: history, + isHistoryWritable: false, + protectedEntryIDs: Set(currentRoundEntries.map(\.id)) + ) { try await operation() } } @@ -454,9 +493,14 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func withSessionProperties( history: [Transcript.Entry], isHistoryWritable: Bool, + protectedEntryIDs: Set = [], operation: @Sendable () async throws -> Result ) async rethrows -> Result { - let history = SessionHistoryBinding(history, isWritable: isHistoryWritable) + let history = properties.historyBinding( + history, + isWritable: isHistoryWritable, + protecting: protectedEntryIDs + ) return try await SessionPropertyBinding.$values.withValue(properties) { try await SessionPropertyBinding.$history.withValue(history) { try await operation() @@ -693,11 +737,15 @@ public final class LanguageModelSession: @unchecked Sendable { endResponding() return result } catch { - if transcriptErrorHandlingPolicy == .revertTranscript { + let failure = error as? ProfiledModelFailure + let policy = + failure?.prepared.context.transcriptErrorHandlingPolicy + ?? transcriptErrorHandlingPolicy + if policy == .revertTranscript { removePrompt(id: promptID) } endResponding() - throw error + throw failure?.underlyingError ?? error } } @@ -817,9 +865,13 @@ public final class LanguageModelSession: @unchecked Sendable { } ) } - if state.transcriptErrorHandlingPolicy == .preserveTranscript { + let policy = + profileTracker?.prepared?.context + .transcriptErrorHandlingPolicy + ?? state.transcriptErrorHandlingPolicy + if policy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) - } else if state.transcriptErrorHandlingPolicy == .revertTranscript { + } else if policy == .revertTranscript { state.transcript = Transcript( entries: state.transcript.filter { $0.id != promptEntry.id } ) @@ -1076,10 +1128,14 @@ public final class LanguageModelSession: @unchecked Sendable { context: context, callSiteOptions: options ) - let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { - try await generate(context.model, context.options, context.contextOptions) + do { + let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { + try await generate(context.model, context.options, context.contextOptions) + } + return ProfiledModelResponse(response: response, prepared: prepared) + } catch { + throw ProfiledModelFailure(underlyingError: error, prepared: prepared) } - return ProfiledModelResponse(response: response, prepared: prepared) } nonisolated private func respond( @@ -1164,7 +1220,7 @@ public final class LanguageModelSession: @unchecked Sendable { ) } } - throw error + throw ProfiledModelFailure(underlyingError: error, prepared: prepared) } } @@ -2193,6 +2249,11 @@ private struct ProfiledModelResponse where Content: Generable { let prepared: PreparedProfileRequest? } +private struct ProfiledModelFailure: Error { + let underlyingError: any Error + let prepared: PreparedProfileRequest +} + fileprivate final class ProfileCallbackState: @unchecked Sendable { private let dispatchedEntryIDs = Locked>([]) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 6a618a79..7ec49151 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -27,7 +27,7 @@ import OrderedCollections /// betas: ["beta1", "beta2"] /// ) /// ``` -public struct AnthropicLanguageModel: LanguageModel { +public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel { /// Custom generation options specific to Anthropic's Claude API. /// /// Reached through `GenerationOptions[custom: AnthropicLanguageModel.self]`, @@ -370,6 +370,7 @@ public struct AnthropicLanguageModel: LanguageModel { public let model: String private let httpSession: HTTPSession + let profileRequestIdentity = UUID() /// Creates an Anthropic language model. /// @@ -445,99 +446,136 @@ public struct AnthropicLanguageModel: LanguageModel { ) async throws -> LanguageModelSession.Response where Content: Generable { let url = baseURL.appendingPathComponent("v1/messages") let headers = buildHeaders() - let requestContext = session.resolvedRequestContext() - - // Convert available tools to Anthropic format - let anthropicTools: [AnthropicTool] = try requestContext.tools.map { tool in - try convertToolToAnthropicFormat(tool) - } - let responseSchema = type == String.self ? nil : try convertSchemaToAnthropicFormat(schema) - let params = try createMessageParams( - model: model, - system: nil, - messages: try requestContext.transcript.toAnthropicMessages(), - tools: anthropicTools.isEmpty ? nil : anthropicTools, - responseSchema: responseSchema, - options: options - ) - - let body = try JSONEncoder().encode(params) - - let message: AnthropicMessageResponse = try await httpSession.fetch( - .post, - url: url, - headers: headers, - body: body - ) - - var entries: [Transcript.Entry] = message.content.compactMap { block in - switch block { - case .thinking(let thinking): return .reasoning(thinking.transcriptReasoning()) - case .redactedThinking(let redacted): return .reasoning(redacted.transcriptReasoning()) - default: return nil + var entries: [Transcript.Entry] = [] + var usage = LanguageModelSession.Usage.zero + var earlierText = "" + var inFlightMessages: [AnthropicMessage] = [] + var toolRounds = ToolRoundLimit(provider: "Anthropic") + + while true { + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options + ) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage, + prefixText: earlierText + ) { + return continuation } - } - let usage = message.usage?.reportedUsage?.value ?? .zero - - // Handle tool calls, if present - let toolUses: [AnthropicToolUse] = message.content.compactMap { block in - if case .toolUse(let u) = block { return u } - return nil - } - - if !toolUses.isEmpty { - let resolution = try await resolveToolUses( - toolUses, - tools: requestContext.tools, - session: session + let anthropicTools: [AnthropicTool] = try requestContext.tools.map { + try convertToolToAnthropicFormat($0) + } + let params = try createMessageParams( + model: model, + system: nil, + messages: try anthropicMessages( + from: requestContext.transcript, + replacing: entries, + with: inFlightMessages + ), + tools: anthropicTools.isEmpty ? nil : anthropicTools, + responseSchema: responseSchema, + options: requestContext.options ) - switch resolution { - case .stop(let calls): - if !calls.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(calls))) + let message: AnthropicMessageResponse = try await httpSession.fetch( + .post, + url: url, + headers: headers, + body: try JSONEncoder().encode(params) + ) + usage.add(message.usage?.reportedUsage?.value ?? .zero) + + let reasoningEntries: [Transcript.Entry] = message.content.compactMap { block in + switch block { + case .thinking(let thinking): return .reasoning(thinking.transcriptReasoning()) + case .redactedThinking(let redacted): return .reasoning(redacted.transcriptReasoning()) + default: return nil } - let empty = try emptyResponseContent(for: type) - return LanguageModelSession.Response( - content: empty.content, - rawContent: empty.rawContent, - transcriptEntries: ArraySlice(entries), - usage: usage + } + entries.append(contentsOf: reasoningEntries) + let toolUses: [AnthropicToolUse] = message.content.compactMap { block in + if case .toolUse(let use) = block { return use } + return nil + } + if !toolUses.isEmpty { + try toolRounds.record(toolUses.map(\.roundCall)) + try await session.profileDidProduceEntries( + reasoningEntries, + requestContext: requestContext, + currentRoundEntries: entries ) - case .invocations(let invocations): - if !invocations.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call)))) + switch try await resolveToolUses( + toolUses, + requestContext: requestContext, + currentRoundEntries: entries, + session: session + ) { + case .stop(let calls): + entries.append(.toolCalls(.init(calls))) + let empty = try emptyResponseContent(for: type) + return .init( + content: empty.content, + rawContent: empty.rawContent, + transcriptEntries: ArraySlice(entries), + usage: usage + ) + case .invocations(let invocations): + inFlightMessages.append(.init(role: .assistant, content: message.content)) + entries.append(.toolCalls(.init(invocations.map(\.call)))) + var results: [AnthropicContent] = [] for invocation in invocations { entries.append(.toolOutput(invocation.output)) + results.append( + .toolResult( + .init( + toolUseId: invocation.call.id, + content: convertSegmentsToAnthropicContent(invocation.output.segments) + ) + ) + ) } + inFlightMessages.append(.init(role: .user, content: results)) + if type == String.self { + earlierText += message.content.compactMap { block -> String? in + if case .text(let text) = block { return text.text } + return nil + }.joined() + } + continue } } - } - let text = message.content.compactMap { block -> String? in - switch block { - case .text(let t): return t.text - default: return nil + let text = + earlierText + + message.content.compactMap { block -> String? in + if case .text(let text) = block { return text.text } + return nil + }.joined() + if type == String.self { + return .init( + content: text as! Content, + rawContent: GeneratedContent(text), + transcriptEntries: ArraySlice(entries), + usage: usage + ) } - }.joined() - - if type == String.self { - return LanguageModelSession.Response( - content: text as! Content, - rawContent: GeneratedContent(text), + let rawContent = try GeneratedContent(json: text) + return .init( + content: try Content(rawContent), + rawContent: rawContent, transcriptEntries: ArraySlice(entries), usage: usage ) } - - let rawContent = try GeneratedContent(json: text) - let content = try Content(rawContent) - return LanguageModelSession.Response( - content: content, - rawContent: rawContent, - transcriptEntries: ArraySlice(entries), - usage: usage - ) } public func streamResponse( @@ -596,18 +634,40 @@ public struct AnthropicLanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "Anthropic") while true { try Task.checkCancellation() - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: state.entries, + options: options + ) + if let handoff = session.profileContinuationStream( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: state.entries, + prefixUsage: state.totalUsage, + prefixText: state.responseText + ) { + for try await snapshot in handoff { continuation.yield(snapshot) } + continuation.finish() + return + } let anthropicTools: [AnthropicTool] = try requestContext.tools.map { try convertToolToAnthropicFormat($0) } - let messages = try requestContext.transcript.toAnthropicMessages() + inFlightMessages + let messages = try anthropicMessages( + from: requestContext.transcript, + replacing: state.entries, + with: inFlightMessages + ) var params = try createMessageParams( model: model, system: nil, messages: messages, tools: anthropicTools.isEmpty ? nil : anthropicTools, responseSchema: responseSchema, - options: options + options: requestContext.options ) params["stream"] = .bool(true) let body = try JSONEncoder().encode(params) @@ -696,9 +756,15 @@ public struct AnthropicLanguageModel: LanguageModel { guard !toolUses.isEmpty else { break } try Task.checkCancellation() try toolRounds.record(toolUses.map(\.roundCall)) + try await session.profileDidProduceEntries( + state.entries, + requestContext: requestContext, + currentRoundEntries: state.entries + ) switch try await resolveToolUses( toolUses, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: state.entries, session: session ) { case .stop(let calls): @@ -856,6 +922,31 @@ private func createMessageParams( // MARK: - Tool Invocation Handling +private func anthropicMessages( + from transcript: Transcript, + replacing currentRoundEntries: [Transcript.Entry], + with continuation: [AnthropicMessage] +) throws -> [AnthropicMessage] { + guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { + return try transcript.toAnthropicMessages() + } + let projected = Array(transcript) + let count = currentRoundEntries.count + let ranges = projected.indices.compactMap { start -> Range? in + let end = start + count + guard end <= projected.count, + Array(projected[start ..< end]) == currentRoundEntries + else { return nil } + return start ..< end + } + guard ranges.count == 1, let range = ranges.first else { + return try transcript.toAnthropicMessages() + } + return try Transcript(entries: projected[.. ToolResolutionOutcome { if toolUses.isEmpty { return .invocations([]) } - var toolsByName: [String: any Tool] = [:] - for tool in tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - var transcriptCalls: [Transcript.ToolCall] = [] transcriptCalls.reserveCapacity(toolUses.count) for use in toolUses { @@ -927,82 +1012,18 @@ private func resolveToolUses( ) } - 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) - } - } + let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + switch try await session.resolveProfileToolCalls( + transcriptCalls, + requestContext: requestContext, + currentRoundEntries: entries + ) { + case .stop(let calls): + return .stop(calls: calls) + case .invocations(let invocations): + return .invocations(invocations.map { .init(call: $0.call, output: $0.output) }) } - - return .invocations(results) } // 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 5fc12db9..901564a7 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -7,7 +7,7 @@ import OrderedCollections import FoundationNetworking #endif -public struct GeminiLanguageModel: LanguageModel { +public struct GeminiLanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel { public typealias UnavailableReason = Never public static let defaultBaseURL = URL(string: "https://generativelanguage.googleapis.com")! @@ -148,6 +148,7 @@ public struct GeminiLanguageModel: LanguageModel { public let model: String private let httpSession: HTTPSession + let profileRequestIdentity = UUID() /// Creates a new Gemini language model. /// @@ -218,22 +219,13 @@ public struct GeminiLanguageModel: LanguageModel { includeSchemaInPrompt: Bool, options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { - // Extract effective configuration from custom options or fall back to model defaults - let customOptions = options[custom: GeminiLanguageModel.self] - let effectiveThinking = customOptions?.thinking ?? .disabled - let effectiveServerTools = customOptions?.serverTools ?? [] - let effectiveJsonMode = customOptions?.jsonMode - let url = baseURL .appendingPathComponent(apiVersion) .appendingPathComponent("models/\(model):generateContent") let headers = buildHeaders() - var inFlightEntries: [Transcript.Entry] = [] - - // The entries this call adds, which is what the response reports. `inFlightEntries` - // preserves tool rounds while each iteration rebuilds the request from a fresh context. + // The entries this call adds, which is what the response reports. var entries: [Transcript.Entry] = [] var usage = ReportedUsage() // The text of earlier tool rounds, which string responses include. @@ -242,21 +234,37 @@ public struct GeminiLanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "Gemini") // Multi-turn conversation loop for tool calling while true { - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options + ) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage.value, + prefixText: earlierText + ) { + return continuation + } + let customOptions = requestContext.options[custom: GeminiLanguageModel.self] + let effectiveThinking = customOptions?.thinking ?? .disabled + let effectiveServerTools = customOptions?.serverTools ?? [] + let effectiveJsonMode = customOptions?.jsonMode let geminiTools = try buildTools( from: requestContext.tools, serverTools: effectiveServerTools ) - var requestTranscript = requestContext.transcript - for entry in inFlightEntries { - requestTranscript.append(entry) - } let params = try createGenerateContentParams( - contents: requestTranscript.toGeminiContent(), + contents: requestContext.transcript.toGeminiContent(), tools: geminiTools, generating: type, schema: schema, - options: options, + options: requestContext.options, thinking: effectiveThinking, jsonMode: effectiveJsonMode ) @@ -291,7 +299,8 @@ public struct GeminiLanguageModel: LanguageModel { try toolRounds.record(functionCalls.map(\.roundCall)) let resolution = try await resolveFunctionCalls( functionCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: entries, session: session ) switch resolution { @@ -311,12 +320,10 @@ public struct GeminiLanguageModel: LanguageModel { let calls = Transcript.Entry.toolCalls( Transcript.ToolCalls(invocations.map(\.call), providerMetadata: providerMetadata) ) - inFlightEntries.append(calls) entries.append(calls) for invocation in invocations { let output = Transcript.Entry.toolOutput(invocation.output) - inFlightEntries.append(output) entries.append(output) } } @@ -396,11 +403,6 @@ public struct GeminiLanguageModel: LanguageModel { options: GenerationOptions ) -> sending LanguageModelSession.ResponseStream where Content: Generable { // Extract effective configuration from custom options or fall back to model defaults - let customOptions = options[custom: GeminiLanguageModel.self] - let effectiveThinking = customOptions?.thinking ?? .disabled - let effectiveServerTools = customOptions?.serverTools ?? [] - let effectiveJsonMode = customOptions?.jsonMode - var streamURL = baseURL .appendingPathComponent(apiVersion) @@ -413,26 +415,43 @@ public struct GeminiLanguageModel: LanguageModel { let task = Task { @Sendable in do { let headers = buildHeaders() - var inFlightEntries: [Transcript.Entry] = [] var state = StreamingResponseState() var toolRounds = ToolRoundLimit(provider: "Gemini") while true { try Task.checkCancellation() - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: state.entries, + options: options + ) + if let handoff = session.profileContinuationStream( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: state.entries, + prefixUsage: state.totalUsage, + prefixText: state.responseText + ) { + for try await snapshot in handoff { continuation.yield(snapshot) } + continuation.finish() + return + } + let customOptions = requestContext.options[custom: GeminiLanguageModel.self] + let effectiveThinking = customOptions?.thinking ?? .disabled + let effectiveServerTools = customOptions?.serverTools ?? [] + let effectiveJsonMode = customOptions?.jsonMode let geminiTools = try buildTools( from: requestContext.tools, serverTools: effectiveServerTools ) - var requestTranscript = requestContext.transcript - for entry in inFlightEntries { - requestTranscript.append(entry) - } let params = try createGenerateContentParams( - contents: requestTranscript.toGeminiContent(), + contents: requestContext.transcript.toGeminiContent(), tools: geminiTools, generating: type, schema: schema, - options: options, + options: requestContext.options, thinking: effectiveThinking, jsonMode: effectiveJsonMode ) @@ -471,7 +490,8 @@ public struct GeminiLanguageModel: LanguageModel { try toolRounds.record(functionCalls.map(\.roundCall)) switch try await resolveFunctionCalls( functionCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: state.entries, session: session ) { case .stop(let calls): @@ -483,11 +503,9 @@ public struct GeminiLanguageModel: LanguageModel { let calls = Transcript.Entry.toolCalls( Transcript.ToolCalls(invocations.map(\.call), providerMetadata: metadata) ) - inFlightEntries.append(calls) state.entries.append(calls) for invocation in invocations { let output = Transcript.Entry.toolOutput(invocation.output) - inFlightEntries.append(output) state.entries.append(output) } } @@ -635,18 +653,12 @@ private enum ToolResolutionOutcome { private func resolveFunctionCalls( _ functionCalls: [GeminiFunctionCall], - tools: [any Tool], + requestContext: LanguageModelSession.RequestContext, + currentRoundEntries: [Transcript.Entry], session: LanguageModelSession ) async throws -> ToolResolutionOutcome { if functionCalls.isEmpty { return .invocations([]) } - var toolsByName: [String: any Tool] = [:] - for tool in tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - var transcriptCalls: [Transcript.ToolCall] = [] transcriptCalls.reserveCapacity(functionCalls.count) for call in functionCalls { @@ -662,82 +674,18 @@ private func resolveFunctionCalls( ) } - 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) - } - } + let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + switch try await session.resolveProfileToolCalls( + transcriptCalls, + requestContext: requestContext, + currentRoundEntries: entries + ) { + case .stop(let calls): + return .stop(calls: calls) + case .invocations(let invocations): + return .invocations(invocations.map { .init(call: $0.call, output: $0.output) }) } - - return .invocations(results) } /// Joins the text parts of a Gemini response. diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index ac1a37e4..fb69e091 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -13,7 +13,7 @@ import OrderedCollections /// ```swift /// let model = OllamaLanguageModel(model: "qwen2.5") /// ``` -public struct OllamaLanguageModel: LanguageModel { +public struct OllamaLanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel { /// The reason the model is unavailable. /// This model is always available. public typealias UnavailableReason = Never @@ -55,6 +55,7 @@ public struct OllamaLanguageModel: LanguageModel { public let model: String private let httpSession: HTTPSession + let profileRequestIdentity = UUID() /// Creates an Ollama language model. /// @@ -119,7 +120,6 @@ public struct OllamaLanguageModel: LanguageModel { includeSchemaInPrompt: Bool, options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { - let ollamaOptions = convertOptions(options) let ollamaFormat: JSONValue? if type == String.self { ollamaFormat = nil @@ -128,79 +128,116 @@ public struct OllamaLanguageModel: LanguageModel { ollamaFormat = try JSONValue(schema) } - let requestContext = session.resolvedRequestContext() - let ollamaTools = try requestContext.tools.map(convertToolToOllamaFormat) - var messages = try requestContext.transcript.toOllamaMessages() - if messages.isEmpty { - messages.append(.init(role: .user, content: prompt.description)) - } - let params = try createChatParams( - model: model, - messages: messages, - tools: ollamaTools.isEmpty ? nil : ollamaTools, - options: ollamaOptions, - stream: false, - format: ollamaFormat, - parameters: extractTopLevelChatParameters(options) - ) - let url = baseURL.appendingPathComponent("api/chat") - let body = try encodeChatParams(params) - let chatResponse: ChatResponse = try await httpSession.fetch( - .post, - url: url, - body: body, - dateDecodingStrategy: .iso8601WithFractionalSeconds - ) - var entries: [Transcript.Entry] = [] - let usage = chatResponse.reportedUsage?.value ?? .zero - - if let toolCalls = chatResponse.message.toolCalls, !toolCalls.isEmpty { - let resolution = try await resolveToolCalls( - toolCalls, - tools: requestContext.tools, - session: session + var usage = LanguageModelSession.Usage.zero + var earlierText = "" + var inFlightMessages: [OllamaMessage] = [] + var toolRounds = ToolRoundLimit(provider: "Ollama") + + while true { + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options ) - switch resolution { - case .stop(let calls): - if !calls.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(calls))) - } - return LanguageModelSession.Response( - content: "" as! Content, - rawContent: GeneratedContent(""), - transcriptEntries: ArraySlice(entries), - usage: usage - ) - case .invocations(let invocations): - if !invocations.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call)))) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage, + prefixText: earlierText + ) { + return continuation + } + let ollamaTools = try requestContext.tools.map(convertToolToOllamaFormat) + var messages = try ollamaMessages( + from: requestContext.transcript, + replacing: entries, + with: inFlightMessages + ) + if messages.isEmpty { + messages.append(.init(role: .user, content: prompt.description)) + } + let params = try createChatParams( + model: model, + messages: messages, + tools: ollamaTools.isEmpty ? nil : ollamaTools, + options: convertOptions(requestContext.options), + stream: false, + format: ollamaFormat, + parameters: extractTopLevelChatParameters(requestContext.options) + ) + let chatResponse: ChatResponse = try await httpSession.fetch( + .post, + url: url, + body: try encodeChatParams(params), + dateDecodingStrategy: .iso8601WithFractionalSeconds + ) + usage.add(chatResponse.reportedUsage?.value ?? .zero) + + if let toolCalls = chatResponse.message.toolCalls, !toolCalls.isEmpty { + try toolRounds.record(toolCalls.map(\.roundCall)) + switch try await resolveToolCalls( + toolCalls, + requestContext: requestContext, + currentRoundEntries: entries, + session: session + ) { + case .stop(let calls): + entries.append(.toolCalls(.init(calls))) + return .init( + content: "" as! Content, + rawContent: GeneratedContent(""), + transcriptEntries: ArraySlice(entries), + usage: usage + ) + case .invocations(let invocations): + inFlightMessages.append( + .init( + role: .assistant, + content: chatResponse.message.content ?? "", + toolCalls: try toolCalls.map { try JSONValue($0) } + ) + ) + entries.append(.toolCalls(.init(invocations.map(\.call)))) for invocation in invocations { entries.append(.toolOutput(invocation.output)) + let (text, images) = convertSegmentsToOllama(invocation.output.segments) + inFlightMessages.append( + .init( + role: .tool, + content: text, + images: images.isEmpty ? nil : images, + toolName: invocation.call.toolName + ) + ) } + if type == String.self { earlierText += chatResponse.message.content ?? "" } + continue } } - } - let text = chatResponse.message.content ?? "" - if type == String.self { - return LanguageModelSession.Response( - content: text as! Content, - rawContent: GeneratedContent(text), + let text = earlierText + (chatResponse.message.content ?? "") + if type == String.self { + return .init( + content: text as! Content, + rawContent: GeneratedContent(text), + transcriptEntries: ArraySlice(entries), + usage: usage + ) + } + let generatedContent = try GeneratedContent(json: text) + return .init( + content: try type.init(generatedContent), + rawContent: generatedContent, transcriptEntries: ArraySlice(entries), usage: usage ) } - - let generatedContent = try GeneratedContent(json: text) - let content = try type.init(generatedContent) - return LanguageModelSession.Response( - content: content, - rawContent: generatedContent, - transcriptEntries: ArraySlice(entries), - usage: usage - ) } public func streamResponse( @@ -256,21 +293,42 @@ public struct OllamaLanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "Ollama") while true { try Task.checkCancellation() - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: state.entries, + options: options + ) + if let handoff = session.profileContinuationStream( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: state.entries, + prefixUsage: state.totalUsage, + prefixText: state.responseText + ) { + for try await snapshot in handoff { continuation.yield(snapshot) } + continuation.finish() + return + } let tools = try requestContext.tools.map(convertToolToOllamaFormat) - var messages = try requestContext.transcript.toOllamaMessages() + var messages = try ollamaMessages( + from: requestContext.transcript, + replacing: state.entries, + with: inFlightMessages + ) if messages.isEmpty { messages.append(.init(role: .user, content: prompt.description)) } - messages.append(contentsOf: inFlightMessages) let params = try createChatParams( model: model, messages: messages, tools: tools.isEmpty ? nil : tools, - options: convertOptions(options), + options: convertOptions(requestContext.options), stream: true, format: format, - parameters: extractTopLevelChatParameters(options) + parameters: extractTopLevelChatParameters(requestContext.options) ) let body = try encodeChatParams(params) let chunks: AsyncThrowingStream = httpSession.fetchStream( @@ -294,7 +352,8 @@ public struct OllamaLanguageModel: LanguageModel { try toolRounds.record(toolCalls.map(\.roundCall)) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: state.entries, session: session ) { case .stop(let calls): @@ -340,6 +399,31 @@ public struct OllamaLanguageModel: LanguageModel { // MARK: - Tool Invocation Handling +private func ollamaMessages( + from transcript: Transcript, + replacing currentRoundEntries: [Transcript.Entry], + with continuation: [OllamaMessage] +) throws -> [OllamaMessage] { + guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { + return try transcript.toOllamaMessages() + } + let projected = Array(transcript) + let count = currentRoundEntries.count + let ranges = projected.indices.compactMap { start -> Range? in + let end = start + count + guard end <= projected.count, + Array(projected[start ..< end]) == currentRoundEntries + else { return nil } + return start ..< end + } + guard ranges.count == 1, let range = ranges.first else { + return try transcript.toOllamaMessages() + } + return try Transcript(entries: projected[.. ToolResolutionOutcome { if toolCalls.isEmpty { return .invocations([]) } - var toolsByName: [String: any Tool] = [:] - for tool in tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - var transcriptCalls: [Transcript.ToolCall] = [] transcriptCalls.reserveCapacity(toolCalls.count) for call in toolCalls { @@ -380,82 +458,18 @@ private func resolveToolCalls( ) } - 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) - } - } + let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + switch try await session.resolveProfileToolCalls( + transcriptCalls, + requestContext: requestContext, + currentRoundEntries: entries + ) { + case .stop(let calls): + return .stop(calls: calls) + case .invocations(let invocations): + return .invocations(invocations.map { .init(call: $0.call, output: $0.output) }) } - - return .invocations(results) } // MARK: - Conversions diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index 6f17d3da..f5c8f793 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -17,7 +17,7 @@ import JSONSchema /// model: "gpt-4" /// ) /// ``` -public struct OpenAILanguageModel: LanguageModel { +public struct OpenAILanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel { /// The reason the model is unavailable. /// This model is always available. public typealias UnavailableReason = Never @@ -398,6 +398,7 @@ public struct OpenAILanguageModel: LanguageModel { public let apiVariant: APIVariant private let httpSession: HTTPSession + let profileRequestIdentity = UUID() /// Creates an OpenAI language model. /// @@ -471,15 +472,19 @@ public struct OpenAILanguageModel: LanguageModel { switch apiVariant { case .chatCompletions: return try await respondWithChatCompletions( + prompt: prompt, generating: type, schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options, session: session ) case .responses: return try await respondWithResponses( + prompt: prompt, generating: type, schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options, session: session ) @@ -487,8 +492,10 @@ public struct OpenAILanguageModel: LanguageModel { } private func respondWithChatCompletions( + prompt: Prompt, generating type: Content.Type, schema: GenerationSchema, + includeSchemaInPrompt: Bool, options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { @@ -503,18 +510,38 @@ public struct OpenAILanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "OpenAI") // Loop until no more tool calls while true { - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options + ) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage.value, + prefixText: earlierText + ) { + return continuation + } let tools = requestContext.tools.isEmpty ? nil : requestContext.tools.map(convertToolToOpenAIFormat) - let messages = requestContext.transcript.toOpenAIMessages() + inFlightMessages + let messages = openAIMessages( + from: requestContext.transcript, + replacing: entries, + with: inFlightMessages + ) let params = try ChatCompletions.createRequestBody( model: model, messages: messages, tools: tools, generating: type, schema: schema, - options: options, + options: requestContext.options, stream: false ) @@ -555,7 +582,8 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(toolCalls.map(\.roundCall)) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: entries, session: session ) switch resolution { @@ -613,8 +641,10 @@ public struct OpenAILanguageModel: LanguageModel { } private func respondWithResponses( + prompt: Prompt, generating type: Content.Type, schema: GenerationSchema, + includeSchemaInPrompt: Bool, options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { @@ -631,18 +661,38 @@ public struct OpenAILanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "OpenAI") // Loop until no more tool calls while true { - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options + ) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage.value, + prefixText: earlierText + ) { + return continuation + } let tools = requestContext.tools.isEmpty ? nil : requestContext.tools.map(convertToolToOpenAIFormat) - let messages = requestContext.transcript.toOpenAIMessages() + inFlightMessages + let messages = openAIMessages( + from: requestContext.transcript, + replacing: entries, + with: inFlightMessages + ) let params = try Responses.createRequestBody( model: model, messages: messages, tools: tools, generating: type, schema: schema, - options: options, + options: requestContext.options, stream: false ) @@ -670,7 +720,8 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(toolCalls.map(\.roundCall)) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: entries, session: session ) switch resolution { @@ -785,11 +836,33 @@ public struct OpenAILanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "OpenAI") while true { try Task.checkCancellation() - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: state.entries, + options: options + ) + if let handoff = session.profileContinuationStream( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: state.entries, + prefixUsage: state.totalUsage, + prefixText: state.responseText + ) { + for try await snapshot in handoff { continuation.yield(snapshot) } + continuation.finish() + return + } let tools = requestContext.tools.isEmpty ? nil : requestContext.tools.map(convertToolToOpenAIFormat) - let messages = requestContext.transcript.toOpenAIMessages() + inFlightMessages + let messages = openAIMessages( + from: requestContext.transcript, + replacing: state.entries, + with: inFlightMessages + ) let params: JSONValue let path: String switch apiVariant { @@ -801,7 +874,7 @@ public struct OpenAILanguageModel: LanguageModel { tools: tools, generating: type, schema: schema, - options: options, + options: requestContext.options, stream: true ) case .chatCompletions: @@ -812,7 +885,7 @@ public struct OpenAILanguageModel: LanguageModel { tools: tools, generating: type, schema: schema, - options: options, + options: requestContext.options, stream: true, includeUsage: baseURL.host == Self.defaultBaseURL.host ) @@ -890,7 +963,8 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(toolCalls.map(\.roundCall)) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: state.entries, session: session ) { case .stop(let calls): @@ -1736,18 +1810,12 @@ private enum OpenAIToolResolutionOutcome { private func resolveToolCalls( _ toolCalls: [OpenAIToolCall], - tools: [any Tool], + requestContext: LanguageModelSession.RequestContext, + currentRoundEntries: [Transcript.Entry], session: LanguageModelSession ) async throws -> OpenAIToolResolutionOutcome { if toolCalls.isEmpty { return .invocations([]) } - var toolsByName: [String: any Tool] = [:] - for tool in tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - var transcriptCalls: [Transcript.ToolCall] = [] transcriptCalls.reserveCapacity(toolCalls.count) for call in toolCalls { @@ -1763,86 +1831,51 @@ private func resolveToolCalls( ) } - 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) + let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + switch try await session.resolveProfileToolCalls( + transcriptCalls, + requestContext: requestContext, + currentRoundEntries: entries + ) { + case .stop(let calls): + return .stop(calls: calls) + case .invocations(let invocations): + return .invocations( + invocations.map { .init(call: $0.call, output: $0.output) } + ) } +} - 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 - } +// MARK: - Converters - 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) - } - } +private func openAIMessages( + from transcript: Transcript, + replacing currentRoundEntries: [Transcript.Entry], + with continuation: [OpenAIMessage] +) -> [OpenAIMessage] { + guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { + return transcript.toOpenAIMessages() } - - return .invocations(results) + let projected = Array(transcript) + let count = currentRoundEntries.count + let ranges = projected.indices.compactMap { start -> Range? in + let end = start + count + guard end <= projected.count, + Array(projected[start ..< end]) == currentRoundEntries + else { return nil } + return start ..< end + } + guard ranges.count == 1, let range = ranges.first else { + // A legal request projection may remove or reorder the current Tool round. + // In that case its mapped transcript is authoritative and opaque replay is reset. + return transcript.toOpenAIMessages() + } + return Transcript(entries: projected[.. OpenAITool { // Prefer passing through a JSONSchema value built from GenerationSchema // where possible; fallback to minimal type/required map. diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 7eb9992d..a16bc848 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -20,7 +20,7 @@ import JSONSchema /// model: "openai/gpt-4o-mini" /// ) /// ``` -public struct OpenResponsesLanguageModel: LanguageModel { +public struct OpenResponsesLanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel { /// The reason the model is unavailable. /// This model is always available. public typealias UnavailableReason = Never @@ -369,6 +369,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { public let model: String private let httpSession: HTTPSession + let profileRequestIdentity = UUID() /// Creates an Open Responses language model. /// @@ -436,8 +437,10 @@ public struct OpenResponsesLanguageModel: LanguageModel { options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { return try await respondWithOpenResponses( + prompt: prompt, generating: type, schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, options: options, session: session ) @@ -495,18 +498,40 @@ public struct OpenResponsesLanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "Open Responses") while true { try Task.checkCancellation() - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: state.entries, + options: options + ) + if let handoff = session.profileContinuationStream( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: state.entries, + prefixUsage: state.totalUsage, + prefixText: state.responseText + ) { + for try await snapshot in handoff { continuation.yield(snapshot) } + continuation.finish() + return + } let tools: [OpenResponsesTool]? = requestContext.tools.isEmpty ? nil : requestContext.tools.map(convertToolToOpenResponsesFormat) - let messages = requestContext.transcript.toOpenResponsesMessages() + inFlightMessages + let messages = openResponsesMessages( + from: requestContext.transcript, + replacing: state.entries, + with: inFlightMessages + ) let params = try OpenResponsesAPI.createRequestBody( model: model, messages: messages, tools: tools, generating: type, schema: schema, - options: options, + options: requestContext.options, stream: true ) let body = try JSONEncoder().encode(params) @@ -551,7 +576,8 @@ public struct OpenResponsesLanguageModel: LanguageModel { try toolRounds.record(toolCalls.map(\.roundCall)) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: state.entries, session: session ) { case .stop(let calls): @@ -588,8 +614,10 @@ public struct OpenResponsesLanguageModel: LanguageModel { /// Sends a non-streaming request to the Open Responses API and returns the parsed response. private func respondWithOpenResponses( + prompt: Prompt, generating type: Content.Type, schema: GenerationSchema, + includeSchemaInPrompt: Bool, options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { @@ -604,18 +632,38 @@ public struct OpenResponsesLanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "Open Responses") while true { - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options + ) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage.value, + prefixText: earlierText + ) { + return continuation + } let tools: [OpenResponsesTool]? = requestContext.tools.isEmpty ? nil : requestContext.tools.map(convertToolToOpenResponsesFormat) - let messages = requestContext.transcript.toOpenResponsesMessages() + inFlightMessages + let messages = openResponsesMessages( + from: requestContext.transcript, + replacing: entries, + with: inFlightMessages + ) let params = try OpenResponsesAPI.createRequestBody( model: model, messages: messages, tools: tools, generating: type, schema: schema, - options: options, + options: requestContext.options, stream: false ) let body = try JSONEncoder().encode(params) @@ -641,7 +689,8 @@ public struct OpenResponsesLanguageModel: LanguageModel { try toolRounds.record(toolCalls.map(\.roundCall)) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: entries, session: session ) switch resolution { @@ -1167,6 +1216,31 @@ private struct OpenResponsesToolInvocationResult: Sendable { let output: Transcript.ToolOutput } +private func openResponsesMessages( + from transcript: Transcript, + replacing currentRoundEntries: [Transcript.Entry], + with continuation: [OpenResponsesMessage] +) -> [OpenResponsesMessage] { + guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { + return transcript.toOpenResponsesMessages() + } + let projected = Array(transcript) + let count = currentRoundEntries.count + let ranges = projected.indices.compactMap { start -> Range? in + let end = start + count + guard end <= projected.count, + Array(projected[start ..< end]) == currentRoundEntries + else { return nil } + return start ..< end + } + guard ranges.count == 1, let range = ranges.first else { + return transcript.toOpenResponsesMessages() + } + return Transcript(entries: projected[.. OpenResponsesToolResolutionOutcome { if toolCalls.isEmpty { return .invocations([]) } - var byName: [String: any Tool] = [:] - for t in 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) - } - } + let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + switch try await session.resolveProfileToolCalls( + transcriptCalls, + requestContext: requestContext, + currentRoundEntries: entries + ) { + case .stop(let calls): + return .stop(calls: calls) + case .invocations(let invocations): + return .invocations( + invocations.map { .init(call: $0.call, output: $0.output) } + ) } - return .invocations(results) } // MARK: - Streaming events diff --git a/Sources/AnyLanguageModel/ProfileToolExecution.swift b/Sources/AnyLanguageModel/ProfileToolExecution.swift new file mode 100644 index 00000000..68ec1930 --- /dev/null +++ b/Sources/AnyLanguageModel/ProfileToolExecution.swift @@ -0,0 +1,246 @@ +import Foundation + +protocol ProfileRequestIdentifiedLanguageModel { + var profileRequestIdentity: UUID { get } +} + +struct ProfileToolInvocation: Sendable { + let call: Transcript.ToolCall + let output: Transcript.ToolOutput +} + +enum ProfileToolResolution: Sendable { + case stop(calls: [Transcript.ToolCall]) + case invocations([ProfileToolInvocation]) +} + +extension LanguageModelSession { + func profileContinuationResponse( + ifSelectedModelDiffersFrom currentModelIdentity: UUID, + requestContext: RequestContext, + prompt: Prompt, + generating type: Content.Type, + schema: GenerationSchema, + includeSchemaInPrompt: Bool, + prefixEntries: [Transcript.Entry], + prefixUsage: Usage, + prefixText: String + ) async throws -> Response? { + if let selected = requestContext.model as? any ProfileRequestIdentifiedLanguageModel, + selected.profileRequestIdentity == currentModelIdentity + { + return nil + } + + let response: Response + if type == GeneratedContent.self { + let generated = try await requestContext.model.respond( + within: self, + to: prompt, + schema: schema, + includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: requestContext.options + ) + guard let typed = generated as? Response else { + preconditionFailure("GeneratedContent response type mismatch") + } + response = typed + } else { + response = try await requestContext.model.respond( + within: self, + to: prompt, + generating: type, + includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: requestContext.options + ) + } + + var usage = prefixUsage + usage.add(response.usage) + var content = response.content + var rawContent = response.rawContent + if type == String.self, case .string(let suffix) = response.rawContent.kind { + let combined = prefixText + suffix + content = combined as! Content + rawContent = GeneratedContent(combined) + } + return Response( + content: content, + rawContent: rawContent, + transcriptEntries: ArraySlice(prefixEntries + response.transcriptEntries), + usage: usage, + providerMetadata: response.providerMetadata + ) + } + + func profileContinuationStream( + ifSelectedModelDiffersFrom currentModelIdentity: UUID, + requestContext: RequestContext, + prompt: Prompt, + generating type: Content.Type, + schema: GenerationSchema, + includeSchemaInPrompt: Bool, + prefixEntries: [Transcript.Entry], + prefixUsage: Usage, + prefixText: String + ) -> ResponseStream? { + if let selected = requestContext.model as? any ProfileRequestIdentifiedLanguageModel, + selected.profileRequestIdentity == currentModelIdentity + { + return nil + } + + let upstream: ResponseStream + if type == GeneratedContent.self { + let generated = requestContext.model.streamResponse( + within: self, + to: prompt, + schema: schema, + includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: requestContext.options + ) + guard let typed = generated as? ResponseStream else { + preconditionFailure("GeneratedContent response stream type mismatch") + } + upstream = typed + } else { + upstream = requestContext.model.streamResponse( + within: self, + to: prompt, + generating: type, + includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt + ?? includeSchemaInPrompt, + options: requestContext.options + ) + } + + let stream = AsyncThrowingStream.Snapshot, any Error> { continuation in + let task = Task { + do { + for try await snapshot in upstream { + var usage = prefixUsage + usage.add(snapshot.usage) + var content = snapshot.content + var rawContent = snapshot.rawContent + if type == String.self, case .string(let suffix) = snapshot.rawContent.kind { + let combined = prefixText + suffix + content = combined as! Content.PartiallyGenerated + rawContent = GeneratedContent(combined) + } + continuation.yield( + .init( + content: content, + rawContent: rawContent, + transcriptEntries: ArraySlice(prefixEntries + snapshot.transcriptEntries), + usage: usage, + providerMetadata: snapshot.providerMetadata + ) + ) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return ResponseStream(stream: stream) + } + + func resolveProfileToolCalls( + _ calls: [Transcript.ToolCall], + requestContext: RequestContext, + currentRoundEntries: [Transcript.Entry], + missingToolOutput: @Sendable (Transcript.ToolCall) -> [Transcript.Segment] = { call in + [.text(.init(content: "Tool not found: \(call.toolName)"))] + } + ) async throws -> ProfileToolResolution { + guard !calls.isEmpty else { return .invocations([]) } + + if let delegate = toolExecutionDelegate { + await delegate.didGenerateToolCalls(calls, in: self) + } + + var decisions: [ToolExecutionDecision] = [] + decisions.reserveCapacity(calls.count) + for call in calls { + try await profileWillExecuteToolCall( + call, + requestContext: requestContext, + currentRoundEntries: currentRoundEntries + ) + let decision = + await toolExecutionDelegate?.toolCallDecision(for: call, in: self) ?? .execute + if case .stop = decision { + return .stop(calls: calls) + } + decisions.append(decision) + } + + var toolsByName: [String: any Tool] = [:] + for tool in requestContext.tools where toolsByName[tool.name] == nil { + toolsByName[tool.name] = tool + } + + var invocations: [ProfileToolInvocation] = [] + invocations.reserveCapacity(calls.count) + var completedEntries: [Transcript.Entry] = [] + for (call, decision) in zip(calls, decisions) { + try Task.checkCancellation() + let output: Transcript.ToolOutput + switch decision { + case .stop: + return .stop(calls: calls) + case .provideOutput(let segments): + output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: segments + ) + case .execute: + guard let tool = toolsByName[call.toolName] else { + output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: missingToolOutput(call) + ) + break + } + do { + let segments = try await withProfileToolExecution( + requestContext: requestContext, + currentRoundEntries: currentRoundEntries + completedEntries + ) { + try await tool.makeOutputSegments(from: call.arguments) + } + output = Transcript.ToolOutput( + id: call.id, + toolName: tool.name, + segments: segments + ) + } catch { + if let delegate = toolExecutionDelegate { + await delegate.didFailToolCall(call, error: error, in: self) + } + throw ToolCallError(tool: tool, underlyingError: error) + } + } + + invocations.append(.init(call: call, output: output)) + completedEntries.append(.toolOutput(output)) + try await profileDidProduceToolOutput( + for: call, + output: output, + requestContext: requestContext, + currentRoundEntries: currentRoundEntries + completedEntries + ) + if let delegate = toolExecutionDelegate { + await delegate.didExecuteToolCall(call, output: output, in: self) + } + } + return .invocations(invocations) + } +} diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 27b22188..9f1fba8b 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -52,6 +52,8 @@ public final class SessionPropertyValues: @unchecked Sendable { "Session history is read-only while dynamic instructions or a Tool is active" ) history.entries = newValue + history.persist(newValue) + return } historySetter(newValue) } @@ -72,6 +74,28 @@ public final class SessionPropertyValues: @unchecked Sendable { } } } + + func historyBinding( + _ entries: Transcript.HistoryView, + isWritable: Bool, + protecting protectedEntryIDs: Set = [] + ) -> SessionHistoryBinding { + let persist: (@Sendable (Transcript.HistoryView) -> Void)? + if isWritable { + persist = { [historySetter] updated in + historySetter( + updated.filter { !protectedEntryIDs.contains($0.id) } + ) + } + } else { + persist = nil + } + return SessionHistoryBinding( + entries, + isWritable: isWritable, + persist: persist + ) + } } enum SessionPropertyBinding { @@ -82,16 +106,26 @@ enum SessionPropertyBinding { final class SessionHistoryBinding: @unchecked Sendable { private let storage: Locked let isWritable: Bool + private let persistValue: (@Sendable (Transcript.HistoryView) -> Void)? - init(_ entries: Transcript.HistoryView, isWritable: Bool) { + init( + _ entries: Transcript.HistoryView, + isWritable: Bool, + persist: (@Sendable (Transcript.HistoryView) -> Void)? = nil + ) { storage = Locked(entries) self.isWritable = isWritable + self.persistValue = persist } var entries: Transcript.HistoryView { get { storage.withLock { $0 } } set { storage.withLock { $0 = newValue } } } + + func persist(_ entries: Transcript.HistoryView) { + persistValue?(entries) + } } extension LanguageModelSession { diff --git a/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift index 8506ba9e..f8f4f7d4 100644 --- a/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift +++ b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift @@ -65,26 +65,16 @@ import Testing result = try await session.streamResponse(to: "Weather").collect() } else { ReasoningURLProtocol.enqueue(json: response(tool: true)) + ReasoningURLProtocol.enqueue(json: response()) result = try await session.respond(to: "Weather") } - #expect(result.content == (streaming ? "Answer" : "")) - #expect(result.transcriptEntries.count == (streaming ? 4 : 3)) + #expect(result.content == "Answer") + #expect(result.transcriptEntries.count == 4) #expect(Set(result.transcriptEntries.map(\.id)).count == result.transcriptEntries.count) - #expect(ReasoningURLProtocol.recordedBodies.count == (streaming ? 2 : 1)) - if streaming { - let body = String(decoding: ReasoningURLProtocol.recordedBodies[1], as: UTF8.self) - #expect(body.contains("opaque-signature")) - #expect(body.contains("tool_result")) - } else { - // Nonstreaming Anthropic keeps its existing one-request tool behavior. - let restored = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(session.transcript)) - ReasoningURLProtocol.enqueue(json: response()) - _ = try await LanguageModelSession(model: model(), tools: [WeatherTool()], transcript: restored) - .respond(to: "Continue") - let body = String(decoding: ReasoningURLProtocol.recordedBodies[1], as: UTF8.self) - #expect(body.contains("opaque-signature")) - #expect(body.contains("tool_result")) - } + #expect(ReasoningURLProtocol.recordedBodies.count == 2) + let body = String(decoding: ReasoningURLProtocol.recordedBodies[1], as: UTF8.self) + #expect(body.contains("opaque-signature")) + #expect(body.contains("tool_result")) } @Test(arguments: [false, true]) diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index 64ec740f..ff71084b 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -1,4 +1,5 @@ import Foundation +import Observation import Testing @_spi(Compatibility) @testable import AnyLanguageModel @@ -77,6 +78,25 @@ struct DynamicProfileTests { ) } + @MainActor + @Test func appleConcurrencyAndObservationSurfaceCompiles() { + var callbackCount = 0 + _ = LanguageModelSession.Profile { Instructions("Actor callbacks") } + .model(LifecycleFixtureModel()) + .onActivate { callbackCount += 1 } + .onDeactivate { callbackCount += 1 } + .onPrompt { (_: Transcript.Prompt) in callbackCount += 1 } + .onResponse { (_: Transcript.Response) in callbackCount += 1 } + .onReasoning { (_: Transcript.Reasoning) in callbackCount += 1 } + .onToolCall { (_: Transcript.ToolCall) in callbackCount += 1 } + .onToolOutput { (_: Transcript.ToolCall, _: Transcript.ToolOutput) in + callbackCount += 1 + } + + let session = LanguageModelSession(model: LifecycleFixtureModel()) + let _: any Observable = session.properties + } + @Test(arguments: [false, true]) func callbackErrorsPropagateWithoutCommittingAResponse(streaming: Bool) async throws { let model = LifecycleFixtureModel() @@ -214,6 +234,57 @@ struct DynamicProfileTests { #expect(second.properties.profileRevision == 0) #expect(state.callbackEvents.withLock { $0 } == ["call:call", "output:call"]) } + + @Test func continuationHistoryMutationDoesNotCommitPendingEntries() async throws { + let state = ProfileFixtureState() + let model = ProfileFixtureModel(id: "model", state: state) + let session = LanguageModelSession(profile: HistoryWritingProfile(model: model)) + _ = try await session.respond(to: "Durable") + let durableBefore = session.transcript + let call = Transcript.ToolCall( + id: "pending-call", + toolName: "fixture", + arguments: GeneratedContent(properties: [:]) + ) + let output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: [.text(.init(content: "pending"))] + ) + let pending: [Transcript.Entry] = [ + .toolCalls(.init([call])), + .toolOutput(output), + ] + + let context = try await session.resolvedRequestContext( + including: pending, + options: .init() + ) + + #expect(context.transcript.contains { $0.id == output.id }) + #expect(session.transcript == durableBefore) + } + + @Test func profileErrorPolicyDoesNotLeakIntoTheNextProfile() async throws { + let state = LifecycleFixtureState() + let session = LanguageModelSession( + profile: ErrorPolicyProfile(state: state, model: FailingProfileModel()) + ) + session.transcriptErrorHandlingPolicy = .preserveTranscript + + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.respond(to: "Revert") + } + #expect(session.transcript.isEmpty) + + state.useSecond.withLock { $0 = true } + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.streamResponse(to: "Preserve").collect() + } + #expect(session.transcript.count == 1) + #expect(session.transcript.allSatisfy { if case .prompt = $0 { true } else { false } }) + #expect(session.transcriptErrorHandlingPolicy == .preserveTranscript) + } } private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { @@ -433,6 +504,57 @@ private struct ThrowingResponseProfile: LanguageModelSession.DynamicProfile { } } +private struct HistoryWritingProfile: LanguageModelSession.DynamicProfile { + let model: ProfileFixtureModel + @SessionProperty(\.history) private var history + + var body: some LanguageModelSession.DynamicProfile { + history = history + return LanguageModelSession.Profile { Instructions("History") } + .model(model) + } +} + +private struct ErrorPolicyProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let state: LifecycleFixtureState + let model: FailingProfileModel + + var body: some LanguageModelSession.DynamicProfile { + if state.useSecond.withLock({ $0 }) { + LanguageModelSession.Profile { Instructions("Caller policy") } + .model(model) + } else { + LanguageModelSession.Profile { Instructions("Profile policy") } + .model(model) + .transcriptErrorHandlingPolicy(.revertTranscript) + } + } +} + +private struct FailingProfileModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + throw LifecycleFixtureError.rejected + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + .init(stream: AsyncThrowingStream { $0.finish(throwing: LifecycleFixtureError.rejected) }) + } +} + private struct LifecycleFixtureModel: LanguageModel { typealias UnavailableReason = Never diff --git a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift index c4331da1..0a780ffa 100644 --- a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift @@ -9,32 +9,34 @@ import Testing enum Provider: CaseIterable, Equatable, Sendable { case chat, responses, openResponses, anthropic, gemini, ollama - func makeSession(tools: [any Tool] = []) -> LanguageModelSession { + func makeModel() -> any LanguageModel { let http = UsageURLProtocol.makeSession() - let model: any LanguageModel switch self { case .chat, .responses: - model = OpenAILanguageModel( + return OpenAILanguageModel( apiKey: "test", model: "test", apiVariant: self == .chat ? .chatCompletions : .responses, session: http ) case .openResponses: - model = OpenResponsesLanguageModel( + return OpenResponsesLanguageModel( baseURL: URL(string: "https://example.com/v1")!, apiKey: "test", model: "test", session: http ) case .anthropic: - model = AnthropicLanguageModel(apiKey: "test", model: "test", session: http) + return AnthropicLanguageModel(apiKey: "test", model: "test", session: http) case .gemini: - model = GeminiLanguageModel(apiKey: "test", model: "test", session: http) + return GeminiLanguageModel(apiKey: "test", model: "test", session: http) case .ollama: - model = OllamaLanguageModel(model: "test", session: http) + return OllamaLanguageModel(model: "test", session: http) } - return LanguageModelSession(model: model, tools: tools) + } + + func makeSession(tools: [any Tool] = []) -> LanguageModelSession { + LanguageModelSession(model: makeModel(), tools: tools) } var counts: [String: Any] { @@ -460,6 +462,85 @@ import Testing #expect(String(decoding: followUp, as: UTF8.self).contains("Checking. ")) } + @Test(arguments: Provider.allCases, [false, true]) + func providerProfileReevaluatesBeforeEveryToolContinuation( + _ provider: Provider, + _ streaming: Bool + ) async throws { + UsageURLProtocol.reset() + if streaming { + UsageURLProtocol.enqueue(json: try provider.toolStream()) + UsageURLProtocol.enqueue(json: try provider.stream(text: "Profile done")) + } else { + UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "", tool: true))) + UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "Profile done"))) + } + let model = provider.makeModel() + let state = ProviderProfileState() + let session = LanguageModelSession( + profile: ProviderProfile(state: state, model: model) + ) + + let response = + try await streaming + ? session.streamResponse(to: "Weather?").collect() + : session.respond(to: "Weather?") + + #expect(response.content == "Profile done") + #expect(state.executions.withLock { $0 } == ["Paris"]) + #expect(state.transformSawOutput.withLock { $0 } == [false, true]) + #expect( + state.events.withLock { $0 } == [ + "call:A", "execute:A", "output:A", + ] + ) + #expect(UsageURLProtocol.recordedBodies.count == 2) + let first = String(decoding: UsageURLProtocol.recordedBodies[0], as: UTF8.self) + let second = String(decoding: UsageURLProtocol.recordedBodies[1], as: UTF8.self) + #expect(first.contains("Profile A")) + #expect(first.contains("getWeather")) + #expect(first.contains("0.2")) + #expect(second.contains("Profile B")) + #expect(second.contains("otherTool")) + #expect(second.contains("0.8")) + } + + @Test(arguments: Provider.allCases, [false, true]) + func providerProfileCanSwitchModelForToolContinuation( + _ provider: Provider, + _ streaming: Bool + ) async throws { + UsageURLProtocol.reset() + if streaming { + UsageURLProtocol.enqueue(json: try provider.toolStream()) + } else { + UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "", tool: true))) + } + let state = ProviderProfileState() + let session = LanguageModelSession( + profile: ProviderProfile( + state: state, + model: provider.makeModel(), + continuationModel: ProviderProfileTerminalModel(state: state) + ) + ) + + let response = + try await streaming + ? session.streamResponse(to: "Weather?").collect() + : session.respond(to: "Weather?") + + #expect(response.content == "Switched") + #expect(UsageURLProtocol.recordedBodies.count == 1) + #expect(response.transcriptEntries.contains { if case .toolCalls = $0 { true } else { false } }) + #expect(response.transcriptEntries.contains { if case .toolOutput = $0 { true } else { false } }) + let selected = try #require(state.selectedRequests.withLock { $0.last }) + #expect(selected.instructions == "Profile B") + #expect(selected.temperature == 0.8) + #expect(selected.tools == ["otherTool"]) + #expect(selected.sawToolOutput) + } + @Test(arguments: Provider.allCases, [false, true]) func streamedToolsThenStructuredContent(_ provider: Provider, _ dynamicSchema: Bool) async throws { UsageURLProtocol.reset() @@ -905,6 +986,145 @@ import Testing #expect(response.usage == provider.expected) #expect(UsageURLProtocol.recordedBodies.count == 1) } + + private final class ProviderProfileState: @unchecked Sendable { + struct SelectedRequest: Sendable { + let instructions: String? + let temperature: Double? + let tools: [String] + let sawToolOutput: Bool + } + + let useSecond = Locked(false) + let executions = Locked<[String]>([]) + let transformSawOutput = Locked<[Bool]>([]) + let events = Locked<[String]>([]) + let selectedRequests = Locked<[SelectedRequest]>([]) + } + + private struct ProviderProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let state: ProviderProfileState + let model: any LanguageModel + var continuationModel: (any LanguageModel)? = nil + + var body: some LanguageModelSession.DynamicProfile { + if state.useSecond.withLock({ $0 }) { + LanguageModelSession.Profile { + Instructions("Profile B") + ProviderProfileOtherTool() + } + .model(continuationModel ?? model) + .temperature(0.8) + .historyTransform(transform) + .onToolCall { _ in state.events.withLock { $0.append("call:B") } } + .onToolOutput { _, _ in state.events.withLock { $0.append("output:B") } } + } else { + LanguageModelSession.Profile { + Instructions("Profile A") + ProviderProfileWeatherTool(state: state) + } + .model(model) + .temperature(0.2) + .historyTransform(transform) + .onToolCall { _ in state.events.withLock { $0.append("call:A") } } + .onToolOutput { _, _ in state.events.withLock { $0.append("output:A") } } + } + } + + private func transform(_ entries: [Transcript.Entry]) -> [Transcript.Entry] { + state.transformSawOutput.withLock { values in + values.append(entries.contains { if case .toolOutput = $0 { true } else { false } }) + } + return entries + } + } + + private struct ProviderProfileWeatherTool: Tool { + let name = "getWeather" + let description = "Get weather" + let state: ProviderProfileState + + func call(arguments: WeatherTool.Arguments) async throws -> String { + state.executions.withLock { $0.append(arguments.city) } + state.events.withLock { $0.append("execute:A") } + state.useSecond.withLock { $0 = true } + return "Sunny" + } + } + + struct ProviderProfileOtherTool: Tool { + @Generable struct Arguments {} + let name = "otherTool" + let description = "Only available in profile B" + func call(arguments: Arguments) async throws -> String { "unused" } + } + + private struct ProviderProfileTerminalModel: LanguageModel { + typealias UnavailableReason = Never + let state: ProviderProfileState + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let context = session.resolvedRequestContext() + state.selectedRequests.withLock { + $0.append( + .init( + instructions: context.instructions?.description, + temperature: context.options.temperature, + tools: context.tools.map(\.name), + sawToolOutput: context.transcript.contains { + if case .toolOutput = $0 { true } else { false } + } + ) + ) + } + let raw = GeneratedContent("Switched") + return .init( + content: try Content(raw), + rawContent: raw, + transcriptEntries: [] + ) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let stream = AsyncThrowingStream.Snapshot, any Error> { + continuation in + let task = Task { + do { + let response: LanguageModelSession.Response = try await respond( + within: session, + to: prompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + continuation.yield( + .init( + content: response.content.asPartiallyGenerated(), + rawContent: response.rawContent + ) + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return .init(stream: stream) + } + } } /// A `URLProtocol` that answers requests from a queue of canned responses /// and records every request body it sees, From dc79bbf45827015c13754a036f67645e6ecac0ff Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 03:09:26 +0800 Subject: [PATCH 09/53] Document attachments in tool outputs --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6826b869..02bf252b 100644 --- a/README.md +++ b/README.md @@ -384,28 +384,31 @@ pass the model's multimodal projector with `mmprojPath:`. ### Prompt attachments -Image attachments use the Foundation Models 27 prompt construction syntax: +Image attachments use the Foundation Models 27 prompt construction syntax, +including ordinary Tool outputs: ```swift -let prompt = Prompt { - "Describe this image" - Attachment(imageURL: fileURL) +func call(arguments: Arguments) async throws -> Prompt { + Prompt { + "Image read from the selected file" + Attachment(imageURL: fileURL) + } } ``` On Apple platforms, `Attachment(CGImage, orientation:)` retains typed image -content and orientation. Prompt builders preserve the order of text and images, -and the OS 26 compatibility transcript continues to store images using its -existing `Transcript.Segment.image` Codable representation. +content and orientation. Prompts, builders, and Tool outputs preserve the order +of text and images, and the OS 26 compatibility transcript continues to store +images using its existing `Transcript.Segment.image` Codable representation. `SystemLanguageModel` and `FoundationLanguageModel` bridge prompt attachments to Foundation Models on OS 27. Earlier system runtimes reject them explicitly. The system bridge requires local file URLs; other provider adapters retain URL images in their existing transcript representation. -This first compatibility surface covers prompt `CGImage` and `imageURL` -attachments. Tool outputs, instruction attachments, labels, `CIImage`, pixel -buffers, and newer data attachments remain outside this change. +This compatibility surface covers `CGImage` and `imageURL` attachments in +prompts and ordinary Tool outputs. Instruction attachments, labels, `CIImage`, +pixel buffers, and newer data attachments remain outside this change. ### Tool Calling @@ -638,6 +641,10 @@ say which API they follow. [image inputs](#image-inputs). Foundation Models 27-style [prompt attachments](#prompt-attachments) are also available for ordered text and image prompt construction. +- `Tool.makeOutputSegments(from:)`, exposed only through the `Compatibility` SPI: + an integration extension for back-deployment provider adapters to lower normal + Tool outputs into the compatibility transcript. It is not an Apple Tool API; + OS 27 canonical executors use Foundation Models' own Tool execution instead. - `ToolExecutionDelegate`, `ToolExecutionDecision`, and `toolExecutionDelegate`: [observing and controlling tool calls](#tool-calling). - `transcriptErrorHandlingPolicy` and `waitForResponseCompletion()`: From f96d6decb4be0eca99af2741e8f8d2b2ba9ee524 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:04:53 +0800 Subject: [PATCH 10/53] Generate fresh prompt segment identifiers --- .../Models/SystemLanguageModel.swift | 20 ++++++++++- Sources/AnyLanguageModel/Prompt.swift | 6 ++-- .../PromptAttachmentTests.swift | 35 +++++++++++++++---- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index bb451d50..fb176823 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -342,13 +342,31 @@ return transcript } guard let promptSegments = try? prompt.makeTranscriptSegments(), - lastPrompt.segments == promptSegments + lastPrompt.segments.hasSamePayload(as: promptSegments) else { return transcript } return Transcript(entries: transcript.dropLast()) } + private extension Array where Element == Transcript.Segment { + func hasSamePayload(as other: [Transcript.Segment]) -> Bool { + guard count == other.count else { return false } + return zip(self, other).allSatisfy { lhs, rhs in + switch (lhs, rhs) { + case (.text(let lhs), .text(let rhs)): + lhs.content == rhs.content + case (.structure(let lhs), .structure(let rhs)): + lhs.source == rhs.source && lhs.content == rhs.content + case (.image(let lhs), .image(let rhs)): + lhs.source == rhs.source + default: + false + } + } + } + } + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @available(watchOS, unavailable) extension SystemLanguageModel { diff --git a/Sources/AnyLanguageModel/Prompt.swift b/Sources/AnyLanguageModel/Prompt.swift index 5d215051..3a829982 100644 --- a/Sources/AnyLanguageModel/Prompt.swift +++ b/Sources/AnyLanguageModel/Prompt.swift @@ -57,9 +57,9 @@ public struct Prompt: Sendable { try components.map { component in switch component { case .text(let text): - return .text(text) - case .image(let id, let content): - return .image(try content.makeSegment(id: id)) + return .text(.init(content: text.content)) + case .image(_, let content): + return .image(try content.makeSegment(id: UUID().uuidString)) } } } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index ea85a885..8f91183c 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -13,8 +13,28 @@ import Testing @Suite("Prompt attachments") struct PromptAttachmentTests { + private enum SegmentPayload: Equatable { + case text(String) + case image(AnyLanguageModel.Transcript.ImageSegment.Source) + } + private let imageURL = URL(fileURLWithPath: "/tmp/attachment-fixture.png") + private func payloads( + _ segments: [AnyLanguageModel.Transcript.Segment] + ) -> [SegmentPayload] { + segments.compactMap { segment in + switch segment { + case .text(let text): + .text(text.content) + case .image(let image): + .image(image.source) + case .structure: + nil + } + } + } + private func prompt() -> AnyLanguageModel.Prompt { AnyLanguageModel.Prompt { "before" @@ -41,9 +61,12 @@ struct PromptAttachmentTests { } let expected = try original.makeTranscriptSegments() - #expect(try wrapped.makeTranscriptSegments() == expected) + let wrappedSegments = try wrapped.makeTranscriptSegments() + #expect(payloads(wrappedSegments) == payloads(expected)) + #expect(Set(wrappedSegments.map(\.id)).isDisjoint(with: expected.map(\.id))) let combined = try composed.makeTranscriptSegments() #expect(combined.count == 5) + #expect(Set(combined.map(\.id)).count == combined.count) let sources: [AnyLanguageModel.Transcript.ImageSegment.Source] = combined.compactMap { if case .image(let value) = $0 { return value.source @@ -59,10 +82,10 @@ struct PromptAttachmentTests { #expect(sources == [.url(imageURL), .url(imageURL)]) #expect(texts == ["before", "after\nbefore", "after"]) #expect(original.description == "before\n\nafter") - #expect( - try AnyLanguageModel.Prompt([original, original]).makeTranscriptSegments() - == combined - ) + let repeated = try AnyLanguageModel.Prompt([original, original]).makeTranscriptSegments() + #expect(payloads(repeated) == payloads(combined)) + #expect(Set(repeated.map(\.id)).count == repeated.count) + #expect(Set(repeated.map(\.id)).isDisjoint(with: combined.map(\.id))) } @Test func directSessionPromptsRetainImagesInBothModes() async throws { @@ -80,7 +103,7 @@ struct PromptAttachmentTests { Issue.record("Missing prompt") return } - #expect(recorded.segments == (try input.makeTranscriptSegments())) + #expect(payloads(recorded.segments) == payloads(try input.makeTranscriptSegments())) } } From c7aaa57799f619a0a623abf53bd54d00f8306931 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:10:14 +0800 Subject: [PATCH 11/53] Preserve Gemini tool output segments --- README.md | 7 +- .../Models/GeminiLanguageModel.swift | 52 ++++++++++---- .../Models/SystemLanguageModel.swift | 11 ++- .../GeminiThoughtSignatureTests.swift | 71 +++++++++++++++++++ .../PromptAttachmentTests.swift | 11 +++ 5 files changed, 134 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6826b869..6c4eb428 100644 --- a/README.md +++ b/README.md @@ -403,9 +403,10 @@ to Foundation Models on OS 27. Earlier system runtimes reject them explicitly. The system bridge requires local file URLs; other provider adapters retain URL images in their existing transcript representation. -This first compatibility surface covers prompt `CGImage` and `imageURL` -attachments. Tool outputs, instruction attachments, labels, `CIImage`, pixel -buffers, and newer data attachments remain outside this change. +This compatibility surface covers prompt `CGImage` and `imageURL` attachments, +including attachments returned from ordinary `Tool` output values. Instruction +attachments, labels, `CIImage`, pixel buffers, and newer data attachments remain +outside this change. ### Tool Calling diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index 5fc12db9..bdc5da7f 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -769,26 +769,47 @@ private func emptyResponseContent( } } -private func toJSONValue(_ toolOutput: Transcript.ToolOutput) throws -> [String: JSONValue] { - var result: [String: JSONValue] = [:] +private func toGeminiFunctionResponse( + _ toolOutput: Transcript.ToolOutput +) throws -> GeminiFunctionResponse { + var resultValues: [String] = [] + var parts: [GeminiPart] = [] for segment in toolOutput.segments { switch segment { case .text(let text): - result["result"] = .string(text.content) + resultValues.append(text.content) case .structure(let structured): - // For structured segments, encode the content let data = try JSONEncoder().encode(structured.content) if let jsonString = String(data: data, encoding: .utf8) { - result["result"] = .string(jsonString) + resultValues.append(jsonString) + } + case .image(let image): + switch image.source { + case .data(let data, let mimeType): + parts.append( + .inlineData( + GeminiInlineData( + mimeType: mimeType, + data: data.base64EncodedString() + ) + ) + ) + case .url(let url): + parts.append(.fileData(GeminiFileData(fileURI: url.absoluteString))) } - case .image: - // Ignore images in tool outputs for Gemini conversion - break } } - return result + var response: [String: JSONValue] = [:] + if !resultValues.isEmpty { + response["result"] = .string(resultValues.joined(separator: "\n")) + } + return GeminiFunctionResponse( + name: toolOutput.toolName, + response: response, + parts: parts.isEmpty ? nil : parts + ) } // MARK: - Supporting Types @@ -844,11 +865,13 @@ extension Transcript { ) case .toolOutput(let toolOutput): // Add function response as a user message (Gemini API expects function responses from user role) - let response = try? toJSONValue(toolOutput) - let functionResponse = GeminiFunctionResponse( - name: toolOutput.toolName, - response: response ?? [:] - ) + let functionResponse = + (try? toGeminiFunctionResponse(toolOutput)) + ?? GeminiFunctionResponse( + name: toolOutput.toolName, + response: [:], + parts: nil + ) messages.append( .init( role: .user, @@ -1116,6 +1139,7 @@ private struct GeminiFunctionCall: Codable, Sendable { private struct GeminiFunctionResponse: Codable, Sendable { let name: String let response: [String: JSONValue] + let parts: [GeminiPart]? } private struct GeminiGenerateContentResponse: Codable, Sendable { diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index fb176823..7354e824 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -572,9 +572,18 @@ } } - enum PromptBridgeError: Error { + enum PromptBridgeError: LocalizedError, Sendable, Equatable { case attachmentsUnavailable case nonFileImageURL + + var errorDescription: String? { + switch self { + case .attachmentsUnavailable: + "Prompt attachments require an OS 27 Foundation Models runtime." + case .nonFileImageURL: + "Foundation Models prompt attachments require a local file URL." + } + } } /// A type-erased wrapper that bridges any `Tool` to `FoundationModels.Tool`. diff --git a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift index 5265718f..d1a93925 100644 --- a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift +++ b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift @@ -4,11 +4,27 @@ import Testing @testable import AnyLanguageModel #if canImport(Darwin) && !canImport(AsyncHTTPClient) + import CoreGraphics @Suite("GeminiLanguageModel thought signatures", .serialized) struct GeminiThoughtSignatureTests { private static let signature = "CvsBAdHtim8n5xQK1pVX2H0lPQeXAMPLEsignature==" + @Generable + struct ImageArguments { + var request: String + } + + private struct ImageOutputTool: Tool { + let name = "image" + let description = "Return an image" + let output: Prompt + + func call(arguments: ImageArguments) async throws -> Prompt { + output + } + } + private func makeModel() -> GeminiLanguageModel { GeminiLanguageModel( apiKey: "test-key", @@ -51,6 +67,21 @@ import Testing """ } + private func image() throws -> CGImage { + let context = try #require( + CGContext( + data: nil, + width: 2, + height: 2, + bitsPerComponent: 8, + bytesPerRow: 8, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + ) + return try #require(context.makeImage()) + } + /// Signatures of every `functionCall` part in a request body, in order. /// A part without a signature contributes `nil`. private func functionCallSignatures(in body: Data) throws -> [String?] { @@ -108,6 +139,46 @@ import Testing #expect(signatures.allSatisfy { $0 == Self.signature }) } + @Test("preserves all text and image data in function responses") + func preservesSegmentedToolOutput() async throws { + StubURLProtocol.reset() + StubURLProtocol.enqueue( + json: + #"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"image","args":{"request":"read"}}}]},"finishReason":"STOP"}]}"# + ) + StubURLProtocol.enqueue(json: textResponse("OK")) + + let output = try Prompt { + "before" + Attachment(try image()) + "after" + } + let session = LanguageModelSession( + model: makeModel(), + tools: [ImageOutputTool(output: output)] + ) + _ = try await session.respond(to: "read image") + + let bodies = StubURLProtocol.recordedBodies + try #require(bodies.count == 2) + let json = try #require( + try JSONSerialization.jsonObject(with: bodies[1]) as? [String: Any] + ) + let contents = try #require(json["contents"] as? [[String: Any]]) + let outputContent = try #require(contents.last) + let outputParts = try #require(outputContent["parts"] as? [[String: Any]]) + let functionResponse = try #require( + outputParts.first?["functionResponse"] as? [String: Any] + ) + let response = try #require(functionResponse["response"] as? [String: Any]) + #expect(response["result"] as? String == "before\nafter") + + let mediaParts = try #require(functionResponse["parts"] as? [[String: Any]]) + let inlineData = try #require(mediaParts.first?["inlineData"] as? [String: Any]) + #expect(inlineData["mime_type"] as? String == "image/png") + #expect(Data(base64Encoded: try #require(inlineData["data"] as? String)) != nil) + } + @Test("does not replay the conversation history on later turns") func doesNotReplayHistoryOnLaterTurns() async throws { StubURLProtocol.reset() diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 8f91183c..81a3b3f7 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -177,6 +177,17 @@ struct PromptAttachmentTests { #endif #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + @Test func promptBridgeErrorsHaveLocalizedDescriptions() { + #expect( + AnyLanguageModel.PromptBridgeError.attachmentsUnavailable.errorDescription + == "Prompt attachments require an OS 27 Foundation Models runtime." + ) + #expect( + AnyLanguageModel.PromptBridgeError.nonFileImageURL.errorDescription + == "Foundation Models prompt attachments require a local file URL." + ) + } + @available(macOS 27, iOS 27, visionOS 27, *) @Test func nativeBridgeRetainsAttachment() async throws { let input = try AnyLanguageModel.Prompt { From d70bf125ffb4d95e5d2329af677721a3efd7d60b Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:04:53 +0800 Subject: [PATCH 12/53] Generate fresh prompt segment identifiers --- .../Models/SystemLanguageModel.swift | 20 ++++++++++- Sources/AnyLanguageModel/Prompt.swift | 6 ++-- .../PromptAttachmentTests.swift | 35 +++++++++++++++---- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index bb451d50..fb176823 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -342,13 +342,31 @@ return transcript } guard let promptSegments = try? prompt.makeTranscriptSegments(), - lastPrompt.segments == promptSegments + lastPrompt.segments.hasSamePayload(as: promptSegments) else { return transcript } return Transcript(entries: transcript.dropLast()) } + private extension Array where Element == Transcript.Segment { + func hasSamePayload(as other: [Transcript.Segment]) -> Bool { + guard count == other.count else { return false } + return zip(self, other).allSatisfy { lhs, rhs in + switch (lhs, rhs) { + case (.text(let lhs), .text(let rhs)): + lhs.content == rhs.content + case (.structure(let lhs), .structure(let rhs)): + lhs.source == rhs.source && lhs.content == rhs.content + case (.image(let lhs), .image(let rhs)): + lhs.source == rhs.source + default: + false + } + } + } + } + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @available(watchOS, unavailable) extension SystemLanguageModel { diff --git a/Sources/AnyLanguageModel/Prompt.swift b/Sources/AnyLanguageModel/Prompt.swift index 5d215051..3a829982 100644 --- a/Sources/AnyLanguageModel/Prompt.swift +++ b/Sources/AnyLanguageModel/Prompt.swift @@ -57,9 +57,9 @@ public struct Prompt: Sendable { try components.map { component in switch component { case .text(let text): - return .text(text) - case .image(let id, let content): - return .image(try content.makeSegment(id: id)) + return .text(.init(content: text.content)) + case .image(_, let content): + return .image(try content.makeSegment(id: UUID().uuidString)) } } } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index ea85a885..8f91183c 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -13,8 +13,28 @@ import Testing @Suite("Prompt attachments") struct PromptAttachmentTests { + private enum SegmentPayload: Equatable { + case text(String) + case image(AnyLanguageModel.Transcript.ImageSegment.Source) + } + private let imageURL = URL(fileURLWithPath: "/tmp/attachment-fixture.png") + private func payloads( + _ segments: [AnyLanguageModel.Transcript.Segment] + ) -> [SegmentPayload] { + segments.compactMap { segment in + switch segment { + case .text(let text): + .text(text.content) + case .image(let image): + .image(image.source) + case .structure: + nil + } + } + } + private func prompt() -> AnyLanguageModel.Prompt { AnyLanguageModel.Prompt { "before" @@ -41,9 +61,12 @@ struct PromptAttachmentTests { } let expected = try original.makeTranscriptSegments() - #expect(try wrapped.makeTranscriptSegments() == expected) + let wrappedSegments = try wrapped.makeTranscriptSegments() + #expect(payloads(wrappedSegments) == payloads(expected)) + #expect(Set(wrappedSegments.map(\.id)).isDisjoint(with: expected.map(\.id))) let combined = try composed.makeTranscriptSegments() #expect(combined.count == 5) + #expect(Set(combined.map(\.id)).count == combined.count) let sources: [AnyLanguageModel.Transcript.ImageSegment.Source] = combined.compactMap { if case .image(let value) = $0 { return value.source @@ -59,10 +82,10 @@ struct PromptAttachmentTests { #expect(sources == [.url(imageURL), .url(imageURL)]) #expect(texts == ["before", "after\nbefore", "after"]) #expect(original.description == "before\n\nafter") - #expect( - try AnyLanguageModel.Prompt([original, original]).makeTranscriptSegments() - == combined - ) + let repeated = try AnyLanguageModel.Prompt([original, original]).makeTranscriptSegments() + #expect(payloads(repeated) == payloads(combined)) + #expect(Set(repeated.map(\.id)).count == repeated.count) + #expect(Set(repeated.map(\.id)).isDisjoint(with: combined.map(\.id))) } @Test func directSessionPromptsRetainImagesInBothModes() async throws { @@ -80,7 +103,7 @@ struct PromptAttachmentTests { Issue.record("Missing prompt") return } - #expect(recorded.segments == (try input.makeTranscriptSegments())) + #expect(payloads(recorded.segments) == payloads(try input.makeTranscriptSegments())) } } From ddfa8f1f09071acf0a15b847c8f8537c1dd4471d Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:10:14 +0800 Subject: [PATCH 13/53] Preserve Gemini tool output segments --- .../Models/GeminiLanguageModel.swift | 52 ++++++++++---- .../Models/SystemLanguageModel.swift | 11 ++- .../GeminiThoughtSignatureTests.swift | 71 +++++++++++++++++++ .../PromptAttachmentTests.swift | 11 +++ 4 files changed, 130 insertions(+), 15 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index 5fc12db9..bdc5da7f 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -769,26 +769,47 @@ private func emptyResponseContent( } } -private func toJSONValue(_ toolOutput: Transcript.ToolOutput) throws -> [String: JSONValue] { - var result: [String: JSONValue] = [:] +private func toGeminiFunctionResponse( + _ toolOutput: Transcript.ToolOutput +) throws -> GeminiFunctionResponse { + var resultValues: [String] = [] + var parts: [GeminiPart] = [] for segment in toolOutput.segments { switch segment { case .text(let text): - result["result"] = .string(text.content) + resultValues.append(text.content) case .structure(let structured): - // For structured segments, encode the content let data = try JSONEncoder().encode(structured.content) if let jsonString = String(data: data, encoding: .utf8) { - result["result"] = .string(jsonString) + resultValues.append(jsonString) + } + case .image(let image): + switch image.source { + case .data(let data, let mimeType): + parts.append( + .inlineData( + GeminiInlineData( + mimeType: mimeType, + data: data.base64EncodedString() + ) + ) + ) + case .url(let url): + parts.append(.fileData(GeminiFileData(fileURI: url.absoluteString))) } - case .image: - // Ignore images in tool outputs for Gemini conversion - break } } - return result + var response: [String: JSONValue] = [:] + if !resultValues.isEmpty { + response["result"] = .string(resultValues.joined(separator: "\n")) + } + return GeminiFunctionResponse( + name: toolOutput.toolName, + response: response, + parts: parts.isEmpty ? nil : parts + ) } // MARK: - Supporting Types @@ -844,11 +865,13 @@ extension Transcript { ) case .toolOutput(let toolOutput): // Add function response as a user message (Gemini API expects function responses from user role) - let response = try? toJSONValue(toolOutput) - let functionResponse = GeminiFunctionResponse( - name: toolOutput.toolName, - response: response ?? [:] - ) + let functionResponse = + (try? toGeminiFunctionResponse(toolOutput)) + ?? GeminiFunctionResponse( + name: toolOutput.toolName, + response: [:], + parts: nil + ) messages.append( .init( role: .user, @@ -1116,6 +1139,7 @@ private struct GeminiFunctionCall: Codable, Sendable { private struct GeminiFunctionResponse: Codable, Sendable { let name: String let response: [String: JSONValue] + let parts: [GeminiPart]? } private struct GeminiGenerateContentResponse: Codable, Sendable { diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index fb176823..7354e824 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -572,9 +572,18 @@ } } - enum PromptBridgeError: Error { + enum PromptBridgeError: LocalizedError, Sendable, Equatable { case attachmentsUnavailable case nonFileImageURL + + var errorDescription: String? { + switch self { + case .attachmentsUnavailable: + "Prompt attachments require an OS 27 Foundation Models runtime." + case .nonFileImageURL: + "Foundation Models prompt attachments require a local file URL." + } + } } /// A type-erased wrapper that bridges any `Tool` to `FoundationModels.Tool`. diff --git a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift index 5265718f..d1a93925 100644 --- a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift +++ b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift @@ -4,11 +4,27 @@ import Testing @testable import AnyLanguageModel #if canImport(Darwin) && !canImport(AsyncHTTPClient) + import CoreGraphics @Suite("GeminiLanguageModel thought signatures", .serialized) struct GeminiThoughtSignatureTests { private static let signature = "CvsBAdHtim8n5xQK1pVX2H0lPQeXAMPLEsignature==" + @Generable + struct ImageArguments { + var request: String + } + + private struct ImageOutputTool: Tool { + let name = "image" + let description = "Return an image" + let output: Prompt + + func call(arguments: ImageArguments) async throws -> Prompt { + output + } + } + private func makeModel() -> GeminiLanguageModel { GeminiLanguageModel( apiKey: "test-key", @@ -51,6 +67,21 @@ import Testing """ } + private func image() throws -> CGImage { + let context = try #require( + CGContext( + data: nil, + width: 2, + height: 2, + bitsPerComponent: 8, + bytesPerRow: 8, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + ) + return try #require(context.makeImage()) + } + /// Signatures of every `functionCall` part in a request body, in order. /// A part without a signature contributes `nil`. private func functionCallSignatures(in body: Data) throws -> [String?] { @@ -108,6 +139,46 @@ import Testing #expect(signatures.allSatisfy { $0 == Self.signature }) } + @Test("preserves all text and image data in function responses") + func preservesSegmentedToolOutput() async throws { + StubURLProtocol.reset() + StubURLProtocol.enqueue( + json: + #"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"image","args":{"request":"read"}}}]},"finishReason":"STOP"}]}"# + ) + StubURLProtocol.enqueue(json: textResponse("OK")) + + let output = try Prompt { + "before" + Attachment(try image()) + "after" + } + let session = LanguageModelSession( + model: makeModel(), + tools: [ImageOutputTool(output: output)] + ) + _ = try await session.respond(to: "read image") + + let bodies = StubURLProtocol.recordedBodies + try #require(bodies.count == 2) + let json = try #require( + try JSONSerialization.jsonObject(with: bodies[1]) as? [String: Any] + ) + let contents = try #require(json["contents"] as? [[String: Any]]) + let outputContent = try #require(contents.last) + let outputParts = try #require(outputContent["parts"] as? [[String: Any]]) + let functionResponse = try #require( + outputParts.first?["functionResponse"] as? [String: Any] + ) + let response = try #require(functionResponse["response"] as? [String: Any]) + #expect(response["result"] as? String == "before\nafter") + + let mediaParts = try #require(functionResponse["parts"] as? [[String: Any]]) + let inlineData = try #require(mediaParts.first?["inlineData"] as? [String: Any]) + #expect(inlineData["mime_type"] as? String == "image/png") + #expect(Data(base64Encoded: try #require(inlineData["data"] as? String)) != nil) + } + @Test("does not replay the conversation history on later turns") func doesNotReplayHistoryOnLaterTurns() async throws { StubURLProtocol.reset() diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 8f91183c..81a3b3f7 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -177,6 +177,17 @@ struct PromptAttachmentTests { #endif #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + @Test func promptBridgeErrorsHaveLocalizedDescriptions() { + #expect( + AnyLanguageModel.PromptBridgeError.attachmentsUnavailable.errorDescription + == "Prompt attachments require an OS 27 Foundation Models runtime." + ) + #expect( + AnyLanguageModel.PromptBridgeError.nonFileImageURL.errorDescription + == "Foundation Models prompt attachments require a local file URL." + ) + } + @available(macOS 27, iOS 27, visionOS 27, *) @Test func nativeBridgeRetainsAttachment() async throws { let input = try AnyLanguageModel.Prompt { From da630296a0a5bfb37d21e0e71c14f84e0150a3ea Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:14:45 +0800 Subject: [PATCH 14/53] Keep session property requests observable --- .../LanguageModelSession.swift | 33 ++++++-- .../AnyLanguageModel/SessionProperties.swift | 20 +++++ .../SessionPropertyEntryMacro.swift | 15 +++- .../SessionPropertyTests.swift | 78 +++++++++++++++++++ 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 10627f0d..59a1c075 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -183,7 +183,7 @@ public final class LanguageModelSession: @unchecked Sendable { } } } - var requestTranscript = transcript + var requestTranscript = Transcript(entries: canonicalHistory) if let instructions = resolved.instructions { let instructionsEntry = Transcript.Entry.instructions( Transcript.Instructions( @@ -332,6 +332,16 @@ public final class LanguageModelSession: @unchecked Sendable { let state = Locked(State(finalTranscript)) self.state = state + let setHistory: @Sendable (Transcript.HistoryView) -> Void = { history in + state.withLock { locked in + let instructions = locked.transcript.prefix { + if case .instructions = $0 { return true } + return false + } + locked.transcript = Transcript(entries: Array(instructions) + history) + } + } + let observedHistorySetter = Locked<(@Sendable (Transcript.HistoryView) -> Void)?>(nil) self.properties = SessionPropertyValues( historyGetter: { state.withLock { locked in @@ -342,15 +352,24 @@ public final class LanguageModelSession: @unchecked Sendable { } }, historySetter: { history in - state.withLock { locked in - let instructions = locked.transcript.prefix { - if case .instructions = $0 { return true } - return false - } - locked.transcript = Transcript(entries: Array(instructions) + history) + if let setter = observedHistorySetter.withLock({ $0 }) { + setter(history) + } else { + setHistory(history) } } ) + observedHistorySetter.withLock { setter in + setter = { [weak self] history in + guard let self else { + setHistory(history) + return + } + self.withMutation(keyPath: \.transcript) { + setHistory(history) + } + } + } } private static func instructions(from transcript: Transcript) -> Instructions? { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 9b503bff..0adfadb8 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -30,6 +30,7 @@ public macro SessionPropertyEntry() = /// Foundation Models 27. @Observable public final class SessionPropertyValues: @unchecked Sendable { + @ObservationIgnored private let sessionPropertyRegistrar = ObservationRegistrar() @ObservationIgnored private let customValues = Locked<[ObjectIdentifier: Any]>([:]) @ObservationIgnored private let historyGetter: @Sendable () -> Transcript.HistoryView @ObservationIgnored private let historySetter: @Sendable (Transcript.HistoryView) -> Void @@ -69,6 +70,25 @@ public final class SessionPropertyValues: @unchecked Sendable { } } } + + @_documentation(visibility: internal) + public func _accessSessionProperty( + keyPath: KeyPath + ) { + sessionPropertyRegistrar.access(self, keyPath: keyPath) + } + + @_documentation(visibility: internal) + public func _withSessionPropertyMutation( + keyPath: KeyPath, + _ mutation: () throws -> Result + ) rethrows -> Result { + try sessionPropertyRegistrar.withMutation( + of: self, + keyPath: keyPath, + mutation + ) + } } enum SessionPropertyBinding { diff --git a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift index 1aae689b..823aae64 100644 --- a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -12,8 +12,19 @@ public struct SessionPropertyEntryMacro: AccessorMacro, PeerMacro { ) throws -> [AccessorDeclSyntax] { guard let name = propertyName(in: declaration) else { return [] } return [ - "get { self[__Key_\(raw: name).self] }", - "set { self[__Key_\(raw: name).self] = newValue }", + """ + get { + self._accessSessionProperty(keyPath: \\.\(raw: name)) + return self[__Key_\(raw: name).self] + } + """, + """ + set { + self._withSessionPropertyMutation(keyPath: \\.\(raw: name)) { + self[__Key_\(raw: name).self] = newValue + } + } + """, ] } diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index d35425b8..e8421891 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -42,6 +42,71 @@ struct SessionPropertyTests { #expect(session.properties.history == [prompt]) } + @Test func requestUsesOneHistorySnapshotDuringDynamicResolution() async { + let initial = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Initial"))]) + ) + let replacement = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + let (started, startedContinuation) = AsyncStream.makeStream() + let resume = DispatchSemaphore(value: 0) + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: BlockingHistoryInstructions( + started: startedContinuation, + resume: resume + ), + history: [initial] + ) + + let request = Task.detached { session.resolvedRequestContext() } + for await _ in started.prefix(1) {} + session.properties.history = [replacement] + resume.signal() + let context = await request.value + + #expect(context.instructions?.description == "History count 1") + #expect( + context.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } == [initial] + ) + #expect(session.properties.history == [replacement]) + } + + @Test func replacingHistoryNotifiesTranscriptObservers() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let changed = Locked(false) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + withObservationTracking { + _ = session.transcript + } onChange: { + changed.withLock { $0 = true } + } + session.properties.history = [prompt] + + #expect(changed.withLock { $0 }) + } + + @Test func generatedPropertiesParticipateInObservation() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let changed = Locked(false) + + withObservationTracking { + _ = session.properties.revision + } onChange: { + changed.withLock { $0 = true } + } + session.properties.revision = 1 + + #expect(changed.withLock { $0 }) + } + @Test func propertiesAndHistoryAreIsolatedBetweenSessions() { let first = LanguageModelSession(model: SessionPropertyFixtureModel()) let second = LanguageModelSession(model: SessionPropertyFixtureModel()) @@ -98,6 +163,19 @@ private struct SessionPropertyHistoryInstructions: DynamicInstructions { } } +private struct BlockingHistoryInstructions: DynamicInstructions { + @SessionProperty(\.history) private var history + let started: AsyncStream.Continuation + let resume: DispatchSemaphore + + var body: some DynamicInstructions { + let count = history.count + started.yield() + resume.wait() + return Instructions("History count \(count)") + } +} + private struct SessionPropertyFixtureModel: LanguageModel { typealias UnavailableReason = Never From 8b3a234ff6c4248c6540940e420e67519fe5a4a8 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:14:45 +0800 Subject: [PATCH 15/53] Keep session property requests observable --- .../LanguageModelSession.swift | 31 ++++++-- .../AnyLanguageModel/SessionProperties.swift | 20 +++++ .../SessionPropertyEntryMacro.swift | 15 +++- .../SessionPropertyTests.swift | 78 +++++++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index e6163b30..6b9a0e81 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -621,6 +621,16 @@ public final class LanguageModelSession: @unchecked Sendable { let state = Locked(State(finalTranscript)) self.state = state + let setHistory: @Sendable (Transcript.HistoryView) -> Void = { history in + state.withLock { locked in + let instructions = locked.transcript.prefix { + if case .instructions = $0 { return true } + return false + } + locked.transcript = Transcript(entries: Array(instructions) + history) + } + } + let observedHistorySetter = Locked<(@Sendable (Transcript.HistoryView) -> Void)?>(nil) self.properties = SessionPropertyValues( historyGetter: { state.withLock { locked in @@ -631,15 +641,24 @@ public final class LanguageModelSession: @unchecked Sendable { } }, historySetter: { history in - state.withLock { locked in - let instructions = locked.transcript.prefix { - if case .instructions = $0 { return true } - return false - } - locked.transcript = Transcript(entries: Array(instructions) + history) + if let setter = observedHistorySetter.withLock({ $0 }) { + setter(history) + } else { + setHistory(history) } } ) + observedHistorySetter.withLock { setter in + setter = { [weak self] history in + guard let self else { + setHistory(history) + return + } + self.withMutation(keyPath: \.transcript) { + setHistory(history) + } + } + } } private static func instructions(from transcript: Transcript) -> Instructions? { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 27b22188..5c8ab58e 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -30,6 +30,7 @@ public macro SessionPropertyEntry() = /// Foundation Models 27. @Observable public final class SessionPropertyValues: @unchecked Sendable { + @ObservationIgnored private let sessionPropertyRegistrar = ObservationRegistrar() @ObservationIgnored private let customValues = Locked<[ObjectIdentifier: Any]>([:]) @ObservationIgnored private let historyGetter: @Sendable () -> Transcript.HistoryView @ObservationIgnored private let historySetter: @Sendable (Transcript.HistoryView) -> Void @@ -72,6 +73,25 @@ public final class SessionPropertyValues: @unchecked Sendable { } } } + + @_documentation(visibility: internal) + public func _accessSessionProperty( + keyPath: KeyPath + ) { + sessionPropertyRegistrar.access(self, keyPath: keyPath) + } + + @_documentation(visibility: internal) + public func _withSessionPropertyMutation( + keyPath: KeyPath, + _ mutation: () throws -> Result + ) rethrows -> Result { + try sessionPropertyRegistrar.withMutation( + of: self, + keyPath: keyPath, + mutation + ) + } } enum SessionPropertyBinding { diff --git a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift index 1aae689b..823aae64 100644 --- a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -12,8 +12,19 @@ public struct SessionPropertyEntryMacro: AccessorMacro, PeerMacro { ) throws -> [AccessorDeclSyntax] { guard let name = propertyName(in: declaration) else { return [] } return [ - "get { self[__Key_\(raw: name).self] }", - "set { self[__Key_\(raw: name).self] = newValue }", + """ + get { + self._accessSessionProperty(keyPath: \\.\(raw: name)) + return self[__Key_\(raw: name).self] + } + """, + """ + set { + self._withSessionPropertyMutation(keyPath: \\.\(raw: name)) { + self[__Key_\(raw: name).self] = newValue + } + } + """, ] } diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index d35425b8..e8421891 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -42,6 +42,71 @@ struct SessionPropertyTests { #expect(session.properties.history == [prompt]) } + @Test func requestUsesOneHistorySnapshotDuringDynamicResolution() async { + let initial = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Initial"))]) + ) + let replacement = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + let (started, startedContinuation) = AsyncStream.makeStream() + let resume = DispatchSemaphore(value: 0) + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: BlockingHistoryInstructions( + started: startedContinuation, + resume: resume + ), + history: [initial] + ) + + let request = Task.detached { session.resolvedRequestContext() } + for await _ in started.prefix(1) {} + session.properties.history = [replacement] + resume.signal() + let context = await request.value + + #expect(context.instructions?.description == "History count 1") + #expect( + context.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } == [initial] + ) + #expect(session.properties.history == [replacement]) + } + + @Test func replacingHistoryNotifiesTranscriptObservers() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let changed = Locked(false) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + withObservationTracking { + _ = session.transcript + } onChange: { + changed.withLock { $0 = true } + } + session.properties.history = [prompt] + + #expect(changed.withLock { $0 }) + } + + @Test func generatedPropertiesParticipateInObservation() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let changed = Locked(false) + + withObservationTracking { + _ = session.properties.revision + } onChange: { + changed.withLock { $0 = true } + } + session.properties.revision = 1 + + #expect(changed.withLock { $0 }) + } + @Test func propertiesAndHistoryAreIsolatedBetweenSessions() { let first = LanguageModelSession(model: SessionPropertyFixtureModel()) let second = LanguageModelSession(model: SessionPropertyFixtureModel()) @@ -98,6 +163,19 @@ private struct SessionPropertyHistoryInstructions: DynamicInstructions { } } +private struct BlockingHistoryInstructions: DynamicInstructions { + @SessionProperty(\.history) private var history + let started: AsyncStream.Continuation + let resume: DispatchSemaphore + + var body: some DynamicInstructions { + let count = history.count + started.yield() + resume.wait() + return Instructions("History count \(count)") + } +} + private struct SessionPropertyFixtureModel: LanguageModel { typealias UnavailableReason = Never From 9525809370ab1a2c08f3b37afd0dd41923cf5a9a Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:14:45 +0800 Subject: [PATCH 16/53] Keep session property requests observable --- .../LanguageModelSession.swift | 31 ++++++-- .../AnyLanguageModel/SessionProperties.swift | 20 +++++ .../SessionPropertyEntryMacro.swift | 15 +++- .../SessionPropertyTests.swift | 78 +++++++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 0e8a3290..b3c67845 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -665,6 +665,16 @@ public final class LanguageModelSession: @unchecked Sendable { let state = Locked(State(finalTranscript)) self.state = state + let setHistory: @Sendable (Transcript.HistoryView) -> Void = { history in + state.withLock { locked in + let instructions = locked.transcript.prefix { + if case .instructions = $0 { return true } + return false + } + locked.transcript = Transcript(entries: Array(instructions) + history) + } + } + let observedHistorySetter = Locked<(@Sendable (Transcript.HistoryView) -> Void)?>(nil) self.properties = SessionPropertyValues( historyGetter: { state.withLock { locked in @@ -675,15 +685,24 @@ public final class LanguageModelSession: @unchecked Sendable { } }, historySetter: { history in - state.withLock { locked in - let instructions = locked.transcript.prefix { - if case .instructions = $0 { return true } - return false - } - locked.transcript = Transcript(entries: Array(instructions) + history) + if let setter = observedHistorySetter.withLock({ $0 }) { + setter(history) + } else { + setHistory(history) } } ) + observedHistorySetter.withLock { setter in + setter = { [weak self] history in + guard let self else { + setHistory(history) + return + } + self.withMutation(keyPath: \.transcript) { + setHistory(history) + } + } + } } private static func instructions(from transcript: Transcript) -> Instructions? { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 9f1fba8b..de3eb220 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -30,6 +30,7 @@ public macro SessionPropertyEntry() = /// Foundation Models 27. @Observable public final class SessionPropertyValues: @unchecked Sendable { + @ObservationIgnored private let sessionPropertyRegistrar = ObservationRegistrar() @ObservationIgnored private let customValues = Locked<[ObjectIdentifier: Any]>([:]) @ObservationIgnored private let historyGetter: @Sendable () -> Transcript.HistoryView @ObservationIgnored private let historySetter: @Sendable (Transcript.HistoryView) -> Void @@ -96,6 +97,25 @@ public final class SessionPropertyValues: @unchecked Sendable { persist: persist ) } + + @_documentation(visibility: internal) + public func _accessSessionProperty( + keyPath: KeyPath + ) { + sessionPropertyRegistrar.access(self, keyPath: keyPath) + } + + @_documentation(visibility: internal) + public func _withSessionPropertyMutation( + keyPath: KeyPath, + _ mutation: () throws -> Result + ) rethrows -> Result { + try sessionPropertyRegistrar.withMutation( + of: self, + keyPath: keyPath, + mutation + ) + } } enum SessionPropertyBinding { diff --git a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift index 1aae689b..823aae64 100644 --- a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -12,8 +12,19 @@ public struct SessionPropertyEntryMacro: AccessorMacro, PeerMacro { ) throws -> [AccessorDeclSyntax] { guard let name = propertyName(in: declaration) else { return [] } return [ - "get { self[__Key_\(raw: name).self] }", - "set { self[__Key_\(raw: name).self] = newValue }", + """ + get { + self._accessSessionProperty(keyPath: \\.\(raw: name)) + return self[__Key_\(raw: name).self] + } + """, + """ + set { + self._withSessionPropertyMutation(keyPath: \\.\(raw: name)) { + self[__Key_\(raw: name).self] = newValue + } + } + """, ] } diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index d35425b8..e8421891 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -42,6 +42,71 @@ struct SessionPropertyTests { #expect(session.properties.history == [prompt]) } + @Test func requestUsesOneHistorySnapshotDuringDynamicResolution() async { + let initial = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Initial"))]) + ) + let replacement = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + let (started, startedContinuation) = AsyncStream.makeStream() + let resume = DispatchSemaphore(value: 0) + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: BlockingHistoryInstructions( + started: startedContinuation, + resume: resume + ), + history: [initial] + ) + + let request = Task.detached { session.resolvedRequestContext() } + for await _ in started.prefix(1) {} + session.properties.history = [replacement] + resume.signal() + let context = await request.value + + #expect(context.instructions?.description == "History count 1") + #expect( + context.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } == [initial] + ) + #expect(session.properties.history == [replacement]) + } + + @Test func replacingHistoryNotifiesTranscriptObservers() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let changed = Locked(false) + let prompt = Transcript.Entry.prompt( + Transcript.Prompt(segments: [.text(.init(content: "Replacement"))]) + ) + + withObservationTracking { + _ = session.transcript + } onChange: { + changed.withLock { $0 = true } + } + session.properties.history = [prompt] + + #expect(changed.withLock { $0 }) + } + + @Test func generatedPropertiesParticipateInObservation() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let changed = Locked(false) + + withObservationTracking { + _ = session.properties.revision + } onChange: { + changed.withLock { $0 = true } + } + session.properties.revision = 1 + + #expect(changed.withLock { $0 }) + } + @Test func propertiesAndHistoryAreIsolatedBetweenSessions() { let first = LanguageModelSession(model: SessionPropertyFixtureModel()) let second = LanguageModelSession(model: SessionPropertyFixtureModel()) @@ -98,6 +163,19 @@ private struct SessionPropertyHistoryInstructions: DynamicInstructions { } } +private struct BlockingHistoryInstructions: DynamicInstructions { + @SessionProperty(\.history) private var history + let started: AsyncStream.Continuation + let resume: DispatchSemaphore + + var body: some DynamicInstructions { + let count = history.count + started.yield() + resume.wait() + return Instructions("History count \(count)") + } +} + private struct SessionPropertyFixtureModel: LanguageModel { typealias UnavailableReason = Never From d9de68af09d1a21e44d5a095423f9c5fa347dac7 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:08:28 +0800 Subject: [PATCH 17/53] Avoid relowering recorded prompt attachments --- .../LanguageModelSession.swift | 10 ++-- .../Models/SystemLanguageModel.swift | 25 ++------- Sources/AnyLanguageModel/Prompt.swift | 28 ++++++++-- .../PromptAttachmentTests.swift | 51 +++++++++++++++++++ 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index b7775ce3..552c3fe0 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -728,7 +728,7 @@ public final class LanguageModelSession: @unchecked Sendable { let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( id: promptID, - segments: try prompt.makeTranscriptSegments(), + segments: try prompt.makeTranscriptSegments(occurrenceID: promptID), options: options, responseFormat: responseFormat ) @@ -776,14 +776,16 @@ public final class LanguageModelSession: @unchecked Sendable { options: GenerationOptions = GenerationOptions() ) -> sending ResponseStream where Content: Generable { // Add prompt to transcript + let promptID = UUID().uuidString let segments: [Transcript.Segment] do { - segments = try prompt.makeTranscriptSegments() + segments = try prompt.makeTranscriptSegments(occurrenceID: promptID) } catch { return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) } let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( + id: promptID, segments: segments, options: options, responseFormat: type == String.self ? nil : .init(type: type) @@ -930,14 +932,16 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) -> sending ResponseStream { + let promptID = UUID().uuidString let segments: [Transcript.Segment] do { - segments = try prompt.makeTranscriptSegments() + segments = try prompt.makeTranscriptSegments(occurrenceID: promptID) } catch { return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) } let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( + id: promptID, segments: segments, options: options, responseFormat: .init(schema: schema) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 7354e824..2eb7e785 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -341,32 +341,17 @@ guard let lastEntry = transcript.last, case .prompt(let lastPrompt) = lastEntry else { return transcript } - guard let promptSegments = try? prompt.makeTranscriptSegments(), - lastPrompt.segments.hasSamePayload(as: promptSegments) + guard + prompt.matchesTranscriptOccurrence( + lastPrompt.segments, + occurrenceID: lastPrompt.id + ) else { return transcript } return Transcript(entries: transcript.dropLast()) } - private extension Array where Element == Transcript.Segment { - func hasSamePayload(as other: [Transcript.Segment]) -> Bool { - guard count == other.count else { return false } - return zip(self, other).allSatisfy { lhs, rhs in - switch (lhs, rhs) { - case (.text(let lhs), .text(let rhs)): - lhs.content == rhs.content - case (.structure(let lhs), .structure(let rhs)): - lhs.source == rhs.source && lhs.content == rhs.content - case (.image(let lhs), .image(let rhs)): - lhs.source == rhs.source - default: - false - } - } - } - } - @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @available(watchOS, unavailable) extension SystemLanguageModel { diff --git a/Sources/AnyLanguageModel/Prompt.swift b/Sources/AnyLanguageModel/Prompt.swift index 3a829982..9365c47c 100644 --- a/Sources/AnyLanguageModel/Prompt.swift +++ b/Sources/AnyLanguageModel/Prompt.swift @@ -53,18 +53,38 @@ public struct Prompt: Sendable { self.components = components } - func makeTranscriptSegments() throws -> [Transcript.Segment] { - try components.map { component in + func makeTranscriptSegments(occurrenceID: String = UUID().uuidString) throws -> [Transcript.Segment] { + try components.enumerated().map { index, component in + let id = Self.transcriptSegmentID(occurrenceID: occurrenceID, index: index) switch component { case .text(let text): - return .text(.init(content: text.content)) + return .text(.init(id: id, content: text.content)) case .image(_, let content): - return .image(try content.makeSegment(id: UUID().uuidString)) + return .image(try content.makeSegment(id: id)) } } } + func matchesTranscriptOccurrence( + _ segments: [Transcript.Segment], + occurrenceID: String + ) -> Bool { + guard components.count == segments.count else { return false } + return segments.indices.allSatisfy { index in + segments[index].id + == Self.transcriptSegmentID( + occurrenceID: occurrenceID, + index: index + ) + } + } + + private static func transcriptSegmentID(occurrenceID: String, index: Int) -> String { + "\(occurrenceID).\(index)" + } + static func joining(_ prompts: [Prompt], trim: Bool = false) -> Prompt { + guard !prompts.isEmpty else { return Prompt(content: "") } var components: [Component] = [] for prompt in prompts { for component in prompt.components { diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 81a3b3f7..3283aee6 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -43,6 +43,21 @@ struct PromptAttachmentTests { } } + @Test func emptyBuildersKeepOneEmptyTextSegment() throws { + let built = AnyLanguageModel.Prompt {} + let array = AnyLanguageModel.Prompt([AnyLanguageModel.Prompt]()) + + for prompt in [built, array] { + let segments = try prompt.makeTranscriptSegments() + #expect(segments.count == 1) + guard case .text(let text) = segments[0] else { + Issue.record("Empty prompt did not retain its text segment") + continue + } + #expect(text.content.isEmpty) + } + } + @Test func buildersAndRepresentablesPreserveOrderedImages() throws { let original = prompt() struct Representable: AnyLanguageModel.PromptRepresentable { @@ -174,6 +189,42 @@ struct PromptAttachmentTests { == CGImagePropertyOrientation.right.rawValue ) } + + #if canImport(FoundationModels) + @available(macOS 26, iOS 26, visionOS 26, *) + @Test func duplicateSuppressionDoesNotReencodeRecordedAttachments() throws { + let encoded = try AnyLanguageModel.Prompt { + AnyLanguageModel.Attachment(try image(), orientation: .right) + }.makeTranscriptSegments() + guard case .image(let image) = try #require(encoded.first), + case .data(let data, _) = image.source + else { + Issue.record("Failed to encode the image fixture") + return + } + + let url = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + .appendingPathExtension("png") + try data.write(to: url) + let prompt = AnyLanguageModel.Prompt { + AnyLanguageModel.Attachment(imageURL: url, orientation: .right) + } + let occurrenceID = UUID().uuidString + let segments = try prompt.makeTranscriptSegments(occurrenceID: occurrenceID) + try FileManager.default.removeItem(at: url) + let transcript = AnyLanguageModel.Transcript(entries: [ + .prompt(.init(id: occurrenceID, segments: segments)) + ]) + + #expect( + AnyLanguageModel.fmTranscriptDroppingDuplicatePrompt( + transcript, + prompt: prompt + ).isEmpty + ) + } + #endif #endif #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) From 9ab55ea4fe3351a52d1c4d7aefe381058c9faeb4 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:10:00 +0800 Subject: [PATCH 18/53] Clarify supported attachment platforms --- Sources/AnyLanguageModel/Models/SystemLanguageModel.swift | 2 +- Tests/AnyLanguageModelTests/PromptAttachmentTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 2eb7e785..7727bbbf 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -564,7 +564,7 @@ var errorDescription: String? { switch self { case .attachmentsUnavailable: - "Prompt attachments require an OS 27 Foundation Models runtime." + "Prompt attachments require macOS 27, iOS 27, or visionOS 27." case .nonFileImageURL: "Foundation Models prompt attachments require a local file URL." } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 3283aee6..02cf014e 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -231,7 +231,7 @@ struct PromptAttachmentTests { @Test func promptBridgeErrorsHaveLocalizedDescriptions() { #expect( AnyLanguageModel.PromptBridgeError.attachmentsUnavailable.errorDescription - == "Prompt attachments require an OS 27 Foundation Models runtime." + == "Prompt attachments require macOS 27, iOS 27, or visionOS 27." ) #expect( AnyLanguageModel.PromptBridgeError.nonFileImageURL.errorDescription From a03fe05dec40acfa4c85b0f1c61bc7c0e3242219 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:08:28 +0800 Subject: [PATCH 19/53] Avoid relowering recorded prompt attachments --- .../LanguageModelSession.swift | 10 ++-- .../Models/SystemLanguageModel.swift | 25 ++------- Sources/AnyLanguageModel/Prompt.swift | 28 ++++++++-- .../PromptAttachmentTests.swift | 51 +++++++++++++++++++ 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index b7775ce3..552c3fe0 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -728,7 +728,7 @@ public final class LanguageModelSession: @unchecked Sendable { let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( id: promptID, - segments: try prompt.makeTranscriptSegments(), + segments: try prompt.makeTranscriptSegments(occurrenceID: promptID), options: options, responseFormat: responseFormat ) @@ -776,14 +776,16 @@ public final class LanguageModelSession: @unchecked Sendable { options: GenerationOptions = GenerationOptions() ) -> sending ResponseStream where Content: Generable { // Add prompt to transcript + let promptID = UUID().uuidString let segments: [Transcript.Segment] do { - segments = try prompt.makeTranscriptSegments() + segments = try prompt.makeTranscriptSegments(occurrenceID: promptID) } catch { return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) } let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( + id: promptID, segments: segments, options: options, responseFormat: type == String.self ? nil : .init(type: type) @@ -930,14 +932,16 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) -> sending ResponseStream { + let promptID = UUID().uuidString let segments: [Transcript.Segment] do { - segments = try prompt.makeTranscriptSegments() + segments = try prompt.makeTranscriptSegments(occurrenceID: promptID) } catch { return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) } let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( + id: promptID, segments: segments, options: options, responseFormat: .init(schema: schema) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 7354e824..2eb7e785 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -341,32 +341,17 @@ guard let lastEntry = transcript.last, case .prompt(let lastPrompt) = lastEntry else { return transcript } - guard let promptSegments = try? prompt.makeTranscriptSegments(), - lastPrompt.segments.hasSamePayload(as: promptSegments) + guard + prompt.matchesTranscriptOccurrence( + lastPrompt.segments, + occurrenceID: lastPrompt.id + ) else { return transcript } return Transcript(entries: transcript.dropLast()) } - private extension Array where Element == Transcript.Segment { - func hasSamePayload(as other: [Transcript.Segment]) -> Bool { - guard count == other.count else { return false } - return zip(self, other).allSatisfy { lhs, rhs in - switch (lhs, rhs) { - case (.text(let lhs), .text(let rhs)): - lhs.content == rhs.content - case (.structure(let lhs), .structure(let rhs)): - lhs.source == rhs.source && lhs.content == rhs.content - case (.image(let lhs), .image(let rhs)): - lhs.source == rhs.source - default: - false - } - } - } - } - @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @available(watchOS, unavailable) extension SystemLanguageModel { diff --git a/Sources/AnyLanguageModel/Prompt.swift b/Sources/AnyLanguageModel/Prompt.swift index 3a829982..9365c47c 100644 --- a/Sources/AnyLanguageModel/Prompt.swift +++ b/Sources/AnyLanguageModel/Prompt.swift @@ -53,18 +53,38 @@ public struct Prompt: Sendable { self.components = components } - func makeTranscriptSegments() throws -> [Transcript.Segment] { - try components.map { component in + func makeTranscriptSegments(occurrenceID: String = UUID().uuidString) throws -> [Transcript.Segment] { + try components.enumerated().map { index, component in + let id = Self.transcriptSegmentID(occurrenceID: occurrenceID, index: index) switch component { case .text(let text): - return .text(.init(content: text.content)) + return .text(.init(id: id, content: text.content)) case .image(_, let content): - return .image(try content.makeSegment(id: UUID().uuidString)) + return .image(try content.makeSegment(id: id)) } } } + func matchesTranscriptOccurrence( + _ segments: [Transcript.Segment], + occurrenceID: String + ) -> Bool { + guard components.count == segments.count else { return false } + return segments.indices.allSatisfy { index in + segments[index].id + == Self.transcriptSegmentID( + occurrenceID: occurrenceID, + index: index + ) + } + } + + private static func transcriptSegmentID(occurrenceID: String, index: Int) -> String { + "\(occurrenceID).\(index)" + } + static func joining(_ prompts: [Prompt], trim: Bool = false) -> Prompt { + guard !prompts.isEmpty else { return Prompt(content: "") } var components: [Component] = [] for prompt in prompts { for component in prompt.components { diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 81a3b3f7..3283aee6 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -43,6 +43,21 @@ struct PromptAttachmentTests { } } + @Test func emptyBuildersKeepOneEmptyTextSegment() throws { + let built = AnyLanguageModel.Prompt {} + let array = AnyLanguageModel.Prompt([AnyLanguageModel.Prompt]()) + + for prompt in [built, array] { + let segments = try prompt.makeTranscriptSegments() + #expect(segments.count == 1) + guard case .text(let text) = segments[0] else { + Issue.record("Empty prompt did not retain its text segment") + continue + } + #expect(text.content.isEmpty) + } + } + @Test func buildersAndRepresentablesPreserveOrderedImages() throws { let original = prompt() struct Representable: AnyLanguageModel.PromptRepresentable { @@ -174,6 +189,42 @@ struct PromptAttachmentTests { == CGImagePropertyOrientation.right.rawValue ) } + + #if canImport(FoundationModels) + @available(macOS 26, iOS 26, visionOS 26, *) + @Test func duplicateSuppressionDoesNotReencodeRecordedAttachments() throws { + let encoded = try AnyLanguageModel.Prompt { + AnyLanguageModel.Attachment(try image(), orientation: .right) + }.makeTranscriptSegments() + guard case .image(let image) = try #require(encoded.first), + case .data(let data, _) = image.source + else { + Issue.record("Failed to encode the image fixture") + return + } + + let url = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + .appendingPathExtension("png") + try data.write(to: url) + let prompt = AnyLanguageModel.Prompt { + AnyLanguageModel.Attachment(imageURL: url, orientation: .right) + } + let occurrenceID = UUID().uuidString + let segments = try prompt.makeTranscriptSegments(occurrenceID: occurrenceID) + try FileManager.default.removeItem(at: url) + let transcript = AnyLanguageModel.Transcript(entries: [ + .prompt(.init(id: occurrenceID, segments: segments)) + ]) + + #expect( + AnyLanguageModel.fmTranscriptDroppingDuplicatePrompt( + transcript, + prompt: prompt + ).isEmpty + ) + } + #endif #endif #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) From 51fa0a9707002d39bb4ccf24b5a7eaad56a2e7d6 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:10:00 +0800 Subject: [PATCH 20/53] Clarify supported attachment platforms --- Sources/AnyLanguageModel/Models/SystemLanguageModel.swift | 2 +- Tests/AnyLanguageModelTests/PromptAttachmentTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 2eb7e785..7727bbbf 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -564,7 +564,7 @@ var errorDescription: String? { switch self { case .attachmentsUnavailable: - "Prompt attachments require an OS 27 Foundation Models runtime." + "Prompt attachments require macOS 27, iOS 27, or visionOS 27." case .nonFileImageURL: "Foundation Models prompt attachments require a local file URL." } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 3283aee6..02cf014e 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -231,7 +231,7 @@ struct PromptAttachmentTests { @Test func promptBridgeErrorsHaveLocalizedDescriptions() { #expect( AnyLanguageModel.PromptBridgeError.attachmentsUnavailable.errorDescription - == "Prompt attachments require an OS 27 Foundation Models runtime." + == "Prompt attachments require macOS 27, iOS 27, or visionOS 27." ) #expect( AnyLanguageModel.PromptBridgeError.nonFileImageURL.errorDescription From d0cae54870d015b9451b52ae1c506a19f20d5f4e Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:19:17 +0800 Subject: [PATCH 21/53] Isolate dynamic profile request state --- Sources/AnyLanguageModel/DynamicProfile.swift | 28 ++++ .../LanguageModelSession.swift | 121 ++++++++++++++---- .../AnyLanguageModel/SessionProperties.swift | 4 + .../DynamicProfileTests.swift | 84 +++++++++++- .../SessionPropertyTests.swift | 18 +++ 5 files changed, 225 insertions(+), 30 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 6e4c0668..111159fb 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -475,11 +475,39 @@ private func resolveDynamicProfile( actor ProfileLifecycle { private var activeProfile: ResolvedDynamicProfile? private var lastPromptID: String? + private var pendingPreparation: (id: Int, task: Task)? + private var nextPreparationID = 0 func prepare( _ profile: ResolvedDynamicProfile, properties: SessionPropertyValues, history: [Transcript.Entry] + ) async throws { + let previous = pendingPreparation?.task + nextPreparationID += 1 + let id = nextPreparationID + let task = Task { + _ = try? await previous?.value + try await self.performPreparation( + profile, + properties: properties, + history: history + ) + } + pendingPreparation = (id, task) + do { + try await task.value + if pendingPreparation?.id == id { pendingPreparation = nil } + } catch { + if pendingPreparation?.id == id { pendingPreparation = nil } + throw error + } + } + + private func performPreparation( + _ profile: ResolvedDynamicProfile, + properties: SessionPropertyValues, + history: [Transcript.Entry] ) async throws { if activeProfile?.identity != profile.identity { if let previous = activeProfile { diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 6b9a0e81..784fa438 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -165,6 +165,7 @@ public final class LanguageModelSession: @unchecked Sendable { fileprivate let profile: ResolvedDynamicProfile? fileprivate let canonicalTranscript: [Transcript.Entry] + fileprivate let transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? fileprivate let callbackState: ProfileCallbackState fileprivate init( @@ -176,6 +177,7 @@ public final class LanguageModelSession: @unchecked Sendable { contextOptions: ContextOptions = .init(), profile: ResolvedDynamicProfile? = nil, canonicalTranscript: [Transcript.Entry]? = nil, + transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? = nil, callbackState: ProfileCallbackState = ProfileCallbackState() ) { self.transcript = transcript @@ -186,6 +188,7 @@ public final class LanguageModelSession: @unchecked Sendable { self.contextOptions = contextOptions self.profile = profile self.canonicalTranscript = canonicalTranscript ?? Array(transcript) + self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy self.callbackState = callbackState } @@ -199,6 +202,7 @@ public final class LanguageModelSession: @unchecked Sendable { contextOptions: contextOptions, profile: profile, canonicalTranscript: canonicalTranscript, + transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy, callbackState: context.callbackState ) } @@ -253,6 +257,9 @@ public final class LanguageModelSession: @unchecked Sendable { context = context.sharingCallbackState(with: prepared.context) } if let profile = context.profile { + if let prepared, prepared.session === self { + prepared.context = context + } try await profileLifecycle.prepare( profile, properties: properties, @@ -347,9 +354,12 @@ public final class LanguageModelSession: @unchecked Sendable { if resolvedOptions.toolCallingMode == nil, resolved.hasToolCallingMode { resolvedOptions.toolCallingMode = resolved.toolCallingMode } - if resolved.hasTranscriptErrorHandlingPolicy { - state.withLock { $0.transcriptErrorHandlingPolicy = resolved.transcriptErrorHandlingPolicy } - } + let transcriptErrorHandlingPolicy = + if resolved.hasTranscriptErrorHandlingPolicy { + resolved.transcriptErrorHandlingPolicy + } else { + state.withLock { $0.transcriptErrorHandlingPolicy } + } return RequestContext( transcript: requestTranscript, instructions: resolved.instructions, @@ -358,7 +368,8 @@ public final class LanguageModelSession: @unchecked Sendable { options: resolvedOptions, contextOptions: .init(reasoningLevel: resolved.reasoningLevel), profile: resolved, - canonicalTranscript: canonical + canonicalTranscript: canonical, + transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy ) } @@ -384,7 +395,7 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry] ) async throws { guard let profile = requestContext.profile else { return } - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries try await withSessionProperties(history: history, isHistoryWritable: true) { for action in profile.onToolCall { try await action(call) } } @@ -398,7 +409,7 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry] ) async throws { guard let profile = requestContext.profile else { return } - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries try await withSessionProperties(history: history, isHistoryWritable: true) { for action in profile.onToolOutput { try await action(call, output) } } @@ -420,7 +431,7 @@ public final class LanguageModelSession: @unchecked Sendable { if requestContext.callbackState.wasDispatched(entry.id) { continue } - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries switch entry { case .reasoning(let reasoning): try await withSessionProperties(history: history, isHistoryWritable: true) { @@ -445,7 +456,7 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry], operation: @Sendable () async throws -> Result ) async rethrows -> Result { - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries return try await withSessionProperties(history: history, isHistoryWritable: false) { try await operation() } @@ -704,7 +715,10 @@ public final class LanguageModelSession: @unchecked Sendable { } } - nonisolated private func wrapRespond(_ operation: (String) async throws -> T) async throws -> T { + nonisolated private func wrapRespond( + profileTracker: PreparedProfileRequestTracker? = nil, + _ operation: (String) async throws -> T + ) async throws -> T { let promptID = UUID().uuidString beginResponding() do { @@ -712,7 +726,13 @@ public final class LanguageModelSession: @unchecked Sendable { endResponding() return result } catch { - if transcriptErrorHandlingPolicy == .revertTranscript { + let policy: TranscriptErrorHandlingPolicy? + if let prepared = profileTracker?.prepared { + policy = prepared.context.transcriptErrorHandlingPolicy + } else { + policy = transcriptErrorHandlingPolicy + } + if policy == .revertTranscript { removePrompt(id: promptID) } endResponding() @@ -836,9 +856,15 @@ public final class LanguageModelSession: @unchecked Sendable { } ) } - if state.transcriptErrorHandlingPolicy == .preserveTranscript { + let policy: TranscriptErrorHandlingPolicy? + if let prepared = profileTracker?.prepared { + policy = prepared.context.transcriptErrorHandlingPolicy + } else { + policy = state.transcriptErrorHandlingPolicy + } + if policy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) - } else if state.transcriptErrorHandlingPolicy == .revertTranscript { + } else if policy == .revertTranscript { state.transcript = Transcript( entries: state.transcript.filter { $0.id != promptEntry.id } ) @@ -1057,12 +1083,17 @@ public final class LanguageModelSession: @unchecked Sendable { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) async throws -> Response where Content: Generable { - try await respond( + let profileTracker = dynamicProfile == nil ? nil : PreparedProfileRequestTracker() + return try await respond( to: prompt, responseFormat: type == String.self ? nil : .init(type: type), - options: options + options: options, + profileTracker: profileTracker ) { - try await profiledResponse(options: options) { model, resolvedOptions, contextOptions in + try await profiledResponse(options: options, tracker: profileTracker) { + model, + resolvedOptions, + contextOptions in try await model.respond( within: self, to: prompt, @@ -1077,6 +1108,7 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func profiledResponse( options: GenerationOptions, + tracker: PreparedProfileRequestTracker?, generate: ( any LanguageModel, GenerationOptions, @@ -1089,14 +1121,22 @@ public final class LanguageModelSession: @unchecked Sendable { prepared: nil ) } - let context = try await resolvedRequestContext(including: [], options: options) + let context = resolveRequestContext(additionalEntries: [], options: options) let prepared = PreparedProfileRequest( session: self, context: context, callSiteOptions: options ) + tracker?.prepared = prepared let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { - try await generate(context.model, context.options, context.contextOptions) + if let profile = context.profile { + try await profileLifecycle.prepare( + profile, + properties: properties, + history: context.canonicalTranscript + ) + } + return try await generate(context.model, context.options, context.contextOptions) } return ProfiledModelResponse(response: response, prepared: prepared) } @@ -1105,9 +1145,10 @@ public final class LanguageModelSession: @unchecked Sendable { to prompt: Prompt, responseFormat: Transcript.ResponseFormat?, options: GenerationOptions, + profileTracker: PreparedProfileRequestTracker?, generate: () async throws -> ProfiledModelResponse ) async throws -> Response { - try await wrapRespond { promptID in + try await wrapRespond(profileTracker: profileTracker) { promptID in // Add prompt to transcript let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( @@ -1269,18 +1310,32 @@ public final class LanguageModelSession: @unchecked Sendable { let stream = AsyncThrowingStream.Snapshot, any Error> { continuation in let task = Task { do { - let context = try await self.resolvedRequestContext(including: [], options: options) + let context = self.resolveRequestContext( + additionalEntries: [], + options: options + ) let prepared = PreparedProfileRequest( session: self, context: context, callSiteOptions: options ) tracker.prepared = prepared - let upstream = ProfileRequestBinding.$prepared.withValue(prepared) { - generate(context.model, context.options, context.contextOptions) - } - for try await snapshot in upstream { - continuation.yield(snapshot) + try await ProfileRequestBinding.$prepared.withValue(prepared) { + if let profile = context.profile { + try await self.profileLifecycle.prepare( + profile, + properties: self.properties, + history: context.canonicalTranscript + ) + } + let upstream = generate( + context.model, + context.options, + context.contextOptions + ) + for try await snapshot in upstream { + continuation.yield(snapshot) + } } continuation.finish() } catch { @@ -1364,8 +1419,17 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) async throws -> Response { - try await respond(to: prompt, responseFormat: .init(schema: schema), options: options) { - try await profiledResponse(options: options) { model, resolvedOptions, contextOptions in + let profileTracker = dynamicProfile == nil ? nil : PreparedProfileRequestTracker() + return try await respond( + to: prompt, + responseFormat: .init(schema: schema), + options: options, + profileTracker: profileTracker + ) { + try await profiledResponse(options: options, tracker: profileTracker) { + model, + resolvedOptions, + contextOptions in try await model.respond( within: self, to: prompt, @@ -1632,7 +1696,8 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) async throws -> Response where Content: Generable { - try await wrapRespond { promptID in + let profileTracker = dynamicProfile == nil ? nil : PreparedProfileRequestTracker() + return try await wrapRespond(profileTracker: profileTracker) { promptID in // Build segments from text and images var segments: [Transcript.Segment] = [] if !prompt.isEmpty { @@ -1656,7 +1721,7 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - let profiled = try await profiledResponse(options: options) { + let profiled = try await profiledResponse(options: options, tracker: profileTracker) { model, resolvedOptions, contextOptions in diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 5c8ab58e..5b8078b8 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -47,6 +47,10 @@ public final class SessionPropertyValues: @unchecked Sendable { public var history: Transcript.HistoryView { get { SessionPropertyBinding.history?.entries ?? historyGetter() } set { + let newValue = newValue.filter { entry in + if case .instructions = entry { return false } + return true + } if let history = SessionPropertyBinding.history { precondition( history.isWritable, diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index 64ec740f..4f58a076 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -96,6 +96,43 @@ struct DynamicProfileTests { #expect(session.transcript.allSatisfy { if case .prompt = $0 { true } else { false } }) } + @Test(arguments: [false, true]) + func promptFailureUsesTheProducingRequestPolicy(streaming: Bool) async throws { + let state = PromptFailureProfileState() + let session = LanguageModelSession( + profile: PromptFailureProfile(state: state, model: LifecycleFixtureModel()) + ) + + if streaming { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.streamResponse(to: "Revert").collect() + } + } else { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.respond(to: "Revert") + } + } + #expect(session.transcript.isEmpty) + #expect(session.transcriptErrorHandlingPolicy == nil) + + state.useRevert.withLock { $0 = false } + if streaming { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.streamResponse(to: "Keep").collect() + } + } else { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.respond(to: "Keep") + } + } + #expect(session.transcript.count == 1) + guard case .prompt = session.transcript[0] else { + Issue.record("Expected the second request prompt to be retained") + return + } + #expect(session.transcriptErrorHandlingPolicy == nil) + } + @Test func dynamicInstructionsAndToolsShareSessionProperties() async throws { let model = ProfileFixtureModel(id: "model", state: ProfileFixtureState()) let session = LanguageModelSession( @@ -197,22 +234,39 @@ struct DynamicProfileTests { segments: [.text(.init(content: "done"))] ) let request = try await first.resolvedRequestContext(including: [], options: .init()) + let unrelated = Transcript.Entry.prompt( + .init(id: "unrelated", segments: [.text(.init(content: "Other request"))]) + ) + first.properties.history = [unrelated] + let callEntry = Transcript.Entry.toolCalls(.init([call])) + let outputEntry = Transcript.Entry.toolOutput(output) try await first.profileWillExecuteToolCall( call, requestContext: request, - currentRoundEntries: [.toolCalls(.init([call]))] + currentRoundEntries: [callEntry] ) try await first.profileDidProduceToolOutput( for: call, output: output, requestContext: request, - currentRoundEntries: [.toolCalls(.init([call])), .toolOutput(output)] + currentRoundEntries: [callEntry, outputEntry] ) + let toolHistory = await first.withProfileToolExecution( + requestContext: request, + currentRoundEntries: [callEntry, outputEntry] + ) { + first.properties.history.map(\.id) + } #expect(first.properties.profileRevision == 1) #expect(second.properties.profileRevision == 0) #expect(state.callbackEvents.withLock { $0 } == ["call:call", "output:call"]) + #expect( + state.callbackHistories.withLock { $0 } + == [[callEntry.id], [callEntry.id, outputEntry.id]] + ) + #expect(toolHistory == [callEntry.id, outputEntry.id]) } } @@ -221,6 +275,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S let first: ProfileFixtureModel let second: ProfileFixtureModel @SessionProperty(\.profileRevision) private var revision + @SessionProperty(\.history) private var history var body: some LanguageModelSession.DynamicProfile { if state.usesSecond { @@ -257,6 +312,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S private func recordCall(_ call: Transcript.ToolCall) async throws { state.callbackEvents.withLock { $0.append("call:\(call.id)") } + state.callbackHistories.withLock { $0.append(history.map(\.id)) } } private func recordOutput( @@ -265,6 +321,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S ) async throws { revision += 1 state.callbackEvents.withLock { $0.append("output:\(output.id)") } + state.callbackHistories.withLock { $0.append(history.map(\.id)) } } } @@ -356,6 +413,7 @@ private final class ProfileFixtureState: @unchecked Sendable { let requests = Locked<[Request]>([]) let transformedHistory = Locked<[[String]]>([]) let callbackEvents = Locked<[String]>([]) + let callbackHistories = Locked<[[String]]>([]) var usesSecond: Bool { selection.withLock { $0 } } func selectSecond() { selection.withLock { $0 = true } } @@ -433,6 +491,28 @@ private struct ThrowingResponseProfile: LanguageModelSession.DynamicProfile { } } +private final class PromptFailureProfileState: @unchecked Sendable { + let useRevert = Locked(true) +} + +private struct PromptFailureProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let state: PromptFailureProfileState + let model: LifecycleFixtureModel + + var body: some LanguageModelSession.DynamicProfile { + if state.useRevert.withLock({ $0 }) { + LanguageModelSession.Profile { Instructions("Revert prompt failures") } + .model(model) + .transcriptErrorHandlingPolicy(.revertTranscript) + .onPrompt { throw LifecycleFixtureError.rejected } + } else { + LanguageModelSession.Profile { Instructions("Keep prompt failures") } + .model(model) + .onPrompt { throw LifecycleFixtureError.rejected } + } + } +} + private struct LifecycleFixtureModel: LanguageModel { typealias UnavailableReason = Never diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index e8421891..b9e9f4e8 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -143,6 +143,24 @@ struct SessionPropertyTests { } #expect(session.transcript[1] == prompt) } + + @Test func replacingHistoryDropsInstructionEntries() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let instructions = Transcript.Entry.instructions( + .init( + segments: [.text(.init(content: "Do not persist"))], + toolDefinitions: [] + ) + ) + let prompt = Transcript.Entry.prompt( + .init(segments: [.text(.init(content: "Keep"))]) + ) + + session.properties.history = [instructions, prompt] + + #expect(session.properties.history == [prompt]) + #expect(Array(session.transcript) == [prompt]) + } } private struct SessionPropertyFixtureInstructions: DynamicInstructions { From 1ac8207ea6261fb554cb8d06b7a1e5d85037bd11 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:19:17 +0800 Subject: [PATCH 22/53] Isolate dynamic profile request state --- Sources/AnyLanguageModel/DynamicProfile.swift | 30 +++++ .../LanguageModelSession.swift | 122 ++++++++++++------ .../AnyLanguageModel/SessionProperties.swift | 4 + .../DynamicProfileTests.swift | 84 +++++++++++- .../SessionPropertyTests.swift | 18 +++ 5 files changed, 215 insertions(+), 43 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 8b7c2fc2..75d712bc 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -493,12 +493,42 @@ private func resolveDynamicProfile( actor ProfileLifecycle { private var activeProfile: ResolvedDynamicProfile? private var lastPromptID: String? + private var pendingPreparation: (id: Int, task: Task)? + private var nextPreparationID = 0 func prepare( _ profile: ResolvedDynamicProfile, properties: SessionPropertyValues, history: [Transcript.Entry], protectedEntryIDs: Set = [] + ) async throws { + let previous = pendingPreparation?.task + nextPreparationID += 1 + let id = nextPreparationID + let task = Task { + _ = try? await previous?.value + try await self.performPreparation( + profile, + properties: properties, + history: history, + protectedEntryIDs: protectedEntryIDs + ) + } + pendingPreparation = (id, task) + do { + try await task.value + if pendingPreparation?.id == id { pendingPreparation = nil } + } catch { + if pendingPreparation?.id == id { pendingPreparation = nil } + throw error + } + } + + private func performPreparation( + _ profile: ResolvedDynamicProfile, + properties: SessionPropertyValues, + history: [Transcript.Entry], + protectedEntryIDs: Set ) async throws { if activeProfile?.identity != profile.identity { if let previous = activeProfile { diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index b3c67845..af5de933 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -259,6 +259,9 @@ public final class LanguageModelSession: @unchecked Sendable { context = context.sharingCallbackState(with: prepared.context) } if let profile = context.profile { + if let prepared, prepared.session === self { + prepared.context = context + } try await profileLifecycle.prepare( profile, properties: properties, @@ -403,7 +406,7 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry] ) async throws { guard let profile = requestContext.profile else { return } - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries try await withSessionProperties( history: history, isHistoryWritable: true, @@ -421,7 +424,7 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry] ) async throws { guard let profile = requestContext.profile else { return } - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries try await withSessionProperties( history: history, isHistoryWritable: true, @@ -447,7 +450,7 @@ public final class LanguageModelSession: @unchecked Sendable { if requestContext.callbackState.wasDispatched(entry.id) { continue } - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries switch entry { case .reasoning(let reasoning): try await withSessionProperties( @@ -480,7 +483,7 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry], operation: @Sendable () async throws -> Result ) async rethrows -> Result { - let history = Array(transcript) + currentRoundEntries + let history = requestContext.canonicalTranscript + currentRoundEntries return try await withSessionProperties( history: history, isHistoryWritable: false, @@ -748,7 +751,10 @@ public final class LanguageModelSession: @unchecked Sendable { } } - nonisolated private func wrapRespond(_ operation: (String) async throws -> T) async throws -> T { + nonisolated private func wrapRespond( + profileTracker: PreparedProfileRequestTracker? = nil, + _ operation: (String) async throws -> T + ) async throws -> T { let promptID = UUID().uuidString beginResponding() do { @@ -756,15 +762,17 @@ public final class LanguageModelSession: @unchecked Sendable { endResponding() return result } catch { - let failure = error as? ProfiledModelFailure - let policy = - failure?.prepared.context.transcriptErrorHandlingPolicy - ?? transcriptErrorHandlingPolicy + let policy: TranscriptErrorHandlingPolicy? + if let prepared = profileTracker?.prepared { + policy = prepared.context.transcriptErrorHandlingPolicy + } else { + policy = transcriptErrorHandlingPolicy + } if policy == .revertTranscript { removePrompt(id: promptID) } endResponding() - throw failure?.underlyingError ?? error + throw error } } @@ -884,10 +892,12 @@ public final class LanguageModelSession: @unchecked Sendable { } ) } - let policy = - profileTracker?.prepared?.context - .transcriptErrorHandlingPolicy - ?? state.transcriptErrorHandlingPolicy + let policy: TranscriptErrorHandlingPolicy? + if let prepared = profileTracker?.prepared { + policy = prepared.context.transcriptErrorHandlingPolicy + } else { + policy = state.transcriptErrorHandlingPolicy + } if policy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) } else if policy == .revertTranscript { @@ -1109,12 +1119,17 @@ public final class LanguageModelSession: @unchecked Sendable { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) async throws -> Response where Content: Generable { - try await respond( + let profileTracker = dynamicProfile == nil ? nil : PreparedProfileRequestTracker() + return try await respond( to: prompt, responseFormat: type == String.self ? nil : .init(type: type), - options: options + options: options, + profileTracker: profileTracker ) { - try await profiledResponse(options: options) { model, resolvedOptions, contextOptions in + try await profiledResponse(options: options, tracker: profileTracker) { + model, + resolvedOptions, + contextOptions in try await model.respond( within: self, to: prompt, @@ -1129,6 +1144,7 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func profiledResponse( options: GenerationOptions, + tracker: PreparedProfileRequestTracker?, generate: ( any LanguageModel, GenerationOptions, @@ -1141,29 +1157,34 @@ public final class LanguageModelSession: @unchecked Sendable { prepared: nil ) } - let context = try await resolvedRequestContext(including: [], options: options) + let context = resolveRequestContext(additionalEntries: [], options: options) let prepared = PreparedProfileRequest( session: self, context: context, callSiteOptions: options ) - do { - let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { - try await generate(context.model, context.options, context.contextOptions) + tracker?.prepared = prepared + let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { + if let profile = context.profile { + try await profileLifecycle.prepare( + profile, + properties: properties, + history: context.canonicalTranscript + ) } - return ProfiledModelResponse(response: response, prepared: prepared) - } catch { - throw ProfiledModelFailure(underlyingError: error, prepared: prepared) + return try await generate(context.model, context.options, context.contextOptions) } + return ProfiledModelResponse(response: response, prepared: prepared) } nonisolated private func respond( to prompt: Prompt, responseFormat: Transcript.ResponseFormat?, options: GenerationOptions, + profileTracker: PreparedProfileRequestTracker?, generate: () async throws -> ProfiledModelResponse ) async throws -> Response { - try await wrapRespond { promptID in + try await wrapRespond(profileTracker: profileTracker) { promptID in // Add prompt to transcript let promptEntry = Transcript.Entry.prompt( Transcript.Prompt( @@ -1239,7 +1260,7 @@ public final class LanguageModelSession: @unchecked Sendable { ) } } - throw ProfiledModelFailure(underlyingError: error, prepared: prepared) + throw error } } @@ -1325,18 +1346,32 @@ public final class LanguageModelSession: @unchecked Sendable { let stream = AsyncThrowingStream.Snapshot, any Error> { continuation in let task = Task { do { - let context = try await self.resolvedRequestContext(including: [], options: options) + let context = self.resolveRequestContext( + additionalEntries: [], + options: options + ) let prepared = PreparedProfileRequest( session: self, context: context, callSiteOptions: options ) tracker.prepared = prepared - let upstream = ProfileRequestBinding.$prepared.withValue(prepared) { - generate(context.model, context.options, context.contextOptions) - } - for try await snapshot in upstream { - continuation.yield(snapshot) + try await ProfileRequestBinding.$prepared.withValue(prepared) { + if let profile = context.profile { + try await self.profileLifecycle.prepare( + profile, + properties: self.properties, + history: context.canonicalTranscript + ) + } + let upstream = generate( + context.model, + context.options, + context.contextOptions + ) + for try await snapshot in upstream { + continuation.yield(snapshot) + } } continuation.finish() } catch { @@ -1420,8 +1455,17 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) async throws -> Response { - try await respond(to: prompt, responseFormat: .init(schema: schema), options: options) { - try await profiledResponse(options: options) { model, resolvedOptions, contextOptions in + let profileTracker = dynamicProfile == nil ? nil : PreparedProfileRequestTracker() + return try await respond( + to: prompt, + responseFormat: .init(schema: schema), + options: options, + profileTracker: profileTracker + ) { + try await profiledResponse(options: options, tracker: profileTracker) { + model, + resolvedOptions, + contextOptions in try await model.respond( within: self, to: prompt, @@ -1688,7 +1732,8 @@ extension LanguageModelSession { includeSchemaInPrompt: Bool = true, options: GenerationOptions = GenerationOptions() ) async throws -> Response where Content: Generable { - try await wrapRespond { promptID in + let profileTracker = dynamicProfile == nil ? nil : PreparedProfileRequestTracker() + return try await wrapRespond(profileTracker: profileTracker) { promptID in // Build segments from text and images var segments: [Transcript.Segment] = [] if !prompt.isEmpty { @@ -1712,7 +1757,7 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - let profiled = try await profiledResponse(options: options) { + let profiled = try await profiledResponse(options: options, tracker: profileTracker) { model, resolvedOptions, contextOptions in @@ -2268,11 +2313,6 @@ private struct ProfiledModelResponse where Content: Generable { let prepared: PreparedProfileRequest? } -private struct ProfiledModelFailure: Error { - let underlyingError: any Error - let prepared: PreparedProfileRequest -} - fileprivate final class ProfileCallbackState: @unchecked Sendable { private let dispatchedEntryIDs = Locked>([]) diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index de3eb220..0a21de7f 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -47,6 +47,10 @@ public final class SessionPropertyValues: @unchecked Sendable { public var history: Transcript.HistoryView { get { SessionPropertyBinding.history?.entries ?? historyGetter() } set { + let newValue = newValue.filter { entry in + if case .instructions = entry { return false } + return true + } if let history = SessionPropertyBinding.history { precondition( history.isWritable, diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index ff71084b..4f4dfe8c 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -116,6 +116,43 @@ struct DynamicProfileTests { #expect(session.transcript.allSatisfy { if case .prompt = $0 { true } else { false } }) } + @Test(arguments: [false, true]) + func promptFailureUsesTheProducingRequestPolicy(streaming: Bool) async throws { + let state = PromptFailureProfileState() + let session = LanguageModelSession( + profile: PromptFailureProfile(state: state, model: LifecycleFixtureModel()) + ) + + if streaming { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.streamResponse(to: "Revert").collect() + } + } else { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.respond(to: "Revert") + } + } + #expect(session.transcript.isEmpty) + #expect(session.transcriptErrorHandlingPolicy == nil) + + state.useRevert.withLock { $0 = false } + if streaming { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.streamResponse(to: "Keep").collect() + } + } else { + await #expect(throws: LifecycleFixtureError.self) { + _ = try await session.respond(to: "Keep") + } + } + #expect(session.transcript.count == 1) + guard case .prompt = session.transcript[0] else { + Issue.record("Expected the second request prompt to be retained") + return + } + #expect(session.transcriptErrorHandlingPolicy == nil) + } + @Test func dynamicInstructionsAndToolsShareSessionProperties() async throws { let model = ProfileFixtureModel(id: "model", state: ProfileFixtureState()) let session = LanguageModelSession( @@ -217,22 +254,39 @@ struct DynamicProfileTests { segments: [.text(.init(content: "done"))] ) let request = try await first.resolvedRequestContext(including: [], options: .init()) + let unrelated = Transcript.Entry.prompt( + .init(id: "unrelated", segments: [.text(.init(content: "Other request"))]) + ) + first.properties.history = [unrelated] + let callEntry = Transcript.Entry.toolCalls(.init([call])) + let outputEntry = Transcript.Entry.toolOutput(output) try await first.profileWillExecuteToolCall( call, requestContext: request, - currentRoundEntries: [.toolCalls(.init([call]))] + currentRoundEntries: [callEntry] ) try await first.profileDidProduceToolOutput( for: call, output: output, requestContext: request, - currentRoundEntries: [.toolCalls(.init([call])), .toolOutput(output)] + currentRoundEntries: [callEntry, outputEntry] ) + let toolHistory = await first.withProfileToolExecution( + requestContext: request, + currentRoundEntries: [callEntry, outputEntry] + ) { + first.properties.history.map(\.id) + } #expect(first.properties.profileRevision == 1) #expect(second.properties.profileRevision == 0) #expect(state.callbackEvents.withLock { $0 } == ["call:call", "output:call"]) + #expect( + state.callbackHistories.withLock { $0 } + == [[callEntry.id], [callEntry.id, outputEntry.id]] + ) + #expect(toolHistory == [callEntry.id, outputEntry.id]) } @Test func continuationHistoryMutationDoesNotCommitPendingEntries() async throws { @@ -292,6 +346,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S let first: ProfileFixtureModel let second: ProfileFixtureModel @SessionProperty(\.profileRevision) private var revision + @SessionProperty(\.history) private var history var body: some LanguageModelSession.DynamicProfile { if state.usesSecond { @@ -328,6 +383,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S private func recordCall(_ call: Transcript.ToolCall) async throws { state.callbackEvents.withLock { $0.append("call:\(call.id)") } + state.callbackHistories.withLock { $0.append(history.map(\.id)) } } private func recordOutput( @@ -336,6 +392,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S ) async throws { revision += 1 state.callbackEvents.withLock { $0.append("output:\(output.id)") } + state.callbackHistories.withLock { $0.append(history.map(\.id)) } } } @@ -427,6 +484,7 @@ private final class ProfileFixtureState: @unchecked Sendable { let requests = Locked<[Request]>([]) let transformedHistory = Locked<[[String]]>([]) let callbackEvents = Locked<[String]>([]) + let callbackHistories = Locked<[[String]]>([]) var usesSecond: Bool { selection.withLock { $0 } } func selectSecond() { selection.withLock { $0 = true } } @@ -531,6 +589,28 @@ private struct ErrorPolicyProfile: LanguageModelSession.DynamicProfile, @uncheck } } +private final class PromptFailureProfileState: @unchecked Sendable { + let useRevert = Locked(true) +} + +private struct PromptFailureProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let state: PromptFailureProfileState + let model: LifecycleFixtureModel + + var body: some LanguageModelSession.DynamicProfile { + if state.useRevert.withLock({ $0 }) { + LanguageModelSession.Profile { Instructions("Revert prompt failures") } + .model(model) + .transcriptErrorHandlingPolicy(.revertTranscript) + .onPrompt { throw LifecycleFixtureError.rejected } + } else { + LanguageModelSession.Profile { Instructions("Keep prompt failures") } + .model(model) + .onPrompt { throw LifecycleFixtureError.rejected } + } + } +} + private struct FailingProfileModel: LanguageModel { typealias UnavailableReason = Never diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index e8421891..b9e9f4e8 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -143,6 +143,24 @@ struct SessionPropertyTests { } #expect(session.transcript[1] == prompt) } + + @Test func replacingHistoryDropsInstructionEntries() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + let instructions = Transcript.Entry.instructions( + .init( + segments: [.text(.init(content: "Do not persist"))], + toolDefinitions: [] + ) + ) + let prompt = Transcript.Entry.prompt( + .init(segments: [.text(.init(content: "Keep"))]) + ) + + session.properties.history = [instructions, prompt] + + #expect(session.properties.history == [prompt]) + #expect(Array(session.transcript) == [prompt]) + } } private struct SessionPropertyFixtureInstructions: DynamicInstructions { From c3500137809652b8579b8bb0bc1a87c25daeb519 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:26:24 +0800 Subject: [PATCH 23/53] Fix dynamic profile continuation boundaries --- Sources/AnyLanguageModel/DynamicProfile.swift | 17 +++++++ .../LanguageModelSession.swift | 5 +- .../ProfileToolExecution.swift | 2 + .../AnyLanguageModel/SessionProperties.swift | 11 ++++- .../DynamicProfileTests.swift | 49 ++++++++++++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 75d712bc..583b8c95 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -195,6 +195,15 @@ extension LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .reasoningLevel(reasoningLevel)) } + public func includeSchemaInPrompt( + _ includeSchemaInPrompt: Bool? + ) -> some LanguageModelSession.DynamicProfile { + BuiltinModifiedDynamicProfile( + content: self, + modifier: .includeSchemaInPrompt(includeSchemaInPrompt) + ) + } + public func toolCallingMode( _ toolCallingMode: GenerationOptions.ToolCallingMode? ) -> some LanguageModelSession.DynamicProfile { @@ -328,6 +337,8 @@ struct ResolvedDynamicProfile: @unchecked Sendable { var hasMaximumResponseTokens = false var reasoningLevel: ContextOptions.ReasoningLevel? var hasReasoningLevel = false + var includeSchemaInPrompt: Bool? + var hasIncludeSchemaInPrompt = false var toolCallingMode: GenerationOptions.ToolCallingMode? var hasToolCallingMode = false var transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? @@ -411,6 +422,7 @@ private enum BuiltinDynamicProfileModifier: @unchecked Sendable { case samplingMode(GenerationOptions.SamplingMode?) case maximumResponseTokens(Int?) case reasoningLevel(ContextOptions.ReasoningLevel?) + case includeSchemaInPrompt(Bool?) case toolCallingMode(GenerationOptions.ToolCallingMode?) case historyTransform(([Transcript.Entry]) -> [Transcript.Entry]) case transcriptErrorHandlingPolicy(TranscriptErrorHandlingPolicy?) @@ -446,6 +458,11 @@ private enum BuiltinDynamicProfileModifier: @unchecked Sendable { resolved.reasoningLevel = value resolved.hasReasoningLevel = true } + case .includeSchemaInPrompt(let value): + if !resolved.hasIncludeSchemaInPrompt { + resolved.includeSchemaInPrompt = value + resolved.hasIncludeSchemaInPrompt = true + } case .toolCallingMode(let value): if !resolved.hasToolCallingMode { resolved.toolCallingMode = value diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index af5de933..7c5abc0a 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -375,7 +375,10 @@ public final class LanguageModelSession: @unchecked Sendable { tools: resolved.tools, model: resolved.model ?? model, options: resolvedOptions, - contextOptions: .init(reasoningLevel: resolved.reasoningLevel), + contextOptions: .init( + includeSchemaInPrompt: resolved.includeSchemaInPrompt, + reasoningLevel: resolved.reasoningLevel + ), transcriptErrorHandlingPolicy: resolved.hasTranscriptErrorHandlingPolicy ? resolved.transcriptErrorHandlingPolicy : sessionErrorPolicy, diff --git a/Sources/AnyLanguageModel/ProfileToolExecution.swift b/Sources/AnyLanguageModel/ProfileToolExecution.swift index 68ec1930..d87a1ada 100644 --- a/Sources/AnyLanguageModel/ProfileToolExecution.swift +++ b/Sources/AnyLanguageModel/ProfileToolExecution.swift @@ -26,6 +26,7 @@ extension LanguageModelSession { prefixUsage: Usage, prefixText: String ) async throws -> Response? { + guard !prefixEntries.isEmpty else { return nil } if let selected = requestContext.model as? any ProfileRequestIdentifiedLanguageModel, selected.profileRequestIdentity == currentModelIdentity { @@ -86,6 +87,7 @@ extension LanguageModelSession { prefixUsage: Usage, prefixText: String ) -> ResponseStream? { + guard !prefixEntries.isEmpty else { return nil } if let selected = requestContext.model as? any ProfileRequestIdentifiedLanguageModel, selected.profileRequestIdentity == currentModelIdentity { diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 0a21de7f..9efa76d1 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -98,6 +98,7 @@ public final class SessionPropertyValues: @unchecked Sendable { return SessionHistoryBinding( entries, isWritable: isWritable, + protectedEntryIDs: protectedEntryIDs, persist: persist ) } @@ -130,21 +131,29 @@ enum SessionPropertyBinding { final class SessionHistoryBinding: @unchecked Sendable { private let storage: Locked let isWritable: Bool + private let protectedEntryIDs: Set private let persistValue: (@Sendable (Transcript.HistoryView) -> Void)? init( _ entries: Transcript.HistoryView, isWritable: Bool, + protectedEntryIDs: Set = [], persist: (@Sendable (Transcript.HistoryView) -> Void)? = nil ) { storage = Locked(entries) self.isWritable = isWritable + self.protectedEntryIDs = protectedEntryIDs self.persistValue = persist } var entries: Transcript.HistoryView { get { storage.withLock { $0 } } - set { storage.withLock { $0 = newValue } } + set { + storage.withLock { current in + let protected = current.filter { protectedEntryIDs.contains($0.id) } + current = newValue.filter { !protectedEntryIDs.contains($0.id) } + protected + } + } } func persist(_ entries: Transcript.HistoryView) { diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index 4f4dfe8c..6400cd5c 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -29,6 +29,7 @@ struct DynamicProfileTests { #expect(requests.map(\.temperature) == [0.2, 0.8]) #expect(requests.map(\.maximumResponseTokens) == [64, 128]) #expect(requests.map(\.reasoningLevel) == [.light, .deep]) + #expect(requests.map(\.includeSchemaInPrompt) == [false, true]) #expect(session.transcript.count == 4) #expect(!session.transcript.contains { if case .instructions = $0 { true } else { false } }) } @@ -54,6 +55,7 @@ struct DynamicProfileTests { #expect(request.maximumResponseTokens == 17) #expect(request.toolCallingMode == .required) #expect(request.reasoningLevel == .light) + #expect(request.includeSchemaInPrompt == false) } @Test func lifecycleCallbacksAccumulateInOrderForResponseAndStream() async throws { @@ -319,6 +321,41 @@ struct DynamicProfileTests { #expect(session.transcript == durableBefore) } + @Test func modelHandoffIsSkippedForTheInitialRequest() async throws { + let session = LanguageModelSession( + profile: LifecycleFixtureProfile( + state: LifecycleFixtureState(), + model: LifecycleFixtureModel() + ) + ) + let context = try await session.resolvedRequestContext(including: [], options: .init()) + let response: LanguageModelSession.Response? = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: UUID(), + requestContext: context, + prompt: Prompt("Initial"), + generating: String.self, + schema: String.generationSchema, + includeSchemaInPrompt: true, + prefixEntries: [], + prefixUsage: .zero, + prefixText: "" + ) + let stream: LanguageModelSession.ResponseStream? = session.profileContinuationStream( + ifSelectedModelDiffersFrom: UUID(), + requestContext: context, + prompt: Prompt("Initial"), + generating: String.self, + schema: String.generationSchema, + includeSchemaInPrompt: true, + prefixEntries: [], + prefixUsage: .zero, + prefixText: "" + ) + + #expect(response == nil) + #expect(stream == nil) + } + @Test func profileErrorPolicyDoesNotLeakIntoTheNextProfile() async throws { let state = LifecycleFixtureState() let session = LanguageModelSession( @@ -358,6 +395,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S .temperature(0.8) .maximumResponseTokens(128) .reasoningLevel(.deep) + .includeSchemaInPrompt(true) .historyTransform(recordingTransform) .onToolCall(perform: recordCall) .onToolOutput(perform: recordOutput) @@ -370,6 +408,7 @@ private struct FixtureProfile: LanguageModelSession.DynamicProfile, @unchecked S .temperature(0.2) .maximumResponseTokens(64) .reasoningLevel(.light) + .includeSchemaInPrompt(false) .historyTransform(recordingTransform) .onToolCall(perform: recordCall) .onToolOutput(perform: recordOutput) @@ -427,7 +466,8 @@ private struct ProfileFixtureModel: LanguageModel { temperature: context.options.temperature, maximumResponseTokens: context.options.maximumResponseTokens, toolCallingMode: context.options.toolCallingMode, - reasoningLevel: context.contextOptions.reasoningLevel + reasoningLevel: context.contextOptions.reasoningLevel, + includeSchemaInPrompt: includeSchemaInPrompt ) ) } @@ -478,6 +518,7 @@ private final class ProfileFixtureState: @unchecked Sendable { let maximumResponseTokens: Int? let toolCallingMode: GenerationOptions.ToolCallingMode? let reasoningLevel: ContextOptions.ReasoningLevel? + let includeSchemaInPrompt: Bool } private let selection = Locked(false) @@ -567,7 +608,11 @@ private struct HistoryWritingProfile: LanguageModelSession.DynamicProfile { @SessionProperty(\.history) private var history var body: some LanguageModelSession.DynamicProfile { - history = history + history = history.filter { entry in + if case .toolCalls = entry { return false } + if case .toolOutput = entry { return false } + return true + } return LanguageModelSession.Profile { Instructions("History") } .model(model) } From 1892a39bca496b413e11a7dc0d570381a68e2b1d Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Thu, 15 Oct 2026 09:01:38 +0800 Subject: [PATCH 24/53] Inline Gemini tool output file attachments --- .../Models/GeminiLanguageModel.swift | 47 ++++++++++---- .../GeminiThoughtSignatureTests.swift | 62 +++++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index d531c426..cbb4cf2d 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -254,7 +254,7 @@ public struct GeminiLanguageModel: LanguageModel { requestTranscript.append(entry) } let params = try createGenerateContentParams( - contents: requestTranscript.toGeminiContent(), + contents: try requestTranscript.toGeminiContent(), tools: geminiTools, generating: type, schema: responseSchema.schema, @@ -432,7 +432,7 @@ public struct GeminiLanguageModel: LanguageModel { requestTranscript.append(entry) } let params = try createGenerateContentParams( - contents: requestTranscript.toGeminiContent(), + contents: try requestTranscript.toGeminiContent(), tools: geminiTools, generating: type, schema: responseSchema.schema, @@ -800,7 +800,7 @@ private func toGeminiFunctionResponse( ) ) case .url(let url): - parts.append(.fileData(GeminiFileData(fileURI: url.absoluteString))) + parts.append(.inlineData(try geminiFunctionResponseData(from: url))) } } } @@ -816,10 +816,41 @@ private func toGeminiFunctionResponse( ) } +private enum GeminiFunctionResponseAttachmentError: LocalizedError { + case unsupportedURL(URL) + case unsupportedImageType(URL) + + var errorDescription: String? { + switch self { + case .unsupportedURL(let url): + "Gemini Tool output attachments require a local file URL; cannot inline \(url.absoluteString)." + case .unsupportedImageType(let url): + "Gemini Tool output attachments support PNG, JPEG, and WebP files; cannot determine a supported MIME type for \(url.lastPathComponent)." + } + } +} + +private func geminiFunctionResponseData(from url: URL) throws -> GeminiInlineData { + guard url.isFileURL else { + throw GeminiFunctionResponseAttachmentError.unsupportedURL(url) + } + + let mimeType = switch url.pathExtension.lowercased() { + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "webp": "image/webp" + default: throw GeminiFunctionResponseAttachmentError.unsupportedImageType(url) + } + return try GeminiInlineData( + mimeType: mimeType, + data: Data(contentsOf: url).base64EncodedString() + ) +} + // MARK: - Supporting Types extension Transcript { - fileprivate func toGeminiContent() -> [GeminiContent] { + fileprivate func toGeminiContent() throws -> [GeminiContent] { var messages = [GeminiContent]() for item in self { switch item { @@ -869,13 +900,7 @@ extension Transcript { ) case .toolOutput(let toolOutput): // Add function response as a user message (Gemini API expects function responses from user role) - let functionResponse = - (try? toGeminiFunctionResponse(toolOutput)) - ?? GeminiFunctionResponse( - name: toolOutput.toolName, - response: [:], - parts: nil - ) + let functionResponse = try toGeminiFunctionResponse(toolOutput) messages.append( .init( role: .user, diff --git a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift index d1a93925..006490ab 100644 --- a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift +++ b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift @@ -179,6 +179,68 @@ import Testing #expect(Data(base64Encoded: try #require(inlineData["data"] as? String)) != nil) } + @Test("inlines URL-backed images in function responses") + func inlinesURLBackedToolOutput() async throws { + StubURLProtocol.reset() + StubURLProtocol.enqueue( + json: + #"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"image","args":{"request":"read"}}}]},"finishReason":"STOP"}]}"# + ) + StubURLProtocol.enqueue(json: textResponse("OK")) + + let imageData = Data([0x89, 0x50, 0x4E, 0x47]) + let url = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + .appendingPathExtension("png") + try imageData.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let output = Prompt { Attachment(imageURL: url) } + let session = LanguageModelSession( + model: makeModel(), + tools: [ImageOutputTool(output: output)] + ) + _ = try await session.respond(to: "read image") + + let body = try #require(StubURLProtocol.recordedBodies.last) + let json = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let contents = try #require(json["contents"] as? [[String: Any]]) + let outputParts = try #require(contents.last?["parts"] as? [[String: Any]]) + let functionResponse = try #require( + outputParts.first?["functionResponse"] as? [String: Any] + ) + let mediaParts = try #require(functionResponse["parts"] as? [[String: Any]]) + let inlineData = try #require(mediaParts.first?["inlineData"] as? [String: Any]) + #expect(inlineData["mime_type"] as? String == "image/png") + #expect(Data(base64Encoded: try #require(inlineData["data"] as? String)) == imageData) + #expect(mediaParts.first?["fileData"] == nil) + } + + @Test("rejects remote URL-backed images with an actionable error") + func rejectsRemoteURLBackedToolOutput() async throws { + StubURLProtocol.reset() + StubURLProtocol.enqueue( + json: + #"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"image","args":{"request":"read"}}}]},"finishReason":"STOP"}]}"# + ) + + let url = try #require(URL(string: "https://example.com/image.png")) + let output = Prompt { Attachment(imageURL: url) } + let session = LanguageModelSession( + model: makeModel(), + tools: [ImageOutputTool(output: output)] + ) + + do { + _ = try await session.respond(to: "read image") + Issue.record("Expected a remote Tool output attachment to be rejected") + } catch { + #expect(error.localizedDescription.contains("local file URL")) + } + } + @Test("does not replay the conversation history on later turns") func doesNotReplayHistoryOnLaterTurns() async throws { StubURLProtocol.reset() From 93e4d4d7fdfb5e211882c948793dc2230214a752 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Thu, 15 Oct 2026 09:03:50 +0800 Subject: [PATCH 25/53] Fix session property observation and typed defaults --- .../LanguageModelSession.swift | 28 ++++++--- .../AnyLanguageModel/SessionProperties.swift | 17 +++-- .../SessionPropertyEntryMacro.swift | 24 ++++--- .../SessionPropertyTests.swift | 62 +++++++++++++++++++ 4 files changed, 108 insertions(+), 23 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 59a1c075..683dae8e 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -405,10 +405,18 @@ public final class LanguageModelSession: @unchecked Sendable { } } + nonisolated private func withTranscriptMutation( + _ mutation: () throws -> Result + ) rethrows -> Result { + try properties._withSessionPropertyMutation(keyPath: \.history) { + try withMutation(keyPath: \.transcript, mutation) + } + } + // The prompt is the only entry committed before a request succeeds. Checkpoints and // the final response commit atomically at completion, so rollback owns only this ID. nonisolated private func removePrompt(id: String) { - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { state in state.transcript = Transcript(entries: state.transcript.filter { $0.id != id }) } @@ -517,7 +525,7 @@ public final class LanguageModelSession: @unchecked Sendable { providerMetadata: lastSnapshot.providerMetadata ) ) - session.withMutation(keyPath: \.transcript) { + session.withTranscriptMutation { session.state.withLock { $0.transcript.append(contentsOf: lastSnapshot.transcriptEntries) $0.transcript.append(responseEntry) @@ -526,7 +534,7 @@ public final class LanguageModelSession: @unchecked Sendable { session.endResponding() continuation.finish() } catch { - session.withMutation(keyPath: \.transcript) { + session.withTranscriptMutation { session.state.withLock { state in if state.transcriptErrorHandlingPolicy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) @@ -780,7 +788,7 @@ public final class LanguageModelSession: @unchecked Sendable { responseFormat: responseFormat ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -805,7 +813,7 @@ public final class LanguageModelSession: @unchecked Sendable { ) // Add tool entries and response to transcript - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { lockedState in lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) @@ -830,7 +838,7 @@ public final class LanguageModelSession: @unchecked Sendable { responseFormat: type == String.self ? nil : .init(type: type) ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -978,7 +986,7 @@ extension LanguageModelSession { responseFormat: .init(schema: schema) ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1177,7 +1185,7 @@ extension LanguageModelSession { responseFormat: type == String.self ? nil : .init(type: type) ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1214,7 +1222,7 @@ extension LanguageModelSession { ) // Add tool entries and response to transcript - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { lockedState in lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) @@ -1309,7 +1317,7 @@ extension LanguageModelSession { responseFormat: type == String.self ? nil : .init(type: type) ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 0adfadb8..42aa3e92 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -45,13 +45,18 @@ public final class SessionPropertyValues: @unchecked Sendable { /// The session history without an instructions entry. public var history: Transcript.HistoryView { - get { SessionPropertyBinding.history ?? historyGetter() } + get { + _accessSessionProperty(keyPath: \.history) + return SessionPropertyBinding.history ?? historyGetter() + } set { - precondition( - SessionPropertyBinding.history == nil, - "Session history is read-only while dynamic instructions are active" - ) - historySetter(newValue) + _withSessionPropertyMutation(keyPath: \.history) { + precondition( + SessionPropertyBinding.history == nil, + "Session history is read-only while dynamic instructions are active" + ) + historySetter(newValue) + } } } diff --git a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift index 823aae64..e6625660 100644 --- a/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -41,13 +41,23 @@ public struct SessionPropertyEntryMacro: AccessorMacro, PeerMacro { let initializer = binding.initializer?.value else { return [] } - return [ - """ - private struct __Key_\(raw: name): SessionPropertyKey { - static let defaultValue = \(initializer) - } - """ - ] + let defaultValue: DeclSyntax + if let typeAnnotation = binding.typeAnnotation { + defaultValue = + """ + private struct __Key_\(raw: name): SessionPropertyKey { + static let defaultValue \(typeAnnotation) = \(initializer) + } + """ + } else { + defaultValue = + """ + private struct __Key_\(raw: name): SessionPropertyKey { + static let defaultValue = \(initializer) + } + """ + } + return [defaultValue] } private static func propertyName(in declaration: some DeclSyntaxProtocol) -> String? { diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index e8421891..b4a4011d 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -6,6 +6,14 @@ import Testing extension SessionPropertyValues { @SessionPropertyEntry var revision = 0 + @SessionPropertyEntry var optionalSelection: String? = nil + @SessionPropertyEntry var items: [Int] = [] + @SessionPropertyEntry var mode: SessionPropertyMode = .first +} + +enum SessionPropertyMode { + case first + case second } @Suite("Session properties") @@ -107,6 +115,35 @@ struct SessionPropertyTests { #expect(changed.withLock { $0 }) } + @Test func generatedPropertiesPreserveExplicitTypeContext() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + + #expect(session.properties.optionalSelection == nil) + #expect(session.properties.items.isEmpty) + #expect(session.properties.mode == .first) + session.properties.optionalSelection = "selected" + session.properties.items = [1, 2] + session.properties.mode = .second + #expect(session.properties.optionalSelection == "selected") + #expect(session.properties.items == [1, 2]) + #expect(session.properties.mode == .second) + } + + @Test func historyObservationTracksSessionTranscriptChanges() async throws { + let session = LanguageModelSession(model: CompletingSessionPropertyModel()) + let changed = Locked(false) + + withObservationTracking { + _ = session.properties.history + } onChange: { + changed.withLock { $0 = true } + } + _ = try await session.respond(to: "Observe history") + + #expect(changed.withLock { $0 }) + #expect(session.properties.history.count == 2) + } + @Test func propertiesAndHistoryAreIsolatedBetweenSessions() { let first = LanguageModelSession(model: SessionPropertyFixtureModel()) let second = LanguageModelSession(model: SessionPropertyFixtureModel()) @@ -203,3 +240,28 @@ private struct SessionPropertyFixtureModel: LanguageModel { fatalError("SessionPropertyFixtureModel does not generate responses") } } + +private struct CompletingSessionPropertyModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let raw = GeneratedContent("Done") + return .init(content: try Content(raw), rawContent: raw, transcriptEntries: []) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + fatalError("This fixture only supports non-streaming responses") + } +} From cdeed51bcb34b465ecde2134c0647948df739e39 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Thu, 15 Oct 2026 09:07:21 +0800 Subject: [PATCH 26/53] Fix dynamic profile callback concurrency --- Sources/AnyLanguageModel/DynamicProfile.swift | 6 +- .../LanguageModelSession.swift | 35 +++++++---- .../DynamicProfileTests.swift | 61 +++++++++++++++++++ 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 111159fb..d2199126 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -202,7 +202,7 @@ extension LanguageModelSession.DynamicProfile { } public func historyTransform( - _ transform: @escaping ([Transcript.Entry]) -> [Transcript.Entry] + _ transform: @Sendable @escaping ([Transcript.Entry]) -> [Transcript.Entry] ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .historyTransform(transform)) } @@ -314,7 +314,7 @@ struct ResolvedDynamicProfile: @unchecked Sendable { var hasToolCallingMode = false var transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? var hasTranscriptErrorHandlingPolicy = false - var historyTransforms: [([Transcript.Entry]) -> [Transcript.Entry]] = [] + var historyTransforms: [@Sendable ([Transcript.Entry]) -> [Transcript.Entry]] = [] var onPrompt: [@Sendable (Transcript.Prompt) async throws -> Void] = [] var onResponse: [@Sendable (Transcript.Response) async throws -> Void] = [] var onReasoning: [@Sendable (Transcript.Reasoning) async throws -> Void] = [] @@ -394,7 +394,7 @@ private enum BuiltinDynamicProfileModifier: @unchecked Sendable { case maximumResponseTokens(Int?) case reasoningLevel(ContextOptions.ReasoningLevel?) case toolCallingMode(GenerationOptions.ToolCallingMode?) - case historyTransform(([Transcript.Entry]) -> [Transcript.Entry]) + case historyTransform(@Sendable ([Transcript.Entry]) -> [Transcript.Entry]) case transcriptErrorHandlingPolicy(TranscriptErrorHandlingPolicy?) case onPrompt(@Sendable (Transcript.Prompt) async throws -> Void) case onResponse(@Sendable (Transcript.Response) async throws -> Void) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 50bc9cd9..96cf5e5d 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -428,23 +428,32 @@ public final class LanguageModelSession: @unchecked Sendable { ) async throws { guard let profile = requestContext.profile else { return } for entry in entries { - if requestContext.callbackState.wasDispatched(entry.id) { - continue - } - let history = requestContext.canonicalTranscript + currentRoundEntries switch entry { case .reasoning(let reasoning): - try await withSessionProperties(history: history, isHistoryWritable: true) { - for action in profile.onReasoning { try await action(reasoning) } + guard requestContext.callbackState.reserve(entry.id) else { continue } + do { + let history = requestContext.canonicalTranscript + currentRoundEntries + try await withSessionProperties(history: history, isHistoryWritable: true) { + for action in profile.onReasoning { try await action(reasoning) } + } + } catch { + requestContext.callbackState.release(entry.id) + throw error } case .response(let response): - try await withSessionProperties(history: history, isHistoryWritable: true) { - for action in profile.onResponse { try await action(response) } + guard requestContext.callbackState.reserve(entry.id) else { continue } + do { + let history = requestContext.canonicalTranscript + currentRoundEntries + try await withSessionProperties(history: history, isHistoryWritable: true) { + for action in profile.onResponse { try await action(response) } + } + } catch { + requestContext.callbackState.release(entry.id) + throw error } default: continue } - requestContext.callbackState.markDispatched(entry.id) } } @@ -2304,12 +2313,12 @@ private struct ProfiledModelResponse where Content: Generable { fileprivate final class ProfileCallbackState: @unchecked Sendable { private let dispatchedEntryIDs = Locked>([]) - func wasDispatched(_ id: String) -> Bool { - dispatchedEntryIDs.withLock { $0.contains(id) } + func reserve(_ id: String) -> Bool { + dispatchedEntryIDs.withLock { $0.insert(id).inserted } } - func markDispatched(_ id: String) { - dispatchedEntryIDs.withLock { _ = $0.insert(id) } + func release(_ id: String) { + dispatchedEntryIDs.withLock { _ = $0.remove(id) } } } diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index 4f58a076..5ad5b108 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -77,6 +77,43 @@ struct DynamicProfileTests { ) } + @Test func concurrentSnapshotsDispatchEachCallbackOnce() async throws { + let probe = ConcurrentCallbackProbe() + let model = LifecycleFixtureModel() + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Concurrent callback") } + .model(model) + .onResponse { _ in await probe.recordInvocation() } + ) + let context = try await session.resolvedRequestContext(including: [], options: .init()) + let entry = Transcript.Entry.response( + .init(assetIDs: [], segments: [.text(.init(content: "response"))]) + ) + + let first = Task { + try await session.profileDidProduceEntries( + [entry], + requestContext: context, + currentRoundEntries: [entry] + ) + } + await probe.waitUntilFirstInvocation() + let second = Task { + try await session.profileDidProduceEntries( + [entry], + requestContext: context, + currentRoundEntries: [entry] + ) + } + + try await second.value + let invocationCount = await probe.invocationCount + await probe.releaseFirstInvocation() + try await first.value + + #expect(invocationCount == 1) + } + @Test(arguments: [false, true]) func callbackErrorsPropagateWithoutCommittingAResponse(streaming: Bool) async throws { let model = LifecycleFixtureModel() @@ -444,6 +481,30 @@ private enum LifecycleFixtureError: Error { case rejected } +private actor ConcurrentCallbackProbe { + private(set) var invocationCount = 0 + private var firstInvocationWaiter: CheckedContinuation? + private var firstInvocationRelease: CheckedContinuation? + + func recordInvocation() async { + invocationCount += 1 + guard invocationCount == 1 else { return } + firstInvocationWaiter?.resume() + firstInvocationWaiter = nil + await withCheckedContinuation { firstInvocationRelease = $0 } + } + + func waitUntilFirstInvocation() async { + guard invocationCount == 0 else { return } + await withCheckedContinuation { firstInvocationWaiter = $0 } + } + + func releaseFirstInvocation() { + firstInvocationRelease?.resume() + firstInvocationRelease = nil + } +} + private final class LifecycleFixtureState: @unchecked Sendable { let useSecond = Locked(false) let events = Locked<[String]>([]) From 2f435bd2fd598c7182211b60bac6a0036f6fe498 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 16 Oct 2026 09:04:44 +0800 Subject: [PATCH 27/53] Format Gemini attachment MIME mapping --- .../Models/GeminiLanguageModel.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index cbb4cf2d..271378c5 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -835,12 +835,13 @@ private func geminiFunctionResponseData(from url: URL) throws -> GeminiInlineDat throw GeminiFunctionResponseAttachmentError.unsupportedURL(url) } - let mimeType = switch url.pathExtension.lowercased() { - case "png": "image/png" - case "jpg", "jpeg": "image/jpeg" - case "webp": "image/webp" - default: throw GeminiFunctionResponseAttachmentError.unsupportedImageType(url) - } + let mimeType = + switch url.pathExtension.lowercased() { + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "webp": "image/webp" + default: throw GeminiFunctionResponseAttachmentError.unsupportedImageType(url) + } return try GeminiInlineData( mimeType: mimeType, data: Data(contentsOf: url).base64EncodedString() From d211109334fc467309eb86b47005398bbfdb07b2 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 16 Oct 2026 09:05:28 +0800 Subject: [PATCH 28/53] Allow sampling mode without tool calling policy --- Sources/AnyLanguageModel/GenerationOptions.swift | 12 ++++++++++++ .../AnyLanguageModelTests/RequestOptionsTests.swift | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Sources/AnyLanguageModel/GenerationOptions.swift b/Sources/AnyLanguageModel/GenerationOptions.swift index f02e7fb6..b6092652 100644 --- a/Sources/AnyLanguageModel/GenerationOptions.swift +++ b/Sources/AnyLanguageModel/GenerationOptions.swift @@ -184,6 +184,18 @@ public struct GenerationOptions: Sendable, Equatable { self.toolCallingMode = nil } + public init( + samplingMode: SamplingMode?, + temperature: Double? = nil, + maximumResponseTokens: Int? = nil + ) { + self.init( + sampling: samplingMode, + temperature: temperature, + maximumResponseTokens: maximumResponseTokens + ) + } + /// Creates Foundation Models 27-style generation options. public init( samplingMode: SamplingMode? = nil, diff --git a/Tests/AnyLanguageModelTests/RequestOptionsTests.swift b/Tests/AnyLanguageModelTests/RequestOptionsTests.swift index 7822a614..ba9d9b2f 100644 --- a/Tests/AnyLanguageModelTests/RequestOptionsTests.swift +++ b/Tests/AnyLanguageModelTests/RequestOptionsTests.swift @@ -43,6 +43,19 @@ struct RequestOptionsTests { #expect(options.sampling == .random(probabilityThreshold: 0.8, seed: 9)) } + @Test func samplingModeInitializerDoesNotRequireAToolPolicy() { + let options = GenerationOptions(samplingMode: .greedy) + #expect(options.sampling == .greedy) + #expect(options.toolCallingMode == nil) + #expect(GenerationOptions(samplingMode: nil) == GenerationOptions()) + #expect( + GenerationOptions(samplingMode: .greedy, temperature: 0.4, maximumResponseTokens: 16) + == GenerationOptions(sampling: .greedy, temperature: 0.4, maximumResponseTokens: 16) + ) + #expect(GenerationOptions(temperature: 0.4).temperature == 0.4) + #expect(GenerationOptions(toolCallingMode: .required).toolCallingMode == .required) + } + @Test func transcriptRoundTripsToolCallingMode() throws { let options = GenerationOptions( samplingMode: .greedy, From 3ad28fb00d2e6b6aade9eb13227d6b4b29a94b66 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 16 Oct 2026 09:06:28 +0800 Subject: [PATCH 29/53] Keep session history bindings isolated and instruction free --- .../LanguageModelSession.swift | 4 ++ .../AnyLanguageModel/SessionProperties.swift | 7 ++- .../SessionPropertyTests.swift | 45 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 683dae8e..7f751a51 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -333,6 +333,10 @@ public final class LanguageModelSession: @unchecked Sendable { let state = Locked(State(finalTranscript)) self.state = state let setHistory: @Sendable (Transcript.HistoryView) -> Void = { history in + let history = history.filter { entry in + if case .instructions = entry { return false } + return true + } state.withLock { locked in let instructions = locked.transcript.prefix { if case .instructions = $0 { return true } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 42aa3e92..47ef8a8c 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -47,12 +47,15 @@ public final class SessionPropertyValues: @unchecked Sendable { public var history: Transcript.HistoryView { get { _accessSessionProperty(keyPath: \.history) - return SessionPropertyBinding.history ?? historyGetter() + if SessionPropertyBinding.values === self, let history = SessionPropertyBinding.history { + return history + } + return historyGetter() } set { _withSessionPropertyMutation(keyPath: \.history) { precondition( - SessionPropertyBinding.history == nil, + SessionPropertyBinding.values !== self || SessionPropertyBinding.history == nil, "Session history is read-only while dynamic instructions are active" ) historySetter(newValue) diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index b4a4011d..ebecf1ed 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -180,6 +180,51 @@ struct SessionPropertyTests { } #expect(session.transcript[1] == prompt) } + + @Test func boundHistoryOnlyAppliesToItsOwningSession() { + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + let replacement = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Replacement"))])) + let other = LanguageModelSession(model: SessionPropertyFixtureModel()) + other.properties.history = [earlier] + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: ForeignSessionHistoryInstructions(other: other, replacement: replacement) + ) + + let context = session.resolvedRequestContext() + + #expect(context.instructions?.description == "Other history count 1") + #expect(session.properties.history.isEmpty) + #expect(other.properties.history == [replacement]) + } + + @Test func replacingHistoryRejectsForeignInstructions() { + let other = LanguageModelSession(model: SessionPropertyFixtureModel(), instructions: "Foreign") + let prompt = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + other.properties.history = [prompt] + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: SessionPropertyHistoryInstructions() + ) + + session.properties.history = Array(other.transcript) + let context = session.resolvedRequestContext() + + #expect(session.transcript == Transcript(entries: [prompt])) + #expect(context.instructions?.description == "History count 1") + #expect(context.transcript.count == 2) + } +} + +private struct ForeignSessionHistoryInstructions: DynamicInstructions { + let other: LanguageModelSession + let replacement: Transcript.Entry + + var body: some DynamicInstructions { + let count = other.properties.history.count + other.properties.history = [replacement] + return Instructions("Other history count \(count)") + } } private struct SessionPropertyFixtureInstructions: DynamicInstructions { From 9b76d74cd575ef4f3e6e098f826acfbbcba0b8bd Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 16 Oct 2026 09:11:41 +0800 Subject: [PATCH 30/53] Preserve profile history and request callback identity --- Sources/AnyLanguageModel/DynamicProfile.swift | 39 ++-- .../LanguageModelSession.swift | 137 ++++++++----- .../Models/SystemLanguageModel.swift | 4 +- .../AnyLanguageModel/SessionProperties.swift | 7 +- .../DynamicProfileTests.swift | 189 ++++++++++++++++++ 5 files changed, 305 insertions(+), 71 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index d2199126..9d00f1ef 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -326,7 +326,12 @@ struct ResolvedDynamicProfile: @unchecked Sendable { extension LanguageModelSession.Profile: PrimitiveDynamicProfile { fileprivate func resolve() -> ResolvedDynamicProfile { - let dynamic = dynamicInstructions.resolveForRequest() + let history = SessionPropertyBinding.history.map { + SessionHistoryBinding($0.entries, isWritable: false) + } + let dynamic = SessionPropertyBinding.$history.withValue(history) { + dynamicInstructions.resolveForRequest() + } return ResolvedDynamicProfile( identity: identity, model: nil, @@ -474,14 +479,15 @@ private func resolveDynamicProfile( actor ProfileLifecycle { private var activeProfile: ResolvedDynamicProfile? - private var lastPromptID: String? private var pendingPreparation: (id: Int, task: Task)? private var nextPreparationID = 0 func prepare( _ profile: ResolvedDynamicProfile, properties: SessionPropertyValues, - history: [Transcript.Entry] + history: [Transcript.Entry], + prompt: Transcript.Prompt?, + callbackState: ProfileCallbackState ) async throws { let previous = pendingPreparation?.task nextPreparationID += 1 @@ -491,7 +497,9 @@ actor ProfileLifecycle { try await self.performPreparation( profile, properties: properties, - history: history + history: history, + prompt: prompt, + callbackState: callbackState ) } pendingPreparation = (id, task) @@ -507,7 +515,9 @@ actor ProfileLifecycle { private func performPreparation( _ profile: ResolvedDynamicProfile, properties: SessionPropertyValues, - history: [Transcript.Entry] + history: [Transcript.Entry], + prompt: Transcript.Prompt?, + callbackState: ProfileCallbackState ) async throws { if activeProfile?.identity != profile.identity { if let previous = activeProfile { @@ -523,18 +533,15 @@ actor ProfileLifecycle { activeProfile = profile } - guard - let prompt = history.reversed().compactMap({ entry -> Transcript.Prompt? in - if case .prompt(let prompt) = entry { return prompt } - return nil - }).first, - prompt.id != lastPromptID - else { return } - - try await withBindings(properties: properties, history: history) { - for action in profile.onPrompt { try await action(prompt) } + guard let prompt, callbackState.reserve(prompt.id) else { return } + do { + try await withBindings(properties: properties, history: history) { + for action in profile.onPrompt { try await action(prompt) } + } + } catch { + callbackState.release(prompt.id) + throw error } - lastPromptID = prompt.id } private func withBindings( diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 8e976ddc..0e63ddf7 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -206,6 +206,21 @@ public final class LanguageModelSession: @unchecked Sendable { callbackState: context.callbackState ) } + + fileprivate func withCanonicalTranscript(_ entries: [Transcript.Entry]) -> RequestContext { + RequestContext( + transcript: transcript, + instructions: instructions, + tools: tools, + model: model, + options: options, + contextOptions: contextOptions, + profile: profile, + canonicalTranscript: entries, + transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy, + callbackState: callbackState + ) + } } /// Returns the transcript, instructions, and tools for the next model request. @@ -263,7 +278,14 @@ public final class LanguageModelSession: @unchecked Sendable { try await profileLifecycle.prepare( profile, properties: properties, - history: context.canonicalTranscript + history: context.canonicalTranscript, + prompt: prepared?.session === self + ? prepared?.prompt + : context.canonicalTranscript.reversed().compactMap { entry in + if case .prompt(let prompt) = entry { return prompt } + return nil + }.first, + callbackState: context.callbackState ) } if let prepared, prepared.session === self { @@ -859,9 +881,10 @@ public final class LanguageModelSession: @unchecked Sendable { lastSnapshot.transcriptEntries.map(\.id) + [responseEntry.id] ) if let prepared = profileTracker?.prepared { + let context = prepared.context.withCanonicalTranscript(Array(session.transcript)) try await session.profileDidProduceEntries( Array(lastSnapshot.transcriptEntries) + [responseEntry], - requestContext: prepared.context, + requestContext: context, currentRoundEntries: [] ) } @@ -1110,8 +1133,8 @@ public final class LanguageModelSession: @unchecked Sendable { responseFormat: type == String.self ? nil : .init(type: type), options: options, profileTracker: profileTracker - ) { - try await profiledResponse(options: options, tracker: profileTracker) { + ) { requestPrompt in + try await profiledResponse(prompt: requestPrompt, options: options, tracker: profileTracker) { model, resolvedOptions, contextOptions in @@ -1128,6 +1151,7 @@ public final class LanguageModelSession: @unchecked Sendable { } nonisolated private func profiledResponse( + prompt: Transcript.Prompt, options: GenerationOptions, tracker: PreparedProfileRequestTracker?, generate: ( @@ -1146,6 +1170,7 @@ public final class LanguageModelSession: @unchecked Sendable { let prepared = PreparedProfileRequest( session: self, context: context, + prompt: prompt, callSiteOptions: options ) tracker?.prepared = prepared @@ -1154,7 +1179,9 @@ public final class LanguageModelSession: @unchecked Sendable { try await profileLifecycle.prepare( profile, properties: properties, - history: context.canonicalTranscript + history: context.canonicalTranscript, + prompt: prepared.prompt, + callbackState: context.callbackState ) } return try await generate(context.model, context.options, context.contextOptions) @@ -1167,23 +1194,22 @@ public final class LanguageModelSession: @unchecked Sendable { responseFormat: Transcript.ResponseFormat?, options: GenerationOptions, profileTracker: PreparedProfileRequestTracker?, - generate: () async throws -> ProfiledModelResponse + generate: (Transcript.Prompt) async throws -> ProfiledModelResponse ) async throws -> Response { try await wrapRespond(profileTracker: profileTracker) { promptID in // Add prompt to transcript - let promptEntry = Transcript.Entry.prompt( - Transcript.Prompt( - id: promptID, - segments: try prompt.makeTranscriptSegments(occurrenceID: promptID), - options: options, - responseFormat: responseFormat - ) + let requestPrompt = Transcript.Prompt( + id: promptID, + segments: try prompt.makeTranscriptSegments(occurrenceID: promptID), + options: options, + responseFormat: responseFormat ) + let promptEntry = Transcript.Entry.prompt(requestPrompt) withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } - let profiled = try await generate() + let profiled = try await generate(requestPrompt) let response = Self.representingNilExplicitly( in: profiled.response, responseFormat: responseFormat @@ -1222,7 +1248,7 @@ public final class LanguageModelSession: @unchecked Sendable { entry responseEntry: Transcript.Entry, prepared: PreparedProfileRequest? ) async throws where Content: Generable { - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { lockedState in lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) @@ -1230,15 +1256,16 @@ public final class LanguageModelSession: @unchecked Sendable { } guard let prepared else { return } + let context = prepared.context.withCanonicalTranscript(Array(transcript)) do { try await profileDidProduceEntries( Array(response.transcriptEntries) + [responseEntry], - requestContext: prepared.context, + requestContext: context, currentRoundEntries: [] ) } catch { let ids = Set(response.transcriptEntries.map(\.id) + [responseEntry.id]) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { lockedState in lockedState.transcript = Transcript( entries: lockedState.transcript.filter { !ids.contains($0.id) } @@ -1263,14 +1290,13 @@ public final class LanguageModelSession: @unchecked Sendable { } catch { return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) } - let promptEntry = Transcript.Entry.prompt( - Transcript.Prompt( - id: promptID, - segments: segments, - options: options, - responseFormat: type == String.self ? nil : .init(type: type) - ) + let requestPrompt = Transcript.Prompt( + id: promptID, + segments: segments, + options: options, + responseFormat: type == String.self ? nil : .init(type: type) ) + let promptEntry = Transcript.Entry.prompt(requestPrompt) withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1282,6 +1308,7 @@ public final class LanguageModelSession: @unchecked Sendable { profileTracker = tracker upstream = profileDispatchedStream( prompt: prompt, + requestPrompt: requestPrompt, type: type, includeSchemaInPrompt: includeSchemaInPrompt, options: options, @@ -1306,12 +1333,13 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func profileDispatchedStream( prompt: Prompt, + requestPrompt: Transcript.Prompt, type: Content.Type, includeSchemaInPrompt: Bool, options: GenerationOptions, tracker: PreparedProfileRequestTracker ) -> ResponseStream where Content: Generable { - profileDispatchedStream(options: options, tracker: tracker) { + profileDispatchedStream(requestPrompt: requestPrompt, options: options, tracker: tracker) { model, resolvedOptions, contextOptions in @@ -1327,6 +1355,7 @@ public final class LanguageModelSession: @unchecked Sendable { } nonisolated private func profileDispatchedStream( + requestPrompt: Transcript.Prompt, options: GenerationOptions, tracker: PreparedProfileRequestTracker, generate: @@ -1346,6 +1375,7 @@ public final class LanguageModelSession: @unchecked Sendable { let prepared = PreparedProfileRequest( session: self, context: context, + prompt: requestPrompt, callSiteOptions: options ) tracker.prepared = prepared @@ -1354,7 +1384,9 @@ public final class LanguageModelSession: @unchecked Sendable { try await self.profileLifecycle.prepare( profile, properties: self.properties, - history: context.canonicalTranscript + history: context.canonicalTranscript, + prompt: prepared.prompt, + callbackState: context.callbackState ) } let upstream = generate( @@ -1454,8 +1486,8 @@ extension LanguageModelSession { responseFormat: .init(schema: schema), options: options, profileTracker: profileTracker - ) { - try await profiledResponse(options: options, tracker: profileTracker) { + ) { requestPrompt in + try await profiledResponse(prompt: requestPrompt, options: options, tracker: profileTracker) { model, resolvedOptions, contextOptions in @@ -1520,14 +1552,13 @@ extension LanguageModelSession { } catch { return ResponseStream(stream: AsyncThrowingStream { $0.finish(throwing: error) }) } - let promptEntry = Transcript.Entry.prompt( - Transcript.Prompt( - id: promptID, - segments: segments, - options: options, - responseFormat: .init(schema: schema) - ) + let requestPrompt = Transcript.Prompt( + id: promptID, + segments: segments, + options: options, + responseFormat: .init(schema: schema) ) + let promptEntry = Transcript.Entry.prompt(requestPrompt) withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1537,7 +1568,7 @@ extension LanguageModelSession { if dynamicProfile != nil { let tracker = PreparedProfileRequestTracker() profileTracker = tracker - upstream = profileDispatchedStream(options: options, tracker: tracker) { + upstream = profileDispatchedStream(requestPrompt: requestPrompt, options: options, tracker: tracker) { model, resolvedOptions, contextOptions in @@ -1743,14 +1774,13 @@ extension LanguageModelSession { segments.append(contentsOf: images.map { .image($0) }) // Add prompt to transcript - let promptEntry = Transcript.Entry.prompt( - Transcript.Prompt( - id: promptID, - segments: segments, - options: options, - responseFormat: type == String.self ? nil : .init(type: type) - ) + let requestPrompt = Transcript.Prompt( + id: promptID, + segments: segments, + options: options, + responseFormat: type == String.self ? nil : .init(type: type) ) + let promptEntry = Transcript.Entry.prompt(requestPrompt) withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1758,7 +1788,8 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - let profiled = try await profiledResponse(options: options, tracker: profileTracker) { + let profiled = try await profiledResponse(prompt: requestPrompt, options: options, tracker: profileTracker) + { model, resolvedOptions, contextOptions in @@ -1881,13 +1912,12 @@ extension LanguageModelSession { segments.append(contentsOf: images.map { .image($0) }) // Create prompt entry that will be added when stream starts - let promptEntry = Transcript.Entry.prompt( - Transcript.Prompt( - segments: segments, - options: options, - responseFormat: type == String.self ? nil : .init(type: type) - ) + let requestPrompt = Transcript.Prompt( + segments: segments, + options: options, + responseFormat: type == String.self ? nil : .init(type: type) ) + let promptEntry = Transcript.Entry.prompt(requestPrompt) withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1900,7 +1930,7 @@ extension LanguageModelSession { if dynamicProfile != nil { let tracker = PreparedProfileRequestTracker() profileTracker = tracker - upstream = profileDispatchedStream(options: options, tracker: tracker) { + upstream = profileDispatchedStream(requestPrompt: requestPrompt, options: options, tracker: tracker) { model, resolvedOptions, contextOptions in @@ -2314,7 +2344,7 @@ private struct ProfiledModelResponse where Content: Generable { let prepared: PreparedProfileRequest? } -fileprivate final class ProfileCallbackState: @unchecked Sendable { +final class ProfileCallbackState: @unchecked Sendable { private let dispatchedEntryIDs = Locked>([]) func reserve(_ id: String) -> Bool { @@ -2328,15 +2358,18 @@ fileprivate final class ProfileCallbackState: @unchecked Sendable { private final class PreparedProfileRequest: @unchecked Sendable { let session: LanguageModelSession + let prompt: Transcript.Prompt let callSiteOptions: GenerationOptions private let state: Locked init( session: LanguageModelSession, context: LanguageModelSession.RequestContext, + prompt: Transcript.Prompt, callSiteOptions: GenerationOptions ) { self.session = session + self.prompt = prompt self.callSiteOptions = callSiteOptions state = Locked(State(context: context)) } diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 194ab949..86e1571e 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -398,12 +398,13 @@ session: LanguageModelSession, prompt: Prompt ) -> FoundationModels.LanguageModelSession { + let requestContext = session.resolvedRequestContext() if session.usesDynamicInstructions { // Foundation Models evaluates the dynamic instructions itself, // so the history leaves out the instructions entry. let history = fmTranscriptDroppingDuplicatePrompt( Transcript( - entries: session.transcript.filter { entry in + entries: requestContext.transcript.filter { entry in if case .instructions = entry { return false } return true } @@ -417,7 +418,6 @@ ) } - let requestContext = session.resolvedRequestContext() return FoundationModels.LanguageModelSession( model: model, tools: requestContext.tools.toFoundationModels(), diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index edbe7a75..c56da70e 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -116,7 +116,12 @@ final class SessionHistoryBinding: @unchecked Sendable { let isWritable: Bool init(_ entries: Transcript.HistoryView, isWritable: Bool) { - storage = Locked(entries) + storage = Locked( + entries.filter { entry in + if case .instructions = entry { return false } + return true + } + ) self.isWritable = isWritable } diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index 5ad5b108..2dc45a1d 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -1,6 +1,11 @@ import Foundation +import Observation import Testing +#if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + import class FoundationModels.SystemLanguageModel +#endif + @_spi(Compatibility) @testable import AnyLanguageModel extension SessionPropertyValues { @@ -114,6 +119,153 @@ struct DynamicProfileTests { #expect(invocationCount == 1) } + @Test(arguments: [false, true]) + func completionCallbacksSeeAndPreserveCommittedHistory(streaming: Bool) async throws { + let histories = Locked<[[Transcript.Entry]]>([]) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Completion history") } + .model(LifecycleFixtureModel()) + .onResponse { _ in + let properties = try #require(SessionPropertyBinding.values) + let history = properties.history + histories.withLock { $0.append(history) } + properties.history = history + } + ) + + if streaming { + _ = try await session.streamResponse(to: "Complete").collect() + } else { + _ = try await session.respond(to: "Complete") + } + + let history = try #require(histories.withLock { $0.first }) + #expect(history == Array(session.transcript)) + #expect(history.contains { if case .response = $0 { true } else { false } }) + #expect(history.contains { if case .reasoning = $0 { true } else { false } }) + } + + @Test(arguments: [false, true]) + func historyObserversSeeCompletionAndCallbackRollback(streaming: Bool) async throws { + let completionObserved = Locked(false) + let rollbackObserved = Locked(false) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Observe completion") } + .model(LifecycleFixtureModel()) + .onPrompt { _ in + let properties = try #require(SessionPropertyBinding.values) + withObservationTracking { + _ = properties.history + } onChange: { + completionObserved.withLock { $0 = true } + } + } + .onResponse { _ in + let properties = try #require(SessionPropertyBinding.values) + withObservationTracking { + _ = properties.history + } onChange: { + rollbackObserved.withLock { $0 = true } + } + throw LifecycleFixtureError.rejected + } + ) + + await #expect(throws: LifecycleFixtureError.self) { + if streaming { + _ = try await session.streamResponse(to: "Roll back").collect() + } else { + _ = try await session.respond(to: "Roll back") + } + } + + #expect(completionObserved.withLock { $0 }) + #expect(rollbackObserved.withLock { $0 }) + #expect(session.transcript.count == 1) + } + + @Test func nestedInstructionsReceiveReadOnlyHistory() { + let writability = Locked<[Bool]>([]) + let session = LanguageModelSession(profile: HistoryBindingProfile(writability: writability)) + + _ = session.resolvedRequestContext() + + #expect(writability.withLock { $0 } == [true, false]) + } + + @Test func toolHistoryExcludesStaticInstructions() async { + let session = LanguageModelSession(model: LifecycleFixtureModel(), instructions: "Static") + let context = session.resolvedRequestContext() + let history = await session.withProfileToolExecution(requestContext: context, currentRoundEntries: []) { + session.properties.history + } + + #expect(history.isEmpty) + #expect(session.transcript.count == 1) + } + + @Test(arguments: [false, true]) + func promptCallbackUsesTheActualRequestAfterHistoryMutation(streaming: Bool) async throws { + let prompts = Locked<[String]>([]) + let session = LanguageModelSession(profile: PromptRemovingProfile(prompts: prompts)) + + if streaming { + _ = try await session.streamResponse(to: "Actual request").collect() + } else { + _ = try await session.respond(to: "Actual request") + } + + #expect(prompts.withLock { $0 } == ["Actual request"]) + } + + @Test func concurrentStreamsKeepTheirOwnPromptCallbacks() async throws { + let prompts = Locked<[String]>([]) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Concurrent prompts") } + .model(LifecycleFixtureModel()) + .onPrompt { (prompt: Transcript.Prompt) in + let text = prompt.segments.compactMap { segment in + if case .text(let text) = segment { return text.content } + return nil + }.joined() + prompts.withLock { $0.append(text) } + } + ) + let first = session.streamResponse(to: "First") + let second = session.streamResponse(to: "Second") + async let firstResponse = first.collect() + async let secondResponse = second.collect() + _ = try await (firstResponse, secondResponse) + + #expect(prompts.withLock { $0.sorted() } == ["First", "Second"]) + } + + #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + @Test func nativeSessionUsesProjectedHistory() throws { + guard #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) else { return } + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + let prompt = Prompt("Current") + let current = Transcript.Entry.prompt( + .init(id: "current", segments: try prompt.makeTranscriptSegments(occurrenceID: "current")) + ) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Projected") } + .model(LifecycleFixtureModel()) + .historyTransform { $0.filter { $0.id != earlier.id } }, + history: [earlier, current] + ) + + let native = makeFoundationModelsSession( + model: FoundationModels.SystemLanguageModel.default, + session: session, + prompt: prompt + ) + + #expect(!native.transcript.contains { if case .prompt = $0 { true } else { false } }) + #expect(Array(session.transcript) == [earlier, current]) + } + #endif + @Test(arguments: [false, true]) func callbackErrorsPropagateWithoutCommittingAResponse(streaming: Bool) async throws { let model = LifecycleFixtureModel() @@ -481,6 +633,43 @@ private enum LifecycleFixtureError: Error { case rejected } +private struct HistoryBindingProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let writability: Locked<[Bool]> + + var body: some LanguageModelSession.DynamicProfile { + writability.withLock { $0.append(SessionPropertyBinding.history?.isWritable ?? false) } + return LanguageModelSession.Profile { HistoryBindingInstructions(writability: writability) } + .model(LifecycleFixtureModel()) + } +} + +private struct HistoryBindingInstructions: DynamicInstructions, @unchecked Sendable { + let writability: Locked<[Bool]> + + var body: some DynamicInstructions { + writability.withLock { $0.append(SessionPropertyBinding.history?.isWritable ?? true) } + return Instructions("Read-only instructions") + } +} + +private struct PromptRemovingProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { + let prompts: Locked<[String]> + @SessionProperty(\.history) private var history + + var body: some LanguageModelSession.DynamicProfile { + history = [] + return LanguageModelSession.Profile { Instructions("Removed prompt") } + .model(LifecycleFixtureModel()) + .onPrompt { (prompt: Transcript.Prompt) in + let text = prompt.segments.compactMap { segment in + if case .text(let text) = segment { return text.content } + return nil + }.joined() + prompts.withLock { $0.append(text) } + } + } +} + private actor ConcurrentCallbackProbe { private(set) var invocationCount = 0 private var firstInvocationWaiter: CheckedContinuation? From 4d9cd915d377ad1bb02d040548a2e7c67527a4f6 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 09:00:25 +0800 Subject: [PATCH 31/53] Inline local Anthropic image attachments during transport --- .../Models/AnthropicLanguageModel.swift | 44 +++++++++++++--- .../ProviderUsageTests.swift | 51 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 6a618a79..2fae7d71 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -716,7 +716,7 @@ public struct AnthropicLanguageModel: LanguageModel { .toolResult( .init( toolUseId: invocation.call.id, - content: convertSegmentsToAnthropicContent(invocation.output.segments) + content: try convertSegmentsToAnthropicContent(invocation.output.segments) ) ) ) @@ -1029,14 +1029,14 @@ extension Transcript { messages.append( .init( role: .user, - content: convertSegmentsToAnthropicContent(instructions.segments) + content: try convertSegmentsToAnthropicContent(instructions.segments) ) ) case .prompt(let prompt): messages.append( .init( role: .user, - content: convertSegmentsToAnthropicContent(prompt.segments) + content: try convertSegmentsToAnthropicContent(prompt.segments) ) ) case .reasoning(let reasoning): @@ -1063,7 +1063,7 @@ extension Transcript { case .response(let response): // Anthropic rejects text blocks without non-whitespace text, // such as the empty response of a turn that only called tools. - let content = convertSegmentsToAnthropicContent(response.segments).filter { block in + let content = try convertSegmentsToAnthropicContent(response.segments).filter { block in guard case .text(let text) = block else { return true } return !text.text.allSatisfy(\.isWhitespace) } @@ -1091,7 +1091,7 @@ extension Transcript { .toolResult( AnthropicToolResult( toolUseId: toolOutput.id, - content: convertSegmentsToAnthropicContent(toolOutput.segments) + content: try convertSegmentsToAnthropicContent(toolOutput.segments) ) ) ] @@ -1244,7 +1244,7 @@ private extension AnthropicThinking { } } -private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) -> [AnthropicContent] { +private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) throws -> [AnthropicContent] { var blocks: [AnthropicContent] = [] blocks.reserveCapacity(segments.count) for segment in segments { @@ -1256,7 +1256,26 @@ private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) case .image(let img): switch img.source { case .url(let url): - blocks.append(.image(AnthropicImage(url: url.absoluteString))) + if url.isFileURL { + let mimeType: String + switch url.pathExtension.lowercased() { + case "jpg", "jpeg": mimeType = "image/jpeg" + case "png": mimeType = "image/png" + case "gif": mimeType = "image/gif" + case "webp": mimeType = "image/webp" + default: throw AnthropicImageAttachmentError.unsupportedImageType(url) + } + blocks.append( + .image( + AnthropicImage( + base64Data: try Data(contentsOf: url).base64EncodedString(), + mimeType: mimeType + ) + ) + ) + } else { + blocks.append(.image(AnthropicImage(url: url.absoluteString))) + } case .data(let data, let mimeType): blocks.append(.image(AnthropicImage(base64Data: data.base64EncodedString(), mimeType: mimeType))) } @@ -1265,6 +1284,17 @@ private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) return blocks } +private enum AnthropicImageAttachmentError: LocalizedError { + case unsupportedImageType(URL) + + var errorDescription: String? { + switch self { + case .unsupportedImageType(let url): + "Anthropic image attachments support PNG, JPEG, GIF, and WebP files; cannot determine a supported MIME type for \(url.lastPathComponent)." + } + } +} + private struct AnthropicToolUse: Codable, Sendable { let type: String let id: String diff --git a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift index c4331da1..a2f9cc7b 100644 --- a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift @@ -482,6 +482,57 @@ import Testing #expect(UsageURLProtocol.recordedBodies.count == 2) } + @Test(arguments: [false, true]) + func anthropicInlinesLocalToolImagesAndReplaysThem(_ streaming: Bool) async throws { + let provider = Provider.anthropic + UsageURLProtocol.reset() + UsageURLProtocol.enqueue( + json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true)) + ) + if streaming { + UsageURLProtocol.enqueue(json: try provider.stream()) + } + UsageURLProtocol.enqueue(json: try Self.json(provider.response())) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("png") + try testImageData.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let session = provider.makeSession(tools: [LocalImageWeatherTool(url: url)]) + + if streaming { + _ = try await session.streamResponse(to: "Weather?").collect() + } else { + _ = try await session.respond(to: "Weather?") + } + _ = try await session.respond(to: "Review the image") + + #expect(UsageURLProtocol.recordedBodies.count == (streaming ? 3 : 2)) + for body in UsageURLProtocol.recordedBodies.dropFirst() { + let request = try JSONDecoder().decode([String: JSONValue].self, from: body) + let messages = try #require(request["messages"]?.arrayValue) + let results = messages.flatMap { $0.objectValue?["content"]?.arrayValue ?? [] } + .filter { $0.objectValue?["type"] == .string("tool_result") } + let content = try #require(results.first?.objectValue?["content"]?.arrayValue) + let image = try #require(content.first { $0.objectValue?["type"] == .string("image") }) + let source = try #require(image.objectValue?["source"]?.objectValue) + #expect(source["type"] == .string("base64")) + #expect(source["media_type"] == .string("image/png")) + #expect(source["data"] == .string(testImageData.base64EncodedString())) + #expect(source["url"] == nil) + } + } + + private struct LocalImageWeatherTool: Tool { + let name = "getWeather" + let description = "Get weather as an image" + let url: URL + + func call(arguments: WeatherTool.Arguments) async throws -> Prompt { + Prompt { Attachment(imageURL: url) } + } + } + @Test(arguments: Provider.allCases) func stoppedStreamedTools(_ provider: Provider) async throws { UsageURLProtocol.reset() From 2c5c4889e5ed7428deb1aafcc4be580a4e843b6e Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 09:01:50 +0800 Subject: [PATCH 32/53] Invalidate stale history commits and evaluate property defaults outside the lock --- .../LanguageModelSession.swift | 10 +- .../AnyLanguageModel/SessionProperties.swift | 11 +- .../SessionPropertyTests.swift | 125 ++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 7f751a51..ab14a6c5 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -530,9 +530,10 @@ public final class LanguageModelSession: @unchecked Sendable { ) ) session.withTranscriptMutation { - session.state.withLock { - $0.transcript.append(contentsOf: lastSnapshot.transcriptEntries) - $0.transcript.append(responseEntry) + session.state.withLock { state in + guard state.transcript.contains(where: { $0.id == promptEntry.id }) else { return } + state.transcript.append(contentsOf: lastSnapshot.transcriptEntries) + state.transcript.append(responseEntry) } } session.endResponding() @@ -540,6 +541,7 @@ public final class LanguageModelSession: @unchecked Sendable { } catch { session.withTranscriptMutation { session.state.withLock { state in + guard state.transcript.contains(where: { $0.id == promptEntry.id }) else { return } if state.transcriptErrorHandlingPolicy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) } else if state.transcriptErrorHandlingPolicy == .revertTranscript { @@ -819,6 +821,7 @@ public final class LanguageModelSession: @unchecked Sendable { // Add tool entries and response to transcript withTranscriptMutation { state.withLock { lockedState in + guard lockedState.transcript.contains(where: { $0.id == promptID }) else { return } lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) } @@ -1228,6 +1231,7 @@ extension LanguageModelSession { // Add tool entries and response to transcript withTranscriptMutation { state.withLock { lockedState in + guard lockedState.transcript.contains(where: { $0.id == promptID }) else { return } lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 47ef8a8c..b7b968d2 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -65,12 +65,15 @@ public final class SessionPropertyValues: @unchecked Sendable { public subscript(key: Key.Type) -> Key.Value where Key: SessionPropertyKey { get { - customValues.withLock { values in - if let value = values[ObjectIdentifier(key)] as? Key.Value { return value } - let value = Key.defaultValue - values[ObjectIdentifier(key)] = value + if let value = customValues.withLock({ $0[ObjectIdentifier(key)] as? Key.Value }) { return value } + let defaultValue = Key.defaultValue + return customValues.withLock { values in + if let value = values[ObjectIdentifier(key)] as? Key.Value { return value } + values[ObjectIdentifier(key)] = defaultValue + return defaultValue + } } set { customValues.withLock { values in diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index ebecf1ed..00311fdc 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -18,6 +18,64 @@ enum SessionPropertyMode { @Suite("Session properties") struct SessionPropertyTests { + @Test func propertyDefaultsCanReadAnotherProperty() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + SessionPropertyBinding.$values.withValue(session.properties) { + #expect(session.properties[DerivedPropertyKey.self] == 42) + session.properties[BasePropertyKey.self] = 7 + #expect(session.properties[DerivedPropertyKey.self] == 42) + } + } + + @Test(arguments: [false, true], [false, true]) + func replacingHistoryDiscardsStaleGenerationCommits(streaming: Bool, images: Bool) async throws { + let (started, start) = AsyncStream.makeStream() + let (resume, resumeContinuation) = AsyncStream.makeStream() + let session = LanguageModelSession( + model: PausedHistoryModel(started: start, resume: resume), + instructions: "Keep instructions" + ) + let request = Task { + if streaming { + let stream = + images + ? session.streamResponse(to: "Old request", images: []) + : session.streamResponse(to: "Old request") + return try await stream.collect().content + } + let response = + try await images + ? session.respond(to: "Old request", images: []) + : session.respond(to: "Old request") + return response.content + } + for await _ in started.prefix(1) {} + let replacement = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "New conversation"))])) + session.properties.history = [replacement] + resumeContinuation.yield() + + #expect(try await request.value == "Done") + #expect(session.properties.history == [replacement]) + #expect(session.transcript.count == 2) + #expect(!session.isResponding) + } + + @Test func replacingHistoryDiscardsStaleStreamingFailureCheckpoint() async { + let (started, start) = AsyncStream.makeStream() + let (resume, resumeContinuation) = AsyncStream.makeStream() + let session = LanguageModelSession(model: PausedHistoryModel(started: start, resume: resume, fail: true)) + session.transcriptErrorHandlingPolicy = .preserveTranscript + let request = Task { try await session.streamResponse(to: "Old request").collect() } + for await _ in started.prefix(1) {} + let replacement = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "New conversation"))])) + session.properties.history = [replacement] + resumeContinuation.yield() + + await #expect(throws: PausedHistoryError.self) { try await request.value } + #expect(session.properties.history == [replacement]) + #expect(!session.isResponding) + } + @Test func dynamicInstructionsReadAndUpdateSessionProperties() { let session = LanguageModelSession( model: SessionPropertyFixtureModel(), @@ -216,6 +274,73 @@ struct SessionPropertyTests { } } +private enum BasePropertyKey: SessionPropertyKey { + static let defaultValue = 42 +} + +private enum DerivedPropertyKey: SessionPropertyKey { + static var defaultValue: Int { + SessionPropertyBinding.values![BasePropertyKey.self] + } +} + +private enum PausedHistoryError: Error { case failed } + +private struct PausedHistoryModel: LanguageModel { + typealias UnavailableReason = Never + let started: AsyncStream.Continuation + let resume: AsyncStream + var fail = false + + private var entries: [Transcript.Entry] { + [.reasoning(.init(segments: [.text(.init(content: "Old reasoning"))]))] + } + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + started.yield() + for await _ in resume.prefix(1) {} + let raw = GeneratedContent("Done") + return .init(content: try Content(raw), rawContent: raw, transcriptEntries: entries[...]) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + .init( + stream: AsyncThrowingStream { continuation in + Task { + do { + let raw = GeneratedContent("Done") + continuation.yield( + .init( + content: try Content.PartiallyGenerated(raw), + rawContent: raw, + transcriptEntries: entries[...] + ) + ) + started.yield() + for await _ in resume.prefix(1) {} + if fail { throw PausedHistoryError.failed } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + ) + } +} + private struct ForeignSessionHistoryInstructions: DynamicInstructions { let other: LanguageModelSession let replacement: Transcript.Entry From d9e5f31f545299fcfd395def4586b4a2dea6a1c9 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 09:05:46 +0800 Subject: [PATCH 33/53] Preserve profile lifecycle identity and evolving callback history --- Sources/AnyLanguageModel/DynamicProfile.swift | 11 +- .../LanguageModelSession.swift | 28 ++- .../Models/SystemLanguageModel.swift | 12 +- .../DynamicProfileTests.swift | 185 ++++++++++++++++++ 4 files changed, 223 insertions(+), 13 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 9d00f1ef..b996fca5 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -38,7 +38,12 @@ extension LanguageModelSession { fileprivate let resolveValue: () -> ResolvedDynamicProfile public init(_ dynamicProfile: any DynamicProfile) { - resolveValue = { resolveDynamicProfile(dynamicProfile) } + let identity = String(reflecting: type(of: dynamicProfile)) + resolveValue = { + var resolved = resolveDynamicProfile(dynamicProfile) + resolved.identity = identity + "/" + resolved.identity + return resolved + } } public init(erasing dynamicProfile: some DynamicProfile) { @@ -519,6 +524,7 @@ actor ProfileLifecycle { prompt: Transcript.Prompt?, callbackState: ProfileCallbackState ) async throws { + let history = SessionHistoryBinding(history, isWritable: true) if activeProfile?.identity != profile.identity { if let previous = activeProfile { await withBindings(properties: properties, history: history) { @@ -546,10 +552,9 @@ actor ProfileLifecycle { private func withBindings( properties: SessionPropertyValues, - history: [Transcript.Entry], + history: SessionHistoryBinding, operation: @Sendable () async throws -> Result ) async rethrows -> Result { - let history = SessionHistoryBinding(history, isWritable: true) return try await SessionPropertyBinding.$values.withValue(properties) { try await SessionPropertyBinding.$history.withValue(history) { try await operation() diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 805ee771..8a427658 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -64,6 +64,7 @@ public final class LanguageModelSession: @unchecked Sendable { @ObservationIgnored private let state: Locked @ObservationIgnored private let responseRelays = Locked<[UUID: Task]>([:]) + @ObservationIgnored private let lastResponseModel = Locked<(any LanguageModel)?>(nil) /// Waits for transcript cleanup of all streaming relays registered when this call begins. /// @@ -121,6 +122,8 @@ public final class LanguageModelSession: @unchecked Sendable { dynamicInstructions != nil || dynamicProfile != nil } + nonisolated var usesDynamicProfile: Bool { dynamicProfile != nil } + /// A delegate that observes and controls tool execution. /// /// Set this property to intercept tool calls, provide custom output, @@ -449,13 +452,16 @@ public final class LanguageModelSession: @unchecked Sendable { currentRoundEntries: [Transcript.Entry] ) async throws { guard let profile = requestContext.profile else { return } + let history = SessionHistoryBinding( + requestContext.canonicalTranscript + currentRoundEntries, + isWritable: true + ) for entry in entries { switch entry { case .reasoning(let reasoning): guard requestContext.callbackState.reserve(entry.id) else { continue } do { - let history = requestContext.canonicalTranscript + currentRoundEntries - try await withSessionProperties(history: history, isHistoryWritable: true) { + try await withSessionProperties(history: history) { for action in profile.onReasoning { try await action(reasoning) } } } catch { @@ -465,8 +471,7 @@ public final class LanguageModelSession: @unchecked Sendable { case .response(let response): guard requestContext.callbackState.reserve(entry.id) else { continue } do { - let history = requestContext.canonicalTranscript + currentRoundEntries - try await withSessionProperties(history: history, isHistoryWritable: true) { + try await withSessionProperties(history: history) { for action in profile.onResponse { try await action(response) } } } catch { @@ -499,6 +504,13 @@ public final class LanguageModelSession: @unchecked Sendable { operation: @Sendable () async throws -> Result ) async rethrows -> Result { let history = SessionHistoryBinding(history, isWritable: isHistoryWritable) + return try await withSessionProperties(history: history, operation: operation) + } + + nonisolated private func withSessionProperties( + history: SessionHistoryBinding, + operation: @Sendable () async throws -> Result + ) async rethrows -> Result { return try await SessionPropertyBinding.$values.withValue(properties) { try await SessionPropertyBinding.$history.withValue(history) { try await operation() @@ -718,6 +730,7 @@ public final class LanguageModelSession: @unchecked Sendable { } public func prewarm(promptPrefix: Prompt? = nil) { + let model = usesDynamicProfile ? resolvedRequestContext().model : self.model model.prewarm(for: self, promptPrefix: promptPrefix) } @@ -891,6 +904,7 @@ public final class LanguageModelSession: @unchecked Sendable { requestContext: context, currentRoundEntries: [] ) + session.lastResponseModel.withLock { $0 = prepared.context.model } } session.endResponding() continuation.finish() @@ -1272,6 +1286,7 @@ public final class LanguageModelSession: @unchecked Sendable { requestContext: context, currentRoundEntries: [] ) + lastResponseModel.withLock { $0 = prepared.context.model } } catch { let ids = Set(response.transcriptEntries.map(\.id) + [responseEntry.id]) withTranscriptMutation { @@ -1980,7 +1995,10 @@ extension LanguageModelSession { issues: [LanguageModelFeedback.Issue] = [], desiredOutput: Transcript.Entry? = nil ) -> Data { - model.logFeedbackAttachment( + let model = + lastResponseModel.withLock { $0 } + ?? (usesDynamicProfile ? resolvedRequestContext().model : self.model) + return model.logFeedbackAttachment( within: self, sentiment: sentiment, issues: issues, diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 86e1571e..50306093 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -398,16 +398,17 @@ session: LanguageModelSession, prompt: Prompt ) -> FoundationModels.LanguageModelSession { - let requestContext = session.resolvedRequestContext() if session.usesDynamicInstructions { // Foundation Models evaluates the dynamic instructions itself, // so the history leaves out the instructions entry. let history = fmTranscriptDroppingDuplicatePrompt( Transcript( - entries: requestContext.transcript.filter { entry in - if case .instructions = entry { return false } - return true - } + entries: (session.usesDynamicProfile + ? session.resolvedRequestContext().transcript + : session.transcript).filter { entry in + if case .instructions = entry { return false } + return true + } ), prompt: prompt ).toFoundationModels(instructions: nil, toolDefinitions: []) @@ -418,6 +419,7 @@ ) } + let requestContext = session.resolvedRequestContext() return FoundationModels.LanguageModelSession( model: model, tools: requestContext.tools.toFoundationModels(), diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index 2dc45a1d..689068f2 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -14,6 +14,94 @@ extension SessionPropertyValues { @Suite("Dynamic profiles") struct DynamicProfileTests { + @Test func erasedConcreteProfilesKeepDistinctLifecycleIdentities() async throws { + let state = LifecycleFixtureState() + let session = LanguageModelSession(profile: ErasedRoutingProfile(state: state)) + _ = try await session.respond(to: "First") + _ = try await session.respond(to: "Same profile") + state.useSecond.withLock { $0 = true } + _ = try await session.respond(to: "Switch") + + #expect(state.events.withLock { $0 } == ["activate:A", "deactivate:A", "activate:B"]) + } + + @Test func lifecycleHistoryEditsAccumulateAcrossPreparationStages() async throws { + let state = LifecycleFixtureState() + let first = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "First old entry"))])) + let second = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Second old entry"))])) + let seen = Locked<[[String]]>([]) + let session = LanguageModelSession( + profile: HistoryEditingLifecycleProfile(state: state, first: first.id, second: second.id, seen: seen), + history: [first, second] + ) + _ = try await session.respond(to: "Activate A") + state.useSecond.withLock { $0 = true } + _ = try await session.respond(to: "Activate B") + + let stages = seen.withLock { $0 } + #expect(stages.count == 2) + #expect(stages.allSatisfy { !$0.contains(first.id) }) + #expect(stages.last?.contains(second.id) == false) + #expect(!session.properties.history.contains { $0.id == first.id || $0.id == second.id }) + } + + @Test(arguments: [false, true]) + func completionEntryCallbacksShareHistoryEdits(streaming: Bool) async throws { + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Remove me"))])) + let marker = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Marker"))])) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Evolving callback history") } + .model(LifecycleFixtureModel()) + .onReasoning { _ in + let properties = try #require(SessionPropertyBinding.values) + properties.history.removeAll { $0.id == earlier.id } + } + .onResponse { _ in + let properties = try #require(SessionPropertyBinding.values) + #expect(!properties.history.contains { $0.id == earlier.id }) + properties.history.append(marker) + }, + history: [earlier] + ) + if streaming { + _ = try await session.streamResponse(to: "Complete").collect() + } else { + _ = try await session.respond(to: "Complete") + } + + #expect(!session.properties.history.contains { $0.id == earlier.id }) + #expect(session.properties.history.last == marker) + } + + @Test(arguments: [false, true]) + func prewarmUsesActiveModelAndFeedbackUsesProducingModel(streaming: Bool) async throws { + let state = ProfileFixtureState() + let session = LanguageModelSession( + profile: FixtureProfile( + state: state, + first: ProfileFixtureModel(id: "a", state: state), + second: ProfileFixtureModel(id: "b", state: state) + ) + ) + session.prewarm(promptPrefix: Prompt("Prefix A")) + if streaming { + _ = try await session.streamResponse(to: "First").collect() + } else { + _ = try await session.respond(to: "First") + } + state.selectSecond() + session.prewarm(promptPrefix: Prompt("Prefix B")) + #expect(session.logFeedbackAttachment(sentiment: .positive) == Data("a".utf8)) + if streaming { + _ = try await session.streamResponse(to: "Second").collect() + } else { + _ = try await session.respond(to: "Second") + } + #expect(session.logFeedbackAttachment(sentiment: .positive) == Data("b".utf8)) + #expect(state.prewarms.withLock { $0 } == ["a:Prefix A", "b:Prefix B"]) + #expect(state.feedback.withLock { $0 } == ["a", "b"]) + } + @Test func sameSessionReevaluatesModelInstructionsOptionsAndProperties() async throws { let state = ProfileFixtureState() let first = ProfileFixtureModel(id: "model-a", state: state) @@ -241,6 +329,23 @@ struct DynamicProfileTests { } #if canImport(FoundationModels) && compiler(>=6.4) && !os(tvOS) && !os(watchOS) + @Test func nativeSessionEvaluatesOrdinaryDynamicInstructionsOnce() { + guard #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) else { return } + let session = LanguageModelSession( + model: LifecycleFixtureModel(), + dynamicInstructions: CountingNativeInstructions() + ) + + _ = makeFoundationModelsSession( + model: FoundationModels.SystemLanguageModel.default, + session: session, + prompt: Prompt("Current") + ) + #expect(session.properties.profileRevision == 1) + _ = FoundationModelsDynamicInstructionsAdapter(session: session).body + #expect(session.properties.profileRevision == 2) + } + @Test func nativeSessionUsesProjectedHistory() throws { guard #available(macOS 27.0, iOS 27.0, visionOS 27.0, *) else { return } let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) @@ -529,6 +634,20 @@ private struct ProfileFixtureModel: LanguageModel { let id: String let state: ProfileFixtureState + func prewarm(for session: LanguageModelSession, promptPrefix: Prompt?) { + state.prewarms.withLock { $0.append("\(id):\(promptPrefix?.description ?? "")") } + } + + func logFeedbackAttachment( + within session: LanguageModelSession, + sentiment: LanguageModelFeedback.Sentiment?, + issues: [LanguageModelFeedback.Issue], + desiredOutput: Transcript.Entry? + ) -> Data { + state.feedback.withLock { $0.append(id) } + return Data(id.utf8) + } + func respond( within session: LanguageModelSession, to prompt: Prompt, @@ -603,6 +722,8 @@ private final class ProfileFixtureState: @unchecked Sendable { let transformedHistory = Locked<[[String]]>([]) let callbackEvents = Locked<[String]>([]) let callbackHistories = Locked<[[String]]>([]) + let prewarms = Locked<[String]>([]) + let feedback = Locked<[String]>([]) var usesSecond: Bool { selection.withLock { $0 } } func selectSecond() { selection.withLock { $0 = true } } @@ -703,6 +824,70 @@ private final class LifecycleFixtureState: @unchecked Sendable { } } +private enum FirstErasedProfile {} +private enum SecondErasedProfile {} + +private struct ErasedLeafProfile: LanguageModelSession.DynamicProfile { + var body: some LanguageModelSession.DynamicProfile { + LanguageModelSession.Profile { Instructions("Same leaf type") } + } +} + +private struct ErasedRoutingProfile: LanguageModelSession.DynamicProfile { + let state: LifecycleFixtureState + + var body: some LanguageModelSession.DynamicProfile { + let second = state.useSecond.withLock { $0 } + let profile = + second + ? LanguageModelSession.AnyDynamicProfile(ErasedLeafProfile()) + : LanguageModelSession.AnyDynamicProfile(ErasedLeafProfile()) + let label = second ? "B" : "A" + return profile.model(LifecycleFixtureModel()) + .onActivate { state.record("activate:\(label)") } + .onDeactivate { state.record("deactivate:\(label)") } + } +} + +private struct HistoryEditingLifecycleProfile: LanguageModelSession.DynamicProfile { + let state: LifecycleFixtureState + let first: String + let second: String + let seen: Locked<[[String]]> + + var body: some LanguageModelSession.DynamicProfile { + if state.useSecond.withLock({ $0 }) { + LanguageModelSession.Profile { Instructions("B") } + .model(LifecycleFixtureModel()) + .onActivate { + guard let properties = SessionPropertyBinding.values else { return } + seen.withLock { $0.append(properties.history.map(\.id)) } + properties.history.removeAll { $0.id == second } + } + .onPrompt { _ in + let properties = try #require(SessionPropertyBinding.values) + seen.withLock { $0.append(properties.history.map(\.id)) } + } + } else { + LanguageModelSession.Profile { Instructions("A") } + .model(LifecycleFixtureModel()) + .onDeactivate { + guard let properties = SessionPropertyBinding.values else { return } + properties.history.removeAll { $0.id == first } + } + } + } +} + +private struct CountingNativeInstructions: DynamicInstructions { + @SessionProperty(\.profileRevision) private var revision + + var body: some DynamicInstructions { + revision += 1 + return Instructions("Revision \(revision)") + } +} + private struct LifecycleFixtureProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { let state: LifecycleFixtureState let model: LifecycleFixtureModel From ef80f149acaea3952199daa6980a8ab61726bb69 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 09:09:45 +0800 Subject: [PATCH 34/53] Carry callback history edits and original schema options through tool handoffs --- Sources/AnyLanguageModel/DynamicProfile.swift | 10 +- .../LanguageModelSession.swift | 115 +++++++++++++++--- .../ProfileToolExecution.swift | 20 +-- .../AnyLanguageModel/SessionProperties.swift | 6 +- .../DynamicProfileTests.swift | 72 +++++++++++ .../ProviderUsageTests.swift | 104 +++++++++++++++- 6 files changed, 294 insertions(+), 33 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index b2c2d98a..8b310555 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -528,6 +528,7 @@ actor ProfileLifecycle { history: [Transcript.Entry], prompt: Transcript.Prompt?, callbackState: ProfileCallbackState, + historyEdits: Locked, protectedEntryIDs: Set = [] ) async throws { let previous = pendingPreparation?.task @@ -541,6 +542,7 @@ actor ProfileLifecycle { history: history, prompt: prompt, callbackState: callbackState, + historyEdits: historyEdits, protectedEntryIDs: protectedEntryIDs ) } @@ -560,9 +562,15 @@ actor ProfileLifecycle { history: [Transcript.Entry], prompt: Transcript.Prompt?, callbackState: ProfileCallbackState, + historyEdits: Locked, protectedEntryIDs: Set ) async throws { - let history = properties.historyBinding(history, isWritable: true, protecting: protectedEntryIDs) + let history = properties.historyBinding( + history, + isWritable: true, + protecting: protectedEntryIDs, + onChange: { updated in historyEdits.withLock { $0 = updated } } + ) if activeProfile?.identity != profile.identity { if let previous = activeProfile { await withBindings( diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 7218deb7..70e62fd4 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -173,6 +173,7 @@ public final class LanguageModelSession: @unchecked Sendable { fileprivate let canonicalTranscript: [Transcript.Entry] fileprivate let pendingEntryIDs: Set fileprivate let callbackState: ProfileCallbackState + fileprivate let callbackHistory = Locked(nil) var hasDynamicProfile: Bool { profile != nil } @@ -261,6 +262,19 @@ public final class LanguageModelSession: @unchecked Sendable { return prepared.toolRoundLimit(for: provider) } + nonisolated func profileIncludeSchemaInPrompt( + for context: RequestContext, + callSiteValue: Bool + ) -> Bool { + let originalValue: Bool + if let prepared = ProfileRequestBinding.prepared, prepared.session === self { + originalValue = prepared.callSiteIncludeSchemaInPrompt + } else { + originalValue = callSiteValue + } + return context.contextOptions.includeSchemaInPrompt ?? originalValue + } + /// Resolves the active profile for one actual model request. /// /// `additionalEntries` supplies canonical entries produced in the current Tool @@ -312,6 +326,7 @@ public final class LanguageModelSession: @unchecked Sendable { return nil }.first, callbackState: context.callbackState, + historyEdits: context.callbackHistory, protectedEntryIDs: context.pendingEntryIDs ) } @@ -468,7 +483,8 @@ public final class LanguageModelSession: @unchecked Sendable { try await withSessionProperties( history: history.entries, isHistoryWritable: true, - protectedEntryIDs: history.protectedEntryIDs + protectedEntryIDs: history.protectedEntryIDs, + onHistoryChange: { updated in requestContext.callbackHistory.withLock { $0 = updated } } ) { for action in profile.onToolCall { try await action(call) } } @@ -489,7 +505,8 @@ public final class LanguageModelSession: @unchecked Sendable { try await withSessionProperties( history: history.entries, isHistoryWritable: true, - protectedEntryIDs: history.protectedEntryIDs + protectedEntryIDs: history.protectedEntryIDs, + onHistoryChange: { updated in requestContext.callbackHistory.withLock { $0 = updated } } ) { for action in profile.onToolOutput { try await action(call, output) } } @@ -514,7 +531,8 @@ public final class LanguageModelSession: @unchecked Sendable { let binding = properties.historyBinding( history.entries, isWritable: true, - protecting: history.protectedEntryIDs + protecting: history.protectedEntryIDs, + onChange: { updated in requestContext.callbackHistory.withLock { $0 = updated } } ) for entry in entries { switch entry { @@ -569,11 +587,28 @@ public final class LanguageModelSession: @unchecked Sendable { requestContext: RequestContext, currentRoundEntries: [Transcript.Entry] ) -> (entries: [Transcript.Entry], protectedEntryIDs: Set) { - var entryIDs = Set(requestContext.canonicalTranscript.map(\.id)) - let additionalEntries = currentRoundEntries.filter { entryIDs.insert($0.id).inserted } + let canonicalIDs = Set(requestContext.canonicalTranscript.map(\.id)) + let pending = currentRoundEntries.filter { + !canonicalIDs.contains($0.id) || requestContext.pendingEntryIDs.contains($0.id) + } + let history = requestContext.callbackHistory.withLock { history in + var entries = history ?? requestContext.canonicalTranscript + var indices: [String: Int] = [:] + for (index, entry) in entries.enumerated() { indices[entry.id] = index } + for entry in pending { + if let index = indices[entry.id] { + entries[index] = entry + } else { + indices[entry.id] = entries.count + entries.append(entry) + } + } + history = entries + return entries + } return ( - requestContext.canonicalTranscript + additionalEntries, - requestContext.pendingEntryIDs.union(additionalEntries.map(\.id)) + history, + requestContext.pendingEntryIDs.union(pending.map(\.id)) ) } @@ -581,12 +616,14 @@ public final class LanguageModelSession: @unchecked Sendable { history: [Transcript.Entry], isHistoryWritable: Bool, protectedEntryIDs: Set = [], + onHistoryChange: (@Sendable (Transcript.HistoryView) -> Void)? = nil, operation: @Sendable () async throws -> Result ) async rethrows -> Result { let history = properties.historyBinding( history, isWritable: isHistoryWritable, - protecting: protectedEntryIDs + protecting: protectedEntryIDs, + onChange: onHistoryChange ) return try await withSessionProperties(history: history, operation: operation) } @@ -1237,7 +1274,12 @@ public final class LanguageModelSession: @unchecked Sendable { options: options, profileTracker: profileTracker ) { requestPrompt in - try await profiledResponse(prompt: requestPrompt, options: options, tracker: profileTracker) { + try await profiledResponse( + prompt: requestPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: profileTracker + ) { model, resolvedOptions, contextOptions in @@ -1255,6 +1297,7 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func profiledResponse( prompt: Transcript.Prompt, + includeSchemaInPrompt: Bool, options: GenerationOptions, tracker: PreparedProfileRequestTracker?, generate: ( @@ -1274,7 +1317,8 @@ public final class LanguageModelSession: @unchecked Sendable { session: self, context: context, prompt: prompt, - callSiteOptions: options + callSiteOptions: options, + callSiteIncludeSchemaInPrompt: includeSchemaInPrompt ) tracker?.prepared = prepared let response = try await ProfileRequestBinding.$prepared.withValue(prepared) { @@ -1284,7 +1328,8 @@ public final class LanguageModelSession: @unchecked Sendable { properties: properties, history: context.canonicalTranscript, prompt: prepared.prompt, - callbackState: context.callbackState + callbackState: context.callbackState, + historyEdits: context.callbackHistory ) } return try await generate(context.model, context.options, context.contextOptions) @@ -1447,7 +1492,12 @@ public final class LanguageModelSession: @unchecked Sendable { options: GenerationOptions, tracker: PreparedProfileRequestTracker ) -> ResponseStream where Content: Generable { - profileDispatchedStream(requestPrompt: requestPrompt, options: options, tracker: tracker) { + profileDispatchedStream( + requestPrompt: requestPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: tracker + ) { model, resolvedOptions, contextOptions in @@ -1464,6 +1514,7 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func profileDispatchedStream( requestPrompt: Transcript.Prompt, + includeSchemaInPrompt: Bool, options: GenerationOptions, tracker: PreparedProfileRequestTracker, generate: @@ -1484,7 +1535,8 @@ public final class LanguageModelSession: @unchecked Sendable { session: self, context: context, prompt: requestPrompt, - callSiteOptions: options + callSiteOptions: options, + callSiteIncludeSchemaInPrompt: includeSchemaInPrompt ) tracker.prepared = prepared try await ProfileRequestBinding.$prepared.withValue(prepared) { @@ -1494,7 +1546,8 @@ public final class LanguageModelSession: @unchecked Sendable { properties: self.properties, history: context.canonicalTranscript, prompt: prepared.prompt, - callbackState: context.callbackState + callbackState: context.callbackState, + historyEdits: context.callbackHistory ) } let upstream = generate( @@ -1595,7 +1648,12 @@ extension LanguageModelSession { options: options, profileTracker: profileTracker ) { requestPrompt in - try await profiledResponse(prompt: requestPrompt, options: options, tracker: profileTracker) { + try await profiledResponse( + prompt: requestPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: profileTracker + ) { model, resolvedOptions, contextOptions in @@ -1676,7 +1734,12 @@ extension LanguageModelSession { if dynamicProfile != nil { let tracker = PreparedProfileRequestTracker() profileTracker = tracker - upstream = profileDispatchedStream(requestPrompt: requestPrompt, options: options, tracker: tracker) { + upstream = profileDispatchedStream( + requestPrompt: requestPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: tracker + ) { model, resolvedOptions, contextOptions in @@ -1896,8 +1959,12 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - let profiled = try await profiledResponse(prompt: requestPrompt, options: options, tracker: profileTracker) - { + let profiled = try await profiledResponse( + prompt: requestPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: profileTracker + ) { model, resolvedOptions, contextOptions in @@ -2039,7 +2106,12 @@ extension LanguageModelSession { if dynamicProfile != nil { let tracker = PreparedProfileRequestTracker() profileTracker = tracker - upstream = profileDispatchedStream(requestPrompt: requestPrompt, options: options, tracker: tracker) { + upstream = profileDispatchedStream( + requestPrompt: requestPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options, + tracker: tracker + ) { model, resolvedOptions, contextOptions in @@ -2472,6 +2544,7 @@ private final class PreparedProfileRequest: @unchecked Sendable { let session: LanguageModelSession let prompt: Transcript.Prompt let callSiteOptions: GenerationOptions + let callSiteIncludeSchemaInPrompt: Bool private let state: Locked private let toolRoundLimitStorage = Locked(nil) @@ -2479,11 +2552,13 @@ private final class PreparedProfileRequest: @unchecked Sendable { session: LanguageModelSession, context: LanguageModelSession.RequestContext, prompt: Transcript.Prompt, - callSiteOptions: GenerationOptions + callSiteOptions: GenerationOptions, + callSiteIncludeSchemaInPrompt: Bool ) { self.session = session self.prompt = prompt self.callSiteOptions = callSiteOptions + self.callSiteIncludeSchemaInPrompt = callSiteIncludeSchemaInPrompt state = Locked(State(context: context)) } diff --git a/Sources/AnyLanguageModel/ProfileToolExecution.swift b/Sources/AnyLanguageModel/ProfileToolExecution.swift index 363d5d1a..9604f528 100644 --- a/Sources/AnyLanguageModel/ProfileToolExecution.swift +++ b/Sources/AnyLanguageModel/ProfileToolExecution.swift @@ -33,14 +33,17 @@ extension LanguageModelSession { return nil } + let includeSchemaInPrompt = profileIncludeSchemaInPrompt( + for: requestContext, + callSiteValue: includeSchemaInPrompt + ) let response: Response if type == GeneratedContent.self { let generated = try await requestContext.model.respond( within: self, to: prompt, schema: schema, - includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt - ?? includeSchemaInPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, options: requestContext.options ) guard let typed = generated as? Response else { @@ -52,8 +55,7 @@ extension LanguageModelSession { within: self, to: prompt, generating: type, - includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt - ?? includeSchemaInPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, options: requestContext.options ) } @@ -94,14 +96,17 @@ extension LanguageModelSession { return nil } + let includeSchemaInPrompt = profileIncludeSchemaInPrompt( + for: requestContext, + callSiteValue: includeSchemaInPrompt + ) let upstream: ResponseStream if type == GeneratedContent.self { let generated = requestContext.model.streamResponse( within: self, to: prompt, schema: schema, - includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt - ?? includeSchemaInPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, options: requestContext.options ) guard let typed = generated as? ResponseStream else { @@ -113,8 +118,7 @@ extension LanguageModelSession { within: self, to: prompt, generating: type, - includeSchemaInPrompt: requestContext.contextOptions.includeSchemaInPrompt - ?? includeSchemaInPrompt, + includeSchemaInPrompt: includeSchemaInPrompt, options: requestContext.options ) } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 79e13613..ed0cb802 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -64,7 +64,7 @@ public final class SessionPropertyValues: @unchecked Sendable { "Session history is read-only while dynamic instructions or a Tool is active" ) history.entries = newValue - history.persist(newValue) + history.persist(history.entries) } else { historySetter(newValue) } @@ -94,11 +94,13 @@ public final class SessionPropertyValues: @unchecked Sendable { func historyBinding( _ entries: Transcript.HistoryView, isWritable: Bool, - protecting protectedEntryIDs: Set = [] + protecting protectedEntryIDs: Set = [], + onChange: (@Sendable (Transcript.HistoryView) -> Void)? = nil ) -> SessionHistoryBinding { let persist: (@Sendable (Transcript.HistoryView) -> Void)? if isWritable { persist = { [historySetter] updated in + onChange?(updated) historySetter( updated.filter { !protectedEntryIDs.contains($0.id) } ) diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index e52ada28..e4e73155 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -14,6 +14,78 @@ extension SessionPropertyValues { @Suite("Dynamic profiles") struct DynamicProfileTests { + @Test func callbacksAndToolExecutionRetainRequestScopedHistoryEdits() async throws { + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Remove me"))])) + let promptMarker = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Prompt marker"))])) + let callMarker = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Call marker"))])) + let outputMarker = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Output marker"))])) + let responseMarker = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Response marker"))])) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Evolving history") } + .model(LifecycleFixtureModel()) + .onPrompt { _ in + let properties = try #require(SessionPropertyBinding.values) + properties.history.removeAll { $0.id == earlier.id } + properties.history.append(promptMarker) + } + .onToolCall { _ in + let properties = try #require(SessionPropertyBinding.values) + #expect(!properties.history.contains { $0.id == earlier.id }) + #expect(properties.history.contains(promptMarker)) + properties.history.append(callMarker) + } + .onToolOutput { _, _ in + let properties = try #require(SessionPropertyBinding.values) + #expect(!properties.history.contains { $0.id == earlier.id }) + #expect(properties.history.contains(callMarker)) + properties.history.removeAll { $0.id == callMarker.id } + properties.history.append(outputMarker) + } + .onReasoning { _ in + let properties = try #require(SessionPropertyBinding.values) + #expect(!properties.history.contains { $0.id == earlier.id || $0.id == callMarker.id }) + #expect(properties.history.contains(outputMarker)) + properties.history = properties.history + } + .onResponse { _ in + let properties = try #require(SessionPropertyBinding.values) + #expect(!properties.history.contains { $0.id == earlier.id || $0.id == callMarker.id }) + properties.history.append(responseMarker) + }, + history: [earlier] + ) + let context = try await session.resolvedRequestContext(including: []) + let call = Transcript.ToolCall(id: "call", toolName: "fixture", arguments: .init(properties: [:])) + let calls = Transcript.Entry.toolCalls(.init([call])) + let output = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: [.text(.init(content: "Done"))] + ) + let reasoning = Transcript.Entry.reasoning(.init(segments: [.text(.init(content: "Thinking"))])) + let response = Transcript.Entry.response(.init(assetIDs: [], segments: [.text(.init(content: "Answer"))])) + try await session.profileWillExecuteToolCall(call, requestContext: context, currentRoundEntries: [calls]) + let toolHistory = await session.withProfileToolExecution(requestContext: context, currentRoundEntries: [calls]) + { + session.properties.history + } + #expect(!toolHistory.contains { $0.id == earlier.id }) + #expect(toolHistory.contains(callMarker)) + try await session.profileDidProduceToolOutput( + for: call, + output: output, + requestContext: context, + currentRoundEntries: [calls, .toolOutput(output)] + ) + try await session.profileDidProduceEntries( + [reasoning, response], + requestContext: context, + currentRoundEntries: [calls, .toolOutput(output), reasoning, response] + ) + + #expect(session.properties.history == [promptMarker, outputMarker, responseMarker]) + } + @Test func erasedConcreteProfilesKeepDistinctLifecycleIdentities() async throws { let state = LifecycleFixtureState() let session = LanguageModelSession(profile: ErasedRoutingProfile(state: state)) diff --git a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift index 7b835ea2..652bd6ce 100644 --- a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift @@ -583,6 +583,101 @@ import Testing #expect(UsageURLProtocol.recordedBodies.count == limit + 1) } + private enum HandoffResponseFormat: CaseIterable { + case text, typed, schema, images + } + + @Test(arguments: Provider.allCases, [false, true]) + func modelHandoffsRestoreOriginalSchemaPromptOption(_ provider: Provider, _ streaming: Bool) async throws { + for format in HandoffResponseFormat.allCases { + for callSiteValue in [false, true] { + UsageURLProtocol.reset() + UsageURLProtocol.enqueue( + json: try streaming + ? provider.toolStream() + : Self.json(provider.response(text: "", tool: true)) + ) + let state = ProviderProfileState() + let raw = + format == .text + ? GeneratedContent("Switched") + : GeneratedContent(properties: ["answer": "Switched"]) + let session = LanguageModelSession( + profile: ProviderProfile( + state: state, + model: provider.makeModel(), + continuationModel: ProviderProfileTerminalModel(state: state, rawResponse: raw), + initialSchemaOverride: !callSiteValue + ) + ) + + switch format { + case .text: + let result = + try await streaming + ? session.streamResponse( + to: "Weather?", + generating: String.self, + includeSchemaInPrompt: callSiteValue + ).collect() + : session.respond( + to: "Weather?", + generating: String.self, + includeSchemaInPrompt: callSiteValue + ) + #expect(result.content == "Switched") + case .typed: + let result = + try await streaming + ? session.streamResponse( + to: "Weather?", + generating: Answer.self, + includeSchemaInPrompt: callSiteValue + ).collect() + : session.respond( + to: "Weather?", + generating: Answer.self, + includeSchemaInPrompt: callSiteValue + ) + #expect(result.content.answer == "Switched") + case .schema: + let result = + try await streaming + ? session.streamResponse( + to: "Weather?", + schema: Answer.generationSchema, + includeSchemaInPrompt: callSiteValue + ).collect() + : session.respond( + to: "Weather?", + schema: Answer.generationSchema, + includeSchemaInPrompt: callSiteValue + ) + #expect(try result.content.value(String.self, forProperty: "answer") == "Switched") + case .images: + let result = + try await streaming + ? session.streamResponse( + to: "Weather?", + images: [], + generating: Answer.self, + includeSchemaInPrompt: callSiteValue + ).collect() + : session.respond( + to: "Weather?", + images: [], + generating: Answer.self, + includeSchemaInPrompt: callSiteValue + ) + #expect(result.content.answer == "Switched") + } + let selected = try #require(state.selectedRequests.withLock { $0.last }) + #expect(selected.includeSchemaInPrompt == callSiteValue) + #expect(UsageURLProtocol.recordedBodies.count == 1) + } + } + } + @Test(arguments: Provider.allCases, [false, true]) func modelHandoffsPreserveEarlierToolResultsAndHistoryWrites( _ provider: Provider, @@ -1140,6 +1235,7 @@ import Testing let temperature: Double? let tools: [String] let sawToolOutput: Bool + let includeSchemaInPrompt: Bool } let useSecond = Locked(false) @@ -1153,6 +1249,7 @@ import Testing let state: ProviderProfileState let model: any LanguageModel var continuationModel: (any LanguageModel)? = nil + var initialSchemaOverride: Bool? = nil var body: some LanguageModelSession.DynamicProfile { if state.useSecond.withLock({ $0 }) { @@ -1171,6 +1268,7 @@ import Testing ProviderProfileWeatherTool(state: state) } .model(model) + .includeSchemaInPrompt(initialSchemaOverride) .temperature(0.2) .historyTransform(transform) .onToolCall { _ in state.events.withLock { $0.append("call:A") } } @@ -1321,6 +1419,7 @@ import Testing private struct ProviderProfileTerminalModel: LanguageModel { typealias UnavailableReason = Never let state: ProviderProfileState + var rawResponse = GeneratedContent("Switched") func respond( within session: LanguageModelSession, @@ -1338,11 +1437,12 @@ import Testing tools: context.tools.map(\.name), sawToolOutput: context.transcript.contains { if case .toolOutput = $0 { true } else { false } - } + }, + includeSchemaInPrompt: includeSchemaInPrompt ) ) } - let raw = GeneratedContent("Switched") + let raw = rawResponse return .init( content: try Content(raw), rawContent: raw, From 5a17ad3c1079afcdf4eca5efbd561e8b93bccd3f Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 23:33:20 +0800 Subject: [PATCH 35/53] Snapshot local tool image data before persisting output --- README.md | 5 ++ Sources/AnyLanguageModel/Tool.swift | 40 ++++++++++++++- .../PromptAttachmentTests.swift | 27 +++++++++- .../ProviderUsageTests.swift | 50 +++++++++++++++---- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a4a871e3..0e4e63cb 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,11 @@ they throw an error when a prompt has an attachment. The system bridge requires local file URLs; other provider adapters retain URL images in their existing transcript representation. +Ordinary Tool outputs read local image files once when the output is created, +and retain the image bytes and MIME type in the compatibility transcript. +Continuations and saved transcript replay therefore do not depend on the original +file remaining at its URL. Remote image URLs are retained without downloading. + This compatibility surface covers `CGImage` and `imageURL` attachments in prompts and ordinary Tool outputs. Instruction attachments, labels, `CIImage`, pixel buffers, and newer data attachments remain outside this change. diff --git a/Sources/AnyLanguageModel/Tool.swift b/Sources/AnyLanguageModel/Tool.swift index 59f5b800..558bb628 100644 --- a/Sources/AnyLanguageModel/Tool.swift +++ b/Sources/AnyLanguageModel/Tool.swift @@ -1,3 +1,9 @@ +import Foundation + +#if canImport(UniformTypeIdentifiers) + import UniformTypeIdentifiers +#endif + /// A tool that a model can call to gather information at runtime or perform side effects. /// /// Tool calling gives the model the ability to call your code to incorporate @@ -127,6 +133,38 @@ extension Tool { return [Transcript.Segment.text(.init(content: stringOutput))] } - return try output.promptRepresentation.makeTranscriptSegments() + return try output.promptRepresentation.makeTranscriptSegments().map { segment in + guard case .image(let image) = segment, + case .url(let url) = image.source, + url.isFileURL + else { return segment } + + let mimeType: String? + #if canImport(UniformTypeIdentifiers) + mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType + #else + mimeType = + switch url.pathExtension.lowercased() { + case "jpg", "jpeg": "image/jpeg" + case "png": "image/png" + case "gif": "image/gif" + case "webp": "image/webp" + default: nil + } + #endif + guard let mimeType else { throw ToolAttachmentError.unknownImageType(url) } + return .image(.init(id: image.id, data: try Data(contentsOf: url), mimeType: mimeType)) + } + } +} + +private enum ToolAttachmentError: LocalizedError { + case unknownImageType(URL) + + var errorDescription: String? { + switch self { + case .unknownImageType(let url): + "Cannot determine the MIME type of the Tool output image \(url.lastPathComponent)." + } } } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 589ce23f..77f1dd6b 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -123,7 +123,15 @@ struct PromptAttachmentTests { } @Test func ordinaryToolReturnsOrderedImageSegments() async throws { - let segments = try await ImageTool(output: prompt()).makeOutputSegments( + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png") + try testImageData.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let output = AnyLanguageModel.Prompt { + "before" + AnyLanguageModel.Attachment(imageURL: url) + "after" + } + let segments = try await ImageTool(output: output).makeOutputSegments( from: AnyLanguageModel.GeneratedContent("read") ) #expect(segments.count == 3) @@ -136,7 +144,8 @@ struct PromptAttachmentTests { } #expect(before.content == "before") #expect(after.content == "after") - #expect(image.source == .url(imageURL)) + #expect(image.source == .data(testImageData, mimeType: "image/png")) + try FileManager.default.removeItem(at: url) let transcript = AnyLanguageModel.Transcript(entries: [ .toolOutput(.init(id: "image-call", toolName: "image", segments: segments)) @@ -145,6 +154,20 @@ struct PromptAttachmentTests { #expect(try JSONDecoder().decode(AnyLanguageModel.Transcript.self, from: encoded) == transcript) } + @Test func ordinaryToolRetainsRemoteImageURLs() async throws { + let url = try #require(URL(string: "https://example.com/image.png")) + let segments = try await ImageTool( + output: AnyLanguageModel.Prompt { + AnyLanguageModel.Attachment(imageURL: url) + } + ).makeOutputSegments(from: AnyLanguageModel.GeneratedContent("read")) + guard case .image(let image) = try #require(segments.first) else { + Issue.record("Missing image") + return + } + #expect(image.source == .url(url)) + } + #if canImport(CoreGraphics) && canImport(ImageIO) private func image() throws -> CGImage { let context = try #require( diff --git a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift index f1252f4b..b0215f99 100644 --- a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift @@ -9,7 +9,7 @@ import Testing enum Provider: CaseIterable, Equatable, Sendable { case chat, responses, openResponses, anthropic, gemini, ollama - func makeSession(tools: [any Tool] = []) -> LanguageModelSession { + func makeSession(tools: [any Tool] = [], transcript: Transcript = .init()) -> LanguageModelSession { let http = UsageURLProtocol.makeSession() let model: any LanguageModel switch self { @@ -34,7 +34,7 @@ import Testing case .ollama: model = OllamaLanguageModel(model: "test", session: http) } - return LanguageModelSession(model: model, tools: tools) + return LanguageModelSession(model: model, tools: tools, transcript: transcript) } var counts: [String: Any] { @@ -482,15 +482,16 @@ import Testing #expect(UsageURLProtocol.recordedBodies.count == 2) } - @Test(arguments: [false, true]) - func anthropicInlinesLocalToolImagesAndReplaysThem(_ streaming: Bool) async throws { - let provider = Provider.anthropic + @Test(arguments: [Provider.anthropic, .gemini], [false, true]) + func localToolImagesSurviveDeletionAndTranscriptRestoration(_ provider: Provider, _ streaming: Bool) + async throws + { UsageURLProtocol.reset() UsageURLProtocol.enqueue( json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true)) ) - if streaming { - UsageURLProtocol.enqueue(json: try provider.stream()) + if streaming || provider == .gemini { + UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response())) } UsageURLProtocol.enqueue(json: try Self.json(provider.response())) let url = FileManager.default.temporaryDirectory @@ -499,17 +500,31 @@ import Testing try testImageData.write(to: url) defer { try? FileManager.default.removeItem(at: url) } let session = provider.makeSession(tools: [LocalImageWeatherTool(url: url)]) + session.toolExecutionDelegate = DeleteImageAfterToolOutput(url: url) if streaming { _ = try await session.streamResponse(to: "Weather?").collect() } else { _ = try await session.respond(to: "Weather?") } - _ = try await session.respond(to: "Review the image") + #expect(!FileManager.default.fileExists(atPath: url.path)) + let archived = try JSONEncoder().encode(session.transcript) + let restored = provider.makeSession(transcript: try JSONDecoder().decode(Transcript.self, from: archived)) + _ = try await restored.respond(to: "Review the image") - #expect(UsageURLProtocol.recordedBodies.count == (streaming ? 3 : 2)) + #expect(UsageURLProtocol.recordedBodies.count == (streaming || provider == .gemini ? 3 : 2)) for body in UsageURLProtocol.recordedBodies.dropFirst() { let request = try JSONDecoder().decode([String: JSONValue].self, from: body) + if provider == .gemini { + let contents = try #require(request["contents"]?.arrayValue) + let results = contents.flatMap { $0.objectValue?["parts"]?.arrayValue ?? [] } + .compactMap { $0.objectValue?["functionResponse"]?.objectValue } + let parts = try #require(results.first?["parts"]?.arrayValue) + let inlineData = try #require(parts.first?.objectValue?["inlineData"]?.objectValue) + #expect(inlineData["mime_type"] == .string("image/png")) + #expect(inlineData["data"] == .string(testImageData.base64EncodedString())) + continue + } let messages = try #require(request["messages"]?.arrayValue) let results = messages.flatMap { $0.objectValue?["content"]?.arrayValue ?? [] } .filter { $0.objectValue?["type"] == .string("tool_result") } @@ -523,6 +538,23 @@ import Testing } } + private struct DeleteImageAfterToolOutput: ToolExecutionDelegate { + let url: URL + + func didExecuteToolCall( + _ toolCall: Transcript.ToolCall, + output: Transcript.ToolOutput, + in session: LanguageModelSession + ) async { + guard case .image(let image)? = output.segments.first else { + Issue.record("Missing image in Tool output") + return + } + #expect(image.source == .data(testImageData, mimeType: "image/png")) + do { try FileManager.default.removeItem(at: url) } catch { Issue.record(error) } + } + } + private struct LocalImageWeatherTool: Tool { let name = "getWeather" let description = "Get weather as an image" From 0a3580bc97e421f23d6d04ff4f93e695f2b0c2e3 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 23:52:14 +0800 Subject: [PATCH 36/53] Match HistoryView collection shape and bind session properties in context tools --- .../LanguageModelSession.swift | 39 ++++++--- .../AnyLanguageModel/SessionProperties.swift | 81 +++++++++++++++++-- ...PICompatibilityAnyLanguageModelTests.swift | 17 ++++ ...PICompatibilityFoundationModelsTests.swift | 18 +++++ .../DynamicInstructionsTests.swift | 2 +- .../HistoryViewTests.swift | 47 +++++++++++ .../MLXLanguageModelTests.swift | 2 +- .../NativeSessionPropertyTests.swift | 50 ++++++++++++ .../RequestContextTests.swift | 2 +- .../SessionPropertyTests.swift | 74 +++++++++++++++-- .../Shared/MockLanguageModel.swift | 2 +- 11 files changed, 308 insertions(+), 26 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/HistoryViewTests.swift create mode 100644 Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 86f00d82..31e464d9 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -141,6 +141,7 @@ public final class LanguageModelSession: @unchecked Sendable { /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. + @_spi(Compatibility) public struct RequestContext: Sendable { /// The transcript to send with the request. public let transcript: Transcript @@ -160,22 +161,36 @@ public final class LanguageModelSession: @unchecked Sendable { /// Returns the transcript, instructions, and tools for the next model request. /// - /// Calling this method doesn't change the session. + /// Dynamic instructions may read and update custom session properties while + /// being resolved. Their history snapshot is read-only and instructions are + /// not committed to the canonical transcript. /// /// - Note: This API is exclusive to AnyLanguageModel /// and using it means your code is no longer drop-in compatible /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. + @_spi(Compatibility) nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { - return RequestContext(transcript: transcript, instructions: instructions, tools: tools) + let transcript = transcript + let history = Transcript.HistoryView( + transcript.filter { + if case .instructions = $0 { return false } + return true + } + ) + return RequestContext( + transcript: transcript, + instructions: instructions, + tools: tools.map { $0.bindingSessionProperties(properties, history: history) } + ) } // Evaluate the body for this request. // The resolved instructions go into the request's transcript, // never into the session's. - let canonicalHistory = Array(transcript) + let canonicalHistory = Transcript.HistoryView(transcript) let resolved = dynamicInstructionsLock.withLock { SessionPropertyBinding.$values.withValue(properties) { SessionPropertyBinding.$history.withValue(canonicalHistory) { @@ -183,7 +198,7 @@ public final class LanguageModelSession: @unchecked Sendable { } } } - var requestTranscript = Transcript(entries: canonicalHistory) + var requestTranscript = Transcript(entries: Array(canonicalHistory)) if let instructions = resolved.instructions { let instructionsEntry = Transcript.Entry.instructions( Transcript.Instructions( @@ -195,7 +210,11 @@ public final class LanguageModelSession: @unchecked Sendable { ) requestTranscript = Transcript(entries: [instructionsEntry] + requestTranscript) } - return RequestContext(transcript: requestTranscript, instructions: resolved.instructions, tools: resolved.tools) + return RequestContext( + transcript: requestTranscript, + instructions: resolved.instructions, + tools: resolved.tools.map { $0.bindingSessionProperties(properties, history: canonicalHistory) } + ) } /// Creates a session with a model, tools, @@ -349,10 +368,12 @@ public final class LanguageModelSession: @unchecked Sendable { self.properties = SessionPropertyValues( historyGetter: { state.withLock { locked in - locked.transcript.filter { entry in - if case .instructions = entry { return false } - return true - } + Transcript.HistoryView( + locked.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } + ) } }, historySetter: { history in diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index b7b968d2..ecc300d2 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -6,7 +6,50 @@ extension Transcript { /// /// - Note: This API is exclusive to AnyLanguageModel on OS 26. It mirrors /// Foundation Models 27's `Transcript.HistoryView` role with value semantics. - public typealias HistoryView = [Entry] + public struct HistoryView: RandomAccessCollection, MutableCollection, RangeReplaceableCollection, + ExpressibleByArrayLiteral, Sendable + { + public typealias Element = Entry + public typealias SubSequence = HistoryView + public typealias Indices = Range + private var entries: ArraySlice + + public struct Index: Comparable, Hashable, Strideable, Sendable { + fileprivate let offset: Int + + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.offset < rhs.offset } + public func distance(to other: Self) -> Int { other.offset - offset } + public func advanced(by n: Int) -> Self { Self(offset: offset + n) } + } + + public init() { entries = [] } + public init(_ elements: S) where S.Element == Entry { entries = Array(elements)[...] } + public init(arrayLiteral elements: Entry...) { entries = elements[...] } + private init(entries: ArraySlice) { self.entries = entries } + + public var startIndex: Index { Index(offset: entries.startIndex) } + public var endIndex: Index { Index(offset: entries.endIndex) } + public var indices: Indices { startIndex ..< endIndex } + public func index(after i: Index) -> Index { i.advanced(by: 1) } + public func index(before i: Index) -> Index { i.advanced(by: -1) } + public func index(_ i: Index, offsetBy distance: Int) -> Index { i.advanced(by: distance) } + public func distance(from start: Index, to end: Index) -> Int { start.distance(to: end) } + + public subscript(position: Index) -> Entry { + get { entries[position.offset] } + set { entries[position.offset] = newValue } + } + + public subscript(bounds: Range) -> HistoryView { + get { HistoryView(entries: entries[bounds.lowerBound.offset ..< bounds.upperBound.offset]) } + set { replaceSubrange(bounds, with: newValue) } + } + + public mutating func replaceSubrange(_ subrange: Range, with newElements: C) + where C.Element == Entry { + entries.replaceSubrange(subrange.lowerBound.offset ..< subrange.upperBound.offset, with: newElements) + } + } } /// A type-keyed session property. @@ -24,7 +67,7 @@ public protocol SessionPropertyKey: SendableMetatype { public macro SessionPropertyEntry() = #externalMacro(module: "AnyLanguageModelMacros", type: "SessionPropertyEntryMacro") -/// Session-scoped storage available while dynamic instructions are evaluated. +/// Session-scoped storage shared by dynamic instructions and Tools. /// /// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors /// Foundation Models 27. @@ -56,7 +99,7 @@ public final class SessionPropertyValues: @unchecked Sendable { _withSessionPropertyMutation(keyPath: \.history) { precondition( SessionPropertyBinding.values !== self || SessionPropertyBinding.history == nil, - "Session history is read-only while dynamic instructions are active" + "Session history is read-only while dynamic instructions or a Tool is active" ) historySetter(newValue) } @@ -121,7 +164,7 @@ extension LanguageModelSession { get { guard let values = SessionPropertyBinding.values else { preconditionFailure( - "SessionProperty can only be accessed while dynamic instructions are active" + "SessionProperty can only be accessed while dynamic instructions or a Tool is active" ) } return values[keyPath: keyPath] @@ -129,7 +172,7 @@ extension LanguageModelSession { nonmutating set { guard let values = SessionPropertyBinding.values else { preconditionFailure( - "SessionProperty can only be accessed while dynamic instructions are active" + "SessionProperty can only be accessed while dynamic instructions or a Tool is active" ) } values[keyPath: keyPath] = newValue @@ -143,3 +186,31 @@ extension LanguageModelSession.SessionProperty: @unchecked Sendable where Value: extension DynamicInstructions { public typealias SessionProperty = LanguageModelSession.SessionProperty } + +extension Tool { + func bindingSessionProperties(_ properties: SessionPropertyValues, history: Transcript.HistoryView) -> any Tool { + SessionPropertyTool(wrapped: self, properties: properties, history: history) + } +} + +private struct SessionPropertyTool: Tool { + let wrapped: Wrapped + let properties: SessionPropertyValues + let history: Transcript.HistoryView + + var name: String { wrapped.name } + var description: String { wrapped.description } + var parameters: GenerationSchema { wrapped.parameters } + var includesSchemaInInstructions: Bool { wrapped.includesSchemaInInstructions } + + func call(arguments: Wrapped.Arguments) async throws -> Wrapped.Output { + let history = + SessionPropertyBinding.values === properties + ? SessionPropertyBinding.history ?? history : history + return try await SessionPropertyBinding.$values.withValue(properties) { + try await SessionPropertyBinding.$history.withValue(history) { + try await wrapped.call(arguments: arguments) + } + } + } +} diff --git a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift index 2afe9d3e..67f509c8 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -10,6 +10,23 @@ import Testing return false }() + @Test func anyLanguageModelHistoryViewCollectionCompatibility() { + func requireContract(_ history: C) + where + C: RandomAccessCollection & MutableCollection & RangeReplaceableCollection + & ExpressibleByArrayLiteral & Sendable, + C.Element == Transcript.Entry, C.SubSequence == C, C.Index: Hashable & Strideable, + C.Index.Stride == Int + {} + var history: Transcript.HistoryView = [] + let empty: Transcript.HistoryView = history[history.startIndex ..< history.endIndex] + history[history.startIndex ..< history.endIndex] = empty + history.append(contentsOf: empty) + history.replaceSubrange(history.startIndex ..< history.endIndex, with: empty) + requireContract(history) + #expect(history.isEmpty) + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) extension SessionPropertyValues { @SessionPropertyEntry var compatibilityRevision = 0 diff --git a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift index d8d0c51e..79cfe2d3 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -12,6 +12,24 @@ import Testing #if compiler(>=6.4) #if os(macOS) || os(iOS) || os(visionOS) + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + @Test func foundationModelsHistoryViewCollectionCompatibility() { + func requireContract(_ history: C) + where + C: RandomAccessCollection & MutableCollection & RangeReplaceableCollection + & ExpressibleByArrayLiteral & Sendable, + C.Element == Transcript.Entry, C.SubSequence == C, C.Index: Hashable & Strideable, + C.Index.Stride == Int + {} + var history: Transcript.HistoryView = [] + let empty: Transcript.HistoryView = history[history.startIndex ..< history.endIndex] + history[history.startIndex ..< history.endIndex] = empty + history.append(contentsOf: empty) + history.replaceSubrange(history.startIndex ..< history.endIndex, with: empty) + requireContract(history) + #expect(history.isEmpty) + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) extension SessionPropertyValues { @SessionPropertyEntry var compatibilityRevision = 0 diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift index af43ad37..baba7aa1 100644 --- a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel @Suite("Dynamic instructions") struct DynamicInstructionsTests { diff --git a/Tests/AnyLanguageModelTests/HistoryViewTests.swift b/Tests/AnyLanguageModelTests/HistoryViewTests.swift new file mode 100644 index 00000000..57ee4107 --- /dev/null +++ b/Tests/AnyLanguageModelTests/HistoryViewTests.swift @@ -0,0 +1,47 @@ +import Testing + +import AnyLanguageModel + +@Suite("HistoryView collection contract") +struct HistoryViewTests { + private func entry(_ text: String) -> Transcript.Entry { + .prompt(.init(segments: [.text(.init(content: text))])) + } + + @Test func mutableSlicesPreserveIndicesAndValueSemantics() { + let first = entry("first") + let second = entry("second") + let third = entry("third") + var history: Transcript.HistoryView = [first, second, third] + let original = history + let middle = history.index(after: history.startIndex) + var slice: Transcript.HistoryView = history[middle ..< history.endIndex] + + #expect(slice.startIndex == middle) + #expect(slice.endIndex == history.endIndex) + #expect(slice.distance(from: slice.startIndex, to: slice.endIndex) == 2) + #expect(slice.index(before: slice.endIndex) == history.index(history.startIndex, offsetBy: 2)) + #expect(Array(slice.indices) == [middle, middle.advanced(by: 1)]) + #expect(slice.startIndex.distance(to: slice.endIndex) == 2) + slice[slice.startIndex] = first + #expect(Array(history) == [first, second, third]) + history[middle ..< history.endIndex] = slice + #expect(Array(history) == [first, first, third]) + #expect(Array(original) == [first, second, third]) + } + + @Test func rangeReplacementAndCollectionInitializers() { + let first = entry("first") + let second = entry("second") + let third = entry("third") + var history = Transcript.HistoryView([first, second]) + history.replaceSubrange(history.startIndex ..< history.index(after: history.startIndex), with: [third, first]) + #expect(Array(history) == [third, first, second]) + history.append(third) + history.append(contentsOf: [second]) + history.removeFirst() + #expect(Array(history) == [first, second, third, second]) + history.removeAll() + #expect(history.isEmpty) + } +} diff --git a/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift b/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift index 18841c72..3e931d8f 100644 --- a/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel #if MLX import class MLX.MLXArray diff --git a/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift new file mode 100644 index 00000000..595b0714 --- /dev/null +++ b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift @@ -0,0 +1,50 @@ +import Testing + +#if canImport(FoundationModels) && !os(tvOS) && !os(watchOS) + import FoundationModels + @_spi(Compatibility) @testable import AnyLanguageModel + + @Suite("Native Tool session-property binding") + struct NativeSessionPropertyTests { + @Test func nativeWrappersBindPropertiesWithoutTaskLocalInheritance() async throws { + guard #available(macOS 26.0, iOS 26.0, visionOS 26.0, *) else { return } + let first = AnyLanguageModel.LanguageModelSession(model: MockLanguageModel(), tools: [NativePropertyTool()]) + let second = AnyLanguageModel.LanguageModelSession( + model: MockLanguageModel(), + tools: [NativePropertyTool()] + ) + first.properties.revision = 1 + second.properties.revision = 20 + let firstTool = try #require(first.resolvedRequestContext().tools.toFoundationModels().first) + let secondTool = try #require(second.resolvedRequestContext().tools.toFoundationModels().first) + + let firstCall = Task.detached { try await callNativeTool(firstTool) } + let secondCall = Task.detached { try await callNativeTool(secondTool) } + try await firstCall.value + try await secondCall.value + + #expect(first.properties.revision == 2) + #expect(second.properties.revision == 21) + #expect(AnyLanguageModel.SessionPropertyBinding.values == nil) + } + } + + @available(macOS 26.0, iOS 26.0, visionOS 26.0, *) + private func callNativeTool(_ tool: T) async throws { + #expect(AnyLanguageModel.SessionPropertyBinding.values == nil) + let arguments = try T.Arguments(FoundationModels.GeneratedContent(json: "\"read\"")) + _ = try await tool.call(arguments: arguments) + } + + private struct NativePropertyTool: AnyLanguageModel.Tool { + @AnyLanguageModel.LanguageModelSession.SessionProperty(\.revision) private var revision + let name = "property" + let description = "Update a session property" + var parameters: AnyLanguageModel.GenerationSchema { String.generationSchema } + + func call(arguments: String) async throws -> String { + revision += 1 + return "done" + } + } +#endif diff --git a/Tests/AnyLanguageModelTests/RequestContextTests.swift b/Tests/AnyLanguageModelTests/RequestContextTests.swift index a39cbf7a..5503f183 100644 --- a/Tests/AnyLanguageModelTests/RequestContextTests.swift +++ b/Tests/AnyLanguageModelTests/RequestContextTests.swift @@ -1,6 +1,6 @@ import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel @Suite("Request context") struct RequestContextTests { diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index 00311fdc..df99a875 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -2,7 +2,7 @@ import Foundation import Observation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel extension SessionPropertyValues { @SessionPropertyEntry var revision = 0 @@ -18,6 +18,42 @@ enum SessionPropertyMode { @Suite("Session properties") struct SessionPropertyTests { + @Test(arguments: [false, true]) + func contextToolsCarryPropertiesIntoDetachedExecution(dynamic: Bool) async throws { + let session = + dynamic + ? LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: PropertyToolInstructions() + ) + : LanguageModelSession( + model: SessionPropertyFixtureModel(), + tools: [PropertyBoundTool()], + instructions: "Static" + ) + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + session.properties.history = [earlier] + session.properties.revision = 7 + let context = session.resolvedRequestContext() + let tool = try #require(context.tools.first) + let output = try await Task.detached { + #expect(SessionPropertyBinding.values == nil) + return try await tool.makeOutputSegments(from: GeneratedContent("read")) + }.value + + switch try #require(output.first) { + case .text(let text): + #expect(text.content == "revision:7 history:1") + case .structure(let structured): + #expect(try String(structured.content) == "revision:7 history:1") + case .image: + Issue.record("Unexpected image output") + } + #expect(session.properties.revision == 8) + #expect(Array(session.properties.history) == [earlier]) + #expect(SessionPropertyBinding.values == nil) + } + @Test func propertyDefaultsCanReadAnotherProperty() { let session = LanguageModelSession(model: SessionPropertyFixtureModel()) SessionPropertyBinding.$values.withValue(session.properties) { @@ -55,7 +91,7 @@ struct SessionPropertyTests { resumeContinuation.yield() #expect(try await request.value == "Done") - #expect(session.properties.history == [replacement]) + #expect(Array(session.properties.history) == [replacement]) #expect(session.transcript.count == 2) #expect(!session.isResponding) } @@ -72,7 +108,7 @@ struct SessionPropertyTests { resumeContinuation.yield() await #expect(throws: PausedHistoryError.self) { try await request.value } - #expect(session.properties.history == [replacement]) + #expect(Array(session.properties.history) == [replacement]) #expect(!session.isResponding) } @@ -105,7 +141,7 @@ struct SessionPropertyTests { #expect(context.instructions?.description == "History count 1") #expect(context.transcript.count == 2) - #expect(session.properties.history == [prompt]) + #expect(Array(session.properties.history) == [prompt]) } @Test func requestUsesOneHistorySnapshotDuringDynamicResolution() async { @@ -139,7 +175,7 @@ struct SessionPropertyTests { return true } == [initial] ) - #expect(session.properties.history == [replacement]) + #expect(Array(session.properties.history) == [replacement]) } @Test func replacingHistoryNotifiesTranscriptObservers() { @@ -230,7 +266,7 @@ struct SessionPropertyTests { session.properties.history = [prompt] - #expect(session.properties.history == [prompt]) + #expect(Array(session.properties.history) == [prompt]) #expect(session.transcript.count == 2) guard case .instructions = session.transcript[0] else { Issue.record("Expected the static instructions entry to remain first") @@ -253,7 +289,7 @@ struct SessionPropertyTests { #expect(context.instructions?.description == "Other history count 1") #expect(session.properties.history.isEmpty) - #expect(other.properties.history == [replacement]) + #expect(Array(other.properties.history) == [replacement]) } @Test func replacingHistoryRejectsForeignInstructions() { @@ -265,7 +301,7 @@ struct SessionPropertyTests { dynamicInstructions: SessionPropertyHistoryInstructions() ) - session.properties.history = Array(other.transcript) + session.properties.history = .init(other.transcript) let context = session.resolvedRequestContext() #expect(session.transcript == Transcript(entries: [prompt])) @@ -274,6 +310,28 @@ struct SessionPropertyTests { } } +private struct PropertyToolInstructions: DynamicInstructions { + var body: some DynamicInstructions { + Instructions("Property Tool") + PropertyBoundTool() + } +} + +private struct PropertyBoundTool: Tool { + @LanguageModelSession.SessionProperty(\.revision) private var revision + @LanguageModelSession.SessionProperty(\.history) private var history + + let name = "property" + let description = "Read and update session properties" + var parameters: GenerationSchema { String.generationSchema } + + func call(arguments: String) async throws -> String { + let result = "revision:\(revision) history:\(history.count)" + revision += 1 + return result + } +} + private enum BasePropertyKey: SessionPropertyKey { static let defaultValue = 42 } diff --git a/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift b/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift index 7fa23bc1..e4e46c98 100644 --- a/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift +++ b/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift @@ -1,4 +1,4 @@ -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel struct MockLanguageModel: LanguageModel { enum UnavailableReason: Hashable, Sendable { From 572fe1e8293af39f8f2ad6ef02516c3f716066b6 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 18 Oct 2026 00:08:07 +0800 Subject: [PATCH 37/53] Integrate Anthropic profile Tool continuations without changing ordinary requests --- .../Models/AnthropicLanguageModel.swift | 353 ++++++++++-------- .../AnthropicProfileTests.swift | 53 +++ 2 files changed, 241 insertions(+), 165 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/AnthropicProfileTests.swift diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 7a6c190a..a741d871 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -27,7 +27,7 @@ import OrderedCollections /// betas: ["beta1", "beta2"] /// ) /// ``` -public struct AnthropicLanguageModel: LanguageModel { +public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLanguageModel { /// Custom generation options specific to Anthropic's Claude API. /// /// Reached through `GenerationOptions[custom: AnthropicLanguageModel.self]`, @@ -370,6 +370,7 @@ public struct AnthropicLanguageModel: LanguageModel { public let model: String private let httpSession: HTTPSession + let profileRequestIdentity = UUID() /// Creates an Anthropic language model. /// @@ -445,99 +446,138 @@ public struct AnthropicLanguageModel: LanguageModel { ) async throws -> LanguageModelSession.Response where Content: Generable { let url = baseURL.appendingPathComponent("v1/messages") let headers = buildHeaders() - let requestContext = session.resolvedRequestContext() - - // Convert available tools to Anthropic format - let anthropicTools: [AnthropicTool] = try requestContext.tools.map { tool in - try convertToolToAnthropicFormat(tool) - } - let responseSchema = type == String.self ? nil : try convertSchemaToAnthropicFormat(schema) - let params = try createMessageParams( - model: model, - system: nil, - messages: try requestContext.transcript.toAnthropicMessages(), - tools: anthropicTools.isEmpty ? nil : anthropicTools, - responseSchema: responseSchema, - options: options - ) - - let body = try JSONEncoder().encode(params) - - let message: AnthropicMessageResponse = try await httpSession.fetch( - .post, - url: url, - headers: headers, - body: body - ) - - var entries: [Transcript.Entry] = message.content.compactMap { block in - switch block { - case .thinking(let thinking): return .reasoning(thinking.transcriptReasoning()) - case .redactedThinking(let redacted): return .reasoning(redacted.transcriptReasoning()) - default: return nil + var entries: [Transcript.Entry] = [] + var usage = LanguageModelSession.Usage.zero + var earlierText = "" + var inFlightMessages: [AnthropicMessage] = [] + let toolRounds = session.toolRoundLimit(for: "Anthropic") + + while true { + let requestContext = try await session.resolvedRequestContext( + including: entries, + options: options + ) + if let continuation = try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: entries, + prefixUsage: usage, + prefixText: earlierText + ) { + return continuation } - } - let usage = message.usage?.reportedUsage?.value ?? .zero - - // Handle tool calls, if present - let toolUses: [AnthropicToolUse] = message.content.compactMap { block in - if case .toolUse(let u) = block { return u } - return nil - } - - if !toolUses.isEmpty { - let resolution = try await resolveToolUses( - toolUses, - tools: requestContext.tools, - session: session + let anthropicTools: [AnthropicTool] = try requestContext.tools.map { + try convertToolToAnthropicFormat($0) + } + let params = try createMessageParams( + model: model, + system: nil, + messages: try anthropicMessages( + from: requestContext.transcript, + replacing: entries, + with: inFlightMessages + ), + tools: anthropicTools.isEmpty ? nil : anthropicTools, + responseSchema: responseSchema, + options: requestContext.options ) - switch resolution { - case .stop(let calls): - if !calls.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(calls))) + let message: AnthropicMessageResponse = try await httpSession.fetch( + .post, + url: url, + headers: headers, + body: try JSONEncoder().encode(params) + ) + usage.add(message.usage?.reportedUsage?.value ?? .zero) + + let reasoningEntries: [Transcript.Entry] = message.content.compactMap { block in + switch block { + case .thinking(let thinking): return .reasoning(thinking.transcriptReasoning()) + case .redactedThinking(let redacted): return .reasoning(redacted.transcriptReasoning()) + default: return nil } - let empty = try emptyResponseContent(for: type) - return LanguageModelSession.Response( - content: empty.content, - rawContent: empty.rawContent, - transcriptEntries: ArraySlice(entries), - usage: usage + } + entries.append(contentsOf: reasoningEntries) + let toolUses: [AnthropicToolUse] = message.content.compactMap { block in + if case .toolUse(let use) = block { return use } + return nil + } + if !toolUses.isEmpty { + try toolRounds.record() + try await session.profileDidProduceEntries( + reasoningEntries, + requestContext: requestContext, + currentRoundEntries: entries ) - case .invocations(let invocations): - if !invocations.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call)))) + switch try await resolveToolUses( + toolUses, + requestContext: requestContext, + currentRoundEntries: entries, + session: session + ) { + case .stop(let calls): + entries.append(.toolCalls(.init(calls))) + let empty = try emptyResponseContent(for: type) + return .init( + content: empty.content, + rawContent: empty.rawContent, + transcriptEntries: ArraySlice(entries), + usage: usage + ) + case .invocations(let invocations): + inFlightMessages.append(.init(role: .assistant, content: message.content)) + entries.append(.toolCalls(.init(invocations.map(\.call)))) + var results: [AnthropicContent] = [] for invocation in invocations { entries.append(.toolOutput(invocation.output)) + results.append( + .toolResult( + .init( + toolUseId: invocation.call.id, + content: convertSegmentsToAnthropicContent(invocation.output.segments) + ) + ) + ) + } + inFlightMessages.append(.init(role: .user, content: results)) + if requestContext.hasDynamicProfile { + if type == String.self { + earlierText += message.content.compactMap { block -> String? in + if case .text(let text) = block { return text.text } + return nil + }.joined() + } + continue } } } - } - let text = message.content.compactMap { block -> String? in - switch block { - case .text(let t): return t.text - default: return nil + let text = + earlierText + + message.content.compactMap { block -> String? in + if case .text(let text) = block { return text.text } + return nil + }.joined() + if type == String.self { + return .init( + content: text as! Content, + rawContent: GeneratedContent(text), + transcriptEntries: ArraySlice(entries), + usage: usage + ) } - }.joined() - - if type == String.self { - return LanguageModelSession.Response( - content: text as! Content, - rawContent: GeneratedContent(text), + let rawContent = try GeneratedContent(json: text) + return .init( + content: try Content(rawContent), + rawContent: rawContent, transcriptEntries: ArraySlice(entries), usage: usage ) } - - let rawContent = try GeneratedContent(json: text) - let content = try Content(rawContent) - return LanguageModelSession.Response( - content: content, - rawContent: rawContent, - transcriptEntries: ArraySlice(entries), - usage: usage - ) } public func streamResponse( @@ -593,21 +633,43 @@ public struct AnthropicLanguageModel: LanguageModel { type == String.self ? nil : try convertSchemaToAnthropicFormat(schema) var inFlightMessages: [AnthropicMessage] = [] var state = StreamingResponseState() - var toolRounds = ToolRoundLimit(provider: "Anthropic") + let toolRounds = session.toolRoundLimit(for: "Anthropic") while true { try Task.checkCancellation() - let requestContext = session.resolvedRequestContext() + let requestContext = try await session.resolvedRequestContext( + including: state.entries, + options: options + ) + if let handoff = session.profileContinuationStream( + ifSelectedModelDiffersFrom: profileRequestIdentity, + requestContext: requestContext, + prompt: prompt, + generating: type, + schema: schema, + includeSchemaInPrompt: includeSchemaInPrompt, + prefixEntries: state.entries, + prefixUsage: state.totalUsage, + prefixText: state.responseText + ) { + for try await snapshot in handoff { continuation.yield(snapshot) } + continuation.finish() + return + } let anthropicTools: [AnthropicTool] = try requestContext.tools.map { try convertToolToAnthropicFormat($0) } - let messages = try requestContext.transcript.toAnthropicMessages() + inFlightMessages + let messages = try anthropicMessages( + from: requestContext.transcript, + replacing: state.entries, + with: inFlightMessages + ) var params = try createMessageParams( model: model, system: nil, messages: messages, tools: anthropicTools.isEmpty ? nil : anthropicTools, responseSchema: responseSchema, - options: options + options: requestContext.options ) params["stream"] = .bool(true) let body = try JSONEncoder().encode(params) @@ -696,9 +758,15 @@ public struct AnthropicLanguageModel: LanguageModel { guard !toolUses.isEmpty else { break } try Task.checkCancellation() try toolRounds.record() + try await session.profileDidProduceEntries( + state.entries, + requestContext: requestContext, + currentRoundEntries: state.entries + ) switch try await resolveToolUses( toolUses, - tools: requestContext.tools, + requestContext: requestContext, + currentRoundEntries: state.entries, session: session ) { case .stop(let calls): @@ -856,6 +924,31 @@ private func createMessageParams( // MARK: - Tool Invocation Handling +private func anthropicMessages( + from transcript: Transcript, + replacing currentRoundEntries: [Transcript.Entry], + with continuation: [AnthropicMessage] +) throws -> [AnthropicMessage] { + guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { + return try transcript.toAnthropicMessages() + } + let projected = Array(transcript) + let count = currentRoundEntries.count + let ranges = projected.indices.compactMap { start -> Range? in + let end = start + count + guard end <= projected.count, + Array(projected[start ..< end]) == currentRoundEntries + else { return nil } + return start ..< end + } + guard ranges.count == 1, let range = ranges.first else { + return try transcript.toAnthropicMessages() + } + return try Transcript(entries: projected[.. ToolResolutionOutcome { if toolUses.isEmpty { return .invocations([]) } - var toolsByName: [String: any Tool] = [:] - for tool in tools { - if toolsByName[tool.name] == nil { - toolsByName[tool.name] = tool - } - } - var transcriptCalls: [Transcript.ToolCall] = [] transcriptCalls.reserveCapacity(toolUses.count) for use in toolUses { @@ -927,82 +1014,18 @@ private func resolveToolUses( ) } - 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) - } - } + let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + switch try await session.resolveProfileToolCalls( + transcriptCalls, + requestContext: requestContext, + currentRoundEntries: entries + ) { + case .stop(let calls): + return .stop(calls: calls) + case .invocations(let invocations): + return .invocations(invocations.map { .init(call: $0.call, output: $0.output) }) } - - return .invocations(results) } // Convert our GenerationSchema into Anthropic's expected JSON Schema payload diff --git a/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift b/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift new file mode 100644 index 00000000..e3171d36 --- /dev/null +++ b/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift @@ -0,0 +1,53 @@ +import Testing + +@_spi(Compatibility) @testable import AnyLanguageModel + +#if canImport(Darwin) && !canImport(AsyncHTTPClient) + extension ProviderUsageTests { + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicProfileReevaluation(_ provider: Provider, _ streaming: Bool) async throws { + try await providerProfileReevaluatesBeforeEveryToolContinuation(provider, streaming) + } + + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicModelHandoff(_ provider: Provider, _ streaming: Bool) async throws { + try await providerProfileCanSwitchModelForToolContinuation(provider, streaming) + } + + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicSchemaPromptHandoff(_ provider: Provider, _ streaming: Bool) async throws { + try await modelHandoffsRestoreOriginalSchemaPromptOption(provider, streaming) + } + + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicHistoryAcrossHandoffs(_ provider: Provider, _ streaming: Bool) async throws { + try await modelHandoffsPreserveEarlierToolResultsAndHistoryWrites(provider, streaming) + } + + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicSharedToolRoundBudget(_ provider: Provider, _ streaming: Bool) async throws { + try await providerModelHandoffsShareToolRoundLimit(provider, streaming) + } + + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicFailedHandoffHistory(_ provider: Provider, _ streaming: Bool) async throws { + try await failedHandoffDoesNotPersistPendingHistory(provider, streaming) + } + + @Test(arguments: [Provider.anthropic], [false, true]) + func anthropicOrdinaryToolLoop(_ provider: Provider, _ streaming: Bool) async throws { + if streaming { + try await ordinaryWrapperKeepsTheProviderToolLoop(provider, streaming) + } else { + UsageURLProtocol.reset() + UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "", tool: true))) + let session = provider.makeSession(tools: [WeatherTool()]) + let response = try await session.respond(to: "Weather?") + #expect(response.content.isEmpty) + #expect(UsageURLProtocol.recordedBodies.count == 1) + #expect(response.transcriptEntries.count == 2) + #expect(session.transcript.count == 4) + } + } + } +#endif From 25d51f1ff5df796d00d618eaa675ad9bf2596369 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 19 Oct 2026 09:08:26 +0800 Subject: [PATCH 38/53] Preserve public request context and expose Tool session properties --- .../LanguageModelSession.swift | 2 -- .../AnyLanguageModel/SessionProperties.swift | 2 ++ .../RequestContextPublicAPITests.swift | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 31e464d9..63711343 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -141,7 +141,6 @@ public final class LanguageModelSession: @unchecked Sendable { /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. - @_spi(Compatibility) public struct RequestContext: Sendable { /// The transcript to send with the request. public let transcript: Transcript @@ -170,7 +169,6 @@ public final class LanguageModelSession: @unchecked Sendable { /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. - @_spi(Compatibility) nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { let transcript = transcript diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index ecc300d2..7f897e19 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -188,6 +188,8 @@ extension DynamicInstructions { } extension Tool { + public typealias SessionProperty = LanguageModelSession.SessionProperty + func bindingSessionProperties(_ properties: SessionPropertyValues, history: Transcript.HistoryView) -> any Tool { SessionPropertyTool(wrapped: self, properties: properties, history: history) } diff --git a/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift new file mode 100644 index 00000000..ee991bff --- /dev/null +++ b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift @@ -0,0 +1,26 @@ +import AnyLanguageModel +import Testing + +@Test func requestContextRemainsPublicWithoutSPI() { + let session = LanguageModelSession( + model: OpenAILanguageModel(apiKey: "unused", model: "test"), + tools: [PublicSessionPropertyTool()], + instructions: "Public request context" + ) + let context: LanguageModelSession.RequestContext = session.resolvedRequestContext() + #expect(context.transcript.count == session.transcript.count) + #expect(context.instructions?.description == "Public request context") + #expect(context.tools.map(\.name) == ["publicSessionProperty"]) + let _: PublicSessionPropertyTool.SessionProperty = .init(\.history) +} + +private struct PublicSessionPropertyTool: Tool { + @SessionProperty(\.history) private var history + + let name = "publicSessionProperty" + let description = "Read session history" + + func call(arguments: String) async throws -> String { + String(history.count) + } +} From 982cc0704c88710bc691c8bd5af1ca4f2f4a12c4 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 19 Oct 2026 09:10:41 +0800 Subject: [PATCH 39/53] Match the SDK history transform closure contract --- Sources/AnyLanguageModel/DynamicProfile.swift | 6 +++--- .../AnyLanguageModel/GenerationOptions.swift | 1 + ...APICompatibilityAnyLanguageModelTests.swift | 17 +++++++++++++++++ ...APICompatibilityFoundationModelsTests.swift | 18 ++++++++++++++++++ .../DynamicProfileTests.swift | 18 ++++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index f08c4d3d..51ce2c90 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -217,7 +217,7 @@ extension LanguageModelSession.DynamicProfile { } public func historyTransform( - _ transform: @Sendable @escaping ([Transcript.Entry]) -> [Transcript.Entry] + _ transform: @escaping ([Transcript.Entry]) -> [Transcript.Entry] ) -> some LanguageModelSession.DynamicProfile { BuiltinModifiedDynamicProfile(content: self, modifier: .historyTransform(transform)) } @@ -349,7 +349,7 @@ struct ResolvedDynamicProfile: @unchecked Sendable { var hasToolCallingMode = false var transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? var hasTranscriptErrorHandlingPolicy = false - var historyTransforms: [@Sendable ([Transcript.Entry]) -> [Transcript.Entry]] = [] + var historyTransforms: [([Transcript.Entry]) -> [Transcript.Entry]] = [] var onPrompt: [(Transcript.Prompt) async throws -> Void] = [] var onResponse: [(Transcript.Response) async throws -> Void] = [] var onReasoning: [(Transcript.Reasoning) async throws -> Void] = [] @@ -430,7 +430,7 @@ private enum BuiltinDynamicProfileModifier: @unchecked Sendable { case reasoningLevel(ContextOptions.ReasoningLevel?) case includeSchemaInPrompt(Bool?) case toolCallingMode(GenerationOptions.ToolCallingMode?) - case historyTransform(@Sendable ([Transcript.Entry]) -> [Transcript.Entry]) + case historyTransform(([Transcript.Entry]) -> [Transcript.Entry]) case transcriptErrorHandlingPolicy(TranscriptErrorHandlingPolicy?) case onPrompt((Transcript.Prompt) async throws -> Void) case onResponse((Transcript.Response) async throws -> Void) diff --git a/Sources/AnyLanguageModel/GenerationOptions.swift b/Sources/AnyLanguageModel/GenerationOptions.swift index 56cd4fc4..cfe15d38 100644 --- a/Sources/AnyLanguageModel/GenerationOptions.swift +++ b/Sources/AnyLanguageModel/GenerationOptions.swift @@ -156,6 +156,7 @@ public struct GenerationOptions: Sendable, Equatable { precondition(maximumToolRounds >= 1, "maximumToolRounds must be at least 1, got \(maximumToolRounds)") } } + /// Storage for model-specific custom options. private var customOptionsStorage: CustomOptionsStorage = .init() diff --git a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift index de982bb6..9d2eb3bb 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -65,6 +65,23 @@ import Testing } } + @Test func anyLanguageModelNonSendableHistoryTransformCompatibility() { + let captured = NonSendableHistoryCapture() + let transform: ([Transcript.Entry]) -> [Transcript.Entry] = { entries in + captured.calls += 1 + return entries + } + _ = LanguageModelSession.AnyDynamicProfile( + LanguageModelSession.Profile { Instructions("Projection") } + .historyTransform(transform) + ) + #expect(captured.calls == 0) + } + + private final class NonSendableHistoryCapture { + var calls = 0 + } + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test("AnyLanguageModel Drop-In Compatibility", .enabled(if: isSystemLanguageModelAvailable)) func anyLanguageModelCompatibility() async throws { diff --git a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift index 37b3eec8..8901e486 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -67,6 +67,24 @@ import Testing .transcriptErrorHandlingPolicy(nil) } } + + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + @Test func foundationModelsNonSendableHistoryTransformCompatibility() { + let captured = NonSendableHistoryCapture() + let transform: ([Transcript.Entry]) -> [Transcript.Entry] = { entries in + captured.calls += 1 + return entries + } + _ = LanguageModelSession.AnyDynamicProfile( + LanguageModelSession.Profile { Instructions("Projection") } + .historyTransform(transform) + ) + #expect(captured.calls == 0) + } + + private final class NonSendableHistoryCapture { + var calls = 0 + } #endif #endif diff --git a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift index a54909d8..45431adc 100644 --- a/Tests/AnyLanguageModelTests/DynamicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicProfileTests.swift @@ -51,6 +51,20 @@ struct DynamicProfileBuilderTests { #expect(profile.maximumResponseTokens == 64) } + @Test func historyTransformCanCaptureNonSendableState() { + let recorder = HistoryTransformRecorder() + let profile = LanguageModelSession.AnyDynamicProfile( + LanguageModelSession.Profile { Instructions("Projection") } + .historyTransform { entries in + recorder.entryIDs = entries.map(\.id) + return entries + } + ).resolveForRequest() + let history: [Transcript.Entry] = [.prompt(.init(segments: [.text(.init(content: "Hello"))]))] + #expect(profile.historyTransforms[0](history) == history) + #expect(recorder.entryIDs == history.map(\.id)) + } + @Test func historyTransformsAndCallbacksKeepCompositionOrder() async throws { let events = Locked<[String]>([]) let profile = LanguageModelSession.AnyDynamicProfile( @@ -87,6 +101,10 @@ struct DynamicProfileBuilderTests { } } +private final class HistoryTransformRecorder { + var entryIDs: [String] = [] +} + private struct ConditionalBuilderProfile: LanguageModelSession.DynamicProfile { let first: Bool From 89c00775e32fa195e8b80a75faa768e5e0064e4e Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 19 Oct 2026 09:15:06 +0800 Subject: [PATCH 40/53] Use request options for shared profile tool-round budgets --- .../LanguageModelSession.swift | 10 ++- .../ProfileToolRoundLimitTests.swift | 65 +++++++++++++++++++ .../Shared/ProfileProviderFixtures.swift | 6 +- 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/ProfileToolRoundLimitTests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 82d348e6..11291341 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -158,15 +158,19 @@ public final class LanguageModelSession: @unchecked Sendable { public let tools: [any Tool] /// The model selected for this request. + @_spi(Compatibility) public let model: any LanguageModel /// Generation options after profile defaults are combined with call-site options. + @_spi(Compatibility) public let options: GenerationOptions /// Context options selected by the active profile. + @_spi(Compatibility) public let contextOptions: ContextOptions /// Transcript failure behavior captured for this request. + @_spi(Compatibility) public let transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? fileprivate let profile: ResolvedDynamicProfile? @@ -253,11 +257,11 @@ public final class LanguageModelSession: @unchecked Sendable { return resolveRequestContext(additionalEntries: [], options: .init()) } - nonisolated func toolRoundLimit(for provider: String) -> ToolRoundLimit { + nonisolated func toolRoundLimit(for provider: String, options: GenerationOptions) -> ToolRoundLimit { guard let prepared = ProfileRequestBinding.prepared, prepared.session === self else { - return ToolRoundLimit(provider: provider) + return ToolRoundLimit(provider: provider, options: options) } return prepared.toolRoundLimit(for: provider) } @@ -2593,7 +2597,7 @@ private final class PreparedProfileRequest: @unchecked Sendable { func toolRoundLimit(for provider: String) -> ToolRoundLimit { toolRoundLimitStorage.withLock { limit in if let limit { return limit } - let created = ToolRoundLimit(provider: provider) + let created = ToolRoundLimit(provider: provider, options: callSiteOptions) limit = created return created } diff --git a/Tests/AnyLanguageModelTests/ProfileToolRoundLimitTests.swift b/Tests/AnyLanguageModelTests/ProfileToolRoundLimitTests.swift new file mode 100644 index 00000000..1aeec4a9 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ProfileToolRoundLimitTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@_spi(Compatibility) @testable import AnyLanguageModel + +@Test func ordinarySessionToolRoundLimitUsesRequestOptions() throws { + let session = LanguageModelSession(model: MockLanguageModel()) + var options = GenerationOptions() + options.maximumToolRounds = 2 + let budget = session.toolRoundLimit(for: "fixture", options: options) + try budget.record(completed: []) + try budget.record(completed: []) + #expect(throws: LanguageModelSession.ToolRoundLimitExceeded.self) { + try budget.record(completed: []) + } +} + +@Test func profileToolRoundLimitSharesOriginalCallSiteBudget() async throws { + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Budget") } + .model(ToolBudgetProbeModel()) + ) + var options = GenerationOptions() + options.maximumToolRounds = 2 + do { + _ = try await session.respond(to: "Use the configured budget", options: options) + Issue.record("Expected the third round to exceed the shared budget") + } catch let error as LanguageModelSession.ToolRoundLimitExceeded { + #expect(error.limit == 2) + #expect(error.provider == "first") + #expect(error.transcriptEntries.count == 1) + } +} + +private struct ToolBudgetProbeModel: LanguageModel { + typealias UnavailableReason = Never + var availability: Availability { .available } + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let first = session.toolRoundLimit(for: "first", options: .init()) + let second = session.toolRoundLimit(for: "second", options: .init()) + #expect(first === second) + try first.record(completed: []) + try second.record(completed: []) + let completed: [Transcript.Entry] = [.response(.init(assetIDs: [], segments: []))] + try second.record(completed: completed) + throw CancellationError() + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + fatalError("ToolBudgetProbeModel only supports nonstreaming tests") + } +} diff --git a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift index 59879940..fdd4ba39 100644 --- a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift +++ b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift @@ -84,7 +84,7 @@ import Testing func providerModelHandoffsShareToolRoundLimit(_ provider: Provider, _ streaming: Bool) async throws { UsageURLProtocol.reset() - let limit = ToolRoundLimit.maximumRounds + let limit = GenerationOptions().maximumToolRounds for round in 0 ... limit { if streaming { UsageURLProtocol.enqueue( @@ -109,7 +109,7 @@ import Testing ) ) - let description = await Self.decodingFailureDescription { + let exceeded = await Self.toolRoundLimitExceeded { if streaming { _ = try await session.streamResponse(to: "Weather?").collect() } else { @@ -117,7 +117,7 @@ import Testing } } - #expect(description?.contains("Exceeded maximum tool iterations (\(limit))") == true) + #expect(exceeded?.limit == limit) #expect(state.executions.withLock { $0.count } == limit) #expect(UsageURLProtocol.recordedBodies.count == limit + 1) } From cf756b173da31011c9419ac7d751a099d94f870f Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 19 Oct 2026 09:18:11 +0800 Subject: [PATCH 41/53] Preserve native Tool attachment snapshots through delegate execution --- .../Models/SystemLanguageModel.swift | 16 ++++--- .../SystemLanguageModelTests.swift | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 88a5e632..4eb80a16 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -744,9 +744,10 @@ Transcript.ToolOutput(id: call.id, toolName: tool.name, segments: segments) } guard let session, let delegate = session.toolExecutionDelegate else { - let text = try await tool.callFunction(content: arguments).promptRepresentation.description - await ToolCallRecord.current?.ran(call, output: output([.text(.init(content: text))])) - return FoundationModels.Prompt(text) + let segments = try await tool.makeOutputSegments(from: arguments) + let prompt = try segments.toFoundationModelsToolOutput() + await ToolCallRecord.current?.ran(call, output: output(segments)) + return prompt } await delegate.didGenerateToolCalls([call], in: session) switch await delegate.toolCallDecision(for: call, in: session) { @@ -761,10 +762,11 @@ return prompt case .execute: do { - let text = try await tool.callFunction(content: arguments).promptRepresentation.description - await delegate.didExecuteToolCall(call, output: output([.text(.init(content: text))]), in: session) - await ToolCallRecord.current?.ran(call, output: output([.text(.init(content: text))])) - return FoundationModels.Prompt(text) + let segments = try await tool.makeOutputSegments(from: arguments) + let prompt = try segments.toFoundationModelsToolOutput() + await delegate.didExecuteToolCall(call, output: output(segments), in: session) + await ToolCallRecord.current?.ran(call, output: output(segments)) + return prompt } catch { await delegate.didFailToolCall(call, error: error, in: session) throw error diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index dbee954d..4a70475e 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -323,6 +323,50 @@ import Testing } #if compiler(>=6.4) + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + @Test(arguments: [false, true]) + func ordinaryNativeToolImageSurvivesDeletionAndReplay(useDelegate: Bool) async throws { + let data = try onePixelPNG() + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("png") + try data.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let tool = NativeImageOutputTool(imageURL: url) + let session = LanguageModelSession(model: SystemLanguageModel.default, tools: [tool]) + if useDelegate { session.toolExecutionDelegate = ScriptedDelegate([.execute]) } + let record = ToolCallRecord() + try await ToolCallRecord.$current.withValue(record) { + _ = try await callTool(tool, with: GeneratedContent("image"), in: session) + } + try FileManager.default.removeItem(at: url) + let transcript = Transcript(entries: await record.entries) + let replay = try JSONDecoder().decode(Transcript.self, from: JSONEncoder().encode(transcript)) + let outputs = replay.compactMap { entry -> Transcript.ToolOutput? in + if case .toolOutput(let output) = entry { return output } + return nil + } + let output = try #require(outputs.first) + #expect(output.segments.count == 2) + guard case .image(let image) = output.segments.last else { + Issue.record("Expected the native Tool's image, not its text description") + return + } + #expect(image.source == .data(data, mimeType: "image/png")) + _ = try output.segments.toFoundationModelsToolOutput() + } + + private struct NativeImageOutputTool: Tool { + let imageURL: URL + let description = "Return an image" + + func call(arguments: String) async throws -> Prompt { + Prompt { + "Image output" + Attachment(imageURL: imageURL) + } + } + } + /// An image the delegate provides goes to Foundation Models as an attachment. @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) @Test func providedImageIsAttached() async throws { From 90c85f120f1ad0ed99273329e8d56a2d2190853c Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 20 Oct 2026 09:14:42 +0800 Subject: [PATCH 42/53] Scope continuation entries to Tool rounds and share wire replay --- Sources/AnyLanguageModel/DynamicProfile.swift | 8 +- .../LanguageModelSession.swift | 83 +++++++--------- .../AnyLanguageModel/SessionProperties.swift | 20 ++-- .../Shared/ToolContinuation.swift | 79 +++++++++++++++ .../Shared/ProfileProviderFixtures.swift | 82 ++++++++++++++++ .../ToolContinuationTests.swift | 98 +++++++++++++++++++ 6 files changed, 311 insertions(+), 59 deletions(-) create mode 100644 Sources/AnyLanguageModel/Shared/ToolContinuation.swift create mode 100644 Tests/AnyLanguageModelTests/ToolContinuationTests.swift diff --git a/Sources/AnyLanguageModel/DynamicProfile.swift b/Sources/AnyLanguageModel/DynamicProfile.swift index 223ea8df..f1cdd3d8 100644 --- a/Sources/AnyLanguageModel/DynamicProfile.swift +++ b/Sources/AnyLanguageModel/DynamicProfile.swift @@ -530,7 +530,7 @@ actor ProfileLifecycle { prompt: Transcript.Prompt?, callbackState: ProfileCallbackState, historyEdits: Locked<[Transcript.Entry]?>, - protectedEntryIDs: Set = [] + protectedEntryKeys: Set = [] ) async throws { let previous = pendingPreparation?.task nextPreparationID += 1 @@ -544,7 +544,7 @@ actor ProfileLifecycle { prompt: prompt, callbackState: callbackState, historyEdits: historyEdits, - protectedEntryIDs: protectedEntryIDs + protectedEntryKeys: protectedEntryKeys ) } pendingPreparation = (id, task) @@ -564,12 +564,12 @@ actor ProfileLifecycle { prompt: Transcript.Prompt?, callbackState: ProfileCallbackState, historyEdits: Locked<[Transcript.Entry]?>, - protectedEntryIDs: Set + protectedEntryKeys: Set ) async throws { let history = properties.historyBinding( history, isWritable: true, - protecting: protectedEntryIDs, + protecting: protectedEntryKeys, onChange: { updated in historyEdits.withLock { $0 = updated } } ) if activeProfile?.identity != profile.identity { diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 11291341..cf5cc952 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -175,7 +175,7 @@ public final class LanguageModelSession: @unchecked Sendable { fileprivate let profile: ResolvedDynamicProfile? fileprivate let canonicalTranscript: [Transcript.Entry] - fileprivate let pendingEntryIDs: Set + fileprivate let pendingEntryKeys: Set fileprivate let callbackState: ProfileCallbackState fileprivate let callbackHistory = Locked<[Transcript.Entry]?>(nil) @@ -191,7 +191,7 @@ public final class LanguageModelSession: @unchecked Sendable { transcriptErrorHandlingPolicy: TranscriptErrorHandlingPolicy? = nil, profile: ResolvedDynamicProfile? = nil, canonicalTranscript: [Transcript.Entry]? = nil, - pendingEntryIDs: Set = [], + pendingEntryKeys: Set = [], callbackState: ProfileCallbackState = ProfileCallbackState() ) { self.transcript = transcript @@ -203,7 +203,7 @@ public final class LanguageModelSession: @unchecked Sendable { self.transcriptErrorHandlingPolicy = transcriptErrorHandlingPolicy self.profile = profile self.canonicalTranscript = canonicalTranscript ?? Array(transcript) - self.pendingEntryIDs = pendingEntryIDs + self.pendingEntryKeys = pendingEntryKeys self.callbackState = callbackState } @@ -218,7 +218,7 @@ public final class LanguageModelSession: @unchecked Sendable { transcriptErrorHandlingPolicy: transcriptErrorHandlingPolicy, profile: profile, canonicalTranscript: canonicalTranscript, - pendingEntryIDs: pendingEntryIDs, + pendingEntryKeys: pendingEntryKeys, callbackState: context.callbackState ) } @@ -266,6 +266,14 @@ public final class LanguageModelSession: @unchecked Sendable { return prepared.toolRoundLimit(for: provider) } + nonisolated func recordToolRound(_ limit: ToolRoundLimit, completed: [Transcript.Entry]) throws { + if let prepared = ProfileRequestBinding.prepared, prepared.session === self { + try limit.record(completed: prepared.pendingEntries(including: completed)) + } else { + try limit.record(completed: completed) + } + } + nonisolated func profileIncludeSchemaInPrompt( for context: RequestContext, callSiteValue: Bool @@ -332,7 +340,7 @@ public final class LanguageModelSession: @unchecked Sendable { }.first, callbackState: context.callbackState, historyEdits: context.callbackHistory, - protectedEntryIDs: context.pendingEntryIDs + protectedEntryKeys: context.pendingEntryKeys ) } if let prepared, prepared.session === self { @@ -346,10 +354,11 @@ public final class LanguageModelSession: @unchecked Sendable { options: GenerationOptions ) -> RequestContext { let committed = Array(transcript) - var entryIDs = Set(committed.map(\.id)) - let pending = additionalEntries.filter { entryIDs.insert($0.id).inserted } - let protectedEntryIDs = Set(pending.map(\.id)) - var canonical = committed + pending + var entryKeys = Set(keyedTranscriptEntries(committed).map(\.key)) + let pending = keyedTranscriptEntries(additionalEntries) + .filter { entryKeys.insert($0.key).inserted } + let protectedEntryKeys = Set(pending.map(\.key)) + var canonical = committed + pending.map(\.entry) let sessionErrorPolicy = transcriptErrorHandlingPolicy guard let dynamicProfile else { guard let dynamicInstructions else { @@ -361,7 +370,7 @@ public final class LanguageModelSession: @unchecked Sendable { options: options, transcriptErrorHandlingPolicy: sessionErrorPolicy, canonicalTranscript: canonical, - pendingEntryIDs: protectedEntryIDs + pendingEntryKeys: protectedEntryKeys ) } @@ -389,14 +398,14 @@ public final class LanguageModelSession: @unchecked Sendable { options: options, transcriptErrorHandlingPolicy: sessionErrorPolicy, canonicalTranscript: canonical, - pendingEntryIDs: protectedEntryIDs + pendingEntryKeys: protectedEntryKeys ) } let history = properties.historyBinding( canonical, isWritable: true, - protecting: protectedEntryIDs + protecting: protectedEntryKeys ) let resolved = dynamicProfileLock.withLock { SessionPropertyBinding.$values.withValue(properties) { @@ -411,7 +420,7 @@ public final class LanguageModelSession: @unchecked Sendable { let transformHistory = properties.historyBinding( canonical, isWritable: true, - protecting: protectedEntryIDs + protecting: protectedEntryKeys ) projected = SessionPropertyBinding.$values.withValue(properties) { SessionPropertyBinding.$history.withValue(transformHistory) { @@ -455,7 +464,7 @@ public final class LanguageModelSession: @unchecked Sendable { : sessionErrorPolicy, profile: resolved, canonicalTranscript: canonical, - pendingEntryIDs: protectedEntryIDs + pendingEntryKeys: protectedEntryKeys ) } @@ -489,7 +498,7 @@ public final class LanguageModelSession: @unchecked Sendable { try await withSessionProperties( history: history.entries, isHistoryWritable: true, - protectedEntryIDs: history.protectedEntryIDs, + protectedEntryKeys: history.protectedEntryKeys, onHistoryChange: { updated in requestContext.callbackHistory.withLock { $0 = updated } } ) { for action in profile.onToolCall { try await action(call) } @@ -512,7 +521,7 @@ public final class LanguageModelSession: @unchecked Sendable { try await withSessionProperties( history: history.entries, isHistoryWritable: true, - protectedEntryIDs: history.protectedEntryIDs, + protectedEntryKeys: history.protectedEntryKeys, onHistoryChange: { updated in requestContext.callbackHistory.withLock { $0 = updated } } ) { for action in profile.onToolOutput { try await action(call, output) } @@ -539,7 +548,7 @@ public final class LanguageModelSession: @unchecked Sendable { let binding = properties.historyBinding( history.entries, isWritable: true, - protecting: history.protectedEntryIDs, + protecting: history.protectedEntryKeys, onChange: { updated in requestContext.callbackHistory.withLock { $0 = updated } } ) for entry in entries { @@ -586,7 +595,7 @@ public final class LanguageModelSession: @unchecked Sendable { return try await withSessionProperties( history: history.entries, isHistoryWritable: false, - protectedEntryIDs: history.protectedEntryIDs + protectedEntryKeys: history.protectedEntryKeys ) { try await operation() } @@ -595,43 +604,33 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated private func profileHistory( requestContext: RequestContext, currentRoundEntries: [Transcript.Entry] - ) -> (entries: [Transcript.Entry], protectedEntryIDs: Set) { - let canonicalIDs = Set(requestContext.canonicalTranscript.map(\.id)) - let pending = currentRoundEntries.filter { - !canonicalIDs.contains($0.id) || requestContext.pendingEntryIDs.contains($0.id) + ) -> (entries: [Transcript.Entry], protectedEntryKeys: Set) { + let canonicalKeys = Set(keyedTranscriptEntries(requestContext.canonicalTranscript).map(\.key)) + let pending = keyedTranscriptEntries(currentRoundEntries).filter { + !canonicalKeys.contains($0.key) || requestContext.pendingEntryKeys.contains($0.key) } let history = requestContext.callbackHistory.withLock { history in - var entries = history ?? requestContext.canonicalTranscript - var indices: [String: Int] = [:] - for (index, entry) in entries.enumerated() { indices[entry.id] = index } - for entry in pending { - if let index = indices[entry.id] { - entries[index] = entry - } else { - indices[entry.id] = entries.count - entries.append(entry) - } - } + let entries = mergingTranscriptEntries(history ?? requestContext.canonicalTranscript, with: pending) history = entries return entries } return ( history, - requestContext.pendingEntryIDs.union(pending.map(\.id)) + requestContext.pendingEntryKeys.union(pending.map(\.key)) ) } nonisolated private func withSessionProperties( history: [Transcript.Entry], isHistoryWritable: Bool, - protectedEntryIDs: Set = [], + protectedEntryKeys: Set = [], onHistoryChange: (@Sendable ([Transcript.Entry]) -> Void)? = nil, operation: @Sendable () async throws -> Result ) async rethrows -> Result { let history = properties.historyBinding( history, isWritable: isHistoryWritable, - protecting: protectedEntryIDs, + protecting: protectedEntryKeys, onChange: onHistoryChange ) return try await withSessionProperties(history: history, operation: operation) @@ -2579,17 +2578,7 @@ private final class PreparedProfileRequest: @unchecked Sendable { func pendingEntries(including entries: [Transcript.Entry]) -> [Transcript.Entry] { state.withLock { state in - var indices = Dictionary( - uniqueKeysWithValues: state.pendingEntries.enumerated().map { ($0.element.id, $0.offset) } - ) - for entry in entries { - if let index = indices[entry.id] { - state.pendingEntries[index] = entry - } else { - indices[entry.id] = state.pendingEntries.count - state.pendingEntries.append(entry) - } - } + state.pendingEntries = mergingTranscriptEntries(state.pendingEntries, with: keyedTranscriptEntries(entries)) return state.pendingEntries } } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 5f5c7d18..54a9893c 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -137,15 +137,16 @@ public final class SessionPropertyValues: @unchecked Sendable { func historyBinding( _ entries: [Transcript.Entry], isWritable: Bool, - protecting protectedEntryIDs: Set = [], + protecting protectedEntryKeys: Set = [], onChange: (@Sendable ([Transcript.Entry]) -> Void)? = nil ) -> SessionHistoryBinding { + let protected = keyedTranscriptEntries(entries).filter { protectedEntryKeys.contains($0.key) }.map(\.entry) let persist: (@Sendable ([Transcript.Entry]) -> Void)? if isWritable { persist = { [historySetter] updated in onChange?(updated) historySetter( - updated.filter { !protectedEntryIDs.contains($0.id) } + removingProtectedTranscriptEntries(from: updated, protecting: protected) ) } } else { @@ -154,7 +155,7 @@ public final class SessionPropertyValues: @unchecked Sendable { return SessionHistoryBinding( entries, isWritable: isWritable, - protectedEntryIDs: protectedEntryIDs, + protectedEntryKeys: protectedEntryKeys, persist: persist ) } @@ -187,13 +188,13 @@ enum SessionPropertyBinding { final class SessionHistoryBinding: @unchecked Sendable { private let storage: Locked<[Transcript.Entry]> let isWritable: Bool - private let protectedEntryIDs: Set + private let protectedEntries: [Transcript.Entry] private let persistValue: (@Sendable ([Transcript.Entry]) -> Void)? init( _ entries: [Transcript.Entry], isWritable: Bool, - protectedEntryIDs: Set = [], + protectedEntryKeys: Set = [], persist: (@Sendable ([Transcript.Entry]) -> Void)? = nil ) { storage = Locked( @@ -203,7 +204,9 @@ final class SessionHistoryBinding: @unchecked Sendable { } ) self.isWritable = isWritable - self.protectedEntryIDs = protectedEntryIDs + self.protectedEntries = keyedTranscriptEntries(entries).filter { protectedEntryKeys.contains($0.key) }.map( + \.entry + ) self.persistValue = persist } @@ -211,8 +214,9 @@ final class SessionHistoryBinding: @unchecked Sendable { get { storage.withLock { $0 } } set { storage.withLock { current in - let protected = current.filter { protectedEntryIDs.contains($0.id) } - current = newValue.filter { !protectedEntryIDs.contains($0.id) } + protected + current = + removingProtectedTranscriptEntries(from: newValue, protecting: protectedEntries) + + protectedEntries } } } diff --git a/Sources/AnyLanguageModel/Shared/ToolContinuation.swift b/Sources/AnyLanguageModel/Shared/ToolContinuation.swift new file mode 100644 index 00000000..ae84d9de --- /dev/null +++ b/Sources/AnyLanguageModel/Shared/ToolContinuation.swift @@ -0,0 +1,79 @@ +enum TranscriptEntryKey: Hashable, Sendable { + case entry(String) + case toolOutput(round: String?, call: String) +} + +// Provider call IDs are scoped to their Tool round, not to the whole transcript. +func keyedTranscriptEntries(_ entries: [Transcript.Entry]) -> [(key: TranscriptEntryKey, entry: Transcript.Entry)] { + var round: String? + return entries.map { entry in + let key: TranscriptEntryKey + switch entry { + case .toolCalls(let calls): + round = calls.id + key = .entry(entry.id) + case .toolOutput: + key = .toolOutput(round: round, call: entry.id) + case .instructions, .prompt, .response: + round = nil + key = .entry(entry.id) + case .reasoning: + key = .entry(entry.id) + } + return (key, entry) + } +} + +func mergingTranscriptEntries( + _ original: [Transcript.Entry], + with additional: [(key: TranscriptEntryKey, entry: Transcript.Entry)] +) -> [Transcript.Entry] { + var result = original + var indices: [TranscriptEntryKey: Int] = [:] + for (index, item) in keyedTranscriptEntries(original).enumerated() { indices[item.key] = index } + for item in additional { + if let index = indices[item.key] { + result[index] = item.entry + } else { + indices[item.key] = result.count + result.append(item.entry) + } + } + return result +} + +func removingProtectedTranscriptEntries( + from entries: [Transcript.Entry], + protecting protected: [Transcript.Entry] +) -> [Transcript.Entry] { + var result = entries + for entry in protected.reversed() { + if let index = result.lastIndex(of: entry) { result.remove(at: index) } + } + return result +} + +func toolContinuationMessages( + from transcript: Transcript, + replacing currentRoundEntries: [Transcript.Entry], + with continuation: [Message], + hasDynamicProfile: Bool, + convert: (Transcript) throws -> [Message] +) rethrows -> [Message] { + guard hasDynamicProfile else { return try convert(transcript) + continuation } + guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { return try convert(transcript) } + let projected = Array(transcript) + let count = currentRoundEntries.count + let ranges = projected.indices.compactMap { start -> Range? in + let end = start + count + guard end <= projected.count, Array(projected[start ..< end]) == currentRoundEntries else { return nil } + return start ..< end + } + guard ranges.count == 1, let range = ranges.first else { + // A profile may remove or reorder the current round. Its projection is authoritative. + return try convert(transcript) + } + return try convert(Transcript(entries: projected[.. String { "OUTPUT:\(arguments.city)" } + } + func providerProfileReevaluatesBeforeEveryToolContinuation( _ provider: Provider, _ streaming: Bool @@ -118,6 +194,12 @@ import Testing } #expect(exceeded?.limit == limit) + #expect( + exceeded?.transcriptEntries.filter { if case .toolCalls = $0 { true } else { false } }.count == limit + ) + #expect( + exceeded?.transcriptEntries.filter { if case .toolOutput = $0 { true } else { false } }.count == limit + ) #expect(state.executions.withLock { $0.count } == limit) #expect(UsageURLProtocol.recordedBodies.count == limit + 1) } diff --git a/Tests/AnyLanguageModelTests/ToolContinuationTests.swift b/Tests/AnyLanguageModelTests/ToolContinuationTests.swift new file mode 100644 index 00000000..508dc853 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ToolContinuationTests.swift @@ -0,0 +1,98 @@ +import Testing + +@_spi(Compatibility) @testable import AnyLanguageModel + +@Suite("Tool continuation history") +struct ToolContinuationTests { + @Test func removingDurableParentDoesNotCommitItsPendingOutput() async throws { + let round = Self.round("pending") + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("History") } + .model(MockLanguageModel()) + .historyTransform { entries in + let properties = SessionPropertyBinding.values! + properties.history = [] + properties.history = properties.history + return entries + }, + history: [round[0]] + ) + _ = try await session.resolvedRequestContext(including: round) + #expect(session.properties.history.isEmpty) + } + + @Test func ordinaryContinuationKeepsTranscriptAndOpaqueMessages() { + let previous = Self.round("previous") + let current = Self.round("current") + let messages = toolContinuationMessages( + from: Transcript(entries: previous), + replacing: current, + with: current, + hasDynamicProfile: false, + convert: Array.init + ) + #expect(messages == previous + current) + } + + @Test func profileContinuationReplacesOnlyTheUnchangedCurrentRound() { + let previous = Self.round("previous") + let current = Self.round("current") + let opaque: [Transcript.Entry] = [.response(.init(assetIDs: [], segments: []))] + let messages = toolContinuationMessages( + from: Transcript(entries: previous + current), + replacing: current, + with: opaque, + hasDynamicProfile: true, + convert: Array.init + ) + #expect(messages == previous + opaque) + let projection = previous + current.reversed() + let reordered = toolContinuationMessages( + from: Transcript(entries: projection), + replacing: current, + with: opaque, + hasDynamicProfile: true, + convert: Array.init + ) + #expect(reordered == projection) + } + + @Test(arguments: [false, true]) + func reusedCallIDDoesNotDiscardANewTurnOutput(profile: Bool) async throws { + let previous = Self.round("previous") + let current = Self.round("current") + let session: LanguageModelSession + if profile { + session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("History") } + .model(MockLanguageModel()) + .historyTransform { entries in + let properties = SessionPropertyBinding.values! + properties.history = properties.history + return entries + }, + history: previous + ) + } else { + session = LanguageModelSession(model: MockLanguageModel(), transcript: Transcript(entries: previous)) + } + let context = try await session.resolvedRequestContext(including: current) + #expect(Self.outputs(context.transcript) == Self.outputs(Transcript(entries: previous + current))) + #expect(Array(session.properties.history) == previous) + } + + private static func round(_ result: String) -> [Transcript.Entry] { + let call = Transcript.ToolCall(id: "call_0", toolName: "fixture", arguments: .init(properties: [:])) + return [ + .toolCalls(.init([call])), + .toolOutput(.init(id: call.id, toolName: call.toolName, segments: [.text(.init(content: result))])), + ] + } + + private static func outputs(_ transcript: Transcript) -> [Transcript.ToolOutput] { + transcript.compactMap { entry in + if case .toolOutput(let output) = entry { return output } + return nil + } + } +} From c96bd09eac92dfaeb9c8a2710b0fc192bb8d3841 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 20 Oct 2026 09:21:20 +0800 Subject: [PATCH 43/53] Reject non-image file types when snapshotting Tool attachments --- Sources/AnyLanguageModel/Tool.swift | 11 ++++++++++- .../PromptAttachmentTests.swift | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/AnyLanguageModel/Tool.swift b/Sources/AnyLanguageModel/Tool.swift index 558bb628..d72ef9ca 100644 --- a/Sources/AnyLanguageModel/Tool.swift +++ b/Sources/AnyLanguageModel/Tool.swift @@ -141,7 +141,13 @@ extension Tool { let mimeType: String? #if canImport(UniformTypeIdentifiers) - mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType + guard let type = UTType(filenameExtension: url.pathExtension) else { + throw ToolAttachmentError.unknownImageType(url) + } + guard type.conforms(to: .image) else { + throw ToolAttachmentError.unsupportedImageType(url) + } + mimeType = type.preferredMIMEType #else mimeType = switch url.pathExtension.lowercased() { @@ -160,11 +166,14 @@ extension Tool { private enum ToolAttachmentError: LocalizedError { case unknownImageType(URL) + case unsupportedImageType(URL) var errorDescription: String? { switch self { case .unknownImageType(let url): "Cannot determine the MIME type of the Tool output image \(url.lastPathComponent)." + case .unsupportedImageType(let url): + "The Tool output attachment \(url.lastPathComponent) is not an image file." } } } diff --git a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift index 77f1dd6b..348c93e3 100644 --- a/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift +++ b/Tests/AnyLanguageModelTests/PromptAttachmentTests.swift @@ -154,6 +154,18 @@ struct PromptAttachmentTests { #expect(try JSONDecoder().decode(AnyLanguageModel.Transcript.self, from: encoded) == transcript) } + @Test(arguments: ["pdf", "txt"]) + func ordinaryToolRejectsNonImageFiles(fileExtension: String) async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + try Data("Not an image".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + await #expect(throws: (any Error).self) { + _ = try await ImageTool(output: AnyLanguageModel.Prompt { AnyLanguageModel.Attachment(imageURL: url) }) + .makeOutputSegments(from: AnyLanguageModel.GeneratedContent("read")) + } + } + @Test func ordinaryToolRetainsRemoteImageURLs() async throws { let url = try #require(URL(string: "https://example.com/image.png")) let segments = try await ImageTool( From 4f6d2bbd02ceb724664f6264e462094b26fa1815 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 20 Oct 2026 09:23:53 +0800 Subject: [PATCH 44/53] Share Anthropic continuation replay without changing ordinary nonstreaming behavior --- .../Models/AnthropicLanguageModel.swift | 55 ++++++++----------- .../AnthropicProfileTests.swift | 7 +++ 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index dfd764cd..d2c64afb 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -458,11 +458,11 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan var usage = LanguageModelSession.Usage.zero var earlierText = "" var inFlightMessages: [AnthropicMessage] = [] - let toolRounds = session.toolRoundLimit(for: "Anthropic") + let toolRounds = session.toolRoundLimit(for: "Anthropic", options: options) while true { let requestContext = try await session.resolvedRequestContext( - including: entries, + including: session.usesDynamicProfile ? entries : [], options: options ) if let continuation = try await session.profileContinuationResponse( @@ -487,7 +487,8 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan messages: try anthropicMessages( from: requestContext.transcript, replacing: entries, - with: inFlightMessages + with: inFlightMessages, + hasDynamicProfile: requestContext.hasDynamicProfile ), tools: anthropicTools.isEmpty ? nil : anthropicTools, responseSchema: responseSchema, @@ -514,7 +515,7 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan return nil } if !toolUses.isEmpty { - try toolRounds.record() + try session.recordToolRound(toolRounds, completed: entries) try await session.profileDidProduceEntries( reasoningEntries, requestContext: requestContext, @@ -536,22 +537,21 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan usage: usage ) case .invocations(let invocations): - inFlightMessages.append(.init(role: .assistant, content: message.content)) entries.append(.toolCalls(.init(invocations.map(\.call)))) - var results: [AnthropicContent] = [] for invocation in invocations { entries.append(.toolOutput(invocation.output)) - results.append( + } + if requestContext.hasDynamicProfile { + inFlightMessages.append(.init(role: .assistant, content: message.content)) + let results: [AnthropicContent] = try invocations.map { invocation in .toolResult( .init( toolUseId: invocation.call.id, content: try convertSegmentsToAnthropicContent(invocation.output.segments) ) ) - ) - } - inFlightMessages.append(.init(role: .user, content: results)) - if requestContext.hasDynamicProfile { + } + inFlightMessages.append(.init(role: .user, content: results)) if type == String.self { earlierText += message.content.compactMap { block -> String? in if case .text(let text) = block { return text.text } @@ -644,7 +644,7 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan while true { try Task.checkCancellation() let requestContext = try await session.resolvedRequestContext( - including: state.entries, + including: session.usesDynamicProfile ? state.entries : [], options: options ) if let handoff = session.profileContinuationStream( @@ -668,7 +668,8 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan let messages = try anthropicMessages( from: requestContext.transcript, replacing: state.entries, - with: inFlightMessages + with: inFlightMessages, + hasDynamicProfile: requestContext.hasDynamicProfile ) var params = try createMessageParams( model: model, @@ -978,26 +979,16 @@ private func createMessageParams( private func anthropicMessages( from transcript: Transcript, replacing currentRoundEntries: [Transcript.Entry], - with continuation: [AnthropicMessage] + with continuation: [AnthropicMessage], + hasDynamicProfile: Bool ) throws -> [AnthropicMessage] { - guard !continuation.isEmpty, !currentRoundEntries.isEmpty else { - return try transcript.toAnthropicMessages() - } - let projected = Array(transcript) - let count = currentRoundEntries.count - let ranges = projected.indices.compactMap { start -> Range? in - let end = start + count - guard end <= projected.count, - Array(projected[start ..< end]) == currentRoundEntries - else { return nil } - return start ..< end - } - guard ranges.count == 1, let range = ranges.first else { - return try transcript.toAnthropicMessages() - } - return try Transcript(entries: projected[.. Date: Tue, 20 Oct 2026 09:30:59 +0800 Subject: [PATCH 45/53] Let provider adapters retain their offered Tool boundary --- .../ProfileToolExecution.swift | 3 +- .../Shared/ProfileProviderFixtures.swift | 13 +++++++- .../ToolContinuationTests.swift | 30 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/ProfileToolExecution.swift b/Sources/AnyLanguageModel/ProfileToolExecution.swift index 9604f528..2d8827ae 100644 --- a/Sources/AnyLanguageModel/ProfileToolExecution.swift +++ b/Sources/AnyLanguageModel/ProfileToolExecution.swift @@ -160,6 +160,7 @@ extension LanguageModelSession { _ calls: [Transcript.ToolCall], requestContext: RequestContext, currentRoundEntries: [Transcript.Entry], + offeredTools: [any Tool]? = nil, missingToolOutput: @Sendable (Transcript.ToolCall) -> [Transcript.Segment] = { call in [.text(.init(content: "Tool not found: \(call.toolName)"))] } @@ -187,7 +188,7 @@ extension LanguageModelSession { } var toolsByName: [String: any Tool] = [:] - for tool in requestContext.tools where toolsByName[tool.name] == nil { + for tool in offeredTools ?? requestContext.tools where toolsByName[tool.name] == nil { toolsByName[tool.name] = tool } diff --git a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift index 5271a14f..74b9c7f8 100644 --- a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift +++ b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift @@ -19,12 +19,18 @@ import Testing for round in 0 ..< roundsPerTurn { expectedRequests.append(expectedOutputs) let city = "Turn\(turn)Round\(round)" - let response = + var response = try streaming ? provider.toolStream(city: city, callID: "call_0") : Self.json(provider.response(text: "", tool: true)) .replacingOccurrences(of: "Paris", with: city) .replacingOccurrences(of: "call_1", with: "call_0") + if provider == .ollama { + response = response.replacingOccurrences( + of: "\"function\":", + with: "\"id\":\"call_0\",\"function\":" + ) + } UsageURLProtocol.enqueue(json: response) expectedOutputs.append("OUTPUT:\(city)") } @@ -64,6 +70,11 @@ import Testing } } let bodies = UsageURLProtocol.recordedBodies + let outputIDs = session.transcript.compactMap { entry -> String? in + if case .toolOutput(let output) = entry { return output.id } + return nil + } + #expect(outputIDs == Array(repeating: "call_0", count: expectedOutputs.count)) try #require(bodies.count == expectedRequests.count) for (body, outputs) in zip(bodies, expectedRequests) { let json = String(decoding: body, as: UTF8.self) diff --git a/Tests/AnyLanguageModelTests/ToolContinuationTests.swift b/Tests/AnyLanguageModelTests/ToolContinuationTests.swift index 508dc853..bfe7c378 100644 --- a/Tests/AnyLanguageModelTests/ToolContinuationTests.swift +++ b/Tests/AnyLanguageModelTests/ToolContinuationTests.swift @@ -4,6 +4,25 @@ import Testing @Suite("Tool continuation history") struct ToolContinuationTests { + @Test func explicitEmptyOfferedToolsDoesNotExecuteASessionTool() async throws { + let calls = Locked(0) + let tool = OfferedToolProbe(calls: calls) + let session = LanguageModelSession(model: MockLanguageModel(), tools: [tool]) + let call = Transcript.ToolCall(id: "call_0", toolName: tool.name, arguments: .init("input")) + let result = try await session.resolveProfileToolCalls( + [call], + requestContext: session.resolvedRequestContext(), + currentRoundEntries: [.toolCalls(.init([call]))], + offeredTools: [] + ) + guard case .invocations(let invocations) = result else { + Issue.record("Expected the existing missing-tool outcome") + return + } + #expect(calls.withLock { $0 } == 0) + #expect(invocations.count == 1) + } + @Test func removingDurableParentDoesNotCommitItsPendingOutput() async throws { let round = Self.round("pending") let session = LanguageModelSession( @@ -96,3 +115,14 @@ struct ToolContinuationTests { } } } + +private struct OfferedToolProbe: Tool { + let name = "offeredProbe" + let description = "Record unexpected executions" + let calls: Locked + + func call(arguments: String) async throws -> String { + calls.withLock { $0 += 1 } + return "executed" + } +} From 07ad9540f1d95d68ed842304971c88eb96e18733 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 20 Oct 2026 13:08:57 +0800 Subject: [PATCH 46/53] Describe remote Gemini Tool images as URL text --- README.md | 4 + .../Models/GeminiLanguageModel.swift | 6 +- .../GeminiThoughtSignatureTests.swift | 79 ++++++++++++++++--- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1632bff6..24c7a42e 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,10 @@ and retain the image bytes and MIME type in the compatibility transcript. Continuations and saved transcript replay therefore do not depend on the original file remaining at its URL. Remote image URLs are retained without downloading. +Gemini supports images in Tool function responses only with Gemini 3 and later. +In Gemini Tool outputs, remote image URLs are sent as text at their original +position without downloading the image; the transcript retains the image segment. + This compatibility surface covers `CGImage` and `imageURL` attachments in prompts and ordinary Tool outputs. Instruction attachments, labels, `CIImage`, pixel buffers, and newer data attachments remain outside this change. diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index c9ada7fd..cfbbc455 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -814,7 +814,11 @@ private func toGeminiFunctionResponse( ) ) case .url(let url): - parts.append(.inlineData(try geminiFunctionResponseData(from: url))) + if url.isFileURL { + parts.append(.inlineData(try geminiFunctionResponseData(from: url))) + } else { + resultValues.append(url.absoluteString) + } } } } diff --git a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift index 006490ab..6ee026b6 100644 --- a/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift +++ b/Tests/AnyLanguageModelTests/GeminiThoughtSignatureTests.swift @@ -218,27 +218,80 @@ import Testing #expect(mediaParts.first?["fileData"] == nil) } - @Test("rejects remote URL-backed images with an actionable error") - func rejectsRemoteURLBackedToolOutput() async throws { + @Test("sends remote Tool image URLs as ordered text without downloading", arguments: [false, true]) + func describesRemoteURLBackedToolOutput(streaming: Bool) async throws { StubURLProtocol.reset() - StubURLProtocol.enqueue( - json: - #"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"image","args":{"request":"read"}}}]},"finishReason":"STOP"}]}"# - ) + let toolResponse = + #"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"image","args":{"request":"read"}}}]},"finishReason":"STOP"}]}"# + if streaming { + StubURLProtocol.enqueue(eventStream: [toolResponse]) + StubURLProtocol.enqueue(eventStream: [try self.response(parts: [["text": "OK"]])]) + } else { + StubURLProtocol.enqueue(json: toolResponse) + StubURLProtocol.enqueue(json: textResponse("OK")) + } - let url = try #require(URL(string: "https://example.com/image.png")) - let output = Prompt { Attachment(imageURL: url) } + let url = try #require(URL(string: "https://example.invalid/image.png")) + let output = Prompt { + "before" + Attachment(imageURL: url) + "after" + } let session = LanguageModelSession( model: makeModel(), tools: [ImageOutputTool(output: output)] ) - do { - _ = try await session.respond(to: "read image") - Issue.record("Expected a remote Tool output attachment to be rejected") - } catch { - #expect(error.localizedDescription.contains("local file URL")) + let response = + if streaming { + try await session.streamResponse(to: "read image").collect() + } else { + try await session.respond(to: "read image") + } + #expect(response.content == "OK") + let bodies = StubURLProtocol.recordedBodies + try #require(bodies.count == 2) + + func expectURLText(in body: Data) throws { + let responses = try contents(in: body).flatMap { content in + (content["parts"] as? [[String: Any]] ?? []).compactMap { + $0["functionResponse"] as? [String: Any] + } + } + try #require(responses.count == 1) + let functionResponse = try #require(responses.first) + let result = try #require(functionResponse["response"] as? [String: Any]) + #expect(result["result"] as? String == "before\n\(url.absoluteString)\nafter") + #expect(functionResponse["parts"] == nil) + } + try expectURLText(in: bodies[1]) + + let outputs = session.transcript.compactMap { entry -> Transcript.ToolOutput? in + if case .toolOutput(let output) = entry { return output } + return nil + } + let segments = try #require(outputs.first).segments + try #require(segments.count == 3) + guard case .image(let image) = segments[1] else { + Issue.record("The transcript must retain the remote image segment") + return + } + #expect(image.source == .url(url)) + + let data = try JSONEncoder().encode(session.transcript) + let transcript = try JSONDecoder().decode(Transcript.self, from: data) + #expect(transcript == session.transcript) + let restored = LanguageModelSession(model: makeModel(), transcript: transcript) + if streaming { + StubURLProtocol.enqueue(eventStream: [try self.response(parts: [["text": "Replayed"]])]) + #expect(try await restored.streamResponse(to: "continue").collect().content == "Replayed") + } else { + StubURLProtocol.enqueue(json: textResponse("Replayed")) + #expect(try await restored.respond(to: "continue").content == "Replayed") } + let replayBodies = StubURLProtocol.recordedBodies + try #require(replayBodies.count == 3) + try expectURLText(in: replayBodies[2]) } @Test("does not replay the conversation history on later turns") From db6bc62dc7c31d35cbfcdaf12368e0aa90edc5bc Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 09:13:04 +0800 Subject: [PATCH 47/53] Preserve public Tool identity and bind at execution boundaries --- .../LanguageModelSession.swift | 20 +++++++++++-------- .../Models/AnthropicLanguageModel.swift | 4 ++-- .../Models/GeminiLanguageModel.swift | 4 ++-- .../Models/LlamaLanguageModel.swift | 4 ++-- .../Models/MLXLanguageModel.swift | 4 ++-- .../Models/OllamaLanguageModel.swift | 4 ++-- .../Models/OpenAILanguageModel.swift | 6 +++--- .../Models/OpenResponsesLanguageModel.swift | 4 ++-- .../PrivateCloudComputeLanguageModel.swift | 2 +- .../Models/SystemLanguageModel.swift | 8 ++++---- .../DynamicInstructionsTests.swift | 6 +++--- .../NativeSessionPropertyTests.swift | 8 ++++++-- .../RequestContextPublicAPITests.swift | 18 +++++++++++++++++ .../SessionPropertyTests.swift | 5 +++-- 14 files changed, 62 insertions(+), 35 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 63711343..0dcf9b74 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -151,6 +151,16 @@ public final class LanguageModelSession: @unchecked Sendable { /// The tools that the model can call in response to the request. public let tools: [any Tool] + func toolsForExecution(in session: LanguageModelSession) -> [any Tool] { + let history = Transcript.HistoryView( + transcript.filter { + if case .instructions = $0 { return false } + return true + } + ) + return tools.map { $0.bindingSessionProperties(session.properties, history: history) } + } + fileprivate init(transcript: Transcript, instructions: Instructions?, tools: [any Tool]) { self.transcript = transcript self.instructions = instructions @@ -172,16 +182,10 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { let transcript = transcript - let history = Transcript.HistoryView( - transcript.filter { - if case .instructions = $0 { return false } - return true - } - ) return RequestContext( transcript: transcript, instructions: instructions, - tools: tools.map { $0.bindingSessionProperties(properties, history: history) } + tools: tools ) } @@ -211,7 +215,7 @@ public final class LanguageModelSession: @unchecked Sendable { return RequestContext( transcript: requestTranscript, instructions: resolved.instructions, - tools: resolved.tools.map { $0.bindingSessionProperties(properties, history: canonicalHistory) } + tools: resolved.tools ) } diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 3f370420..91677d36 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -489,7 +489,7 @@ public struct AnthropicLanguageModel: LanguageModel { if !toolUses.isEmpty { let resolution = try await resolveToolUses( toolUses, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -698,7 +698,7 @@ public struct AnthropicLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolUses( toolUses, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index 7e69a923..242d061e 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -293,7 +293,7 @@ public struct GeminiLanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveFunctionCalls( functionCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -475,7 +475,7 @@ public struct GeminiLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveFunctionCalls( functionCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift index d03386d4..d5efeb11 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -932,7 +932,7 @@ import Foundation let resolution = try await resolveToolCalls( parsedCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -1194,7 +1194,7 @@ import Foundation let resolution = try await self.resolveToolCalls( parsedCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift index 51be9b0c..c53f0e82 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1199,7 +1199,7 @@ import Foundation let resolution = try await resolveToolCalls( collectedToolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -1440,7 +1440,7 @@ import Foundation let resolution = try await resolveToolCalls( collectedToolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 36c3ddfb..2701770e 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -160,7 +160,7 @@ public struct OllamaLanguageModel: LanguageModel { if let toolCalls = chatResponse.message.toolCalls, !toolCalls.isEmpty { let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -296,7 +296,7 @@ public struct OllamaLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index b6f17391..f7ed4bd9 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -555,7 +555,7 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -686,7 +686,7 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -902,7 +902,7 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 807bbb5e..f4e80a2d 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -553,7 +553,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): @@ -645,7 +645,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift index d76657c2..b8c6bc3c 100644 --- a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift @@ -130,7 +130,7 @@ // Attach the feedback to the session's conversation, including its latest response. let fmSession = FoundationModels.LanguageModelSession( model: pccModel, - tools: requestContext.tools.toFoundationModels(), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(), transcript: requestContext.transcript.toFoundationModels( instructions: requestContext.instructions, toolDefinitions: requestContext.tools diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 88a5e632..8e5300c4 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -277,7 +277,7 @@ // Attach the feedback to the session's conversation, including its latest response. let fmSession = FoundationModels.LanguageModelSession( model: systemModel, - tools: requestContext.tools.toFoundationModels(in: session), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(in: session), transcript: requestContext.transcript.toFoundationModels( instructions: requestContext.instructions, toolDefinitions: requestContext.tools @@ -319,7 +319,7 @@ let requestContext = session.resolvedRequestContext() return FoundationModels.LanguageModelSession( model: systemModel, - tools: requestContext.tools.toFoundationModels(in: session), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(in: session), transcript: fmTranscriptDroppingDuplicatePrompt( requestContext.transcript, prompt: prompt @@ -388,7 +388,7 @@ if let instructions = requestContext.instructions { instructions.toFoundationModels() } - requestContext.tools.toFoundationModels(in: session) + requestContext.toolsForExecution(in: session).toFoundationModels(in: session) } } @@ -420,7 +420,7 @@ let requestContext = session.resolvedRequestContext() return FoundationModels.LanguageModelSession( model: model, - tools: requestContext.tools.toFoundationModels(in: session), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(in: session), transcript: fmTranscriptDroppingDuplicatePrompt( requestContext.transcript, prompt: prompt diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift index baba7aa1..8943439b 100644 --- a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift @@ -407,7 +407,7 @@ private struct DynamicContextModel: LanguageModel { var entries: [Transcript.Entry] = [] if continuesAfterTool { - let tool = try #require(first.tools.first) + let tool = try #require(first.toolsForExecution(in: session).first) state.select(.b) let call = Transcript.ToolCall( id: "request-a-call", @@ -475,7 +475,7 @@ private struct FailingAfterToolModel: LanguageModel { return !didFail } if shouldFail { - let tool = try #require(context.tools.first) + let tool = try #require(context.toolsForExecution(in: session).first) _ = try await tool.makeOutputSegments(from: GeneratedContent(properties: [:])) state.select(.b) throw DynamicFixtureError.failed @@ -565,7 +565,7 @@ private struct CancellingAfterToolModel: LanguageModel { return !didSuspend } if shouldSuspend { - let tool = try #require(context.tools.first) + let tool = try #require(context.toolsForExecution(in: session).first) _ = try await tool.makeOutputSegments(from: GeneratedContent(properties: [:])) state.select(.b) control.signalStarted() diff --git a/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift index 595b0714..cc76f281 100644 --- a/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift @@ -15,8 +15,12 @@ import Testing ) first.properties.revision = 1 second.properties.revision = 20 - let firstTool = try #require(first.resolvedRequestContext().tools.toFoundationModels().first) - let secondTool = try #require(second.resolvedRequestContext().tools.toFoundationModels().first) + let firstTool = try #require( + first.resolvedRequestContext().toolsForExecution(in: first).toFoundationModels().first + ) + let secondTool = try #require( + second.resolvedRequestContext().toolsForExecution(in: second).toFoundationModels().first + ) let firstCall = Task.detached { try await callNativeTool(firstTool) } let secondCall = Task.detached { try await callNativeTool(secondTool) } diff --git a/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift index ee991bff..d5b313b2 100644 --- a/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift +++ b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift @@ -11,9 +11,27 @@ import Testing #expect(context.transcript.count == session.transcript.count) #expect(context.instructions?.description == "Public request context") #expect(context.tools.map(\.name) == ["publicSessionProperty"]) + #expect(context.tools.first is PublicSessionPropertyTool) let _: PublicSessionPropertyTool.SessionProperty = .init(\.history) } +@Test func requestContextRetainsConcreteToolIdentity() { + let original = PublicReferenceTool() + let session = LanguageModelSession( + model: OpenAILanguageModel(apiKey: "unused", model: "test"), + tools: [original] + ) + let context = session.resolvedRequestContext() + #expect(context.tools.first as? PublicReferenceTool === original) +} + +private final class PublicReferenceTool: Tool { + let name = "reference" + let description = "Preserve the supplied Tool instance" + + func call(arguments: String) async throws -> String { arguments } +} + private struct PublicSessionPropertyTool: Tool { @SessionProperty(\.history) private var history diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index df99a875..6ac6d154 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -19,7 +19,7 @@ enum SessionPropertyMode { @Suite("Session properties") struct SessionPropertyTests { @Test(arguments: [false, true]) - func contextToolsCarryPropertiesIntoDetachedExecution(dynamic: Bool) async throws { + func executionToolsCarryPropertiesIntoDetachedExecution(dynamic: Bool) async throws { let session = dynamic ? LanguageModelSession( @@ -35,7 +35,8 @@ struct SessionPropertyTests { session.properties.history = [earlier] session.properties.revision = 7 let context = session.resolvedRequestContext() - let tool = try #require(context.tools.first) + #expect(context.tools.first is PropertyBoundTool) + let tool = try #require(context.toolsForExecution(in: session).first) let output = try await Task.detached { #expect(SessionPropertyBinding.values == nil) return try await tool.makeOutputSegments(from: GeneratedContent("read")) From 57094dbdce6ecdbf3f6a8bb04099d45ca033012a Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 09:26:13 +0800 Subject: [PATCH 48/53] Share local-provider round budgets and verify Tool-call entry identity --- .../Models/AnthropicLanguageModel.swift | 2 +- .../Models/GeminiLanguageModel.swift | 4 +- .../Models/LlamaLanguageModel.swift | 8 ++-- .../Models/MLXLanguageModel.swift | 8 ++-- .../Models/OllamaLanguageModel.swift | 2 +- .../Models/OpenAILanguageModel.swift | 6 +-- .../Models/OpenResponsesLanguageModel.swift | 4 +- .../Shared/ProfileProviderFixtures.swift | 39 ++++++++++++++++--- 8 files changed, 51 insertions(+), 22 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index bfec3341..aeec9c2d 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -600,7 +600,7 @@ public struct AnthropicLanguageModel: LanguageModel { type == String.self ? nil : try convertSchemaToAnthropicFormat(schema) var inFlightMessages: [AnthropicMessage] = [] var state = StreamingResponseState() - var toolRounds = ToolRoundLimit(provider: "Anthropic", options: options) + let toolRounds = ToolRoundLimit(provider: "Anthropic", options: options) while true { try Task.checkCancellation() let requestContext = session.resolvedRequestContext() diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index e5517e1e..c065ea75 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -241,7 +241,7 @@ public struct GeminiLanguageModel: LanguageModel { // The text of earlier tool rounds, which string responses include. var earlierText = "" - var toolRounds = ToolRoundLimit(provider: "Gemini", options: options) + let toolRounds = ToolRoundLimit(provider: "Gemini", options: options) // Multi-turn conversation loop for tool calling while true { let requestContext = session.resolvedRequestContext() @@ -419,7 +419,7 @@ public struct GeminiLanguageModel: LanguageModel { let headers = buildHeaders() var inFlightEntries: [Transcript.Entry] = [] var state = StreamingResponseState(wrapperProperty: responseSchema.wrapperProperty) - var toolRounds = ToolRoundLimit(provider: "Gemini", options: options) + let toolRounds = ToolRoundLimit(provider: "Gemini", options: options) while true { try Task.checkCancellation() let requestContext = session.resolvedRequestContext() diff --git a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift index 9d5e6bd9..f34b1e33 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -850,7 +850,7 @@ import Foundation if type == String.self { let maxTokens = runtimeOptions.maximumResponseTokens ?? 100 let outputFormat = currentToolCallFormat() - var toolRounds = ToolRoundLimit(provider: "Llama", options: options) + let toolRounds = session.toolRoundLimit(for: "Llama", options: options) var allEntries: [Transcript.Entry] = [] var text = "" var usage = LanguageModelSession.Usage.zero @@ -930,7 +930,7 @@ import Foundation break generationLoop } - try toolRounds.record(completed: allEntries) + try session.recordToolRound(toolRounds, completed: allEntries) let resolution = try await resolveToolCalls( parsedCalls, @@ -1085,7 +1085,7 @@ import Foundation let runtimeOptions = resolvedOptions(from: options) let maxTokens = runtimeOptions.maximumResponseTokens ?? 100 let outputFormat = self.currentToolCallFormat() - var toolRounds = ToolRoundLimit(provider: "Llama", options: options) + let toolRounds = session.toolRoundLimit(for: "Llama", options: options) var accumulatedEntries: [Transcript.Entry] = [] var emittedBase = "" var usage = LanguageModelSession.Usage.zero @@ -1194,7 +1194,7 @@ import Foundation break generationLoop } - try toolRounds.record(completed: accumulatedEntries) + try session.recordToolRound(toolRounds, completed: accumulatedEntries) let resolution = try await self.resolveToolCalls( parsedCalls, diff --git a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift index 34a05613..237e85b8 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1128,7 +1128,7 @@ import Foundation var usage = LanguageModelSession.Usage.zero var allTextChunks: [String] = [] var allEntries: [Transcript.Entry] = [] - var toolRounds = ToolRoundLimit(provider: "MLX", options: options) + let toolRounds = session.toolRoundLimit(for: "MLX", options: options) // Loop until no more tool calls while true { @@ -1197,7 +1197,7 @@ import Foundation // If there are tool calls, execute them and continue if !collectedToolCalls.isEmpty { - try toolRounds.record(completed: allEntries) + try session.recordToolRound(toolRounds, completed: allEntries) let resolution = try await resolveToolCalls( collectedToolCalls, @@ -1356,7 +1356,7 @@ import Foundation var usage = LanguageModelSession.Usage.zero var accumulatedText = "" var accumulatedEntries: [Transcript.Entry] = [] - var toolRounds = ToolRoundLimit(provider: "MLX", options: options) + let toolRounds = session.toolRoundLimit(for: "MLX", options: options) // Yields a snapshot carrying the cumulative text and tool entries so far. func yieldSnapshot() { @@ -1440,7 +1440,7 @@ import Foundation guard !collectedToolCalls.isEmpty else { break } - try toolRounds.record(completed: accumulatedEntries) + try session.recordToolRound(toolRounds, completed: accumulatedEntries) let resolution = try await resolveToolCalls( collectedToolCalls, diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 170db974..05820bc1 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -256,7 +256,7 @@ public struct OllamaLanguageModel: LanguageModel { let format = type == String.self ? nil : try JSONValue(convertSchemaToOllamaFormat(schema)) var inFlightMessages: [OllamaMessage] = [] var state = StreamingResponseState() - var toolRounds = ToolRoundLimit(provider: "Ollama", options: options) + let toolRounds = ToolRoundLimit(provider: "Ollama", options: options) while true { try Task.checkCancellation() let requestContext = session.resolvedRequestContext() diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index 9667da64..d386f555 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -503,7 +503,7 @@ public struct OpenAILanguageModel: LanguageModel { var roundText = "" var inFlightMessages: [OpenAIMessage] = [] - var toolRounds = ToolRoundLimit(provider: "OpenAI", options: options) + let toolRounds = ToolRoundLimit(provider: "OpenAI", options: options) // Loop until no more tool calls while true { let requestContext = session.resolvedRequestContext() @@ -644,7 +644,7 @@ public struct OpenAILanguageModel: LanguageModel { let url = baseURL.appendingPathComponent("responses") - var toolRounds = ToolRoundLimit(provider: "OpenAI", options: options) + let toolRounds = ToolRoundLimit(provider: "OpenAI", options: options) // Loop until no more tool calls while true { let requestContext = session.resolvedRequestContext() @@ -800,7 +800,7 @@ public struct OpenAILanguageModel: LanguageModel { do { var inFlightMessages: [OpenAIMessage] = [] var state = StreamingResponseState(wrapperProperty: responseSchema.wrapperProperty) - var toolRounds = ToolRoundLimit(provider: "OpenAI", options: options) + let toolRounds = ToolRoundLimit(provider: "OpenAI", options: options) while true { try Task.checkCancellation() let requestContext = session.resolvedRequestContext() diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 83de0f37..26eb21c5 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -510,7 +510,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { do { var inFlightMessages: [OpenResponsesMessage] = [] var state = StreamingResponseState(wrapperProperty: responseSchema.wrapperProperty) - var toolRounds = ToolRoundLimit(provider: "Open Responses", options: options) + let toolRounds = ToolRoundLimit(provider: "Open Responses", options: options) while true { try Task.checkCancellation() let requestContext = session.resolvedRequestContext() @@ -622,7 +622,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { var inFlightMessages: [OpenResponsesMessage] = [] let url = baseURL.appendingPathComponent("responses") - var toolRounds = ToolRoundLimit(provider: "Open Responses", options: options) + let toolRounds = ToolRoundLimit(provider: "Open Responses", options: options) while true { let requestContext = session.resolvedRequestContext() let tools: [OpenResponsesTool]? = diff --git a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift index 74b9c7f8..1a15b734 100644 --- a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift +++ b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift @@ -13,6 +13,7 @@ import Testing UsageURLProtocol.reset() let legacyAnthropic = provider == .anthropic && !streaming && !profile let roundsPerTurn = legacyAnthropic ? 1 : 2 + let observedCalls = Locked<[Transcript.Entry]>([]) var expectedOutputs: [String] = [] var expectedRequests: [[String]] = [] for turn in 0 ..< 2 { @@ -50,7 +51,7 @@ import Testing session = LanguageModelSession( profile: LanguageModelSession.Profile { Instructions("Repeated call IDs") - ReusedCallIDTool() + ReusedCallIDTool(observedCalls: observedCalls) } .model(provider.makeModel()) .historyTransform { entries in @@ -60,7 +61,7 @@ import Testing } ) } else { - session = provider.makeSession(tools: [ReusedCallIDTool()]) + session = provider.makeSession(tools: [ReusedCallIDTool(observedCalls: observedCalls)]) } for _ in 0 ..< (legacyAnthropic ? 3 : 2) { if streaming { @@ -76,6 +77,8 @@ import Testing } #expect(outputIDs == Array(repeating: "call_0", count: expectedOutputs.count)) try #require(bodies.count == expectedRequests.count) + let committedCalls = session.transcript.filter { if case .toolCalls = $0 { true } else { false } } + #expect(observedCalls.withLock { $0 } == committedCalls) for (body, outputs) in zip(bodies, expectedRequests) { let json = String(decoding: body, as: UTF8.self) for output in outputs { @@ -85,11 +88,18 @@ import Testing } struct ReusedCallIDTool: Tool { + @SessionProperty(\.history) private var history + let observedCalls: Locked<[Transcript.Entry]> let name = "getWeather" let description = "A deterministic Tool with distinguishable outputs" @Generable struct Arguments { let city: String } - func call(arguments: Arguments) async throws -> String { "OUTPUT:\(arguments.city)" } + func call(arguments: Arguments) async throws -> String { + if let calls = history.last(where: { if case .toolCalls = $0 { true } else { false } }) { + observedCalls.withLock { $0.append(calls) } + } + return "OUTPUT:\(arguments.city)" + } } func providerProfileReevaluatesBeforeEveryToolContinuation( @@ -124,6 +134,10 @@ import Testing ] ) #expect(UsageURLProtocol.recordedBodies.count == 2) + let committedCalls = try #require( + session.transcript.first { if case .toolCalls = $0 { true } else { false } } + ) + #expect(state.observedCallEntries.withLock { $0 } == Array(repeating: committedCalls, count: 3)) let first = String(decoding: UsageURLProtocol.recordedBodies[0], as: UTF8.self) let second = String(decoding: UsageURLProtocol.recordedBodies[1], as: UTF8.self) #expect(first.contains("Profile A")) @@ -423,7 +437,14 @@ import Testing let executions = Locked<[String]>([]) let transformSawOutput = Locked<[Bool]>([]) let events = Locked<[String]>([]) + let observedCallEntries = Locked<[Transcript.Entry]>([]) let selectedRequests = Locked<[SelectedRequest]>([]) + + func recordCalls(in history: Transcript.HistoryView) { + if let calls = history.last(where: { if case .toolCalls = $0 { true } else { false } }) { + observedCallEntries.withLock { $0.append(calls) } + } + } } private struct ProviderProfile: LanguageModelSession.DynamicProfile, @unchecked Sendable { @@ -452,8 +473,14 @@ import Testing .includeSchemaInPrompt(initialSchemaOverride) .temperature(0.2) .historyTransform(transform) - .onToolCall { _ in state.events.withLock { $0.append("call:A") } } - .onToolOutput { _, _ in state.events.withLock { $0.append("output:A") } } + .onToolCall { _ in + state.events.withLock { $0.append("call:A") } + state.recordCalls(in: try #require(SessionPropertyBinding.values).history) + } + .onToolOutput { _, _ in + state.events.withLock { $0.append("output:A") } + state.recordCalls(in: try #require(SessionPropertyBinding.values).history) + } } } @@ -578,11 +605,13 @@ import Testing } private struct ProviderProfileWeatherTool: Tool { + @SessionProperty(\.history) private var history let name = "getWeather" let description = "Get weather" let state: ProviderProfileState func call(arguments: WeatherTool.Arguments) async throws -> String { + state.recordCalls(in: history) state.executions.withLock { $0.append(arguments.city) } state.events.withLock { $0.append("execute:A") } state.useSecond.withLock { $0 = true } From 14d1b74f91bd18bd46a6656fc3da5f3341005f91 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 09:32:15 +0800 Subject: [PATCH 49/53] Remove unreachable remote-URL failure from Gemini file lowering --- Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index cfbbc455..c2eebe69 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -835,13 +835,10 @@ private func toGeminiFunctionResponse( } private enum GeminiFunctionResponseAttachmentError: LocalizedError { - case unsupportedURL(URL) case unsupportedImageType(URL) var errorDescription: String? { switch self { - case .unsupportedURL(let url): - "Gemini Tool output attachments require a local file URL; cannot inline \(url.absoluteString)." case .unsupportedImageType(let url): "Gemini Tool output attachments support PNG, JPEG, and WebP files; cannot determine a supported MIME type for \(url.lastPathComponent)." } @@ -849,10 +846,6 @@ private enum GeminiFunctionResponseAttachmentError: LocalizedError { } private func geminiFunctionResponseData(from url: URL) throws -> GeminiInlineData { - guard url.isFileURL else { - throw GeminiFunctionResponseAttachmentError.unsupportedURL(url) - } - let mimeType = switch url.pathExtension.lowercased() { case "png": "image/png" From 06808c7a1026964e088667882fcfd92aeab757b2 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 09:34:09 +0800 Subject: [PATCH 50/53] Preserve producing Anthropic Tool-call entries through execution --- .../Models/AnthropicLanguageModel.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index d2c64afb..2fdc5aff 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -521,14 +521,15 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan requestContext: requestContext, currentRoundEntries: entries ) - switch try await resolveToolUses( + let (callsEntry, resolution) = try await resolveToolUses( toolUses, requestContext: requestContext, currentRoundEntries: entries, session: session - ) { - case .stop(let calls): - entries.append(.toolCalls(.init(calls))) + ) + switch resolution { + case .stop: + entries.append(.toolCalls(callsEntry)) let empty = try emptyResponseContent(for: type) return .init( content: empty.content, @@ -537,7 +538,7 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan usage: usage ) case .invocations(let invocations): - entries.append(.toolCalls(.init(invocations.map(\.call)))) + entries.append(.toolCalls(callsEntry)) for invocation in invocations { entries.append(.toolOutput(invocation.output)) } @@ -771,20 +772,21 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan requestContext: requestContext, currentRoundEntries: state.entries ) - switch try await resolveToolUses( + let (callsEntry, resolution) = try await resolveToolUses( toolUses, requestContext: requestContext, currentRoundEntries: state.entries, session: session - ) { - case .stop(let calls): - state.entries.append(.toolCalls(Transcript.ToolCalls(calls))) + ) + switch resolution { + case .stop: + state.entries.append(.toolCalls(callsEntry)) continuation.yield(try state.stoppedSnapshot()) continuation.finish() return case .invocations(let invocations): inFlightMessages.append(.init(role: .assistant, content: content)) - state.entries.append(.toolCalls(Transcript.ToolCalls(invocations.map(\.call)))) + state.entries.append(.toolCalls(callsEntry)) var results: [AnthropicContent] = [] for invocation in invocations { state.entries.append(.toolOutput(invocation.output)) @@ -1039,9 +1041,7 @@ private func resolveToolUses( requestContext: LanguageModelSession.RequestContext, currentRoundEntries: [Transcript.Entry], session: LanguageModelSession -) async throws -> ToolResolutionOutcome { - if toolUses.isEmpty { return .invocations([]) } - +) async throws -> (calls: Transcript.ToolCalls, outcome: ToolResolutionOutcome) { var transcriptCalls: [Transcript.ToolCall] = [] transcriptCalls.reserveCapacity(toolUses.count) for use in toolUses { @@ -1056,17 +1056,17 @@ private func resolveToolUses( ) } - guard !transcriptCalls.isEmpty else { return .invocations([]) } - let entries = currentRoundEntries + [.toolCalls(.init(transcriptCalls))] + let callsEntry = Transcript.ToolCalls(transcriptCalls) + let entries = currentRoundEntries + [.toolCalls(callsEntry)] switch try await session.resolveProfileToolCalls( transcriptCalls, requestContext: requestContext, currentRoundEntries: entries ) { case .stop(let calls): - return .stop(calls: calls) + return (callsEntry, .stop(calls: calls)) case .invocations(let invocations): - return .invocations(invocations.map { .init(call: $0.call, output: $0.output) }) + return (callsEntry, .invocations(invocations.map { .init(call: $0.call, output: $0.output) })) } } From 77e57d07a53ccb7a3de74ccea969c000442f3cc7 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 10:00:21 +0800 Subject: [PATCH 51/53] Use image placeholders for unsupported provider tool-output formats --- .../Models/AnthropicLanguageModel.swift | 26 +- .../Models/GeminiLanguageModel.swift | 35 ++- .../ProviderUsageTests.swift | 248 +++++++++++++++++- 3 files changed, 285 insertions(+), 24 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 4087241d..3f7ac123 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -723,7 +723,10 @@ public struct AnthropicLanguageModel: LanguageModel { .toolResult( .init( toolUseId: invocation.call.id, - content: try convertSegmentsToAnthropicContent(invocation.output.segments) + content: try convertSegmentsToAnthropicContent( + invocation.output.segments, + isToolOutput: true + ) ) ) ) @@ -1142,7 +1145,10 @@ extension Transcript { .toolResult( AnthropicToolResult( toolUseId: toolOutput.id, - content: try convertSegmentsToAnthropicContent(toolOutput.segments) + content: try convertSegmentsToAnthropicContent( + toolOutput.segments, + isToolOutput: true + ) ) ) ] @@ -1295,7 +1301,10 @@ private extension AnthropicThinking { } } -private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) throws -> [AnthropicContent] { +private func convertSegmentsToAnthropicContent( + _ segments: [Transcript.Segment], + isToolOutput: Bool = false +) throws -> [AnthropicContent] { var blocks: [AnthropicContent] = [] blocks.reserveCapacity(segments.count) for segment in segments { @@ -1314,7 +1323,12 @@ private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) case "png": mimeType = "image/png" case "gif": mimeType = "image/gif" case "webp": mimeType = "image/webp" - default: throw AnthropicImageAttachmentError.unsupportedImageType(url) + default: + if isToolOutput { + blocks.append(.text(AnthropicText(text: ""))) + continue + } + throw AnthropicImageAttachmentError.unsupportedImageType(url) } blocks.append( .image( @@ -1328,6 +1342,10 @@ private func convertSegmentsToAnthropicContent(_ segments: [Transcript.Segment]) blocks.append(.image(AnthropicImage(url: url.absoluteString))) } case .data(let data, let mimeType): + if isToolOutput && !["image/jpeg", "image/png", "image/gif", "image/webp"].contains(mimeType) { + blocks.append(.text(AnthropicText(text: ""))) + continue + } blocks.append(.image(AnthropicImage(base64Data: data.base64EncodedString(), mimeType: mimeType))) } } diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index c2eebe69..d67499ec 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -805,6 +805,10 @@ private func toGeminiFunctionResponse( case .image(let image): switch image.source { case .data(let data, let mimeType): + guard ["image/png", "image/jpeg", "image/webp"].contains(mimeType) else { + resultValues.append("") + continue + } parts.append( .inlineData( GeminiInlineData( @@ -815,7 +819,11 @@ private func toGeminiFunctionResponse( ) case .url(let url): if url.isFileURL { - parts.append(.inlineData(try geminiFunctionResponseData(from: url))) + if let data = try geminiFunctionResponseData(from: url) { + parts.append(.inlineData(data)) + } else { + resultValues.append("") + } } else { resultValues.append(url.absoluteString) } @@ -834,25 +842,14 @@ private func toGeminiFunctionResponse( ) } -private enum GeminiFunctionResponseAttachmentError: LocalizedError { - case unsupportedImageType(URL) - - var errorDescription: String? { - switch self { - case .unsupportedImageType(let url): - "Gemini Tool output attachments support PNG, JPEG, and WebP files; cannot determine a supported MIME type for \(url.lastPathComponent)." - } +private func geminiFunctionResponseData(from url: URL) throws -> GeminiInlineData? { + let mimeType: String + switch url.pathExtension.lowercased() { + case "png": mimeType = "image/png" + case "jpg", "jpeg": mimeType = "image/jpeg" + case "webp": mimeType = "image/webp" + default: return nil } -} - -private func geminiFunctionResponseData(from url: URL) throws -> GeminiInlineData { - let mimeType = - switch url.pathExtension.lowercased() { - case "png": "image/png" - case "jpg", "jpeg": "image/jpeg" - case "webp": "image/webp" - default: throw GeminiFunctionResponseAttachmentError.unsupportedImageType(url) - } return try GeminiInlineData( mimeType: mimeType, data: Data(contentsOf: url).base64EncodedString() diff --git a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift index b40134e8..54604ca6 100644 --- a/Tests/AnyLanguageModelTests/ProviderUsageTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderUsageTests.swift @@ -538,8 +538,254 @@ import Testing } } + @Test(arguments: [Provider.anthropic, .gemini], [false, true]) + func toolOutputImageMIMEsPreserveOnlySupportedWireParts(_ provider: Provider, _ streaming: Bool) async throws { + for mimeType in [ + "image/png", "image/jpeg", "image/gif", "image/webp", "image/tiff", "image/svg+xml", "image/avif", + "application/octet-stream", + ] { + let supported = + ["image/png", "image/jpeg", "image/webp"].contains(mimeType) + || (provider == .anthropic && mimeType == "image/gif") + let segments: [Transcript.Segment] = [ + .text(.init(content: "before")), + .image(.init(data: testImageData, mimeType: mimeType)), + .text(.init(content: "after")), + ] + let session = provider.makeSession(tools: [WeatherTool()]) + session.toolExecutionDelegate = ImageSegmentsDelegate(segments: segments) + try await checkImageToolContinuationAndReplay( + provider, + streaming: streaming, + session: session, + expectedTexts: supported ? ["before", "after"] : ["before", "", "after"], + expectedMIME: supported ? mimeType : nil + ) + let output = try #require( + session.transcript.compactMap { entry -> Transcript.ToolOutput? in + if case .toolOutput(let output) = entry { return output } + return nil + }.first + ) + #expect(output.segments == segments) + } + } + + @Test(arguments: [Provider.anthropic, .gemini], [false, true]) + func localToolImageFormatsSnapshotBeforePlaceholder(_ provider: Provider, _ streaming: Bool) async throws { + for (fileExtension, mimeType) in [("tiff", "image/tiff"), ("svg", "image/svg+xml"), ("gif", "image/gif")] { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString).appendingPathExtension(fileExtension) + try testImageData.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let session = provider.makeSession(tools: [LocalImageWeatherTool(url: url)]) + session.toolExecutionDelegate = DeleteImageAfterToolOutput(url: url, mimeType: mimeType) + let supported = provider == .anthropic && fileExtension == "gif" + try await checkImageToolContinuationAndReplay( + provider, + streaming: streaming, + session: session, + expectedTexts: supported ? [] : [""], + expectedMIME: supported ? mimeType : nil + ) + #expect(!FileManager.default.fileExists(atPath: url.path)) + let output = try #require( + session.transcript.compactMap { entry -> Transcript.ToolOutput? in + if case .toolOutput(let output) = entry { return output } + return nil + }.first + ) + guard case .image(let image)? = output.segments.first else { + Issue.record("Wire placeholder must not replace the canonical image") + continue + } + #expect(image.source == .data(testImageData, mimeType: mimeType)) + } + } + + private func checkImageToolContinuationAndReplay( + _ provider: Provider, + streaming: Bool, + session: LanguageModelSession, + expectedTexts: [String], + expectedMIME: String? + ) async throws { + UsageURLProtocol.reset() + UsageURLProtocol.enqueue( + json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true)) + ) + if streaming || provider == .gemini { + UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response())) + } + if streaming { + _ = try await session.streamResponse(to: "Weather?").collect() + } else { + _ = try await session.respond(to: "Weather?") + } + let archived = try JSONEncoder().encode(session.transcript) + let transcript = try JSONDecoder().decode(Transcript.self, from: archived) + #expect(transcript == session.transcript) + let restored = provider.makeSession(transcript: transcript) + UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response())) + if streaming { + _ = try await restored.streamResponse(to: "Review the image").collect() + } else { + _ = try await restored.respond(to: "Review the image") + } + #expect(UsageURLProtocol.recordedBodies.count == (streaming || provider == .gemini ? 3 : 2)) + for body in UsageURLProtocol.recordedBodies.dropFirst() { + try expectToolImage(in: body, provider: provider, texts: expectedTexts, mimeType: expectedMIME) + } + } + + private func expectToolImage(in body: Data, provider: Provider, texts: [String], mimeType: String?) throws { + let request = try JSONDecoder().decode([String: JSONValue].self, from: body) + if provider == .gemini { + let contents = try #require(request["contents"]?.arrayValue) + let results = contents.flatMap { $0.objectValue?["parts"]?.arrayValue ?? [] } + .compactMap { $0.objectValue?["functionResponse"]?.objectValue } + try #require(results.count == 1) + let result = try #require(results.first) + #expect( + result["response"]?.objectValue?["result"] + == (texts.isEmpty ? nil : .string(texts.joined(separator: "\n"))) + ) + let parts = result["parts"]?.arrayValue ?? [] + #expect(parts.count == (mimeType == nil ? 0 : 1)) + if let mimeType { + let image = try #require(parts.first?.objectValue?["inlineData"]?.objectValue) + #expect(image["mime_type"] == .string(mimeType)) + #expect(image["data"] == .string(testImageData.base64EncodedString())) + } + } else { + let messages = try #require(request["messages"]?.arrayValue) + let results = messages.flatMap { $0.objectValue?["content"]?.arrayValue ?? [] } + .filter { $0.objectValue?["type"] == .string("tool_result") } + try #require(results.count == 1) + let content = try #require(results.first?.objectValue?["content"]?.arrayValue) + #expect(content.compactMap { $0.objectValue?["text"]?.stringValue } == texts) + let images = content.filter { $0.objectValue?["type"] == .string("image") } + #expect(images.count == (mimeType == nil ? 0 : 1)) + if let mimeType { + let source = try #require(images.first?.objectValue?["source"]?.objectValue) + #expect(source["media_type"] == .string(mimeType)) + #expect(source["data"] == .string(testImageData.base64EncodedString())) + #expect(source["type"] == .string("base64")) + #expect( + content.map { $0.objectValue?["type"]?.stringValue } + == (texts.isEmpty ? ["image"] : ["text", "image", "text"]) + ) + } else { + #expect(content.allSatisfy { $0.objectValue?["type"] == .string("text") }) + } + } + } + + private struct ImageSegmentsDelegate: ToolExecutionDelegate { + let segments: [Transcript.Segment] + + func toolCallDecision(for toolCall: Transcript.ToolCall, in session: LanguageModelSession) async + -> ToolExecutionDecision + { .provideOutput(segments) } + } + + @Test(arguments: [Provider.anthropic, .gemini]) + func legacyUnsupportedToolImageURLsUsePlaceholder(_ provider: Provider) async throws { + for fileExtension in ["tiff", "svg"] { + UsageURLProtocol.reset() + UsageURLProtocol.enqueue(json: try Self.json(provider.response())) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString).appendingPathExtension(fileExtension) + let transcript = Transcript(entries: [ + .toolCalls( + .init([.init(id: "call_1", toolName: "getWeather", arguments: GeneratedContent("Paris"))]) + ), + .toolOutput( + .init(id: "call_1", toolName: "getWeather", segments: [.image(.init(source: .url(url)))]) + ), + ]) + let archived = try JSONEncoder().encode(transcript) + let decoded = try JSONDecoder().decode(Transcript.self, from: archived) + #expect(decoded == transcript) + _ = try await provider.makeSession(transcript: decoded).respond(to: "Review the image") + try #require(UsageURLProtocol.recordedBodies.count == 1) + try expectToolImage( + in: try #require(UsageURLProtocol.recordedBodies.first), + provider: provider, + texts: [""], + mimeType: nil + ) + } + } + + @Test(arguments: [Provider.anthropic, .gemini], [false, true]) + func missingToolImageFilesStillFail(_ provider: Provider, _ streaming: Bool) async throws { + UsageURLProtocol.reset() + UsageURLProtocol.enqueue( + json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true)) + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString).appendingPathExtension("png") + let session = provider.makeSession(tools: [LocalImageWeatherTool(url: url)]) + do { + if streaming { + _ = try await session.streamResponse(to: "Weather?").collect() + } else { + _ = try await session.respond(to: "Weather?") + } + Issue.record("A read failure must not become an image placeholder") + } catch let error as LanguageModelSession.ToolCallError { + #expect((error.underlyingError as NSError).domain == NSCocoaErrorDomain) + #expect((error.underlyingError as NSError).code == NSFileReadNoSuchFileError) + } + #expect(UsageURLProtocol.recordedBodies.count == 1) + } + + @Test(arguments: [Provider.anthropic, .gemini]) + func legacySupportedToolImageReadFailureIsNotAPlaceholder(_ provider: Provider) async throws { + UsageURLProtocol.reset() + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString).appendingPathExtension("png") + let transcript = Transcript(entries: [ + .toolCalls(.init([.init(id: "call_1", toolName: "getWeather", arguments: GeneratedContent("Paris"))])), + .toolOutput(.init(id: "call_1", toolName: "getWeather", segments: [.image(.init(source: .url(url)))])), + ]) + do { + _ = try await provider.makeSession(transcript: transcript).respond(to: "Review the image") + Issue.record("A supported image read failure must remain explicit") + } catch { + #expect((error as NSError).domain == NSCocoaErrorDomain) + #expect((error as NSError).code == NSFileReadNoSuchFileError) + } + #expect(UsageURLProtocol.recordedBodies.isEmpty) + } + + @Test(arguments: [Provider.anthropic, .gemini]) + func unsupportedUserImageMIMEsRemainImages(_ provider: Provider) async throws { + UsageURLProtocol.reset() + UsageURLProtocol.enqueue(json: try Self.json(provider.response())) + let transcript = Transcript(entries: [ + .prompt(.init(segments: [.image(.init(data: testImageData, mimeType: "image/tiff"))])) + ]) + _ = try await provider.makeSession(transcript: transcript).respond(to: "Review the image") + let body = try #require(UsageURLProtocol.recordedBodies.first) + let request = try JSONDecoder().decode([String: JSONValue].self, from: body) + if provider == .gemini { + let images = + request["contents"]?.arrayValue?.flatMap { $0.objectValue?["parts"]?.arrayValue ?? [] } + .compactMap { $0.objectValue?["inlineData"]?.objectValue } ?? [] + #expect(images.first?["mime_type"] == .string("image/tiff")) + } else { + let images = + request["messages"]?.arrayValue?.flatMap { $0.objectValue?["content"]?.arrayValue ?? [] } + .filter { $0.objectValue?["type"] == .string("image") } ?? [] + #expect(images.first?.objectValue?["source"]?.objectValue?["media_type"] == .string("image/tiff")) + } + } + private struct DeleteImageAfterToolOutput: ToolExecutionDelegate { let url: URL + var mimeType = "image/png" func didExecuteToolCall( _ toolCall: Transcript.ToolCall, @@ -550,7 +796,7 @@ import Testing Issue.record("Missing image in Tool output") return } - #expect(image.source == .data(testImageData, mimeType: "image/png")) + #expect(image.source == .data(testImageData, mimeType: mimeType)) do { try FileManager.default.removeItem(at: url) } catch { Issue.record(error) } } } From cb8603b25ae5a25114dad01f52af9f8d6bf94356 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 10:02:25 +0800 Subject: [PATCH 52/53] Apply tool image placeholders to Anthropic profile continuations --- .../Models/AnthropicLanguageModel.swift | 5 +- .../AnthropicProfileTests.swift | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 88a6b9aa..a55d1cab 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -548,7 +548,10 @@ public struct AnthropicLanguageModel: LanguageModel, ProfileRequestIdentifiedLan .toolResult( .init( toolUseId: invocation.call.id, - content: try convertSegmentsToAnthropicContent(invocation.output.segments) + content: try convertSegmentsToAnthropicContent( + invocation.output.segments, + isToolOutput: true + ) ) ) } diff --git a/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift b/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift index 40bf093b..d9229e66 100644 --- a/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift +++ b/Tests/AnyLanguageModelTests/AnthropicProfileTests.swift @@ -1,9 +1,57 @@ +import Foundation import Testing @_spi(Compatibility) @testable import AnyLanguageModel #if canImport(Darwin) && !canImport(AsyncHTTPClient) extension ProviderUsageTests { + @Test(arguments: [false, true]) + func anthropicProfileUnsupportedToolImageUsesPlaceholder(_ streaming: Bool) async throws { + let provider = Provider.anthropic + UsageURLProtocol.reset() + UsageURLProtocol.enqueue( + json: try streaming ? provider.toolStream() : Self.json(provider.response(text: "", tool: true)) + ) + UsageURLProtocol.enqueue(json: try streaming ? provider.stream() : Self.json(provider.response())) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { WeatherTool() }.model(provider.makeModel()) + ) + let segments: [Transcript.Segment] = [ + .text(.init(content: "before")), + .image(.init(data: testImageData, mimeType: "image/tiff")), + .text(.init(content: "after")), + ] + session.toolExecutionDelegate = ProfileImageDelegate(segments: segments) + if streaming { + _ = try await session.streamResponse(to: "Weather?").collect() + } else { + _ = try await session.respond(to: "Weather?") + } + try #require(UsageURLProtocol.recordedBodies.count == 2) + let request = try JSONDecoder().decode( + [String: JSONValue].self, + from: try #require(UsageURLProtocol.recordedBodies.last) + ) + let content = + request["messages"]?.arrayValue?.flatMap { $0.objectValue?["content"]?.arrayValue ?? [] } + .first { $0.objectValue?["type"] == .string("tool_result") }?.objectValue?["content"]?.arrayValue ?? [] + #expect(content.compactMap { $0.objectValue?["text"]?.stringValue } == ["before", "", "after"]) + #expect(content.allSatisfy { $0.objectValue?["type"] == .string("text") }) + let output = session.transcript.compactMap { entry -> Transcript.ToolOutput? in + if case .toolOutput(let output) = entry { return output } + return nil + }.first + #expect(output?.segments == segments) + } + + private struct ProfileImageDelegate: ToolExecutionDelegate { + let segments: [Transcript.Segment] + + func toolCallDecision(for toolCall: Transcript.ToolCall, in session: LanguageModelSession) async + -> ToolExecutionDecision + { .provideOutput(segments) } + } + @Test(arguments: [false, true]) func anthropicReusedCallIDs(_ streaming: Bool) async throws { for profile in [false, true] { From 2e968830e64952c8d32233570d5ec5cebbeb0bb9 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Thu, 22 Oct 2026 09:12:52 +0800 Subject: [PATCH 53/53] Preserve final-request and cumulative usage across profile handoffs --- .../ProfileToolExecution.swift | 12 +- .../ProfileRuntimeTests.swift | 104 ++++++++++++++++++ .../Shared/ProfileProviderFixtures.swift | 6 +- 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/Sources/AnyLanguageModel/ProfileToolExecution.swift b/Sources/AnyLanguageModel/ProfileToolExecution.swift index f7f0e626..f476b65b 100644 --- a/Sources/AnyLanguageModel/ProfileToolExecution.swift +++ b/Sources/AnyLanguageModel/ProfileToolExecution.swift @@ -60,8 +60,8 @@ extension LanguageModelSession { ) } - var usage = prefixUsage - usage.add(response.usage) + var earlierUsage = prefixUsage + earlierUsage.add(response.requests.earlier) var content = response.content var rawContent = response.rawContent if type == String.self, case .string(let suffix) = response.rawContent.kind { @@ -73,7 +73,7 @@ extension LanguageModelSession { content: content, rawContent: rawContent, transcriptEntries: ArraySlice(prefixEntries + response.transcriptEntries), - usage: usage, + requests: RequestsUsage(earlier: earlierUsage, final: response.requests.final), providerMetadata: response.providerMetadata ) } @@ -127,8 +127,8 @@ extension LanguageModelSession { let task = Task { do { for try await snapshot in upstream { - var usage = prefixUsage - usage.add(snapshot.usage) + var earlierUsage = prefixUsage + earlierUsage.add(snapshot.requests.earlier) var content = snapshot.content var rawContent = snapshot.rawContent if type == String.self, case .string(let suffix) = snapshot.rawContent.kind { @@ -141,7 +141,7 @@ extension LanguageModelSession { content: content, rawContent: rawContent, transcriptEntries: ArraySlice(prefixEntries + snapshot.transcriptEntries), - usage: usage, + requests: RequestsUsage(earlier: earlierUsage, final: snapshot.requests.final), providerMetadata: snapshot.providerMetadata ) ) diff --git a/Tests/AnyLanguageModelTests/ProfileRuntimeTests.swift b/Tests/AnyLanguageModelTests/ProfileRuntimeTests.swift index 88230743..9b7e5adc 100644 --- a/Tests/AnyLanguageModelTests/ProfileRuntimeTests.swift +++ b/Tests/AnyLanguageModelTests/ProfileRuntimeTests.swift @@ -681,6 +681,72 @@ struct DynamicProfileTests { #expect(session.transcript == durableBefore) } + @Test(arguments: [false, true], [false, true]) + func modelHandoffPreservesFinalAndEarlierRequestUsage(streaming: Bool, finalReportsUsage: Bool) async throws { + let prefix = LanguageModelSession.Usage( + input: .init(totalTokenCount: 11, cachedTokenCount: 0), + output: .init(totalTokenCount: 3, reasoningTokenCount: 0) + ) + let earlier = LanguageModelSession.Usage( + input: .init(totalTokenCount: 7, cachedTokenCount: 0), + output: .init(totalTokenCount: 2, reasoningTokenCount: 0) + ) + let final = + finalReportsUsage + ? LanguageModelSession.Usage( + input: .init(totalTokenCount: 5, cachedTokenCount: 0), + output: .init(totalTokenCount: 1, reasoningTokenCount: 0) + ) : .zero + let model = HandoffUsageModel(requests: .init(earlier: earlier, final: final)) + let session = LanguageModelSession( + profile: LanguageModelSession.Profile { Instructions("Usage handoff") }.model(model) + ) + let context = session.resolvedRequestContext() + let call = Transcript.ToolCall(id: "earlier-call", toolName: "fixture", arguments: .init(properties: [:])) + let entries: [Transcript.Entry] = [ + .toolCalls(.init([call])), + .toolOutput(.init(id: call.id, toolName: call.toolName, segments: [.text(.init(content: "Earlier"))])), + ] + let response: LanguageModelSession.Response + if streaming { + let stream: LanguageModelSession.ResponseStream = try #require( + session.profileContinuationStream( + ifSelectedModelDiffersFrom: UUID(), + requestContext: context, + prompt: Prompt("Continue"), + generating: String.self, + schema: String.generationSchema, + includeSchemaInPrompt: true, + prefixEntries: entries, + prefixUsage: prefix, + prefixText: "" + ) + ) + response = try await stream.collect() + } else { + response = try #require( + try await session.profileContinuationResponse( + ifSelectedModelDiffersFrom: UUID(), + requestContext: context, + prompt: Prompt("Continue"), + generating: String.self, + schema: String.generationSchema, + includeSchemaInPrompt: true, + prefixEntries: entries, + prefixUsage: prefix, + prefixText: "" + ) + ) + } + var total = prefix + total.add(earlier) + let expectedEarlier = total + total.add(final) + #expect(response.usage == final) + #expect(response.requests.earlier == expectedEarlier) + #expect(response.requests.total == total) + } + @Test func modelHandoffIsSkippedForTheInitialRequest() async throws { let session = LanguageModelSession( profile: LifecycleFixtureProfile( @@ -805,6 +871,44 @@ private struct FixtureProfileTool: Tool { func call(arguments: Arguments) async throws -> String { "done" } } +private struct HandoffUsageModel: LanguageModel { + typealias UnavailableReason = Never + let requests: RequestsUsage + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response { + let raw = GeneratedContent("Final") + return .init(content: try Content(raw), rawContent: raw, transcriptEntries: [], requests: requests) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream { + .init( + stream: AsyncThrowingStream { continuation in + do { + let raw = GeneratedContent("Final") + continuation.yield( + .init(content: try Content(raw).asPartiallyGenerated(), rawContent: raw, requests: requests) + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + ) + } +} + private struct ProfileFixtureModel: LanguageModel { typealias UnavailableReason = Never let id: String diff --git a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift index 1a15b734..fd4b1de2 100644 --- a/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift +++ b/Tests/AnyLanguageModelTests/Shared/ProfileProviderFixtures.swift @@ -156,7 +156,9 @@ import Testing if streaming { UsageURLProtocol.enqueue(json: try provider.toolStream()) } else { - UsageURLProtocol.enqueue(json: try Self.json(provider.response(text: "", tool: true))) + UsageURLProtocol.enqueue( + json: try Self.json(provider.response(text: "", counts: provider.counts, tool: true)) + ) } let state = ProviderProfileState() let session = LanguageModelSession( @@ -173,6 +175,8 @@ import Testing : session.respond(to: "Weather?") #expect(response.content == "Switched") + #expect(response.usage == .zero) + #expect(session.usage == provider.expected) #expect(UsageURLProtocol.recordedBodies.count == 1) #expect(response.transcriptEntries.contains { if case .toolCalls = $0 { true } else { false } }) #expect(response.transcriptEntries.contains { if case .toolOutput = $0 { true } else { false } })