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(&mut self, tools: &[Tool]) -> Vec<ToolCallItem> {
178        let mut calls: Vec<ToolCallItem> = vec![];
179        let param_types = helpers::param_types_for_function(tools, &self.current_function_name);
180
181        // Find all complete parameter patterns in buffer
182        let mut new_params = serde_json::Map::new();
183        for cap in self.xml_param_pattern.captures_iter(&self.buffer) {
184            if let (Some(key_match), Some(value_match)) = (cap.get(1), cap.get(2)) {
185                let key = key_match.as_str().trim().to_string();
186                let value = value_match.as_str();
187                let json_value = coerce_value(value, param_types.get(&key).map(String::as_str));
188                new_params.insert(key, json_value);
189            }
190        }
191
192        // Calculate parameter diff and stream updates
193        if new_params != self.current_parameters {
194            let current_args = &mut self.streamed_args_for_tool[self.current_tool_id as usize];
195
196            if self.current_parameters.is_empty() {
197                // First parameter(s) - build JSON fragment (without closing brace)
198                let mut items = Vec::new();
199                for (key, value) in &new_params {
200                    let key_json =
201                        serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
202                    let value_json = serde_json::to_string(value).unwrap_or_default();
203                    items.push(format!("{key_json}: {value_json}"));
204                }
205                let json_fragment = format!("{{{}", items.join(", "));
206
207                calls.push(ToolCallItem {
208                    tool_index: self.current_tool_id as usize,
209                    name: None,
210                    parameters: json_fragment.clone(),
211                });
212                *current_args = json_fragment;
213            } else {
214                // Additional parameters - add them incrementally
215                let new_keys: Vec<_> = new_params
216                    .keys()
217                    .filter(|k| !self.current_parameters.contains_key(*k))
218                    .collect();
219
220                if !new_keys.is_empty() {
221                    let mut continuation_parts = Vec::new();
222                    for key in new_keys {
223                        if let Some(value) = new_params.get(key) {
224                            let key_json =
225                                serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
226                            let value_json = serde_json::to_string(value).unwrap_or_default();
227                            continuation_parts.push(format!("{key_json}: {value_json}"));
228                        }
229                    }
230
231                    let json_fragment = format!(", {}", continuation_parts.join(", "));
232
233                    calls.push(ToolCallItem {
234                        tool_index: self.current_tool_id as usize,
235                        name: None,
236                        parameters: json_fragment.clone(),
237                    });
238                    current_args.push_str(&json_fragment);
239                }
240            }
241
242            // Update current state
243            self.current_parameters.clone_from(&new_params);
244            if let Some(tool_obj) =
245                self.prev_tool_call_arr[self.current_tool_id as usize].as_object_mut()
246            {
247                tool_obj.insert("arguments".to_string(), Value::Object(new_params));
248            }
249        }
250
251        calls
252    }
253
254    /// Shared non-streaming parse, schema-aware when `tools` are provided.
255    fn parse_complete_inner(
256        &self,
257        text: &str,
258        tools: &[Tool],
259    ) -> ParserResult<(String, Vec<ToolCall>)> {
260        // Check if text contains Qwen XML format
261        if !self.has_tool_markers(text) {
262            return Ok((text.to_string(), vec![]));
263        }
264
265        // Find where the first tool call begins
266        // Safe: has_tool_markers() already confirmed the marker exists
267        let idx = text
268            .find(self.tool_call_start_token)
269            .ok_or_else(|| ParserError::ParsingFailed("tool call marker not found".to_string()))?;
270        let normal_text = text[..idx].to_string();
271
272        // Extract tool calls
273        let mut parsed = Vec::new();
274        for captures in self.extractor.captures_iter(text) {
275            if let Some(content_str) = captures.get(1) {
276                let content = content_str.as_str().trim();
277
278                match self.parse_xml_format(content, tools) {
279                    Ok(Some(tool)) => parsed.push(tool),
280                    Ok(None) => continue,
281                    Err(e) => {
282                        tracing::warn!("Failed to parse XML tool call: {:?}", e);
283                        continue;
284                    }
285                }
286            }
287        }
288
289        // If no tools were successfully parsed despite having markers, return entire text
290        if parsed.is_empty() {
291            return Ok((text.to_string(), vec![]));
292        }
293
294        Ok((normal_text, parsed))
295    }
296
297    /// Reset streaming state for next tool call
298    fn reset_streaming_state(&mut self) {
299        self.in_tool_call = false;
300        self.current_tool_name_sent = false;
301        self.current_function_name.clear();
302        self.current_parameters.clear();
303    }
304}
305
306impl Default for QwenXmlParser {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312#[async_trait]
313impl ToolParser for QwenXmlParser {
314    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
315        self.parse_complete_inner(text, &[])
316    }
317
318    async fn parse_complete_with_tools(
319        &self,
320        text: &str,
321        tools: &[Tool],
322    ) -> ParserResult<(String, Vec<ToolCall>)> {
323        self.parse_complete_inner(text, tools)
324    }
325
326    async fn parse_incremental(
327        &mut self,
328        chunk: &str,
329        tools: &[Tool],
330    ) -> ParserResult<StreamingParseResult> {
331        self.buffer.push_str(chunk);
332
333        let mut normal_text = String::new();
334        let mut calls: Vec<ToolCallItem> = vec![];
335
336        // Build tool indices for validation
337        let tool_indices = helpers::get_tool_indices(tools);
338
339        loop {
340            // If we're not in a tool call and don't see a start token, return normal text
341            if !self.in_tool_call && !self.buffer.contains(self.tool_call_start_token) {
342                // Check for partial start token
343                if helpers::ends_with_partial_token(&self.buffer, self.tool_call_start_token)
344                    .is_none()
345                {
346                    normal_text.push_str(&self.buffer);
347                    self.buffer.clear();
348                }
349                break;
350            }
351
352            // Look for tool call start
353            if !self.in_tool_call {
354                if let Some(s) = self.buffer.find(self.tool_call_start_token) {
355                    normal_text.push_str(&self.buffer[..s]);
356                    self.buffer = self.buffer[s + self.tool_call_start_token.len()..].to_string();
357                    self.in_tool_call = true;
358                    self.current_tool_name_sent = false;
359                    self.current_function_name.clear();
360                    self.current_parameters.clear();
361                    continue;
362                } else {
363                    break;
364                }
365            }
366
367            // We're in a tool call, try to parse function name if not sent yet
368            if !self.current_tool_name_sent {
369                if let Some(captures) = self.xml_function_pattern.captures(&self.buffer) {
370                    if let Some(name_match) = captures.get(1) {
371                        let function_name = name_match.as_str().trim().to_string();
372
373                        // Validate function name
374                        if tool_indices.contains_key(&function_name) {
375                            self.current_function_name.clone_from(&function_name);
376                            self.current_tool_name_sent = true;
377
378                            // Initialize tool call tracking
379                            if self.current_tool_id == -1 {
380                                self.current_tool_id = 0;
381                            }
382
383                            // Ensure tracking arrays are large enough
384                            helpers::ensure_capacity(
385                                self.current_tool_id,
386                                &mut self.prev_tool_call_arr,
387                                &mut self.streamed_args_for_tool,
388                            );
389
390                            // Store tool call info
391                            self.prev_tool_call_arr[self.current_tool_id as usize] = serde_json::json!({
392                                "name": function_name,
393                                "arguments": {}
394                            });
395
396                            // Send tool name
397                            calls.push(ToolCallItem {
398                                tool_index: self.current_tool_id as usize,
399                                name: Some(function_name),
400                                parameters: String::new(),
401                            });
402
403                            // Remove processed function declaration from buffer
404                            // Safe: captures.get(0) always returns Some (group 0 is the entire match)
405                            self.buffer =
406                                self.buffer[captures.get(0).map_or(0, |m| m.end())..].to_string();
407                            continue;
408                        } else {
409                            // Invalid function name, reset state
410                            tracing::warn!("Invalid function name: {}", function_name);
411                            self.reset_streaming_state();
412                            normal_text.push_str(&self.buffer);
413                            self.buffer.clear();
414                            break;
415                        }
416                    }
417                } else {
418                    // Function name not complete yet, wait for more text
419                    break;
420                }
421            }
422
423            // Parse parameters (only complete ones)
424            if self.current_tool_name_sent {
425                let param_calls = self.parse_and_stream_parameters(tools);
426                calls.extend(param_calls);
427
428                // Check if tool call is complete
429                if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) {
430                    // Close JSON object if we have parameters
431                    let current_args = &self.streamed_args_for_tool[self.current_tool_id as usize];
432                    if !current_args.is_empty() {
433                        // Count braces to check if JSON is complete
434                        let open_braces = current_args.matches('{').count();
435                        let close_braces = current_args.matches('}').count();
436                        if open_braces > close_braces {
437                            calls.push(ToolCallItem {
438                                tool_index: self.current_tool_id as usize,
439                                name: None,
440                                parameters: "}".to_string(),
441                            });
442                            self.streamed_args_for_tool[self.current_tool_id as usize].push('}');
443                        }
444                    }
445
446                    // Complete the tool call
447                    self.buffer =
448                        self.buffer[end_pos + self.tool_call_end_token.len()..].to_string();
449                    self.reset_streaming_state();
450                    self.current_tool_id += 1;
451                    continue;
452                } else {
453                    // Tool call not complete yet, wait for more text
454                    break;
455                }
456            }
457
458            break;
459        }
460
461        Ok(StreamingParseResult { normal_text, calls })
462    }
463
464    fn has_tool_markers(&self, text: &str) -> bool {
465        text.contains(self.tool_call_start_token)
466    }
467
468    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
469        helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
470    }
471
472    fn reset(&mut self) {
473        helpers::reset_parser_state(
474            &mut self.buffer,
475            &mut self.prev_tool_call_arr,
476            &mut self.current_tool_id,
477            &mut self.current_tool_name_sent,
478            &mut self.streamed_args_for_tool,
479        );
480        self.reset_streaming_state();
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use openai_protocol::common::Function;
487
488    use super::*;
489
490    #[test]
491    fn test_safe_val_json() {
492        assert_eq!(safe_val("42"), Value::Number(42.into()));
493        assert_eq!(safe_val("1.5"), serde_json::json!(1.5));
494        assert_eq!(safe_val("true"), Value::Bool(true));
495        assert_eq!(safe_val("false"), Value::Bool(false));
496        assert_eq!(safe_val("null"), Value::Null);
497        assert_eq!(
498            safe_val(r#"{"key": "value"}"#),
499            serde_json::json!({"key": "value"})
500        );
501        assert_eq!(safe_val(r"[1, 2, 3]"), serde_json::json!([1, 2, 3]));
502    }
503
504    #[test]
505    fn test_safe_val_python_literals() {
506        assert_eq!(safe_val("True"), Value::Bool(true));
507        assert_eq!(safe_val("False"), Value::Bool(false));
508        assert_eq!(safe_val("None"), Value::Null);
509    }
510
511    #[test]
512    fn test_safe_val_string_fallback() {
513        assert_eq!(
514            safe_val("hello world"),
515            Value::String("hello world".to_string())
516        );
517        assert_eq!(safe_val("  spaces  "), Value::String("spaces".to_string()));
518    }
519
520    // Values are treated literally: entity-like substrings must NOT be decoded
521    // (parity with Qwen's official API, which returns them intact).
522    #[test]
523    fn test_safe_val_preserves_html_entities() {
524        assert_eq!(
525            safe_val("&lt;div&gt;"),
526            Value::String("&lt;div&gt;".to_string())
527        );
528        assert_eq!(
529            safe_val("Tom &amp; Jerry"),
530            Value::String("Tom &amp; Jerry".to_string())
531        );
532        // Numeric/hex entities are likewise left untouched.
533        assert_eq!(safe_val("it&#39;s"), Value::String("it&#39;s".to_string()));
534        assert_eq!(safe_val("&#x3C;"), Value::String("&#x3C;".to_string()));
535    }
536
537    fn tool_with_props(props: Value) -> Vec<Tool> {
538        vec![Tool {
539            tool_type: "function".to_string(),
540            function: Function {
541                name: "f".to_string(),
542                description: None,
543                parameters: serde_json::json!({"type": "object", "properties": props}),
544                strict: None,
545            },
546        }]
547    }
548
549    // String-typed params stay strings even when they look numeric/bool/array/object.
550    #[tokio::test]
551    async fn test_schema_aware_coercion_keeps_strings() {
552        let tools = tool_with_props(serde_json::json!({
553            "limit": {"type": "string"},
554            "flag": {"type": "string"},
555            "coords": {"type": "string"},
556            "cfg": {"type": "string"},
557            "count": {"type": "integer"},
558        }));
559        let text = "<tool_call>\n<function=f>\n\
560            <parameter=limit>4</parameter>\n\
561            <parameter=flag>true</parameter>\n\
562            <parameter=coords>[60,30]</parameter>\n\
563            <parameter=cfg>{\"a\": 1}</parameter>\n\
564            <parameter=count>5</parameter>\n\
565            </function>\n</tool_call>";
566        let (_, calls) = QwenXmlParser::new()
567            .parse_complete_with_tools(text, &tools)
568            .await
569            .unwrap();
570        assert_eq!(calls.len(), 1);
571        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
572        assert_eq!(args["limit"], Value::String("4".to_string()));
573        assert_eq!(args["flag"], Value::String("true".to_string()));
574        assert_eq!(args["coords"], Value::String("[60,30]".to_string()));
575        assert_eq!(args["cfg"], Value::String("{\"a\": 1}".to_string()));
576        assert_eq!(args["count"], Value::Number(5.into()));
577    }
578
579    // The streaming path threads `tools` separately, so cover it too.
580    #[tokio::test]
581    async fn test_streaming_schema_aware_coercion() {
582        let tools = tool_with_props(serde_json::json!({
583            "limit": {"type": "string"},
584            "count": {"type": "integer"},
585        }));
586        let text = "<tool_call>\n<function=f>\n\
587            <parameter=limit>4</parameter>\n\
588            <parameter=count>5</parameter>\n\
589            </function>\n</tool_call>";
590        let result = QwenXmlParser::new()
591            .parse_incremental(text, &tools)
592            .await
593            .unwrap();
594        let args: String = result.calls.iter().map(|c| c.parameters.as_str()).collect();
595        assert!(
596            args.contains(r#""limit": "4""#),
597            "string param must stay string: {args}"
598        );
599        assert!(
600            args.contains(r#""count": 5"#),
601            "int param must coerce: {args}"
602        );
603    }
604
605    // Golden conformance test (regression guard for #1888): a tool argument
606    // whose value contains HTML entities must round-trip UNCHANGED, matching
607    // Qwen's official API (verified on Qwen3-Coder and Qwen3.5). Covers both the
608    // schema-typed `string` path and the schema-less inference fallback.
609    #[tokio::test]
610    async fn test_arg_values_with_entities_roundtrip_unchanged() {
611        let literal = "<a>Tom &amp; Jerry</a> &lt;x&gt; it&#39;s";
612
613        // Function name matches `tool_with_props` (`f`) so the schema-typed
614        // branch below actually resolves the `content` param's type.
615        let text = format!(
616            "<tool_call>\n<function=f>\n\
617             <parameter=content>\n{literal}\n</parameter>\n\
618             </function>\n</tool_call>"
619        );
620
621        // Schema-less inference path (`safe_val`): not valid JSON -> stays a
622        // literal string with entities intact.
623        let (_, calls) = QwenXmlParser::new().parse_complete(&text).await.unwrap();
624        assert_eq!(calls.len(), 1);
625        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
626        assert_eq!(args["content"], Value::String(literal.to_string()));
627
628        // Schema-typed `string` path (`coerce_value` -> `coerce_by_schema_type`).
629        let tools = tool_with_props(serde_json::json!({
630            "content": {"type": "string"},
631        }));
632        let (_, calls) = QwenXmlParser::new()
633            .parse_complete_with_tools(&text, &tools)
634            .await
635            .unwrap();
636        let args: Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
637        assert_eq!(args["content"], Value::String(literal.to_string()));
638    }
639}