Skip to content

Commit 452c0f7

Browse files
committed
fix(foundation-models): reject unsupported history mapping
1 parent 3bb7acf commit 452c0f7

7 files changed

Lines changed: 348 additions & 85 deletions

File tree

Sources/AgentRunKitFoundationModels/Documentation.docc/AgentRunKitFoundationModels.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ On-device LLM inference via Apple's Foundation Models framework.
44

55
## Overview
66

7-
AgentRunKitFoundationModels bridges AgentRunKit to Apple's on-device foundation model. Use `Agent.onDevice(tools:context:instructions:)` as the primary entry point, or construct a ``FoundationModelsClient`` directly. The same tools, agent loop, and streaming API work on-device with no network access required. Requires macOS 26+ or iOS 26+ with Apple Intelligence enabled.
7+
AgentRunKitFoundationModels bridges AgentRunKit to Apple's on-device foundation model. Use
8+
`Agent.onDevice(tools:context:instructions:)` as the primary entry point, or construct a
9+
``FoundationModelsClient`` directly. Single-turn generation and streaming run on-device with no network access
10+
required. Requires macOS 26+ or iOS 26+ with Apple Intelligence enabled.
811

9-
`Chat` and direct ``FoundationModelsClient`` usage are supported for single-turn interactions: `Chat.send()` returns an `AssistantMessage` with text in `content`, and `Chat.stream()` terminates on content-only iterations the same way it does for cloud clients. Multi-turn conversations are currently limited: only the most recent user turn is forwarded to the on-device session, so prior assistant and tool messages are not yet preserved across turns. Full history linearization is tracked as a follow-up.
12+
`Chat` and direct ``FoundationModelsClient`` usage are supported for single-turn text interactions: `Chat.send()`
13+
returns an `AssistantMessage` with text in `content`, and `Chat.stream()` terminates on content-only iterations the
14+
same way it does for cloud clients. Histories must contain exactly one non-empty text user prompt, optionally
15+
preceded by system instructions. Multi-turn histories, AgentRunKit tool-loop history, and non-text multimodal input
16+
are rejected until AgentRunKit maps histories into Foundation Models `Transcript` values or implements history
17+
linearization.
1018

1119
## Topics
1220

Sources/AgentRunKitFoundationModels/FMMessageMapper.swift

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,55 @@
1010
let prompt: String
1111
}
1212

13-
static func map(_ messages: [ChatMessage]) -> MappedInput {
13+
static func map(_ messages: [ChatMessage]) throws -> MappedInput {
1414
var systemParts: [String] = []
15-
var lastUserPrompt: String?
15+
var userPrompt: String?
1616

1717
for message in messages {
1818
switch message {
1919
case let .system(content):
20+
guard userPrompt == nil else { throw unsupportedMappingError }
2021
systemParts.append(content)
2122
case let .user(content):
22-
lastUserPrompt = content
23+
try assignPrompt(content, to: &userPrompt)
2324
case let .userMultimodal(parts):
24-
let textContent = parts.compactMap { part -> String? in
25-
if case let .text(text) = part { return text }
26-
return nil
27-
}
28-
if !textContent.isEmpty {
29-
lastUserPrompt = textContent.joined(separator: "\n")
30-
}
25+
try assignPrompt(textOnlyPrompt(from: parts), to: &userPrompt)
3126
case .assistant, .tool:
32-
break
27+
throw unsupportedMappingError
3328
}
3429
}
3530

31+
guard let userPrompt else { throw unsupportedMappingError }
3632
return MappedInput(
3733
instructions: systemParts.isEmpty ? nil : systemParts.joined(separator: "\n"),
38-
prompt: lastUserPrompt ?? ""
34+
prompt: userPrompt
3935
)
4036
}
37+
38+
private static func assignPrompt(_ prompt: String, to currentPrompt: inout String?) throws {
39+
guard currentPrompt == nil, !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
40+
throw unsupportedMappingError
41+
}
42+
currentPrompt = prompt
43+
}
44+
45+
private static func textOnlyPrompt(from parts: [ContentPart]) throws -> String {
46+
var textParts: [String] = []
47+
for part in parts {
48+
guard case let .text(text) = part else {
49+
throw unsupportedMappingError
50+
}
51+
textParts.append(text)
52+
}
53+
return textParts.joined(separator: "\n")
54+
}
55+
56+
private static var unsupportedMappingError: AgentError {
57+
.llmError(.featureUnsupported(
58+
provider: ProviderIdentifier.foundationModels.description,
59+
feature: "single-turn text-only message mapping"
60+
))
61+
}
4162
}
4263

4364
#endif

Sources/AgentRunKitFoundationModels/FoundationModelsClient.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
)
3838
}
3939

40-
let mapped = FMMessageMapper.map(messages)
40+
let mapped = try FMMessageMapper.map(messages)
4141
let adapters: [any FoundationModels.Tool] = try tools.map {
4242
try FMToolAdapter(wrapping: $0, context: context)
4343
}
@@ -55,7 +55,7 @@
5555
let task = Task {
5656
do {
5757
try messages.validateForLLMRequest()
58-
let mapped = FMMessageMapper.map(messages)
58+
let mapped = try FMMessageMapper.map(messages)
5959
let adapters: [any FoundationModels.Tool] = try tools.map {
6060
try FMToolAdapter(wrapping: $0, context: context)
6161
}

Tests/AgentRunKitFoundationModelsTests/FMMessageMapperTests.swift

Lines changed: 145 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,81 +2,195 @@
22

33
import AgentRunKit
44
@testable import AgentRunKitFoundationModels
5+
import Foundation
56
import Testing
67

78
@Suite(.serialized) struct FMMessageMapperTests {
8-
@Test func singleUserMessage() {
9+
@Test func singleUserMessage() throws {
910
guard #available(macOS 26, iOS 26, *) else { return }
10-
let mapped = FMMessageMapper.map([.user("Hello")])
11+
let mapped = try FMMessageMapper.map([.user("Hello")])
1112
#expect(mapped.prompt == "Hello")
1213
#expect(mapped.instructions == nil)
1314
}
1415

15-
@Test func systemMessageExtractedAsInstructions() {
16+
@Test func systemMessageExtractedAsInstructions() throws {
1617
guard #available(macOS 26, iOS 26, *) else { return }
17-
let mapped = FMMessageMapper.map([
18+
let mapped = try FMMessageMapper.map([
1819
.system("You are helpful"),
1920
.user("Hi"),
2021
])
2122
#expect(mapped.instructions == "You are helpful")
2223
#expect(mapped.prompt == "Hi")
2324
}
2425

25-
@Test func multipleSystemMessagesJoinedWithNewline() {
26+
@Test func multipleSystemMessagesJoinedWithNewline() throws {
2627
guard #available(macOS 26, iOS 26, *) else { return }
27-
let mapped = FMMessageMapper.map([
28+
let mapped = try FMMessageMapper.map([
2829
.system("First instruction"),
2930
.system("Second instruction"),
3031
.user("Question"),
3132
])
3233
#expect(mapped.instructions == "First instruction\nSecond instruction")
3334
}
3435

35-
@Test func multimodalUserExtractsTextOnly() {
36+
@Test func textOnlyMultimodalUserMessage() throws {
3637
guard #available(macOS 26, iOS 26, *) else { return }
37-
let mapped = FMMessageMapper.map([
38+
let mapped = try FMMessageMapper.map([
3839
.userMultimodal([
3940
.text("Describe this"),
40-
.imageURL("https://example.com/image.jpg"),
4141
.text("in detail"),
4242
]),
4343
])
4444
#expect(mapped.prompt == "Describe this\nin detail")
4545
}
4646

47-
@Test func noSystemMessageYieldsNilInstructions() {
47+
@Test func noSystemMessageYieldsNilInstructions() throws {
4848
guard #available(macOS 26, iOS 26, *) else { return }
49-
let mapped = FMMessageMapper.map([.user("Just a question")])
49+
let mapped = try FMMessageMapper.map([.user("Just a question")])
5050
#expect(mapped.instructions == nil)
5151
}
5252

53-
@Test func multipleUserMessagesUsesLast() {
53+
@Test func multipleUserMessagesThrows() {
5454
guard #available(macOS 26, iOS 26, *) else { return }
55-
let mapped = FMMessageMapper.map([
56-
.user("First question"),
57-
.user("Second question"),
58-
])
59-
#expect(mapped.prompt == "Second question")
55+
#expect {
56+
_ = try FMMessageMapper.map([
57+
.user("First question"),
58+
.user("Second question"),
59+
])
60+
} throws: { error in
61+
isUnsupportedFoundationModelsMappingError(error)
62+
}
6063
}
6164

62-
@Test func assistantAndToolMessagesIgnored() {
65+
@Test func systemMessageAfterUserThrows() {
6366
guard #available(macOS 26, iOS 26, *) else { return }
64-
let mapped = FMMessageMapper.map([
65-
.system("System"),
66-
.user("First"),
67-
.assistant(AssistantMessage(content: "Response")),
68-
.tool(id: "1", name: "test", content: "result"),
69-
.user("Follow up"),
70-
])
71-
#expect(mapped.instructions == "System")
72-
#expect(mapped.prompt == "Follow up")
67+
#expect {
68+
_ = try FMMessageMapper.map([
69+
.user("Question"),
70+
.system("Late instruction"),
71+
])
72+
} throws: { error in
73+
isUnsupportedFoundationModelsMappingError(error)
74+
}
7375
}
7476

75-
@Test func emptyMessagesYieldsEmptyPrompt() {
77+
@Test func assistantMessageThrows() {
7678
guard #available(macOS 26, iOS 26, *) else { return }
77-
let mapped = FMMessageMapper.map([])
78-
#expect(mapped.prompt == "")
79-
#expect(mapped.instructions == nil)
79+
#expect {
80+
_ = try FMMessageMapper.map([
81+
.user("First"),
82+
.assistant(AssistantMessage(content: "Response")),
83+
])
84+
} throws: { error in
85+
isUnsupportedFoundationModelsMappingError(error)
86+
}
87+
}
88+
89+
@Test func toolMessageThrows() {
90+
guard #available(macOS 26, iOS 26, *) else { return }
91+
#expect {
92+
_ = try FMMessageMapper.map([
93+
.user("First"),
94+
.tool(id: "1", name: "test", content: "result"),
95+
])
96+
} throws: { error in
97+
isUnsupportedFoundationModelsMappingError(error)
98+
}
99+
}
100+
101+
@Test func emptyHistoryThrows() {
102+
guard #available(macOS 26, iOS 26, *) else { return }
103+
#expect {
104+
_ = try FMMessageMapper.map([])
105+
} throws: { error in
106+
isUnsupportedFoundationModelsMappingError(error)
107+
}
108+
}
109+
110+
@Test func systemOnlyHistoryThrows() {
111+
guard #available(macOS 26, iOS 26, *) else { return }
112+
#expect {
113+
_ = try FMMessageMapper.map([.system("System")])
114+
} throws: { error in
115+
isUnsupportedFoundationModelsMappingError(error)
116+
}
117+
}
118+
119+
@Test func emptyUserTextThrows() {
120+
guard #available(macOS 26, iOS 26, *) else { return }
121+
#expect {
122+
_ = try FMMessageMapper.map([.user(" \n\t ")])
123+
} throws: { error in
124+
isUnsupportedFoundationModelsMappingError(error)
125+
}
126+
}
127+
128+
@Test func nonTextMultimodalPartsThrow() {
129+
guard #available(macOS 26, iOS 26, *) else { return }
130+
let data = Data([0x01, 0x02])
131+
let nonTextParts: [ContentPart] = [
132+
.imageURL("https://example.com/image.jpg"),
133+
.imageBase64(data: data, mimeType: "image/png"),
134+
.videoBase64(data: data, mimeType: "video/mp4"),
135+
.pdfBase64(data: data),
136+
.audioBase64(data: data, format: .mp3),
137+
]
138+
139+
for part in nonTextParts {
140+
#expect {
141+
_ = try FMMessageMapper.map([
142+
.userMultimodal([part]),
143+
])
144+
} throws: { error in
145+
isUnsupportedFoundationModelsMappingError(error)
146+
}
147+
148+
#expect {
149+
_ = try FMMessageMapper.map([
150+
.userMultimodal([.text("Describe this"), part]),
151+
])
152+
} throws: { error in
153+
isUnsupportedFoundationModelsMappingError(error)
154+
}
155+
}
156+
}
157+
158+
@Test func whitespaceOnlyMultimodalThrows() {
159+
guard #available(macOS 26, iOS 26, *) else { return }
160+
#expect {
161+
_ = try FMMessageMapper.map([
162+
.userMultimodal([.text(" "), .text("\n")]),
163+
])
164+
} throws: { error in
165+
isUnsupportedFoundationModelsMappingError(error)
166+
}
167+
}
168+
169+
@Test func multimodalPlusUserThrows() {
170+
guard #available(macOS 26, iOS 26, *) else { return }
171+
#expect {
172+
_ = try FMMessageMapper.map([
173+
.userMultimodal([.text("First question")]),
174+
.user("Second question"),
175+
])
176+
} throws: { error in
177+
isUnsupportedFoundationModelsMappingError(error)
178+
}
179+
}
180+
181+
@Test func assistantToolAndFollowUpThrows() {
182+
guard #available(macOS 26, iOS 26, *) else { return }
183+
#expect {
184+
_ = try FMMessageMapper.map([
185+
.system("System"),
186+
.user("First"),
187+
.assistant(AssistantMessage(content: "Response")),
188+
.tool(id: "1", name: "test", content: "result"),
189+
.user("Follow up"),
190+
])
191+
} throws: { error in
192+
isUnsupportedFoundationModelsMappingError(error)
193+
}
80194
}
81195
}
82196

0 commit comments

Comments
 (0)