Skip to content

fix: tool_choice=required bypasses format-specific parsers (#6821)#7589

Merged
dagil-nvidia merged 8 commits intoai-dynamo:mainfrom
AsadShahid04:fix/tool-choice-required-parser-6821
Apr 2, 2026
Merged

fix: tool_choice=required bypasses format-specific parsers (#6821)#7589
dagil-nvidia merged 8 commits intoai-dynamo:mainfrom
AsadShahid04:fix/tool-choice-required-parser-6821

Conversation

@AsadShahid04
Copy link
Copy Markdown
Contributor

@AsadShahid04 AsadShahid04 commented Mar 23, 2026

Problem

When tool_choice is set to "required" or a named function in a /v1/chat/completions or /v1/responses request, 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 into output_text instead of being parsed into structured function_call items. This is a 100% failure rate for affected parsers under tool_choice=required.

tool_choice: "auto" works correctly because it follows a different code path that sets the tool_call_parser.

Root Cause

In lib/llm/src/preprocessor.rs::apply_tool_calling_jail(), the match on tool_choice unconditionally activates Immediate JSON jail mode via tool_choice_required() / tool_choice_named() on the builder, WITHOUT also setting the tool_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_parser is configured, always use marker-based jail mode regardless of the tool_choice value. This ensures the format-specific parser is invoked to detect and extract tool calls correctly. 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).

Files changed:

  • lib/llm/src/preprocessor.rs — Prioritize marker-based parser over Immediate JSON mode when a parser is configured
  • lib/llm/tests/test_jail.rs — Added regression test test_tool_choice_required_with_qwen3_coder_parser

Affected Parsers

qwen3_coder, nemotron_nano, pythonic, harmony, dsml, glm47, kimi_k2, and any future non-JSON parser.

Testing

  • Added regression test covering tool_choice=required with qwen3_coder parser
  • Existing tool_choice tests for JSON-only (no parser) paths pass unchanged
  • Manually verified that tool_choice=auto behavior is unaffected

Fixes #6821

Summary by CodeRabbit

  • New Features

    • Tool calling now supports format-specific parsers (e.g., XML-based parsers) alongside JSON-based parsing.
  • Bug Fixes

    • Fixed tool-calling configuration to conditionally apply appropriate parsing modes based on parser availability.
  • Tests

    • Added regression test coverage for XML-based tool parsing with required tool-choice behavior.

@AsadShahid04 AsadShahid04 requested a review from a team as a code owner March 23, 2026 23:57
@copy-pr-bot
Copy link
Copy Markdown

copy-pr-bot bot commented Mar 23, 2026

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions
Copy link
Copy Markdown
Contributor

👋 Hi AsadShahid04! Thank you for contributing to ai-dynamo/dynamo.

Just a reminder: The NVIDIA Test Github Validation CI runs an essential subset of the testing framework to quickly catch errors.Your PR reviewers may elect to test the changes comprehensively before approving your changes.

🚀

@github-actions github-actions bot added fix external-contribution Pull request is from an external contributor frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` labels Mar 23, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 24, 2026

Walkthrough

This change fixes a bug where tool_choice: "required" was not parsing tool calls correctly. The preprocessor now conditionally applies marker-based parsing when a tool_call_parser is configured, instead of always using Immediate JSON modes for Named and Required tool choices, restoring proper XML tool call extraction for formats like qwen3_coder.

Changes

Cohort / File(s) Summary
Tool-calling jail configuration fix
lib/llm/src/preprocessor.rs
Updated apply_tool_calling_jail to conditionally select jail mode based on tool_call_parser presence. When a parser is configured, Named and Required tool choices now use marker-based parsing via builder.tool_call_parser(parser) instead of always defaulting to Immediate JSON jail modes.
Regression test for qwen3_coder parser
lib/llm/tests/test_jail.rs
Added new async test test_tool_choice_required_with_qwen3_coder_parser that validates tool_choice: "required" correctly parses XML tool calls using qwen3_coder parser, ensuring parsed tool calls appear in output and raw XML markers do not leak into content.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main issue: tool_choice=required bypasses format-specific parsers, which matches the core problem addressed in the changeset.
Description check ✅ Passed The description comprehensively covers the problem, root cause, fix, affected files, parsers, and testing approach, closely following the template structure with all required sections filled.
Linked Issues check ✅ Passed The code changes fully address the requirements in #6821: tool calls from non-JSON format parsers are now correctly extracted when tool_choice is required, and a regression test validates the qwen3_coder parser behavior.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the tool_choice=required parser bypass issue: the preprocessor logic targets the root cause, and the test directly validates the fix with qwen3_coder.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between aaa8a56 and 0cfba35.

📒 Files selected for processing (2)
  • lib/llm/src/preprocessor.rs
  • lib/llm/tests/test_jail.rs

@ishandhanani ishandhanani requested a review from MatejKosec March 24, 2026 11:55
AsadShahid04 added a commit to AsadShahid04/dynamo that referenced this pull request Mar 24, 2026
…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
@AsadShahid04 AsadShahid04 force-pushed the fix/tool-choice-required-parser-6821 branch from 05d5025 to 1ebeaa0 Compare March 25, 2026 03:43
AsadShahid04 added a commit to AsadShahid04/dynamo that referenced this pull request Mar 25, 2026
…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>
AsadShahid04 added a commit to AsadShahid04/dynamo that referenced this pull request Mar 25, 2026
…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>
@AsadShahid04 AsadShahid04 force-pushed the fix/tool-choice-required-parser-6821 branch from 1ebeaa0 to 2beb950 Compare March 25, 2026 03:43
AsadShahid04 added a commit to AsadShahid04/dynamo that referenced this pull request Mar 25, 2026
…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>
@AsadShahid04 AsadShahid04 force-pushed the fix/tool-choice-required-parser-6821 branch 2 times, most recently from a8e8141 to a562034 Compare March 25, 2026 05:13
AsadShahid04 added a commit to AsadShahid04/dynamo that referenced this pull request Mar 25, 2026
…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>
@AsadShahid04 AsadShahid04 force-pushed the fix/tool-choice-required-parser-6821 branch 2 times, most recently from 43ffaf7 to 5aa27f1 Compare March 25, 2026 05:15
AsadShahid04 added a commit to AsadShahid04/dynamo that referenced this pull request Mar 25, 2026
…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>
@AsadShahid04 AsadShahid04 force-pushed the fix/tool-choice-required-parser-6821 branch 2 times, most recently from 390c206 to 9888ada Compare March 25, 2026 20:06
@dagil-nvidia
Copy link
Copy Markdown
Collaborator

/ok to test 9888ada

@grahamking
Copy link
Copy Markdown
Contributor

@AsadShahid04 can you fix the clippy errors? rust-clippy check.

vvagias pushed a commit that referenced this pull request Mar 31, 2026
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>
@vvagias vvagias force-pushed the fix/tool-choice-required-parser-6821 branch from 66cdb98 to eb3d79a Compare March 31, 2026 20:35
@vvagias
Copy link
Copy Markdown
Contributor

vvagias commented Mar 31, 2026

Rebased onto main and fixed CI (clippy lint + struct wrapper change).

@vvagias vvagias force-pushed the fix/tool-choice-required-parser-6821 branch from eb3d79a to 490a338 Compare March 31, 2026 21:21
@rmccorm4
Copy link
Copy Markdown
Contributor

/ok to test 961d6c4

Copy link
Copy Markdown
Contributor

@MatejKosec MatejKosec left a comment

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

@rmccorm4 rmccorm4 left a comment

Choose a reason for hiding this comment

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

LGTM other than Matej's comments

AsadShahid04 pushed a commit to AsadShahid04/dynamo that referenced this pull request Apr 1, 2026
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>
@AsadShahid04 AsadShahid04 force-pushed the fix/tool-choice-required-parser-6821 branch from 961d6c4 to cb6c142 Compare April 1, 2026 08:14
@ishandhanani
Copy link
Copy Markdown
Contributor

Fix lint please.

@vvagias vvagias force-pushed the fix/tool-choice-required-parser-6821 branch from 848a616 to 23d8683 Compare April 1, 2026 17:15
AsadShahid04 and others added 6 commits April 1, 2026 14:57
…#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>
@vvagias vvagias force-pushed the fix/tool-choice-required-parser-6821 branch from e0ed991 to ba0a851 Compare April 1, 2026 20:45
@huitianbai
Copy link
Copy Markdown
Contributor

huitianbai commented Apr 2, 2026

Fix lint please.

@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 dynamo's logic: https://github.com/ai-dynamo/dynamo/blob/main/lib/llm/src/preprocessor/prompt/template/oai.rs#L425, it only emits all when tool choice is none. This will affect formatted prompt when tool choice is named function.

And I make a draft PR, maybe merge together #7808

@dagil-nvidia
Copy link
Copy Markdown
Collaborator

/ok to test 976a3f6

@dagil-nvidia dagil-nvidia enabled auto-merge (squash) April 2, 2026 13:09
@dagil-nvidia dagil-nvidia merged commit 07af8dc into ai-dynamo:main Apr 2, 2026
94 checks passed
@vladnosiv
Copy link
Copy Markdown
Contributor

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).
This is achieved by passing through the guided decoding JSON schema generated for the tools. If the model does not generate JSON in this case, it means that there was no real forcing of the JSON schema.
For example, SGLang + Dynamo still doesn't seem to forcing json schema, here's a relevant PR from us: #6620

I didn't really understand how, after this PR, the tool call guarantee assumed for required/named modes is achieved.

@MatejKosec
Copy link
Copy Markdown
Contributor

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). This is achieved by passing through the guided decoding JSON schema generated for the tools. If the model does not generate JSON in this case, it means that there was no real forcing of the JSON schema. For example, SGLang + Dynamo still doesn't seem to forcing json schema, here's a relevant PR from us: #6620

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:

  <think>The user wants weather in Paris, I should call get_weather.</think>
  [{"name": "get_weather", "arguments": {"location": "Paris"}}]

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:

  <think>...</think>
  <tool_call>{"name": "get_weather", "arguments": {"location": "Paris"}}</tool_call>

The fix keeps Immediate JSON as the fallback when no parser is configured (simple JSON-only models that
don't think), and uses marker-based parsing when a parser is set. Both paths were already best-effort —
the difference is the marker-based path works across formats and improves compatibility with latest reasoning models.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contribution Pull request is from an external contributor fix frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tool_choice: required causes tool call to always leak as raw XML (never parsed)

9 participants