Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions tensorrt_llm/serve/tool_parser/base_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
))
Expand Down Expand Up @@ -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:
Comment thread
zhaoyangwang-nvidia marked this conversation as resolved.
# Calculate how much of the arguments we've already streamed
sent = len(
self.streamed_args_for_tool[self.current_tool_id])
Expand Down
62 changes: 62 additions & 0 deletions tests/unittest/llmapi/apps/test_tool_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 = [
"<tool_call>\n",
'{"name": "get_time"',
arguments_chunk,
"\n</tool_call>",
]

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 "<tool_call>" not in parser._buffer
Comment thread
zhaoyangwang-nvidia marked this conversation as resolved.


class TestQwen3CoderToolParser(BaseToolParserTestClass):
"""Test suite for Qwen3CoderToolParser class."""
Expand Down
Loading