Skip to content

Commit 21f6bcf

Browse files
committed
Test Core ML tool calling
Adds `withTools` and `streamWithTools` to the existing Core ML suite, modeled on the Anthropic equivalents. `streamWithTools` additionally asserts that tool-call markup never appears in the streamed assistant text. Both follow the suite's existing gating and so need the downloaded Core ML model. Because that model is not available in most environments, this also adds an ungated `CoreMLToolCallParsing` suite covering the tool-call text formats the provider claims to support: Hermes/Qwen tags, Mistral `[TOOL_CALLS]`, Llama `<|python_tag|>`, arguments encoded as a JSON string, the OpenAI function envelope, bare JSON gated on known tool names, the mid-stream hold-back heuristic, loop-detection signatures, and the nine-character alphanumeric tool call ids that Mistral's chat template requires. These need no model and run wherever the CoreML trait is enabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fe36cb7 commit 21f6bcf

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Tests/AnyLanguageModelTests/CoreMLLanguageModelTests.swift

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,68 @@ import Testing
300300
#expect(!response.content.colors.isEmpty)
301301
}
302302

303+
/// Requires a model whose chat template supports tools. The template renders the tool specs,
304+
/// the model answers with tool-call text, and the parser turns that back into transcript
305+
/// entries.
306+
@Test @available(macOS 15.0, iOS 18.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
307+
func withTools() async throws {
308+
let model = try await getModel()
309+
let weatherTool = WeatherTool()
310+
let session = LanguageModelSession(model: model, tools: [weatherTool])
311+
312+
let response = try await session.respond(to: "How's the weather in San Francisco?")
313+
314+
var foundToolOutput = false
315+
for case let .toolOutput(toolOutput) in response.transcriptEntries {
316+
#expect(!toolOutput.id.isEmpty)
317+
#expect(toolOutput.toolName == "getWeather")
318+
foundToolOutput = true
319+
}
320+
#expect(foundToolOutput)
321+
}
322+
323+
@Test @available(macOS 15.0, iOS 18.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
324+
func streamWithTools() async throws {
325+
let model = try await getModel()
326+
let weatherTool = WeatherTool()
327+
let session = LanguageModelSession(model: model, tools: [weatherTool])
328+
329+
let stream = session.streamResponse(to: "How's the weather in San Francisco?")
330+
331+
var snapshots: [LanguageModelSession.ResponseStream<String>.Snapshot] = []
332+
333+
var toolAppearedInTranscript: Bool = false
334+
var toolResponseAppearedInTranscript: Bool = false
335+
336+
for try await snapshot in stream {
337+
snapshots.append(snapshot)
338+
339+
for entry in session.transcript {
340+
switch entry {
341+
case .toolCalls:
342+
toolAppearedInTranscript = true
343+
case .toolOutput:
344+
toolResponseAppearedInTranscript = true
345+
default: break
346+
}
347+
}
348+
}
349+
350+
#expect(toolAppearedInTranscript, "Expected a tool call to appear in the transcript during streaming.")
351+
#expect(
352+
toolResponseAppearedInTranscript,
353+
"Expected a tool output to appear in the transcript during streaming."
354+
)
355+
356+
// Tool-call markup must never be published as assistant text.
357+
if #available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, watchOS 26.0, *) {
358+
for snapshot in snapshots {
359+
#expect(!snapshot.content.contains("<tool_call>"))
360+
#expect(!snapshot.content.contains("[TOOL_CALLS]"))
361+
}
362+
}
363+
}
364+
303365
@Test @available(macOS 15.0, iOS 18.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
304366
func structuredGenerationNestedStruct() async throws {
305367
let model = try await getModel()
@@ -317,4 +379,147 @@ import Testing
317379
#expect(!response.content.address.city.isEmpty)
318380
}
319381
}
382+
383+
/// Exercises the tool-call text formats the Core ML provider claims to support. These need no
384+
/// downloaded model, so unlike the suite above they run everywhere the CoreML trait is enabled.
385+
@Suite("CoreMLToolCallParsing")
386+
struct CoreMLToolCallParsingTests {
387+
private let knownToolNames: Set<String> = ["getWeather"]
388+
389+
@Test func parsesHermesStyleTaggedCall() {
390+
let result = CoreMLToolCallParser.parse(
391+
"Let me look that up.\n<tool_call>\n{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Paris\"}}\n</tool_call>",
392+
knownToolNames: knownToolNames
393+
)
394+
395+
#expect(result.visibleText == "Let me look that up.")
396+
#expect(result.calls.count == 1)
397+
#expect(result.calls.first?.name == "getWeather")
398+
#expect(result.calls.first?.argumentsJSON == "{\"city\":\"Paris\"}")
399+
}
400+
401+
@Test func parsesMultipleTaggedCalls() {
402+
let result = CoreMLToolCallParser.parse(
403+
"<tool_call>{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Paris\"}}</tool_call>"
404+
+ "<tool_call>{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Oslo\"}}</tool_call>",
405+
knownToolNames: knownToolNames
406+
)
407+
408+
#expect(result.calls.count == 2)
409+
#expect(result.visibleText.isEmpty)
410+
}
411+
412+
@Test func parsesMistralToolCallsMarker() {
413+
let result = CoreMLToolCallParser.parse(
414+
"[TOOL_CALLS] [{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Paris\"}}]",
415+
knownToolNames: knownToolNames
416+
)
417+
418+
#expect(result.calls.count == 1)
419+
#expect(result.calls.first?.name == "getWeather")
420+
#expect(result.visibleText.isEmpty)
421+
}
422+
423+
@Test func parsesLlamaPythonTagWithParametersKey() {
424+
let result = CoreMLToolCallParser.parse(
425+
"<|python_tag|>{\"name\": \"getWeather\", \"parameters\": {\"city\": \"Paris\"}}",
426+
knownToolNames: knownToolNames
427+
)
428+
429+
#expect(result.calls.count == 1)
430+
#expect(result.calls.first?.argumentsJSON == "{\"city\":\"Paris\"}")
431+
}
432+
433+
@Test func parsesArgumentsEncodedAsJSONString() {
434+
let result = CoreMLToolCallParser.parse(
435+
"<tool_call>{\"name\": \"getWeather\", \"arguments\": \"{\\\"city\\\": \\\"Paris\\\"}\"}</tool_call>",
436+
knownToolNames: knownToolNames
437+
)
438+
439+
#expect(result.calls.first?.argumentsJSON == "{\"city\":\"Paris\"}")
440+
}
441+
442+
@Test func parsesOpenAIFunctionEnvelope() {
443+
let result = CoreMLToolCallParser.parse(
444+
"<tool_call>{\"type\": \"function\", \"function\": {\"name\": \"getWeather\", \"arguments\": {\"city\": \"Paris\"}}}</tool_call>",
445+
knownToolNames: knownToolNames
446+
)
447+
448+
#expect(result.calls.first?.name == "getWeather")
449+
}
450+
451+
@Test func parsesBareJSONOnlyForKnownTools() {
452+
let text = "{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Paris\"}}"
453+
454+
let known = CoreMLToolCallParser.parse(text, knownToolNames: knownToolNames)
455+
#expect(known.calls.count == 1)
456+
457+
let unknown = CoreMLToolCallParser.parse(text, knownToolNames: [])
458+
#expect(unknown.calls.isEmpty)
459+
#expect(unknown.visibleText == text)
460+
}
461+
462+
@Test func treatsPlainProseAsText() {
463+
let result = CoreMLToolCallParser.parse(
464+
"The weather in Paris is sunny.",
465+
knownToolNames: knownToolNames
466+
)
467+
468+
#expect(result.calls.isEmpty)
469+
#expect(result.visibleText == "The weather in Paris is sunny.")
470+
}
471+
472+
@Test func treatsUnparseableTagBodyAsText() {
473+
let text = "<tool_call>not json</tool_call>"
474+
let result = CoreMLToolCallParser.parse(text, knownToolNames: knownToolNames)
475+
476+
#expect(result.calls.isEmpty)
477+
#expect(result.visibleText == text)
478+
}
479+
480+
@Test func withholdsToolCallMarkupWhileStreaming() {
481+
#expect(
482+
CoreMLToolCallParser.visibleTextForStreaming("Checking. <tool_call>{\"na")
483+
== "Checking. "
484+
)
485+
#expect(CoreMLToolCallParser.visibleTextForStreaming("[TOOL_CALLS] [{\"na").isEmpty)
486+
#expect(CoreMLToolCallParser.visibleTextForStreaming("{\"name\": \"get").isEmpty)
487+
#expect(
488+
CoreMLToolCallParser.visibleTextForStreaming("The weather is") == "The weather is"
489+
)
490+
}
491+
492+
@Test func signatureDistinguishesArgumentsAndIgnoresKeyOrder() {
493+
let first = CoreMLToolCallParser.parse(
494+
"<tool_call>{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Paris\", \"unit\": \"C\"}}</tool_call>",
495+
knownToolNames: knownToolNames
496+
)
497+
let reordered = CoreMLToolCallParser.parse(
498+
"<tool_call>{\"name\": \"getWeather\", \"arguments\": {\"unit\": \"C\", \"city\": \"Paris\"}}</tool_call>",
499+
knownToolNames: knownToolNames
500+
)
501+
let different = CoreMLToolCallParser.parse(
502+
"<tool_call>{\"name\": \"getWeather\", \"arguments\": {\"city\": \"Oslo\"}}</tool_call>",
503+
knownToolNames: knownToolNames
504+
)
505+
506+
#expect(
507+
CoreMLToolCallParser.signature(for: first.calls)
508+
== CoreMLToolCallParser.signature(for: reordered.calls)
509+
)
510+
#expect(
511+
CoreMLToolCallParser.signature(for: first.calls)
512+
!= CoreMLToolCallParser.signature(for: different.calls)
513+
)
514+
}
515+
516+
@Test func toolCallIDsAreNineAlphanumericCharacters() {
517+
// Mistral's chat template rejects anything else.
518+
for _ in 0 ..< 32 {
519+
let id = CoreMLToolCallParser.makeToolCallID()
520+
#expect(id.count == 9)
521+
#expect(id.allSatisfy { $0.isLetter || $0.isNumber })
522+
}
523+
}
524+
}
320525
#endif // CoreML

0 commit comments

Comments
 (0)