[Enhancement] Honor tool_choice (forced tool calls)#2189
Draft
michaelharrigan wants to merge 4 commits into
Draft
[Enhancement] Honor tool_choice (forced tool calls)#2189michaelharrigan wants to merge 4 commits into
tool_choice (forced tool calls)#2189michaelharrigan wants to merge 4 commits into
Conversation
The OpenAI chat-completions request accepts tool_choice but it was dropped on the way to generation, so the model always chose freely. Thread it through to TextGenerationTaskParams and, when tool_choice is "required" or names a function, prefill the assistant turn with the tool-call start marker (seeded with the function name for a named choice) so the model is forced to emit that call. The marker is inferred from the tokenizer's chat template, so this is no-op for templates without an inferable tool-call format.
The first cut prefilled a JSON tool-call opener inferred from the chat template, but models like Qwen use an XML <function=...> format, so the model stalled. Use the tokenizer's authoritative tool_call_start marker for the prefill, and seed parse_tool_calls to start inside the call (mirroring the existing detect_thinking_prompt_suffix path) since the forced marker lands in the prompt, not the output stream.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
ChatCompletionRequest.tool_choiceis accepted on the wire (api.py) but is never read on the way to generation, so"required"and{"type": "function", "function": {"name": ...}}are silently ignored.This means the model always chooses whether and which tool to call and in turn it makes tool use non-deterministic, which is a problem for structured extraction, agent reliability, and any integration test that needs a specific tool call to happen.
Changes
shared/types/text_generation.py: added atool_choice: str | dict[str, Any] | Nonefield toTextGenerationTaskParams.api/adapters/chat_completions.py: threadtool_choice=request.tool_choiceinto the task params (it was previously dropped).worker/engines/mlx/utils_mlx.py:_forced_tool_call_prefill(...): whentool_choiceis"required"or names a function and tools are present, return the model's owntokenizer.tool_call_startmarker.render_chat_template, use that as the assistant prefill (when there isn't already an explicit assistant prefill).detect_tool_call_prompt_suffix(prompt, tokenizer): a twin of the existingdetect_thinking_prompt_suffix— true when the prompt ends with the tool-call start marker.worker/runner/llm_inference/model_output_parsers.py:parse_tool_callsgainsstarts_in_tool_call(defaultFalse);apply_all_parsersseeds it fromdetect_tool_call_prompt_suffix, exactly like the thinking path seedsstarts_in_thinking.worker/tests/unittests/test_mlx/test_tool_choice_prefill.py: unit tests for the prefill + suffix detection.Why It Works
Forcing a tool call is just assistant prefilling, which the engine already supports: a trailing assistant message is stripped before templating and re-appended after (
render_chat_template).Prefilling the model's own
tool_call_startmarker forces it to continue in its native tool-call format (e.g. Qwen's<tool_call><function=...>XML, not a hardcoded JSON shape).The one subtlety: the forced marker lands in the prompt, so it is not present at the start of the output stream, and
parse_tool_callsdetects a call viaresponse.text.startswith(tool_call_start).The thinking path already solves this exact problem with
detect_thinking_prompt_suffix+starts_in_thinking; this change mirrors it (detect_tool_call_prompt_suffix+starts_in_tool_call), seeding the parser to begin inside the call and pre-loading the start marker so the parsed call is well formed.No new decoding machinery, no per-model format assumptions.
Test Plan
Manual Testing
Hardware: 2-node EXO ring (M3 Ultra Mac Studio (96GB) + Mac Mini M4 Pro (24GB)), pipeline parallelism, serving
mlx-community/Qwen3.5-9B-8bit(node wired limit ~77 GiB, group size 2).What I did:
"What is your name?"— with a singlebashtool andtool_choice: {"type":"function","function":{"name":"bash"}}.finish_reason: stop, empty content,tool_calls: null(the model declines the tool; a naive JSON prefill instead stalls after 1 token because the format doesn't match Qwen's XML).finish_reason: tool_callswith a real{"name": "bash", "arguments": "{\"command\": \"whoami\"}"}— the model was forced into the tool call it would never otherwise make.tool_choiceabsent /"auto"is unchanged (model chooses freely).Automated Testing
test_tool_choice_prefill.py(7 tests): named-function and"required"both return the start marker;"auto"/None/no-tools/no-marker are no-ops;detect_tool_call_prompt_suffixis true only when the prompt ends with the marker.uv run pytest src/exo/worker/tests/unittests/test_mlx/test_tool_choice_prefill.py