@@ -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