From fd1a8cb685f25ff0aeab41c1b64ac52dae543334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Wed, 12 Aug 2026 22:16:15 +0300 Subject: [PATCH 1/3] [#17574][fix] Complete zero-argument tool calls in the streaming tool parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseToolParser.parse_streaming_increment gated the argument-streaming and call-completion branch on the truthiness of the parsed argument object. An empty object is falsy, so a tool call with no arguments never emitted its arguments and was never consumed from the buffer: the client was left with arguments="", which is not valid JSON, and has_tool_call stayed true for the rest of the request, so every later chunk was routed back into the tool-call branch instead of being emitted as content. Gate on "is not None" instead. The partial path is unaffected because the elif prev_arguments branch keeps its own falsy guard, so no premature "{}" is emitted while the JSON is still incomplete. Qwen3ToolParser is the parser that reaches this code, through _wrapped_streaming. Glm4ToolParser and Glm47ToolParser already assert the expected behaviour for their own streaming paths, so the new test mirrors theirs. Signed-off-by: Yiğit ERDOĞAN --- .../serve/tool_parser/base_tool_parser.py | 5 ++- .../unittest/llmapi/apps/test_tool_parsers.py | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py index 2d70d2fa572f..05dc6b698c9e 100644 --- a/tensorrt_llm/serve/tool_parser/base_tool_parser.py +++ b/tensorrt_llm/serve/tool_parser/base_tool_parser.py @@ -239,7 +239,10 @@ def parse_streaming_increment(self, new_text: str, cur_arguments = current_tool_call.get("arguments") res = StreamingParseResult() - if cur_arguments: + # An empty argument object is falsy but still has to be streamed and + # completed, otherwise a zero-argument tool call never finishes and its + # text stays in the buffer forever. + if cur_arguments is not None: # Calculate how much of the arguments we've already streamed sent = len( self.streamed_args_for_tool[self.current_tool_id]) diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index 710f6dd7b2b8..b4bfd9d79e20 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -1035,6 +1035,46 @@ def test_streaming_wrapped_form_unregressed(self, sample_tools, parser): assert len(r_args.calls) == 1 assert json.loads(r_args.calls[0].parameters) == {"location": "SF"} + def test_streaming_zero_arg_tool(self, parser): + """Test streaming a zero-argument tool call.""" + tools = [ + ChatCompletionToolsParam( + type="function", + function=FunctionDefinition( + name="get_time", + description="Get current time", + parameters={ + "type": "object", + "properties": {}, + }, + ), + ) + ] + chunks = [ + "\n", + '{"name": "get_time"', + ', "arguments": {}}', + "\n", + ] + + results = [ + parser.parse_streaming_increment(chunk, tools) for chunk in chunks + ] + + names = [c.name for r in results for c in r.calls if c.name] + assert "get_time" in names + + # An empty argument object still has to be streamed, otherwise the client + # is left with arguments="", which is not valid JSON. + params = "".join(c.parameters for r in results for c in r.calls) + assert params == "{}", f"Expected '{{}}', got {params!r}" + + # The completed call must also be consumed from the buffer, otherwise the + # parser stays in the tool-call branch and swallows the rest of the output. + assert parser.detect_and_parse("".join(chunks), + tools).calls[0].parameters == "{}" + assert "" not in parser._buffer + class TestQwen3CoderToolParser(BaseToolParserTestClass): """Test suite for Qwen3CoderToolParser class.""" From c91f613e03ad32a0e93de2488097c471c8483c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Sat, 15 Aug 2026 11:22:26 +0300 Subject: [PATCH 2/3] [#17574][fix] Complete zero-argument calls that omit or null the arguments key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. Gating on "is not None" fixed the empty-object shape but left two equivalent ones dead-ending in exactly the same way: a model can express "no arguments" by omitting the key entirely, or by emitting an explicit null. Both make current_tool_call.get("arguments") return None, so the completion branch is skipped, the buffer never advances past the call, and _buffer only grows for the rest of the request. Normalize a missing or null arguments object to {} once the call's JSON is complete. Restricting it to is_current_complete keeps the partial path intact: while the JSON is still incomplete a missing key only means "not streamed yet", so it must not be mistaken for an empty object and flushed early. This matches parse_base_json, which already resolves a missing key to {} on the non-streaming path. It still dumps an explicit null as "null" there; the new parametrization asserts that so the divergence stays visible. Signed-off-by: Yiğit ERDOĞAN --- .../serve/tool_parser/base_tool_parser.py | 9 ++++++ .../unittest/llmapi/apps/test_tool_parsers.py | 31 ++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py index 05dc6b698c9e..9dad74b2b2e8 100644 --- a/tensorrt_llm/serve/tool_parser/base_tool_parser.py +++ b/tensorrt_llm/serve/tool_parser/base_tool_parser.py @@ -239,6 +239,15 @@ def parse_streaming_increment(self, new_text: str, cur_arguments = current_tool_call.get("arguments") res = StreamingParseResult() + # A finished call may carry no "arguments" key at all, or an + # explicit null. Both mean "no arguments" and are normalized to + # {} so the call still completes, matching what parse_base_json + # returns on the non-streaming path. While the JSON is still + # partial a missing key only means "not streamed yet", so it is + # left alone and the elif prev_arguments branch keeps handling it. + if is_current_complete and cur_arguments is None: + cur_arguments = {} + # An empty argument object is falsy but still has to be streamed and # completed, otherwise a zero-argument tool call never finishes and its # text stays in the buffer forever. diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index b4bfd9d79e20..6bde8f983f31 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -1035,8 +1035,25 @@ def test_streaming_wrapped_form_unregressed(self, sample_tools, parser): assert len(r_args.calls) == 1 assert json.loads(r_args.calls[0].parameters) == {"location": "SF"} - def test_streaming_zero_arg_tool(self, parser): - """Test streaming a zero-argument tool call.""" + @pytest.mark.parametrize( + "arguments_chunk,oneshot_params", + [ + (', "arguments": {}}', "{}"), + ("}", "{}"), + # detect_and_parse dumps an explicit null as-is; only the streaming + # path normalizes it. Asserted here so the divergence stays visible. + (', "arguments": null}', "null"), + ], + ids=["empty_object", "key_absent", "explicit_null"], + ) + def test_streaming_zero_arg_tool(self, parser, arguments_chunk, + oneshot_params): + """Test streaming a zero-argument tool call. + + A model can express "no arguments" as an empty object, by omitting the + key, or as an explicit null. All three have to complete the call and + stream "{}". + """ tools = [ ChatCompletionToolsParam( type="function", @@ -1053,7 +1070,7 @@ def test_streaming_zero_arg_tool(self, parser): chunks = [ "\n", '{"name": "get_time"', - ', "arguments": {}}', + arguments_chunk, "\n", ] @@ -1064,15 +1081,15 @@ def test_streaming_zero_arg_tool(self, parser): names = [c.name for r in results for c in r.calls if c.name] assert "get_time" in names - # An empty argument object still has to be streamed, otherwise the client - # is left with arguments="", which is not valid JSON. + # A zero-argument call still has to stream its arguments, otherwise the + # client is left with arguments="", which is not valid JSON. params = "".join(c.parameters for r in results for c in r.calls) assert params == "{}", f"Expected '{{}}', got {params!r}" # The completed call must also be consumed from the buffer, otherwise the # parser stays in the tool-call branch and swallows the rest of the output. - assert parser.detect_and_parse("".join(chunks), - tools).calls[0].parameters == "{}" + oneshot = parser.detect_and_parse("".join(chunks), tools) + assert oneshot.calls[0].parameters == oneshot_params assert "" not in parser._buffer From 5d3e4f56fe07a355ff9dc4f2ac42fdaf0f97b948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Mon, 17 Aug 2026 22:12:40 +0300 Subject: [PATCH 3/3] [#17574][fix] Normalize an explicit null arguments value to {} in parse_base_json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit act.get("arguments", {}) only defaults when the key is absent, so a present-but-null value was dumped as "null" while the streaming path completes the same call with "{}". Both paths now agree on {}. `or {}` normalizes every falsy value, not just None: "arguments" of [], "" or 0 become {} where they used to be dumped as-is. All are malformed for a tool call, and act.get("parameters") or ... on the left of the same expression already has that property, so it is not a new hazard. parse_base_json is shared by Qwen3, GLM4, GLM4.7 and DeepSeek V3/V3.1/V3.2; none of their tests assert "null" for this shape. Signed-off-by: Yiğit ERDOĞAN --- .../serve/tool_parser/base_tool_parser.py | 2 +- .../unittest/llmapi/apps/test_tool_parsers.py | 29 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py index 9dad74b2b2e8..8cbea0be7893 100644 --- a/tensorrt_llm/serve/tool_parser/base_tool_parser.py +++ b/tensorrt_llm/serve/tool_parser/base_tool_parser.py @@ -85,7 +85,7 @@ def parse_base_json(self, action: Any, -1, # Caller should update this based on the actual tools array called name=name, parameters=json.dumps( - act.get("parameters") or act.get("arguments", {}), + act.get("parameters") or act.get("arguments") or {}, ensure_ascii=False, ), )) diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py index 6bde8f983f31..0651bfc7f665 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -225,6 +225,16 @@ def test_parse_base_json_missing_parameters(self, sample_tools): assert len(results) == 1 assert json.loads(results[0].parameters) == {} + def test_parse_base_json_null_arguments(self, sample_tools): + """Test parse_base_json handles an explicit null arguments value.""" + parser = ConcreteToolParser() + action = {"name": "get_weather", "arguments": None} + + results = parser.parse_base_json(action, sample_tools) + + assert len(results) == 1 + assert json.loads(results[0].parameters) == {} + def test_ends_with_partial_token(self): """Test _ends_with_partial_token detection.""" parser = ConcreteToolParser() @@ -1036,18 +1046,11 @@ def test_streaming_wrapped_form_unregressed(self, sample_tools, parser): assert json.loads(r_args.calls[0].parameters) == {"location": "SF"} @pytest.mark.parametrize( - "arguments_chunk,oneshot_params", - [ - (', "arguments": {}}', "{}"), - ("}", "{}"), - # detect_and_parse dumps an explicit null as-is; only the streaming - # path normalizes it. Asserted here so the divergence stays visible. - (', "arguments": null}', "null"), - ], + "arguments_chunk", + [', "arguments": {}}', "}", ', "arguments": null}'], ids=["empty_object", "key_absent", "explicit_null"], ) - def test_streaming_zero_arg_tool(self, parser, arguments_chunk, - oneshot_params): + def test_streaming_zero_arg_tool(self, parser, arguments_chunk): """Test streaming a zero-argument tool call. A model can express "no arguments" as an empty object, by omitting the @@ -1086,10 +1089,12 @@ def test_streaming_zero_arg_tool(self, parser, arguments_chunk, params = "".join(c.parameters for r in results for c in r.calls) assert params == "{}", f"Expected '{{}}', got {params!r}" + # The one-shot path resolves all three shapes to the same "{}". + oneshot = parser.detect_and_parse("".join(chunks), tools) + assert oneshot.calls[0].parameters == "{}" + # The completed call must also be consumed from the buffer, otherwise the # parser stays in the tool-call branch and swallows the rest of the output. - oneshot = parser.detect_and_parse("".join(chunks), tools) - assert oneshot.calls[0].parameters == oneshot_params assert "" not in parser._buffer