diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py index ece736749d2f..f7d1055a418a 100644 --- a/tensorrt_llm/serve/tool_parser/base_tool_parser.py +++ b/tensorrt_llm/serve/tool_parser/base_tool_parser.py @@ -223,7 +223,19 @@ def parse_streaming_increment(self, new_text: str, cur_arguments = current_tool_call.get("arguments") res = StreamingParseResult() - if cur_arguments: + # 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. + 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 7b24d4554337..2a7bfcad819b 100644 --- a/tests/unittest/llmapi/apps/test_tool_parsers.py +++ b/tests/unittest/llmapi/apps/test_tool_parsers.py @@ -1016,6 +1016,63 @@ 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"} + @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", + function=FunctionDefinition( + name="get_time", + description="Get current time", + parameters={ + "type": "object", + "properties": {}, + }, + ), + ) + ] + chunks = [ + "\n", + '{"name": "get_time"', + arguments_chunk, + "\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 + + # 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. + oneshot = parser.detect_and_parse("".join(chunks), tools) + assert oneshot.calls[0].parameters == oneshot_params + assert "" not in parser._buffer + class TestQwen3CoderToolParser(BaseToolParserTestClass): """Test suite for Qwen3CoderToolParser class."""