@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / e1a5fc406b
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Rust Frontend][Perf] O(n) argument scan in tool parser (#45826)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jun 18, 2026 at 09:42 UTC e1a5fc406b248925427a84beefbd123924f0c9e5
8 files changed +408 -72
rust/src/tool-parser/benches/qwen3_coder.rs
+23
@@ -10,6 +10,7 @@ use utils::{feed_external_parser, feed_parser, openai_tools};
10
11 const CHUNK_CHARS: usize = 7;
12 const LONG_NORMAL_TEXT_REPEATS: usize = 2048;
13 +const LONG_TOOL_BODY_REPEATS: usize = 8192;
14
15 fn mixed_fixture() -> String {
16 concat!(
@@ -39,6 +40,17 @@ fn long_normal_text_fixture() -> String {
40 line.repeat(LONG_NORMAL_TEXT_REPEATS)
41 }
42
43 +fn long_tool_call_fixture() -> String {
44 + let location = "x".repeat(LONG_TOOL_BODY_REPEATS);
45 + format!(
46 + "<tool_call>\n\
47 + <function=get_weather>\n\
48 + <parameter=location>{location}</parameter>\n\
49 + </function>\n\
50 + </tool_call>"
51 + )
52 +}
53 +
54 fn native_parser(tools: &[Tool]) -> Box<dyn ToolParser> {
55 Qwen3CoderToolParser::create(tools).expect("Qwen Coder parser should initialize")
56 }
@@ -112,6 +124,7 @@ fn bench_qwen3_coder(c: &mut Criterion) {
124 let tools = test_tools();
125 let mixed_text = mixed_fixture();
126 let long_normal_text = long_normal_text_fixture();
127 + let long_tool_call = long_tool_call_fixture();
128
129 run_stream_group(
130 c,
@@ -132,6 +145,16 @@ fn bench_qwen3_coder(c: &mut Criterion) {
145 &long_normal_text,
146 0,
147 );
148 +
149 + run_stream_group(
150 + c,
151 + "qwen3_coder/long_tool_call_body",
152 + &tools,
153 + &long_tool_call,
154 + CHUNK_CHARS,
155 + "",
156 + 1,
157 + );
158 }
159
160 criterion_group!(benches, bench_qwen3_coder);
rust/src/tool-parser/src/deepseek_dsml/mod.rs
+28 -14
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len};
8 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9 use super::{Result, ToolCallDelta, ToolParserOutput};
10 use crate::Tool;
11
@@ -39,10 +39,10 @@ impl DsmlTokens {
39 };
40 }
41
42 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 +#[derive(Debug, Clone, PartialEq, Eq)]
43 enum DsmlMode {
44 Text,
45 - ToolBlock,
45 + ToolBlock { invoke_end_scan: MarkerScanState },
46 Done,
47 }
48
@@ -94,7 +94,11 @@ impl DeepSeekDsmlToolParser {
94 DsmlEvent::Text { len: consumed_len } => {
95 output.normal_text.push_str(&self.buffer[..consumed_len]);
96 }
97 - DsmlEvent::ToolCallsStart => self.mode = DsmlMode::ToolBlock,
97 + DsmlEvent::ToolCallsStart => {
98 + self.mode = DsmlMode::ToolBlock {
99 + invoke_end_scan: MarkerScanState::default(),
100 + };
101 + }
102 DsmlEvent::Invoke { name, raw_params } => {
103 let mut arguments = serde_json::Map::with_capacity(raw_params.len());
104 for param in raw_params {
@@ -140,7 +144,7 @@ impl DeepSeekDsmlToolParser {
144 self.buffer.push_str(chunk);
145
146 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
143 - parse_next_dsml_event(input, self.mode, self.tokens)
147 + parse_next_dsml_event(input, &mut self.mode, self.tokens)
148 })? {
149 self.apply_event(event, output)?;
150 self.buffer.drain(..consumed_len);
@@ -154,7 +158,7 @@ impl DeepSeekDsmlToolParser {
158 match self.mode {
159 DsmlMode::Text => output.normal_text.push_str(&self.buffer),
160 DsmlMode::Done => {}
157 - DsmlMode::ToolBlock => {
161 + DsmlMode::ToolBlock { .. } => {
162 return Err(parsing_failed!("incomplete DeepSeek DSML tool call"));
163 }
164 }
@@ -166,12 +170,14 @@ impl DeepSeekDsmlToolParser {
170 /// Parse a DSML event for the current parser mode.
171 fn parse_next_dsml_event(
172 input: &mut DsmlInput<'_>,
169 - mode: DsmlMode,
173 + mode: &mut DsmlMode,
174 tokens: DsmlTokens,
175 ) -> ModalResult<DsmlEvent> {
176 match mode {
177 DsmlMode::Text => parse_text_event(input, tokens),
174 - DsmlMode::ToolBlock => parse_tool_block_event(input, tokens),
178 + DsmlMode::ToolBlock { invoke_end_scan } => {
179 + parse_tool_block_event(input, tokens, invoke_end_scan)
180 + }
181 DsmlMode::Done => ignored_rest_event(input),
182 }
183 }
@@ -186,11 +192,16 @@ fn parse_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResul
192 }
193
194 /// Parse a tool-block DSML event.
189 -fn parse_tool_block_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult<DsmlEvent> {
195 +fn parse_tool_block_event(
196 + input: &mut DsmlInput<'_>,
197 + tokens: DsmlTokens,
198 + invoke_end_scan: &mut MarkerScanState,
199 +) -> ModalResult<DsmlEvent> {
200 ws0.void().parse_next(input)?;
191 - alt((invoke_event, |input: &mut DsmlInput<'_>| {
192 - tool_calls_end_event(input, tokens)
193 - }))
201 + alt((
202 + |input: &mut DsmlInput<'_>| invoke_event(input, invoke_end_scan),
203 + |input: &mut DsmlInput<'_>| tool_calls_end_event(input, tokens),
204 + ))
205 .parse_next(input)
206 }
207
@@ -217,14 +228,17 @@ fn safe_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult
228 }
229
230 /// Parse a DSML invoke block.
220 -fn invoke_event(input: &mut DsmlInput<'_>) -> ModalResult<DsmlEvent> {
231 +fn invoke_event(
232 + input: &mut DsmlInput<'_>,
233 + invoke_end_scan: &mut MarkerScanState,
234 +) -> ModalResult<DsmlEvent> {
235 let (name, body) = seq!(
236 _: literal(INVOKE_START),
237 _: ws1,
238 dsml_name_attr,
239 _: ws0,
240 _: ">",
227 - take_until(0.., INVOKE_END),
241 + take_until_marker(INVOKE_END, invoke_end_scan),
242 _: literal(INVOKE_END),
243 )
244 .parse_next(input)?;
rust/src/tool-parser/src/glm_xml/mod.rs
+22 -10
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until, take_while};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len};
8 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9 use super::{Result, ToolCallDelta, ToolParserOutput};
10 use crate::Tool;
11
@@ -24,10 +24,10 @@ const ARG_VALUE_END: &str = "</arg_value>";
24
25 type GlmInput<'i> = Partial<&'i str>;
26
27 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 +#[derive(Debug, Clone, PartialEq, Eq)]
28 enum GlmMode {
29 Text,
30 - ToolCall,
30 + ToolCall { tool_call_end_scan: MarkerScanState },
31 AfterToolCall,
32 }
33
@@ -81,7 +81,11 @@ impl GlmXmlToolParser {
81 GlmEvent::Text { len: consumed_len } => {
82 output.normal_text.push_str(&self.buffer[..consumed_len]);
83 }
84 - GlmEvent::ToolCallStart => self.mode = GlmMode::ToolCall,
84 + GlmEvent::ToolCallStart => {
85 + self.mode = GlmMode::ToolCall {
86 + tool_call_end_scan: MarkerScanState::default(),
87 + };
88 + }
89 GlmEvent::ToolCall { name, raw_params } => {
90 self.mode = GlmMode::AfterToolCall;
91 let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params);
@@ -110,7 +114,7 @@ impl GlmXmlToolParser {
114 self.buffer.push_str(chunk);
115
116 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
113 - parse_next_glm_event(input, self.mode, self.separator)
117 + parse_next_glm_event(input, &mut self.mode, self.separator)
118 })? {
119 self.apply_event(event, output)?;
120 self.buffer.drain(..consumed_len);
@@ -124,7 +128,9 @@ impl GlmXmlToolParser {
128 if !self.buffer.is_empty() {
129 match self.mode {
130 GlmMode::Text => output.normal_text.push_str(&self.buffer),
127 - GlmMode::ToolCall => return Err(parsing_failed!("incomplete GLM MoE tool call")),
131 + GlmMode::ToolCall { .. } => {
132 + return Err(parsing_failed!("incomplete GLM MoE tool call"));
133 + }
134 GlmMode::AfterToolCall => {}
135 }
136 }
@@ -136,12 +142,14 @@ impl GlmXmlToolParser {
142 /// Parse a GLM event for the current parser mode.
143 fn parse_next_glm_event(
144 input: &mut GlmInput<'_>,
139 - mode: GlmMode,
145 + mode: &mut GlmMode,
146 separator: Separator,
147 ) -> ModalResult<GlmEvent> {
148 match mode {
149 GlmMode::Text => parse_text_event(input),
144 - GlmMode::ToolCall => tool_call_event(input, separator),
150 + GlmMode::ToolCall { tool_call_end_scan } => {
151 + tool_call_event(input, separator, tool_call_end_scan)
152 + }
153 GlmMode::AfterToolCall => after_tool_call_event(input),
154 }
155 }
@@ -173,9 +181,13 @@ fn ignored_rest_event(input: &mut GlmInput<'_>) -> ModalResult<GlmEvent> {
181 }
182
183 /// Parse a complete GLM tool call.
176 -fn tool_call_event(input: &mut GlmInput<'_>, separator: Separator) -> ModalResult<GlmEvent> {
184 +fn tool_call_event(
185 + input: &mut GlmInput<'_>,
186 + separator: Separator,
187 + tool_call_end_scan: &mut MarkerScanState,
188 +) -> ModalResult<GlmEvent> {
189 let (body,) = seq!(
178 - take_until(0.., TOOL_CALL_END),
190 + take_until_marker(TOOL_CALL_END, tool_call_end_scan),
191 _: literal(TOOL_CALL_END),
192 )
193 .parse_next(input)?;
rust/src/tool-parser/src/hy_v3.rs
+30 -12
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len};
8 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10 use crate::Tool;
11
@@ -21,10 +21,10 @@ const ARG_VALUE_END: &str = "</arg_value>";
21
22 type HyV3Input<'i> = Partial<&'i str>;
23
24 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 +#[derive(Debug, Clone, PartialEq, Eq)]
25 enum HyV3Mode {
26 Text,
27 - ToolBlock,
27 + ToolBlock { tool_call_end_scan: MarkerScanState },
28 Done,
29 }
30
@@ -81,7 +81,11 @@ impl HyV3ToolParser {
81 HyV3Event::Text { len: consumed_len } => {
82 output.normal_text.push_str(&self.buffer[..consumed_len]);
83 }
84 - HyV3Event::ToolBlockStart => self.mode = HyV3Mode::ToolBlock,
84 + HyV3Event::ToolBlockStart => {
85 + self.mode = HyV3Mode::ToolBlock {
86 + tool_call_end_scan: MarkerScanState::default(),
87 + };
88 + }
89 HyV3Event::ToolCall { name, raw_params } => {
90 let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params);
91 let arguments = serde_json::to_string(&arguments)
@@ -113,7 +117,7 @@ impl ToolParser for HyV3ToolParser {
117 self.buffer.push_str(chunk);
118
119 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
116 - parse_next_hy_v3_event(input, self.mode)
120 + parse_next_hy_v3_event(input, &mut self.mode)
121 })? {
122 self.apply_event(event, output)?;
123 self.buffer.drain(..consumed_len);
@@ -126,7 +130,7 @@ impl ToolParser for HyV3ToolParser {
130 let mut output = ToolParserOutput::default();
131 match self.mode {
132 HyV3Mode::Text => output.normal_text.push_str(&self.buffer),
129 - HyV3Mode::ToolBlock => return Err(parsing_failed!("incomplete HY3 tool call")),
133 + HyV3Mode::ToolBlock { .. } => return Err(parsing_failed!("incomplete HY3 tool call")),
134 HyV3Mode::Done => {}
135 }
136 let _ = self.reset();
@@ -141,10 +145,15 @@ impl ToolParser for HyV3ToolParser {
145 }
146
147 /// Parse a HY3 event for the current parser mode.
144 -fn parse_next_hy_v3_event(input: &mut HyV3Input<'_>, mode: HyV3Mode) -> ModalResult<HyV3Event> {
148 +fn parse_next_hy_v3_event(
149 + input: &mut HyV3Input<'_>,
150 + mode: &mut HyV3Mode,
151 +) -> ModalResult<HyV3Event> {
152 match mode {
153 HyV3Mode::Text => parse_text_event(input),
147 - HyV3Mode::ToolBlock => parse_tool_block_event(input),
154 + HyV3Mode::ToolBlock { tool_call_end_scan } => {
155 + parse_tool_block_event(input, tool_call_end_scan)
156 + }
157 HyV3Mode::Done => ignored_rest_event(input),
158 }
159 }
@@ -165,8 +174,14 @@ fn safe_text_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
174 }
175
176 /// Parse one event inside a HY3 tool block.
168 -fn parse_tool_block_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
169 - alt((tool_block_end_event, tool_call_event)).parse_next(input)
177 +fn parse_tool_block_event(
178 + input: &mut HyV3Input<'_>,
179 + tool_call_end_scan: &mut MarkerScanState,
180 +) -> ModalResult<HyV3Event> {
181 + alt((tool_block_end_event, |input: &mut HyV3Input<'_>| {
182 + tool_call_event(input, tool_call_end_scan)
183 + }))
184 + .parse_next(input)
185 }
186
187 /// Parse a HY3 tool-block end marker.
@@ -175,13 +190,16 @@ fn tool_block_end_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
190 }
191
192 /// Parse a complete HY3 tool-call block.
178 -fn tool_call_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
193 +fn tool_call_event(
194 + input: &mut HyV3Input<'_>,
195 + tool_call_end_scan: &mut MarkerScanState,
196 +) -> ModalResult<HyV3Event> {
197 let (name, body) = seq!(
198 _: ws0,
199 _: literal(TOOL_CALL_START),
200 take_until(0.., TOOL_SEP),
201 _: literal(TOOL_SEP),
184 - take_until(0.., TOOL_CALL_END),
202 + take_until_marker(TOOL_CALL_END, tool_call_end_scan),
203 _: literal(TOOL_CALL_END),
204 )
205 .parse_next(input)?;
rust/src/tool-parser/src/minimax_m2.rs
+27 -12
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len};
8 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10 use crate::Tool;
11
@@ -18,10 +18,10 @@ const PARAMETER_END: &str = "</parameter>";
18
19 type MinimaxM2Input<'i> = Partial<&'i str>;
20
21 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 +#[derive(Debug, Clone, PartialEq, Eq)]
22 enum MinimaxM2Mode {
23 Text,
24 - ToolBlock,
24 + ToolBlock { invoke_end_scan: MarkerScanState },
25 Done,
26 }
27
@@ -74,7 +74,11 @@ impl MinimaxM2ToolParser {
74 MinimaxM2Event::Text { len: consumed_len } => {
75 output.normal_text.push_str(&self.buffer[..consumed_len]);
76 }
77 - MinimaxM2Event::ToolBlockStart => self.mode = MinimaxM2Mode::ToolBlock,
77 + MinimaxM2Event::ToolBlockStart => {
78 + self.mode = MinimaxM2Mode::ToolBlock {
79 + invoke_end_scan: MarkerScanState::default(),
80 + };
81 + }
82 MinimaxM2Event::Invoke { name, raw_params } => {
83 let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params);
84 let arguments = serde_json::to_string(&arguments)
@@ -112,7 +116,7 @@ impl ToolParser for MinimaxM2ToolParser {
116 self.buffer.push_str(chunk);
117
118 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
115 - parse_next_minimax_m2_event(input, self.mode)
119 + parse_next_minimax_m2_event(input, &mut self.mode)
120 })? {
121 self.apply_event(event, output)?;
122 self.buffer.drain(..consumed_len);
@@ -127,7 +131,7 @@ impl ToolParser for MinimaxM2ToolParser {
131 MinimaxM2Mode::Text => {
132 output.normal_text.push_str(&self.buffer);
133 }
130 - MinimaxM2Mode::ToolBlock => {
134 + MinimaxM2Mode::ToolBlock { .. } => {
135 return Err(parsing_failed!("incomplete MiniMax M2 tool call"));
136 }
137 MinimaxM2Mode::Done => {}
@@ -144,11 +148,13 @@ impl ToolParser for MinimaxM2ToolParser {
148 /// Parse a MiniMax M2 event for the current parser mode.
149 fn parse_next_minimax_m2_event(
150 input: &mut MinimaxM2Input<'_>,
147 - mode: MinimaxM2Mode,
151 + mode: &mut MinimaxM2Mode,
152 ) -> ModalResult<MinimaxM2Event> {
153 match mode {
154 MinimaxM2Mode::Text => parse_text_event(input),
151 - MinimaxM2Mode::ToolBlock => parse_tool_block_event(input),
155 + MinimaxM2Mode::ToolBlock { invoke_end_scan } => {
156 + parse_tool_block_event(input, invoke_end_scan)
157 + }
158 MinimaxM2Mode::Done => ignored_rest_event(input),
159 }
160 }
@@ -169,8 +175,14 @@ fn safe_text_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event
175 }
176
177 /// Parse one event inside a MiniMax M2 tool block.
172 -fn parse_tool_block_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
173 - alt((tool_block_end_event, invoke_event)).parse_next(input)
178 +fn parse_tool_block_event(
179 + input: &mut MinimaxM2Input<'_>,
180 + invoke_end_scan: &mut MarkerScanState,
181 +) -> ModalResult<MinimaxM2Event> {
182 + alt((tool_block_end_event, |input: &mut MinimaxM2Input<'_>| {
183 + invoke_event(input, invoke_end_scan)
184 + }))
185 + .parse_next(input)
186 }
187
188 /// Parse a MiniMax M2 tool-block end marker.
@@ -181,14 +193,17 @@ fn tool_block_end_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2
193 }
194
195 /// Parse a complete MiniMax M2 invoke block.
184 -fn invoke_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
196 +fn invoke_event(
197 + input: &mut MinimaxM2Input<'_>,
198 + invoke_end_scan: &mut MarkerScanState,
199 +) -> ModalResult<MinimaxM2Event> {
200 let (name, body) = seq!(
201 _: ws0,
202 _: literal(INVOKE_START),
203 _: (ws1, literal("name=")),
204 partial_attr_value,
205 _: literal(">"),
191 - take_until(0.., INVOKE_END),
206 + take_until_marker(INVOKE_END, invoke_end_scan),
207 _: literal(INVOKE_END),
208 )
209 .parse_next(input)?;
rust/src/tool-parser/src/minimax_m3.rs
+27 -12
@@ -6,7 +6,7 @@ use winnow::stream::Partial;
6 use winnow::token::{literal, rest, take_until};
7
8 use super::parameters::{ParamElement, ParamInput, ToolSchemas};
9 -use super::utils::{parse_buffered_event, safe_text_len};
9 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
10 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
11 use crate::Tool;
12
@@ -21,10 +21,10 @@ const MIXED_TEXT_FIELD: &str = "$text";
21
22 type MinimaxM3Input<'i> = Partial<&'i str>;
23
24 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 +#[derive(Debug, Clone, PartialEq, Eq)]
25 enum MinimaxM3Mode {
26 Text,
27 - ToolBlock,
27 + ToolBlock { invoke_end_scan: MarkerScanState },
28 Done,
29 }
30
@@ -109,7 +109,11 @@ impl MinimaxM3ToolParser {
109 MinimaxM3Event::Text { len: consumed_len } => {
110 output.normal_text.push_str(&self.buffer[..consumed_len]);
111 }
112 - MinimaxM3Event::ToolBlockStart => self.mode = MinimaxM3Mode::ToolBlock,
112 + MinimaxM3Event::ToolBlockStart => {
113 + self.mode = MinimaxM3Mode::ToolBlock {
114 + invoke_end_scan: MarkerScanState::default(),
115 + };
116 + }
117 MinimaxM3Event::Invoke { name, params } => {
118 let arguments = self.tool_parameters.convert_params_with_schema(&name, params);
119 let arguments = serde_json::to_string(&arguments)
@@ -141,7 +145,7 @@ impl ToolParser for MinimaxM3ToolParser {
145 self.buffer.push_str(chunk);
146
147 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
144 - parse_next_minimax_m3_event(input, self.mode)
148 + parse_next_minimax_m3_event(input, &mut self.mode)
149 })? {
150 self.apply_event(event, output)?;
151 self.buffer.drain(..consumed_len);
@@ -156,7 +160,7 @@ impl ToolParser for MinimaxM3ToolParser {
160 MinimaxM3Mode::Text => {
161 output.normal_text.push_str(&self.buffer);
162 }
159 - MinimaxM3Mode::ToolBlock => {
163 + MinimaxM3Mode::ToolBlock { .. } => {
164 if !self.buffer.trim_start().is_empty() {
165 return Err(parsing_failed!("incomplete MiniMax M3 tool call"));
166 }
@@ -177,11 +181,13 @@ impl ToolParser for MinimaxM3ToolParser {
181 /// Parse a MiniMax M3 event for the current parser mode.
182 fn parse_next_minimax_m3_event(
183 input: &mut MinimaxM3Input<'_>,
180 - mode: MinimaxM3Mode,
184 + mode: &mut MinimaxM3Mode,
185 ) -> ModalResult<MinimaxM3Event> {
186 match mode {
187 MinimaxM3Mode::Text => parse_text_event(input),
184 - MinimaxM3Mode::ToolBlock => parse_tool_block_event(input),
188 + MinimaxM3Mode::ToolBlock { invoke_end_scan } => {
189 + parse_tool_block_event(input, invoke_end_scan)
190 + }
191 MinimaxM3Mode::Done => ignored_rest_event(input),
192 }
193 }
@@ -202,8 +208,14 @@ fn safe_text_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event
208 }
209
210 /// Parse one event inside a MiniMax M3 tool block.
205 -fn parse_tool_block_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
206 - alt((tool_block_end_event, invoke_event)).parse_next(input)
211 +fn parse_tool_block_event(
212 + input: &mut MinimaxM3Input<'_>,
213 + invoke_end_scan: &mut MarkerScanState,
214 +) -> ModalResult<MinimaxM3Event> {
215 + alt((tool_block_end_event, |input: &mut MinimaxM3Input<'_>| {
216 + invoke_event(input, invoke_end_scan)
217 + }))
218 + .parse_next(input)
219 }
220
221 /// Parse a MiniMax M3 tool-block end marker.
@@ -214,14 +226,17 @@ fn tool_block_end_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3
226 }
227
228 /// Parse a complete MiniMax M3 invoke block.
217 -fn invoke_event(input: &mut MinimaxM3Input<'_>) -> ModalResult<MinimaxM3Event> {
229 +fn invoke_event(
230 + input: &mut MinimaxM3Input<'_>,
231 + invoke_end_scan: &mut MarkerScanState,
232 +) -> ModalResult<MinimaxM3Event> {
233 let (name, body) = seq!(
234 _: ws0,
235 _: literal(INVOKE_START),
236 _: (ws1, literal("name=")),
237 partial_attr_value,
238 _: literal(">"),
224 - take_until(0.., INVOKE_END),
239 + take_until_marker(INVOKE_END, invoke_end_scan),
240 _: literal(INVOKE_END),
241 )
242 .parse_next(input)?;
rust/src/tool-parser/src/qwen_coder.rs
+82 -11
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len};
8 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
9 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10 use crate::Tool;
11
@@ -18,10 +18,10 @@ const PARAMETER_END: &str = "</parameter>";
18
19 type QwenCoderInput<'i> = Partial<&'i str>;
20
21 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 +#[derive(Debug, Clone, PartialEq, Eq)]
22 enum QwenCoderMode {
23 Text,
24 - ToolCall,
24 + ToolCall { end_marker_scan: MarkerScanState },
25 }
26
27 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -76,7 +76,11 @@ impl Qwen3CoderToolParser {
76 QwenCoderEvent::Text { len: consumed_len } => {
77 output.normal_text.push_str(&self.buffer[..consumed_len]);
78 }
79 - QwenCoderEvent::ToolCallStart => self.mode = QwenCoderMode::ToolCall,
79 + QwenCoderEvent::ToolCallStart => {
80 + self.mode = QwenCoderMode::ToolCall {
81 + end_marker_scan: MarkerScanState::default(),
82 + };
83 + }
84 QwenCoderEvent::ToolCall { name, raw_params } => {
85 self.mode = QwenCoderMode::Text;
86 let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params);
@@ -113,7 +117,7 @@ impl ToolParser for Qwen3CoderToolParser {
117 self.buffer.push_str(chunk);
118
119 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
116 - parse_next_qwen_coder_event(input, self.mode)
120 + parse_next_qwen_coder_event(input, &mut self.mode)
121 })? {
122 self.apply_event(event, output)?;
123 self.buffer.drain(..consumed_len);
@@ -125,7 +129,9 @@ impl ToolParser for Qwen3CoderToolParser {
129 fn finish(&mut self) -> Result<ToolParserOutput> {
130 let mut output = ToolParserOutput::default();
131 if !self.buffer.is_empty() {
128 - if self.mode == QwenCoderMode::ToolCall || self.buffer.starts_with(TOOL_CALL_START) {
132 + if matches!(self.mode, QwenCoderMode::ToolCall { .. })
133 + || self.buffer.starts_with(TOOL_CALL_START)
134 + {
135 return Err(parsing_failed!("incomplete Qwen Coder tool call"));
136 }
137 output.normal_text.push_str(&self.buffer);
@@ -142,11 +148,11 @@ impl ToolParser for Qwen3CoderToolParser {
148 /// Parse a Qwen Coder event for the current parser mode.
149 fn parse_next_qwen_coder_event(
150 input: &mut QwenCoderInput<'_>,
145 - mode: QwenCoderMode,
151 + mode: &mut QwenCoderMode,
152 ) -> ModalResult<QwenCoderEvent> {
153 match mode {
154 QwenCoderMode::Text => parse_text_event(input),
149 - QwenCoderMode::ToolCall => tool_call_event(input),
155 + QwenCoderMode::ToolCall { end_marker_scan } => tool_call_event(input, end_marker_scan),
156 }
157 }
158
@@ -166,10 +172,13 @@ fn safe_text_event(input: &mut QwenCoderInput<'_>) -> ModalResult<QwenCoderEvent
172 }
173
174 /// Parse a complete Qwen Coder tool call.
169 -fn tool_call_event(input: &mut QwenCoderInput<'_>) -> ModalResult<QwenCoderEvent> {
175 +fn tool_call_event(
176 + input: &mut QwenCoderInput<'_>,
177 + end_marker_scan: &mut MarkerScanState,
178 +) -> ModalResult<QwenCoderEvent> {
179 let (body,) = seq!(
180 _: ws0,
172 - take_until(0.., TOOL_CALL_END),
181 + take_until_marker(TOOL_CALL_END, end_marker_scan),
182 _: literal(TOOL_CALL_END),
183 )
184 .parse_next(input)?;
@@ -228,8 +237,8 @@ mod tests {
237 use thiserror_ext::AsReport;
238
239 use super::{Qwen3CoderToolParser, ToolParser};
231 - use crate::ToolParserTestExt as _;
240 use crate::test_utils::{collect_stream, split_by_chars, test_tools};
241 + use crate::{ToolParserOutput, ToolParserTestExt as _};
242
243 fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
244 let params = params
@@ -574,6 +583,68 @@ mod tests {
583 );
584 }
585
586 + #[test]
587 + fn qwen_coder_streaming_handles_end_token_split_across_chunks() {
588 + let mut parser = Qwen3CoderToolParser::new(&test_tools());
589 + let output = parser
590 + .parse_chunk(
591 + "<tool_call>\n\
592 + <function=get_weather>\n\
593 + <parameter=location>SF</parameter>\n\
594 + </function>\n\
595 + </tool",
596 + )
597 + .unwrap();
598 +
599 + assert!(output.normal_text.is_empty());
600 + assert!(output.calls.is_empty());
601 +
602 + let mut output = output;
603 + output.append(parser.parse_chunk("_call>").unwrap());
604 + output.append(parser.finish().unwrap());
605 + let output = output.coalesce_calls();
606 +
607 + assert!(output.normal_text.is_empty());
608 + assert_eq!(output.calls.len(), 1);
609 + assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
610 + assert_eq!(
611 + serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
612 + json!({ "location": "SF" })
613 + );
614 + }
615 +
616 + #[test]
617 + fn qwen_coder_streaming_buffers_long_body_until_end_marker() {
618 + let long_location = format!("SF-{}", "x".repeat(8192));
619 + let text = build_tool_call("get_weather", &[("location", &long_location)]);
620 + let split_at = text.len() - "_call>".len();
621 + let (body_with_partial_end, end_suffix) = text.split_at(split_at);
622 + let chunks = split_by_chars(body_with_partial_end, 31);
623 + let mut parser = Qwen3CoderToolParser::new(&test_tools());
624 + let mut output = ToolParserOutput::default();
625 +
626 + assert_eq!(end_suffix, "_call>");
627 +
628 + for chunk in chunks {
629 + let chunk_output = parser.parse_chunk(chunk).unwrap();
630 + assert!(chunk_output.normal_text.is_empty());
631 + assert!(chunk_output.calls.is_empty());
632 + output.append(chunk_output);
633 + }
634 +
635 + output.append(parser.parse_chunk(end_suffix).unwrap());
636 + output.append(parser.finish().unwrap());
637 + let output = output.coalesce_calls();
638 +
639 + assert!(output.normal_text.is_empty());
640 + assert_eq!(output.calls.len(), 1);
641 + assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
642 + assert_eq!(
643 + serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
644 + json!({ "location": long_location })
645 + );
646 + }
647 +
648 #[test]
649 fn qwen_coder_streaming_does_not_emit_incomplete_tool_call() {
650 let mut parser = Qwen3CoderToolParser::new(&test_tools());
rust/src/tool-parser/src/utils.rs
+169 -1
@@ -1,5 +1,6 @@
1 //! Shared helpers for tool parsers.
2
3 +use winnow::Parser;
4 use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue};
5 use winnow::stream::{Offset, Partial, Stream};
6
@@ -66,6 +67,74 @@ pub(super) fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalRes
67 Ok(emit_len)
68 }
69
70 +/// Streaming scan state for a buffered marker search [`take_until_marker`],
71 +/// so that we don't have to rescan the whole buffered prefix when resuming.
72 +#[derive(Debug, Clone, Default, PartialEq, Eq)]
73 +pub(super) struct MarkerScanState {
74 + scan_start: usize,
75 +}
76 +
77 +impl MarkerScanState {
78 + pub(super) fn reset(&mut self) {
79 + self.scan_start = 0;
80 + }
81 +}
82 +
83 +/// Parse text until `marker`, resuming from the last safe scan checkpoint.
84 +///
85 +/// This is the streaming-buffered variant of `winnow::token::take_until(0..,
86 +/// marker)`: it returns the slice before `marker` and leaves `marker` for the
87 +/// caller to consume. On incomplete input, it stores the earliest byte offset
88 +/// that can still match `marker` and returns `Incomplete` without consuming
89 +/// input, so the next parse can avoid rescanning the whole buffered prefix.
90 +///
91 +/// Use this for outer parser states that keep the full buffered input across
92 +/// chunks while waiting for a closing marker. Plain `take_until` is still a
93 +/// better fit for one-shot parsers over a complete body, and for `1..` cases
94 +/// where an empty slice before the marker should be rejected.
95 +pub(super) fn take_until_marker<'i, 'a>(
96 + marker: &'a str,
97 + state: &'a mut MarkerScanState,
98 +) -> impl Parser<Partial<&'i str>, &'i str, ErrMode<ContextError>> + 'a {
99 + move |input: &mut Partial<&'i str>| take_until_marker_(input, marker, state)
100 +}
101 +
102 +fn take_until_marker_<'i>(
103 + input: &mut Partial<&'i str>,
104 + marker: &str,
105 + state: &mut MarkerScanState,
106 +) -> ModalResult<&'i str> {
107 + debug_assert!(!marker.is_empty());
108 +
109 + let text = **input;
110 + if text.is_empty() {
111 + return incomplete();
112 + }
113 +
114 + // Normal updates store a char boundary; this keeps stale or misused state from panicking.
115 + let scan_start = floor_char_boundary(text, state.scan_start);
116 +
117 + if let Some(offset) = text[scan_start..].find(marker) {
118 + let marker_start = scan_start + offset;
119 + let body = &text[..marker_start];
120 + input.next_slice(marker_start);
121 + state.reset();
122 + return Ok(body);
123 + }
124 +
125 + let keep_len = partial_prefix_len(text, marker);
126 + state.scan_start = text.len() - keep_len;
127 + incomplete()
128 +}
129 +
130 +fn floor_char_boundary(text: &str, index: usize) -> usize {
131 + let mut index = index.min(text.len());
132 + while !text.is_char_boundary(index) {
133 + index -= 1;
134 + }
135 + index
136 +}
137 +
138 /// Streaming lexical state for a top-level JSON object.
139 #[derive(Debug, Clone, Default, PartialEq, Eq)]
140 pub(super) struct JsonObjectScanState {
@@ -273,11 +342,13 @@ pub(super) fn incomplete<T>() -> ModalResult<T> {
342 mod tests {
343
344 use expect_test::expect;
345 + use winnow::Parser;
346 use winnow::error::ErrMode;
347 use winnow::stream::{Offset, Partial, Stream};
348
349 use super::{
280 - JsonObjectScanState, json_str, partial_prefix_len, safe_text_len, take_json_object,
350 + JsonObjectScanState, MarkerScanState, json_str, partial_prefix_len, safe_text_len,
351 + take_json_object, take_until_marker,
352 };
353
354 #[test]
@@ -335,6 +406,103 @@ mod tests {
406 assert!(matches!(error, ErrMode::Incomplete(_)));
407 }
408
409 + #[test]
410 + fn take_until_marker_stops_before_marker() {
411 + let mut state = MarkerScanState::default();
412 + let mut input = Partial::new("body</end>tail");
413 + let checkpoint = input.checkpoint();
414 +
415 + let body = take_until_marker("</end>", &mut state).parse_next(&mut input).unwrap();
416 +
417 + assert_eq!(body, "body");
418 + assert_eq!(input.offset_from(&checkpoint), "body".len());
419 + assert_eq!(*input, "</end>tail");
420 + assert_eq!(state, MarkerScanState::default());
421 + }
422 +
423 + #[test]
424 + fn take_until_marker_resumes_after_split_marker() {
425 + let mut state = MarkerScanState::default();
426 + let mut input = Partial::new("body</to");
427 +
428 + let error = take_until_marker("</tool_call>", &mut state)
429 + .parse_next(&mut input)
430 + .unwrap_err();
431 +
432 + assert!(matches!(error, ErrMode::Incomplete(_)));
433 + assert_eq!(state.scan_start, "body".len());
434 +
435 + let mut input = Partial::new("body</tool_call>tail");
436 + let body = take_until_marker("</tool_call>", &mut state).parse_next(&mut input).unwrap();
437 +
438 + assert_eq!(body, "body");
439 + assert_eq!(*input, "</tool_call>tail");
440 + assert_eq!(state, MarkerScanState::default());
441 + }
442 +
443 + #[test]
444 + fn take_until_marker_advances_checkpoint_for_long_prefix() {
445 + let mut state = MarkerScanState::default();
446 + let text = format!("{}{}", "x".repeat(1024), "</too");
447 + let mut input = Partial::new(text.as_str());
448 +
449 + let error = take_until_marker("</tool_call>", &mut state)
450 + .parse_next(&mut input)
451 + .unwrap_err();
452 +
453 + assert!(matches!(error, ErrMode::Incomplete(_)));
454 + assert_eq!(state.scan_start, 1024);
455 + }
456 +
457 + #[test]
458 + fn take_until_marker_keeps_unicode_marker_boundaries() {
459 + let marker = "<|DSML|function_calls>";
460 + let mut state = MarkerScanState::default();
461 + let mut input = Partial::new("prefix <|DSML|fun");
462 +
463 + let error = take_until_marker(marker, &mut state).parse_next(&mut input).unwrap_err();
464 +
465 + assert!(matches!(error, ErrMode::Incomplete(_)));
466 + assert_eq!(state.scan_start, "prefix ".len());
467 + assert!("prefix <|DSML|fun".is_char_boundary(state.scan_start));
468 +
469 + let mut input = Partial::new("prefix <|DSML|function_calls>tail");
470 + let body = take_until_marker(marker, &mut state).parse_next(&mut input).unwrap();
471 +
472 + assert_eq!(body, "prefix ");
473 + assert_eq!(*input, "<|DSML|function_calls>tail");
474 + assert_eq!(state, MarkerScanState::default());
475 + }
476 +
477 + #[test]
478 + fn take_until_marker_floors_stale_checkpoint_to_char_boundary() {
479 + let mut state = MarkerScanState { scan_start: 1 };
480 + let mut input = Partial::new("é</end>");
481 +
482 + let body = take_until_marker("</end>", &mut state).parse_next(&mut input).unwrap();
483 +
484 + assert_eq!(body, "é");
485 + assert_eq!(*input, "</end>");
486 + assert_eq!(state, MarkerScanState::default());
487 + }
488 +
489 + #[test]
490 + fn take_until_marker_handles_overlapping_prefixes() {
491 + let mut state = MarkerScanState::default();
492 + let mut input = Partial::new("xxaba");
493 +
494 + let error = take_until_marker("ababa", &mut state).parse_next(&mut input).unwrap_err();
495 +
496 + assert!(matches!(error, ErrMode::Incomplete(_)));
497 + assert_eq!(state.scan_start, 2);
498 +
499 + let mut input = Partial::new("xxababa!");
500 + let body = take_until_marker("ababa", &mut state).parse_next(&mut input).unwrap();
501 +
502 + assert_eq!(body, "xx");
503 + assert_eq!(*input, "ababa!");
504 + }
505 +
506 #[test]
507 fn take_json_object_consumes_simple_object() {
508 let mut state = JsonObjectScanState::default();