A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 2, 13:30 UTC
[Rust Frontend] Add `hy_v3` tool parser (#43872)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
May 28, 2026 at 22:42 UTC
3a282230ee5b58e7331910a81bedacdd0e2ac7f1
6 files changed
+583
-7
rust/src/chat/src/lib.rs
+1
-1
@@ -233,7 +233,7 @@ mod tests {
233
)
234
.unwrap_err();
235
236
- expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, kimi_k2, llama3_json, llama4_json, minimax_m2, mistral, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
236
+ expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, hy_v3, kimi_k2, llama3_json, llama4_json, minimax_m2, mistral, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
237
}
238
239
#[test]
rust/src/chat/src/parser/tool/mod.rs
+8
-3
@@ -4,9 +4,10 @@ use std::sync::LazyLock;
4
5
pub use vllm_tool_parser::{
6
DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
7
- Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, HermesToolParser, KimiK2ToolParser,
8
- Llama3JsonToolParser, MinimaxM2ToolParser, MistralToolParser, Qwen3CoderToolParser,
9
- Qwen3XmlToolParser, ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput,
7
+ Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, HermesToolParser, HyV3ToolParser,
8
+ KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser, MistralToolParser,
9
+ Qwen3CoderToolParser, Qwen3XmlToolParser, ToolCallDelta, ToolParser, ToolParserError,
10
+ ToolParserOutput,
11
};
12
13
use crate::parser::ParserFactory;
@@ -22,6 +23,7 @@ pub mod names {
23
pub const GLM47: &str = "glm47";
24
pub const GEMMA4: &str = "gemma4";
25
pub const HERMES: &str = "hermes";
26
+ pub const HY_V3: &str = "hy_v3";
27
pub const KIMI_K2: &str = "kimi_k2";
28
pub const LLAMA3_JSON: &str = "llama3_json";
29
pub const LLAMA4_JSON: &str = "llama4_json";
@@ -59,6 +61,7 @@ impl ToolParserFactory {
61
.register_parser::<Glm47MoeToolParser>(names::GLM47)
62
.register_parser::<Gemma4ToolParser>(names::GEMMA4)
63
.register_parser::<HermesToolParser>(names::HERMES)
64
+ .register_parser::<HyV3ToolParser>(names::HY_V3)
65
.register_parser::<KimiK2ToolParser>(names::KIMI_K2)
66
.register_parser::<Llama3JsonToolParser>(names::LLAMA3_JSON)
67
.register_parser::<Llama3JsonToolParser>(names::LLAMA4_JSON)
@@ -75,6 +78,8 @@ impl ToolParserFactory {
78
.register_pattern("qwen3.5", names::QWEN3_CODER)
79
.register_pattern("qwen", names::QWEN3_XML)
80
.register_pattern("hermes", names::HERMES)
81
+ .register_pattern("hy3", names::HY_V3)
82
+ .register_pattern("hy_v3", names::HY_V3)
83
.register_pattern("llama-4", names::LLAMA4_JSON)
84
.register_pattern("llama-3.2", names::LLAMA3_JSON)
85
.register_pattern("llama-3.1", names::LLAMA3_JSON)
rust/src/chat/src/parser/tool/tests.rs
+4
@@ -149,6 +149,10 @@ fn factory_new_resolves_default_patterns() {
149
factory.resolve_name_for_model("NousResearch/Hermes-3-Llama-3.1-8B"),
150
Some(names::HERMES)
151
);
152
+ assert_eq!(
153
+ factory.resolve_name_for_model("tencent/Hy3-preview"),
154
+ Some(names::HY_V3)
155
+ );
156
assert_eq!(
157
factory.resolve_name_for_model("MiniMax/MiniMax-M2-01"),
158
Some(names::MINIMAX_M2)
rust/src/tool-parser/src/hy_v3.rs
new
+559
@@ -0,0 +1,559 @@
1
+use winnow::ascii::multispace0 as ws0;
2
+use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
3
+use winnow::prelude::*;
4
+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};
9
+use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10
+use crate::Tool;
11
+
12
+const TOOL_CALLS_START: &str = "<tool_calls>";
13
+const TOOL_CALLS_END: &str = "</tool_calls>";
14
+const TOOL_CALL_START: &str = "<tool_call>";
15
+const TOOL_CALL_END: &str = "</tool_call>";
16
+const TOOL_SEP: &str = "<tool_sep>";
17
+const ARG_KEY_START: &str = "<arg_key>";
18
+const ARG_KEY_END: &str = "</arg_key>";
19
+const ARG_VALUE_START: &str = "<arg_value>";
20
+const ARG_VALUE_END: &str = "</arg_value>";
21
+
22
+type HyV3Input<'i> = Partial<&'i str>;
23
+
24
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25
+enum HyV3Mode {
26
+ Text,
27
+ ToolBlock,
28
+ Done,
29
+}
30
+
31
+#[derive(Debug, Clone, PartialEq, Eq)]
32
+enum HyV3Event {
33
+ Text {
34
+ len: usize,
35
+ },
36
+ ToolBlockStart,
37
+ ToolCall {
38
+ name: String,
39
+ raw_params: Vec<(String, String)>,
40
+ },
41
+ ToolBlockEnd,
42
+ IgnoredRest,
43
+}
44
+
45
+/// Tool parser for HY3 XML-style tool calls.
46
+///
47
+/// Example tool call content:
48
+///
49
+/// ```text
50
+/// <tool_calls>
51
+/// <tool_call>get_weather<tool_sep>
52
+/// <arg_key>city</arg_key><arg_value>Beijing</arg_value>
53
+/// </tool_call>
54
+/// </tool_calls>
55
+/// ```
56
+///
57
+/// Arguments are emitted only after a full `<tool_call>` block is parsed.
58
+/// HY3 marker tokens are added-vocabulary tokens rather than tokenizer special
59
+/// tokens, so the default `preserve_special_tokens() == false` is sufficient.
60
+pub struct HyV3ToolParser {
61
+ buffer: String,
62
+ mode: HyV3Mode,
63
+ emitted_tool_count: usize,
64
+ tool_parameters: ToolSchemas,
65
+}
66
+
67
+impl HyV3ToolParser {
68
+ /// Create a HY3 tool parser.
69
+ fn new(tools: &[Tool]) -> Self {
70
+ Self {
71
+ buffer: String::new(),
72
+ mode: HyV3Mode::Text,
73
+ emitted_tool_count: 0,
74
+ tool_parameters: ToolSchemas::from_tools(tools),
75
+ }
76
+ }
77
+
78
+ /// Apply one parsed HY3 event to parser state and output.
79
+ fn apply_event(&mut self, event: HyV3Event, output: &mut ToolParserOutput) -> Result<()> {
80
+ match event {
81
+ HyV3Event::Text { len: consumed_len } => {
82
+ output.normal_text.push_str(&self.buffer[..consumed_len]);
83
+ }
84
+ HyV3Event::ToolBlockStart => self.mode = HyV3Mode::ToolBlock,
85
+ HyV3Event::ToolCall { name, raw_params } => {
86
+ let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params);
87
+ let arguments = serde_json::to_string(&arguments)
88
+ .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?;
89
+
90
+ output.calls.push(ToolCallDelta {
91
+ tool_index: self.emitted_tool_count,
92
+ name: Some(name),
93
+ arguments,
94
+ });
95
+ self.emitted_tool_count += 1;
96
+ }
97
+ HyV3Event::ToolBlockEnd => self.mode = HyV3Mode::Done,
98
+ HyV3Event::IgnoredRest => {}
99
+ }
100
+ Ok(())
101
+ }
102
+}
103
+
104
+impl ToolParser for HyV3ToolParser {
105
+ fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
106
+ where
107
+ Self: Sized + 'static,
108
+ {
109
+ Ok(Box::new(Self::new(tools)))
110
+ }
111
+
112
+ fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
113
+ self.buffer.push_str(chunk);
114
+
115
+ while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
116
+ parse_next_hy_v3_event(input, self.mode)
117
+ })? {
118
+ self.apply_event(event, output)?;
119
+ self.buffer.drain(..consumed_len);
120
+ }
121
+
122
+ Ok(())
123
+ }
124
+
125
+ fn finish(&mut self) -> Result<ToolParserOutput> {
126
+ let mut output = ToolParserOutput::default();
127
+ match self.mode {
128
+ HyV3Mode::Text => output.normal_text.push_str(&self.buffer),
129
+ HyV3Mode::ToolBlock => return Err(parsing_failed!("incomplete HY3 tool call")),
130
+ HyV3Mode::Done => {}
131
+ }
132
+ let _ = self.reset();
133
+ Ok(output)
134
+ }
135
+
136
+ fn reset(&mut self) -> String {
137
+ self.mode = HyV3Mode::Text;
138
+ self.emitted_tool_count = 0;
139
+ std::mem::take(&mut self.buffer)
140
+ }
141
+}
142
+
143
+/// Parse a HY3 event for the current parser mode.
144
+fn parse_next_hy_v3_event(input: &mut HyV3Input<'_>, mode: HyV3Mode) -> ModalResult<HyV3Event> {
145
+ match mode {
146
+ HyV3Mode::Text => parse_text_event(input),
147
+ HyV3Mode::ToolBlock => parse_tool_block_event(input),
148
+ HyV3Mode::Done => ignored_rest_event(input),
149
+ }
150
+}
151
+
152
+/// Parse a text-mode HY3 event.
153
+fn parse_text_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
154
+ alt((tool_block_start_event, safe_text_event)).parse_next(input)
155
+}
156
+
157
+/// Parse a HY3 tool-block start marker.
158
+fn tool_block_start_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
159
+ literal(TOOL_CALLS_START).value(HyV3Event::ToolBlockStart).parse_next(input)
160
+}
161
+
162
+/// Parse a safe text run before the next HY3 marker.
163
+fn safe_text_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
164
+ safe_text_len(input, TOOL_CALLS_START).map(|len| HyV3Event::Text { len })
165
+}
166
+
167
+/// 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)
170
+}
171
+
172
+/// Parse a HY3 tool-block end marker.
173
+fn tool_block_end_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
174
+ (ws0, literal(TOOL_CALLS_END)).value(HyV3Event::ToolBlockEnd).parse_next(input)
175
+}
176
+
177
+/// Parse a complete HY3 tool-call block.
178
+fn tool_call_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
179
+ let (name, body) = seq!(
180
+ _: ws0,
181
+ _: literal(TOOL_CALL_START),
182
+ take_until(0.., TOOL_SEP),
183
+ _: literal(TOOL_SEP),
184
+ take_until(0.., TOOL_CALL_END),
185
+ _: literal(TOOL_CALL_END),
186
+ )
187
+ .parse_next(input)?;
188
+ let raw_params = parse_tool_call_params(body)?;
189
+
190
+ Ok(HyV3Event::ToolCall {
191
+ name: name.trim().to_string(),
192
+ raw_params,
193
+ })
194
+}
195
+
196
+/// Parse all parameter blocks inside a complete HY3 tool call.
197
+fn parse_tool_call_params(tool_call_body: &str) -> ModalResult<Vec<(String, String)>> {
198
+ let mut input = tool_call_body;
199
+ delimited(ws0, repeat(0.., terminated(parameter, ws0)), eof).parse_next(&mut input)
200
+}
201
+
202
+/// Parse a HY3 argument key/value block.
203
+fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
204
+ let (name, value) = seq!(
205
+ _: literal(ARG_KEY_START),
206
+ take_until(0.., ARG_KEY_END),
207
+ _: literal(ARG_KEY_END),
208
+ _: ws0,
209
+ _: literal(ARG_VALUE_START),
210
+ take_until(0.., ARG_VALUE_END),
211
+ _: literal(ARG_VALUE_END),
212
+ )
213
+ .parse_next(input)?;
214
+
215
+ Ok((name.trim().to_string(), value.to_string()))
216
+}
217
+
218
+/// Parse ignored rest after the HY3 tool block ends.
219
+fn ignored_rest_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
220
+ rest.value(HyV3Event::IgnoredRest).parse_next(input)
221
+}
222
+
223
+#[cfg(test)]
224
+mod tests {
225
+ use expect_test::expect;
226
+ use serde_json::{Value, json};
227
+ use thiserror_ext::AsReport;
228
+
229
+ use super::{HyV3ToolParser, ToolParser};
230
+ use crate::test_utils::{collect_stream, split_by_chars, test_tools};
231
+ use crate::{ToolParserOutput, ToolParserTestExt as _};
232
+
233
+ fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
234
+ let params = params
235
+ .iter()
236
+ .map(|(name, value)| format!("<arg_key>{name}</arg_key><arg_value>{value}</arg_value>"))
237
+ .collect::<Vec<_>>()
238
+ .join("\n");
239
+ format!("<tool_call>{function_name}<tool_sep>{params}</tool_call>")
240
+ }
241
+
242
+ fn build_tool_calls(tool_calls: &[String]) -> String {
243
+ format!("<tool_calls>\n{}\n</tool_calls>", tool_calls.join("\n"))
244
+ }
245
+
246
+ fn parsed_arguments(output: &ToolParserOutput, index: usize) -> Value {
247
+ serde_json::from_str(&output.calls[index].arguments).unwrap()
248
+ }
249
+
250
+ #[test]
251
+ fn hy_v3_does_not_preserve_special_tokens() {
252
+ let parser = HyV3ToolParser::new(&test_tools());
253
+
254
+ assert!(!parser.preserve_special_tokens());
255
+ }
256
+
257
+ #[test]
258
+ fn hy_v3_parse_complete_without_tool_call_keeps_text() {
259
+ let mut parser = HyV3ToolParser::new(&test_tools());
260
+ let output = parser.parse_complete("This is a plain response.").unwrap();
261
+
262
+ assert_eq!(output.normal_text, "This is a plain response.");
263
+ assert!(output.calls.is_empty());
264
+ }
265
+
266
+ #[test]
267
+ fn hy_v3_parse_complete_extracts_zero_arg_inline_tool_call() {
268
+ let mut parser = HyV3ToolParser::new(&test_tools());
269
+ let output = parser
270
+ .parse_complete(
271
+ "<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>",
272
+ )
273
+ .unwrap();
274
+
275
+ assert_eq!(output.normal_text, "");
276
+ assert_eq!(output.calls.len(), 1);
277
+ assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date"));
278
+ assert_eq!(parsed_arguments(&output, 0), json!({}));
279
+ }
280
+
281
+ #[test]
282
+ fn hy_v3_parse_complete_extracts_zero_arg_newline_tool_call() {
283
+ let mut parser = HyV3ToolParser::new(&test_tools());
284
+ let output = parser
285
+ .parse_complete(
286
+ "<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>",
287
+ )
288
+ .unwrap();
289
+
290
+ assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date"));
291
+ assert_eq!(parsed_arguments(&output, 0), json!({}));
292
+ }
293
+
294
+ #[test]
295
+ fn hy_v3_parse_complete_extracts_arguments_on_same_line() {
296
+ let mut parser = HyV3ToolParser::new(&test_tools());
297
+ let output = parser
298
+ .parse_complete(
299
+ "<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing</arg_value><arg_key>date</arg_key><arg_value>2026-03-30</arg_value></tool_call></tool_calls>",
300
+ )
301
+ .unwrap();
302
+
303
+ assert_eq!(
304
+ parsed_arguments(&output, 0),
305
+ json!({ "city": "Beijing", "date": "2026-03-30" })
306
+ );
307
+ }
308
+
309
+ #[test]
310
+ fn hy_v3_parse_complete_extracts_arguments_with_newlines() {
311
+ let mut parser = HyV3ToolParser::new(&test_tools());
312
+ let output = parser
313
+ .parse_complete(&build_tool_calls(&[build_tool_call(
314
+ "get_weather",
315
+ &[("city", "Beijing"), ("date", "2026-03-30")],
316
+ )]))
317
+ .unwrap();
318
+
319
+ assert_eq!(
320
+ parsed_arguments(&output, 0),
321
+ json!({ "city": "Beijing", "date": "2026-03-30" })
322
+ );
323
+ }
324
+
325
+ #[test]
326
+ fn hy_v3_parse_complete_preserves_prefix_and_ignores_trailing_text() {
327
+ let mut parser = HyV3ToolParser::new(&test_tools());
328
+ let output = parser
329
+ .parse_complete(&format!(
330
+ "Checking.{} trailing text",
331
+ build_tool_calls(&[build_tool_call("get_current_date", &[])])
332
+ ))
333
+ .unwrap();
334
+
335
+ assert_eq!(output.normal_text, "Checking.");
336
+ assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date"));
337
+ }
338
+
339
+ #[test]
340
+ fn hy_v3_parse_complete_extracts_multiple_tool_calls_in_one_block() {
341
+ let mut parser = HyV3ToolParser::new(&test_tools());
342
+ let output = parser
343
+ .parse_complete(&build_tool_calls(&[
344
+ build_tool_call(
345
+ "get_weather",
346
+ &[("city", "Beijing"), ("date", "2026-03-30")],
347
+ ),
348
+ build_tool_call(
349
+ "get_weather",
350
+ &[("city", "Hangzhou"), ("date", "2026-03-30")],
351
+ ),
352
+ ]))
353
+ .unwrap();
354
+
355
+ expect![[r#"
356
+ ToolParserOutput {
357
+ normal_text: "",
358
+ calls: [
359
+ ToolCallDelta {
360
+ tool_index: 0,
361
+ name: Some(
362
+ "get_weather",
363
+ ),
364
+ arguments: "{\"city\":\"Beijing\",\"date\":\"2026-03-30\"}",
365
+ },
366
+ ToolCallDelta {
367
+ tool_index: 1,
368
+ name: Some(
369
+ "get_weather",
370
+ ),
371
+ arguments: "{\"city\":\"Hangzhou\",\"date\":\"2026-03-30\"}",
372
+ },
373
+ ],
374
+ }
375
+ "#]]
376
+ .assert_debug_eq(&output);
377
+ }
378
+
379
+ #[test]
380
+ fn hy_v3_parse_complete_converts_schema_types() {
381
+ let mut parser = HyV3ToolParser::new(&test_tools());
382
+ let output = parser
383
+ .parse_complete(&build_tool_calls(&[build_tool_call(
384
+ "convert",
385
+ &[
386
+ ("whole", "5.3"),
387
+ ("flag", "true"),
388
+ ("payload", r#"{"k":1}"#),
389
+ ("items", "[1,2]"),
390
+ ],
391
+ )]))
392
+ .unwrap();
393
+
394
+ assert_eq!(
395
+ parsed_arguments(&output, 0),
396
+ json!({
397
+ "whole": 5.3,
398
+ "flag": true,
399
+ "payload": { "k": 1 },
400
+ "items": [1, 2]
401
+ })
402
+ );
403
+ }
404
+
405
+ #[test]
406
+ fn hy_v3_streaming_without_tool_call_emits_text_incrementally() {
407
+ let mut parser = HyV3ToolParser::new(&test_tools());
408
+ let mut output = ToolParserOutput::default();
409
+
410
+ output.append(parser.parse_chunk("This is ").unwrap());
411
+ output.append(parser.parse_chunk("a plain ").unwrap());
412
+ output.append(parser.parse_chunk("response.").unwrap());
413
+ output.append(parser.finish().unwrap());
414
+
415
+ assert_eq!(output.normal_text, "This is a plain response.");
416
+ assert!(output.calls.is_empty());
417
+ }
418
+
419
+ #[test]
420
+ fn hy_v3_streaming_extracts_zero_arg_tool_call() {
421
+ let mut parser = HyV3ToolParser::new(&test_tools());
422
+ let chunks = [
423
+ "<tool_calls>",
424
+ "\n<tool_call>",
425
+ "get_current_date",
426
+ "<tool_sep>",
427
+ "\n</tool_call>",
428
+ "\n</tool_calls>",
429
+ ];
430
+
431
+ let output = collect_stream(&mut parser, &chunks);
432
+
433
+ assert_eq!(output.calls.len(), 1);
434
+ assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date"));
435
+ assert_eq!(parsed_arguments(&output, 0), json!({}));
436
+ }
437
+
438
+ #[test]
439
+ fn hy_v3_streaming_extracts_arguments() {
440
+ let mut parser = HyV3ToolParser::new(&test_tools());
441
+ let chunks = [
442
+ "<tool_calls>",
443
+ "\n<tool_call>",
444
+ "get_weather",
445
+ "<tool_sep>",
446
+ "\n<arg_key>city</arg_key>",
447
+ "\n<arg_value>Beijing</arg_value>",
448
+ "\n<arg_key>date</arg_key>",
449
+ "\n<arg_value>2026-03-30</arg_value>",
450
+ "\n</tool_call>",
451
+ "\n</tool_calls>",
452
+ ];
453
+
454
+ let output = collect_stream(&mut parser, &chunks);
455
+
456
+ assert_eq!(output.calls.len(), 1);
457
+ assert_eq!(output.calls[0].name.as_deref(), Some("get_weather"));
458
+ assert_eq!(
459
+ parsed_arguments(&output, 0),
460
+ json!({ "city": "Beijing", "date": "2026-03-30" })
461
+ );
462
+ }
463
+
464
+ #[test]
465
+ fn hy_v3_streaming_preserves_prefix_text() {
466
+ let mut parser = HyV3ToolParser::new(&test_tools());
467
+ let chunks = [
468
+ "Checking.",
469
+ "<tool_calls>",
470
+ "\n<tool_call>",
471
+ "get_current_date",
472
+ "<tool_sep>",
473
+ "\n</tool_call>",
474
+ "\n</tool_calls>",
475
+ ];
476
+
477
+ let output = collect_stream(&mut parser, &chunks);
478
+
479
+ assert_eq!(output.normal_text, "Checking.");
480
+ assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date"));
481
+ }
482
+
483
+ #[test]
484
+ fn hy_v3_streaming_extracts_multiple_tool_calls_in_one_block() {
485
+ let input = build_tool_calls(&[
486
+ build_tool_call(
487
+ "get_weather",
488
+ &[("city", "Beijing"), ("date", "2026-03-30")],
489
+ ),
490
+ build_tool_call(
491
+ "get_weather",
492
+ &[("city", "Hangzhou"), ("date", "2026-03-30")],
493
+ ),
494
+ ]);
495
+ let chunks = split_by_chars(&input, 9);
496
+ let mut parser = HyV3ToolParser::new(&test_tools());
497
+
498
+ let output = collect_stream(&mut parser, &chunks);
499
+
500
+ assert_eq!(output.calls.len(), 2);
501
+ assert_eq!(parsed_arguments(&output, 0)["city"], json!("Beijing"));
502
+ assert_eq!(parsed_arguments(&output, 1)["city"], json!("Hangzhou"));
503
+ }
504
+
505
+ #[test]
506
+ fn hy_v3_streaming_handles_start_marker_split_across_chunks() {
507
+ let input = format!(
508
+ "hello {}",
509
+ build_tool_calls(&[build_tool_call("get_weather", &[("city", "Beijing")])])
510
+ );
511
+ let chunks = split_by_chars(&input, 5);
512
+ let mut parser = HyV3ToolParser::new(&test_tools());
513
+
514
+ let output = collect_stream(&mut parser, &chunks);
515
+
516
+ assert_eq!(output.normal_text, "hello ");
517
+ assert_eq!(output.calls.len(), 1);
518
+ assert_eq!(parsed_arguments(&output, 0), json!({ "city": "Beijing" }));
519
+ }
520
+
521
+ #[test]
522
+ fn hy_v3_streaming_does_not_emit_incomplete_tool_call() {
523
+ let mut parser = HyV3ToolParser::new(&test_tools());
524
+ let mut output = ToolParserOutput::default();
525
+
526
+ parser
527
+ .parse_into(
528
+ "<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Bei",
529
+ &mut output,
530
+ )
531
+ .unwrap();
532
+
533
+ assert_eq!(output.normal_text, "");
534
+ assert!(output.calls.is_empty());
535
+ }
536
+
537
+ #[test]
538
+ fn hy_v3_finish_fails_incomplete_tool_call() {
539
+ let mut parser = HyV3ToolParser::new(&test_tools());
540
+ parser.parse_chunk("<tool_calls><tool_call>get_weather<tool_sep>").unwrap();
541
+
542
+ let error = parser.finish().unwrap_err();
543
+
544
+ expect!["tool parser parsing failed: incomplete HY3 tool call"]
545
+ .assert_eq(&error.to_report_string());
546
+ }
547
+
548
+ #[test]
549
+ fn hy_v3_malformed_tool_call_fails_fast() {
550
+ let mut parser = HyV3ToolParser::new(&test_tools());
551
+ let error = parser
552
+ .parse_complete(
553
+ "<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing</tool_call></tool_calls>",
554
+ )
555
+ .unwrap_err();
556
+
557
+ assert!(error.to_report_string().starts_with("tool parser parsing failed:"));
558
+ }
559
+}
rust/src/tool-parser/src/lib.rs
+2
@@ -6,6 +6,7 @@ mod deepseek_dsml;
6
mod deepseek_json;
7
mod gemma4;
8
mod glm_xml;
9
+mod hy_v3;
10
mod json;
11
mod kimi_k2;
12
mod minimax_m2;
@@ -22,6 +23,7 @@ pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser};
23
pub use error::{Result, ToolParserError};
24
pub use gemma4::Gemma4ToolParser;
25
pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
26
+pub use hy_v3::HyV3ToolParser;
27
pub use json::{HermesToolParser, Llama3JsonToolParser, MistralToolParser, Qwen3XmlToolParser};
28
pub use kimi_k2::KimiK2ToolParser;
29
pub use minimax_m2::MinimaxM2ToolParser;
rust/src/tool-parser/src/parameters.rs
+9
-3
@@ -177,10 +177,10 @@ impl JsonParamType {
177
match kind.as_str() {
178
"string" | "str" | "text" | "varchar" | "char" | "enum" => Some(Self::String),
179
"integer" | "int" => Some(Self::Integer),
180
- "number" | "float" => Some(Self::Number),
180
+ "number" | "float" | "double" => Some(Self::Number),
181
"boolean" | "bool" | "binary" => Some(Self::Boolean),
182
- "object" => Some(Self::Object),
183
- "array" | "arr" | "sequence" => Some(Self::Array),
182
+ "object" | "dict" | "map" => Some(Self::Object),
183
+ "array" | "arr" | "list" | "sequence" => Some(Self::Array),
184
"null" => Some(Self::Null),
185
_ if kind.starts_with("int")
186
|| kind.starts_with("uint")
@@ -288,9 +288,12 @@ mod tests {
288
"text": { "type": "string" },
289
"count": { "type": "integer" },
290
"size": { "type": "number" },
291
+ "ratio": { "type": "double" },
292
"enabled": { "type": "boolean" },
293
"payload": { "type": "object" },
294
+ "mapping": { "type": "map" },
295
"items": { "type": "array" },
296
+ "names": { "type": "list" },
297
"nothing": { "type": "null" }
298
}
299
}));
@@ -298,9 +301,12 @@ mod tests {
301
assert_eq!(params.convert("text", "42"), json!("42"));
302
assert_eq!(params.convert("count", "42"), json!(42));
303
assert_eq!(params.convert("size", "5.0"), json!(5.0));
304
+ assert_eq!(params.convert("ratio", "2.5"), json!(2.5));
305
assert_eq!(params.convert("enabled", "1"), json!(true));
306
assert_eq!(params.convert("payload", r#"{"k":1}"#), json!({ "k": 1 }));
307
+ assert_eq!(params.convert("mapping", r#"{"k":1}"#), json!({ "k": 1 }));
308
assert_eq!(params.convert("items", "[1,2]"), json!([1, 2]));
309
+ assert_eq!(params.convert("names", r#"["a","b"]"#), json!(["a", "b"]));
310
assert_eq!(params.convert("nothing", "null"), json!(null));
311
}
312