Skip to content
Open
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
141 changes: 135 additions & 6 deletions tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

from ..openai_protocol import ChatCompletionToolsParam as Tool
from .base_tool_parser import BaseToolParser
from .core_types import StreamingParseResult, StructureInfo, _GetInfoFunc
from .core_types import (StreamingParseResult, StructureInfo, ToolCallItem,
_GetInfoFunc)


class Qwen3ToolParser(BaseToolParser):
"""
r"""
Detector for Qwen 2.5 and Qwen 3 model function call format.

Format Structure:
Expand All @@ -23,9 +24,21 @@ class Qwen3ToolParser(BaseToolParser):
- Tool Call Tags: `<tool_call>` and `</tool_call>` wrap each individual call
- Function Call Object: JSON object with "name" and "arguments" fields

Some Qwen3 chat templates (notably Qwen3.6 FP8 served with
`--reasoning_parser qwen3_5 --tool_parser qwen3`) emit tool calls as bare
JSON objects without the `<tool_call>...</tool_call>` wrapper once the
reasoning parser strips the `</think>` block. Both `detect_and_parse` and
`parse_streaming_increment` fall back to a bare-JSON parse in that case
(see NVBug 6240584).

Reference: https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct?chat_template=default
"""

# Streaming decision state for the bare-JSON fallback path.
_STREAM_MODE_UNDECIDED = "undecided"
_STREAM_MODE_WRAPPED = "wrapped"
_STREAM_MODE_BARE_JSON = "bare_json"

def __init__(self):
"""
Initializes the detector with necessary state variables.
Expand All @@ -35,9 +48,18 @@ def __init__(self):
self.eot_token = "\n</tool_call>" # nosec B105
self.tool_call_separator = "\n"
self._normal_text_buffer = "" # Buffer for handling partial end tokens
# Bare-JSON streaming fallback state (see NVBug 6240584).
self._bare_json_buffer = ""
self._stream_mode = self._STREAM_MODE_UNDECIDED

def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Qwen 3 format tool call."""
"""Check if the text contains a Qwen 3 format tool call.

Note: intentionally strict — this only checks for the `<tool_call>`
wrapper. Existing callers rely on it as a wrapped-form gate. The
bare-JSON fallback lives inside `detect_and_parse` /
`parse_streaming_increment` and does not go through this method.
"""
return self.bot_token in text

def detect_and_parse(self, text: str,
Expand All @@ -52,6 +74,19 @@ def detect_and_parse(self, text: str,
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
# Some Qwen3 chat templates (e.g. Qwen3.6 FP8 with thinking enabled)
# emit tool calls as bare JSON without a <tool_call> wrapper. Try
# to recover those before dropping the text into normal_text. Use
# an explicit type guard to avoid relying on AttributeError to
# catch scalar JSON like "42" or null.
try:
parsed = json.loads(text.strip())
except json.JSONDecodeError:
return StreamingParseResult(normal_text=normal_text, calls=[])
if isinstance(parsed, (dict, list)):
calls = self.parse_base_json(parsed, tools)
if calls:
return StreamingParseResult(normal_text="", calls=calls)
return StreamingParseResult(normal_text=normal_text, calls=[])
Comment on lines +77 to 90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the cited lines.
sed -n '1,220p' tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py

# Find related parser implementations / helpers for comparison.
rg -n "json\.loads\(|raw_decode|parse_base_json|StreamingParseResult|normal_text" tensorrt_llm/serve/tool_parser -S

Repository: NVIDIA/TensorRT-LLM

Length of output: 36110


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py

rg -n "json\.loads\(|raw_decode|parse_base_json|StreamingParseResult|normal_text" tensorrt_llm/serve/tool_parser -S

Repository: NVIDIA/TensorRT-LLM

Length of output: 36110


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the target file with line numbers.
cat -n tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py

# Inspect the base parser helper and sibling implementations for comparison.
for f in tensorrt_llm/serve/tool_parser/*.py; do
  echo "===== $f ====="
  rg -n "parse_base_json|raw_decode|json\.loads\(|StreamingParseResult|normal_text" "$f" -n -S || true
done

Repository: NVIDIA/TensorRT-LLM

Length of output: 28883


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n tensorrt_llm/serve/tool_parser/utils.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 5965


Parse the leading JSON value instead of the full string. json.loads(text.strip()) fails on "{...} trailing text" with Extra data, so a valid bare-JSON tool call gets sent back as normal_text. Use raw_decode() or the existing partial_json_loads() helper and keep any suffix as text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py` around lines 77 - 90,
Update the bare-JSON fallback in the tool parser to decode only the leading JSON
value using raw_decode() or the existing partial_json_loads() helper, rather
than requiring json.loads() to consume the entire stripped text. Preserve
dict/list validation and tool-call parsing, and append any trailing text after
the decoded value to normal_text when no tool call is returned.


# Find all <tool_call>\n...\n</tool_call> blocks
Expand All @@ -71,13 +106,107 @@ def detect_and_parse(self, text: str,

def parse_streaming_increment(self, new_text: str,
tools: List[Tool]) -> StreamingParseResult:
"""
r"""
Streaming incremental parsing for Qwen 3 tool calls.
Uses base class implementation with buffering to handle partial end tokens.

Handles two shapes:
- Wrapped: `<tool_call>\n{...}\n</tool_call>` — delegates to the base
class implementation, which streams the name first and then the
arguments incrementally. Applies the same partial-end-token
scrubbing as before.
- Bare JSON (NVBug 6240584): when the stream never contains
`<tool_call>` but the accumulated buffer is a complete JSON object
matching `{"name": ..., "arguments"/"parameters": ...}`, parse it
via `parse_base_json` and emit the calls in the same
name-first-then-arguments pattern used by the base class.

The parser picks a mode once and stays in it for the remainder of the
stream. Modes:
- undecided: buffer input; peek to decide.
- wrapped: route everything through the base implementation.
- bare_json: bare-JSON tool call already emitted; drop trailing text.
"""
if self._stream_mode == self._STREAM_MODE_WRAPPED:
return self._wrapped_streaming(new_text, tools)
if self._stream_mode == self._STREAM_MODE_BARE_JSON:
# Tool call already emitted; ignore any trailing text.
return StreamingParseResult()

# Undecided: buffer and decide.
self._bare_json_buffer += new_text
accumulated = self._bare_json_buffer

if self.bot_token in accumulated:
# Wrapped form: replay the accumulated buffer through the base
# streaming path.
self._stream_mode = self._STREAM_MODE_WRAPPED
replay = accumulated
self._bare_json_buffer = ""
return self._wrapped_streaming(replay, tools)

if self._ends_with_partial_token(accumulated, self.bot_token):
# Could still resolve into a wrapped form — keep buffering.
return StreamingParseResult()

stripped = accumulated.lstrip()
if not stripped:
# Only whitespace so far — keep buffering.
return StreamingParseResult()

if stripped[0] not in "{[":
# Not JSON-like at all — flush as normal text via wrapped path.
self._stream_mode = self._STREAM_MODE_WRAPPED
replay = accumulated
self._bare_json_buffer = ""
return self._wrapped_streaming(replay, tools)

# Leading `{` or `[` — could be a bare JSON tool call, or partial.
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
# Not yet complete — buffer more.
return StreamingParseResult()

if isinstance(parsed, (dict, list)):
calls = self.parse_base_json(parsed, tools)
if calls:
streaming_calls: List[ToolCallItem] = []
for i, call_item in enumerate(calls):
# Emit name first with empty parameters, then the full
# arguments — mirrors the base class streaming pattern.
streaming_calls.append(
ToolCallItem(
tool_index=i,
name=call_item.name,
parameters="",
))
if call_item.parameters:
streaming_calls.append(
ToolCallItem(
tool_index=i,
parameters=call_item.parameters,
))
self._stream_mode = self._STREAM_MODE_BARE_JSON
self._bare_json_buffer = ""
# Best-effort bookkeeping for callers that inspect these.
self.current_tool_id = max(0, len(calls) - 1)
self.current_tool_name_sent = True
return StreamingParseResult(normal_text="",
calls=streaming_calls)

Comment on lines +163 to +196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files ==\n'
git ls-files 'tensorrt_llm/serve/tool_parser/*' | sed -n '1,120p'

printf '\n== Outline: qwen3_tool_parser.py ==\n'
ast-grep outline tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py --view expanded || true

printf '\n== Outline: base_tool_parser.py ==\n'
ast-grep outline tensorrt_llm/serve/tool_parser/base_tool_parser.py --view expanded || true

printf '\n== Relevant slices: qwen3_tool_parser.py ==\n'
sed -n '130,230p' tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py

printf '\n== Relevant slices: base_tool_parser.py ==\n'
sed -n '1,260p' tensorrt_llm/serve/tool_parser/base_tool_parser.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 18923


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Search for flush/finish/end-of-stream hooks ==\n'
rg -n "def (finish|flush|end|close)|finish\(" tensorrt_llm/serve/tool_parser tensorrt_llm/serve -g '*.py' || true

printf '\n== Search for qwen3 tool parser state usage ==\n'
rg -n "_stream_mode|_bare_json_buffer|parse_streaming_increment|current_tool_name_sent|current_tool_id" tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py tensorrt_llm/serve -g '*.py' || true

printf '\n== Search for streaming parser invocation sites ==\n'
rg -n "parse_streaming_increment\(" tensorrt_llm/serve -g '*.py' || true

printf '\n== Relevant slice: qwen3_tool_parser.py around streaming branch ==\n'
sed -n '107,220p' tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py

printf '\n== Relevant slice: any end-of-stream handling in serving layer ==\n'
rg -n "StreamingParseResult|current_tool_name_sent|streamed_args_for_tool|prev_tool_call_arr" tensorrt_llm/serve -g '*.py' || true

Repository: NVIDIA/TensorRT-LLM

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '960,1035p' tensorrt_llm/serve/responses_utils.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 3003


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Streaming loop around tool parser calls ==\n'
sed -n '1460,1535p' tensorrt_llm/serve/responses_utils.py

printf '\n== Search for any parser finalization after streaming ends ==\n'
rg -n "tool_parser_dict|parse_streaming_increment\(text, tools\)|finished|finish_result|flush" tensorrt_llm/serve/responses_utils.py tensorrt_llm/serve/postprocess_handlers.py -g '*.py' || true

Repository: NVIDIA/TensorRT-LLM

Length of output: 7894


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '150,340p' tensorrt_llm/serve/postprocess_handlers.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 8169


Handle trailing content in the bare-JSON streaming branch (tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py:163-196) — json.loads(stripped) still treats a complete JSON prefix plus trailing text as “incomplete,” so the parser can stay stuck buffering forever and never emit the tool call. Parse the JSON boundary separately (for example with raw_decode()/end-index tracking) instead of waiting on the whole buffer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tensorrt_llm/serve/tool_parser/qwen3_tool_parser.py` around lines 163 - 196,
Update the bare-JSON parsing branch around json.loads in the streaming parser to
decode the first complete JSON value and track its end index, rather than
requiring the entire stripped buffer to be valid JSON. Emit the parsed tool
calls immediately when a valid dict or list is found, while preserving any
trailing content for subsequent normal-text processing and avoiding indefinite
buffering.

# Parsed as JSON but not a tool call — flush as normal text via
# wrapped path.
self._stream_mode = self._STREAM_MODE_WRAPPED
replay = accumulated
self._bare_json_buffer = ""
return self._wrapped_streaming(replay, tools)

def _wrapped_streaming(self, new_text: str,
tools: List[Tool]) -> StreamingParseResult:
"""Wrapped-form streaming: base implementation + partial-end-token scrubbing."""
result = super().parse_streaming_increment(new_text, tools)

# Handle partial end tokens that are streamed character by character
# Handle partial end tokens that are streamed character by character.
if result.normal_text:
self._normal_text_buffer += result.normal_text

Expand Down
Loading
Loading