From b12560df84cf357972d83a5682f6b8d80149335e Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 3 Oct 2026 08:45:54 -0700 Subject: [PATCH 1/2] Add the DynamicInstructions builder --- .../DynamicInstructions.swift | 353 ++++++++++++++++++ .../DynamicInstructionsBuilderTests.swift | 96 +++++ 2 files changed, 449 insertions(+) create mode 100644 Sources/AnyLanguageModel/DynamicInstructions.swift create mode 100644 Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift diff --git a/Sources/AnyLanguageModel/DynamicInstructions.swift b/Sources/AnyLanguageModel/DynamicInstructions.swift new file mode 100644 index 00000000..f80a251e --- /dev/null +++ b/Sources/AnyLanguageModel/DynamicInstructions.swift @@ -0,0 +1,353 @@ +/// A declarative collection of instructions and tools that a session resolves +/// immediately before each request to a language model. +/// +/// Compose values in ``body`` with ``DynamicInstructionsBuilder``. The session +/// evaluates the body again for every model request, including requests that +/// continue a response after tool execution. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `DynamicInstructions` API, +/// so code that uses it ports to Foundation Models on OS 27. +@_typeEraser(AnyDynamicInstructions) +public protocol DynamicInstructions { + associatedtype Body: DynamicInstructions + + @DynamicInstructionsBuilder + var body: Body { get } +} + +/// Builds declarative dynamic instructions from instructions, tools, nested +/// dynamic instructions, and conditional content. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `DynamicInstructionsBuilder` API, +/// so code that uses it ports to Foundation Models on OS 27. +@resultBuilder +public struct DynamicInstructionsBuilder { + public static func buildExpression(_ expression: T) -> some DynamicInstructions where T: Tool { + DynamicTool(expression) + } + + public static func buildExpression(_ expression: T) -> T where T: DynamicInstructions { + expression + } + + public static func buildExpression(_ tools: [any Tool]) -> some DynamicInstructions { + DynamicInstructionsForEach(tools, id: \.name) { tool in + AnyDynamicInstructions(DynamicTool(tool)) + } + } + + @_disfavoredOverload + public static func buildBlock( + _ contents: repeat each Content + ) -> TupleDynamicInstructions + where repeat each Content: DynamicInstructions { + TupleDynamicInstructions(repeat each contents) + } + + public static func buildBlock(_ content: T) -> T where T: DynamicInstructions { + content + } + + public static func buildBlock() -> EmptyDynamicInstructions { + EmptyDynamicInstructions() + } + + public static func buildEither( + first content: TrueContent + ) -> ConditionalDynamicInstructions + where TrueContent: DynamicInstructions, FalseContent: DynamicInstructions { + ConditionalDynamicInstructions(.trueContent(content)) + } + + public static func buildEither( + second content: FalseContent + ) -> ConditionalDynamicInstructions + where TrueContent: DynamicInstructions, FalseContent: DynamicInstructions { + ConditionalDynamicInstructions(.falseContent(content)) + } + + public static func buildOptional(_ content: Content?) -> Content? + where Content: DynamicInstructions { + content + } + + public static func buildLimitedAvailability( + _ content: some DynamicInstructions + ) -> AnyDynamicInstructions { + AnyDynamicInstructions(content) + } +} + +/// A type-erased dynamic-instructions value. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `AnyDynamicInstructions` API, +/// so code that uses it ports to Foundation Models on OS 27. +public struct AnyDynamicInstructions: DynamicInstructions { + public typealias Body = Never + + fileprivate let resolveValue: () -> ResolvedDynamicInstructions + + public init(_ dynamicInstructions: any DynamicInstructions) { + resolveValue = { resolveDynamicInstructions(dynamicInstructions) } + } + + public init(erasing dynamicInstructions: some DynamicInstructions) { + self.init(dynamicInstructions) + } + + public var body: Never { + fatalError("AnyDynamicInstructions has no body") + } + + func resolveForRequest() -> ResolvedDynamicInstructions { + resolveValue() + } +} + +/// A dynamic-instructions value that contains an ordered tuple of components. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `TupleDynamicInstructions` API, +/// so code that uses it ports to Foundation Models on OS 27. +public struct TupleDynamicInstructions: DynamicInstructions +where repeat each Content: DynamicInstructions { + public typealias Body = Never + + fileprivate let contents: (repeat each Content) + + public init(_ contents: repeat each Content) { + self.contents = (repeat each contents) + } + + public var body: Never { + fatalError("TupleDynamicInstructions has no body") + } +} + +/// A dynamic-instructions value that contains one of two branches. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `ConditionalDynamicInstructions` API, +/// so code that uses it ports to Foundation Models on OS 27. +public struct ConditionalDynamicInstructions: DynamicInstructions +where TrueContent: DynamicInstructions, FalseContent: DynamicInstructions { + public enum Branch { + case trueContent(TrueContent) + case falseContent(FalseContent) + } + + public typealias Body = Never + + fileprivate let branch: Branch + + public init(_ branch: Branch) { + self.branch = branch + } + + public var body: Never { + fatalError("ConditionalDynamicInstructions has no body") + } +} + +extension Optional: DynamicInstructions where Wrapped: DynamicInstructions { + public typealias Body = Never + + public var body: Never { + fatalError("Optional dynamic instructions have no body") + } +} + +extension Never: DynamicInstructions { + public typealias Body = Never + + public var body: Never { self } +} + +/// An empty dynamic-instructions value. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `EmptyDynamicInstructions` API, +/// so code that uses it ports to Foundation Models on OS 27. +public struct EmptyDynamicInstructions: DynamicInstructions, Sendable { + public typealias Body = Never + + public init() {} + + public var body: Never { + fatalError("EmptyDynamicInstructions has no body") + } +} + +/// Builds dynamic instructions from a collection. +/// +/// - Note: This API is exclusive to AnyLanguageModel on OS 26. +/// It follows the Foundation Models 27 `DynamicInstructionsForEach` API, +/// so code that uses it ports to Foundation Models on OS 27. +public struct DynamicInstructionsForEach: DynamicInstructions +where Data: RandomAccessCollection, ID: Hashable, Content: DynamicInstructions { + public typealias Body = Never + + fileprivate let data: Data + fileprivate let id: KeyPath + fileprivate let content: (Data.Element) -> Content + + public init( + _ data: Data, + id: KeyPath, + @DynamicInstructionsBuilder content: @escaping (Data.Element) -> Content + ) { + self.data = data + self.id = id + self.content = content + } + + public var body: Never { + fatalError("DynamicInstructionsForEach has no body") + } +} + +extension DynamicInstructionsForEach where ID == Data.Element.ID, Data.Element: Identifiable { + public init( + _ data: Data, + @DynamicInstructionsBuilder content: @escaping (Data.Element) -> Content + ) { + self.init(data, id: \.id, content: content) + } +} + +extension DynamicInstructions { + public typealias ForEach = DynamicInstructionsForEach +} + +extension Instructions: DynamicInstructions { + public var body: some DynamicInstructions { + EmptyDynamicInstructions() + } +} + +struct ResolvedDynamicInstructions: Sendable { + let instructions: Instructions? + let tools: [any Tool] + + fileprivate init(instructions: Instructions?, tools: [any Tool]) { + self.instructions = instructions + self.tools = tools + } + + fileprivate static let empty = Self(instructions: nil, tools: []) + + fileprivate func appending(_ other: Self) -> Self { + let combinedInstructions: Instructions? + switch (instructions, other.instructions) { + case (nil, nil): + combinedInstructions = nil + case (let instructions?, nil), (nil, let instructions?): + combinedInstructions = instructions + case (let first?, let second?): + combinedInstructions = Instructions { + first + second + } + } + return Self( + instructions: combinedInstructions, + tools: tools + other.tools + ) + } +} + +private protocol PrimitiveDynamicInstructions { + func resolve() -> ResolvedDynamicInstructions +} + +private struct DynamicTool: DynamicInstructions, PrimitiveDynamicInstructions { + typealias Body = Never + + let tool: any Tool + + init(_ tool: any Tool) { + self.tool = tool + } + + var body: Never { + fatalError("DynamicTool has no body") + } + + func resolve() -> ResolvedDynamicInstructions { + ResolvedDynamicInstructions(instructions: nil, tools: [tool]) + } +} + +extension AnyDynamicInstructions: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + resolveValue() + } +} + +extension TupleDynamicInstructions: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + var result = ResolvedDynamicInstructions.empty + repeat result = result.appending(resolveDynamicInstructions(each contents)) + return result + } +} + +extension ConditionalDynamicInstructions: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + switch branch { + case .trueContent(let content): + resolveDynamicInstructions(content) + case .falseContent(let content): + resolveDynamicInstructions(content) + } + } +} + +extension Optional: PrimitiveDynamicInstructions where Wrapped: DynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + map(resolveDynamicInstructions) ?? .empty + } +} + +extension Never: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + switch self {} + } +} + +extension EmptyDynamicInstructions: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + .empty + } +} + +extension DynamicInstructionsForEach: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + data.reduce(into: .empty) { result, element in + result = result.appending(resolveDynamicInstructions(content(element))) + } + } +} + +extension Instructions: PrimitiveDynamicInstructions { + fileprivate func resolve() -> ResolvedDynamicInstructions { + ResolvedDynamicInstructions(instructions: self, tools: []) + } +} + +private func resolveDynamicInstructions( + _ dynamicInstructions: any DynamicInstructions +) -> ResolvedDynamicInstructions { + func resolve(_ content: Content) -> ResolvedDynamicInstructions + where Content: DynamicInstructions { + if let primitive = content as? any PrimitiveDynamicInstructions { + return primitive.resolve() + } + return resolve(content.body) + } + + return resolve(dynamicInstructions) +} diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift new file mode 100644 index 00000000..e46cf57f --- /dev/null +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift @@ -0,0 +1,96 @@ +import Testing + +@testable import AnyLanguageModel + +@Suite("Dynamic instructions builder") +struct DynamicInstructionsBuilderTests { + @Test func builderComposesNestedConditionalEmptyAndToolArrayContent() { + let resolved = AnyDynamicInstructions(erasing: Composition(enabled: true)).resolveForRequest() + + #expect(resolved.instructions?.description == "Outer\nNested\nFor each") + #expect(resolved.tools.map(\.name) == ["tool-a", "tool-b"]) + } + + @Test func falseConditionLeavesOutItsContent() { + let resolved = AnyDynamicInstructions(erasing: Composition(enabled: false)).resolveForRequest() + + #expect(resolved.instructions?.description == "Outer\nFor each") + #expect(resolved.tools.isEmpty) + } + + @Test func bodyIsEvaluatedOnEachResolution() { + let counter = Counter() + let dynamic = AnyDynamicInstructions(erasing: CountingInstructions(counter: counter)) + + #expect(dynamic.resolveForRequest().instructions?.description == "Request 1") + #expect(dynamic.resolveForRequest().instructions?.description == "Request 2") + } + + @Test func emptyBuilderResolvesToNothing() { + let resolved = AnyDynamicInstructions(erasing: EmptyDynamicInstructions()).resolveForRequest() + + #expect(resolved.instructions == nil) + #expect(resolved.tools.isEmpty) + } +} + +private struct Composition: DynamicInstructions { + let enabled: Bool + + var body: some DynamicInstructions { + Instructions("Outer") + if enabled { + Nested() + } + EmptyDynamicInstructions() + ForEach([Item(id: 1, text: "For each")]) { item in + Instructions(item.text) + } + } +} + +private struct Item: Identifiable { + let id: Int + let text: String +} + +private struct Nested: DynamicInstructions { + var body: some DynamicInstructions { + Instructions("Nested") + [NamedTool(name: "tool-a"), NamedTool(name: "tool-b")] as [any Tool] + } +} + +private struct NamedTool: Tool { + let name: String + let description = "A tool that returns its name" + + typealias Arguments = GeneratedContent + + var parameters: GenerationSchema { + GeneratedContent.generationSchema + } + + func call(arguments: GeneratedContent) async throws -> String { + name + } +} + +private final class Counter: @unchecked Sendable { + private let count = Locked(0) + + func next() -> Int { + count.withLock { value in + value += 1 + return value + } + } +} + +private struct CountingInstructions: DynamicInstructions { + let counter: Counter + + var body: some DynamicInstructions { + Instructions("Request \(counter.next())") + } +} From 0da84afa08ed710997fbf28580c10461cfc9a130 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 3 Oct 2026 08:58:22 -0700 Subject: [PATCH 2/2] Collect dynamic instructions without trimming or repeated copying --- .../DynamicInstructions.swift | 55 +++++++++---------- .../DynamicInstructionsBuilderTests.swift | 30 ++++++++++ 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicInstructions.swift b/Sources/AnyLanguageModel/DynamicInstructions.swift index f80a251e..1f4fe4bf 100644 --- a/Sources/AnyLanguageModel/DynamicInstructions.swift +++ b/Sources/AnyLanguageModel/DynamicInstructions.swift @@ -229,33 +229,30 @@ extension Instructions: DynamicInstructions { } struct ResolvedDynamicInstructions: Sendable { - let instructions: Instructions? - let tools: [any Tool] + /// The text of each instructions component, in order. + private var instructionTexts: [String] - fileprivate init(instructions: Instructions?, tools: [any Tool]) { - self.instructions = instructions + /// The tools, in order. + private(set) var tools: [any Tool] + + fileprivate init(instructionTexts: [String], tools: [any Tool]) { + self.instructionTexts = instructionTexts self.tools = tools } - fileprivate static let empty = Self(instructions: nil, tools: []) - - fileprivate func appending(_ other: Self) -> Self { - let combinedInstructions: Instructions? - switch (instructions, other.instructions) { - case (nil, nil): - combinedInstructions = nil - case (let instructions?, nil), (nil, let instructions?): - combinedInstructions = instructions - case (let first?, let second?): - combinedInstructions = Instructions { - first - second - } - } - return Self( - instructions: combinedInstructions, - tools: tools + other.tools - ) + fileprivate static let empty = Self(instructionTexts: [], tools: []) + + /// The instructions components joined by newlines, or `nil` if there are none. + /// + /// Each component keeps its whitespace, + /// so the result doesn't depend on how the components are nested. + var instructions: Instructions? { + instructionTexts.isEmpty ? nil : Instructions(instructionTexts.joined(separator: "\n")) + } + + fileprivate mutating func append(_ other: Self) { + instructionTexts += other.instructionTexts + tools += other.tools } } @@ -277,7 +274,7 @@ private struct DynamicTool: DynamicInstructions, PrimitiveDynamicInstructions { } func resolve() -> ResolvedDynamicInstructions { - ResolvedDynamicInstructions(instructions: nil, tools: [tool]) + ResolvedDynamicInstructions(instructionTexts: [], tools: [tool]) } } @@ -290,7 +287,7 @@ extension AnyDynamicInstructions: PrimitiveDynamicInstructions { extension TupleDynamicInstructions: PrimitiveDynamicInstructions { fileprivate func resolve() -> ResolvedDynamicInstructions { var result = ResolvedDynamicInstructions.empty - repeat result = result.appending(resolveDynamicInstructions(each contents)) + repeat result.append(resolveDynamicInstructions(each contents)) return result } } @@ -326,15 +323,17 @@ extension EmptyDynamicInstructions: PrimitiveDynamicInstructions { extension DynamicInstructionsForEach: PrimitiveDynamicInstructions { fileprivate func resolve() -> ResolvedDynamicInstructions { - data.reduce(into: .empty) { result, element in - result = result.appending(resolveDynamicInstructions(content(element))) + var result = ResolvedDynamicInstructions.empty + for element in data { + result.append(resolveDynamicInstructions(content(element))) } + return result } } extension Instructions: PrimitiveDynamicInstructions { fileprivate func resolve() -> ResolvedDynamicInstructions { - ResolvedDynamicInstructions(instructions: self, tools: []) + ResolvedDynamicInstructions(instructionTexts: [description], tools: []) } } diff --git a/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift b/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift index e46cf57f..f9f654b7 100644 --- a/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicInstructionsBuilderTests.swift @@ -26,6 +26,14 @@ struct DynamicInstructionsBuilderTests { #expect(dynamic.resolveForRequest().instructions?.description == "Request 2") } + @Test func nestingDoesNotChangeWhitespace() { + let flat = AnyDynamicInstructions(erasing: Flat()).resolveForRequest() + let nested = AnyDynamicInstructions(erasing: Outer()).resolveForRequest() + + #expect(flat.instructions?.description == "A\nB \nC") + #expect(nested.instructions?.description == flat.instructions?.description) + } + @Test func emptyBuilderResolvesToNothing() { let resolved = AnyDynamicInstructions(erasing: EmptyDynamicInstructions()).resolveForRequest() @@ -49,6 +57,28 @@ private struct Composition: DynamicInstructions { } } +private struct Flat: DynamicInstructions { + var body: some DynamicInstructions { + Instructions("A") + Instructions("B ") + Instructions("C") + } +} + +private struct Outer: DynamicInstructions { + var body: some DynamicInstructions { + Instructions("A") + Inner() + } +} + +private struct Inner: DynamicInstructions { + var body: some DynamicInstructions { + Instructions("B ") + Instructions("C") + } +} + private struct Item: Identifiable { let id: Int let text: String