Skip to content

[Bug]: Zero-argument tool calls never complete in the streaming tool parser #17574

Closed

Description

@Yigtwxx

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 examples folder (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 += 1

Two 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

  1. added 5 commits that reference this issue on Aug 15, 2026
  2. added 4 commits that reference this issue on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions