Skip to main content

gglib_core/normalize/parsers/
qwen_xml.rs

1//! Qwen-style XML tool-call parser.
2//!
3//! Rewrites `<tool_call>...</tool_call>` markup — emitted by Qwen 2 / 2.5 / 3
4//! family models in either the text or reasoning channel — into proper
5//! [`ToolCall`] values.  Bytes outside of `<tool_call>` regions are forwarded
6//! verbatim on the channel they arrived on.
7//!
8//! Two body dialects are accepted inside the wrapper, tried in order — see
9//! `finalize_tool_call` for the detail:
10//! 1. **JSON** — `{"name":"foo","arguments":{...}}` (Qwen 2 / 2.5).
11//! 2. **Inner XML** — `<function=NAME><parameter=KEY>VALUE</parameter>...</function>`,
12//!    one or more back-to-back inside a single wrapper (Qwen 3 + `--jinja`,
13//!    Hermes-style).
14//!
15//! ## Chunk safety
16//!
17//! Both the open marker (`<tool_call>`, 11 bytes) and the close marker
18//! (`</tool_call>`, 12 bytes) may straddle SSE chunk boundaries.  The parser
19//! holds back at most `CLOSE_MARKER.len() - 1 = 11` bytes per channel as a
20//! lookahead buffer.  The buffered bytes are flushed on the next push or at
21//! [`ToolCallParser::finish`].
22//!
23//! ## Cross-channel handling
24//!
25//! In practice a Qwen tool call appears entirely on one channel — either
26//! text (no reasoning split) or reasoning (when `--reasoning-format` is on).
27//! Each channel therefore maintains its own independent parser state
28//! ([`ChannelState`]) so that markup never crosses channels.  The synthesised
29//! tool-call IDs share a single monotonic counter across both channels.
30
31use serde_json::Value;
32
33use super::super::error::NormalizationError;
34use super::super::parser::{ParserOutput, ToolCallParser};
35use crate::domain::agent::ToolCall;
36
37/// Open marker for a Qwen tool call.
38const OPEN: &str = "<tool_call>";
39/// Close marker for a Qwen tool call.
40const CLOSE: &str = "</tool_call>";
41
42/// Per-channel scanning state.  The text and reasoning channels each own
43/// one of these; they never share buffers.
44#[derive(Default, Debug)]
45struct ChannelState {
46    /// Trailing bytes whose status (markup vs payload) is not yet decided.
47    pending: String,
48    /// `true` between an open and close marker.
49    inside: bool,
50    /// JSON body accumulated while `inside` is true.
51    body: String,
52}
53
54/// Output channel selector — keeps `scan` channel-agnostic.
55#[derive(Copy, Clone)]
56enum Channel {
57    Text,
58    Reasoning,
59}
60
61/// Parser for the Qwen XML tool-call dialect.  See module docs.
62#[derive(Default, Debug)]
63pub struct QwenXmlParser {
64    text: ChannelState,
65    reasoning: ChannelState,
66    /// Monotonic counter for synthesised tool-call IDs.  Shared across
67    /// both channels so IDs remain globally unique within a single stream.
68    next_id: u32,
69}
70
71impl QwenXmlParser {
72    /// Construct a fresh parser with empty per-channel buffers.
73    #[must_use]
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Mint a stream-unique synthetic ID for an extracted tool call.
79    fn mint_id(&mut self) -> String {
80        let n = self.next_id;
81        self.next_id = self.next_id.saturating_add(1);
82        format!("call_qwen_{n}")
83    }
84
85    /// Drive the state machine for one channel.
86    ///
87    /// All scanning logic lives here; `push_text` and `push_reasoning` are
88    /// thin dispatch wrappers that pick the right `ChannelState` and route
89    /// flushed bytes to the right output field.
90    fn scan(&mut self, channel: Channel, chunk: &str) -> ParserOutput {
91        let mut out = ParserOutput::default();
92
93        // Take ownership of the channel state by moving it out, then put it
94        // back at the end.  This sidesteps the borrow conflict between
95        // `&mut self.text` (or `.reasoning`) and `&mut self` for `mint_id`.
96        let mut state = match channel {
97            Channel::Text => std::mem::take(&mut self.text),
98            Channel::Reasoning => std::mem::take(&mut self.reasoning),
99        };
100
101        state.pending.push_str(chunk);
102
103        loop {
104            if state.inside {
105                if let Some(p) = state.pending.find(CLOSE) {
106                    state.body.push_str(&state.pending[..p]);
107                    finalize_tool_call(&state.body, &mut out, || self.mint_id());
108                    state.body.clear();
109                    state.inside = false;
110                    state.pending.drain(..p + CLOSE.len());
111                    continue;
112                }
113                let keep = partial_suffix_len(state.pending.as_bytes(), CLOSE.as_bytes());
114                let flush_to = state.pending.len() - keep;
115                state.body.push_str(&state.pending[..flush_to]);
116                state.pending.drain(..flush_to);
117                break;
118            }
119
120            // Outside any tool_call.
121            if let Some(p) = state.pending.find(OPEN) {
122                forward(&mut out, channel, &state.pending[..p]);
123                state.pending.drain(..p + OPEN.len());
124                state.inside = true;
125                continue;
126            }
127            let keep = partial_suffix_len(state.pending.as_bytes(), OPEN.as_bytes());
128            let flush_to = state.pending.len() - keep;
129            forward(&mut out, channel, &state.pending[..flush_to]);
130            state.pending.drain(..flush_to);
131            break;
132        }
133
134        match channel {
135            Channel::Text => self.text = state,
136            Channel::Reasoning => self.reasoning = state,
137        }
138        out
139    }
140
141    /// Flush a single channel at end-of-stream.
142    fn flush_channel(&mut self, channel: Channel) -> ParserOutput {
143        let mut out = ParserOutput::default();
144        let state = match channel {
145            Channel::Text => std::mem::take(&mut self.text),
146            Channel::Reasoning => std::mem::take(&mut self.reasoning),
147        };
148        if state.inside {
149            // Stream ended mid-`<tool_call>`.  Surface as an error and
150            // discard the partial body — we have no way to know how it
151            // would have closed.
152            let mut partial = state.body;
153            partial.push_str(&state.pending);
154            out.errors
155                .push(NormalizationError::unclosed_tool_call(partial));
156        } else {
157            // Any held-back bytes turned out to be ordinary text — flush.
158            forward(&mut out, channel, &state.pending);
159        }
160        out
161    }
162}
163
164impl ToolCallParser for QwenXmlParser {
165    fn push_text(&mut self, chunk: &str) -> ParserOutput {
166        self.scan(Channel::Text, chunk)
167    }
168
169    fn push_reasoning(&mut self, chunk: &str) -> ParserOutput {
170        self.scan(Channel::Reasoning, chunk)
171    }
172
173    fn finish(&mut self) -> ParserOutput {
174        let mut a = self.flush_channel(Channel::Text);
175        let b = self.flush_channel(Channel::Reasoning);
176        a.forward_text.push_str(&b.forward_text);
177        a.forward_reasoning.push_str(&b.forward_reasoning);
178        a.tool_calls.extend(b.tool_calls);
179        a.errors.extend(b.errors);
180        a
181    }
182}
183
184// =============================================================================
185// Free helpers
186// =============================================================================
187
188/// Append `bytes` to the channel-appropriate field of `out`.
189fn forward(out: &mut ParserOutput, channel: Channel, bytes: &str) {
190    if bytes.is_empty() {
191        return;
192    }
193    match channel {
194        Channel::Text => out.forward_text.push_str(bytes),
195        Channel::Reasoning => out.forward_reasoning.push_str(bytes),
196    }
197}
198
199/// Parse the accumulated tool-call body and push the resulting [`ToolCall`]s
200/// (or a [`NormalizationError`]) onto `out`.
201///
202/// Two body shapes are accepted, in order:
203/// 1. **JSON** — `{"name":"foo","arguments":{...}}` (Qwen2/2.5 native).
204/// 2. **Inner XML** — one or more back-to-back
205///    `<function=NAME><parameter=KEY>VAL</parameter>...</function>` blocks
206///    (Qwen3 + `--jinja`, Hermes-style).
207///
208/// JSON is tried first because it is the historical Qwen format and is
209/// unambiguous; the XML form is the documented fallback for Qwen3 chat
210/// templates that emit nested function/parameter markup inside `<tool_call>`.
211///
212/// On failure, the error kind reflects which dialect was attempted: a body
213/// that looks like it opened the XML dialect (`<function=`) but didn't match
214/// its shape reports [`NormalizationErrorKind::MalformedFunctionXml`]
215/// instead of the generic JSON failure, since the two dialects fail for
216/// unrelated reasons and a log reader should not have to guess which one was
217/// in play.
218///
219/// [`NormalizationErrorKind::MalformedFunctionXml`]: crate::normalize::error::NormalizationErrorKind::MalformedFunctionXml
220fn finalize_tool_call(body: &str, out: &mut ParserOutput, mut mint_id: impl FnMut() -> String) {
221    let trimmed = body.trim();
222    if let Some(call) = parse_json_body(trimmed, &mut mint_id) {
223        out.tool_calls.push(call);
224        return;
225    }
226    if let Some(calls) = parse_function_xml_body(trimmed, &mut mint_id) {
227        out.tool_calls.extend(calls);
228        return;
229    }
230    let error = if trimmed.starts_with("<function=") {
231        NormalizationError::malformed_function_xml(body.to_owned())
232    } else {
233        NormalizationError::malformed_tool_call(body.to_owned())
234    };
235    out.errors.push(error);
236}
237
238/// Try to interpret `body` as a Qwen JSON tool call.
239fn parse_json_body(body: &str, mint_id: &mut impl FnMut() -> String) -> Option<ToolCall> {
240    let parsed: Value = serde_json::from_str(body).ok()?;
241    let obj = parsed.as_object()?;
242    let name = obj.get("name").and_then(Value::as_str)?.to_owned();
243    let arguments = obj
244        .get("arguments")
245        .cloned()
246        .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
247    Some(ToolCall {
248        id: mint_id(),
249        name,
250        arguments,
251    })
252}
253
254/// Try to interpret `body` as one or more back-to-back Hermes/Qwen3
255/// inner-XML tool calls:
256/// `<function=NAME><parameter=KEY>VALUE</parameter>...</function>`, repeated.
257///
258/// Whitespace between tags is tolerated. Each parameter value is parsed as
259/// JSON when it looks like a JSON literal (`{`, `[`, quoted string, number,
260/// `true`/`false`/`null`); otherwise it is forwarded as a string after
261/// trimming surrounding whitespace — see [`parse_param_value`]'s doc comment
262/// for the coercion's known limitation.
263///
264/// Returns `None` (not `Some(vec![])`) when `body` doesn't open with
265/// `<function=` at all or the very first block is malformed, so the caller
266/// can fall through to a "no dialect matched" error. A body that opens
267/// correctly but has a malformed *later* block currently stops at that point
268/// and returns `None` for the whole body, discarding any calls already
269/// parsed — the same fail-shut behaviour the single-call parser always had.
270fn parse_function_xml_body(
271    body: &str,
272    mint_id: &mut impl FnMut() -> String,
273) -> Option<Vec<ToolCall>> {
274    let mut calls = Vec::new();
275    let mut cursor = body.trim();
276
277    while !cursor.is_empty() {
278        let after_open = cursor.strip_prefix("<function=")?;
279        let name_end = after_open.find('>')?;
280        let name = after_open[..name_end].trim();
281        if name.is_empty() {
282            return None;
283        }
284        let after_name = &after_open[name_end + 1..];
285
286        // This block's own `</function>` is the LAST occurrence before the
287        // next sibling `<function=`, if any — never the first occurrence
288        // found anywhere in the remainder, which could belong to a
289        // parameter's own value (e.g. a `content` parameter whose text
290        // happens to mention "</function>"). See `find_own_close` for the
291        // same rule applied to `</parameter>`.
292        let close_at = find_own_close(after_name, "</function>", "<function=")?;
293        let inner = after_name[..close_at].trim();
294        let after_function = &after_name[close_at + "</function>".len()..];
295
296        let mut args = serde_json::Map::new();
297        let mut param_cursor = inner;
298        while !param_cursor.is_empty() {
299            param_cursor = param_cursor.trim_start();
300            if param_cursor.is_empty() {
301                break;
302            }
303            let after_param = param_cursor.strip_prefix("<parameter=")?;
304            let key_end = after_param.find('>')?;
305            let key = after_param[..key_end].trim().to_owned();
306            if key.is_empty() {
307                return None;
308            }
309            let rest = &after_param[key_end + 1..];
310            let close_at = find_own_close(rest, "</parameter>", "<parameter=")?;
311            let raw_value = rest[..close_at].trim();
312            args.insert(key, parse_param_value(raw_value));
313            param_cursor = &rest[close_at + "</parameter>".len()..];
314        }
315
316        calls.push(ToolCall {
317            id: mint_id(),
318            name: name.to_owned(),
319            arguments: Value::Object(args),
320        });
321
322        cursor = after_function.trim_start();
323    }
324
325    (!calls.is_empty()).then_some(calls)
326}
327
328/// Find this tag's own closing marker inside `rest`: the LAST occurrence of
329/// `close` before the next sibling `next_open` marker (or before the end of
330/// `rest`, if there is no next sibling).
331///
332/// A naive `rest.find(close)` truncates the value early whenever it happens
333/// to contain the literal closing-tag text — a real risk for a `content` or
334/// `code` parameter carrying anything that looks like markup. The tag's true
335/// close is always the one immediately before its next sibling opens (or the
336/// end of the block), never an earlier occurrence, so searching backward
337/// from that boundary finds it correctly even when the value embeds the
338/// marker text. This is not a complete fix — a value that also happens to
339/// contain the *next sibling's* open marker is still ambiguous, since this
340/// dialect has no escaping mechanism — but it is strictly more often correct
341/// than a forward search from the start.
342fn find_own_close(rest: &str, close: &str, next_open: &str) -> Option<usize> {
343    let boundary = rest.find(next_open).unwrap_or(rest.len());
344    rest[..boundary].rfind(close)
345}
346
347/// Best-effort coercion of a `<parameter>` body to a JSON value. Falls back
348/// to a string literal when the body is not valid JSON.
349///
350/// This is inherently lossy: the dialect gives no way to distinguish a
351/// parameter that is genuinely meant to be the *string* `"true"` or `"123"`
352/// from one meant to be the boolean or the number — both coerce to the typed
353/// value. There is no tool `input_schema` available here to disambiguate
354/// against (the parser has no access to the tool definitions that produced
355/// this call), so this is a best-effort guess, not a guarantee.
356fn parse_param_value(raw: &str) -> Value {
357    if raw.is_empty() {
358        return Value::String(String::new());
359    }
360    if let Ok(v) = serde_json::from_str::<Value>(raw) {
361        return v;
362    }
363    Value::String(raw.to_owned())
364}
365
366/// Largest `n` in `[0, marker.len())` such that the last `n` bytes of `buf`
367/// are a prefix of `marker`.  Used as the lookahead window for chunk-safe
368/// marker detection.
369fn partial_suffix_len(buf: &[u8], marker: &[u8]) -> usize {
370    if marker.len() < 2 {
371        return 0;
372    }
373    let max = std::cmp::min(buf.len(), marker.len() - 1);
374    for n in (1..=max).rev() {
375        if buf.ends_with(&marker[..n]) {
376            return n;
377        }
378    }
379    0
380}
381
382// =============================================================================
383// Tests
384// =============================================================================
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use serde_json::json;
390
391    fn collect(p: &mut QwenXmlParser, chunks: &[&str]) -> ParserOutput {
392        let mut total = ParserOutput::default();
393        for c in chunks {
394            let o = p.push_text(c);
395            total.forward_text.push_str(&o.forward_text);
396            total.forward_reasoning.push_str(&o.forward_reasoning);
397            total.tool_calls.extend(o.tool_calls);
398            total.errors.extend(o.errors);
399        }
400        let f = p.finish();
401        total.forward_text.push_str(&f.forward_text);
402        total.forward_reasoning.push_str(&f.forward_reasoning);
403        total.tool_calls.extend(f.tool_calls);
404        total.errors.extend(f.errors);
405        total
406    }
407
408    #[test]
409    fn passthrough_with_no_markup() {
410        let mut p = QwenXmlParser::new();
411        let out = collect(&mut p, &["hello ", "world"]);
412        assert_eq!(out.forward_text, "hello world");
413        assert!(out.tool_calls.is_empty());
414        assert!(out.errors.is_empty());
415    }
416
417    #[test]
418    fn extracts_simple_tool_call_from_text() {
419        let mut p = QwenXmlParser::new();
420        let out = collect(
421            &mut p,
422            &[r#"before<tool_call>{"name":"foo","arguments":{"x":1}}</tool_call>after"#],
423        );
424        assert_eq!(out.forward_text, "beforeafter");
425        assert_eq!(out.tool_calls.len(), 1);
426        assert_eq!(out.tool_calls[0].id, "call_qwen_0");
427        assert_eq!(out.tool_calls[0].name, "foo");
428        assert_eq!(out.tool_calls[0].arguments, json!({"x": 1}));
429        assert!(out.errors.is_empty());
430    }
431
432    #[test]
433    fn open_tag_straddles_chunk_boundary() {
434        let mut p = QwenXmlParser::new();
435        let out = collect(
436            &mut p,
437            &[
438                "before<tool",
439                "_call>",
440                r#"{"name":"foo","arguments":{}}"#,
441                "</tool_call>",
442                "after",
443            ],
444        );
445        assert_eq!(out.forward_text, "beforeafter");
446        assert_eq!(out.tool_calls.len(), 1);
447        assert_eq!(out.tool_calls[0].name, "foo");
448    }
449
450    #[test]
451    fn close_tag_straddles_chunk_boundary() {
452        let mut p = QwenXmlParser::new();
453        let out = collect(
454            &mut p,
455            &[
456                "<tool_call>",
457                r#"{"name":"foo","arguments":{}}</tool"#,
458                "_call>tail",
459            ],
460        );
461        assert_eq!(out.forward_text, "tail");
462        assert_eq!(out.tool_calls.len(), 1);
463        assert_eq!(out.tool_calls[0].name, "foo");
464    }
465
466    #[test]
467    fn one_byte_at_a_time_still_works() {
468        let mut p = QwenXmlParser::new();
469        let s = r#"x<tool_call>{"name":"f","arguments":{"a":2}}</tool_call>y"#;
470        let chunks: Vec<String> = s.chars().map(|c| c.to_string()).collect();
471        let refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
472        let out = collect(&mut p, &refs);
473        assert_eq!(out.forward_text, "xy");
474        assert_eq!(out.tool_calls.len(), 1);
475        assert_eq!(out.tool_calls[0].arguments, json!({"a": 2}));
476    }
477
478    #[test]
479    fn tool_call_in_reasoning_channel_is_extracted() {
480        let mut p = QwenXmlParser::new();
481        let chunk = r#"thinking <tool_call>{"name":"foo","arguments":{}}</tool_call> done"#;
482        let out = p.push_reasoning(chunk);
483        let f = p.finish();
484        assert_eq!(out.forward_reasoning, "thinking  done");
485        assert_eq!(out.tool_calls.len(), 1);
486        assert_eq!(out.tool_calls[0].name, "foo");
487        assert!(f.is_empty());
488    }
489
490    #[test]
491    fn malformed_json_emits_error() {
492        let mut p = QwenXmlParser::new();
493        let out = collect(&mut p, &["<tool_call>not json</tool_call>"]);
494        assert!(out.tool_calls.is_empty());
495        assert_eq!(out.errors.len(), 1);
496        assert!(matches!(
497            out.errors[0].kind,
498            crate::normalize::error::NormalizationErrorKind::MalformedToolCallJson { .. }
499        ));
500    }
501
502    #[test]
503    fn missing_name_field_is_malformed() {
504        let mut p = QwenXmlParser::new();
505        let out = collect(&mut p, &[r#"<tool_call>{"arguments":{}}</tool_call>"#]);
506        assert!(out.tool_calls.is_empty());
507        assert_eq!(out.errors.len(), 1);
508    }
509
510    #[test]
511    fn missing_arguments_defaults_to_empty_object() {
512        let mut p = QwenXmlParser::new();
513        let out = collect(&mut p, &[r#"<tool_call>{"name":"foo"}</tool_call>"#]);
514        assert_eq!(out.tool_calls.len(), 1);
515        assert_eq!(out.tool_calls[0].arguments, json!({}));
516        assert!(out.errors.is_empty());
517    }
518
519    #[test]
520    fn unclosed_tag_at_end_yields_error() {
521        let mut p = QwenXmlParser::new();
522        let _ = p.push_text(r#"hello <tool_call>{"name":"foo""#);
523        let f = p.finish();
524        assert_eq!(f.errors.len(), 1);
525        assert!(matches!(
526            f.errors[0].kind,
527            crate::normalize::error::NormalizationErrorKind::UnclosedToolCallTag { .. }
528        ));
529        assert!(f.tool_calls.is_empty());
530    }
531
532    #[test]
533    fn multiple_tool_calls_get_distinct_ids() {
534        let mut p = QwenXmlParser::new();
535        let out = collect(
536            &mut p,
537            &[
538                r#"<tool_call>{"name":"a","arguments":{}}</tool_call>"#,
539                r#"<tool_call>{"name":"b","arguments":{}}</tool_call>"#,
540            ],
541        );
542        assert_eq!(out.tool_calls.len(), 2);
543        assert_eq!(out.tool_calls[0].id, "call_qwen_0");
544        assert_eq!(out.tool_calls[1].id, "call_qwen_1");
545    }
546
547    #[test]
548    fn partial_marker_lookalike_is_eventually_flushed() {
549        // "<tool" looks like an open-marker prefix but is actually just
550        // text — finish() should flush it.
551        let mut p = QwenXmlParser::new();
552        let mid = p.push_text("<tool");
553        assert_eq!(mid.forward_text, "");
554        let f = p.finish();
555        assert_eq!(f.forward_text, "<tool");
556    }
557
558    #[test]
559    fn partial_suffix_len_finds_longest_overlap() {
560        assert_eq!(partial_suffix_len(b"abc<tool", b"<tool_call>"), 5);
561        assert_eq!(partial_suffix_len(b"abc<", b"<tool_call>"), 1);
562        assert_eq!(partial_suffix_len(b"abc", b"<tool_call>"), 0);
563        // A full-marker suffix is *not* a partial — only proper prefixes
564        // (1..len) count.  A full match is `find`'s job upstream.
565        assert_eq!(partial_suffix_len(b"<tool_call>", b"<tool_call>"), 0);
566        // The longest proper prefix that the buffer ends with is "<".
567        assert_eq!(partial_suffix_len(b"</tool_call><", b"<tool_call>"), 1);
568    }
569
570    // -------------------------------------------------------------------
571    // Inner-XML (`<function=…><parameter=…>…</parameter></function>`) —
572    // the Qwen3 + `--jinja` tool-call body shape.
573    // -------------------------------------------------------------------
574
575    #[test]
576    fn extracts_function_xml_body_with_string_param() {
577        let mut p = QwenXmlParser::new();
578        let body = "<tool_call>\n<function=grep>\n<parameter=regex>\ngglib\\s+q\n</parameter>\n</function>\n</tool_call>";
579        let out = collect(&mut p, &[body]);
580        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
581        assert_eq!(out.tool_calls.len(), 1);
582        assert_eq!(out.tool_calls[0].name, "grep");
583        assert_eq!(
584            out.tool_calls[0].arguments,
585            json!({ "regex": "gglib\\s+q" })
586        );
587    }
588
589    #[test]
590    fn function_xml_body_with_multiple_params() {
591        let mut p = QwenXmlParser::new();
592        let body = concat!(
593            "<tool_call><function=read_file>",
594            "<parameter=path>src/main.rs</parameter>",
595            "<parameter=start_line>1</parameter>",
596            "<parameter=end_line>20</parameter>",
597            "</function></tool_call>",
598        );
599        let out = collect(&mut p, &[body]);
600        assert!(out.errors.is_empty());
601        assert_eq!(out.tool_calls.len(), 1);
602        assert_eq!(out.tool_calls[0].name, "read_file");
603        assert_eq!(
604            out.tool_calls[0].arguments,
605            json!({ "path": "src/main.rs", "start_line": 1, "end_line": 20 })
606        );
607    }
608
609    #[test]
610    fn function_xml_body_with_json_object_param() {
611        let mut p = QwenXmlParser::new();
612        let body = r#"<tool_call><function=run><parameter=opts>{"a":1,"b":[2,3]}</parameter></function></tool_call>"#;
613        let out = collect(&mut p, &[body]);
614        assert!(out.errors.is_empty());
615        assert_eq!(out.tool_calls.len(), 1);
616        assert_eq!(
617            out.tool_calls[0].arguments,
618            json!({ "opts": { "a": 1, "b": [2, 3] } })
619        );
620    }
621
622    #[test]
623    fn function_xml_body_streamed_byte_by_byte() {
624        let mut p = QwenXmlParser::new();
625        let s = "<tool_call><function=grep><parameter=regex>x</parameter></function></tool_call>";
626        let chunks: Vec<String> = s.chars().map(|c| c.to_string()).collect();
627        let refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
628        let out = collect(&mut p, &refs);
629        assert!(out.errors.is_empty());
630        assert_eq!(out.tool_calls.len(), 1);
631        assert_eq!(out.tool_calls[0].name, "grep");
632        assert_eq!(out.tool_calls[0].arguments, json!({ "regex": "x" }));
633    }
634
635    #[test]
636    fn function_xml_body_without_parameters_yields_empty_args() {
637        let mut p = QwenXmlParser::new();
638        let body = "<tool_call><function=ping></function></tool_call>";
639        let out = collect(&mut p, &[body]);
640        assert!(out.errors.is_empty());
641        assert_eq!(out.tool_calls.len(), 1);
642        assert_eq!(out.tool_calls[0].name, "ping");
643        assert_eq!(out.tool_calls[0].arguments, json!({}));
644    }
645
646    /// Multiple `<function=...>` blocks back-to-back inside one `<tool_call>`
647    /// wrapper (Hermes-style multi-call) must all be extracted, in order,
648    /// with distinct synthesised IDs.
649    #[test]
650    fn multiple_function_blocks_in_one_wrapper_are_all_extracted() {
651        let mut p = QwenXmlParser::new();
652        let body = concat!(
653            "<tool_call>",
654            "<function=get_weather><parameter=city>Paris</parameter></function>",
655            "<function=get_time><parameter=zone>UTC</parameter></function>",
656            "</tool_call>",
657        );
658        let out = collect(&mut p, &[body]);
659        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
660        assert_eq!(out.tool_calls.len(), 2);
661        assert_eq!(out.tool_calls[0].name, "get_weather");
662        assert_eq!(out.tool_calls[0].arguments, json!({"city": "Paris"}));
663        assert_eq!(out.tool_calls[1].name, "get_time");
664        assert_eq!(out.tool_calls[1].arguments, json!({"zone": "UTC"}));
665        assert_ne!(
666            out.tool_calls[0].id, out.tool_calls[1].id,
667            "each call in the block needs its own ID"
668        );
669    }
670
671    /// A value that happens to contain the literal text `</parameter>` must
672    /// not truncate the value early — the true close is the last occurrence
673    /// before the next sibling tag, not the first occurrence anywhere. This
674    /// is the naive-`find` bug: the old implementation would have stopped at
675    /// "Use ", left `to close a param` dangling as unparsed cursor bytes, and
676    /// failed the whole block.
677    #[test]
678    fn a_parameter_value_containing_the_literal_close_marker_does_not_truncate() {
679        let mut p = QwenXmlParser::new();
680        let body = concat!(
681            "<tool_call><function=write_doc>",
682            "<parameter=text>Use </parameter> to close a param</parameter>",
683            "<parameter=lang>en</parameter>",
684            "</function></tool_call>",
685        );
686        let out = collect(&mut p, &[body]);
687        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
688        assert_eq!(out.tool_calls.len(), 1);
689        assert_eq!(
690            out.tool_calls[0].arguments,
691            json!({"text": "Use </parameter> to close a param", "lang": "en"})
692        );
693    }
694
695    /// Same rule at the `</function>` boundary: a parameter value containing
696    /// the literal text `</function>` must not truncate the function body
697    /// early when another sibling function follows.
698    #[test]
699    fn a_parameter_value_containing_the_literal_function_close_does_not_truncate() {
700        let mut p = QwenXmlParser::new();
701        let body = concat!(
702            "<tool_call>",
703            "<function=write_doc><parameter=text>end with </function> tag</parameter></function>",
704            "<function=ping></function>",
705            "</tool_call>",
706        );
707        let out = collect(&mut p, &[body]);
708        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
709        assert_eq!(out.tool_calls.len(), 2);
710        assert_eq!(
711            out.tool_calls[0].arguments,
712            json!({"text": "end with </function> tag"})
713        );
714        assert_eq!(out.tool_calls[1].name, "ping");
715    }
716
717    /// A body that opens the XML dialect (`<function=`) but is structurally
718    /// broken must be reported with the XML-specific error kind, not the
719    /// generic JSON one — the two dialects fail for unrelated reasons.
720    #[test]
721    fn malformed_function_xml_gets_its_own_error_kind() {
722        let mut p = QwenXmlParser::new();
723        let out = collect(
724            &mut p,
725            &["<tool_call><function=oops(no closing angle</tool_call>"],
726        );
727        assert!(out.tool_calls.is_empty());
728        assert_eq!(out.errors.len(), 1);
729        assert!(matches!(
730            out.errors[0].kind,
731            crate::normalize::error::NormalizationErrorKind::MalformedFunctionXml { .. }
732        ));
733    }
734
735    /// Pinning the type-coercion limitation documented on
736    /// `parse_param_value`: a parameter meant to be the literal string
737    /// `"true"` or `"123"` is indistinguishable from one meant to be the
738    /// boolean or the number, since the dialect carries no schema. This is
739    /// not a bug to fix here — the parser has no `input_schema` to consult —
740    /// but the behaviour must not change silently.
741    #[test]
742    fn parameter_values_that_look_like_json_literals_are_coerced_not_kept_as_strings() {
743        let mut p = QwenXmlParser::new();
744        let body = concat!(
745            "<tool_call><function=configure>",
746            "<parameter=enabled>true</parameter>",
747            "<parameter=count>123</parameter>",
748            "<parameter=label>plain text</parameter>",
749            "</function></tool_call>",
750        );
751        let out = collect(&mut p, &[body]);
752        assert!(out.errors.is_empty());
753        assert_eq!(
754            out.tool_calls[0].arguments,
755            json!({"enabled": true, "count": 123, "label": "plain text"}),
756            "bool- and number-shaped strings coerce; only non-JSON-shaped text stays a string"
757        );
758    }
759}