Skip to content

Add streaming tool calling to the MLX provider - #9

Merged
ActuallyTaylor merged 1 commit into
mainfrom
feature/mlx-streaming-tool-calling
Aug 3, 2026
Merged

Add streaming tool calling to the MLX provider#9
ActuallyTaylor merged 1 commit into
mainfrom
feature/mlx-streaming-tool-calling

Conversation

@ActuallyTaylor

Copy link
Copy Markdown
Collaborator

streamResponse passed tools: nil to makeUserInput and folded .toolCall items into a no-op, so tools were never requested and never handled. respond already had a complete loop.

Streaming now passes the session's tool specs and wraps generation in a turn loop: it accumulates text and collects tool calls, then stores the KV cache, appends the assistant text to the chat, applies the same max-iteration ceiling and repeated-signature guards respond uses, and runs the shared resolveToolCalls. Tool results feed back as .tool chat messages before the next turn.

Resource handling is preserved on every exit path. The GPU memory scope and generation slot are acquired once outside the loop and released through the idempotent finishScope() / finishGenerationSlot() pair on all four exits — normal break, the .stop early return (which releases before returning), the catch, and consumer-side onTermination.

The type == String.self restriction is unchanged.

Verified live via xcodebuild per the README's MLX instructions, against mlx-community/Qwen3-0.6B-4bit: 19/19 tests pass including withTools and streamWithTools. (The suite gates itself to Xcode because swift test hits a Metal library loading failure that also breaks the pre-existing tests.)

Based on #4; merge that first.

🤖 Generated with Claude Code

@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch from 709c428 to c34c831 Compare August 3, 2026 19:36
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch 2 times, most recently from 2d0d080 to cfc98fb Compare August 3, 2026 19:47
@ActuallyTaylor
ActuallyTaylor changed the base branch from feature/central-tool-resolution to feature/openresponses-streaming-tool-calling August 3, 2026 19:48
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch from cfc98fb to a5a0ea7 Compare August 3, 2026 20:03
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch from a5a0ea7 to 043c0ee Compare August 3, 2026 20:07
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch 2 times, most recently from 66176ee to f2fb47c Compare August 3, 2026 20:17
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch 2 times, most recently from 9500897 to 8f4d598 Compare August 3, 2026 20:40
MLXLanguageModel.streamResponse passed `tools: nil` to makeUserInput and its generation loop ignored `.toolCall` items, so tool calls were never requested from the model and never handled. Streaming with tools silently behaved as if the session had no tools, while the non-streaming `respond` path supported them fully.

streamResponse now passes the session's tool specs via the existing mlxToolSpecs(for:) helper and wraps generation in a turn loop that mirrors the semantics of AnthropicLanguageModel.streamResponse. Each turn accumulates `.chunk` text and collects `.toolCall` items; when a turn ends with tool calls pending they are executed and the assistant turn plus tool results are appended to the chat before generating again. The loop exits when a turn completes without tool calls.

Tool execution reuses the same makeTranscriptToolCalls and resolveToolCalls functions the non-streaming path uses, including its max-iteration ceiling and repeated-tool-call-signature loop detection, so both paths abort identically on a runaway model. A `.stop` resolution appends the tool calls and finishes the stream without executing them.

The transcript is updated live: growStreamingTranscript as text arrives, and appendTranscriptEntry for tool activity with `.toolCalls` always appended before the `.toolOutput` entries of the same turn. Accumulated text resets per turn so the trailing transcript response entry tracks only the turn currently being generated, matching the Anthropic provider.

Resource management is unchanged in shape: the GPU memory scope and generation slot are still released through the idempotent finishScope/finishGenerationSlot pair guarded by didEndScope/didReleaseGenerationSlot, now covering the loop's normal exit, the `.stop` early return, the thrown-error path, and cancellation. The KV cache is resolved and stored per turn, as in respond.
@ActuallyTaylor
ActuallyTaylor changed the base branch from feature/openresponses-streaming-tool-calling to main August 3, 2026 20:44
@ActuallyTaylor
ActuallyTaylor force-pushed the feature/mlx-streaming-tool-calling branch from 8f4d598 to befc05d Compare August 3, 2026 20:44

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

Same style as the other prs, all looks good

@ActuallyTaylor
ActuallyTaylor merged commit 543ee7d into main Aug 3, 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