From c09b3fe687382ae90b46d8b733a4f5f46c81f18a Mon Sep 17 00:00:00 2001 From: Robert Kirby Date: Mon, 14 Sep 2026 11:00:00 -0500 Subject: [PATCH] [Inference] Make the tool-call implicit reasoning end opt-in #6411 made qwen3-coder-tool declare an implicit end of reasoning, so a tool call emitted before is split out of the reasoning text and parsed. That matches vLLM's combined qwen3 parser but not its standalone reasoning parsers paired with the qwen3_coder tool parser, which leave such a tool call in reasoning_content. Serving the same model on both engines with a separate reasoning parser now yields different tool calls. Keep qwen3-coder-tool strict and add qwen3-coder-tool-combined carrying the marker, so the behaviour is chosen in the parser list. Streaming markers are unchanged on both. Signed-off-by: Robert Kirby --- .../core/tokenizers/text/parsers/__init__.py | 6 +++- .../text/parsers/qwen3_coder_tool_parser.py | 13 +++++++- megatron/training/arguments.py | 4 ++- .../tokenizers/test_text_parsers.py | 31 +++++++++++++++++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/megatron/core/tokenizers/text/parsers/__init__.py b/megatron/core/tokenizers/text/parsers/__init__.py index d541cb4a74d..8caac50c94d 100644 --- a/megatron/core/tokenizers/text/parsers/__init__.py +++ b/megatron/core/tokenizers/text/parsers/__init__.py @@ -5,11 +5,15 @@ from megatron.core.tokenizers.text.parsers.nemotron_v3_reasoning_parser import ( NemotronV3ReasoningParser, ) -from megatron.core.tokenizers.text.parsers.qwen3_coder_tool_parser import Qwen3CoderToolParser +from megatron.core.tokenizers.text.parsers.qwen3_coder_tool_parser import ( + Qwen3CoderToolCombinedParser, + Qwen3CoderToolParser, +) PARSER_MAPPING = { "deepseek-r1-reasoning": DeepSeekR1ReasoningParser, "qwen3-coder-tool": Qwen3CoderToolParser, + "qwen3-coder-tool-combined": Qwen3CoderToolCombinedParser, "nemotron-v3-reasoning": NemotronV3ReasoningParser, } diff --git a/megatron/core/tokenizers/text/parsers/qwen3_coder_tool_parser.py b/megatron/core/tokenizers/text/parsers/qwen3_coder_tool_parser.py index 328d394f8c6..fd37b5d6e09 100644 --- a/megatron/core/tokenizers/text/parsers/qwen3_coder_tool_parser.py +++ b/megatron/core/tokenizers/text/parsers/qwen3_coder_tool_parser.py @@ -266,7 +266,6 @@ def extract_tool_calls( class Qwen3CoderToolParser(BaseParser): """Parser for Qwen3 Coder style tool calls.""" - implicit_reasoning_end_markers = ("",) streaming_markers = ("", " tuple[str, dict[str, list[dict]]]: return information.get("content", ""), {"tool_calls": information.get("tool_calls", [])} else: return text, {} + + +class Qwen3CoderToolCombinedParser(Qwen3CoderToolParser): + """Qwen3 Coder tool parser where also ends an open reasoning block. + + `qwen3-coder-tool` leaves a tool call emitted before `` inside the + reasoning text, like vLLM's standalone reasoning parsers paired with the + qwen3_coder tool parser. This variant treats `` as an implicit + end of reasoning and parses it, like vLLM's combined qwen3 parser. + """ + + implicit_reasoning_end_markers = ("",) diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index e1f958ccc9e..dcd29b7b435 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -3009,7 +3009,9 @@ def _add_rl_args(parser): help='Directory to write RL profiling data. Defaults to {save}/profiles.') group.add_argument('--rl-inference-parsers', nargs='*', default=[], help='List of response parsers to enable for RL inference ' - '(e.g. --rl-inference-parsers deepseek-r1-reasoning qwen3-coder-tool).') + '(e.g. --rl-inference-parsers deepseek-r1-reasoning qwen3-coder-tool). ' + 'qwen3-coder-tool-combined additionally treats as the end of ' + 'an unterminated reasoning block, like vLLM\'s combined qwen3 parser.') return parser def _add_training_args(parser): diff --git a/tests/unit_tests/tokenizers/test_text_parsers.py b/tests/unit_tests/tokenizers/test_text_parsers.py index b5ab1b1dee7..d4e6445de69 100644 --- a/tests/unit_tests/tokenizers/test_text_parsers.py +++ b/tests/unit_tests/tokenizers/test_text_parsers.py @@ -98,12 +98,12 @@ def test_parser_mapping_registers_nemotron_v3_reasoning(): assert PARSER_MAPPING["nemotron-v3-reasoning"] is NemotronV3ReasoningParser -def test_tool_call_marker_implicitly_ends_reasoning_for_downstream_parser(): +def test_tool_call_marker_implicitly_ends_reasoning_for_combined_parser(): tool_text = ( "echo hi" "" ) model_output = f"I should inspect this first.\n{tool_text}" - tool_parser = PARSER_MAPPING["qwen3-coder-tool"] + tool_parser = PARSER_MAPPING["qwen3-coder-tool-combined"] content, reasoning_info = DeepSeekR1ReasoningParser.parse( model_output, implicit_reasoning_end_markers=tool_parser.implicit_reasoning_end_markers @@ -133,3 +133,30 @@ def test_tool_call_marker_does_not_end_reasoning_unless_configured(): model_output = "reasoningnot enabled" assert DeepSeekR1ReasoningParser.parse(model_output) == ("", {"reasoning": model_output}) + + +def test_strict_tool_parser_keeps_unterminated_reasoning_intact(): + tool_text = ( + "echo hi" "" + ) + model_output = f"I should inspect this first.\n{tool_text}" + tool_parser = PARSER_MAPPING["qwen3-coder-tool"] + + assert getattr(tool_parser, "implicit_reasoning_end_markers", ()) == () + + content, reasoning_info = DeepSeekR1ReasoningParser.parse( + model_output, + implicit_reasoning_end_markers=getattr(tool_parser, "implicit_reasoning_end_markers", ()), + ) + + assert content == "" + assert reasoning_info == {"reasoning": model_output} + + +def test_parser_mapping_registers_both_qwen3_coder_tool_parsers(): + strict = PARSER_MAPPING["qwen3-coder-tool"] + combined = PARSER_MAPPING["qwen3-coder-tool-combined"] + + assert issubclass(combined, strict) + assert combined.implicit_reasoning_end_markers == ("",) + assert combined.streaming_markers == strict.streaming_markers