Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions Sources/AnyLanguageModel/GenerationSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>.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<Double>.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 {
Expand Down
4 changes: 3 additions & 1 deletion Sources/AnyLanguageModel/Models/AnthropicLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions Sources/AnyLanguageModel/Shared/Bounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ enum Bounds<Value: Comparable & Sendable>: 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
Expand Down
7 changes: 6 additions & 1 deletion Sources/AnyLanguageModel/Shared/ResponseUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ final class RequestUsageChannel: Sendable {
}

/// Runs `operation` where no response's requests are counted.
static func outside<T>(_ 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<T>(
_ operation: nonisolated(nonsending) () async throws -> T
) async rethrows -> T {
try await $current.withValue(nil, operation: operation)
}

Expand Down
24 changes: 24 additions & 0 deletions Tests/AnyLanguageModelTests/AnthropicReasoningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 19 additions & 23 deletions Tests/AnyLanguageModelTests/BoundsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>.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)))
}
}
31 changes: 31 additions & 0 deletions Tests/AnyLanguageModelTests/OllamaReasoningReplayTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading