diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py
index 2d70d2fa572f..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,
),
))
@@ -239,7 +239,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 710f6dd7b2b8..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()
@@ -1035,6 +1045,58 @@ 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",
+ [', "arguments": {}}', "}", ', "arguments": null}'],
+ ids=["empty_object", "key_absent", "explicit_null"],
+ )
+ 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
+ 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 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.
+ assert "" not in parser._buffer
+
class TestQwen3CoderToolParser(BaseToolParserTestClass):
"""Test suite for Qwen3CoderToolParser class."""