From cd1d6fcbcf8fe45ec8b7d779cdc3fe4727337e68 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sun, 11 Oct 2026 22:17:19 +0800 Subject: [PATCH 1/8] 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 da630296a0a5bfb37d21e0e71c14f84e0150a3ea Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 12 Oct 2026 09:14:45 +0800 Subject: [PATCH 2/8] 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 93e4d4d7fdfb5e211882c948793dc2230214a752 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Thu, 15 Oct 2026 09:03:50 +0800 Subject: [PATCH 3/8] 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 3ad28fb00d2e6b6aade9eb13227d6b4b29a94b66 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Fri, 16 Oct 2026 09:06:28 +0800 Subject: [PATCH 4/8] Keep session history bindings isolated and instruction free --- .../LanguageModelSession.swift | 4 ++ .../AnyLanguageModel/SessionProperties.swift | 7 ++- .../SessionPropertyTests.swift | 45 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 683dae8e..7f751a51 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -333,6 +333,10 @@ public final class LanguageModelSession: @unchecked Sendable { let state = Locked(State(finalTranscript)) self.state = state let setHistory: @Sendable (Transcript.HistoryView) -> Void = { history in + let history = history.filter { entry in + if case .instructions = entry { return false } + return true + } state.withLock { locked in let instructions = locked.transcript.prefix { if case .instructions = $0 { return true } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 42aa3e92..47ef8a8c 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -47,12 +47,15 @@ public final class SessionPropertyValues: @unchecked Sendable { public var history: Transcript.HistoryView { get { _accessSessionProperty(keyPath: \.history) - return SessionPropertyBinding.history ?? historyGetter() + if SessionPropertyBinding.values === self, let history = SessionPropertyBinding.history { + return history + } + return historyGetter() } set { _withSessionPropertyMutation(keyPath: \.history) { precondition( - SessionPropertyBinding.history == nil, + SessionPropertyBinding.values !== self || SessionPropertyBinding.history == nil, "Session history is read-only while dynamic instructions are active" ) historySetter(newValue) diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index b4a4011d..ebecf1ed 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -180,6 +180,51 @@ struct SessionPropertyTests { } #expect(session.transcript[1] == prompt) } + + @Test func boundHistoryOnlyAppliesToItsOwningSession() { + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + let replacement = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Replacement"))])) + let other = LanguageModelSession(model: SessionPropertyFixtureModel()) + other.properties.history = [earlier] + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: ForeignSessionHistoryInstructions(other: other, replacement: replacement) + ) + + let context = session.resolvedRequestContext() + + #expect(context.instructions?.description == "Other history count 1") + #expect(session.properties.history.isEmpty) + #expect(other.properties.history == [replacement]) + } + + @Test func replacingHistoryRejectsForeignInstructions() { + let other = LanguageModelSession(model: SessionPropertyFixtureModel(), instructions: "Foreign") + let prompt = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + other.properties.history = [prompt] + let session = LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: SessionPropertyHistoryInstructions() + ) + + session.properties.history = Array(other.transcript) + let context = session.resolvedRequestContext() + + #expect(session.transcript == Transcript(entries: [prompt])) + #expect(context.instructions?.description == "History count 1") + #expect(context.transcript.count == 2) + } +} + +private struct ForeignSessionHistoryInstructions: DynamicInstructions { + let other: LanguageModelSession + let replacement: Transcript.Entry + + var body: some DynamicInstructions { + let count = other.properties.history.count + other.properties.history = [replacement] + return Instructions("Other history count \(count)") + } } private struct SessionPropertyFixtureInstructions: DynamicInstructions { From 2c5c4889e5ed7428deb1aafcc4be580a4e843b6e Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 09:01:50 +0800 Subject: [PATCH 5/8] Invalidate stale history commits and evaluate property defaults outside the lock --- .../LanguageModelSession.swift | 10 +- .../AnyLanguageModel/SessionProperties.swift | 11 +- .../SessionPropertyTests.swift | 125 ++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 7f751a51..ab14a6c5 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -530,9 +530,10 @@ public final class LanguageModelSession: @unchecked Sendable { ) ) session.withTranscriptMutation { - session.state.withLock { - $0.transcript.append(contentsOf: lastSnapshot.transcriptEntries) - $0.transcript.append(responseEntry) + session.state.withLock { state in + guard state.transcript.contains(where: { $0.id == promptEntry.id }) else { return } + state.transcript.append(contentsOf: lastSnapshot.transcriptEntries) + state.transcript.append(responseEntry) } } session.endResponding() @@ -540,6 +541,7 @@ public final class LanguageModelSession: @unchecked Sendable { } catch { session.withTranscriptMutation { session.state.withLock { state in + guard state.transcript.contains(where: { $0.id == promptEntry.id }) else { return } if state.transcriptErrorHandlingPolicy == .preserveTranscript { state.transcript.append(contentsOf: lastSnapshot?.transcriptEntries ?? []) } else if state.transcriptErrorHandlingPolicy == .revertTranscript { @@ -819,6 +821,7 @@ public final class LanguageModelSession: @unchecked Sendable { // Add tool entries and response to transcript withTranscriptMutation { state.withLock { lockedState in + guard lockedState.transcript.contains(where: { $0.id == promptID }) else { return } lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) } @@ -1228,6 +1231,7 @@ extension LanguageModelSession { // Add tool entries and response to transcript withTranscriptMutation { state.withLock { lockedState in + guard lockedState.transcript.contains(where: { $0.id == promptID }) else { return } lockedState.transcript.append(contentsOf: response.transcriptEntries) lockedState.transcript.append(responseEntry) } diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index 47ef8a8c..b7b968d2 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -65,12 +65,15 @@ public final class SessionPropertyValues: @unchecked Sendable { public subscript(key: Key.Type) -> Key.Value where Key: SessionPropertyKey { get { - customValues.withLock { values in - if let value = values[ObjectIdentifier(key)] as? Key.Value { return value } - let value = Key.defaultValue - values[ObjectIdentifier(key)] = value + if let value = customValues.withLock({ $0[ObjectIdentifier(key)] as? Key.Value }) { return value } + let defaultValue = Key.defaultValue + return customValues.withLock { values in + if let value = values[ObjectIdentifier(key)] as? Key.Value { return value } + values[ObjectIdentifier(key)] = defaultValue + return defaultValue + } } set { customValues.withLock { values in diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index ebecf1ed..00311fdc 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -18,6 +18,64 @@ enum SessionPropertyMode { @Suite("Session properties") struct SessionPropertyTests { + @Test func propertyDefaultsCanReadAnotherProperty() { + let session = LanguageModelSession(model: SessionPropertyFixtureModel()) + SessionPropertyBinding.$values.withValue(session.properties) { + #expect(session.properties[DerivedPropertyKey.self] == 42) + session.properties[BasePropertyKey.self] = 7 + #expect(session.properties[DerivedPropertyKey.self] == 42) + } + } + + @Test(arguments: [false, true], [false, true]) + func replacingHistoryDiscardsStaleGenerationCommits(streaming: Bool, images: Bool) async throws { + let (started, start) = AsyncStream.makeStream() + let (resume, resumeContinuation) = AsyncStream.makeStream() + let session = LanguageModelSession( + model: PausedHistoryModel(started: start, resume: resume), + instructions: "Keep instructions" + ) + let request = Task { + if streaming { + let stream = + images + ? session.streamResponse(to: "Old request", images: []) + : session.streamResponse(to: "Old request") + return try await stream.collect().content + } + let response = + try await images + ? session.respond(to: "Old request", images: []) + : session.respond(to: "Old request") + return response.content + } + for await _ in started.prefix(1) {} + let replacement = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "New conversation"))])) + session.properties.history = [replacement] + resumeContinuation.yield() + + #expect(try await request.value == "Done") + #expect(session.properties.history == [replacement]) + #expect(session.transcript.count == 2) + #expect(!session.isResponding) + } + + @Test func replacingHistoryDiscardsStaleStreamingFailureCheckpoint() async { + let (started, start) = AsyncStream.makeStream() + let (resume, resumeContinuation) = AsyncStream.makeStream() + let session = LanguageModelSession(model: PausedHistoryModel(started: start, resume: resume, fail: true)) + session.transcriptErrorHandlingPolicy = .preserveTranscript + let request = Task { try await session.streamResponse(to: "Old request").collect() } + for await _ in started.prefix(1) {} + let replacement = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "New conversation"))])) + session.properties.history = [replacement] + resumeContinuation.yield() + + await #expect(throws: PausedHistoryError.self) { try await request.value } + #expect(session.properties.history == [replacement]) + #expect(!session.isResponding) + } + @Test func dynamicInstructionsReadAndUpdateSessionProperties() { let session = LanguageModelSession( model: SessionPropertyFixtureModel(), @@ -216,6 +274,73 @@ struct SessionPropertyTests { } } +private enum BasePropertyKey: SessionPropertyKey { + static let defaultValue = 42 +} + +private enum DerivedPropertyKey: SessionPropertyKey { + static var defaultValue: Int { + SessionPropertyBinding.values![BasePropertyKey.self] + } +} + +private enum PausedHistoryError: Error { case failed } + +private struct PausedHistoryModel: LanguageModel { + typealias UnavailableReason = Never + let started: AsyncStream.Continuation + let resume: AsyncStream + var fail = false + + private var entries: [Transcript.Entry] { + [.reasoning(.init(segments: [.text(.init(content: "Old reasoning"))]))] + } + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + started.yield() + for await _ in resume.prefix(1) {} + let raw = GeneratedContent("Done") + return .init(content: try Content(raw), rawContent: raw, transcriptEntries: entries[...]) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + .init( + stream: AsyncThrowingStream { continuation in + Task { + do { + let raw = GeneratedContent("Done") + continuation.yield( + .init( + content: try Content.PartiallyGenerated(raw), + rawContent: raw, + transcriptEntries: entries[...] + ) + ) + started.yield() + for await _ in resume.prefix(1) {} + if fail { throw PausedHistoryError.failed } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + ) + } +} + private struct ForeignSessionHistoryInstructions: DynamicInstructions { let other: LanguageModelSession let replacement: Transcript.Entry From 0a3580bc97e421f23d6d04ff4f93e695f2b0c2e3 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Sat, 17 Oct 2026 23:52:14 +0800 Subject: [PATCH 6/8] Match HistoryView collection shape and bind session properties in context tools --- .../LanguageModelSession.swift | 39 ++++++--- .../AnyLanguageModel/SessionProperties.swift | 81 +++++++++++++++++-- ...PICompatibilityAnyLanguageModelTests.swift | 17 ++++ ...PICompatibilityFoundationModelsTests.swift | 18 +++++ .../DynamicInstructionsTests.swift | 2 +- .../HistoryViewTests.swift | 47 +++++++++++ .../MLXLanguageModelTests.swift | 2 +- .../NativeSessionPropertyTests.swift | 50 ++++++++++++ .../RequestContextTests.swift | 2 +- .../SessionPropertyTests.swift | 74 +++++++++++++++-- .../Shared/MockLanguageModel.swift | 2 +- 11 files changed, 308 insertions(+), 26 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/HistoryViewTests.swift create mode 100644 Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 86f00d82..31e464d9 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -141,6 +141,7 @@ public final class LanguageModelSession: @unchecked Sendable { /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. + @_spi(Compatibility) public struct RequestContext: Sendable { /// The transcript to send with the request. public let transcript: Transcript @@ -160,22 +161,36 @@ public final class LanguageModelSession: @unchecked Sendable { /// Returns the transcript, instructions, and tools for the next model request. /// - /// Calling this method doesn't change the session. + /// Dynamic instructions may read and update custom session properties while + /// being resolved. Their history snapshot is read-only and instructions are + /// not committed to the canonical transcript. /// /// - Note: This API is exclusive to AnyLanguageModel /// and using it means your code is no longer drop-in compatible /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. + @_spi(Compatibility) nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { - return RequestContext(transcript: transcript, instructions: instructions, tools: tools) + let transcript = transcript + let history = Transcript.HistoryView( + transcript.filter { + if case .instructions = $0 { return false } + return true + } + ) + return RequestContext( + transcript: transcript, + instructions: instructions, + tools: tools.map { $0.bindingSessionProperties(properties, history: history) } + ) } // Evaluate the body for this request. // The resolved instructions go into the request's transcript, // never into the session's. - let canonicalHistory = Array(transcript) + let canonicalHistory = Transcript.HistoryView(transcript) let resolved = dynamicInstructionsLock.withLock { SessionPropertyBinding.$values.withValue(properties) { SessionPropertyBinding.$history.withValue(canonicalHistory) { @@ -183,7 +198,7 @@ public final class LanguageModelSession: @unchecked Sendable { } } } - var requestTranscript = Transcript(entries: canonicalHistory) + var requestTranscript = Transcript(entries: Array(canonicalHistory)) if let instructions = resolved.instructions { let instructionsEntry = Transcript.Entry.instructions( Transcript.Instructions( @@ -195,7 +210,11 @@ public final class LanguageModelSession: @unchecked Sendable { ) requestTranscript = Transcript(entries: [instructionsEntry] + requestTranscript) } - return RequestContext(transcript: requestTranscript, instructions: resolved.instructions, tools: resolved.tools) + return RequestContext( + transcript: requestTranscript, + instructions: resolved.instructions, + tools: resolved.tools.map { $0.bindingSessionProperties(properties, history: canonicalHistory) } + ) } /// Creates a session with a model, tools, @@ -349,10 +368,12 @@ public final class LanguageModelSession: @unchecked Sendable { self.properties = SessionPropertyValues( historyGetter: { state.withLock { locked in - locked.transcript.filter { entry in - if case .instructions = entry { return false } - return true - } + Transcript.HistoryView( + locked.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } + ) } }, historySetter: { history in diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index b7b968d2..ecc300d2 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -6,7 +6,50 @@ extension Transcript { /// /// - Note: This API is exclusive to AnyLanguageModel on OS 26. It mirrors /// Foundation Models 27's `Transcript.HistoryView` role with value semantics. - public typealias HistoryView = [Entry] + public struct HistoryView: RandomAccessCollection, MutableCollection, RangeReplaceableCollection, + ExpressibleByArrayLiteral, Sendable + { + public typealias Element = Entry + public typealias SubSequence = HistoryView + public typealias Indices = Range + private var entries: ArraySlice + + public struct Index: Comparable, Hashable, Strideable, Sendable { + fileprivate let offset: Int + + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.offset < rhs.offset } + public func distance(to other: Self) -> Int { other.offset - offset } + public func advanced(by n: Int) -> Self { Self(offset: offset + n) } + } + + public init() { entries = [] } + public init(_ elements: S) where S.Element == Entry { entries = Array(elements)[...] } + public init(arrayLiteral elements: Entry...) { entries = elements[...] } + private init(entries: ArraySlice) { self.entries = entries } + + public var startIndex: Index { Index(offset: entries.startIndex) } + public var endIndex: Index { Index(offset: entries.endIndex) } + public var indices: Indices { startIndex ..< endIndex } + public func index(after i: Index) -> Index { i.advanced(by: 1) } + public func index(before i: Index) -> Index { i.advanced(by: -1) } + public func index(_ i: Index, offsetBy distance: Int) -> Index { i.advanced(by: distance) } + public func distance(from start: Index, to end: Index) -> Int { start.distance(to: end) } + + public subscript(position: Index) -> Entry { + get { entries[position.offset] } + set { entries[position.offset] = newValue } + } + + public subscript(bounds: Range) -> HistoryView { + get { HistoryView(entries: entries[bounds.lowerBound.offset ..< bounds.upperBound.offset]) } + set { replaceSubrange(bounds, with: newValue) } + } + + public mutating func replaceSubrange(_ subrange: Range, with newElements: C) + where C.Element == Entry { + entries.replaceSubrange(subrange.lowerBound.offset ..< subrange.upperBound.offset, with: newElements) + } + } } /// A type-keyed session property. @@ -24,7 +67,7 @@ public protocol SessionPropertyKey: SendableMetatype { public macro SessionPropertyEntry() = #externalMacro(module: "AnyLanguageModelMacros", type: "SessionPropertyEntryMacro") -/// Session-scoped storage available while dynamic instructions are evaluated. +/// Session-scoped storage shared by dynamic instructions and Tools. /// /// - Note: This API is exclusive to AnyLanguageModel on OS 26 and mirrors /// Foundation Models 27. @@ -56,7 +99,7 @@ public final class SessionPropertyValues: @unchecked Sendable { _withSessionPropertyMutation(keyPath: \.history) { precondition( SessionPropertyBinding.values !== self || SessionPropertyBinding.history == nil, - "Session history is read-only while dynamic instructions are active" + "Session history is read-only while dynamic instructions or a Tool is active" ) historySetter(newValue) } @@ -121,7 +164,7 @@ extension LanguageModelSession { get { guard let values = SessionPropertyBinding.values else { preconditionFailure( - "SessionProperty can only be accessed while dynamic instructions are active" + "SessionProperty can only be accessed while dynamic instructions or a Tool is active" ) } return values[keyPath: keyPath] @@ -129,7 +172,7 @@ extension LanguageModelSession { nonmutating set { guard let values = SessionPropertyBinding.values else { preconditionFailure( - "SessionProperty can only be accessed while dynamic instructions are active" + "SessionProperty can only be accessed while dynamic instructions or a Tool is active" ) } values[keyPath: keyPath] = newValue @@ -143,3 +186,31 @@ extension LanguageModelSession.SessionProperty: @unchecked Sendable where Value: extension DynamicInstructions { public typealias SessionProperty = LanguageModelSession.SessionProperty } + +extension Tool { + func bindingSessionProperties(_ properties: SessionPropertyValues, history: Transcript.HistoryView) -> any Tool { + SessionPropertyTool(wrapped: self, properties: properties, history: history) + } +} + +private struct SessionPropertyTool: Tool { + let wrapped: Wrapped + let properties: SessionPropertyValues + let history: Transcript.HistoryView + + var name: String { wrapped.name } + var description: String { wrapped.description } + var parameters: GenerationSchema { wrapped.parameters } + var includesSchemaInInstructions: Bool { wrapped.includesSchemaInInstructions } + + func call(arguments: Wrapped.Arguments) async throws -> Wrapped.Output { + let history = + SessionPropertyBinding.values === properties + ? SessionPropertyBinding.history ?? history : history + return try await SessionPropertyBinding.$values.withValue(properties) { + try await SessionPropertyBinding.$history.withValue(history) { + try await wrapped.call(arguments: arguments) + } + } + } +} diff --git a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift index 2afe9d3e..67f509c8 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -10,6 +10,23 @@ import Testing return false }() + @Test func anyLanguageModelHistoryViewCollectionCompatibility() { + func requireContract(_ history: C) + where + C: RandomAccessCollection & MutableCollection & RangeReplaceableCollection + & ExpressibleByArrayLiteral & Sendable, + C.Element == Transcript.Entry, C.SubSequence == C, C.Index: Hashable & Strideable, + C.Index.Stride == Int + {} + var history: Transcript.HistoryView = [] + let empty: Transcript.HistoryView = history[history.startIndex ..< history.endIndex] + history[history.startIndex ..< history.endIndex] = empty + history.append(contentsOf: empty) + history.replaceSubrange(history.startIndex ..< history.endIndex, with: empty) + requireContract(history) + #expect(history.isEmpty) + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) extension SessionPropertyValues { @SessionPropertyEntry var compatibilityRevision = 0 diff --git a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift index d8d0c51e..79cfe2d3 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -12,6 +12,24 @@ import Testing #if compiler(>=6.4) #if os(macOS) || os(iOS) || os(visionOS) + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) + @Test func foundationModelsHistoryViewCollectionCompatibility() { + func requireContract(_ history: C) + where + C: RandomAccessCollection & MutableCollection & RangeReplaceableCollection + & ExpressibleByArrayLiteral & Sendable, + C.Element == Transcript.Entry, C.SubSequence == C, C.Index: Hashable & Strideable, + C.Index.Stride == Int + {} + var history: Transcript.HistoryView = [] + let empty: Transcript.HistoryView = history[history.startIndex ..< history.endIndex] + history[history.startIndex ..< history.endIndex] = empty + history.append(contentsOf: empty) + history.replaceSubrange(history.startIndex ..< history.endIndex, with: empty) + requireContract(history) + #expect(history.isEmpty) + } + @available(macOS 27.0, iOS 27.0, visionOS 27.0, *) extension SessionPropertyValues { @SessionPropertyEntry var compatibilityRevision = 0 diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift index af43ad37..baba7aa1 100644 --- a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel @Suite("Dynamic instructions") struct DynamicInstructionsTests { diff --git a/Tests/AnyLanguageModelTests/HistoryViewTests.swift b/Tests/AnyLanguageModelTests/HistoryViewTests.swift new file mode 100644 index 00000000..57ee4107 --- /dev/null +++ b/Tests/AnyLanguageModelTests/HistoryViewTests.swift @@ -0,0 +1,47 @@ +import Testing + +import AnyLanguageModel + +@Suite("HistoryView collection contract") +struct HistoryViewTests { + private func entry(_ text: String) -> Transcript.Entry { + .prompt(.init(segments: [.text(.init(content: text))])) + } + + @Test func mutableSlicesPreserveIndicesAndValueSemantics() { + let first = entry("first") + let second = entry("second") + let third = entry("third") + var history: Transcript.HistoryView = [first, second, third] + let original = history + let middle = history.index(after: history.startIndex) + var slice: Transcript.HistoryView = history[middle ..< history.endIndex] + + #expect(slice.startIndex == middle) + #expect(slice.endIndex == history.endIndex) + #expect(slice.distance(from: slice.startIndex, to: slice.endIndex) == 2) + #expect(slice.index(before: slice.endIndex) == history.index(history.startIndex, offsetBy: 2)) + #expect(Array(slice.indices) == [middle, middle.advanced(by: 1)]) + #expect(slice.startIndex.distance(to: slice.endIndex) == 2) + slice[slice.startIndex] = first + #expect(Array(history) == [first, second, third]) + history[middle ..< history.endIndex] = slice + #expect(Array(history) == [first, first, third]) + #expect(Array(original) == [first, second, third]) + } + + @Test func rangeReplacementAndCollectionInitializers() { + let first = entry("first") + let second = entry("second") + let third = entry("third") + var history = Transcript.HistoryView([first, second]) + history.replaceSubrange(history.startIndex ..< history.index(after: history.startIndex), with: [third, first]) + #expect(Array(history) == [third, first, second]) + history.append(third) + history.append(contentsOf: [second]) + history.removeFirst() + #expect(Array(history) == [first, second, third, second]) + history.removeAll() + #expect(history.isEmpty) + } +} diff --git a/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift b/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift index 18841c72..3e931d8f 100644 --- a/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/MLXLanguageModelTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel #if MLX import class MLX.MLXArray diff --git a/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift new file mode 100644 index 00000000..595b0714 --- /dev/null +++ b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift @@ -0,0 +1,50 @@ +import Testing + +#if canImport(FoundationModels) && !os(tvOS) && !os(watchOS) + import FoundationModels + @_spi(Compatibility) @testable import AnyLanguageModel + + @Suite("Native Tool session-property binding") + struct NativeSessionPropertyTests { + @Test func nativeWrappersBindPropertiesWithoutTaskLocalInheritance() async throws { + guard #available(macOS 26.0, iOS 26.0, visionOS 26.0, *) else { return } + let first = AnyLanguageModel.LanguageModelSession(model: MockLanguageModel(), tools: [NativePropertyTool()]) + let second = AnyLanguageModel.LanguageModelSession( + model: MockLanguageModel(), + tools: [NativePropertyTool()] + ) + first.properties.revision = 1 + second.properties.revision = 20 + let firstTool = try #require(first.resolvedRequestContext().tools.toFoundationModels().first) + let secondTool = try #require(second.resolvedRequestContext().tools.toFoundationModels().first) + + let firstCall = Task.detached { try await callNativeTool(firstTool) } + let secondCall = Task.detached { try await callNativeTool(secondTool) } + try await firstCall.value + try await secondCall.value + + #expect(first.properties.revision == 2) + #expect(second.properties.revision == 21) + #expect(AnyLanguageModel.SessionPropertyBinding.values == nil) + } + } + + @available(macOS 26.0, iOS 26.0, visionOS 26.0, *) + private func callNativeTool(_ tool: T) async throws { + #expect(AnyLanguageModel.SessionPropertyBinding.values == nil) + let arguments = try T.Arguments(FoundationModels.GeneratedContent(json: "\"read\"")) + _ = try await tool.call(arguments: arguments) + } + + private struct NativePropertyTool: AnyLanguageModel.Tool { + @AnyLanguageModel.LanguageModelSession.SessionProperty(\.revision) private var revision + let name = "property" + let description = "Update a session property" + var parameters: AnyLanguageModel.GenerationSchema { String.generationSchema } + + func call(arguments: String) async throws -> String { + revision += 1 + return "done" + } + } +#endif diff --git a/Tests/AnyLanguageModelTests/RequestContextTests.swift b/Tests/AnyLanguageModelTests/RequestContextTests.swift index a39cbf7a..5503f183 100644 --- a/Tests/AnyLanguageModelTests/RequestContextTests.swift +++ b/Tests/AnyLanguageModelTests/RequestContextTests.swift @@ -1,6 +1,6 @@ import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel @Suite("Request context") struct RequestContextTests { diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index 00311fdc..df99a875 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -2,7 +2,7 @@ import Foundation import Observation import Testing -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel extension SessionPropertyValues { @SessionPropertyEntry var revision = 0 @@ -18,6 +18,42 @@ enum SessionPropertyMode { @Suite("Session properties") struct SessionPropertyTests { + @Test(arguments: [false, true]) + func contextToolsCarryPropertiesIntoDetachedExecution(dynamic: Bool) async throws { + let session = + dynamic + ? LanguageModelSession( + model: SessionPropertyFixtureModel(), + dynamicInstructions: PropertyToolInstructions() + ) + : LanguageModelSession( + model: SessionPropertyFixtureModel(), + tools: [PropertyBoundTool()], + instructions: "Static" + ) + let earlier = Transcript.Entry.prompt(.init(segments: [.text(.init(content: "Earlier"))])) + session.properties.history = [earlier] + session.properties.revision = 7 + let context = session.resolvedRequestContext() + let tool = try #require(context.tools.first) + let output = try await Task.detached { + #expect(SessionPropertyBinding.values == nil) + return try await tool.makeOutputSegments(from: GeneratedContent("read")) + }.value + + switch try #require(output.first) { + case .text(let text): + #expect(text.content == "revision:7 history:1") + case .structure(let structured): + #expect(try String(structured.content) == "revision:7 history:1") + case .image: + Issue.record("Unexpected image output") + } + #expect(session.properties.revision == 8) + #expect(Array(session.properties.history) == [earlier]) + #expect(SessionPropertyBinding.values == nil) + } + @Test func propertyDefaultsCanReadAnotherProperty() { let session = LanguageModelSession(model: SessionPropertyFixtureModel()) SessionPropertyBinding.$values.withValue(session.properties) { @@ -55,7 +91,7 @@ struct SessionPropertyTests { resumeContinuation.yield() #expect(try await request.value == "Done") - #expect(session.properties.history == [replacement]) + #expect(Array(session.properties.history) == [replacement]) #expect(session.transcript.count == 2) #expect(!session.isResponding) } @@ -72,7 +108,7 @@ struct SessionPropertyTests { resumeContinuation.yield() await #expect(throws: PausedHistoryError.self) { try await request.value } - #expect(session.properties.history == [replacement]) + #expect(Array(session.properties.history) == [replacement]) #expect(!session.isResponding) } @@ -105,7 +141,7 @@ struct SessionPropertyTests { #expect(context.instructions?.description == "History count 1") #expect(context.transcript.count == 2) - #expect(session.properties.history == [prompt]) + #expect(Array(session.properties.history) == [prompt]) } @Test func requestUsesOneHistorySnapshotDuringDynamicResolution() async { @@ -139,7 +175,7 @@ struct SessionPropertyTests { return true } == [initial] ) - #expect(session.properties.history == [replacement]) + #expect(Array(session.properties.history) == [replacement]) } @Test func replacingHistoryNotifiesTranscriptObservers() { @@ -230,7 +266,7 @@ struct SessionPropertyTests { session.properties.history = [prompt] - #expect(session.properties.history == [prompt]) + #expect(Array(session.properties.history) == [prompt]) #expect(session.transcript.count == 2) guard case .instructions = session.transcript[0] else { Issue.record("Expected the static instructions entry to remain first") @@ -253,7 +289,7 @@ struct SessionPropertyTests { #expect(context.instructions?.description == "Other history count 1") #expect(session.properties.history.isEmpty) - #expect(other.properties.history == [replacement]) + #expect(Array(other.properties.history) == [replacement]) } @Test func replacingHistoryRejectsForeignInstructions() { @@ -265,7 +301,7 @@ struct SessionPropertyTests { dynamicInstructions: SessionPropertyHistoryInstructions() ) - session.properties.history = Array(other.transcript) + session.properties.history = .init(other.transcript) let context = session.resolvedRequestContext() #expect(session.transcript == Transcript(entries: [prompt])) @@ -274,6 +310,28 @@ struct SessionPropertyTests { } } +private struct PropertyToolInstructions: DynamicInstructions { + var body: some DynamicInstructions { + Instructions("Property Tool") + PropertyBoundTool() + } +} + +private struct PropertyBoundTool: Tool { + @LanguageModelSession.SessionProperty(\.revision) private var revision + @LanguageModelSession.SessionProperty(\.history) private var history + + let name = "property" + let description = "Read and update session properties" + var parameters: GenerationSchema { String.generationSchema } + + func call(arguments: String) async throws -> String { + let result = "revision:\(revision) history:\(history.count)" + revision += 1 + return result + } +} + private enum BasePropertyKey: SessionPropertyKey { static let defaultValue = 42 } diff --git a/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift b/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift index 7fa23bc1..e4e46c98 100644 --- a/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift +++ b/Tests/AnyLanguageModelTests/Shared/MockLanguageModel.swift @@ -1,4 +1,4 @@ -@testable import AnyLanguageModel +@_spi(Compatibility) @testable import AnyLanguageModel struct MockLanguageModel: LanguageModel { enum UnavailableReason: Hashable, Sendable { From 25d51f1ff5df796d00d618eaa675ad9bf2596369 Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Mon, 19 Oct 2026 09:08:26 +0800 Subject: [PATCH 7/8] Preserve public request context and expose Tool session properties --- .../LanguageModelSession.swift | 2 -- .../AnyLanguageModel/SessionProperties.swift | 2 ++ .../RequestContextPublicAPITests.swift | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 31e464d9..63711343 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -141,7 +141,6 @@ public final class LanguageModelSession: @unchecked Sendable { /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. - @_spi(Compatibility) public struct RequestContext: Sendable { /// The transcript to send with the request. public let transcript: Transcript @@ -170,7 +169,6 @@ public final class LanguageModelSession: @unchecked Sendable { /// with the Foundation Models framework. /// It's public so that language models outside this module /// can read the inputs for each request. - @_spi(Compatibility) nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { let transcript = transcript diff --git a/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift index ecc300d2..7f897e19 100644 --- a/Sources/AnyLanguageModel/SessionProperties.swift +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -188,6 +188,8 @@ extension DynamicInstructions { } extension Tool { + public typealias SessionProperty = LanguageModelSession.SessionProperty + func bindingSessionProperties(_ properties: SessionPropertyValues, history: Transcript.HistoryView) -> any Tool { SessionPropertyTool(wrapped: self, properties: properties, history: history) } diff --git a/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift new file mode 100644 index 00000000..ee991bff --- /dev/null +++ b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift @@ -0,0 +1,26 @@ +import AnyLanguageModel +import Testing + +@Test func requestContextRemainsPublicWithoutSPI() { + let session = LanguageModelSession( + model: OpenAILanguageModel(apiKey: "unused", model: "test"), + tools: [PublicSessionPropertyTool()], + instructions: "Public request context" + ) + let context: LanguageModelSession.RequestContext = session.resolvedRequestContext() + #expect(context.transcript.count == session.transcript.count) + #expect(context.instructions?.description == "Public request context") + #expect(context.tools.map(\.name) == ["publicSessionProperty"]) + let _: PublicSessionPropertyTool.SessionProperty = .init(\.history) +} + +private struct PublicSessionPropertyTool: Tool { + @SessionProperty(\.history) private var history + + let name = "publicSessionProperty" + let description = "Read session history" + + func call(arguments: String) async throws -> String { + String(history.count) + } +} From db6bc62dc7c31d35cbfcdaf12368e0aa90edc5bc Mon Sep 17 00:00:00 2001 From: "Ronnie W." Date: Wed, 21 Oct 2026 09:13:04 +0800 Subject: [PATCH 8/8] Preserve public Tool identity and bind at execution boundaries --- .../LanguageModelSession.swift | 20 +++++++++++-------- .../Models/AnthropicLanguageModel.swift | 4 ++-- .../Models/GeminiLanguageModel.swift | 4 ++-- .../Models/LlamaLanguageModel.swift | 4 ++-- .../Models/MLXLanguageModel.swift | 4 ++-- .../Models/OllamaLanguageModel.swift | 4 ++-- .../Models/OpenAILanguageModel.swift | 6 +++--- .../Models/OpenResponsesLanguageModel.swift | 4 ++-- .../PrivateCloudComputeLanguageModel.swift | 2 +- .../Models/SystemLanguageModel.swift | 8 ++++---- .../DynamicInstructionsTests.swift | 6 +++--- .../NativeSessionPropertyTests.swift | 8 ++++++-- .../RequestContextPublicAPITests.swift | 18 +++++++++++++++++ .../SessionPropertyTests.swift | 5 +++-- 14 files changed, 62 insertions(+), 35 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 63711343..0dcf9b74 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -151,6 +151,16 @@ public final class LanguageModelSession: @unchecked Sendable { /// The tools that the model can call in response to the request. public let tools: [any Tool] + func toolsForExecution(in session: LanguageModelSession) -> [any Tool] { + let history = Transcript.HistoryView( + transcript.filter { + if case .instructions = $0 { return false } + return true + } + ) + return tools.map { $0.bindingSessionProperties(session.properties, history: history) } + } + fileprivate init(transcript: Transcript, instructions: Instructions?, tools: [any Tool]) { self.transcript = transcript self.instructions = instructions @@ -172,16 +182,10 @@ public final class LanguageModelSession: @unchecked Sendable { nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { let transcript = transcript - let history = Transcript.HistoryView( - transcript.filter { - if case .instructions = $0 { return false } - return true - } - ) return RequestContext( transcript: transcript, instructions: instructions, - tools: tools.map { $0.bindingSessionProperties(properties, history: history) } + tools: tools ) } @@ -211,7 +215,7 @@ public final class LanguageModelSession: @unchecked Sendable { return RequestContext( transcript: requestTranscript, instructions: resolved.instructions, - tools: resolved.tools.map { $0.bindingSessionProperties(properties, history: canonicalHistory) } + tools: resolved.tools ) } diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 3f370420..91677d36 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -489,7 +489,7 @@ public struct AnthropicLanguageModel: LanguageModel { if !toolUses.isEmpty { let resolution = try await resolveToolUses( toolUses, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -698,7 +698,7 @@ public struct AnthropicLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolUses( toolUses, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift index 7e69a923..242d061e 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -293,7 +293,7 @@ public struct GeminiLanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveFunctionCalls( functionCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -475,7 +475,7 @@ public struct GeminiLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveFunctionCalls( functionCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift index d03386d4..d5efeb11 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -932,7 +932,7 @@ import Foundation let resolution = try await resolveToolCalls( parsedCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -1194,7 +1194,7 @@ import Foundation let resolution = try await self.resolveToolCalls( parsedCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift index 51be9b0c..c53f0e82 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1199,7 +1199,7 @@ import Foundation let resolution = try await resolveToolCalls( collectedToolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -1440,7 +1440,7 @@ import Foundation let resolution = try await resolveToolCalls( collectedToolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 36c3ddfb..2701770e 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -160,7 +160,7 @@ public struct OllamaLanguageModel: LanguageModel { if let toolCalls = chatResponse.message.toolCalls, !toolCalls.isEmpty { let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -296,7 +296,7 @@ public struct OllamaLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index b6f17391..f7ed4bd9 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -555,7 +555,7 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -686,7 +686,7 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { @@ -902,7 +902,7 @@ public struct OpenAILanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 807bbb5e..f4e80a2d 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -553,7 +553,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { try toolRounds.record(completed: state.entries) switch try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) { case .stop(let calls): @@ -645,7 +645,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { try toolRounds.record(completed: entries) let resolution = try await resolveToolCalls( toolCalls, - tools: requestContext.tools, + tools: requestContext.toolsForExecution(in: session), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift index d76657c2..b8c6bc3c 100644 --- a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift @@ -130,7 +130,7 @@ // Attach the feedback to the session's conversation, including its latest response. let fmSession = FoundationModels.LanguageModelSession( model: pccModel, - tools: requestContext.tools.toFoundationModels(), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(), transcript: requestContext.transcript.toFoundationModels( instructions: requestContext.instructions, toolDefinitions: requestContext.tools diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 88a5e632..8e5300c4 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -277,7 +277,7 @@ // Attach the feedback to the session's conversation, including its latest response. let fmSession = FoundationModels.LanguageModelSession( model: systemModel, - tools: requestContext.tools.toFoundationModels(in: session), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(in: session), transcript: requestContext.transcript.toFoundationModels( instructions: requestContext.instructions, toolDefinitions: requestContext.tools @@ -319,7 +319,7 @@ let requestContext = session.resolvedRequestContext() return FoundationModels.LanguageModelSession( model: systemModel, - tools: requestContext.tools.toFoundationModels(in: session), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(in: session), transcript: fmTranscriptDroppingDuplicatePrompt( requestContext.transcript, prompt: prompt @@ -388,7 +388,7 @@ if let instructions = requestContext.instructions { instructions.toFoundationModels() } - requestContext.tools.toFoundationModels(in: session) + requestContext.toolsForExecution(in: session).toFoundationModels(in: session) } } @@ -420,7 +420,7 @@ let requestContext = session.resolvedRequestContext() return FoundationModels.LanguageModelSession( model: model, - tools: requestContext.tools.toFoundationModels(in: session), + tools: requestContext.toolsForExecution(in: session).toFoundationModels(in: session), transcript: fmTranscriptDroppingDuplicatePrompt( requestContext.transcript, prompt: prompt diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift index baba7aa1..8943439b 100644 --- a/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift @@ -407,7 +407,7 @@ private struct DynamicContextModel: LanguageModel { var entries: [Transcript.Entry] = [] if continuesAfterTool { - let tool = try #require(first.tools.first) + let tool = try #require(first.toolsForExecution(in: session).first) state.select(.b) let call = Transcript.ToolCall( id: "request-a-call", @@ -475,7 +475,7 @@ private struct FailingAfterToolModel: LanguageModel { return !didFail } if shouldFail { - let tool = try #require(context.tools.first) + let tool = try #require(context.toolsForExecution(in: session).first) _ = try await tool.makeOutputSegments(from: GeneratedContent(properties: [:])) state.select(.b) throw DynamicFixtureError.failed @@ -565,7 +565,7 @@ private struct CancellingAfterToolModel: LanguageModel { return !didSuspend } if shouldSuspend { - let tool = try #require(context.tools.first) + let tool = try #require(context.toolsForExecution(in: session).first) _ = try await tool.makeOutputSegments(from: GeneratedContent(properties: [:])) state.select(.b) control.signalStarted() diff --git a/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift index 595b0714..cc76f281 100644 --- a/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift @@ -15,8 +15,12 @@ import Testing ) first.properties.revision = 1 second.properties.revision = 20 - let firstTool = try #require(first.resolvedRequestContext().tools.toFoundationModels().first) - let secondTool = try #require(second.resolvedRequestContext().tools.toFoundationModels().first) + let firstTool = try #require( + first.resolvedRequestContext().toolsForExecution(in: first).toFoundationModels().first + ) + let secondTool = try #require( + second.resolvedRequestContext().toolsForExecution(in: second).toFoundationModels().first + ) let firstCall = Task.detached { try await callNativeTool(firstTool) } let secondCall = Task.detached { try await callNativeTool(secondTool) } diff --git a/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift index ee991bff..d5b313b2 100644 --- a/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift +++ b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift @@ -11,9 +11,27 @@ import Testing #expect(context.transcript.count == session.transcript.count) #expect(context.instructions?.description == "Public request context") #expect(context.tools.map(\.name) == ["publicSessionProperty"]) + #expect(context.tools.first is PublicSessionPropertyTool) let _: PublicSessionPropertyTool.SessionProperty = .init(\.history) } +@Test func requestContextRetainsConcreteToolIdentity() { + let original = PublicReferenceTool() + let session = LanguageModelSession( + model: OpenAILanguageModel(apiKey: "unused", model: "test"), + tools: [original] + ) + let context = session.resolvedRequestContext() + #expect(context.tools.first as? PublicReferenceTool === original) +} + +private final class PublicReferenceTool: Tool { + let name = "reference" + let description = "Preserve the supplied Tool instance" + + func call(arguments: String) async throws -> String { arguments } +} + private struct PublicSessionPropertyTool: Tool { @SessionProperty(\.history) private var history diff --git a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift index df99a875..6ac6d154 100644 --- a/Tests/AnyLanguageModelTests/SessionPropertyTests.swift +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -19,7 +19,7 @@ enum SessionPropertyMode { @Suite("Session properties") struct SessionPropertyTests { @Test(arguments: [false, true]) - func contextToolsCarryPropertiesIntoDetachedExecution(dynamic: Bool) async throws { + func executionToolsCarryPropertiesIntoDetachedExecution(dynamic: Bool) async throws { let session = dynamic ? LanguageModelSession( @@ -35,7 +35,8 @@ struct SessionPropertyTests { session.properties.history = [earlier] session.properties.revision = 7 let context = session.resolvedRequestContext() - let tool = try #require(context.tools.first) + #expect(context.tools.first is PropertyBoundTool) + let tool = try #require(context.toolsForExecution(in: session).first) let output = try await Task.detached { #expect(SessionPropertyBinding.values == nil) return try await tool.makeOutputSegments(from: GeneratedContent("read"))