Skip to content

[#17574][fix] Complete zero-argument tool calls in the streaming tool parser - #17575

Open
Yigtwxx wants to merge 2 commits into
NVIDIA:mainfrom
Yigtwxx:fix/base-tool-parser-zero-arg-streaming
Open

[#17574][fix] Complete zero-argument tool calls in the streaming tool parser#17575
Yigtwxx wants to merge 2 commits into
NVIDIA:mainfrom
Yigtwxx:fix/base-tool-parser-zero-arg-streaming

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #17574.

BaseToolParser.parse_streaming_increment gates the argument-streaming and
call-completion branch on the truthiness of the parsed argument object
(base_tool_parser.py:226). A tool call with no arguments never satisfies that gate, so
the branch at :239-253 is never entered. Two things follow: the arguments are never
streamed, so the client is left with arguments="", which is not valid JSON, and
self._buffer is never advanced past the completed call, so the call never finishes and
the buffer only grows for the remainder of the request.

A model can express "no arguments" in three ways, and all three hit this. Driving
Qwen3ToolParser chunk by chunk, followed by a trailing All done.:

emitted object streamed parameters final _buffer
{"name": "get_time", "arguments": {}} '' '<tool_call>\n{"name": "get_time", "arguments": {}}\n</tool_call> All done.'
{"name": "get_time"} '' '<tool_call>\n{"name": "get_time"}\n</tool_call> All done.'
{"name": "get_time", "arguments": null} '' '<tool_call>\n{"name": "get_time", "arguments": null}\n</tool_call> All done.'

An empty object is falsy; a missing key and an explicit null both make
current_tool_call.get("arguments") return None. detect_and_parse on the same text
returns parameters='{}' for the first two, so this is a divergence between the streamed
and non-streamed response for the same generation.

The change is in two parts:

  • Gate on is not None rather than truthiness, which covers the empty object.
  • Normalize a missing or null arguments object to {}, but only once the call's JSON is
    complete. Restricting it to is_current_complete keeps the partial path intact: while
    the JSON is still unclosed a missing key only means "not streamed yet", so it must not
    be mistaken for an empty object and flushed early. An unclosed object still falls
    through to the elif prev_arguments: branch at :256, which keeps its own falsy guard.

After the change all three shapes emit {} and advance the buffer past the call.

Qwen3ToolParser is the parser that reaches this code, through _wrapped_streaming, so
this covers the models that resolve to the qwen3 tool parser: qwen2, qwen3,
qwen3_moe, qwen3_5, qwen3_5_moe and qwen3_next. Glm4ToolParser and
Glm47ToolParser implement their own streaming paths and already emit "{}" here, which
is where the expected behaviour comes from. No API change, and no change to the
non-streaming path.

Out of scope

Review raised a second symptom that an earlier revision of this description attributed to
the same cause: after a completed call, later chunks are routed back into the tool-call
branch instead of being emitted as content. That is real, but it has a different cause and
this PR does not fix it. base_tool_parser.py:136-138 also enters the tool-call branch
when current_tool_id > 0 and current_text.startswith(self.tool_call_separator). For
Qwen3ToolParser the separator is "\n" and eot_token is "\n</tool_call>", so the
remainder after any completed call begins with the separator by construction of the
format. It reproduces on main with ordinary non-empty arguments, independently of the
argument-truthiness gate, and fixing it means changing when that clause is allowed to
match — a separate concern in a method shared by every parser inheriting the base
streaming path. Tracked separately as #17740.

Test Coverage

tests/unittest/llmapi/apps/test_tool_parsers.py, already registered as cpu_only in
tests/integration/test_lists/test-db/l0_cpu.yml:

  • TestQwen3ToolParser::test_streaming_zero_arg_tool streams a zero-argument call in four
    chunks and asserts the emitted arguments are {}, that detect_and_parse on the same
    text agrees, and that the call is consumed from the buffer. It is parametrized over the
    three shapes above (empty_object, key_absent, explicit_null). It mirrors
    TestGlm47ToolParser::test_streaming_zero_arg_tool and
    TestGlm4ToolParser::test_streaming_no_args, which pin the same behaviour for those
    parsers.

All three cases fail on main with assert '' == '{}' and pass with this change. The
explicit_null case additionally pins current one-shot behaviour: parse_base_json
resolves a missing key to {} (:83) but dumps a present-but-null value as "null", so
the parametrization records that divergence rather than leaving it silent. The rest of the
file is unchanged and still passes, including every other parser that inherits the base
streaming path.

PR Checklist

Please review the following before submitting your PR:

  • PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.

  • PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.

  • Test cases are provided for new code paths (see test instructions)

  • If PR introduces API changes, an appropriate PR label is added - either api-compatible or api-breaking. For api-breaking, include BREAKING in the PR title.

  • Any new dependencies have been scanned for license and vulnerabilities

  • CODEOWNERS updated if ownership changes

  • Documentation updated as needed

  • Update tava architecture diagram if there is a significant design change in PR.

  • The reviewers assigned automatically/manually are appropriate for the PR.

  • Please check this after reviewing the above items as appropriate for this PR.

GitHub Bot Help

To see a list of available CI bot commands, please comment /bot help.

…g tool parser

BaseToolParser.parse_streaming_increment gated the argument-streaming and
call-completion branch on the truthiness of the parsed argument object. An empty
object is falsy, so a tool call with no arguments never emitted its arguments and
was never consumed from the buffer: the client was left with arguments="", which
is not valid JSON, and has_tool_call stayed true for the rest of the request, so
every later chunk was routed back into the tool-call branch instead of being
emitted as content.

Gate on "is not None" instead. The partial path is unaffected because the
elif prev_arguments branch keeps its own falsy guard, so no premature "{}" is
emitted while the JSON is still incomplete.

Qwen3ToolParser is the parser that reaches this code, through _wrapped_streaming.
Glm4ToolParser and Glm47ToolParser already assert the expected behaviour for their
own streaming paths, so the new test mirrors theirs.

Signed-off-by: Yiğit ERDOĞAN <yigiterdogan023@gmail.com>
@Yigtwxx
Yigtwxx requested a review from a team as a code owner August 12, 2026 19:16
@Yigtwxx

Yigtwxx commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@zhaoyangwang-nvidia @JunyiXu-nv could one of you trigger a pipeline run when you have a moment? /bot run does not work from my account.

The change is one condition in BaseToolParser.parse_streaming_increment and the new test is cpu_only. It fails on main with assert '' == '{}' and passes here, and the rest of test_tool_parsers.py is unaffected, including the other parsers that go through the base streaming path.

Related but separate: #17572 / #17573 covers a text-loss bug in the DeepSeek tool parsers. The two do not overlap in files.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: cb8ec86b-c92f-4de5-9634-b413f0c8b22a

📥 Commits

Reviewing files that changed from the base of the PR and between 8a9aa86 and d58427d.

📒 Files selected for processing (2)
  • tensorrt_llm/serve/tool_parser/base_tool_parser.py
  • tests/unittest/llmapi/apps/test_tool_parsers.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tensorrt_llm/serve/tool_parser/base_tool_parser.py
  • tests/unittest/llmapi/apps/test_tool_parsers.py

Walkthrough

The streaming tool parser now normalizes missing or null arguments to {} and processes empty argument objects. Parameterized Qwen3 tests verify streaming output, one-shot parsing, and buffer cleanup.

Changes

Zero-argument streaming tool calls

Layer / File(s) Summary
Complete empty argument calls
tensorrt_llm/serve/tool_parser/base_tool_parser.py, tests/unittest/llmapi/apps/test_tool_parsers.py
The parser completes calls with empty, omitted, or null arguments. Parameterized Qwen3 tests validate normalized streaming arguments, one-shot parsing, and buffer cleanup.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to d5842

This localized parser fix aligns streamed zero-argument tool calls with the existing non-streaming behavior and adds targeted coverage; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: junyixu-nv

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #17574 by completing zero-argument calls, emitting normalized arguments, consuming the buffer, and preserving non-streaming behavior.
Out of Scope Changes check ✅ Passed The code and tests remain focused on streaming completion for empty, missing, and null tool arguments with no unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly summarizes the primary fix for zero-argument tool calls in the streaming tool parser.
Description check ✅ Passed The description explains the issue, solution, scope, tests, and checklist, and it identifies related behavior that remains out of scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
tests/unittest/llmapi/apps/test_tool_parsers.py (1)

1053-1057: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover response content after the completed tool call.

Line 1057 checks that the opening marker is absent, but the test does not send response content after </tool_call>. Add a follow-up chunk and assert that it produces normal_text with no tool calls. This directly covers the PR objective for subsequent response content.

Suggested assertion
         assert "<tool_call>" not in parser._buffer
+        follow_up = parser.parse_streaming_increment(
+            "The current time is 12:00.", tools)
+        assert follow_up.calls == []
+        assert follow_up.normal_text == "The current time is 12:00."
🤖 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 `@tests/unittest/llmapi/apps/test_tool_parsers.py` around lines 1053 - 1057,
Extend the completed tool-call test around parser.detect_and_parse by appending
a response-content chunk after </tool_call>, then assert the follow-up result
contains that content as normal_text and has no tool calls. Keep the existing
buffer-consumption assertions intact.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@tests/unittest/llmapi/apps/test_tool_parsers.py`:
- Around line 1053-1057: Extend the completed tool-call test around
parser.detect_and_parse by appending a response-content chunk after
</tool_call>, then assert the follow-up result contains that content as
normal_text and has no tool calls. Keep the existing buffer-consumption
assertions intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 302aa629-aef1-472d-af42-860b32485833

📥 Commits

Reviewing files that changed from the base of the PR and between 3612c80 and 8a9aa86.

📒 Files selected for processing (2)
  • tensorrt_llm/serve/tool_parser/base_tool_parser.py
  • tests/unittest/llmapi/apps/test_tool_parsers.py

@zhaoyangwang-nvidia zhaoyangwang-nvidia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve with nits.

# 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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

get("arguments") also returns None when the key is absent, or when the model emits "arguments": null - for a zero-argument call both are plausible outputs, and they dead-end in exactly the same way this PR fixes. I ran the parser standalone on ['<tool_call>\n', '{"name": "get_time"', '}', '\n</tool_call>', ' All done.'] with this patch applied: the name is emitted with parameters='', the call never completes, and _buffer ends as '<tool_call>\n{"name": "get_time"}\n</tool_call> All done.' - it only ever grows. Same for "arguments": null. Since :249 is the only place the buffer advances, would it be worth normalising a missing/null arguments to {} inside the is_current_complete branch? The partial path stays safe there, because an unclosed JSON still falls through to elif prev_arguments:.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, and it is worse than "also returns None" — both shapes dead-end
identically to the case this PR fixes. Reproduced on the patched tree with
Qwen3ToolParser, driving the chunks you gave:

shape streamed parameters final _buffer
{"name": "get_time"} '' '<tool_call>\n{"name": "get_time"}\n</tool_call> All done.'
{"name": "get_time", "arguments": null} '' '<tool_call>\n{"name": "get_time", "arguments": null}\n</tool_call> All done.'
{"name": "get_time", "arguments": {}} '{}' '\n</tool_call> All done.'

So the first two never complete and the buffer only grows, exactly as you
describe. Applied your suggestion — normalising inside the completion branch:

cur_arguments = current_tool_call.get("arguments")
res = StreamingParseResult()

if is_current_complete and cur_arguments is None:
    cur_arguments = {}

if cur_arguments is not None:
    ...

Gating on is_current_complete is what keeps the partial path safe, as you
noted: while the JSON is still unclosed a missing key only means "not streamed
yet", so it must not be mistaken for an empty object and flushed early. An
unclosed object still falls through to elif prev_arguments:. Both new shapes
now emit {} and leave _buffer at '\n</tool_call> All done.', matching the
empty-object row.

test_streaming_zero_arg_tool is now parametrized over the three shapes
(empty_object, key_absent, explicit_null). The two new ones fail without
this commit and pass with it; the full file is otherwise unchanged.

One divergence I left alone, since it is on the non-streaming path and outside
this PR's title: parse_base_json resolves a missing key to {}
(base_tool_parser.py:83, act.get("arguments", {})), so streaming and
one-shot now agree there — but for an explicit null it dumps "null", because
act.get("arguments", {}) returns the present-but-null value. The
parametrization asserts that as current behaviour so the divergence is visible
rather than silent. Happy to fold act.get("parameters") or act.get("arguments") or {} into this PR if you would rather have both paths consistent now.

# parser stays in the tool-call branch and swallows the rest of the output.
assert parser.detect_and_parse("".join(chunks),
tools).calls[0].parameters == "{}"
assert "<tool_call>" not in parser._buffer

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checking this assertion against the running parser: after the fix _buffer is '\n</tool_call> All done.', so this holds - but the second symptom in the PR description ("every later chunk is routed back into the tool-call branch instead of being emitted as content") still reproduces. Feeding a trailing ' All done.' chunk returns normal_text='' and leaves it in the buffer. The cause is no longer has_tool_call(): it is the second clause at base_tool_parser.py:137-138, current_tool_id > 0 and current_text.startswith(self.tool_call_separator). The separator is '\n' and the remainder after a completed call is '\n</tool_call>', so the parser stays in the tool-call branch, hits MalformedJSON, and returns without touching the buffer. It behaves correctly when the remainder does not start with a newline. Is that a known separate issue? If so the PR description may be claiming a bit more than the change delivers; the assertion itself looks correctly calibrated to what does hold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed on all three points — your diagnosis is right and my PR description
over-claims. I have corrected it, and filed the second symptom as #17740.

It is a separate, pre-existing issue, and it is not specific to zero-argument
calls. The same drop happens with ordinary arguments on main:

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 cause is the one you identified at base_tool_parser.py:136-138. For
Qwen3ToolParser, tool_call_separator is "\n" and eot_token is
"\n</tool_call>", so the remainder after any completed call begins with 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 without touching the buffer. The
</tool_call> scrubbing in _wrapped_streaming never runs because
result.normal_text is empty. Your control case is the giveaway: with
'</tool_call>' instead of '\n</tool_call>' the trailing text is emitted and
_buffer drains to ''.

So it is orthogonal to the argument-truthiness gate — it reproduces before this
PR, after it, and with non-empty arguments — and fixing it means changing when
the separator clause is allowed to match, which is a different concern in a
method shared by every parser inheriting the base streaming path. I would rather
not fold it in here, so #17740 carries the repro and links back to this thread.
Say the word if you would prefer it handled in this PR after all.

I have reworded the description: the buffer never advancing is the consequence
this change actually fixes; the "later chunks routed back into the tool-call
branch" symptom is now attributed to the separator clause and pointed at #17740.
The assertion stays as-is, since as you say it is calibrated to what does hold.

…e arguments key

Review follow-up. Gating on "is not None" fixed the empty-object shape but left
two equivalent ones dead-ending in exactly the same way: a model can express "no
arguments" by omitting the key entirely, or by emitting an explicit null. Both
make current_tool_call.get("arguments") return None, so the completion branch is
skipped, the buffer never advances past the call, and _buffer only grows for the
rest of the request.

Normalize a missing or null arguments object to {} once the call's JSON is
complete. Restricting it to is_current_complete keeps the partial path intact:
while the JSON is still incomplete a missing key only means "not streamed yet",
so it must not be mistaken for an empty object and flushed early.

This matches parse_base_json, which already resolves a missing key to {} on the
non-streaming path. It still dumps an explicit null as "null" there; the new
parametrization asserts that so the divergence stays visible.

Signed-off-by: Yiğit ERDOĞAN <yigiterdogan023@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants