Skip to content

Commit 9525447

Browse files
JinjunHanclaude
andcommitted
fix: preserve empty-string content and reject images on FoundationModels
- Message.content now distinguishes "no text part" (nil) from "an empty text part" (""), so an empty tool result round-trips unchanged instead of collapsing to nil. Mirror the same in OpenAI content encoding. - FoundationModelsChatProvider now throws a clear invalidConfiguration error when a message carries images, instead of silently dropping them or surfacing a misleading "No user message found" error. - Add regression test for empty-string content round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 808ebf9 commit 9525447

4 files changed

Lines changed: 36 additions & 7 deletions

File tree

Sources/AgentKit/Conversation/Message.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@ public struct Message: Sendable, Identifiable {
4141
/// The text content of the message, if any.
4242
///
4343
/// Returns the concatenated text of all ``ContentPart/text(_:)`` parts, or
44-
/// `nil` when the message carries no text (e.g. an image-only or
45-
/// tool-call-only message). This mirrors the text-only API that predates
46-
/// multimodal support, so existing call sites keep working unchanged.
44+
/// `nil` when the message carries no text part at all (e.g. an image-only or
45+
/// tool-call-only message). A message that carries an *empty* text part still
46+
/// reports `""` rather than `nil`, so an empty tool result round-trips
47+
/// unchanged. This mirrors the text-only API that predates multimodal
48+
/// support, so existing call sites keep working unchanged.
4749
public var content: String? {
48-
let text = parts.compactMap(\.text).joined()
49-
return text.isEmpty ? nil : text
50+
let texts = parts.compactMap(\.text)
51+
return texts.isEmpty ? nil : texts.joined()
5052
}
5153

5254
/// The image parts of the message, in order. Empty when there are none.

Sources/AgentKit/Provider/FoundationModels/FoundationModelsChatProvider.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public struct FoundationModelsChatProvider: ChatProvider {
4040
tools: [any AgentKit.Tool],
4141
configuration: ProviderConfiguration
4242
) async throws(AgentKitError) -> ChatResponse {
43+
try Self.assertNoImages(in: messages)
4344
let session = makeSession(from: messages)
4445

4546
guard let lastUserMessage = messages.last(where: { $0.role == .user }),
@@ -68,6 +69,12 @@ public struct FoundationModelsChatProvider: ChatProvider {
6869
tools: [any AgentKit.Tool],
6970
configuration: ProviderConfiguration
7071
) -> AsyncThrowingStream<ChatStreamDelta, any Error> {
72+
do {
73+
try Self.assertNoImages(in: messages)
74+
} catch {
75+
return AsyncThrowingStream { $0.finish(throwing: error) }
76+
}
77+
7178
let session = makeSession(from: messages)
7279

7380
guard let lastUserMessage = messages.last(where: { $0.role == .user }),
@@ -109,6 +116,19 @@ public struct FoundationModelsChatProvider: ChatProvider {
109116

110117
// MARK: - Private
111118

119+
/// Rejects any message carrying image content.
120+
///
121+
/// Apple's on-device Foundation Models framework is text-only, so silently
122+
/// dropping images would produce confusing results. Fail loudly instead.
123+
private static func assertNoImages(in messages: [Message]) throws(AgentKitError) {
124+
if messages.contains(where: { !$0.images.isEmpty }) {
125+
throw .invalidConfiguration(
126+
"FoundationModelsChatProvider is text-only and does not support image input. "
127+
+ "Use OpenAIChatProvider or ClaudeChatProvider for multimodal messages."
128+
)
129+
}
130+
}
131+
112132
private func makeSession(from messages: [Message]) -> LanguageModelSession {
113133
let systemInstructions = messages
114134
.filter { $0.role == .system }

Sources/AgentKit/Provider/OpenAI/OpenAIChatProvider.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,9 @@ public struct OpenAIChatProvider: ChatProvider {
292292

293293
let hasImage = parts.contains { $0.image != nil }
294294
if !hasImage {
295-
let text = parts.compactMap(\.text).joined()
296-
return text.isEmpty ? nil : .text(text)
295+
// Preserve an empty text part (e.g. an empty tool result) as "".
296+
let texts = parts.compactMap(\.text)
297+
return texts.isEmpty ? nil : .text(texts.joined())
297298
}
298299

299300
let requestParts: [OpenAIChatRequest.RequestContentPart] = parts.compactMap { part in

Tests/AgentKitTests/MultimodalTests.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ final class MultimodalTests: XCTestCase {
2323
XCTAssertTrue(message.parts.isEmpty)
2424
}
2525

26+
func testEmptyStringContentRoundTrips() {
27+
// An empty tool result ("") must stay "" rather than collapsing to nil.
28+
let message = Message.tool(callID: "call_1", content: "")
29+
XCTAssertEqual(message.content, "")
30+
}
31+
2632
func testMultimodalUserMessageOrdersTextBeforeImages() {
2733
let image = ImageContent.base64("QUJD", mediaType: "image/png")
2834
let message = Message.user(text: "describe", images: [image])

0 commit comments

Comments
 (0)