Repository navigation
Description
System Info
- CPU architecture: N/A (reproducible with a pure-Python snippet, no GPU required)
- GPU: N/A
- TensorRT-LLM branch:
main - TensorRT-LLM commit:
3d3d7c9192 - OS: N/A
The report concerns BaseToolParser.parse_streaming_increment in
tensorrt_llm/serve/tool_parser/base_tool_parser.py. Qwen3ToolParser reaches that code
through _wrapped_streaming, so it affects streaming /v1/chat/completions requests for
every model that resolves to the qwen3 tool parser: qwen2, qwen3, qwen3_moe,
qwen3_5, qwen3_5_moe and qwen3_next.
Who can help?
No response
Information
- The official example scripts
- My own modified scripts
Tasks
- An officially supported task in the
examplesfolder (such as GLUE/SQuAD, ...) - My own task or dataset (give details below)
Reproduction
A tool call whose arguments object is empty never completes in the streaming path,
because the branch that finishes a call is gated on the truthiness of the parsed argument
object and {} is falsy.
tensorrt_llm/serve/tool_parser/base_tool_parser.py:223-253:
cur_arguments = current_tool_call.get("arguments")
res = StreamingParseResult()
if cur_arguments: # {} is falsy, so this is skipped
sent = len(self.streamed_args_for_tool[self.current_tool_id])
cur_args_json = json.dumps(cur_arguments)
...
if is_current_complete:
argument_diff = cur_args_json[sent:] # never reached for {}
...
self._buffer = current_text[start_idx + end_idx:]
...
self.current_tool_id += 1Two things follow. The arguments are never streamed, so the client receives a tool call
whose arguments stay "", which is not valid JSON. And self._buffer is never
advanced past the completed call, so has_tool_call(current_text) stays true for the rest
of the request and every later chunk is routed back into the tool-call branch instead of
being emitted as content.
from tensorrt_llm.serve.openai_protocol import ChatCompletionToolsParam, FunctionDefinition
from tensorrt_llm.serve.tool_parser.qwen3_tool_parser import Qwen3ToolParser
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": {}}',
'\n</tool_call>', ' All done.']
print(Qwen3ToolParser().detect_and_parse("".join(chunks), tools).calls)
parser = Qwen3ToolParser()
results = [parser.parse_streaming_increment(c, tools) for c in chunks]
print([(c.name, c.parameters) for r in results for c in r.calls])
print("normal_text:", repr("".join(r.normal_text or "" for r in results)))
print("buffer:", repr(parser._buffer))[ToolCallItem(tool_index=0, name='get_time', parameters='{}')]
[('get_time', '')]
normal_text: ''
buffer: '<tool_call>\n{"name": "get_time", "arguments": {}}\n</tool_call> All done.'
The same run with a tool that does take arguments behaves correctly and leaves
parameters='{"city": "Paris"}', so the difference is entirely the empty object.
Expected behavior
Streaming a zero-argument tool call emits {} as the arguments, the same value
detect_and_parse returns for the same text, and the completed call is consumed from the
buffer so later chunks are handled normally.
actual behavior
The arguments are never emitted and the call is never consumed. The buffer still holds
the whole tool call, so has_tool_call remains true and the remainder of the response is
swallowed.
additional notes
The intended behavior is already pinned elsewhere in the repository for parsers that
implement their own streaming path:
tests/unittest/llmapi/apps/test_tool_parsers.py:1997 (Glm4ToolParser,
test_streaming_no_args) and :2181 (Glm47ToolParser,
test_streaming_zero_arg_tool), both asserting "{}" in params. Qwen3ToolParser has no
equivalent case, and the nearest base-class test,
test_parse_streaming_increment_tool_arguments at :287, only checks that the call does
not raise.
Non-streaming is unaffected: parse_base_json (base_tool_parser.py:82-85) serializes
act.get("parameters") or act.get("arguments", {}) and produces "{}".
Zero-argument tools are common in practice, for example get_current_time,
list_open_tabs or any refresh-style function.
Changing the gate to if cur_arguments is not None: is enough. The partial path is not
affected because the elif prev_arguments: branch at :256 has its own falsy guard, so
no premature "{}" is emitted while the JSON is still incomplete. I would like to open a
PR with that change plus a test_streaming_zero_arg_tool for TestQwen3ToolParser
mirroring the existing GLM-4.7 one.
Before submitting a new issue...
- Make sure you already searched for relevant issues, and checked the documentation and examples for answers to frequently asked questions.
Activity
[NVIDIA#17574][fix] Complete zero-argument calls that omit or null th…
[NVIDIA#17574][fix] Normalize an explicit null arguments value to {} …
[NVIDIA#17574][fix] Complete zero-argument tool calls in the streamin…
[NVIDIA#17574][fix] Complete zero-argument calls that omit or null th…
[NVIDIA#17574][fix] Normalize an explicit null arguments value to {} …
[NVIDIA#17574][fix] Complete zero-argument tool calls in the streamin…
[NVIDIA#17574][fix] Complete zero-argument calls that omit or null th…
[NVIDIA#17574][fix] Normalize an explicit null arguments value to {} …
[#17574][fix] Complete zero-argument tool calls in the streaming tool…