Skip to main content

tool_parser/parsers/
qwen_xml.rs

1use async_trait::async_trait;
2use openai_protocol::common::Tool;
3use regex::Regex;
4use serde_json::Value;
5
6use crate::{
7    errors::{ParserError, ParserResult},
8    parsers::helpers,
9    traits::ToolParser,
10    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
11};
12
13/// Qwen XML format parser for tool calls
14///
15/// Handles the Qwen XML specific XML format:
16/// `<tool_call>\n<function=name>\n<parameter=key>value</parameter>\n</function>\n</tool_call>`
17///
18/// Features:
19/// - Tool Call Tags: `<tool_call>` and `</tool_call>` wrap each individual call
20/// - XML-style function declaration: `<function=name>`
21/// - XML-style parameters: `<parameter=key>value</parameter>`
22///
23/// Reference: https://huggingface.co/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8?chat_template=default
24pub struct QwenXmlParser {
25    /// Regex for extracting tool calls in parse_complete
26    extractor: Regex,
27
28    /// Buffer for accumulating incomplete patterns across chunks
29    buffer: String,
30
31    /// Stores complete tool call info (name and arguments) for each tool being parsed
32    prev_tool_call_arr: Vec<Value>,
33
34    /// Index of currently streaming tool call (-1 means no active tool)
35    current_tool_id: i32,
36
37    /// Flag for whether current tool's name has been sent to client
38    current_tool_name_sent: bool,
39
40    /// Tracks raw JSON string content streamed to client for each tool's arguments
41    streamed_args_for_tool: Vec<String>,
42
43    /// Token configuration
44    tool_call_start_token: &'static str,
45    tool_call_end_token: &'static str,
46
47    /// XML format streaming state
48    in_tool_call: bool,
49    current_function_name: String,
50    current_parameters: serde_json::Map<String, Value>,
51
52    /// Precompiled regex patterns for XML format parsing
53    xml_function_pattern: Regex,
54    xml_param_pattern: Regex,
55}
56
57/// Parse a raw parameter value, similar to Python's `_safe_val`.
58///
59/// Argument values are treated **literally** — no HTML-entity decoding. This
60/// matches Qwen's own official API (DashScope), verified for both Qwen3-Coder
61/// and Qwen3.5: a tool argument whose value contains `&amp;`, `&lt;`, `&#39;`
62/// is returned with those entities intact. The Qwen XML tool format is not
63/// HTML-escaped on render either (the chat template emits values via
64/// `| tojson | safe` / `| string`), so parsing must not unescape it. vLLM's
65/// `Qwen3CoderToolParser`, SGLang's `qwen3_coder_detector`, and Qwen-Agent all
66/// agree, passing argument values through verbatim.
67///
68/// 1. Try to parse as JSON (numbers, booleans, null, objects, arrays)
69/// 2. Fall back to string if JSON parsing fails
70fn safe_val(raw: &str) -> Value {
71    let trimmed = raw.trim();
72
73    // Try JSON parsing first
74    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
75        return v;
76    }
77
78    // Handle Python-style literals (True, False, None)
79    match trimmed {
80        "True" => return Value::Bool(true),
81        "False" => return Value::Bool(false),
82        "None" => return Value::Null,
83        _ => {}
84    }
85
86    // Fall back to string
87    Value::String(trimmed.to_string())
88}
89
90/// Coerce an XML parameter value by its declared schema type, falling back to
91/// [`safe_val`] inference when the type is unknown.
92///
93/// Values are treated literally; see [`safe_val`] for why the format is not
94/// HTML-unescaped.
95fn coerce_value(raw: &str, declared_type: Option<&str>) -> Value {
96    let trimmed = raw.trim();
97    helpers::coerce_by_schema_type(trimmed, declared_type).unwrap_or_else(|| safe_val(raw))
98}
99
100impl QwenXmlParser {
101    /// Create a new Qwen XML parser
102    #[expect(
103        clippy::expect_used,
104        reason = "regex patterns are compile-time string literals"
105    )]
106    pub fn new() -> Self {
107        // Support XML format: <tool_call>\n<function=name>\n<parameter=key>value</parameter>\n</function>\n</tool_call>
108        let pattern = r"(?s)<tool_call>\s*(.*?)\s*</tool_call>";
109        let extractor = Regex::new(pattern).expect("Valid regex pattern");
110
111        // Precompile XML format regex patterns for performance
112        let xml_function_pattern =
113            Regex::new(r"<function=([^>]+)>").expect("Valid XML function pattern");
114        let xml_param_pattern = Regex::new(r"(?s)<parameter=([^>]+)>(.*?)</parameter>")
115            .expect("Valid XML parameter pattern");
116
117        Self {
118            extractor,
119            buffer: String::new(),
120            prev_tool_call_arr: Vec::new(),
121            current_tool_id: -1,
122            current_tool_name_sent: false,
123            streamed_args_for_tool: Vec::new(),
124            tool_call_start_token: "<tool_call>",
125            tool_call_end_token: "</tool_call>",
126            in_tool_call: false,
127            current_function_name: String::new(),
128            current_parameters: serde_json::Map::new(),
129            xml_function_pattern,
130            xml_param_pattern,
131        }
132    }
133
134    /// Parse XML format tool call: <function=name><parameter=key>value</parameter></function>
135    fn parse_xml_format(&self, content: &str, tools: &[Tool]) -> ParserResult<Option<ToolCall>> {
136        let function_captures = self
137            .xml_function_pattern
138            .captures(content)
139            .ok_or_else(|| ParserError::ParsingFailed("No function name found".to_string()))?;
140
141        let function_name = function_captures
142            .get(1)
143            .ok_or_else(|| ParserError::ParsingFailed("Function name capture failed".to_string()))?
144            .as_str()
145            .trim()
146            .to_string();
147
148        if function_name.is_empty() {
149            return Ok(None);
150        }
151
152        let param_types = helpers::param_types_for_function(tools, &function_name);
153        let mut parameters = serde_json::Map::new();
154
155        for cap in self.xml_param_pattern.captures_iter(content) {
156            if let (Some(key_match), Some(value_match)) = (cap.get(1), cap.get(2)) {
157                let key = key_match.as_str().trim().to_string();
158                let value = value_match.as_str();
159                let json_value = coerce_value(value, param_types.get(&key).map(String::as_str));
160                parameters.insert(key, json_value);
161            }
162        }
163
164        let arguments = serde_json::to_string(&parameters)
165            .map_err(|e| ParserError::ParsingFailed(e.to_string()))?;
166
167        Ok(Some(ToolCall {
168            function: FunctionCall {
169                name: function_name,
170                arguments,
171            },
172        }))
173    }
174
175    /// Parse and stream complete parameters from buffer
176    /// Returns tool call items to emit (similar to Python's _parse_and_stream_parameters)
177    fn parse_and_stream_parameters(
178        &mut self,
179        tools: &[Tool],
180        parameter_end: usize,
181    ) -> Vec<ToolCallItem> {
182        let mut calls: Vec<ToolCallItem> = vec![];
183        let param_types = helpers::param_types_for_function(tools, &self.current_function_name);
184
185        // Leave parameters from subsequent coalesced calls for their own iteration.
186        let mut new_params = serde_json::Map::new();
187        for cap in self
188            .xml_param_pattern
189            .captures_iter(&self.buffer[..parameter_end])
190        {
191            if let (Some(key_match), Some(value_match)) = (cap.get(1), cap.get(2)) {
192                let key = key_match.as_str().trim().to_string();
193                let value = value_match.as_str();
194                let json_value = coerce_value(value, param_types.get(&key).map(String::as_str));
195                new_params.insert(key, json_value);
196            }
197        }
198
199        // Calculate parameter diff and stream updates
200        if new_params != self.current_parameters {
201            let current_args = &mut self.streamed_args_for_tool[self.current_tool_id as usize];
202
203            if self.current_parameters.is_empty() {
204                // First parameter(s) - build JSON fragment (without closing brace)
205                let mut items = Vec::new();
206                for (key, value) in &new_params {
207                    let key_json =
208                        serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
209                    let value_json = serde_json::to_string(value).unwrap_or_default();
210                    items.push(format!("{key_json}: {value_json}"));
211                }
212                let json_fragment = format!("{{{}", items.join(", "));
213
214                calls.push(ToolCallItem {
215                    tool_index: self.current_tool_id as usize,
216                    name: None,
217                    parameters: json_fragment.clone(),
218                });
219                *current_args = json_fragment;
220            } else {
221                // Additional parameters - add them incrementally
222                let new_keys: Vec<_> = new_params
223                    .keys()
224                    .filter(|k| !self.current_parameters.contains_key(*k))
225                    .collect();
226
227                if !new_keys.is_empty() {
228                    let mut continuation_parts = Vec::new();
229                    for key in new_keys {
230                        if let Some(value) = new_params.get(key) {
231                            let key_json =
232                                serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
233                            let value_json = serde_json::to_string(value).unwrap_or_default();
234                            continuation_parts.push(format!("{key_json}: {value_json}"));
235                        }
236                    }
237
238                    let json_fragment = format!(", {}", continuation_parts.join(", "));
239
240                    calls.push(ToolCallItem {
241                        tool_index: self.current_tool_id as usize,
242                        name: None,
243                        parameters: json_fragment.clone(),
244                    });
245                    current_args.push_str(&json_fragment);
246                }
247            }
248
249            // Update current state
250            self.current_parameters.clone_from(&new_params);
251            if let Some(tool_obj) =
252                self.prev_tool_call_arr[self.current_tool_id as usize].as_object_mut()
253            {
254                tool_obj.insert("arguments".to_string(), Value::Object(new_params));
255            }
256        }
257
258        calls
259    }
260
261    /// Shared non-streaming parse, schema-aware when `tools` are provided.
262    fn parse_complete_inner(
263        &self,
264        text: &str,
265        tools: &[Tool],
266    ) -> ParserResult<(String, Vec<ToolCall>)> {
267        // Check if text contains Qwen XML format
268        if !self.has_tool_markers(text) {
269            return Ok((text.to_string(), vec![]));
270        }
271
272        // Find where the first tool call begins
273        // Safe: has_tool_markers() already confirmed the marker exists
274        let idx = text
275            .find(self.tool_call_start_token)
276            .ok_or_else(|| ParserError::ParsingFailed("tool call marker not found".to_string()))?;
277        let normal_text = text[..idx].to_string();
278
279        // Extract tool calls
280        let mut parsed = Vec::new();
281        for captures in self.extractor.captures_iter(text) {
282            if let Some(content_str) = captures.get(1) {
283                let content = content_str.as_str().trim();
284
285                match self.parse_xml_format(content, tools) {
286                    Ok(Some(tool)) => parsed.push(tool),
287                    Ok(None) => continue,
288                    Err(e) => {
289                        tracing::warn!("Failed to parse XML tool call: {:?}", e);
290                        continue;
291                    }
292                }
293            }
294        }
295
296        // If no tools were successfully parsed despite having markers, return entire text
297        if parsed.is_empty() {
298            return Ok((text.to_string(), vec![]));
299        }
300
301        Ok((normal_text, parsed))
302    }
303
304    /// Reset streaming state for next tool call
305    fn reset_streaming_state(&mut self) {
306        self.in_tool_call = false;
307        self.current_tool_name_sent = false;
308        self.current_function_name.clear();
309        self.current_parameters.clear();
310    }
311}
312
313impl Default for QwenXmlParser {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319#[async_trait]
320impl ToolParser for QwenXmlParser {
321    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
322        self.parse_complete_inner(text, &[])
323    }
324
325    async fn parse_complete_with_tools(
326        &self,
327        text: &str,
328        tools: &[Tool],
329    ) -> ParserResult<(String, Vec<ToolCall>)> {
330        self.parse_complete_inner(text, tools)
331    }
332
333    async fn parse_incremental(
334        &mut self,
335        chunk: &str,
336        tools: &[Tool],
337    ) -> ParserResult<StreamingParseResult> {
338        self.buffer.push_str(chunk);
339
340        let mut normal_text = String::new();
341        let mut calls: Vec<ToolCallItem> = vec![];
342
343        // Build tool indices for validation
344        let tool_indices = helpers::get_tool_indices(tools);
345
346        loop {
347            // If we're not in a tool call and don't see a start token, return normal text
348            if !self.in_tool_call && !self.buffer.contains(self.tool_call_start_token) {
349                // Check for partial start token
350                if helpers::ends_with_partial_token(&self.buffer, self.tool_call_start_token)
351                    .is_none()
352                {
353                    normal_text.push_str(&self.buffer);
354                    self.buffer.clear();
355                }
356                break;
357            }
358
359            // Look for tool call start
360            if !self.in_tool_call {
361                if let Some(s) = self.buffer.find(self.tool_call_start_token) {
362                    normal_text.push_str(&self.buffer[..s]);
363                    self.buffer = self.buffer[s + self.tool_call_start_token.len()..].to_string();
364                    self.in_tool_call = true;
365                    self.current_tool_name_sent = false;
366                    self.current_function_name.clear();
367                    self.current_parameters.clear();
368                    continue;
369                } else {
370                    break;
371                }
372            }
373
374            // We're in a tool call, try to parse function name if not sent yet
375            if !self.current_tool_name_sent {
376                if let Some(captures) = self.xml_function_pattern.captures(&self.buffer) {
377                    if let Some(name_match) = captures.get(1) {
378                        let function_name = name_match.as_str().trim().to_string();
379
380                        // Validate function name
381                        if tool_indices.contains_key(&function_name) {
382                            self.current_function_name.clone_from(&function_name);
383                            self.current_tool_name_sent = true;
384
385                            // Initialize tool call tracking
386                            if self.current_tool_id == -1 {
387                                self.current_tool_id = 0;
388                            }
389
390                            // Ensure tracking arrays are large enough
391                            helpers::ensure_capacity(
392                                self.current_tool_id,
393                                &mut self.prev_tool_call_arr,
394                                &mut self.streamed_args_for_tool,
395                            );
396
397                            // Store tool call info
398                            self.prev_tool_call_arr[self.current_tool_id as usize] = serde_json::json!({
399                                "name": function_name,
400                                "arguments": {}
401                            });
402
403                            // Send tool name
404                            calls.push(ToolCallItem {
405                                tool_index: self.current_tool_id as usize,
406                                name: Some(function_name),
407                                parameters: String::new(),
408                            });
409
410                            // Remove processed function declaration from buffer
411                            // Safe: captures.get(0) always returns Some (group 0 is the entire match)
412                            self.buffer =
413                                self.buffer[captures.get(0).map_or(0, |m| m.end())..].to_string();
414                            continue;
415                        } else {
416                            // Invalid function name, reset state
417                            tracing::warn!("Invalid function name: {}", function_name);
418                            self.reset_streaming_state();
419                            normal_text.push_str(&self.buffer);
420                            self.buffer.clear();
421                            break;
422                        }
423                    }
424                } else {
425                    // Function name not complete yet, wait for more text
426                    break;
427                }
428            }
429
430            // Parse parameters (only complete ones)
431            if self.current_tool_name_sent {
432                let end_pos = self.buffer.find(self.tool_call_end_token);
433                let parameter_end = end_pos.unwrap_or(self.buffer.len());
434                let param_calls = self.parse_and_stream_parameters(tools, parameter_end);
435                calls.extend(param_calls);
436
437                // Check if tool call is complete
438                if let Some(end_pos) = end_pos {
439                    // Parameter fragments leave the root open; braces in values are data.
440                    let current_args =
441                        &mut self.streamed_args_for_tool[self.current_tool_id as usize];
442                    let closing = if current_args.is_empty() { "{}" } else { "}" };
443                    calls.push(ToolCallItem {
444                        tool_index: self.current_tool_id as usize,
445                        name: None,
446                        parameters: closing.to_string(),
447                    });
448                    current_args.push_str(closing);
449
450                    // Complete the tool call
451                    self.buffer =
452                        self.buffer[end_pos + self.tool_call_end_token.len()..].to_string();
453                    self.reset_streaming_state();
454                    self.current_tool_id += 1;
455                    continue;
456                } else {
457                    // Tool call not complete yet, wait for more text
458                    break;
459                }
460            }
461
462            break;
463        }
464
465        Ok(StreamingParseResult { normal_text, calls })
466    }
467
468    fn has_tool_markers(&self, text: &str) -> bool {
469        text.contains(self.tool_call_start_token)
470    }
471
472    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
473        let tool_index = self.prev_tool_call_arr.len().checked_sub(1)?;
474        let actual = self.streamed_args_for_tool.get(tool_index)?;
475        let expected = self.prev_tool_call_arr[tool_index].get("arguments")?;
476        // XML parameters use spaced JSON, unlike the generic compact-prefix recovery.
477        // Append only the root brace, and only for exactly the already parsed values.
478        if !actual.is_empty()
479            && serde_json::from_str::<Value>(&format!("{actual}}}"))
480                .ok()
481                .as_ref()
482                == Some(expected)
483        {
484            return Some(vec![ToolCallItem {
485                tool_index,
486                name: None,
487                parameters: "}".to_string(),
488            }]);
489        }
490        helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
491    }
492
493    fn reset(&mut self) {
494        helpers::reset_parser_state(
495            &mut self.buffer,
496            &mut self.prev_tool_call_arr,
497            &mut self.current_tool_id,
498            &mut self.current_tool_name_sent,
499            &mut self.streamed_args_for_tool,
500        );
501        self.reset_streaming_state();
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use openai_protocol::common::Function;
508
509    use super::*;
510
511    #[test]
512    fn test_safe_val_json() {
513        assert_eq!(safe_val("42"), Value::Number(42.into()));
514        assert_eq!(safe_val("1.5"), serde_json::json!(1.5));
515        assert_eq!(safe_val("true"), Value::Bool(true));
516        assert_eq!(safe_val("false"), Value::Bool(false));
517        assert_eq!(safe_val("null"), Value::Null);
518        assert_eq!(
519            safe_val(r#"{"key": "value"}"#),
520            serde_json::json!({"key": "value"})
521        );
522        assert_eq!(safe_val(r"[1, 2, 3]"), serde_json::json!([1, 2, 3]));
523    }
524
525    #[test]
526    fn test_safe_val_python_literals() {
527        assert_eq!(safe_val("True"), Value::Bool(true));
528        assert_eq!(safe_val("False"), Value::Bool(false));
529        assert_eq!(safe_val("None"), Value::Null);
530    }
531
532    #[test]
533    fn test_safe_val_string_fallback() {
534        assert_eq!(
535            safe_val("hello world"),
536            Value::String("hello world".to_string())
537        );
538        assert_eq!(safe_val("  spaces  "), Value::String("spaces".to_string()));
539    }
540
541    // Values are treated literally: entity-like substrings must NOT be decoded
542    // (parity with Qwen's official API, which returns them intact).
543    #[test]
544    fn test_safe_val_preserves_html_entities() {
545        assert_eq!(
546            safe_val("&lt;div&gt;"),
547            Value::String("&lt;div&gt;".to_string())
548        );
549        assert_eq!(
550            safe_val("Tom &amp; Jerry"),
551            Value::String("Tom &amp; Jerry".to_string())
552        );
553        // Numeric/hex entities are likewise left untouched.
554        assert_eq!(safe_val("it&#39;s"), Value::String("it&#39;s".to_string()));
555        assert_eq!(safe_val("&#x3C;"), Value::String("&#x3C;".to_string()));
556    }
557
558    fn tool_with_props(props: Value) -> Vec<Tool> {
559        vec![Tool {
560            tool_type: "function".to_string(),
561            function: Function {
562                name: "f".to_string(),
563                description: None,
564                parameters: serde_json::json!({"type": "object", "properties": props}),
565                strict: None,
566            },
567        }]
568    }
569
570    // String-typed params stay strings even when they look numeric/bool/array/object.
571    #[tokio::test]
572    async fn test_schema_aware_coercion_keeps_strings() {
573        let tools = tool_with_props(serde_json::json!({
574            "limit": {"type": "string"},
575            "flag": {"type": "string"},
576            "coords": {"type": "string"},
577            "cfg": {"type": "string"},
578            "count": {"type": "integer"},
579        }));
580        let text = "<tool_call>\n<function=f>\n\
581            <parameter=limit>4</parameter>\n\
582            <parameter=flag>true</parameter>\n\
583            <parameter=coords>[60,30]</parameter>\n\
584            <parameter=cfg>{\"a\": 1}</parameter>\n\
585            <parameter=count>5</parameter>\n\
586            </function>\n</tool_call>";
587        let (_, calls) = QwenXmlParser::new()
588            .parse_complete_with_tools(text, &tools)
589            .await
590            .unwrap();
591        assert_eq!(calls.len(), 1);
592        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
593        assert_eq!(args["limit"], Value::String("4".to_string()));
594        assert_eq!(args["flag"], Value::String("true".to_string()));
595        assert_eq!(args["coords"], Value::String("[60,30]".to_string()));
596        assert_eq!(args["cfg"], Value::String("{\"a\": 1}".to_string()));
597        assert_eq!(args["count"], Value::Number(5.into()));
598    }
599
600    // The streaming path threads `tools` separately, so cover it too.
601    #[tokio::test]
602    async fn test_streaming_schema_aware_coercion() {
603        let tools = tool_with_props(serde_json::json!({
604            "limit": {"type": "string"},
605            "count": {"type": "integer"},
606        }));
607        let text = "<tool_call>\n<function=f>\n\
608            <parameter=limit>4</parameter>\n\
609            <parameter=count>5</parameter>\n\
610            </function>\n</tool_call>";
611        let result = QwenXmlParser::new()
612            .parse_incremental(text, &tools)
613            .await
614            .unwrap();
615        let args: String = result.calls.iter().map(|c| c.parameters.as_str()).collect();
616        assert!(
617            args.contains(r#""limit": "4""#),
618            "string param must stay string: {args}"
619        );
620        assert!(
621            args.contains(r#""count": 5"#),
622            "int param must coerce: {args}"
623        );
624    }
625
626    // Golden conformance test (regression guard for #1888): a tool argument
627    // whose value contains HTML entities must round-trip UNCHANGED, matching
628    // Qwen's official API (verified on Qwen3-Coder and Qwen3.5). Covers both the
629    // schema-typed `string` path and the schema-less inference fallback.
630    #[tokio::test]
631    async fn test_arg_values_with_entities_roundtrip_unchanged() {
632        let literal = "<a>Tom &amp; Jerry</a> &lt;x&gt; it&#39;s";
633
634        // Function name matches `tool_with_props` (`f`) so the schema-typed
635        // branch below actually resolves the `content` param's type.
636        let text = format!(
637            "<tool_call>\n<function=f>\n\
638             <parameter=content>\n{literal}\n</parameter>\n\
639             </function>\n</tool_call>"
640        );
641
642        // Schema-less inference path (`safe_val`): not valid JSON -> stays a
643        // literal string with entities intact.
644        let (_, calls) = QwenXmlParser::new().parse_complete(&text).await.unwrap();
645        assert_eq!(calls.len(), 1);
646        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
647        assert_eq!(args["content"], Value::String(literal.to_string()));
648
649        // Schema-typed `string` path (`coerce_value` -> `coerce_by_schema_type`).
650        let tools = tool_with_props(serde_json::json!({
651            "content": {"type": "string"},
652        }));
653        let (_, calls) = QwenXmlParser::new()
654            .parse_complete_with_tools(&text, &tools)
655            .await
656            .unwrap();
657        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
658        assert_eq!(args["content"], Value::String(literal.to_string()));
659    }
660}