Skip to content

Surface tool calls in the SystemLanguageModel transcript - #12

Merged
ActuallyTaylor merged 4 commits into
mainfrom
feature/system-streaming-tool-calling
Aug 4, 2026
Merged

Surface tool calls in the SystemLanguageModel transcript#12
ActuallyTaylor merged 4 commits into
mainfrom
feature/system-streaming-tool-calling

Conversation

@ActuallyTaylor

Copy link
Copy Markdown
Collaborator

This provider bridges FoundationModels, which owns the tool loop itself, so this is narrower than the other provider PRs.

respond dropped every transcript entry. It returned transcriptEntries: [] unconditionally in three places, so tool calls FoundationModels had already executed were silently discarded. It now converts FM's Response.transcriptEntries. This also made a #if false'd block in withTools real — its old assertion was wrong too, comparing ToolOutput.id against "getWeather" when that's the toolName.

Streaming now mirrors tool activity into session.transcript as the turn progresses, using the ids FM assigned rather than synthesized ones, preserving .toolCalls before .toolOutput. The provider previously never touched the transcript mid-stream at all. Mirroring happens on snapshot boundaries rather than the instant FM records a call, because polling concurrently would share a non-Sendable FM session across tasks — entries still land mid-stream, plus a final sweep before the stream finishes.

Note

toolExecutionDelegate is not consulted, and this is documented rather than faked. FM invokes tools through Tool.call(arguments:), which receives the decoded arguments and nothing else — no call id, no session, no view of sibling calls. didGenerateToolCalls is defined over a batch FM never surfaces; the delegate's Transcript.ToolCall.id could never match the entry that later appears; and .stop has no expression at all, since FM offers no way to halt a turn from inside a tool. Partial support would be worse than none — a caller returning .stop would get its tool executed anyway, silently.

Verified on-device with Apple Intelligence available: 17/17 pass including withTools and streamWithTools.

Based on #4; merge that first.

🤖 Generated with Claude Code

@ActuallyTaylor
ActuallyTaylor force-pushed the feature/central-tool-resolution branch from 3f72c2c to 6925e25 Compare August 3, 2026 19:35
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch from 3a89e24 to a4d3879 Compare August 3, 2026 19:36
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/central-tool-resolution branch from 6925e25 to 28e5c10 Compare August 3, 2026 19:39
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch 2 times, most recently from 58b72cd to a64cfdb Compare August 3, 2026 19:47
@ActuallyTaylor
ActuallyTaylor changed the base branch from feature/central-tool-resolution to feature/coreml-streaming-tool-calling August 3, 2026 19:49
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/coreml-streaming-tool-calling branch from fef1463 to 92eebd2 Compare August 3, 2026 20:03
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch from a64cfdb to 4973735 Compare August 3, 2026 20:03
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/coreml-streaming-tool-calling branch from 92eebd2 to a72e53f Compare August 3, 2026 20:07
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch from 4973735 to 66ea665 Compare August 3, 2026 20:07
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/coreml-streaming-tool-calling branch from a72e53f to 78cbcab Compare August 3, 2026 20:09
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch 2 times, most recently from ce492c4 to ef5944d Compare August 3, 2026 20:17
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/coreml-streaming-tool-calling branch 2 times, most recently from ad2e937 to 859a64d Compare August 3, 2026 20:18
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch 2 times, most recently from 95f9bde to d968cf9 Compare August 3, 2026 20:40
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/coreml-streaming-tool-calling branch from 859a64d to c61dff5 Compare August 3, 2026 20:40
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch from d968cf9 to 6d79d02 Compare August 3, 2026 20:44
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/coreml-streaming-tool-calling branch from 360efcb to 97c09d6 Compare August 3, 2026 22:23
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch from 6d79d02 to 4e487b4 Compare August 3, 2026 22:23
claude added 2 commits August 3, 2026 20:10
SystemLanguageModel.respond always returned an empty transcriptEntries slice, so tool calls and tool outputs that FoundationModels recorded were dropped instead of reaching session.transcript. Every other provider returns the tool-related entries it produced, and LanguageModelSession.respond appends them ahead of the response entry.

FoundationModels does expose this: LanguageModelSession.Response carries transcriptEntries, and its Transcript.ToolCall/ToolOutput values hold the ids FoundationModels assigned. Convert those into their AnyLanguageModel equivalents and return them. Only .toolCalls and .toolOutput are converted; instructions, prompts, and responses are owned by LanguageModelSession and mirroring them would duplicate entries.

This makes the previously #if false'd assertion in withTools real. Its old expectation was also wrong: it compared ToolOutput.id against "getWeather", but the tool name lives in toolName while id is the call identifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Model streams

Callers of streamResponse expect tool calls and their outputs to land in session.transcript while the stream is still running, with .toolCalls ahead of the matching .toolOutput. SystemLanguageModel surfaced neither: it never touched session.transcript during a stream, so a Transcript-driven UI saw nothing until LanguageModelSession appended the final response.

FoundationModels gives us no tool channel on the stream itself — ResponseStream.Snapshot carries only content and rawContent. Its LanguageModelSession.transcript is the only place tool activity shows up, and it grows as the turn progresses, so mirror new tool entries out of it into session.transcript. Mirroring in the order FoundationModels recorded them is what gives the .toolCalls before .toolOutput ordering; the ids are the ones FoundationModels assigned rather than anything synthesized here. Response text now also grows the transcript as it streams, mirroring after tool entries so the turn reads .toolCalls, .toolOutput, .response.

Mirroring happens on snapshot boundaries instead of the instant a call is recorded, because polling concurrently would share a non-Sendable FoundationModels.LanguageModelSession across tasks. Tool entries therefore appear with the first snapshot after the tool ran, still mid-stream, plus a final sweep before the stream finishes.

session.toolExecutionDelegate remains unconsulted, and AnyToolWrapper documents why. FoundationModels owns the tool loop and hands a tool only its arguments — no call id, no session, no view of sibling calls — so didGenerateToolCalls has no batch to report, the callbacks taking Transcript.ToolCall have no real id to pass, and .stop has no expression at all. Honoring it partially would silently execute a tool the caller asked to stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ActuallyTaylor
ActuallyTaylor changed the base branch from feature/coreml-streaming-tool-calling to main August 4, 2026 00:10
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/system-streaming-tool-calling branch from 4e487b4 to 2825b8d Compare August 4, 2026 00:10

@ActuallyTaylor ActuallyTaylor left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks almost good, just some comments in the tests

Comment thread Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift Outdated
Comment thread Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift Outdated
Two things a reader shouldn't have to infer.

`toolTranscriptEntry` returned `nil` both for entries that aren't tool activity and for entries whose contents fail to convert, and a bare `compactMap` at the call sites made the first case look like an accidental over-filter — as if prompts and responses were being dropped by mistake. They are dropped deliberately: `LanguageModelSession` appends the prompt before calling the model and the response after, so carrying FoundationModels' own copies back would duplicate them. `isToolActivity(_:)` now states that at each call site, leaving `compactMap` to mean only "conversion failed".

The tool tests also asserted with bare `#expect`s, so a failure reported a boolean with no indication of what was expected. Each now carries the reason, matching the ones that already did.
@ActuallyTaylor

Copy link
Copy Markdown
Collaborator Author

Both comments addressed in 56ca70c, plus the filtering question you raised.

Failure reasons. Every bare #expect in withTools and streamWithTools now carries one, matching the ones that already had them — the id, tool name, foundToolOutput, the two content assertions, !snapshots.isEmpty, and the two index checks.

The compactMap that looked like it dropped everything. It did drop non-tool entries, deliberately, but nothing at the call site said so. LanguageModelSession appends the prompt before calling the model and builds the response entry after, so carrying FoundationModels' own .prompt/.response/.instructions back would duplicate them — which is also why every other provider returns only .toolCalls/.toolOutput from respond.

The problem was that toolTranscriptEntry returned nil for two unrelated reasons: "not tool activity" and "contents failed to convert". Those are now separate — isToolActivity(_:) filters at all three call sites, and compactMap means only "conversion failed".

Verified on-device: 17/17 pass, including withTools and streamWithTools.

🤖 Generated with Claude Code

`toolTranscriptEntry` read as though it converted any transcript entry, which made the `compactMap` at its call sites look like it was discarding prompts and responses by accident. It only ever yields tool activity, so `toolActivityEntry(from:)` says that, and the `nil` result reads as "not tool activity" rather than a dropped conversion.

The doc comment keeps the reason those entries are skipped: `LanguageModelSession` appends the prompt before calling the model and the response after, so mirroring FoundationModels' copies would duplicate them.

@ActuallyTaylor ActuallyTaylor left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, not the same as the other models but it seems that the conversions between the Apple Foundations Model API and this API are tricky

@ActuallyTaylor
ActuallyTaylor merged commit fe363a3 into main Aug 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants