Skip to main content

gglib_core/normalize/parsers/
delimited.rs

1//! Spec-driven delimited tool-call parser.
2//!
3//! **Tier A — Compensation** ([ADR 0001]). This parser exists because
4//! llama-server handed dialect tool calls to the client as raw text. It is
5//! not gglib's job in principle; it is gglib's job until upstream does it
6//! correctly.
7//!
8//! *Deletion criterion:* llama.cpp's `peg-native` parser handles the
9//! delimited dialects gglib tags, for every tagged model, **and** the
10//! failure modes this parser was hardened against no longer reproduce —
11//! specifically a parameter value containing a literal `</parameter>`
12//! ([#24807]) and a reasoning model emitting prose before the open marker
13//! ([#20260]). Evidence is the drift alarm ([`crate::normalize::residue`])
14//! reporting zero residue across a release cycle with this parser bypassed,
15//! not the mere presence of [`RuntimeFlags::PEG_NATIVE_TOOL_CALLS`].
16//!
17//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
18//! [`RuntimeFlags::PEG_NATIVE_TOOL_CALLS`]: crate::domain::RuntimeFlags::PEG_NATIVE_TOOL_CALLS
19//! [#24807]: https://github.com/ggml-org/llama.cpp/issues/24807
20//! [#20260]: https://github.com/ggml-org/llama.cpp/issues/20260
21//!
22//! Rewrites `OPEN...CLOSE` tool-call markup — emitted inside the text or
23//! reasoning channel — into proper [`ToolCall`] values, where the envelope
24//! markers come from a [`DialectSpec`] rather than being hardcoded.  Bytes
25//! outside envelope regions are forwarded verbatim on the channel they
26//! arrived on.  The built-in [`DialectSpec::qwen_xml`] spec reproduces the
27//! historical Qwen 2 / 2.5 / 3 behaviour (`<tool_call>` markers); template-
28//! derived specs drive the exact same machine with their own markers.
29//!
30//! Body decoding is selected by the spec's ordered [`BodyCodec`] list:
31//! 1. [`BodyCodec::Json`] — `{"name":"foo","arguments":{...}}`
32//!    (Qwen 2 / 2.5, Hermes, and template-derived dialects).
33//! 2. [`BodyCodec::FunctionXml`] —
34//!    `<function=NAME><parameter=KEY>VALUE</parameter>...</function>`,
35//!    one or more back-to-back inside a single wrapper (Qwen 3 + `--jinja`,
36//!    Hermes-style).  The inner markers are codec-internal constants,
37//!    invariant across models that use the codec.
38//!
39//! ## Chunk safety
40//!
41//! Either marker may straddle SSE chunk boundaries.  The parser holds back
42//! at most `marker.len() - 1` bytes per channel as a lookahead buffer.  The
43//! buffered bytes are flushed on the next push or at
44//! [`ToolCallParser::finish`].
45//!
46//! ## Cross-channel handling
47//!
48//! In practice a tool call appears entirely on one channel — either text
49//! (no reasoning split) or reasoning (when `--reasoning-format` is on).
50//! Each channel therefore maintains its own independent parser state
51//! ([`ChannelState`]) so that markup never crosses channels.  The synthesised
52//! tool-call IDs share a single monotonic counter across both channels.
53
54use serde_json::Value;
55
56use super::super::error::NormalizationError;
57use super::super::parser::{ParserOutput, ToolCallParser};
58use crate::domain::agent::ToolCall;
59use crate::domain::dialect::{BodyCodec, DialectSpec};
60
61/// Per-channel scanning state.  The text and reasoning channels each own
62/// one of these; they never share buffers.
63#[derive(Default, Debug)]
64struct ChannelState {
65    /// Trailing bytes whose status (markup vs payload) is not yet decided.
66    pending: String,
67    /// `true` between an open and close marker.
68    inside: bool,
69    /// JSON body accumulated while `inside` is true.
70    body: String,
71}
72
73/// Output channel selector — keeps `scan` channel-agnostic.
74#[derive(Copy, Clone)]
75enum Channel {
76    Text,
77    Reasoning,
78}
79
80/// Parser for delimited tool-call dialects, configured by a
81/// [`DialectSpec`].  See module docs.
82#[derive(Debug)]
83pub(crate) struct DelimitedToolCallParser {
84    /// The dialect being parsed: markers, body codecs, and ID prefix.
85    spec: DialectSpec,
86    text: ChannelState,
87    reasoning: ChannelState,
88    /// Monotonic counter for synthesised tool-call IDs.  Shared across
89    /// both channels so IDs remain globally unique within a single stream.
90    next_id: u32,
91}
92
93impl DelimitedToolCallParser {
94    /// Construct a fresh parser for `spec` with empty per-channel buffers.
95    #[must_use]
96    pub(crate) fn new(spec: DialectSpec) -> Self {
97        Self {
98            spec,
99            text: ChannelState::default(),
100            reasoning: ChannelState::default(),
101            next_id: 0,
102        }
103    }
104
105    /// Mint a stream-unique synthetic ID for an extracted tool call.
106    fn mint_id(&mut self) -> String {
107        let n = self.next_id;
108        self.next_id = self.next_id.saturating_add(1);
109        format!("{}{n}", self.spec.id_prefix)
110    }
111
112    /// Drive the state machine for one channel.
113    ///
114    /// All scanning logic lives here; `push_text` and `push_reasoning` are
115    /// thin dispatch wrappers that pick the right `ChannelState` and route
116    /// flushed bytes to the right output field.
117    fn scan(&mut self, channel: Channel, chunk: &str) -> ParserOutput {
118        let mut out = ParserOutput::default();
119
120        // Clone the spec's marker/codec data into locals so the loop below
121        // can keep borrowing `&mut self` for `mint_id`.  The strings are a
122        // few bytes each; one clone per chunk is noise next to the scan.
123        let open = self.spec.tool_open.clone();
124        let close = self.spec.tool_close.clone();
125        let codecs = self.spec.body_codecs.clone();
126
127        // Take ownership of the channel state by moving it out, then put it
128        // back at the end.  This sidesteps the borrow conflict between
129        // `&mut self.text` (or `.reasoning`) and `&mut self` for `mint_id`.
130        let mut state = match channel {
131            Channel::Text => std::mem::take(&mut self.text),
132            Channel::Reasoning => std::mem::take(&mut self.reasoning),
133        };
134
135        state.pending.push_str(chunk);
136
137        loop {
138            if state.inside {
139                if let Some(p) = state.pending.find(&*close) {
140                    state.body.push_str(&state.pending[..p]);
141                    finalize_tool_call(&codecs, &state.body, &mut out, || self.mint_id());
142                    state.body.clear();
143                    state.inside = false;
144                    state.pending.drain(..p + close.len());
145                    continue;
146                }
147                let keep = partial_suffix_len(state.pending.as_bytes(), close.as_bytes());
148                let flush_to = state.pending.len() - keep;
149                state.body.push_str(&state.pending[..flush_to]);
150                state.pending.drain(..flush_to);
151                break;
152            }
153
154            // Outside any tool-call envelope.
155            if let Some(p) = state.pending.find(&*open) {
156                forward(&mut out, channel, &state.pending[..p]);
157                state.pending.drain(..p + open.len());
158                state.inside = true;
159                continue;
160            }
161            let keep = partial_suffix_len(state.pending.as_bytes(), open.as_bytes());
162            let flush_to = state.pending.len() - keep;
163            forward(&mut out, channel, &state.pending[..flush_to]);
164            state.pending.drain(..flush_to);
165            break;
166        }
167
168        match channel {
169            Channel::Text => self.text = state,
170            Channel::Reasoning => self.reasoning = state,
171        }
172        out
173    }
174
175    /// Flush a single channel at end-of-stream.
176    fn flush_channel(&mut self, channel: Channel) -> ParserOutput {
177        let mut out = ParserOutput::default();
178        let state = match channel {
179            Channel::Text => std::mem::take(&mut self.text),
180            Channel::Reasoning => std::mem::take(&mut self.reasoning),
181        };
182        if state.inside {
183            // Stream ended mid-`<tool_call>`.  Surface as an error and
184            // discard the partial body — we have no way to know how it
185            // would have closed.
186            let mut partial = state.body;
187            partial.push_str(&state.pending);
188            out.errors
189                .push(NormalizationError::unclosed_tool_call(partial));
190        } else {
191            // Any held-back bytes turned out to be ordinary text — flush.
192            forward(&mut out, channel, &state.pending);
193        }
194        out
195    }
196}
197
198impl ToolCallParser for DelimitedToolCallParser {
199    fn push_text(&mut self, chunk: &str) -> ParserOutput {
200        self.scan(Channel::Text, chunk)
201    }
202
203    fn push_reasoning(&mut self, chunk: &str) -> ParserOutput {
204        self.scan(Channel::Reasoning, chunk)
205    }
206
207    fn finish(&mut self) -> ParserOutput {
208        let mut a = self.flush_channel(Channel::Text);
209        let b = self.flush_channel(Channel::Reasoning);
210        a.forward_text.push_str(&b.forward_text);
211        a.forward_reasoning.push_str(&b.forward_reasoning);
212        a.tool_calls.extend(b.tool_calls);
213        a.errors.extend(b.errors);
214        a
215    }
216}
217
218// =============================================================================
219// Free helpers
220// =============================================================================
221
222/// Append `bytes` to the channel-appropriate field of `out`.
223fn forward(out: &mut ParserOutput, channel: Channel, bytes: &str) {
224    if bytes.is_empty() {
225        return;
226    }
227    match channel {
228        Channel::Text => out.forward_text.push_str(bytes),
229        Channel::Reasoning => out.forward_reasoning.push_str(bytes),
230    }
231}
232
233/// Parse the accumulated tool-call body and push the resulting [`ToolCall`]s
234/// (or a [`NormalizationError`]) onto `out`.
235///
236/// The spec's `codecs` are tried in order; the first codec that decodes the
237/// body wins.  The built-in Qwen spec lists JSON before inner XML: JSON is
238/// the historical Qwen format and is unambiguous, while the XML form is the
239/// documented fallback for Qwen3 chat templates that emit nested
240/// function/parameter markup inside the envelope.
241///
242/// On failure, the error kind reflects which codec was plausibly in play: a
243/// body that looks like it opened the inner-XML codec (`<function=`) — when
244/// that codec is enabled — reports
245/// [`NormalizationErrorKind::MalformedFunctionXml`] instead of the generic
246/// JSON failure, since the two codecs fail for unrelated reasons and a log
247/// reader should not have to guess which one was attempted.
248///
249/// [`NormalizationErrorKind::MalformedFunctionXml`]: crate::normalize::error::NormalizationErrorKind::MalformedFunctionXml
250fn finalize_tool_call(
251    codecs: &[BodyCodec],
252    body: &str,
253    out: &mut ParserOutput,
254    mut mint_id: impl FnMut() -> String,
255) {
256    let trimmed = body.trim();
257    for codec in codecs {
258        match codec {
259            BodyCodec::Json => {
260                if let Some(call) = parse_json_body(trimmed, &mut mint_id) {
261                    out.tool_calls.push(call);
262                    return;
263                }
264            }
265            BodyCodec::FunctionXml => {
266                if let Some(calls) = parse_function_xml_body(trimmed, &mut mint_id) {
267                    out.tool_calls.extend(calls);
268                    return;
269                }
270            }
271        }
272    }
273    // Last resort before giving the turn up: repair the packaging locally.
274    //
275    // A body arriving inside a code fence, trailed by prose, or carrying a
276    // trailing comma is a *right* payload in wrong wrapping. Costs
277    // microseconds and no model call, and returns `None` on anything it
278    // cannot repair honestly — so the turn either improves or is unchanged.
279    if codecs.contains(&BodyCodec::Json)
280        && let Some(repaired) = crate::normalize::coerce::coerce_json_object(trimmed)
281        && let Some(call) = parse_json_body(&repaired, &mut mint_id)
282    {
283        out.tool_calls.push(call);
284        return;
285    }
286
287    let error = if codecs.contains(&BodyCodec::FunctionXml) && trimmed.starts_with("<function=") {
288        NormalizationError::malformed_function_xml(body.to_owned())
289    } else {
290        NormalizationError::malformed_tool_call(body.to_owned())
291    };
292    out.errors.push(error);
293}
294
295/// Try to interpret `body` as a Qwen JSON tool call.
296fn parse_json_body(body: &str, mint_id: &mut impl FnMut() -> String) -> Option<ToolCall> {
297    let parsed: Value = serde_json::from_str(body).ok()?;
298    let obj = parsed.as_object()?;
299    let name = obj.get("name").and_then(Value::as_str)?.to_owned();
300    let arguments = obj
301        .get("arguments")
302        .cloned()
303        .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
304    Some(ToolCall {
305        id: mint_id(),
306        name,
307        arguments,
308    })
309}
310
311/// Try to interpret `body` as one or more back-to-back Hermes/Qwen3
312/// inner-XML tool calls:
313/// `<function=NAME><parameter=KEY>VALUE</parameter>...</function>`, repeated.
314///
315/// Whitespace between tags is tolerated. Each parameter value is parsed as
316/// JSON when it looks like a JSON literal (`{`, `[`, quoted string, number,
317/// `true`/`false`/`null`); otherwise it is forwarded as a string after
318/// trimming surrounding whitespace — see [`parse_param_value`]'s doc comment
319/// for the coercion's known limitation.
320///
321/// Returns `None` (not `Some(vec![])`) when `body` doesn't open with
322/// `<function=` at all or the very first block is malformed, so the caller
323/// can fall through to a "no dialect matched" error. A body that opens
324/// correctly but has a malformed *later* block currently stops at that point
325/// and returns `None` for the whole body, discarding any calls already
326/// parsed — the same fail-shut behaviour the single-call parser always had.
327fn parse_function_xml_body(
328    body: &str,
329    mint_id: &mut impl FnMut() -> String,
330) -> Option<Vec<ToolCall>> {
331    let mut calls = Vec::new();
332    let mut cursor = body.trim();
333
334    while !cursor.is_empty() {
335        let after_open = cursor.strip_prefix("<function=")?;
336        let name_end = after_open.find('>')?;
337        let name = after_open[..name_end].trim();
338        if name.is_empty() {
339            return None;
340        }
341        let after_name = &after_open[name_end + 1..];
342
343        // This block's own `</function>` is the LAST occurrence before the
344        // next sibling `<function=`, if any — never the first occurrence
345        // found anywhere in the remainder, which could belong to a
346        // parameter's own value (e.g. a `content` parameter whose text
347        // happens to mention "</function>"). See `find_own_close` for the
348        // same rule applied to `</parameter>`.
349        let close_at = find_own_close(after_name, "</function>", "<function=")?;
350        let inner = after_name[..close_at].trim();
351        let after_function = &after_name[close_at + "</function>".len()..];
352
353        let mut args = serde_json::Map::new();
354        let mut param_cursor = inner;
355        while !param_cursor.is_empty() {
356            param_cursor = param_cursor.trim_start();
357            if param_cursor.is_empty() {
358                break;
359            }
360            let after_param = param_cursor.strip_prefix("<parameter=")?;
361            let key_end = after_param.find('>')?;
362            let key = after_param[..key_end].trim().to_owned();
363            if key.is_empty() {
364                return None;
365            }
366            let rest = &after_param[key_end + 1..];
367            let close_at = find_own_close(rest, "</parameter>", "<parameter=")?;
368            let raw_value = rest[..close_at].trim();
369            args.insert(key, parse_param_value(raw_value));
370            param_cursor = &rest[close_at + "</parameter>".len()..];
371        }
372
373        calls.push(ToolCall {
374            id: mint_id(),
375            name: name.to_owned(),
376            arguments: Value::Object(args),
377        });
378
379        cursor = after_function.trim_start();
380    }
381
382    (!calls.is_empty()).then_some(calls)
383}
384
385/// Find this tag's own closing marker inside `rest`: the LAST occurrence of
386/// `close` before the next sibling `next_open` marker (or before the end of
387/// `rest`, if there is no next sibling).
388///
389/// A naive `rest.find(close)` truncates the value early whenever it happens
390/// to contain the literal closing-tag text — a real risk for a `content` or
391/// `code` parameter carrying anything that looks like markup. The tag's true
392/// close is always the one immediately before its next sibling opens (or the
393/// end of the block), never an earlier occurrence, so searching backward
394/// from that boundary finds it correctly even when the value embeds the
395/// marker text. This is not a complete fix — a value that also happens to
396/// contain the *next sibling's* open marker is still ambiguous, since this
397/// dialect has no escaping mechanism — but it is strictly more often correct
398/// than a forward search from the start.
399fn find_own_close(rest: &str, close: &str, next_open: &str) -> Option<usize> {
400    let boundary = rest.find(next_open).unwrap_or(rest.len());
401    rest[..boundary].rfind(close)
402}
403
404/// Best-effort coercion of a `<parameter>` body to a JSON value. Falls back
405/// to a string literal when the body is not valid JSON.
406///
407/// This is inherently lossy: the dialect gives no way to distinguish a
408/// parameter that is genuinely meant to be the *string* `"true"` or `"123"`
409/// from one meant to be the boolean or the number — both coerce to the typed
410/// value. There is no tool `input_schema` available here to disambiguate
411/// against (the parser has no access to the tool definitions that produced
412/// this call), so this is a best-effort guess, not a guarantee.
413fn parse_param_value(raw: &str) -> Value {
414    if raw.is_empty() {
415        return Value::String(String::new());
416    }
417    if let Ok(v) = serde_json::from_str::<Value>(raw) {
418        return v;
419    }
420    Value::String(raw.to_owned())
421}
422
423/// Largest `n` in `[0, marker.len())` such that the last `n` bytes of `buf`
424/// are a prefix of `marker`.  Used as the lookahead window for chunk-safe
425/// marker detection.
426fn partial_suffix_len(buf: &[u8], marker: &[u8]) -> usize {
427    if marker.len() < 2 {
428        return 0;
429    }
430    let max = std::cmp::min(buf.len(), marker.len() - 1);
431    for n in (1..=max).rev() {
432        if buf.ends_with(&marker[..n]) {
433            return n;
434        }
435    }
436    0
437}
438
439// =============================================================================
440// Tests
441// =============================================================================
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::normalize::NormalizationErrorKind;
447    use serde_json::json;
448
449    /// A parser wired with the built-in Qwen spec — the configuration every
450    /// pre-spec test in this module was written against.
451    fn qwen() -> DelimitedToolCallParser {
452        DelimitedToolCallParser::new(DialectSpec::qwen_xml())
453    }
454
455    fn collect(p: &mut DelimitedToolCallParser, chunks: &[&str]) -> ParserOutput {
456        let mut total = ParserOutput::default();
457        for c in chunks {
458            let o = p.push_text(c);
459            total.forward_text.push_str(&o.forward_text);
460            total.forward_reasoning.push_str(&o.forward_reasoning);
461            total.tool_calls.extend(o.tool_calls);
462            total.errors.extend(o.errors);
463        }
464        let f = p.finish();
465        total.forward_text.push_str(&f.forward_text);
466        total.forward_reasoning.push_str(&f.forward_reasoning);
467        total.tool_calls.extend(f.tool_calls);
468        total.errors.extend(f.errors);
469        total
470    }
471
472    /// The rescue, end to end through the parser: a body the JSON codec
473    /// rejects outright still becomes a tool call, with its arguments intact.
474    #[test]
475    fn a_fenced_tool_call_body_is_repaired_instead_of_discarded() {
476        let mut p = qwen();
477        let out = collect(
478            &mut p,
479            &[
480                "<tool_call>\n```json\n",
481                r#"{"name":"read_file","arguments":{"path":"a"}}"#,
482                "\n```\n</tool_call>",
483            ],
484        );
485        assert!(
486            out.errors.is_empty(),
487            "the turn should no longer be given up: {:?}",
488            out.errors
489        );
490        assert_eq!(out.tool_calls.len(), 1);
491        assert_eq!(out.tool_calls[0].name, "read_file");
492        assert_eq!(out.tool_calls[0].arguments, json!({"path": "a"}));
493    }
494
495    /// Fail-open: a body that cannot be repaired honestly still produces
496    /// exactly the error it always did, so nothing regresses.
497    #[test]
498    fn an_unrepairable_body_still_reports_the_original_error() {
499        let mut p = qwen();
500        let out = collect(&mut p, &["<tool_call>", "not json at all", "</tool_call>"]);
501        assert!(out.tool_calls.is_empty());
502        assert_eq!(out.errors.len(), 1);
503        assert!(matches!(
504            out.errors[0].kind,
505            NormalizationErrorKind::MalformedToolCallJson { .. }
506        ));
507    }
508
509    /// A call truncated mid-string is *not* rescued — closing the quote would
510    /// dispatch a call reading the wrong path. See `normalize::coerce`.
511    #[test]
512    fn a_call_truncated_mid_string_is_not_invented() {
513        let mut p = qwen();
514        let out = collect(
515            &mut p,
516            &[
517                "<tool_call>",
518                r#"{"name":"read_file","arguments":{"path":"/etc/ho"#,
519                "</tool_call>",
520            ],
521        );
522        assert!(
523            out.tool_calls.is_empty(),
524            "a truncated argument must never become a dispatched call"
525        );
526        assert_eq!(out.errors.len(), 1);
527    }
528
529    #[test]
530    fn passthrough_with_no_markup() {
531        let mut p = qwen();
532        let out = collect(&mut p, &["hello ", "world"]);
533        assert_eq!(out.forward_text, "hello world");
534        assert!(out.tool_calls.is_empty());
535        assert!(out.errors.is_empty());
536    }
537
538    #[test]
539    fn extracts_simple_tool_call_from_text() {
540        let mut p = qwen();
541        let out = collect(
542            &mut p,
543            &[r#"before<tool_call>{"name":"foo","arguments":{"x":1}}</tool_call>after"#],
544        );
545        assert_eq!(out.forward_text, "beforeafter");
546        assert_eq!(out.tool_calls.len(), 1);
547        assert_eq!(out.tool_calls[0].id, "call_qwen_0");
548        assert_eq!(out.tool_calls[0].name, "foo");
549        assert_eq!(out.tool_calls[0].arguments, json!({"x": 1}));
550        assert!(out.errors.is_empty());
551    }
552
553    #[test]
554    fn open_tag_straddles_chunk_boundary() {
555        let mut p = qwen();
556        let out = collect(
557            &mut p,
558            &[
559                "before<tool",
560                "_call>",
561                r#"{"name":"foo","arguments":{}}"#,
562                "</tool_call>",
563                "after",
564            ],
565        );
566        assert_eq!(out.forward_text, "beforeafter");
567        assert_eq!(out.tool_calls.len(), 1);
568        assert_eq!(out.tool_calls[0].name, "foo");
569    }
570
571    #[test]
572    fn close_tag_straddles_chunk_boundary() {
573        let mut p = qwen();
574        let out = collect(
575            &mut p,
576            &[
577                "<tool_call>",
578                r#"{"name":"foo","arguments":{}}</tool"#,
579                "_call>tail",
580            ],
581        );
582        assert_eq!(out.forward_text, "tail");
583        assert_eq!(out.tool_calls.len(), 1);
584        assert_eq!(out.tool_calls[0].name, "foo");
585    }
586
587    #[test]
588    fn one_byte_at_a_time_still_works() {
589        let mut p = qwen();
590        let s = r#"x<tool_call>{"name":"f","arguments":{"a":2}}</tool_call>y"#;
591        let chunks: Vec<String> = s.chars().map(|c| c.to_string()).collect();
592        let refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
593        let out = collect(&mut p, &refs);
594        assert_eq!(out.forward_text, "xy");
595        assert_eq!(out.tool_calls.len(), 1);
596        assert_eq!(out.tool_calls[0].arguments, json!({"a": 2}));
597    }
598
599    #[test]
600    fn tool_call_in_reasoning_channel_is_extracted() {
601        let mut p = qwen();
602        let chunk = r#"thinking <tool_call>{"name":"foo","arguments":{}}</tool_call> done"#;
603        let out = p.push_reasoning(chunk);
604        let f = p.finish();
605        assert_eq!(out.forward_reasoning, "thinking  done");
606        assert_eq!(out.tool_calls.len(), 1);
607        assert_eq!(out.tool_calls[0].name, "foo");
608        assert!(f.is_empty());
609    }
610
611    #[test]
612    fn malformed_json_emits_error() {
613        let mut p = qwen();
614        let out = collect(&mut p, &["<tool_call>not json</tool_call>"]);
615        assert!(out.tool_calls.is_empty());
616        assert_eq!(out.errors.len(), 1);
617        assert!(matches!(
618            out.errors[0].kind,
619            crate::normalize::error::NormalizationErrorKind::MalformedToolCallJson { .. }
620        ));
621    }
622
623    #[test]
624    fn missing_name_field_is_malformed() {
625        let mut p = qwen();
626        let out = collect(&mut p, &[r#"<tool_call>{"arguments":{}}</tool_call>"#]);
627        assert!(out.tool_calls.is_empty());
628        assert_eq!(out.errors.len(), 1);
629    }
630
631    #[test]
632    fn missing_arguments_defaults_to_empty_object() {
633        let mut p = qwen();
634        let out = collect(&mut p, &[r#"<tool_call>{"name":"foo"}</tool_call>"#]);
635        assert_eq!(out.tool_calls.len(), 1);
636        assert_eq!(out.tool_calls[0].arguments, json!({}));
637        assert!(out.errors.is_empty());
638    }
639
640    #[test]
641    fn unclosed_tag_at_end_yields_error() {
642        let mut p = qwen();
643        let _ = p.push_text(r#"hello <tool_call>{"name":"foo""#);
644        let f = p.finish();
645        assert_eq!(f.errors.len(), 1);
646        assert!(matches!(
647            f.errors[0].kind,
648            crate::normalize::error::NormalizationErrorKind::UnclosedToolCallTag { .. }
649        ));
650        assert!(f.tool_calls.is_empty());
651    }
652
653    #[test]
654    fn multiple_tool_calls_get_distinct_ids() {
655        let mut p = qwen();
656        let out = collect(
657            &mut p,
658            &[
659                r#"<tool_call>{"name":"a","arguments":{}}</tool_call>"#,
660                r#"<tool_call>{"name":"b","arguments":{}}</tool_call>"#,
661            ],
662        );
663        assert_eq!(out.tool_calls.len(), 2);
664        assert_eq!(out.tool_calls[0].id, "call_qwen_0");
665        assert_eq!(out.tool_calls[1].id, "call_qwen_1");
666    }
667
668    #[test]
669    fn partial_marker_lookalike_is_eventually_flushed() {
670        // "<tool" looks like an open-marker prefix but is actually just
671        // text — finish() should flush it.
672        let mut p = qwen();
673        let mid = p.push_text("<tool");
674        assert_eq!(mid.forward_text, "");
675        let f = p.finish();
676        assert_eq!(f.forward_text, "<tool");
677    }
678
679    #[test]
680    fn partial_suffix_len_finds_longest_overlap() {
681        assert_eq!(partial_suffix_len(b"abc<tool", b"<tool_call>"), 5);
682        assert_eq!(partial_suffix_len(b"abc<", b"<tool_call>"), 1);
683        assert_eq!(partial_suffix_len(b"abc", b"<tool_call>"), 0);
684        // A full-marker suffix is *not* a partial — only proper prefixes
685        // (1..len) count.  A full match is `find`'s job upstream.
686        assert_eq!(partial_suffix_len(b"<tool_call>", b"<tool_call>"), 0);
687        // The longest proper prefix that the buffer ends with is "<".
688        assert_eq!(partial_suffix_len(b"</tool_call><", b"<tool_call>"), 1);
689    }
690
691    // -------------------------------------------------------------------
692    // Inner-XML (`<function=…><parameter=…>…</parameter></function>`) —
693    // the Qwen3 + `--jinja` tool-call body shape.
694    // -------------------------------------------------------------------
695
696    #[test]
697    fn extracts_function_xml_body_with_string_param() {
698        let mut p = qwen();
699        let body = "<tool_call>\n<function=grep>\n<parameter=regex>\ngglib\\s+q\n</parameter>\n</function>\n</tool_call>";
700        let out = collect(&mut p, &[body]);
701        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
702        assert_eq!(out.tool_calls.len(), 1);
703        assert_eq!(out.tool_calls[0].name, "grep");
704        assert_eq!(
705            out.tool_calls[0].arguments,
706            json!({ "regex": "gglib\\s+q" })
707        );
708    }
709
710    #[test]
711    fn function_xml_body_with_multiple_params() {
712        let mut p = qwen();
713        let body = concat!(
714            "<tool_call><function=read_file>",
715            "<parameter=path>src/main.rs</parameter>",
716            "<parameter=start_line>1</parameter>",
717            "<parameter=end_line>20</parameter>",
718            "</function></tool_call>",
719        );
720        let out = collect(&mut p, &[body]);
721        assert!(out.errors.is_empty());
722        assert_eq!(out.tool_calls.len(), 1);
723        assert_eq!(out.tool_calls[0].name, "read_file");
724        assert_eq!(
725            out.tool_calls[0].arguments,
726            json!({ "path": "src/main.rs", "start_line": 1, "end_line": 20 })
727        );
728    }
729
730    #[test]
731    fn function_xml_body_with_json_object_param() {
732        let mut p = qwen();
733        let body = r#"<tool_call><function=run><parameter=opts>{"a":1,"b":[2,3]}</parameter></function></tool_call>"#;
734        let out = collect(&mut p, &[body]);
735        assert!(out.errors.is_empty());
736        assert_eq!(out.tool_calls.len(), 1);
737        assert_eq!(
738            out.tool_calls[0].arguments,
739            json!({ "opts": { "a": 1, "b": [2, 3] } })
740        );
741    }
742
743    #[test]
744    fn function_xml_body_streamed_byte_by_byte() {
745        let mut p = qwen();
746        let s = "<tool_call><function=grep><parameter=regex>x</parameter></function></tool_call>";
747        let chunks: Vec<String> = s.chars().map(|c| c.to_string()).collect();
748        let refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
749        let out = collect(&mut p, &refs);
750        assert!(out.errors.is_empty());
751        assert_eq!(out.tool_calls.len(), 1);
752        assert_eq!(out.tool_calls[0].name, "grep");
753        assert_eq!(out.tool_calls[0].arguments, json!({ "regex": "x" }));
754    }
755
756    #[test]
757    fn function_xml_body_without_parameters_yields_empty_args() {
758        let mut p = qwen();
759        let body = "<tool_call><function=ping></function></tool_call>";
760        let out = collect(&mut p, &[body]);
761        assert!(out.errors.is_empty());
762        assert_eq!(out.tool_calls.len(), 1);
763        assert_eq!(out.tool_calls[0].name, "ping");
764        assert_eq!(out.tool_calls[0].arguments, json!({}));
765    }
766
767    /// Multiple `<function=...>` blocks back-to-back inside one `<tool_call>`
768    /// wrapper (Hermes-style multi-call) must all be extracted, in order,
769    /// with distinct synthesised IDs.
770    #[test]
771    fn multiple_function_blocks_in_one_wrapper_are_all_extracted() {
772        let mut p = qwen();
773        let body = concat!(
774            "<tool_call>",
775            "<function=get_weather><parameter=city>Paris</parameter></function>",
776            "<function=get_time><parameter=zone>UTC</parameter></function>",
777            "</tool_call>",
778        );
779        let out = collect(&mut p, &[body]);
780        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
781        assert_eq!(out.tool_calls.len(), 2);
782        assert_eq!(out.tool_calls[0].name, "get_weather");
783        assert_eq!(out.tool_calls[0].arguments, json!({"city": "Paris"}));
784        assert_eq!(out.tool_calls[1].name, "get_time");
785        assert_eq!(out.tool_calls[1].arguments, json!({"zone": "UTC"}));
786        assert_ne!(
787            out.tool_calls[0].id, out.tool_calls[1].id,
788            "each call in the block needs its own ID"
789        );
790    }
791
792    /// A value that happens to contain the literal text `</parameter>` must
793    /// not truncate the value early — the true close is the last occurrence
794    /// before the next sibling tag, not the first occurrence anywhere. This
795    /// is the naive-`find` bug: the old implementation would have stopped at
796    /// "Use ", left `to close a param` dangling as unparsed cursor bytes, and
797    /// failed the whole block.
798    #[test]
799    fn a_parameter_value_containing_the_literal_close_marker_does_not_truncate() {
800        let mut p = qwen();
801        let body = concat!(
802            "<tool_call><function=write_doc>",
803            "<parameter=text>Use </parameter> to close a param</parameter>",
804            "<parameter=lang>en</parameter>",
805            "</function></tool_call>",
806        );
807        let out = collect(&mut p, &[body]);
808        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
809        assert_eq!(out.tool_calls.len(), 1);
810        assert_eq!(
811            out.tool_calls[0].arguments,
812            json!({"text": "Use </parameter> to close a param", "lang": "en"})
813        );
814    }
815
816    /// Same rule at the `</function>` boundary: a parameter value containing
817    /// the literal text `</function>` must not truncate the function body
818    /// early when another sibling function follows.
819    #[test]
820    fn a_parameter_value_containing_the_literal_function_close_does_not_truncate() {
821        let mut p = qwen();
822        let body = concat!(
823            "<tool_call>",
824            "<function=write_doc><parameter=text>end with </function> tag</parameter></function>",
825            "<function=ping></function>",
826            "</tool_call>",
827        );
828        let out = collect(&mut p, &[body]);
829        assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
830        assert_eq!(out.tool_calls.len(), 2);
831        assert_eq!(
832            out.tool_calls[0].arguments,
833            json!({"text": "end with </function> tag"})
834        );
835        assert_eq!(out.tool_calls[1].name, "ping");
836    }
837
838    /// A body that opens the XML dialect (`<function=`) but is structurally
839    /// broken must be reported with the XML-specific error kind, not the
840    /// generic JSON one — the two dialects fail for unrelated reasons.
841    #[test]
842    fn malformed_function_xml_gets_its_own_error_kind() {
843        let mut p = qwen();
844        let out = collect(
845            &mut p,
846            &["<tool_call><function=oops(no closing angle</tool_call>"],
847        );
848        assert!(out.tool_calls.is_empty());
849        assert_eq!(out.errors.len(), 1);
850        assert!(matches!(
851            out.errors[0].kind,
852            crate::normalize::error::NormalizationErrorKind::MalformedFunctionXml { .. }
853        ));
854    }
855
856    /// Pinning the type-coercion limitation documented on
857    /// `parse_param_value`: a parameter meant to be the literal string
858    /// `"true"` or `"123"` is indistinguishable from one meant to be the
859    /// boolean or the number, since the dialect carries no schema. This is
860    /// not a bug to fix here — the parser has no `input_schema` to consult —
861    /// but the behaviour must not change silently.
862    #[test]
863    fn parameter_values_that_look_like_json_literals_are_coerced_not_kept_as_strings() {
864        let mut p = qwen();
865        let body = concat!(
866            "<tool_call><function=configure>",
867            "<parameter=enabled>true</parameter>",
868            "<parameter=count>123</parameter>",
869            "<parameter=label>plain text</parameter>",
870            "</function></tool_call>",
871        );
872        let out = collect(&mut p, &[body]);
873        assert!(out.errors.is_empty());
874        assert_eq!(
875            out.tool_calls[0].arguments,
876            json!({"enabled": true, "count": 123, "label": "plain text"}),
877            "bool- and number-shaped strings coerce; only non-JSON-shaped text stays a string"
878        );
879    }
880
881    // -------------------------------------------------------------------
882    // Spec-driven behaviour: the same machine must work for any markers
883    // and honor the spec's codec list and ID prefix.
884    // -------------------------------------------------------------------
885
886    /// A synthetic template-derived spec: custom multibyte markers, JSON
887    /// body only, `call_dialect_` prefix.
888    fn derived() -> DialectSpec {
889        DialectSpec {
890            id: crate::domain::dialect::DERIVED_DIALECT_ID.to_owned(),
891            tool_open: "«TC»".to_owned(),
892            tool_close: "«/TC»".to_owned(),
893            body_codecs: vec![BodyCodec::Json],
894            emission: crate::domain::dialect::EmissionProfile::default(),
895            id_prefix: crate::domain::dialect::DERIVED_ID_PREFIX.to_owned(),
896        }
897    }
898
899    #[test]
900    fn custom_marker_spec_extracts_calls_with_its_own_id_prefix() {
901        let mut p = DelimitedToolCallParser::new(derived());
902        let out = collect(
903            &mut p,
904            &[r#"before«TC»{"name":"foo","arguments":{"x":1}}«/TC»after"#],
905        );
906        assert_eq!(out.forward_text, "beforeafter");
907        assert_eq!(out.tool_calls.len(), 1);
908        assert_eq!(out.tool_calls[0].id, "call_dialect_0");
909        assert_eq!(out.tool_calls[0].name, "foo");
910        assert!(out.errors.is_empty());
911    }
912
913    #[test]
914    fn custom_marker_spec_survives_byte_at_a_time_chunking() {
915        let mut p = DelimitedToolCallParser::new(derived());
916        let s = r#"x«TC»{"name":"f","arguments":{"a":2}}«/TC»y"#;
917        // Char-by-char, not byte-by-byte: push_text takes &str, and the
918        // guillemet markers are multibyte.  Chunks of one char still split
919        // every marker across pushes, which is the property under test.
920        let chunks: Vec<String> = s.chars().map(|c| c.to_string()).collect();
921        let refs: Vec<&str> = chunks.iter().map(String::as_str).collect();
922        let out = collect(&mut p, &refs);
923        assert_eq!(out.forward_text, "xy");
924        assert_eq!(out.tool_calls.len(), 1);
925        assert_eq!(out.tool_calls[0].arguments, json!({"a": 2}));
926    }
927
928    #[test]
929    fn fenced_spec_with_identical_open_and_close_markers_works() {
930        let spec = DialectSpec {
931            tool_open: "@@TOOL@@".to_owned(),
932            tool_close: "@@TOOL@@".to_owned(),
933            ..derived()
934        };
935        let mut p = DelimitedToolCallParser::new(spec);
936        let out = collect(
937            &mut p,
938            &[r#"a@@TOOL@@{"name":"f","arguments":{}}@@TOOL@@b"#],
939        );
940        assert_eq!(out.forward_text, "ab");
941        assert_eq!(out.tool_calls.len(), 1);
942        assert!(out.errors.is_empty());
943    }
944
945    /// A JSON-only spec must not decode inner-XML bodies, and its failure
946    /// must report the JSON error kind — the XML kind belongs to specs
947    /// that actually enable the codec.
948    #[test]
949    fn json_only_spec_rejects_function_xml_with_the_json_error_kind() {
950        let spec = DialectSpec {
951            tool_open: "<tool_call>".to_owned(),
952            tool_close: "</tool_call>".to_owned(),
953            ..derived()
954        };
955        let mut p = DelimitedToolCallParser::new(spec);
956        let out = collect(
957            &mut p,
958            &["<tool_call><function=grep><parameter=regex>x</parameter></function></tool_call>"],
959        );
960        assert!(out.tool_calls.is_empty());
961        assert_eq!(out.errors.len(), 1);
962        assert!(matches!(
963            out.errors[0].kind,
964            crate::normalize::error::NormalizationErrorKind::MalformedToolCallJson { .. }
965        ));
966    }
967
968    /// The spec ↔ parser round trip: whatever `render_call` emits, a parser
969    /// built from the same spec must extract — for the builtin and for a
970    /// derived spec alike.  This is the property the grammar test in
971    /// `request_pipeline::constrain` builds on.
972    #[test]
973    fn render_call_output_round_trips_through_a_parser_of_the_same_spec() {
974        for spec in [DialectSpec::qwen_xml(), derived()] {
975            let emission = spec.render_call("read_file", &json!({"path": "a.rs"}));
976            let mut p = DelimitedToolCallParser::new(spec);
977            let out = collect(&mut p, &[emission.as_str()]);
978            assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
979            assert_eq!(out.tool_calls.len(), 1);
980            assert_eq!(out.tool_calls[0].name, "read_file");
981            assert_eq!(out.tool_calls[0].arguments, json!({"path": "a.rs"}));
982            assert_eq!(out.forward_text, "");
983        }
984    }
985}