From e44a897227c299fca31d4a071c02a08e884c29fe Mon Sep 17 00:00:00 2001 From: mattt Date: Thu, 22 Oct 2026 06:59:40 -0700 Subject: [PATCH 1/3] Keep the later bound of contradicting number guides Guides such as .minimum(10) with .maximum(5) stopped with a precondition since number bounds became typed. Build the schema again, with the later guide's bound replacing the earlier one it contradicts. --- .../AnyLanguageModel/GenerationSchema.swift | 24 ++++------- Sources/AnyLanguageModel/Shared/Bounds.swift | 12 ++++++ Tests/AnyLanguageModelTests/BoundsTests.swift | 42 +++++++++---------- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 37205b55..fba3656d 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -963,26 +963,20 @@ extension GenerationSchema { } return (.string(node), [:]) } else if type == Int.self { - var minimum: Int? - var maximum: Int? + // A later guide's bound replaces an earlier one of the same kind, + // and an earlier one of the other kind that it contradicts, + // so guides that no value satisfies together still make a schema. + var bounds = Bounds.unbounded for guide in guides { - if case .integer(let min)? = guide.minimum { minimum = min } - if case .integer(let max)? = guide.maximum { maximum = max } - } - // Guides that no value satisfies are a mistake in the code, as an inverted ClosedRange is. - guard let bounds = Bounds(minimum: minimum, maximum: maximum) else { - preconditionFailure("A guide's minimum \(minimum!) is above its maximum \(maximum!).") + if case .integer(let min)? = guide.minimum { bounds = bounds.replacing(minimum: min) } + if case .integer(let max)? = guide.maximum { bounds = bounds.replacing(maximum: max) } } return (.number(.integer(description: description, bounds: bounds)), [:]) } else if type == Float.self || type == Double.self || type == Decimal.self { - var minimum: Double? - var maximum: Double? + var bounds = Bounds.unbounded for guide in guides { - if case .floatingPoint(let min)? = guide.minimum { minimum = min } - if case .floatingPoint(let max)? = guide.maximum { maximum = max } - } - guard let bounds = Bounds(minimum: minimum, maximum: maximum) else { - preconditionFailure("A guide's minimum \(minimum!) is above its maximum \(maximum!).") + if case .floatingPoint(let min)? = guide.minimum { bounds = bounds.replacing(minimum: min) } + if case .floatingPoint(let max)? = guide.maximum { bounds = bounds.replacing(maximum: max) } } return (.number(.floatingPoint(description: description, bounds: bounds)), [:]) } else { diff --git a/Sources/AnyLanguageModel/Shared/Bounds.swift b/Sources/AnyLanguageModel/Shared/Bounds.swift index ad7bb80f..1cf72b7b 100644 --- a/Sources/AnyLanguageModel/Shared/Bounds.swift +++ b/Sources/AnyLanguageModel/Shared/Bounds.swift @@ -20,6 +20,18 @@ enum Bounds: Sendable, Equatable { } } + /// These bounds with `minimum` as their minimum, + /// dropping a maximum below it. + func replacing(minimum: Value) -> Bounds { + Bounds(minimum: minimum, maximum: maximum.flatMap { $0 >= minimum ? $0 : nil })! + } + + /// These bounds with `maximum` as their maximum, + /// dropping a minimum above it. + func replacing(maximum: Value) -> Bounds { + Bounds(minimum: minimum.flatMap { $0 <= maximum ? $0 : nil }, maximum: maximum)! + } + var minimum: Value? { switch self { case .from(let minimum): minimum diff --git a/Tests/AnyLanguageModelTests/BoundsTests.swift b/Tests/AnyLanguageModelTests/BoundsTests.swift index e17e2440..c0aefb7b 100644 --- a/Tests/AnyLanguageModelTests/BoundsTests.swift +++ b/Tests/AnyLanguageModelTests/BoundsTests.swift @@ -32,29 +32,25 @@ struct BoundsTests { #expect(guide.maximum == .integer(Int.max)) } - #if os(macOS) || os(Linux) - /// Guides that no value satisfies are a mistake in the code, as an inverted `ClosedRange` is. - /// Given apart, they reach the schema's own check, which says so. - @Test func contradictingIntegerGuidesStop() async throws { - let result = try await #require(processExitsWith: .failure, observing: [\.standardErrorContent]) { - _ = GenerationSchema.Property(name: "count", type: Int.self, guides: [.minimum(10), .maximum(5)]) - } - #expect( - String(decoding: result.standardErrorContent, as: UTF8.self).contains( - "minimum 10 is above its maximum 5" - ) - ) - } + @Test func replacingABoundDropsTheOneItContradicts() { + #expect(Bounds.closed(1 ... 9).replacing(minimum: 5) == .closed(5 ... 9)) + #expect(Bounds.closed(1 ... 9).replacing(minimum: 10) == .from(10)) + #expect(Bounds.closed(1 ... 9).replacing(maximum: 5) == .closed(1 ... 5)) + #expect(Bounds.closed(1 ... 9).replacing(maximum: 0) == .through(0)) + #expect(Bounds.unbounded.replacing(minimum: 3) == .from(3)) + } - @Test func contradictingFloatingPointGuidesStop() async throws { - let result = try await #require(processExitsWith: .failure, observing: [\.standardErrorContent]) { - _ = GenerationSchema.Property(name: "share", type: Double.self, guides: [.minimum(1), .maximum(0.5)]) - } - #expect( - String(decoding: result.standardErrorContent, as: UTF8.self).contains( - "minimum 1.0 is above its maximum 0.5" - ) - ) + /// Guides that no value satisfies together still make a schema: + /// the later guide's bound replaces the earlier one it contradicts. + @Test func contradictingGuidesKeepTheLaterBound() throws { + func bounds(of property: GenerationSchema.Property) -> GenerationSchema.NumberNode? { + if case .number(let node) = property.node { node } else { nil } } - #endif + let count = GenerationSchema.Property(name: "count", type: Int.self, guides: [.minimum(10), .maximum(5)]) + #expect(bounds(of: count) == .integer(description: nil, bounds: .through(5))) + let level = GenerationSchema.Property(name: "level", type: Int.self, guides: [.maximum(5), .minimum(10)]) + #expect(bounds(of: level) == .integer(description: nil, bounds: .from(10))) + let share = GenerationSchema.Property(name: "share", type: Double.self, guides: [.minimum(1), .maximum(0.5)]) + #expect(bounds(of: share) == .floatingPoint(description: nil, bounds: .through(0.5))) + } } From 62d420707caa117f064daa0ea8d76261706a5439 Mon Sep 17 00:00:00 2001 From: mattt Date: Thu, 22 Oct 2026 07:00:57 -0700 Subject: [PATCH 2/3] Leave the reasoning of the round past the limit out of ToolRoundLimitExceeded Ollama and Anthropic added that round's reasoning to the entries before recording the round, so the error's transcript entries could end with it. Record the round with only the rounds that ran, as the other providers do. --- .../Models/AnthropicLanguageModel.swift | 4 ++- .../Models/OllamaLanguageModel.swift | 13 +++++--- .../AnthropicReasoningTests.swift | 24 ++++++++++++++ .../OllamaReasoningReplayTests.swift | 31 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift index 7052572e..6d2e1de1 100644 --- a/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift @@ -634,6 +634,8 @@ public struct AnthropicLanguageModel: LanguageModel { var toolRounds = ToolRoundLimit(provider: "Anthropic", options: options) while true { try Task.checkCancellation() + // The entries of the rounds that ran, before this round adds its reasoning. + let completedEntries = state.entries let requestContext = session.resolvedRequestContext() let anthropicTools: [AnthropicTool] = try requestContext.tools.map { try convertToolToAnthropicFormat($0) @@ -737,7 +739,7 @@ public struct AnthropicLanguageModel: LanguageModel { } guard !toolUses.isEmpty else { break } try Task.checkCancellation() - try toolRounds.record(completed: state.entries) + try toolRounds.record(completed: completedEntries) switch try await resolveToolUses( toolUses, tools: requestContext.tools, diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index 5fce0694..48fb0523 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -322,17 +322,20 @@ public struct OllamaLanguageModel: LanguageModel { } if chunk.done { break } } - // The round's reasoning, kept in the transcript before what it led to. - if !thinking.isEmpty { - state.entries.append(.reasoning(.ollama(thinking))) - } guard !toolCalls.isEmpty else { // The final round's reasoning reaches the last snapshot. - if !thinking.isEmpty, let snapshot = state.snapshot() { continuation.yield(snapshot) } + if !thinking.isEmpty { + state.entries.append(.reasoning(.ollama(thinking))) + if let snapshot = state.snapshot() { continuation.yield(snapshot) } + } break } try Task.checkCancellation() try toolRounds.record(completed: state.entries) + // The round's reasoning, kept in the transcript before what it led to. + if !thinking.isEmpty { + state.entries.append(.reasoning(.ollama(thinking))) + } switch try await resolveToolCalls( toolCalls, tools: offeredTools, diff --git a/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift index 8506ba9e..831d6a19 100644 --- a/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift +++ b/Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift @@ -87,6 +87,30 @@ import Testing } } + /// The round past the limit didn't run, so its reasoning isn't carried with the rounds that did. + @Test func toolRoundLimitOmitsTheReasoningOfTheRoundPastIt() async throws { + ReasoningURLProtocol.reset() + ReasoningURLProtocol.enqueue(eventStream: events(tool: true)) + ReasoningURLProtocol.enqueue(eventStream: events(tool: true)) + var options = GenerationOptions() + options.maximumToolRounds = 1 + let session = LanguageModelSession(model: model(), tools: [WeatherTool()]) + do { + _ = try await session.streamResponse(to: "Weather", options: options).collect() + Issue.record("Expected the tool-round limit") + } catch let error as LanguageModelSession.ToolRoundLimitExceeded { + let kinds = error.transcriptEntries.map { entry in + switch entry { + case .reasoning: "reasoning" + case .toolCalls: "toolCalls" + case .toolOutput: "toolOutput" + default: "other" + } + } + #expect(kinds == ["reasoning", "toolCalls", "toolOutput"]) + } + } + @Test(arguments: [false, true]) func redactedThinkingNeverBecomesDisplayText(streaming: Bool) async throws { ReasoningURLProtocol.reset() diff --git a/Tests/AnyLanguageModelTests/OllamaReasoningReplayTests.swift b/Tests/AnyLanguageModelTests/OllamaReasoningReplayTests.swift index 6fd65174..19e62869 100644 --- a/Tests/AnyLanguageModelTests/OllamaReasoningReplayTests.swift +++ b/Tests/AnyLanguageModelTests/OllamaReasoningReplayTests.swift @@ -39,6 +39,37 @@ struct OllamaReasoningReplayTests { #expect(session.transcript.contains { if case .reasoning = $0 { true } else { false } }) } + @Test("the round past the tool-round limit leaves its thinking out of the rounds that ran") + func toolRoundLimitOmitsTheThinkingOfTheRoundPastIt() async throws { + let round = [ + #"{"model":"m","created_at":"2026-10-16T10:00:00.000Z","message":{"role":"assistant","content":"","thinking":"I should check the weather."},"done":false}"#, + #"{"model":"m","created_at":"2026-10-16T10:00:00.000Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"getWeather","arguments":{"city":"Munich"}}}]},"done":false}"#, + #"{"model":"m","created_at":"2026-10-16T10:00:00.000Z","message":{"role":"assistant","content":""},"done":true}"#, + ] + let model = OllamaLanguageModel( + baseURL: URL(string: "http://localhost:11434")!, + model: "m", + transport: ollama([round, round]) + ) + var options = GenerationOptions() + options.maximumToolRounds = 1 + let session = LanguageModelSession(model: model, tools: [WeatherTool()]) + do { + _ = try await session.streamResponse(to: "Weather in Munich?", options: options).collect() + Issue.record("Expected the tool-round limit") + } catch let error as LanguageModelSession.ToolRoundLimitExceeded { + let kinds = error.transcriptEntries.map { entry in + switch entry { + case .reasoning: "reasoning" + case .toolCalls: "toolCalls" + case .toolOutput: "toolOutput" + default: "other" + } + } + #expect(kinds == ["reasoning", "toolCalls", "toolOutput"]) + } + } + @Test("a streamed answer's own reasoning is in its last snapshot") func finalRoundReasoningIsInTheLastSnapshot() async throws { let transport = ollama([ From d5ba09759affffa053db8548509acbf6a1139b87 Mon Sep 17 00:00:00 2001 From: mattt Date: Thu, 22 Oct 2026 07:03:01 -0700 Subject: [PATCH 3/3] Call the nonisolated(nonsending) TaskLocal.withValue Swift 6.4 deprecates the overload that takes an isolation. Declaring RequestUsageChannel.outside and its operation nonisolated(nonsending) selects the new overload on 6.4 and the old one on 6.3. --- Sources/AnyLanguageModel/Shared/ResponseUsage.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/AnyLanguageModel/Shared/ResponseUsage.swift b/Sources/AnyLanguageModel/Shared/ResponseUsage.swift index 403379be..345565b3 100644 --- a/Sources/AnyLanguageModel/Shared/ResponseUsage.swift +++ b/Sources/AnyLanguageModel/Shared/ResponseUsage.swift @@ -151,7 +151,12 @@ final class RequestUsageChannel: Sendable { } /// Runs `operation` where no response's requests are counted. - static func outside(_ operation: () async throws -> T) async rethrows -> T { + /// + /// It runs on the caller's executor. Swift 6.4 adds the `nonisolated(nonsending)` overload + /// of `withValue` that this calls; earlier versions call the one that takes an isolation. + nonisolated(nonsending) static func outside( + _ operation: nonisolated(nonsending) () async throws -> T + ) async rethrows -> T { try await $current.withValue(nil, operation: operation) }