From afd58acafcecfa6574fe35170bd72cc332085bff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?=
<146086744+edenfunf@users.noreply.github.com>
Date: Fri, 14 Aug 2026 20:26:17 +0800
Subject: [PATCH 1/2] [#17580][fix] Emit text preceding a tool call in
streaming tool parsers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Streaming tool parsers returned an empty normal_text as soon as a tool
call opened, and then advanced the buffer past the tool call markup.
Content the model produced before the call, when it arrived in the same
increment, was consumed together with the markup and never reached the
client, even though detect_and_parse returns it for the same input.
Add BaseToolParser._split_leading_normal_text() and use it to split the
buffer at the earliest start token before parsing the call, returning the
text ahead of it as normal_text.
Affected parsers, each covered by the new tests: deepseek_v3, deepseek_v31,
deepseek_v32, deepseek_v4 (inherits v32), glm4, kimi_k2 and qwen3. qwen3
delegates its wrapped form to BaseToolParser, so fixing the base
implementation covers it.
gemma4, glm47, kimi_k3, minimax_m2, minimax_m3, poolside_v1 and
qwen3_coder already preserved the text and are left unchanged; glm47 is
included in the tests as a regression guard.
Signed-off-by: 許元豪 <146086744+edenfunf@users.noreply.github.com>
---
.../serve/tool_parser/base_tool_parser.py | 40 ++++++-
.../serve/tool_parser/deepseekv31_parser.py | 14 ++-
.../serve/tool_parser/deepseekv32_parser.py | 12 +-
.../serve/tool_parser/deepseekv3_parser.py | 14 ++-
tensorrt_llm/serve/tool_parser/glm4_parser.py | 14 ++-
.../serve/tool_parser/kimi_k2_tool_parser.py | 16 ++-
.../unittest/llmapi/apps/test_tool_parsers.py | 112 ++++++++++++++++++
7 files changed, 201 insertions(+), 21 deletions(-)
diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py
index ece736749d2f..a57421622890 100644
--- a/tensorrt_llm/serve/tool_parser/base_tool_parser.py
+++ b/tensorrt_llm/serve/tool_parser/base_tool_parser.py
@@ -1,7 +1,7 @@
# Adapted from https://github.com/sgl-project/sglang/blob/083629c23564e1a64deaa052f1df5c5d914358d8/python/sglang/srt/function_call/base_format_detector.py
import json
from abc import ABC, abstractmethod
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Tuple
from partial_json_parser.core.exceptions import MalformedJSON
from partial_json_parser.core.options import Allow
@@ -110,6 +110,27 @@ def _ends_with_partial_token(self, buffer: str, bot_token: str) -> int:
return i
return 0
+ def _split_leading_normal_text(self, buffer: str,
+ start_tokens: List[str]) -> Tuple[str, str]:
+ """Split a buffer at the earliest tool call start token.
+
+ A streaming increment can carry ordinary content and the opening of a
+ tool call at once. That content precedes the tool call and is not part
+ of it, so it has to be returned to the client as normal text instead of
+ being consumed along with the markup.
+
+ Returns a ``(normal_text, remainder)`` pair. ``normal_text`` is empty
+ when the buffer starts with a tool call or contains none of the tokens.
+ """
+ indices = [
+ index for index in (buffer.find(token) for token in start_tokens)
+ if index != -1
+ ]
+ if not indices:
+ return "", buffer
+ split_at = min(indices)
+ return buffer[:split_at], buffer[split_at:]
+
def parse_streaming_increment(self, new_text: str,
tools: List[Tool]) -> StreamingParseResult:
"""
@@ -151,6 +172,14 @@ def parse_streaming_increment(self, new_text: str,
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
+ # This increment may hold ordinary text ahead of the tool call. Hand
+ # that text back as content and keep only the markup in the buffer,
+ # otherwise it is consumed along with the tool call and never reaches
+ # the client.
+ normal_text, current_text = self._split_leading_normal_text(
+ current_text, [self.bot_token])
+ self._buffer = current_text
+
flags = Allow.ALL if self.current_tool_name_sent else Allow.ALL & ~Allow.STR
try:
@@ -165,7 +194,7 @@ def parse_streaming_increment(self, new_text: str,
start_idx = 0
if start_idx >= len(current_text):
- return StreamingParseResult()
+ return StreamingParseResult(normal_text=normal_text)
(obj, end_idx) = partial_json_loads(current_text[start_idx:],
flags)
@@ -183,10 +212,10 @@ def parse_streaming_increment(self, new_text: str,
current_tool_call = obj
except MalformedJSON:
- return StreamingParseResult()
+ return StreamingParseResult(normal_text=normal_text)
if not current_tool_call:
- return StreamingParseResult()
+ return StreamingParseResult(normal_text=normal_text)
# Case 1: Handle tool name streaming
# This happens when we encounter a tool but haven't sent its name yet
@@ -284,11 +313,12 @@ def parse_streaming_increment(self, new_text: str,
self.prev_tool_call_arr[
self.current_tool_id] = current_tool_call
+ res.normal_text = normal_text + res.normal_text
return res
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
- return StreamingParseResult()
+ return StreamingParseResult(normal_text=normal_text)
@abstractmethod
def has_tool_call(self, text: str) -> bool:
diff --git a/tensorrt_llm/serve/tool_parser/deepseekv31_parser.py b/tensorrt_llm/serve/tool_parser/deepseekv31_parser.py
index a0b306a1060e..ea0761f18770 100644
--- a/tensorrt_llm/serve/tool_parser/deepseekv31_parser.py
+++ b/tensorrt_llm/serve/tool_parser/deepseekv31_parser.py
@@ -112,6 +112,14 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
+ # This increment may hold ordinary text ahead of the tool call. Hand that
+ # text back as content and keep only the markup in the buffer, otherwise
+ # it is consumed along with the tool call and never reaches the client.
+ normal_text, current_text = self._split_leading_normal_text(
+ current_text, [self.bot_token, "<|tool▁call▁begin|>"]
+ )
+ self._buffer = current_text
+
calls: list[ToolCallItem] = []
try:
partial_match = re.search(
@@ -183,17 +191,17 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
else:
self._buffer = ""
- result = StreamingParseResult(normal_text="", calls=calls)
+ result = StreamingParseResult(normal_text=normal_text, calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
return result
- return StreamingParseResult(normal_text="", calls=calls)
+ return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
- return StreamingParseResult(normal_text=current_text)
+ return StreamingParseResult(normal_text=normal_text + current_text)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
diff --git a/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py b/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py
index 3362ab7cb68a..d2160bf33c2c 100644
--- a/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py
+++ b/tensorrt_llm/serve/tool_parser/deepseekv32_parser.py
@@ -191,6 +191,14 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
+ # This increment may hold ordinary text ahead of the tool call. Hand that
+ # text back as content and keep only the markup in the buffer, otherwise
+ # it is consumed along with the tool call and never reaches the client.
+ normal_text, current_text = self._split_leading_normal_text(
+ current_text, [self.bot_token, "<|DSML|invoke"]
+ )
+ self._buffer = current_text
+
all_calls: list[ToolCallItem] = []
try:
# Loop to handle multiple consecutive invoke blocks
@@ -288,11 +296,11 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
break
# No more invoke blocks found
- return StreamingParseResult(normal_text="", calls=all_calls)
+ return StreamingParseResult(normal_text=normal_text, calls=all_calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
- return StreamingParseResult(normal_text=current_text)
+ return StreamingParseResult(normal_text=normal_text + current_text)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
diff --git a/tensorrt_llm/serve/tool_parser/deepseekv3_parser.py b/tensorrt_llm/serve/tool_parser/deepseekv3_parser.py
index 8eb49eb81c07..a04c02acb126 100644
--- a/tensorrt_llm/serve/tool_parser/deepseekv3_parser.py
+++ b/tensorrt_llm/serve/tool_parser/deepseekv3_parser.py
@@ -115,6 +115,14 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
+ # This increment may hold ordinary text ahead of the tool call. Hand that
+ # text back as content and keep only the markup in the buffer, otherwise
+ # it is consumed along with the tool call and never reaches the client.
+ normal_text, current_text = self._split_leading_normal_text(
+ current_text, [self.bot_token, "<|tool▁call▁begin|>"]
+ )
+ self._buffer = current_text
+
calls: list[ToolCallItem] = []
try:
partial_match = re.search(
@@ -187,17 +195,17 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
else:
self._buffer = ""
- result = StreamingParseResult(normal_text="", calls=calls)
+ result = StreamingParseResult(normal_text=normal_text, calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
return result
- return StreamingParseResult(normal_text="", calls=calls)
+ return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
- return StreamingParseResult(normal_text=current_text)
+ return StreamingParseResult(normal_text=normal_text + current_text)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
diff --git a/tensorrt_llm/serve/tool_parser/glm4_parser.py b/tensorrt_llm/serve/tool_parser/glm4_parser.py
index 540f3d842309..1a3807e92283 100644
--- a/tensorrt_llm/serve/tool_parser/glm4_parser.py
+++ b/tensorrt_llm/serve/tool_parser/glm4_parser.py
@@ -338,6 +338,12 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
+ # This increment may hold ordinary text ahead of the tool call. Hand that
+ # text back as content and keep only the markup in the buffer, otherwise
+ # it is consumed along with the tool call and never reaches the client.
+ normal_text, current_text = self._split_leading_normal_text(current_text, [self.bot_token])
+ self._buffer = current_text
+
calls: list[ToolCallItem] = []
try:
partial_match = re.search(
@@ -351,7 +357,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
is_tool_end = partial_match.group(3)
if func_name_raw is None or not func_name_raw.strip():
- return StreamingParseResult(normal_text="", calls=[])
+ return StreamingParseResult(normal_text=normal_text, calls=[])
func_name = func_name_raw.strip()
func_args_raw = func_args_raw.strip() if func_args_raw else ""
@@ -442,7 +448,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
self._buffer = current_text[partial_match.end(3) :]
- result = StreamingParseResult(normal_text="", calls=calls)
+ result = StreamingParseResult(normal_text=normal_text, calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
@@ -450,11 +456,11 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
self._reset_streaming_state()
return result
- return StreamingParseResult(normal_text="", calls=calls)
+ return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
- return StreamingParseResult(normal_text=current_text)
+ return StreamingParseResult(normal_text=normal_text + current_text)
def _parse_argument_pairs(
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
diff --git a/tensorrt_llm/serve/tool_parser/kimi_k2_tool_parser.py b/tensorrt_llm/serve/tool_parser/kimi_k2_tool_parser.py
index ca2d0a7d7d65..442895a7eaad 100644
--- a/tensorrt_llm/serve/tool_parser/kimi_k2_tool_parser.py
+++ b/tensorrt_llm/serve/tool_parser/kimi_k2_tool_parser.py
@@ -118,6 +118,14 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
+ # This increment may hold ordinary text ahead of the tool call. Hand that
+ # text back as content and keep only the markup in the buffer, otherwise
+ # it is consumed along with the tool call and never reaches the client.
+ normal_text, current_text = self._split_leading_normal_text(
+ current_text, [self.bot_token, self.tool_call_start_token]
+ )
+ self._buffer = current_text
+
calls: list[ToolCallItem] = []
try:
match = self.stream_tool_call_portion_regex.search(current_text)
@@ -128,7 +136,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
m = self.tool_call_id_regex.match(function_id)
if not m:
logger.warning("Unexpected tool_call_id format: %s", function_id)
- return StreamingParseResult(normal_text="", calls=calls)
+ return StreamingParseResult(normal_text=normal_text, calls=calls)
function_name = m.group("name")
# Initialize state if this is the first tool call
@@ -191,7 +199,7 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
else:
self._buffer = ""
- result = StreamingParseResult(normal_text="", calls=calls)
+ result = StreamingParseResult(normal_text=normal_text, calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
@@ -199,11 +207,11 @@ def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> Streami
except json.JSONDecodeError:
pass
- return StreamingParseResult(normal_text="", calls=calls)
+ return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
- return StreamingParseResult(normal_text=current_text)
+ return StreamingParseResult(normal_text=normal_text + current_text)
def structure_info(self) -> _GetInfoFunc:
"""Return function that creates StructureInfo for guided generation."""
diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py
index 7b24d4554337..fc99440e2b61 100644
--- a/tests/unittest/llmapi/apps/test_tool_parsers.py
+++ b/tests/unittest/llmapi/apps/test_tool_parsers.py
@@ -4355,3 +4355,115 @@ def test_other_raw_token_parser_keeps_spacing_contract(self) -> None:
assert sampling_params.skip_special_tokens is False
assert sampling_params.spaces_between_special_tokens is True
+
+
+# ============================================================================
+# Streaming: content sharing an increment with a tool call
+# ============================================================================
+
+_LEADING_TEXT = "Let me check the weather for you. "
+
+# Parser class plus a complete tool call in that parser's own format. Every
+# tool call starts with the parser's bot_token, so tests can split it there to
+# get a delta boundary that never lands inside a token.
+_LEADING_TEXT_PARSERS = {
+ "deepseek_v3": (
+ DeepSeekV3Parser,
+ ("<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>get_weather\n"
+ '```json\n{"location": "NYC"}\n```<|tool▁call▁end|><|tool▁calls▁end|>'
+ ),
+ ),
+ "deepseek_v31": (
+ DeepSeekV31Parser,
+ ("<|tool▁calls▁begin|><|tool▁call▁begin|>get_weather<|tool▁sep|>"
+ '{"location": "NYC"}<|tool▁call▁end|><|tool▁calls▁end|>'),
+ ),
+ "deepseek_v32": (
+ DeepSeekV32Parser,
+ ('<|DSML|function_calls> <|DSML|invoke name="get_weather"> '
+ '<|DSML|parameter name="location" string="true">NYC|DSML|parameter> '
+ "|DSML|invoke> |DSML|function_calls>"),
+ ),
+ "deepseek_v4": (
+ DeepSeekV4Parser,
+ ('<|DSML|tool_calls> <|DSML|invoke name="get_weather"> '
+ '<|DSML|parameter name="location" string="true">NYC|DSML|parameter> '
+ "|DSML|invoke> |DSML|tool_calls>"),
+ ),
+ "glm4": (
+ Glm4ToolParser,
+ ("get_weather\nlocation\n"
+ "NYC\n"),
+ ),
+ # Glm47 already preserved the leading text before this fix; it is kept here
+ # as a guard against regressing it.
+ "glm47": (
+ Glm47ToolParser,
+ ("get_weather\nlocation\n"
+ "NYC\n"),
+ ),
+ "qwen3": (
+ Qwen3ToolParser,
+ ('\n{"name": "get_weather", "arguments": '
+ '{"location": "NYC"}}\n'),
+ ),
+ "kimi_k2": (
+ KimiK2ToolParser,
+ ("<|tool_calls_section_begin|><|tool_call_begin|>functions.get_weather:0"
+ '<|tool_call_argument_begin|>{"location": "NYC"}<|tool_call_end|>'
+ "<|tool_calls_section_end|>"),
+ ),
+}
+
+
+@pytest.mark.parametrize("parser_cls, tool_call",
+ list(_LEADING_TEXT_PARSERS.values()),
+ ids=list(_LEADING_TEXT_PARSERS))
+class TestStreamingLeadingText:
+ """Content that precedes a tool call must survive streaming.
+
+ Regression tests for issue #17580: the streaming path returned an empty
+ normal_text as soon as a tool call opened, so anything the model said
+ before calling the tool was consumed along with the markup and never
+ reached the client, even though detect_and_parse returns it for the same
+ input.
+ """
+
+ @staticmethod
+ def _feed(parser, deltas, tools):
+ """Stream deltas through the parser and accumulate what it emits."""
+ normal_text = ""
+ calls = []
+ for delta in deltas:
+ result = parser.parse_streaming_increment(delta, tools)
+ normal_text += result.normal_text
+ calls.extend(result.calls)
+ return normal_text, calls
+
+ def test_leading_text_in_same_increment(self, sample_tools, parser_cls,
+ tool_call):
+ parser = parser_cls()
+ normal_text, calls = self._feed(parser, [_LEADING_TEXT + tool_call],
+ sample_tools)
+
+ assert normal_text == _LEADING_TEXT
+ assert [call.name for call in calls if call.name] == ["get_weather"]
+
+ def test_leading_text_emitted_once_when_call_spans_increments(
+ self, sample_tools, parser_cls, tool_call):
+ parser = parser_cls()
+ split_at = len(parser.bot_token)
+ deltas = [_LEADING_TEXT + tool_call[:split_at], tool_call[split_at:]]
+
+ normal_text, calls = self._feed(parser, deltas, sample_tools)
+
+ assert normal_text == _LEADING_TEXT
+ assert [call.name for call in calls if call.name] == ["get_weather"]
+
+ def test_tool_call_without_leading_text_emits_no_content(
+ self, sample_tools, parser_cls, tool_call):
+ parser = parser_cls()
+ normal_text, calls = self._feed(parser, [tool_call], sample_tools)
+
+ assert normal_text == ""
+ assert [call.name for call in calls if call.name] == ["get_weather"]
From e3870fdfd67f2f428d4beeecf81f4eaf12b57508 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?=
<146086744+edenfunf@users.noreply.github.com>
Date: Sat, 15 Aug 2026 16:34:23 +0800
Subject: [PATCH 2/2] [#17580][chore] Document the streaming whitespace policy
and harden tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address review feedback on the streaming/non-streaming whitespace gap.
The leading segment stays verbatim. Trimming it at the split point would
make the streamed content depend on where increment boundaries fall,
because a prefix that arrives in its own increment leaves through the
no-tool-call path, which does not trim. Note also that the one-shot paths
do not agree among themselves: most strip the prefix, kimi_k2 does not.
Spell the policy out on _split_leading_normal_text instead.
Tests now reconstruct and assert the tool arguments rather than only the
name, and a new case pins the contract that emitted content is identical
across three different increment boundaries.
Signed-off-by: 許元豪 <146086744+edenfunf@users.noreply.github.com>
---
.../serve/tool_parser/base_tool_parser.py | 7 +++
.../unittest/llmapi/apps/test_tool_parsers.py | 63 +++++++++++++++----
2 files changed, 58 insertions(+), 12 deletions(-)
diff --git a/tensorrt_llm/serve/tool_parser/base_tool_parser.py b/tensorrt_llm/serve/tool_parser/base_tool_parser.py
index a57421622890..5fac1ee979fb 100644
--- a/tensorrt_llm/serve/tool_parser/base_tool_parser.py
+++ b/tensorrt_llm/serve/tool_parser/base_tool_parser.py
@@ -119,6 +119,13 @@ def _split_leading_normal_text(self, buffer: str,
of it, so it has to be returned to the client as normal text instead of
being consumed along with the markup.
+ The split is verbatim: no whitespace is trimmed. Trimming here would
+ make the streamed content depend on where increment boundaries happen
+ to fall, since a prefix that arrives in its own increment is emitted by
+ the no-tool-call path, which does not trim either. Non-streaming
+ ``detect_and_parse`` strips the prefix in most parsers, so accumulated
+ streaming content can carry whitespace that the one-shot path drops.
+
Returns a ``(normal_text, remainder)`` pair. ``normal_text`` is empty
when the buffer starts with a tool call or contains none of the tokens.
"""
diff --git a/tests/unittest/llmapi/apps/test_tool_parsers.py b/tests/unittest/llmapi/apps/test_tool_parsers.py
index fc99440e2b61..0c1b47bee965 100644
--- a/tests/unittest/llmapi/apps/test_tool_parsers.py
+++ b/tests/unittest/llmapi/apps/test_tool_parsers.py
@@ -4431,23 +4431,37 @@ class TestStreamingLeadingText:
@staticmethod
def _feed(parser, deltas, tools):
- """Stream deltas through the parser and accumulate what it emits."""
+ """Stream deltas through the parser and accumulate what it emits.
+
+ A trailing empty increment drains the arguments: parsers send the tool
+ name on the increment that completes the call and the arguments on the
+ one after it.
+ """
normal_text = ""
- calls = []
- for delta in deltas:
+ names = []
+ arguments = ""
+ for delta in [*deltas, ""]:
result = parser.parse_streaming_increment(delta, tools)
normal_text += result.normal_text
- calls.extend(result.calls)
- return normal_text, calls
+ for call in result.calls:
+ if call.name:
+ names.append(call.name)
+ arguments += call.parameters
+ return normal_text, names, arguments
+
+ def _assert_call_intact(self, names, arguments):
+ assert names == ["get_weather"]
+ assert json.loads(arguments) == {"location": "NYC"}
def test_leading_text_in_same_increment(self, sample_tools, parser_cls,
tool_call):
parser = parser_cls()
- normal_text, calls = self._feed(parser, [_LEADING_TEXT + tool_call],
- sample_tools)
+ normal_text, names, arguments = self._feed(parser,
+ [_LEADING_TEXT + tool_call],
+ sample_tools)
assert normal_text == _LEADING_TEXT
- assert [call.name for call in calls if call.name] == ["get_weather"]
+ self._assert_call_intact(names, arguments)
def test_leading_text_emitted_once_when_call_spans_increments(
self, sample_tools, parser_cls, tool_call):
@@ -4455,15 +4469,40 @@ def test_leading_text_emitted_once_when_call_spans_increments(
split_at = len(parser.bot_token)
deltas = [_LEADING_TEXT + tool_call[:split_at], tool_call[split_at:]]
- normal_text, calls = self._feed(parser, deltas, sample_tools)
+ normal_text, names, arguments = self._feed(parser, deltas, sample_tools)
assert normal_text == _LEADING_TEXT
- assert [call.name for call in calls if call.name] == ["get_weather"]
+ self._assert_call_intact(names, arguments)
def test_tool_call_without_leading_text_emits_no_content(
self, sample_tools, parser_cls, tool_call):
parser = parser_cls()
- normal_text, calls = self._feed(parser, [tool_call], sample_tools)
+ normal_text, names, arguments = self._feed(parser, [tool_call],
+ sample_tools)
assert normal_text == ""
- assert [call.name for call in calls if call.name] == ["get_weather"]
+ self._assert_call_intact(names, arguments)
+
+ def test_content_does_not_depend_on_increment_boundaries(
+ self, sample_tools, parser_cls, tool_call):
+ """What the client sees must not change with the delta boundaries.
+
+ This is why the leading segment is emitted verbatim rather than
+ stripped: a prefix arriving in its own increment leaves the parser
+ before any tool call is known, so trimming at the split point would
+ make the streamed content depend on how the text happened to be
+ chunked.
+ """
+ split_at = len(parser_cls().bot_token)
+ chunkings = (
+ [_LEADING_TEXT + tool_call],
+ [_LEADING_TEXT, tool_call],
+ [_LEADING_TEXT + tool_call[:split_at], tool_call[split_at:]],
+ )
+
+ emitted = {
+ self._feed(parser_cls(), deltas, sample_tools)[0]
+ for deltas in chunkings
+ }
+
+ assert emitted == {_LEADING_TEXT}