diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 4b57d92d..80303eee 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -112,6 +112,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 @@ -150,6 +153,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, offeredTools: [any Tool]? = nil) -> [any Tool] { + let history = Transcript.HistoryView( + transcript.filter { + if case .instructions = $0 { return false } + return true + } + ) + return (offeredTools ?? tools).map { $0.bindingSessionProperties(session.properties, history: history) } + } + fileprivate init(transcript: Transcript, instructions: Instructions?, tools: [any Tool]) { self.transcript = transcript self.instructions = instructions @@ -159,7 +172,9 @@ 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 @@ -168,16 +183,26 @@ public final class LanguageModelSession: @unchecked Sendable { /// can read the inputs for each request. nonisolated public func resolvedRequestContext() -> RequestContext { guard let dynamicInstructions else { - return RequestContext(transcript: transcript, instructions: instructions, tools: tools) + let transcript = transcript + return RequestContext( + transcript: transcript, + instructions: instructions, + tools: tools + ) } // Evaluate the body for this request. // The resolved instructions go into the request's transcript, // never into the session's. + let canonicalHistory = Transcript.HistoryView(transcript) let resolved = dynamicInstructionsLock.withLock { - dynamicInstructions.resolveForRequest() + SessionPropertyBinding.$values.withValue(properties) { + SessionPropertyBinding.$history.withValue(canonicalHistory) { + dynamicInstructions.resolveForRequest() + } + } } - var requestTranscript = transcript + var requestTranscript = Transcript(entries: Array(canonicalHistory)) if let instructions = resolved.instructions { let instructionsEntry = Transcript.Entry.instructions( Transcript.Instructions( @@ -189,7 +214,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 + ) } /// Creates a session with a model, tools, @@ -324,7 +353,52 @@ public final class LanguageModelSession: @unchecked Sendable { } } - self.state = .init(.init(finalTranscript)) + 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 } + 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 + Transcript.HistoryView( + locked.transcript.filter { entry in + if case .instructions = entry { return false } + return true + } + ) + } + }, + historySetter: { history in + 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? { @@ -373,10 +447,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 }) } @@ -485,17 +567,19 @@ public final class LanguageModelSession: @unchecked Sendable { providerMetadata: lastSnapshot.providerMetadata ) ) - session.withMutation(keyPath: \.transcript) { - session.state.withLock { - $0.transcript.append(contentsOf: lastSnapshot.transcriptEntries) - $0.transcript.append(responseEntry) + session.withTranscriptMutation { + 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() continuation.finish() } catch { - session.withMutation(keyPath: \.transcript) { + 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 { @@ -751,7 +835,7 @@ public final class LanguageModelSession: @unchecked Sendable { responseFormat: responseFormat ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -777,8 +861,9 @@ public final class LanguageModelSession: @unchecked Sendable { ) // Add tool entries and response to transcript - withMutation(keyPath: \.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) } @@ -810,7 +895,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) } } @@ -970,7 +1055,7 @@ extension LanguageModelSession { responseFormat: .init(schema: schema) ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1173,7 +1258,7 @@ extension LanguageModelSession { responseFormat: type == String.self ? nil : .init(type: type) ) ) - withMutation(keyPath: \.transcript) { + withTranscriptMutation { state.withLock { $0.transcript.append(promptEntry) } } @@ -1210,8 +1295,9 @@ extension LanguageModelSession { ) // Add tool entries and response to transcript - withMutation(keyPath: \.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) } @@ -1305,7 +1391,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/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 6d2e1de1..54c369f8 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -527,7 +527,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 { @@ -742,7 +742,7 @@ public struct AnthropicLanguageModel: LanguageModel { try toolRounds.record(completed: completedEntries) 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 39c54903..2e037ccd 100644 --- a/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/GeminiLanguageModel.swift @@ -315,7 +315,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 { @@ -501,7 +501,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 f0a1eb0e..24bee1b1 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -934,7 +934,7 @@ import Foundation let resolution = try await resolveToolCalls( parsedCalls, - tools: tools, + tools: requestContext.toolsForExecution(in: session, offeredTools: tools), session: session ) switch resolution { @@ -1198,7 +1198,7 @@ import Foundation let resolution = try await self.resolveToolCalls( parsedCalls, - tools: tools, + tools: requestContext.toolsForExecution(in: session, offeredTools: tools), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift index ef8d5008..d10712f3 100644 --- a/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/MLXLanguageModel.swift @@ -1201,7 +1201,7 @@ import Foundation let resolution = try await resolveToolCalls( collectedToolCalls, - tools: tools, + tools: requestContext.toolsForExecution(in: session, offeredTools: tools), session: session ) switch resolution { @@ -1444,7 +1444,7 @@ import Foundation let resolution = try await resolveToolCalls( collectedToolCalls, - tools: tools, + tools: requestContext.toolsForExecution(in: session, offeredTools: tools), session: session ) switch resolution { diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 48fb0523..de50abaa 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -187,7 +187,7 @@ public struct OllamaLanguageModel: LanguageModel { if let toolCalls = chatResponse.message.toolCalls, !toolCalls.isEmpty { let resolution = try await resolveToolCalls( toolCalls, - tools: tools, + tools: requestContext.toolsForExecution(in: session, offeredTools: tools), session: session ) switch resolution { @@ -338,7 +338,7 @@ public struct OllamaLanguageModel: LanguageModel { } switch try await resolveToolCalls( toolCalls, - tools: offeredTools, + tools: requestContext.toolsForExecution(in: session, offeredTools: offeredTools), session: session ) { case .stop(let calls): diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index 5b0196f3..80940a41 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -576,7 +576,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 { @@ -707,7 +707,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 { @@ -944,7 +944,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 71b5859e..4ce77fc3 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -595,7 +595,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): @@ -685,7 +685,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 634bbcef..89e51c96 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/Sources/AnyLanguageModel/SessionProperties.swift b/Sources/AnyLanguageModel/SessionProperties.swift new file mode 100644 index 00000000..7f897e19 --- /dev/null +++ b/Sources/AnyLanguageModel/SessionProperties.swift @@ -0,0 +1,218 @@ +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 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. +/// +/// - 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 shared by dynamic instructions and Tools. +/// +/// - 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 sessionPropertyRegistrar = ObservationRegistrar() + @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 { + _accessSessionProperty(keyPath: \.history) + if SessionPropertyBinding.values === self, let history = SessionPropertyBinding.history { + return history + } + return historyGetter() + } + set { + _withSessionPropertyMutation(keyPath: \.history) { + precondition( + SessionPropertyBinding.values !== self || SessionPropertyBinding.history == nil, + "Session history is read-only while dynamic instructions or a Tool is active" + ) + historySetter(newValue) + } + } + } + + public subscript(key: Key.Type) -> Key.Value where Key: SessionPropertyKey { + get { + 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 + values[ObjectIdentifier(key)] = newValue + } + } + } + + @_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 { + @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 or a Tool is active" + ) + } + return values[keyPath: keyPath] + } + nonmutating set { + guard let values = SessionPropertyBinding.values else { + preconditionFailure( + "SessionProperty can only be accessed while dynamic instructions or a Tool is active" + ) + } + values[keyPath: keyPath] = newValue + } + } + } +} + +extension LanguageModelSession.SessionProperty: @unchecked Sendable where Value: Sendable {} + +extension DynamicInstructions { + public typealias SessionProperty = LanguageModelSession.SessionProperty +} + +extension Tool { + public typealias SessionProperty = LanguageModelSession.SessionProperty + + 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/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..e6625660 --- /dev/null +++ b/Sources/AnyLanguageModelMacros/SessionPropertyEntryMacro.swift @@ -0,0 +1,74 @@ +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._accessSessionProperty(keyPath: \\.\(raw: name)) + return self[__Key_\(raw: name).self] + } + """, + """ + set { + self._withSessionPropertyMutation(keyPath: \\.\(raw: name)) { + 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 [] } + + 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? { + 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 0d7dc600..4cfae32b 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityAnyLanguageModelTests.swift @@ -10,12 +10,35 @@ 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 + } + @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 +55,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 79161f72..655d56c2 100644 --- a/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift +++ b/Tests/AnyLanguageModelTests/APICompatibilityFoundationModelsTests.swift @@ -12,12 +12,36 @@ 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 + } + @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 +65,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/DynamicInstructionsTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsTests.swift index af43ad37..8943439b 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 { @@ -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/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..cc76f281 --- /dev/null +++ b/Tests/AnyLanguageModelTests/NativeSessionPropertyTests.swift @@ -0,0 +1,54 @@ +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().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) } + 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/RequestContextPublicAPITests.swift b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift new file mode 100644 index 00000000..d5b313b2 --- /dev/null +++ b/Tests/AnyLanguageModelTests/RequestContextPublicAPITests.swift @@ -0,0 +1,44 @@ +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"]) + #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 + + let name = "publicSessionProperty" + let description = "Read session history" + + func call(arguments: String) async throws -> String { + String(history.count) + } +} 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 new file mode 100644 index 00000000..6ac6d154 --- /dev/null +++ b/Tests/AnyLanguageModelTests/SessionPropertyTests.swift @@ -0,0 +1,496 @@ +import Foundation +import Observation +import Testing + +@_spi(Compatibility) @testable import AnyLanguageModel + +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") +struct SessionPropertyTests { + @Test(arguments: [false, true]) + func executionToolsCarryPropertiesIntoDetachedExecution(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() + #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")) + }.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) { + #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(Array(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(Array(session.properties.history) == [replacement]) + #expect(!session.isResponding) + } + + @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(Array(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(Array(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 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()) + 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(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") + return + } + #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(Array(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 = .init(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 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 +} + +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 + + var body: some DynamicInstructions { + let count = other.properties.history.count + other.properties.history = [replacement] + return Instructions("Other history count \(count)") + } +} + +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 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 + + 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") + } +} + +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") + } +} 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 {