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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and Aria adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.13.0] - 2026-09-09

### Added

- **Foundation Models Dynamic Profiles on iOS 27 and related platform
releases.** Applications can configure model selection, tools, instructions,
bounded history, transcript failure policy, response limits, and lifecycle
observability while retaining the existing iOS 26 session path.
- **Foundation Models multimodal prompts.** Text streaming and typed structured
generation now preserve validated in-memory JPEG and PNG image content and
explicitly request vision capability from system or injected models.
- **Typed Foundation Models failures.** Provider rejections expose stable
categories for unsupported capabilities, context limits, safety decisions,
and other failure policies without requiring consumers to inspect error text.

### Changed

- Foundation Models prompt, transcript, error, and session construction now use
shared conversion paths so text-only, multimodal, system-model, and custom
model execution behave consistently.

## [0.12.0] - 2026-08-30

### Added
Expand Down
5 changes: 5 additions & 0 deletions Sources/AgentKit/Runtime/AgentRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ import WorkflowKit
return error.localizedDescription
}
switch agentError {
case let .providerRejected(failure):
if let underlying = failure.underlying {
return "Provider rejected (\(failure.kind.rawValue)): \(Self.condense(underlying.message))"
}
return "Provider rejected (\(failure.kind.rawValue)): \(failure.message)"
case let .providerFailed(message, underlying):
if let underlying {
return "Provider failed: \(message) — \(Self.condense(underlying.message))"
Expand Down
2 changes: 1 addition & 1 deletion Sources/Aria/Aria.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public enum AriaInfo {
///
/// Aria follows semantic versioning once it reaches `1.0.0`. Until then,
/// breaking changes may occur on minor version bumps.
public static let version = "0.12.0"
public static let version = "0.13.0"
}

/// Internal logger used by core types. Backends are installed by the platform
Expand Down
47 changes: 47 additions & 0 deletions Sources/Aria/Foundation/AgentError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import Foundation
/// All recoverable errors travel as `AgentError` so callers can pattern-match
/// instead of relying on string comparison or untyped `Error` values.
public enum AgentError: Error, Sendable, Equatable {
/// The provider rejected a request with a stable, actionable category.
case providerRejected(ProviderFailure)

/// The provider failed for a reason the underlying SDK reported. The
/// optional `underlying` carries the original error if available.
case providerFailed(String, underlying: ErrorBox? = nil)
Expand Down Expand Up @@ -38,6 +41,50 @@ public enum AgentError: Error, Sendable, Equatable {
case configurationInvalid(String)
}

// MARK: - ProviderFailureKind

/// Provider-independent failure categories suitable for fallback policy and
/// observability. Providers translate their SDK-specific errors at the edge.
public enum ProviderFailureKind: String, Error, Sendable, Equatable {
case providerUnavailable
case assetsUnavailable
case sessionConflict
case transcriptMutation
case safetyRejected
case contextWindowExceeded
case unsupportedCapability
case unsupportedTranscript
case unsupportedGenerationGuide
case unsupportedLanguageOrLocale
case rateLimited
case timedOut
case invalidOutput
case unknown
}

// MARK: - ProviderFailure

/// A provider failure with a stable category and preserved SDK diagnostics.
public struct ProviderFailure: Sendable, Equatable {
// MARK: Lifecycle

public init(
kind: ProviderFailureKind,
message: String,
underlying: ErrorBox? = nil
) {
self.kind = kind
self.message = message
self.underlying = underlying
}

// MARK: Public

public let kind: ProviderFailureKind
public let message: String
public let underlying: ErrorBox?
}

// MARK: - ErrorBox

/// A `Sendable` wrapper around an arbitrary `Error`.
Expand Down
7 changes: 3 additions & 4 deletions Sources/Aria/Foundation/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,9 @@ extension Message {

/// Build a user message with one or more images attached. Each
/// image is appended as a `ContentPart.image(...)` after the
/// text part. Vision-capable providers (FoundationModels with
/// vision, MLX VLM models) consume them; text-only providers
/// drop them silently via `textContent` (which only joins
/// `.text` parts).
/// text part. Vision-capable providers consume them; providers
/// without vision support reject or otherwise handle them according
/// to their capability policy.
public static func user(
_ text: String,
images: [ImageContent],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
#if canImport(FoundationModels)
import Aria
import FoundationModels

public enum FoundationModelsTranscriptErrorPolicy: Sendable, Equatable {
case revert
case preserve
}

public enum FoundationModelsProfileLifecycleEvent: Sendable, Equatable {
case activated
case deactivated
case prompt
case response
case reasoning
case toolCall
case toolOutput
}

@available(iOS 26.0, macOS 26.0, *)
public struct FoundationModelsProfileConfiguration: Sendable {
// MARK: Lifecycle

public init(
identifier: String,
maximumResponseTokens: Int? = nil,
historyLimit: Int? = nil,
transcriptErrorHandling: FoundationModelsTranscriptErrorPolicy = .revert,
lifecycleHandler: LifecycleHandler? = nil
) {
self.identifier = identifier
self.maximumResponseTokens = maximumResponseTokens
self.historyLimit = historyLimit
self.transcriptErrorHandling = transcriptErrorHandling
self.lifecycleHandler = lifecycleHandler
}

// MARK: Public

public typealias LifecycleHandler = @Sendable (
FoundationModelsProfileLifecycleEvent,
FoundationModelsProfileDescriptor
) async -> Void

public let identifier: String
public let maximumResponseTokens: Int?
public let historyLimit: Int?
public let transcriptErrorHandling: FoundationModelsTranscriptErrorPolicy
public let lifecycleHandler: LifecycleHandler?
}

@available(iOS 26.0, macOS 26.0, *)
public struct FoundationModelsProfileDescriptor: Equatable, Sendable {
public let profileIdentifier: String
public let modelIdentifier: String
public let selectedToolNames: [String]
public let instructions: String?
public let maximumResponseTokens: Int?
public let historyLimit: Int?
public let transcriptErrorHandling: FoundationModelsTranscriptErrorPolicy
public let hasLifecycleHandler: Bool
}

@available(iOS 26.0, macOS 26.0, *)
enum FoundationModelsDynamicProfileFactory {
// MARK: Internal

static func validate(_ configuration: FoundationModelsProfileConfiguration) throws {
guard !configuration.identifier.isEmpty else {
throw AgentError.configurationInvalid(
"Foundation Models profile identifier must not be empty"
)
}
if let maximumResponseTokens = configuration.maximumResponseTokens,
maximumResponseTokens <= 0 {
throw AgentError.configurationInvalid(
"Foundation Models maximum response tokens must be greater than zero"
)
}
if let historyLimit = configuration.historyLimit, historyLimit < 0 {
throw AgentError.configurationInvalid(
"Foundation Models history limit must not be negative"
)
}
}

static func descriptor(
modelIdentifier: String,
tools: [any FoundationModels.Tool],
transcript: Transcript,
configuration: FoundationModelsProfileConfiguration
) -> FoundationModelsProfileDescriptor {
FoundationModelsProfileDescriptor(
profileIdentifier: configuration.identifier,
modelIdentifier: modelIdentifier,
selectedToolNames: tools.map(\.name),
instructions: self.instructions(from: transcript),
maximumResponseTokens: configuration.maximumResponseTokens,
historyLimit: configuration.historyLimit,
transcriptErrorHandling: configuration.transcriptErrorHandling,
hasLifecycleHandler: configuration.lifecycleHandler != nil
)
}

static func history(
from transcript: Transcript,
limit: Int?
) -> [Transcript.Entry] {
let entries = transcript.filter { entry in
if case .instructions = entry {
return false
}
return true
}
guard let limit else {
return entries
}
guard limit > 0 else {
return []
}
var start = max(0, entries.count - limit)
if case .toolOutput = entries[start],
let toolCallsIndex = entries[..<start].lastIndex(where: { entry in
if case .toolCalls = entry {
return true
}
return false
}) {
start = toolCallsIndex
}
return Array(entries[start...])
}

// MARK: Private

private static func instructions(from transcript: Transcript) -> String? {
let parts = transcript.compactMap { entry -> String? in
guard case let .instructions(instructions) = entry else {
return nil
}
return instructions.segments.compactMap { segment -> String? in
guard case let .text(text) = segment else {
return nil
}
return text.content
}
.joined()
}
.filter { !$0.isEmpty }
guard !parts.isEmpty else {
return nil
}
return parts.joined(separator: "\n\n")
}
}

#if compiler(>=6.4)
@available(iOS 27.0, macOS 27.0, visionOS 27.0, watchOS 27.0, *)
@available(tvOS, unavailable)
extension FoundationModelsDynamicProfileFactory {
static func makeSession(
model: any LanguageModel,
modelIdentifier: String,
tools: [any FoundationModels.Tool],
transcript: Transcript,
configuration: FoundationModelsProfileConfiguration
) throws -> LanguageModelSession {
try self.validate(configuration)
let descriptor = self.descriptor(
modelIdentifier: modelIdentifier,
tools: tools,
transcript: transcript,
configuration: configuration
)
let instructionText = self.instructions(from: transcript)
let history = self.history(
from: transcript,
limit: configuration.historyLimit
)
let policy: TranscriptErrorHandlingPolicy =
switch configuration.transcriptErrorHandling {
case .revert: .revertTranscript
case .preserve: .preserveTranscript
}
let handler = configuration.lifecycleHandler
let profile = LanguageModelSession.Profile {
if let instructionText {
Instructions(instructionText)
}
tools
}
.model(model)
.maximumResponseTokens(configuration.maximumResponseTokens)
.historyTransform { entries in
let transcript = Transcript(entries: entries)
return self.history(from: transcript, limit: configuration.historyLimit)
}
.transcriptErrorHandlingPolicy(policy)
.onPrompt { await handler?(.prompt, descriptor) }
.onResponse { await handler?(.response, descriptor) }
.onReasoning { await handler?(.reasoning, descriptor) }
.onToolCall { await handler?(.toolCall, descriptor) }
.onToolOutput { await handler?(.toolOutput, descriptor) }
.onActivate { await handler?(.activated, descriptor) }
.onDeactivate { await handler?(.deactivated, descriptor) }
return LanguageModelSession(profile: profile, history: history)
}
}
#endif
#endif
Loading
Loading