NVIDIA / NVIDIA/TensorRT-LLM

[Bug]: Streaming tool parser drops all response content emitted after a completed tool call

Open
#17,740 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Frontend
Dominant language
Python
Stars
14.7k
Forks
2.8k
Avg merge
2d 23h
Merged PRs (30d)
489

Description

System Info

Reproduced against main by driving the parser directly; no GPU or model weights involved. The affected code is pure Python in tensorrt_llm/serve/tool_parser/.

  • CPU-only, tensorrt_llm/serve/tool_parser/base_tool_parser.py at main
  • Applies to any parser that inherits BaseToolParser.parse_streaming_increment and whose eot_token starts with its tool_call_separatorQwen3ToolParser is the concrete case below
Who can help?

@tongyuantongyu @zhaoyangwang-nvidia

Surfaced during review of #17575 (thread). It is a distinct defect from the one that PR fixes, so filing it separately.

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

Stream a Qwen3-format tool call followed by ordinary assistant text:

from tensorrt_llm.serve.openai_protocol import (ChatCompletionToolsParam,
                                                FunctionDefinition)
from tensorrt_llm.serve.tool_parser.qwen3_tool_parser import Qwen3ToolParser

tools = [
    ChatCompletionToolsParam(function=FunctionDefinition(
        name="get_weather",
        description="w",
        parameters={"type": "object",
                    "properties": {"city": {"type": "string"}}},
    ))
]

parser = Qwen3ToolParser()
chunks = [
    "<tool_call>\n",
    '{"name": "get_weather", "arguments": {"city": "Paris"}}',
    "\n</tool_call>",
    " It is sunny.",
]
for c in chunks:
    r = parser.parse_streaming_increment(c, tools)
    print(f"in={c!r:60} normal_text={r.normal_text!r:16} "
          f"calls={[(x.tool_index, x.name, x.parameters) for x in r.calls]}")
print("FINAL _buffer =", repr(parser._buffer))
Expected behavior

The final chunk is ordinary assistant content and should be emitted as normal_text, with the buffer drained:

in=' It is sunny.'   normal_text=' It is sunny.'   calls=[]
FINAL _buffer = ''

This is what happens when the closing tag is not preceded by a newline — replacing "\n</tool_call>" with "</tool_call>" in the script above produces exactly the output above. That control case is the clearest evidence the newline is what makes the difference.

actual behavior

The trailing content is swallowed and the buffer never drains:

in='<tool_call>\n'                                       normal_text='' calls=[]
in='{"name": "get_weather", "arguments": {"city": "Paris"}}'
                                                         normal_text='' calls=[(0, 'get_weather', '')]
in='\n</tool_call>'                                      normal_text='' calls=[(0, None, '{"city": "Paris"}')]
in=' It is sunny.'                                       normal_text='' calls=[]
FINAL _buffer = '\n</tool_call> It is sunny.'

The tool call itself parses correctly. Only the content after it is lost, and every later chunk in the request is lost the same way, since the buffer only grows from that point on.

Cause, as far as I can tell — base_tool_parser.py:136-138 enters the tool-call branch on either of two conditions:

if not (self.has_tool_call(current_text) or
        (self.current_tool_id > 0
         and current_text.startswith(self.tool_call_separator))):

For Qwen3ToolParser, tool_call_separator is "\n" and eot_token is "\n</tool_call>" (qwen3_tool_parser.py:48-49). After a call completes, :249 leaves the remainder starting at the eot_token, so the remainder begins with "\n" — the separator — by construction of the format. The second clause therefore always holds. The parser stays in the tool-call branch, partial_json_loads raises MalformedJSON, and :186 returns early without touching the buffer, so the eot_token stripping at :143-144 is never reached. The </tool_call> scrubbing in Qwen3ToolParser._wrapped_streaming also never runs, because it is gated on result.normal_text being non-empty.

The second clause is presumably meant to catch the start of a subsequent tool call after a separator. It currently also matches a separator that is merely the leading character of the end token.

Notes:

  • Independent of #17575: it reproduces before and after that change, and with non-empty arguments, so it is not related to the argument-truthiness gate.
  • Any parser inheriting the base streaming path whose eot_token begins with its tool_call_separator is exposed; I only verified Qwen3ToolParser.
  • User-visible effect on trtllm-serve: with a streamed request, any assistant text the model produces after a tool call is silently dropped from the response, while the same generation parsed through detect_and_parse keeps it.

I am happy to open a PR for this if the approach is agreed. The fix needs a decision on when the separator clause should be allowed to match — e.g. requiring that what follows the separator is not the end token — and it touches a method shared by every parser on the base streaming path, so I would rather confirm the intended semantics first.

additional notes

Verified with a direct-import harness against main; only pure-Python parser code is exercised.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with tensorrt_llm/serve/tool_parser/base_tool_parser.py, especially parse_streaming_increment, and compare its behavior with Qwen3ToolParser in qwen3_tool_parser.py. Run the direct-import harness from the issue and add regression coverage so assistant text after a completed tool call is emitted as normal_text and the buffer is drained without breaking subsequent tool calls.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.