From f2de31966ad6dc1bdeddf0c0a07da9454ae7b004 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 9 Oct 2026 16:06:55 +0800 Subject: [PATCH 01/17] 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 cd1d6fcbcf8fe45ec8b7d779cdc3fe4727337e68 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 11 Oct 2026 22:17:19 +0800 Subject: [PATCH 02/17] 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 03/17] 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 04/17] 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 05/17] 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 b9738abb2c9f669af3c45d68a1cc2f223e201e86 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 03:09:58 +0800 Subject: [PATCH 06/17] Document dynamic profiles --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index 37d7d245..cb46c4f6 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,64 @@ 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`. +### Dynamic Profiles + +`LanguageModelSession.DynamicProfile` mirrors the Foundation Models 27 profile +API on supported AnyLanguageModel deployment targets. A profile chooses one +active `Profile` for each actual model request and can change its model, +instructions, tools, generation options, context options, and transcript +projection without rebuilding the session: + +```swift +extension SessionPropertyValues { + @SessionPropertyEntry var isReviewing = false +} + +struct EditingProfile: LanguageModelSession.DynamicProfile { + let model: any LanguageModel + @SessionProperty(\.isReviewing) private var isReviewing + + var body: some LanguageModelSession.DynamicProfile { + if isReviewing { + Profile { + Instructions("Review the current draft.") + ReviewTool() + } + .model(model) + .temperature(0.1) + } else { + Profile { + Instructions("Help edit the current draft.") + EditTool() + } + .model(model) + .temperature(0.7) + } + } +} + +let session = LanguageModelSession( + profile: EditingProfile(model: model), + history: savedHistory +) +session.properties.isReviewing = true +``` + +Value modifiers use call-site, inner-profile, then outer-profile precedence. +Lifecycle callbacks accumulate. `historyTransform` changes only the transcript +projection for that request; `session.transcript` remains the canonical history. +`@SessionProperty` exposes isolated session state to profiles, dynamic +instructions, and tools. The built-in `history` property is writable from a +profile or lifecycle callback and read-only while dynamic instructions or a Tool +is active. + +External compatibility models that own Tool continuation use +`resolvedRequestContext(including:options:)` before every provider request. The +`including:` entries are the canonical Tool calls and outputs completed in the +current response but not yet committed to the session transcript. Calls execute +against the tools in the producing request context; the next resolved context is +used only for the continuation request. + ### Reasoning in the transcript Reasoning is transcript content, separate from the answer in `response.content`. @@ -653,6 +711,12 @@ say which API they follow. [request options](#foundation-models-27-request-options), which follow the Foundation Models 27 API while remaining available on the package's existing deployment targets. +- `LanguageModelSession.DynamicProfile`, profile modifiers, lifecycle callbacks, + session properties, and `LanguageModelSession.init(profile:history:)`: + [dynamic profiles](#dynamic-profiles), which mirror Foundation Models 27 on + earlier deployment targets. `resolvedRequestContext(including:options:)` and + the explicit compatibility lifecycle hooks are AnyLanguageModel extensions for + external model implementations until the package adopts the executor contract. - `Transcript.Entry.reasoning` and `Transcript.Reasoning`: [reasoning in the transcript](#reasoning-in-the-transcript), which follows the Foundation Models 27 API. From da630296a0a5bfb37d21e0e71c14f84e0150a3ea Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:14:45 +0800 Subject: [PATCH 07/17] 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 08/17] 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 09/17] 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 5fdf90e420bf4c8832365bfc8f3e9792b58da3a6 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:14:45 +0800 Subject: [PATCH 10/17] 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 d0cae54870d015b9451b52ae1c506a19f20d5f4e Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:19:17 +0800 Subject: [PATCH 11/17] 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 12/17] 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 13/17] 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 4879808f369b3ce9e641cf897dd74a8dd48e1901 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:19:17 +0800 Subject: [PATCH 14/17] 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 51c5bcc21570e8504ab230e5359cc2e52ff7f6f2 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Tue, 13 Oct 2026 09:26:24 +0800 Subject: [PATCH 15/17] 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 93e4d4d7fdfb5e211882c948793dc2230214a752 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Thu, 15 Oct 2026 09:03:50 +0800 Subject: [PATCH 16/17] 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 17/17] 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]>([])