From ba10ae92563ed35f806a79e7f4f12f56ec242515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Tue, 15 Sep 2026 19:40:04 +0300 Subject: [PATCH 1/2] [#19227][fix] Complete zero-argument tool calls in Qwen3-Coder streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qwen3CoderToolParser.parse_streaming_increment only appends the closing brace when some argument text was already streamed, so a call that carries no block ends with arguments="" while detect_and_parse returns "{}" for the same text. Stream "{}" on completion when nothing was streamed, as Glm47ToolParser does. Signed-off-by: Yiğit ERDOĞAN --- .../serve/tool_parser/qwen3_coder_parser.py | 32 ++++++++++--------- .../unittest/llmapi/apps/test_tool_parsers.py | 29 +++++++++++++++++ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py b/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py index 97447695ac86..862739028506 100644 --- a/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py +++ b/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py @@ -153,23 +153,25 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami if self.tool_call_end_token in self._buf: end_pos = self._buf.find(self.tool_call_end_token) - # Add closing brace to complete the JSON object + # The streamed arguments always end as a complete JSON object. current_streamed = self.streamed_args_for_tool[self.current_tool_id] - if current_streamed: - # Count opening and closing braces to check if JSON is complete - open_braces = current_streamed.count("{") - close_braces = current_streamed.count("}") - if open_braces > close_braces: - calls.append( - ToolCallItem( - tool_index=self.current_tool_id, - name=None, - parameters="}", - ) - ) - self.streamed_args_for_tool[self.current_tool_id] = ( - current_streamed + "}" + if not current_streamed: + closing = "{}" + elif current_streamed.count("{") > current_streamed.count("}"): + closing = "}" + else: + closing = "" + if closing: + calls.append( + ToolCallItem( + tool_index=self.current_tool_id, + name=None, + parameters=closing, ) + ) + self.streamed_args_for_tool[self.current_tool_id] = ( + current_streamed + closing + ) # Complete the tool call self._buf = self._buf[end_pos + len(self.tool_call_end_token) :] diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index ccc82e9adc7e..a15ebf9cf1d5 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -1226,6 +1226,35 @@ def test_parse_streaming_increment_end_token_handling( assert parser._buf == "" assert parser._in_tool_call is False + def test_streaming_zero_arg_tool(self, parser): + """A call without parameter blocks streams "{}" as its arguments.""" + tools = [ + ChatCompletionToolsParam( + type="function", + function=FunctionDefinition( + name="get_time", + description="Get current time", + parameters={ + "type": "object", + "properties": {}, + }, + ), + ) + ] + text = ("\n" + "\n" + "\n" + "") + + result = parser.parse_streaming_increment(text, tools) + + names = [c.name for c in result.calls if c.name] + assert names == ["get_time"] + params = "".join(c.parameters for c in result.calls) + assert params == "{}", f"Expected '{{}}', got {params!r}" + assert params == self.make_parser().detect_and_parse( + text, tools).calls[0].parameters + def test_parse_streaming_increment_multiple_tools_streaming( self, sample_tools, parser): """Test streaming parser handles multiple tool calls.""" From c3b9454ecabd2705e436928ecd5c19e4afae508c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Tue, 15 Sep 2026 19:57:03 +0300 Subject: [PATCH 2/2] [#19227][fix] Append the closing brace unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamed argument fragments never carry the outer closing brace, so the completion step appends it whenever something was streamed instead of counting braces, which miscounts a "}" inside a string value. Signed-off-by: Yiğit ERDOĞAN --- .../serve/tool_parser/qwen3_coder_parser.py | 27 +++++------ .../unittest/llmapi/apps/test_tool_parsers.py | 45 +++++++++++++++---- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py b/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py index 862739028506..d655994542a4 100644 --- a/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py +++ b/tensorrt_llm/serve/tool_parser/qwen3_coder_parser.py @@ -153,25 +153,18 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami if self.tool_call_end_token in self._buf: end_pos = self._buf.find(self.tool_call_end_token) - # The streamed arguments always end as a complete JSON object. + # Streamed fragments never carry the outer closing brace, so the + # arguments are completed here: "{}" when nothing was streamed. current_streamed = self.streamed_args_for_tool[self.current_tool_id] - if not current_streamed: - closing = "{}" - elif current_streamed.count("{") > current_streamed.count("}"): - closing = "}" - else: - closing = "" - if closing: - calls.append( - ToolCallItem( - tool_index=self.current_tool_id, - name=None, - parameters=closing, - ) - ) - self.streamed_args_for_tool[self.current_tool_id] = ( - current_streamed + closing + closing = "}" if current_streamed else "{}" + calls.append( + ToolCallItem( + tool_index=self.current_tool_id, + name=None, + parameters=closing, ) + ) + self.streamed_args_for_tool[self.current_tool_id] = current_streamed + closing # Complete the tool call self._buf = self._buf[end_pos + len(self.tool_call_end_token) :] diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index a15ebf9cf1d5..f42c4c2fb80e 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -1226,7 +1226,9 @@ def test_parse_streaming_increment_end_token_handling( assert parser._buf == "" assert parser._in_tool_call is False - def test_streaming_zero_arg_tool(self, parser): + @pytest.mark.parametrize("chunked", [False, True], + ids=["one_delta", "chunked"]) + def test_streaming_zero_arg_tool(self, parser, chunked): """A call without parameter blocks streams "{}" as its arguments.""" tools = [ ChatCompletionToolsParam( @@ -1241,19 +1243,44 @@ def test_streaming_zero_arg_tool(self, parser): ), ) ] - text = ("\n" - "\n" - "\n" - "") + deltas = [ + "\n", "\n", "\n", + "" + ] + if not chunked: + deltas = ["".join(deltas)] - result = parser.parse_streaming_increment(text, tools) + calls = [ + call for delta in deltas + for call in parser.parse_streaming_increment(delta, tools).calls + ] - names = [c.name for c in result.calls if c.name] + names = [c.name for c in calls if c.name] assert names == ["get_time"] - params = "".join(c.parameters for c in result.calls) + params = "".join(c.parameters for c in calls) assert params == "{}", f"Expected '{{}}', got {params!r}" assert params == self.make_parser().detect_and_parse( - text, tools).calls[0].parameters + "".join(deltas), tools).calls[0].parameters + + def test_streaming_closes_arguments_with_brace_in_value( + self, sample_tools, parser): + """The closing brace is appended regardless of the streamed text.""" + deltas = [ + "\n\n", + "a}b\n", + "\n", + ] + + calls = [ + call + for delta in deltas for call in parser.parse_streaming_increment( + delta, sample_tools).calls + ] + + params = "".join(c.parameters for c in calls) + assert json.loads(params) == {"location": "a}b"} + assert params == self.make_parser().detect_and_parse( + "".join(deltas), sample_tools).calls[0].parameters def test_parse_streaming_increment_multiple_tools_streaming( self, sample_tools, parser):