fix: tool_choice=required bypasses format-specific parsers (#6821)#7589
Conversation
|
👋 Hi AsadShahid04! Thank you for contributing to ai-dynamo/dynamo. Just a reminder: The 🚀 |
WalkthroughThis change fixes a bug where Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lib/llm/src/preprocessor.rs`:
- Around line 1116-1124: The parser-enabled branch for
ChatCompletionToolChoiceOption::Named currently calls
builder.tool_call_parser(parser) and drops the named.function.name enforcement,
allowing other tools; modify this path to keep marker-based parsing but enforce
the named tool by validating/filtering parsed calls against named.function.name
before emission — e.g., wrap or replace tool_call_parser with a small parser
wrapper that invokes the original parser and rejects or maps any parsed ToolCall
whose tool_name != named.function.name (or returns an error/empty choice), or
alternatively add a post-parse validation step that inspects the parsed
ToolCall.tool_name and only forwards/emit when it equals named.function.name;
ensure the change references ChatCompletionToolChoiceOption::Named,
tool_call_parser, builder.tool_call_parser, builder.tool_choice_named, and
named.function.name so the enforcement mirrors the fallback behavior.
In `@lib/llm/tests/test_jail.rs`:
- Around line 3064-3070: The test is constructing JailedStream directly so it
never exercises the tool_choice=required branch; change the test to call
OpenAIPreprocessor::apply_tool_calling_jail(...) with the parser and
Some(Required) (instead of using JailedStream::builder() directly) so the
Required decision path is executed and the same marker-based mode behavior is
validated; locate the test code that currently calls JailedStream::builder() and
replace that setup with a call to
OpenAIPreprocessor::apply_tool_calling_jail(parser, Some(Required), ...) and
assert the resulting jail state as before.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d81b1263-0a53-4caf-a242-e4ca9c1cd3aa
📒 Files selected for processing (2)
lib/llm/src/preprocessor.rslib/llm/tests/test_jail.rs
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission
05d5025 to
1ebeaa0
Compare
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Khalid (Dynamo Agent) <asadblue2015@gmail.com>
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Khalid (Dynamo Agent) <asadblue2015@gmail.com> Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
1ebeaa0 to
2beb950
Compare
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
a8e8141 to
a562034
Compare
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
43ffaf7 to
5aa27f1
Compare
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
390c206 to
9888ada
Compare
|
/ok to test 9888ada |
|
@AsadShahid04 can you fix the clippy errors? |
When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
66cdb98 to
eb3d79a
Compare
|
Rebased onto main and fixed CI (clippy lint + struct wrapper change). |
eb3d79a to
490a338
Compare
|
/ok to test 961d6c4 |
MatejKosec
left a comment
There was a problem hiding this comment.
Nice fix — the root cause analysis is spot-on and the approach (marker-based when parser is configured, immediate JSON as fallback) is clean. The named_tool_filter addition correctly preserves the tool_choice=named contract through the parser path.
One thing to address before merge: the markup leak when the filter drops all calls. A couple of nits inline as well.
rmccorm4
left a comment
There was a problem hiding this comment.
LGTM other than Matej's comments
1. MARKUP LEAK BUG (jail.rs, line ~886): When all parsed tool calls get filtered out, return empty string instead of accumulated_content to prevent unparsed XML markup leakage. Changed: accumulated_content, -> "", 2. VARIABLE NAMING (jail.rs, fn fix_finish_reason): Renamed named_tool_active to is_named_tool_choice throughout the function. The variable means 'request specified tool_choice=named', not 'tool was invoked'. 3. ADD TEST (test_jail.rs): Added test_tool_choice_named_with_qwen3_coder_parser to test the preprocessor decision logic with Some(ChatCompletionToolChoiceOption::Named(..)) and a parser. Exercises the named_tool_filter wiring. Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
961d6c4 to
cb6c142
Compare
|
Fix lint please. |
848a616 to
23d8683
Compare
…#6821) When tool_choice is 'required' or 'named', the jail builder was unconditionally set to Immediate JSON mode, which can only parse raw JSON output. This meant format-specific parsers like qwen3_coder (XML), pythonic, harmony, etc. were never invoked, causing raw markup to leak as output_text instead of being parsed into structured function_call items. Root cause: In apply_tool_calling_jail(), the match on tool_choice set tool_choice_required()/tool_choice_named() on the builder WITHOUT also setting the tool_call_parser. The Immediate jail mode only understands JSON ({...} or [{...}]), so any model that emits non-JSON tool call format (XML <tool_call>...</tool_call>, pythonic, DSML, etc.) was silently broken under tool_choice=required. Fix: When a tool_call_parser is configured, always use marker-based jail mode regardless of tool_choice value. This ensures the format-specific parser is invoked to detect and extract tool calls. Immediate JSON mode is preserved as a fallback for required/named when NO parser is configured (the model is expected to emit raw JSON in that case). Affected parsers: qwen3_coder, nemotron_nano, pythonic, harmony, dsml, glm47, kimi_k2, and any future non-JSON parser. Testing: - Added regression test: tool_choice_required_with_qwen3_coder_parser - Existing tool_choice tests for JSON-only (no parser) paths unchanged Fixes: ai-dynamo#6821 Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
…o#7589) When tool_choice=named and a tool_call_parser is configured, the previous code set builder.tool_call_parser(parser) but never called tool_choice_named(), so named-tool enforcement was silently dropped. Fix: - Add named_tool_name: Option<String> to JailedStream and JailedStreamBuilder - Add JailedStreamBuilder::named_tool_filter(tool_name) method - In preprocessor.rs Named+parser branch: call both .tool_call_parser(parser) and .named_tool_filter(named.function.name.clone()) - In create_tool_call_choice MarkerBased path: filter parsed ToolCalls against named_tool_name before emission; if all calls are for the wrong tool, return the accumulated content as a content choice instead Acceptance criteria verified: - tool_choice=named + parser uses marker-based parsing (unchanged) - Only the named tool is accepted; other tool names are rejected/filtered - tool_choice=auto and tool_choice=required paths are unchanged Tests added: - test_named_tool_with_parser_correct_tool_passes: hermes output for the correct tool passes through with the right name - test_named_tool_with_parser_wrong_tool_is_filtered: hermes output for a different tool is filtered out before emission Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
…6821) Address CodeRabbit review: test now calls through the preprocessor decision path instead of constructing JailedStream directly, ensuring the test catches regressions in the tool_choice=required + parser logic. Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
…ply_jail_named_with_parser The CodeRabbit fix commit (92cc877) accidentally duplicated the `out.filter_map(...).collect().await` expression before the `let out` binding, causing a syntax error (expected `;`, found keyword `let` at line 524). Remove the stray 3-line block so the function compiles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Asad Shahid <asad.shahid@berkeley.edu>
…Based path When named_tool_name is set (tool_choice=named + parser), the finish_reason should stay as Stop per OpenAI spec, matching the behavior of the Immediate/SingleObject path.
Signed-off-by: Vasilis Vagias <vvagias@nvidia.com>
e0ed991 to
ba0a851
Compare
@ishandhanani @rmccorm4 Hi, I also encountered the same issue, and this PR seems not to filter function tools when tool_choice is "named“ function. I referred to sglang implementation (https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/entrypoints/openai/serving_chat.py#L345). And I make a draft PR, maybe merge together #7808 |
|
/ok to test 976a3f6 |
|
Hi, I noticed this PR. Since I wrtied tool_choice support in december (#4722), I'm wondering what problem we're actually facing. In fact, what immediate JSON mode is using for all models is the intended behavior. Since it seems this is the only way to guarantee a tool call (except for structural tag for xgrammar). I didn't really understand how, after this PR, the tool call guarantee assumed for required/named modes is achieved. |
Hey Vlad, good to have the perspective of someone who built the original tool_choice support. You're right that true enforcement needs guided decoding wired through to the engine, which is what your PR #6620 was working toward. Without it, the JSON mode before this PR doesn't actually guarantee a tool call since it is post-generation parsing — the model still generates freely, the jail just tries to interpret the output as JSON from token 1. There's no grammar constraint forcing the model to produce valid JSON. So the "guarantee" was already best-effort, while breaking 100% of the time for models that don't emit raw JSON (qwen3_coder, pythonic, harmony, etc.). The other thing to consider is reasoning models, and how they have evolved over the past 6 to 12 months. Qwen3, DeepSeek-R1, GLM-5 all emit thinking tokens before their tool calls. Even a JSON-format model produces something like: The immediate JSON mode starts parsing from token 1 — it sees and fails because < isn't valid JSON. The actual tool call JSON is there, but the thinking prefix kills it. For non-JSON models like qwen3_coder the tool call itself is XML: The fix keeps Immediate JSON as the fallback when no parser is configured (simple JSON-only models that |
Problem
When
tool_choiceis set to"required"or a named function in a/v1/chat/completionsor/v1/responsesrequest, models that use non-JSON tool call formats (qwen3_coder XML, pythonic, harmony, DSML, etc.) have their tool calls silently ignored. The raw markup leaks intooutput_textinstead of being parsed into structuredfunction_callitems. This is a 100% failure rate for affected parsers undertool_choice=required.tool_choice: "auto"works correctly because it follows a different code path that sets thetool_call_parser.Root Cause
In
lib/llm/src/preprocessor.rs::apply_tool_calling_jail(), the match ontool_choiceunconditionally activates Immediate JSON jail mode viatool_choice_required()/tool_choice_named()on the builder, WITHOUT also setting thetool_call_parser. The Immediate mode only understands raw JSON ({...}or[{...}]), so any model emitting a non-JSON format (XML<tool_call>...</tool_call>, pythonic, etc.) has its output silently dropped.Fix
When a
tool_call_parseris configured, always use marker-based jail mode regardless of thetool_choicevalue. This ensures the format-specific parser is invoked to detect and extract tool calls correctly. Immediate JSON mode is preserved as a fallback forrequired/namedwhen no parser is configured (the model is expected to emit raw JSON in that case).Files changed:
lib/llm/src/preprocessor.rs— Prioritize marker-based parser over Immediate JSON mode when a parser is configuredlib/llm/tests/test_jail.rs— Added regression testtest_tool_choice_required_with_qwen3_coder_parserAffected Parsers
qwen3_coder, nemotron_nano, pythonic, harmony, dsml, glm47, kimi_k2, and any future non-JSON parser.
Testing
tool_choice=requiredwith qwen3_coder parsertool_choicetests for JSON-only (no parser) paths pass unchangedtool_choice=autobehavior is unaffectedFixes #6821
Summary by CodeRabbit
New Features
Bug Fixes
Tests