Skip to main content

cherenkov/server/
tool_call.rs

1//! Qwen tool-call output parsing.
2//!
3//! The model emits each call as XML-style markup: an outer tool marker, a
4//! function element carrying the name, and one parameter element per
5//! argument. Argument text is untyped; a terminal contract built from the
6//! request's tool schemas normalizes it into JSON values. A type mismatch
7//! stays a structured call for consumer validation; only a structure or
8//! identity failure returns the whole marker region to ordinary content.
9
10use serde_json::{Map, Value, from_str};
11use std::sync::Arc;
12
13#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
14pub(crate) enum FallbackReason {
15    #[default]
16    None,
17    MalformedStructure,
18    DuplicateParameter,
19    InvalidToolName,
20    UndeclaredTool,
21    TrailingContent,
22    /// Tolerant recovery discarded a trailing suffix that followed an
23    /// otherwise complete call. A structured response was still produced.
24    TruncatedTail,
25}
26
27/// Upper bound on model-emitted function names. Request-side names use a
28/// shorter grammar on the wire.
29const MAX_TOOL_NAME_LENGTH: usize = 128;
30
31// JSON Schema type bits.
32const T_NULL: u8 = 1 << 0;
33const T_BOOLEAN: u8 = 1 << 1;
34const T_INTEGER: u8 = 1 << 2;
35const T_NUMBER: u8 = 1 << 3;
36const T_STRING: u8 = 1 << 4;
37const T_OBJECT: u8 = 1 << 5;
38const T_ARRAY: u8 = 1 << 6;
39
40// Marker literals are assembled at compile time so the plain tag strings do
41// not appear verbatim in source, diagnostics, or tool traffic.
42const TOOL_OPEN: &str = concat!("<", "tool_call>", "");
43const TOOL_CLOSE: &str = concat!("</", "tool_call", ">");
44const FUNCTION_OPEN: &str = concat!("<", "function=", "");
45const FUNCTION_CLOSE: &str = concat!("</", "function", ">");
46const PARAM_OPEN: &str = concat!("<", "parameter=", "");
47const PARAM_CLOSE: &str = concat!("</", "parameter", ">");
48
49fn is_ws(c: char) -> bool {
50    c == ' ' || c == '\t' || c == '\r' || c == '\n'
51}
52
53fn trim_ws(text: &str) -> &str {
54    text.trim_matches(is_ws)
55}
56
57fn valid_function_name(name: &str) -> bool {
58    !name.is_empty()
59        && name.len() <= MAX_TOOL_NAME_LENGTH
60        && name
61            .bytes()
62            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
63}
64
65fn schema_bit(name: &str) -> Option<u8> {
66    Some(match name {
67        "null" => T_NULL,
68        "boolean" => T_BOOLEAN,
69        "integer" => T_INTEGER,
70        "number" => T_NUMBER,
71        "string" => T_STRING,
72        "object" => T_OBJECT,
73        "array" => T_ARRAY,
74        _ => return None,
75    })
76}
77
78/// Record the supported top-level types from a `type` keyword.
79fn compile_direct_types(t: &Value) -> Option<u8> {
80    if let Some(name) = t.as_str() {
81        return schema_bit(name);
82    }
83
84    let members = t.as_array()?;
85
86    if members.is_empty() {
87        return None;
88    }
89
90    let mut bits = 0;
91
92    for member in members {
93        bits |= schema_bit(member.as_str()?)?;
94    }
95
96    (bits != 0).then_some(bits)
97}
98
99/// Compile the type set from a property schema's `type`, `anyOf`, or `oneOf`.
100fn compile_schema_types(schema: &Value) -> Option<u8> {
101    let object = schema.as_object()?;
102
103    if let Some(ty) = object.get("type") {
104        return compile_direct_types(ty);
105    }
106
107    let has_any_of = object.get("anyOf").is_some();
108    let has_one_of = object.get("oneOf").is_some();
109
110    if has_any_of == has_one_of {
111        return None;
112    }
113
114    let alternatives = object
115        .get(if has_any_of { "anyOf" } else { "oneOf" })?
116        .as_array()?;
117
118    if alternatives.is_empty() {
119        return None;
120    }
121
122    let mut combined = 0;
123
124    for alternative in alternatives {
125        combined |= compile_schema_types(alternative)?;
126    }
127
128    (combined != 0).then_some(combined)
129}
130
131#[derive(Debug, Clone)]
132pub(crate) struct Parameter {
133    name: String,
134    /// Declared types; zero keeps the legacy text policy.
135    types: u8,
136}
137
138#[derive(Debug, Clone)]
139pub(crate) struct Tool {
140    name: String,
141    parameters: Vec<Parameter>,
142    unambiguous: bool,
143}
144
145fn same_tool(a: &Tool, b: &Tool) -> bool {
146    a.parameters.len() == b.parameters.len()
147        && a.parameters
148            .iter()
149            .zip(&b.parameters)
150            .all(|(x, y)| x.name == y.name && x.types == y.types)
151}
152
153/// Parameter names and declared types gathered from the request's tool
154/// schemas. Guidance only: schemas never validate the whole call.
155#[derive(Debug, Clone, Default)]
156pub(crate) struct ToolCallOutputContract {
157    tools: Vec<Tool>,
158    /// Model-emitted names must match a declared tool. Set whenever tool
159    /// definitions are present.
160    pub(crate) enforce_declared_names: bool,
161}
162
163impl ToolCallOutputContract {
164    /// Build the terminal contract from shaped tool definitions. A name
165    /// declared with conflicting schemas loses type guidance and falls back
166    /// to legacy text.
167    pub(crate) fn from_tools(tools: &[Value]) -> Arc<Self> {
168        let mut contract = ToolCallOutputContract {
169            enforce_declared_names: true,
170            tools: Vec::new(),
171        };
172
173        for definition in tools {
174            let Some(function) = definition.get("function").and_then(Value::as_object) else {
175                continue;
176            };
177            let Some(name) = function.get("name").and_then(Value::as_str) else {
178                continue;
179            };
180
181            let mut tool = Tool {
182                name: name.to_owned(),
183                parameters: Vec::new(),
184                unambiguous: true,
185            };
186
187            let parameters = function
188                .get("parameters")
189                .and_then(Value::as_object)
190                .and_then(|schema| schema.get("properties"))
191                .and_then(Value::as_object);
192
193            if let Some(parameters) = parameters {
194                for (param_name, property) in parameters {
195                    tool.parameters.push(Parameter {
196                        name: param_name.to_owned(),
197                        types: compile_schema_types(property).unwrap_or(0),
198                    });
199                }
200            }
201
202            if let Some(existing) = contract.tools.iter_mut().find(|t| t.name == tool.name) {
203                if existing.unambiguous && !same_tool(existing, &tool) {
204                    existing.parameters.clear();
205
206                    existing.unambiguous = false;
207                }
208            } else {
209                contract.tools.push(tool);
210            }
211        }
212
213        Arc::new(contract)
214    }
215
216    /// Structural guidance for a call. `None` when the name is unknown or was
217    /// declared with conflicting schemas.
218    fn find_tool(&self, name: &str) -> Option<&Tool> {
219        self.tools
220            .iter()
221            .find(|tool| tool.name == name && tool.unambiguous)
222    }
223
224    fn is_declared(&self, name: &str) -> bool {
225        self.tools.iter().any(|tool| tool.name == name)
226    }
227}
228
229// -- Value normalization -----------------------------------------------------
230
231/// `1e5` is `100000`; integer classification must respect the exponent.
232fn json_number_is_integer(number: &str) -> bool {
233    let bytes = number.as_bytes();
234    let mut pos = if bytes.first() == Some(&b'-') { 1 } else { 0 };
235
236    if pos >= bytes.len() {
237        return false;
238    }
239
240    let integer_begin = pos;
241
242    while pos < bytes.len() && bytes[pos].is_ascii_digit() {
243        pos += 1;
244    }
245
246    let integer_end = pos;
247    let (mut fraction_begin, mut fraction_end) = (pos, pos);
248
249    if pos < bytes.len() && bytes[pos] == b'.' {
250        fraction_begin = pos + 1;
251        pos += 1;
252
253        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
254            pos += 1;
255        }
256
257        fraction_end = pos;
258    }
259
260    let (mut exponent_negative, mut exponent_value) = (false, 0usize);
261
262    if pos < bytes.len() && (bytes[pos] == b'e' || bytes[pos] == b'E') {
263        pos += 1;
264
265        if pos < bytes.len() && (bytes[pos] == b'+' || bytes[pos] == b'-') {
266            exponent_negative = bytes[pos] == b'-';
267            pos += 1;
268        }
269
270        let cap = bytes.len();
271
272        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
273            let digit = (bytes[pos] - b'0') as usize;
274
275            if exponent_value != cap {
276                if exponent_value > cap / 10 || (exponent_value == cap / 10 && digit > cap % 10) {
277                    exponent_value = cap;
278                } else {
279                    exponent_value = exponent_value * 10 + digit;
280                }
281            }
282
283            pos += 1;
284        }
285    }
286
287    if integer_begin == integer_end || pos != bytes.len() {
288        return false;
289    }
290
291    let mut coefficient_is_zero = true;
292    let mut trailing_zeros = 0;
293
294    for i in integer_begin..integer_end {
295        if bytes[i] == b'0' {
296            trailing_zeros += 1;
297        } else {
298            coefficient_is_zero = false;
299            trailing_zeros = 0;
300        }
301    }
302
303    for i in fraction_begin..fraction_end {
304        if bytes[i] == b'0' {
305            trailing_zeros += 1;
306        } else {
307            coefficient_is_zero = false;
308            trailing_zeros = 0;
309        }
310    }
311
312    if coefficient_is_zero {
313        return true;
314    }
315
316    let fraction_digits = fraction_end - fraction_begin;
317
318    if !exponent_negative {
319        return if exponent_value >= fraction_digits {
320            true
321        } else {
322            fraction_digits - exponent_value <= trailing_zeros
323        };
324    }
325
326    if exponent_value > trailing_zeros {
327        return false;
328    }
329
330    fraction_digits <= trailing_zeros - exponent_value
331}
332
333#[derive(Copy, Clone, PartialEq, Eq)]
334enum JsonValueKind {
335    Null,
336    Boolean,
337    Integer,
338    Number,
339    String,
340    Object,
341    Array,
342}
343
344fn classify_json_value(value: &str, kind: &mut JsonValueKind) -> bool {
345    if value.is_empty() || from_str::<Value>(value).is_err() {
346        return false;
347    }
348
349    match value.as_bytes()[0] {
350        b'n' => *kind = JsonValueKind::Null,
351        b't' | b'f' => *kind = JsonValueKind::Boolean,
352        b'"' => *kind = JsonValueKind::String,
353        b'{' => *kind = JsonValueKind::Object,
354        b'[' => *kind = JsonValueKind::Array,
355        b if b == b'-' || b.is_ascii_digit() => {
356            *kind = if json_number_is_integer(value) {
357                JsonValueKind::Integer
358            } else {
359                JsonValueKind::Number
360            };
361        }
362        _ => return false,
363    }
364
365    true
366}
367
368fn admits_value(types: u8, kind: JsonValueKind) -> bool {
369    match kind {
370        JsonValueKind::Null => types & T_NULL != 0,
371        JsonValueKind::Boolean => types & T_BOOLEAN != 0,
372        JsonValueKind::Integer => types & (T_INTEGER | T_NUMBER) != 0,
373        JsonValueKind::Number => types & T_NUMBER != 0,
374        JsonValueKind::String => types & T_STRING != 0,
375        JsonValueKind::Object => types & T_OBJECT != 0,
376        JsonValueKind::Array => types & T_ARRAY != 0,
377    }
378}
379
380fn encode_json_string(value: &str) -> String {
381    Value::String(value.to_owned()).to_string()
382}
383
384/// Parameter values are framed by newlines; a string-typed parameter keeps
385/// them, other types trim.
386fn strip_parameter_framing(text: &str) -> &str {
387    let start = if text.starts_with("\r\n") {
388        2
389    } else if text.starts_with('\n') {
390        1
391    } else {
392        0
393    };
394    let mut end = text.len();
395
396    if end >= start + 2 && text.ends_with("\r\n") {
397        end -= 2;
398    } else if end > start && text.ends_with('\n') {
399        end -= 1;
400    }
401
402    &text[start..end]
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406enum Disposition {
407    Emitted,
408    Omitted,
409    SchemaMismatch,
410}
411
412fn normalize_declared_parameter(encoded_value: &str, types: u8) -> (Disposition, String) {
413    let framed = strip_parameter_framing(encoded_value);
414
415    if types & T_STRING != 0 {
416        return (Disposition::Emitted, encode_json_string(framed));
417    }
418
419    let value = trim_ws(framed);
420
421    if value.is_empty() {
422        return (Disposition::Omitted, String::new());
423    }
424
425    let mut kind = JsonValueKind::String;
426
427    if classify_json_value(value, &mut kind) {
428        let disposition = if admits_value(types, kind) {
429            Disposition::Emitted
430        } else {
431            Disposition::SchemaMismatch
432        };
433
434        return (disposition, value.to_owned());
435    }
436
437    if types & T_BOOLEAN != 0 {
438        if value.eq_ignore_ascii_case("true") {
439            return (Disposition::Emitted, "true".to_owned());
440        }
441
442        if value.eq_ignore_ascii_case("false") {
443            return (Disposition::Emitted, "false".to_owned());
444        }
445    }
446
447    (Disposition::SchemaMismatch, encode_json_string(framed))
448}
449
450fn normalize_parameter(
451    encoded_value: &str,
452    parameter: Option<&Parameter>,
453) -> (Disposition, String) {
454    if let Some(parameter) = parameter.filter(|p| p.types != 0) {
455        return normalize_declared_parameter(encoded_value, parameter.types);
456    }
457
458    let value = trim_ws(encoded_value);
459
460    if from_str::<Value>(value).is_ok() {
461        (Disposition::Emitted, value.to_owned())
462    } else {
463        (Disposition::Emitted, encode_json_string(value))
464    }
465}
466
467// -- Structural parsing ------------------------------------------------------
468
469#[derive(Debug)]
470struct RawParameter {
471    name: String,
472    value: String,
473}
474
475#[derive(Debug)]
476struct RawToolCall {
477    name: String,
478    parameters: Vec<RawParameter>,
479}
480
481pub(crate) struct GeneratedToolCall {
482    pub name: String,
483    /// Decoded argument object, in emission order. The wire and session
484    /// history use `arguments_json` / re-serialization; the decoded form is
485    /// retained for consumers and qualification.
486    #[allow(dead_code)]
487    pub arguments: Map<String, Value>,
488    /// Canonical serialized form for the wire and for fallback assembly.
489    pub arguments_json: String,
490}
491
492/// Server-minted wire shape for one decoded model tool call.
493pub(crate) struct WireToolCall {
494    pub id: String,
495    pub name: String,
496    pub arguments: String,
497}
498
499#[derive(Debug, Default, Clone, Copy)]
500pub(crate) struct ToolCallParseDiagnostics {
501    /// Whether the terminal marker was seen; informational for qualification.
502    #[allow(dead_code)]
503    pub marker_seen: bool,
504    pub structured_call_count: u32,
505    pub empty_arguments_omitted: u32,
506    pub schema_mismatch_arguments: u32,
507    pub fallback_reason: FallbackReason,
508}
509
510pub(crate) struct ParsedToolCallOutput {
511    pub is_tool_call_response: bool,
512    /// Pre-marker content, or the whole input on fallback. The streaming
513    /// decoder holds the region from the marker on, so it only reconstructs
514    /// held bytes when a marker is present.
515    #[allow(dead_code)]
516    pub content: String,
517    pub tool_calls: Vec<GeneratedToolCall>,
518    pub diagnostics: ToolCallParseDiagnostics,
519}
520
521fn fallback(text: &str, diagnostics: ToolCallParseDiagnostics) -> ParsedToolCallOutput {
522    ParsedToolCallOutput {
523        is_tool_call_response: false,
524        content: text.to_owned(),
525        tool_calls: Vec::new(),
526        diagnostics,
527    }
528}
529
530fn normalize_raw_tool_call(
531    raw: &RawToolCall,
532    contract: &ToolCallOutputContract,
533    diagnostics: &mut ToolCallParseDiagnostics,
534) -> GeneratedToolCall {
535    let tool = contract.find_tool(&raw.name);
536    let mut arguments = Map::new();
537    let mut arguments_json = String::from("{");
538    let mut first = true;
539
540    for parameter in &raw.parameters {
541        let declared = tool
542            .map(|tool| tool.parameters.iter().find(|p| p.name == parameter.name))
543            .flatten();
544        let mut normalized = normalize_parameter(&parameter.value, declared);
545
546        // An undeclared parameter name on a declared tool stays structured,
547        // flagged as a schema mismatch for consumer validation.
548        if tool.is_some() && declared.is_none() {
549            normalized.0 = Disposition::SchemaMismatch;
550        }
551
552        if normalized.0 == Disposition::Omitted {
553            diagnostics.empty_arguments_omitted += 1;
554
555            continue;
556        }
557
558        if normalized.0 == Disposition::SchemaMismatch {
559            diagnostics.schema_mismatch_arguments += 1;
560        }
561
562        if !first {
563            arguments_json.push(',');
564        }
565
566        first = false;
567        let value = from_str::<Value>(&normalized.1).unwrap_or(Value::Null);
568
569        arguments_json.push_str(&encode_json_string(&parameter.name));
570        arguments_json.push(':');
571        arguments_json.push_str(&value.to_string());
572        arguments.insert(parameter.name.clone(), value);
573    }
574
575    arguments_json.push('}');
576
577    GeneratedToolCall {
578        name: raw.name.clone(),
579        arguments,
580        arguments_json,
581    }
582}
583
584struct RegionParser<'a> {
585    text: &'a str,
586    contract: &'a ToolCallOutputContract,
587    tolerant: bool,
588}
589
590impl<'a> RegionParser<'a> {
591    fn parse(&self, calls: &mut Vec<RawToolCall>) -> FallbackReason {
592        let mut pos = 0;
593
594        loop {
595            skip_ws(self.text, &mut pos);
596
597            if pos == self.text.len() {
598                return if calls.is_empty() {
599                    FallbackReason::MalformedStructure
600                } else {
601                    FallbackReason::None
602                };
603            }
604
605            if !starts_at(self.text, pos, TOOL_OPEN) {
606                // In tolerant mode a trailing suffix after one or more complete
607                // calls is discarded rather than failing the whole output.
608                if self.tolerant && !calls.is_empty() {
609                    return FallbackReason::TruncatedTail;
610                }
611
612                return if calls.is_empty() {
613                    FallbackReason::MalformedStructure
614                } else {
615                    FallbackReason::TrailingContent
616                };
617            }
618
619            let mut call = RawToolCall {
620                name: String::new(),
621                parameters: Vec::new(),
622            };
623            let failure = self.parse_tool_call(&mut pos, &mut call);
624
625            if failure == FallbackReason::None {
626                calls.push(call);
627
628                continue;
629            }
630
631            // Tolerant mode only: once at least one complete call has been
632            // recovered, a trailing malformed suffix or a malformed second call
633            // is discarded rather than failing the whole output.
634            if self.tolerant && !calls.is_empty() {
635                return FallbackReason::TruncatedTail;
636            }
637
638            // Tolerant mode only: recover a single truncated final call. Here
639            // the call's name and every parsed parameter are complete, but the
640            // closing tag was cut off or trailing content follows; keep the
641            // recovered call.
642            if failure == FallbackReason::TruncatedTail && calls.is_empty() {
643                calls.push(call);
644
645                return FallbackReason::TruncatedTail;
646            }
647
648            return failure;
649        }
650    }
651
652    fn consume(&self, pos: &mut usize, token: &str) -> bool {
653        if !starts_at(self.text, *pos, token) {
654            return false;
655        }
656
657        *pos += token.len();
658
659        true
660    }
661
662    /// True when nothing but whitespace remains after `pos`.
663    fn at_region_end(&self, pos: usize) -> bool {
664        let mut at = pos;
665
666        while at < self.text.len() && is_ws(self.text.as_bytes()[at] as char) {
667            at += 1;
668        }
669
670        at == self.text.len()
671    }
672
673    fn parse_tool_call(&self, pos: &mut usize, call: &mut RawToolCall) -> FallbackReason {
674        if !self.consume(pos, TOOL_OPEN) {
675            return FallbackReason::MalformedStructure;
676        }
677
678        skip_ws(self.text, pos);
679
680        let failure = self.parse_function(pos, call);
681
682        if failure != FallbackReason::None {
683            return failure;
684        }
685
686        skip_ws(self.text, pos);
687
688        if self.consume(pos, TOOL_CLOSE) {
689            return FallbackReason::None;
690        }
691
692        // In tolerant mode a complete call may be followed by explanatory text,
693        // or the model may have stopped at its budget before the closing tag.
694        // The strict parser treats a missing close tag as a structural failure.
695        if self.tolerant {
696            FallbackReason::TruncatedTail
697        } else {
698            FallbackReason::MalformedStructure
699        }
700    }
701
702    fn parse_function(&self, pos: &mut usize, call: &mut RawToolCall) -> FallbackReason {
703        if !self.consume(pos, FUNCTION_OPEN) {
704            return FallbackReason::MalformedStructure;
705        }
706
707        let name_begin = *pos;
708        let mut name_end = self.text[name_begin..].find('>').map(|r| name_begin + r);
709        let mut ws_boundary = false;
710
711        // Tolerant mode: the model sometimes drops the '>' after the function
712        // name (for example when a parameter element follows immediately on
713        // the next line). Recover by scanning the identifier run and accepting
714        // it when whitespace separates it from the next '<' or the end of the
715        // region.
716        if self.tolerant {
717            let mut scan = name_begin;
718
719            while scan < self.text.len() && scan - name_begin < MAX_TOOL_NAME_LENGTH {
720                let byte = self.text.as_bytes()[scan];
721
722                if !byte.is_ascii_alphanumeric() && byte != b'_' && byte != b'-' {
723                    break;
724                }
725
726                scan += 1;
727            }
728
729            if scan > name_begin
730                && scan < self.text.len()
731                && is_ws(self.text.as_bytes()[scan] as char)
732                && name_end.map(|end| scan < end).unwrap_or(true)
733            {
734                let mut after = scan;
735
736                while after < self.text.len() && is_ws(self.text.as_bytes()[after] as char) {
737                    after += 1;
738                }
739
740                if after >= self.text.len() || self.text[after..].starts_with('<') {
741                    name_end = Some(scan);
742                    ws_boundary = true;
743                }
744            }
745        }
746
747        let Some(end) = name_end else {
748            return FallbackReason::InvalidToolName;
749        };
750
751        if end == name_begin {
752            return FallbackReason::InvalidToolName;
753        }
754
755        let name = &self.text[name_begin..end];
756        call.name = name.to_owned();
757
758        if !valid_function_name(name) {
759            return FallbackReason::InvalidToolName;
760        }
761
762        if self.contract.enforce_declared_names && !self.contract.is_declared(name) {
763            return FallbackReason::UndeclaredTool;
764        }
765
766        *pos = if ws_boundary { end } else { end + 1 };
767
768        loop {
769            skip_ws(self.text, pos);
770
771            if self.consume(pos, FUNCTION_CLOSE) {
772                return FallbackReason::None;
773            }
774
775            // Tolerant mode only: the region is exhausted after the last
776            // complete parameter, so a missing closing tag is a truncation,
777            // not a structural failure.
778            if self.tolerant && self.at_region_end(*pos) {
779                return FallbackReason::TruncatedTail;
780            }
781
782            let failure = self.parse_parameter(pos, call);
783
784            if failure != FallbackReason::None {
785                return failure;
786            }
787        }
788    }
789
790    fn parse_parameter(&self, pos: &mut usize, call: &mut RawToolCall) -> FallbackReason {
791        if !self.consume(pos, PARAM_OPEN) {
792            return FallbackReason::MalformedStructure;
793        }
794
795        let name_begin = *pos;
796        let Some(rel) = self.text[name_begin..].find('>') else {
797            return FallbackReason::MalformedStructure;
798        };
799        let name_end = name_begin + rel;
800
801        if name_end == name_begin {
802            return FallbackReason::MalformedStructure;
803        }
804
805        let name = &self.text[name_begin..name_end];
806
807        if call.parameters.iter().any(|p| p.name == name) {
808            return FallbackReason::DuplicateParameter;
809        }
810
811        let value_begin = name_end + 1;
812        let Some(value_end) = self.find_parameter_close(value_begin) else {
813            return FallbackReason::MalformedStructure;
814        };
815
816        call.parameters.push(RawParameter {
817            name: name.to_owned(),
818            value: self.text[value_begin..value_end].to_owned(),
819        });
820
821        *pos = value_end + PARAM_CLOSE.len();
822
823        FallbackReason::None
824    }
825
826    fn find_parameter_open_before(&self, scan: usize, limit: usize) -> Option<usize> {
827        let mut candidate = self.text[scan..].find(PARAM_OPEN).map(|r| scan + r);
828
829        while let Some(at) = candidate {
830            if at >= limit {
831                break;
832            }
833
834            let name_begin = at + PARAM_OPEN.len();
835            let Some(name_end) = self.text[name_begin..].find('>').map(|r| name_begin + r) else {
836                candidate = self.text[at + 1..].find(PARAM_OPEN).map(|r| at + 1 + r);
837
838                continue;
839            };
840
841            if name_end < limit && name_end != name_begin {
842                return Some(name_end + 1);
843            }
844
845            candidate = self.text[at + 1..].find(PARAM_OPEN).map(|r| at + 1 + r);
846        }
847
848        None
849    }
850
851    /// Depth-aware search for the closing tag belonging to a value; nested
852    /// parameter opens inside the value count as deeper levels.
853    fn find_parameter_close(&self, value_begin: usize) -> Option<usize> {
854        let mut depth = 1;
855        let mut scan = value_begin;
856
857        loop {
858            let Some(rel) = self.text[scan..].find(PARAM_CLOSE) else {
859                return None;
860            };
861            let close = scan + rel;
862            let open_end = self.find_parameter_open_before(scan, close);
863
864            if let Some(open_end) = open_end {
865                depth += 1;
866                scan = open_end;
867
868                continue;
869            }
870
871            depth -= 1;
872
873            if depth == 0 {
874                return Some(close);
875            }
876
877            scan = close + PARAM_CLOSE.len();
878        }
879    }
880}
881
882fn skip_ws(text: &str, pos: &mut usize) {
883    while *pos < text.len() && is_ws(text.as_bytes()[*pos] as char) {
884        *pos += 1;
885    }
886}
887
888fn starts_at(text: &str, pos: usize, prefix: &str) -> bool {
889    text.get(pos..pos + prefix.len())
890        .is_some_and(|slice| slice == prefix)
891}
892
893/// Parse the Qwen XML-like tool-call format. Content before the first marker
894/// is retained as ordinary text; a malformed marker region falls back to the
895/// whole input, marker included. Tolerant mode recovers complete calls from a
896/// truncated or suffixed region.
897pub(crate) fn parse_qwen_tool_call_output(
898    text: &str,
899    contract: &ToolCallOutputContract,
900    tolerant: bool,
901) -> ParsedToolCallOutput {
902    let Some(first) = text.find(TOOL_OPEN) else {
903        return ParsedToolCallOutput {
904            is_tool_call_response: false,
905            content: text.to_owned(),
906            tool_calls: Vec::new(),
907            diagnostics: ToolCallParseDiagnostics::default(),
908        };
909    };
910
911    let mut out = ParsedToolCallOutput {
912        is_tool_call_response: false,
913        content: trim_ws(&text[..first]).to_owned(),
914        tool_calls: Vec::new(),
915        diagnostics: ToolCallParseDiagnostics {
916            marker_seen: true,
917            ..Default::default()
918        },
919    };
920
921    let parser = RegionParser {
922        text: &text[first..],
923        contract,
924        tolerant,
925    };
926    let mut raw_calls = Vec::new();
927    let failure = parser.parse(&mut raw_calls);
928
929    if failure == FallbackReason::TruncatedTail {
930        // A truncated tail after a complete call was discarded; the recovered
931        // calls stand.
932        out.diagnostics.fallback_reason = failure;
933    } else if failure != FallbackReason::None {
934        out.diagnostics.fallback_reason = failure;
935
936        return fallback(text, out.diagnostics);
937    }
938
939    for raw in &raw_calls {
940        out.tool_calls
941            .push(normalize_raw_tool_call(raw, contract, &mut out.diagnostics));
942    }
943
944    out.diagnostics.structured_call_count = out.tool_calls.len() as u32;
945    out.is_tool_call_response = true;
946
947    out
948}
949
950// -- Streaming decoder -------------------------------------------------------
951
952pub(crate) struct Terminal {
953    /// Held bytes that were never published by `feed`.
954    pub content: String,
955    pub tool_calls: Vec<GeneratedToolCall>,
956    pub diagnostics: ToolCallParseDiagnostics,
957}
958
959/// Incrementally publishes bytes that are provably outside a possible
960/// terminal Qwen tool-call suffix. Bytes that could belong to a marker are
961/// held back; at terminal time, valid calls are retained structurally and
962/// malformed output is restored verbatim.
963pub(crate) struct ToolCallOutputDecoder {
964    contract: Arc<ToolCallOutputContract>,
965    tolerant: bool,
966    trailing_whitespace: String,
967    tool_region: String,
968    marker_prefix: usize,
969    saw_tool_marker: bool,
970}
971
972impl ToolCallOutputDecoder {
973    pub(crate) fn new(contract: Arc<ToolCallOutputContract>, tolerant: bool) -> Self {
974        Self {
975            contract,
976            tolerant,
977            trailing_whitespace: String::new(),
978            tool_region: String::new(),
979            marker_prefix: 0,
980            saw_tool_marker: false,
981        }
982    }
983
984    /// Feed decoded text and return the visible (non-tool-call) prefix.
985    pub(crate) fn feed(&mut self, text: &str) -> String {
986        if text.is_empty() {
987            return String::new();
988        }
989
990        if self.saw_tool_marker {
991            self.tool_region.push_str(text);
992
993            return String::new();
994        }
995
996        let marker = TOOL_OPEN.as_bytes();
997        let mut visible = String::new();
998        let mut cursor = 0;
999
1000        for c in text.chars() {
1001            if self.marker_prefix > 0 {
1002                if c == marker[self.marker_prefix] as char {
1003                    self.marker_prefix += 1;
1004
1005                    if self.marker_prefix == marker.len() {
1006                        // The terminal marker completed: hold the buffered
1007                        // whitespace, the marker, and everything after it.
1008                        self.tool_region = std::mem::take(&mut self.trailing_whitespace);
1009
1010                        self.tool_region.push_str(TOOL_OPEN);
1011                        self.tool_region.push_str(&text[cursor + c.len_utf8()..]);
1012
1013                        self.marker_prefix = 0;
1014                        self.saw_tool_marker = true;
1015
1016                        return visible;
1017                    }
1018
1019                    cursor += c.len_utf8();
1020
1021                    continue;
1022                }
1023
1024                // The prefix was ordinary text: publish it and re-handle `c`.
1025                visible.push_str(&self.trailing_whitespace);
1026                self.trailing_whitespace.clear();
1027                visible.push_str(&TOOL_OPEN[..self.marker_prefix]);
1028
1029                self.marker_prefix = 0;
1030            }
1031
1032            if c == '<' {
1033                self.marker_prefix = 1;
1034            } else if is_ws(c) {
1035                self.trailing_whitespace.push(c);
1036            } else {
1037                visible.push_str(&self.trailing_whitespace);
1038                self.trailing_whitespace.clear();
1039                visible.push(c);
1040            }
1041
1042            cursor += c.len_utf8();
1043        }
1044
1045        visible
1046    }
1047
1048    /// Close the stream and interpret the held terminal region.
1049    pub(crate) fn finish(self) -> Terminal {
1050        let parsed = parse_qwen_tool_call_output(&self.tool_region, &self.contract, self.tolerant);
1051
1052        if self.saw_tool_marker && parsed.is_tool_call_response {
1053            return Terminal {
1054                content: String::new(),
1055                tool_calls: parsed.tool_calls,
1056                diagnostics: parsed.diagnostics,
1057            };
1058        }
1059
1060        let mut content = self.trailing_whitespace;
1061
1062        content.push_str(&TOOL_OPEN[..self.marker_prefix]);
1063        content.push_str(&self.tool_region);
1064
1065        Terminal {
1066            content,
1067            tool_calls: Vec::new(),
1068            diagnostics: parsed.diagnostics,
1069        }
1070    }
1071}
1072
1073#[cfg(test)]
1074#[path = "../../tests/unit/server/tool_call.rs"]
1075mod tests;