A high-throughput and memory-efficient inference and serving engine for LLMs
[Bugfix][Rust Frontend] Stop unescaping XML-style tool-call parameter values (#45025)
Signed-off-by: Ting Sun <suntcrick@gmail.com>
Ting SUN committed
Jun 11, 2026 at 10:05 UTC
2d481f8a946ee0521872af0f098674a8ee01ce4a
6 files changed
+36
-120
rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs
+2
-2
@@ -186,7 +186,7 @@ mod tests {
186
}
187
188
#[test]
189
- fn deepseek_v32_parse_complete_unescapes_literal_closing_tags_in_parameter_value() {
189
+ fn deepseek_v32_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
190
let mut parser = DeepSeekV32ToolParser::new(&test_tools());
191
let output = parser
192
.parse_complete(&build_tool_call(
@@ -204,7 +204,7 @@ mod tests {
204
assert_eq!(
205
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
206
json!({
207
- "location": "Hangzhou </|DSML|parameter></|DSML|invoke></|DSML|function_calls>",
207
+ "location": "Hangzhou </|DSML|parameter></|DSML|invoke></|DSML|function_calls>",
208
"date": "2026-05-08",
209
})
210
);
rust/src/tool-parser/src/deepseek_dsml/mod.rs
+2
-2
@@ -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, xml_unescape};
8
+use super::utils::{parse_buffered_event, safe_text_len};
9
use super::{Result, ToolCallDelta, ToolParserOutput};
10
use crate::Tool;
11
@@ -251,7 +251,7 @@ fn parse_parameter(input: &mut &str) -> ModalResult<DsmlParameter> {
251
is_string: string_attr.map(|value| value == "true"),
252
_: ws0,
253
_: ">",
254
- value: take_until(0.., PARAMETER_END).map(xml_unescape).map(|value| value.into_owned()),
254
+ value: take_until(0.., PARAMETER_END).map(str::to_string),
255
_: literal(PARAMETER_END),
256
}}
257
.parse_next(input)
rust/src/tool-parser/src/glm_xml/mod.rs
+5
-5
@@ -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, xml_unescape};
8
+use super::utils::{parse_buffered_event, safe_text_len};
9
use super::{Result, ToolCallDelta, ToolParserOutput};
10
use crate::Tool;
11
@@ -238,12 +238,12 @@ fn parse_parameter(input: &mut &str) -> ModalResult<(String, String)> {
238
_: literal(ARG_KEY_END),
239
_: ws0,
240
_: literal(ARG_VALUE_START),
241
- take_until(0.., ARG_VALUE_END).map(str::trim).map(xml_unescape),
241
+ take_until(0.., ARG_VALUE_END).map(str::trim),
242
_: literal(ARG_VALUE_END),
243
)
244
.parse_next(input)?;
245
246
- Ok((key.trim().to_string(), value.into_owned()))
246
+ Ok((key.trim().to_string(), value.to_string()))
247
}
248
249
#[cfg(test)]
@@ -320,7 +320,7 @@ mod tests {
320
}
321
322
#[test]
323
- fn glm45_parse_complete_unescapes_literal_closing_tags_in_arg_value() {
323
+ fn glm45_parse_complete_preserves_raw_closing_tag_text_in_arg_value() {
324
let mut parser = Glm45MoeToolParser::new(&test_tools());
325
let output = parser
326
.parse_complete(&glm45_tool_call(
@@ -335,7 +335,7 @@ mod tests {
335
assert_eq!(
336
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
337
json!({
338
- "city": "Paris </arg_value></tool_call>",
338
+ "city": "Paris </arg_value></tool_call>",
339
"date": "2026-05-08",
340
})
341
);
rust/src/tool-parser/src/minimax_m2.rs
+22
-5
@@ -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, xml_unescape};
8
+use super::utils::{parse_buffered_event, safe_text_len};
9
use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10
use crate::Tool;
11
@@ -213,12 +213,12 @@ fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
213
_: (ws1, literal("name=")),
214
attr_value,
215
_: literal(">"),
216
- take_until(0.., PARAMETER_END).map(xml_unescape),
216
+ take_until(0.., PARAMETER_END),
217
_: literal(PARAMETER_END),
218
)
219
.parse_next(input)?;
220
221
- Ok((name.trim().to_string(), value.into_owned()))
221
+ Ok((name.trim().to_string(), value.to_string()))
222
}
223
224
/// Parse a quoted or unquoted XML attribute value.
@@ -364,7 +364,24 @@ mod tests {
364
}
365
366
#[test]
367
- fn minimax_m2_parse_complete_unescapes_literal_closing_tags_in_parameter_value() {
367
+ fn minimax_m2_parse_complete_preserves_raw_entities_in_parameter_value() {
368
+ // The MiniMax-M2 chat template renders string parameter values RAW (no
369
+ // XML escaping), so a value the user wants to be the literal text
370
+ // "Tom & Jerry <3" is emitted verbatim. The parser must preserve
371
+ // it; xml_unescape currently decodes it, corrupting the bytes.
372
+ let mut parser = MinimaxM2ToolParser::new(&test_tools());
373
+ let output = parser
374
+ .parse_complete(&build_tool_block(&[(
375
+ "get_weather",
376
+ vec![("city", "Tom & Jerry <3")],
377
+ )]))
378
+ .unwrap();
379
+ let args: Value = serde_json::from_str(&output.calls[0].arguments).unwrap();
380
+ assert_eq!(args["city"], json!("Tom & Jerry <3"));
381
+ }
382
+
383
+ #[test]
384
+ fn minimax_m2_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
385
let mut parser = MinimaxM2ToolParser::new(&test_tools());
386
let output = parser
387
.parse_complete(&build_tool_block(&[(
@@ -382,7 +399,7 @@ mod tests {
399
assert_eq!(
400
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
401
json!({
385
- "city": "Seattle </parameter></invoke></minimax:tool_call>",
402
+ "city": "Seattle </parameter></invoke></minimax:tool_call>",
403
"days": 5,
404
})
405
);
rust/src/tool-parser/src/qwen_coder.rs
+5
-5
@@ -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, xml_unescape};
8
+use super::utils::{parse_buffered_event, safe_text_len};
9
use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10
use crate::Tool;
11
@@ -201,12 +201,12 @@ fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
201
_: literal(PARAMETER_START),
202
take_until(1.., ">"),
203
_: ">",
204
- take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline).map(xml_unescape),
204
+ take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline),
205
_: literal(PARAMETER_END),
206
)
207
.parse_next(input)?;
208
209
- Ok((name.to_string(), value.into_owned()))
209
+ Ok((name.to_string(), value.to_string()))
210
}
211
212
/// Parse a Qwen Coder tool-call body.
@@ -414,7 +414,7 @@ mod tests {
414
}
415
416
#[test]
417
- fn qwen_coder_parse_complete_unescapes_literal_closing_tags_in_parameter_value() {
417
+ fn qwen_coder_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
418
let mut parser = Qwen3CoderToolParser::new(&test_tools());
419
let output = parser
420
.parse_complete(&build_tool_call(
@@ -433,7 +433,7 @@ mod tests {
433
assert_eq!(
434
serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
435
json!({
436
- "location": "杭州 </parameter></function></tool_call>",
436
+ "location": "杭州 </parameter></function></tool_call>",
437
"date": "2026-05-08",
438
})
439
);
rust/src/tool-parser/src/utils.rs
-101
@@ -1,7 +1,5 @@
1
//! Shared helpers for tool parsers.
2
3
-use std::borrow::Cow;
4
-
3
use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue};
4
use winnow::stream::{Offset, Partial, Stream};
5
@@ -68,73 +66,6 @@ pub(super) fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalRes
66
Ok(emit_len)
67
}
68
71
-/// Decode XML/HTML entities in XML-style parameter values.
72
-pub(super) fn xml_unescape(value: &str) -> Cow<'_, str> {
73
- if !value.as_bytes().contains(&b'&') {
74
- return Cow::Borrowed(value);
75
- }
76
-
77
- let mut output: Option<String> = None;
78
- let mut copied_len = 0;
79
- let mut rest = value;
80
-
81
- while let Some(ampersand) = rest.find('&') {
82
- let before_ampersand = &rest[..ampersand];
83
- let after_ampersand = &rest[ampersand + '&'.len_utf8()..];
84
- if let Some(semicolon) = after_ampersand.find(';') {
85
- let entity = &after_ampersand[..semicolon];
86
- if let Some(decoded) = decode_xml_entity(entity) {
87
- match &mut output {
88
- Some(output) => output.push_str(before_ampersand),
89
- None => {
90
- let mut new_output = String::with_capacity(value.len());
91
- new_output.push_str(&value[..copied_len + ampersand]);
92
- output = Some(new_output);
93
- }
94
- }
95
- let output = output.as_mut().expect("output is initialized above");
96
- output.push(decoded);
97
- let consumed_len = ampersand + '&'.len_utf8() + semicolon + ';'.len_utf8();
98
- copied_len += consumed_len;
99
- rest = &rest[consumed_len..];
100
- continue;
101
- }
102
- }
103
-
104
- if let Some(output) = &mut output {
105
- output.push_str(before_ampersand);
106
- output.push('&');
107
- }
108
- let consumed_len = ampersand + '&'.len_utf8();
109
- copied_len += consumed_len;
110
- rest = after_ampersand;
111
- }
112
-
113
- if let Some(mut output) = output {
114
- output.push_str(rest);
115
- Cow::Owned(output)
116
- } else {
117
- Cow::Borrowed(value)
118
- }
119
-}
120
-
121
-fn decode_xml_entity(entity: &str) -> Option<char> {
122
- match entity {
123
- "amp" => Some('&'),
124
- "lt" => Some('<'),
125
- "gt" => Some('>'),
126
- "quot" => Some('"'),
127
- "apos" => Some('\''),
128
- entity if entity.starts_with("#x") || entity.starts_with("#X") => {
129
- u32::from_str_radix(&entity[2..], 16).ok().and_then(char::from_u32)
130
- }
131
- entity if entity.starts_with('#') => {
132
- entity[1..].parse::<u32>().ok().and_then(char::from_u32)
133
- }
134
- _ => None,
135
- }
136
-}
137
-
69
/// Streaming lexical state for a top-level JSON object.
70
#[derive(Debug, Clone, Default, PartialEq, Eq)]
71
pub(super) struct JsonObjectScanState {
@@ -340,7 +271,6 @@ pub(super) fn incomplete<T>() -> ModalResult<T> {
271
272
#[cfg(test)]
273
mod tests {
343
- use std::borrow::Cow;
274
275
use expect_test::expect;
276
use winnow::error::ErrMode;
@@ -348,7 +278,6 @@ mod tests {
278
279
use super::{
280
JsonObjectScanState, json_str, partial_prefix_len, safe_text_len, take_json_object,
351
- xml_unescape,
281
};
282
283
#[test]
@@ -406,36 +335,6 @@ mod tests {
335
assert!(matches!(error, ErrMode::Incomplete(_)));
336
}
337
409
- #[test]
410
- fn xml_unescape_decodes_common_entities() {
411
- assert_eq!(
412
- xml_unescape("<tag attr="value">Tom & Jerry's</tag>"),
413
- r#"<tag attr="value">Tom & Jerry's</tag>"#
414
- );
415
- }
416
-
417
- #[test]
418
- fn xml_unescape_decodes_numeric_entities() {
419
- assert_eq!(xml_unescape("<tag>😀"), "<tag>😀");
420
- }
421
-
422
- #[test]
423
- fn xml_unescape_preserves_unknown_and_incomplete_entities() {
424
- let output = xml_unescape("Tom & Jerry &unknown; &");
425
-
426
- assert!(matches!(output, Cow::Borrowed(_)));
427
- assert_eq!(output, "Tom & Jerry &unknown; &");
428
- }
429
-
430
- #[test]
431
- fn xml_unescape_borrows_when_no_entity_is_present() {
432
- let input = "plain text";
433
- let output = xml_unescape(input);
434
-
435
- assert!(matches!(output, Cow::Borrowed(_)));
436
- assert_eq!(output, input);
437
- }
438
-
338
#[test]
339
fn take_json_object_consumes_simple_object() {
340
let mut state = JsonObjectScanState::default();