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/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/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/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) } 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/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))) + } } 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([