Skip to main content

tool_parser/parsers/
minimax_m3.rs

1use async_trait::async_trait;
2use openai_protocol::common::Tool;
3use serde_json::{json, Map, Value};
4
5use crate::{
6    errors::ParserResult,
7    factory::GrammarStart,
8    parsers::helpers,
9    traits::ToolParser,
10    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
11};
12
13/// Namespace marker MiniMax M3 prepends before each structural tag.
14const NAMESPACE: &str = "]<]minimax[>[";
15/// Opening marker for a tool-call block.
16const TOOL_CALL_START: &str = "]<]minimax[>[<tool_call>";
17/// Closing marker for a tool-call block.
18const TOOL_CALL_END: &str = "]<]minimax[>[</tool_call>";
19/// Opening marker for an `<invoke ...>` element (attributes follow before `>`).
20const INVOKE_START: &str = "]<]minimax[>[<invoke";
21/// Closing marker for an `<invoke>` element.
22const INVOKE_END: &str = "]<]minimax[>[</invoke>";
23/// Opening marker prefix for a parameter element (`<name>`).
24const ELEMENT_START: &str = "]<]minimax[>[<";
25/// Opening marker prefix for a parameter closing tag (`</name>`).
26const ELEMENT_END_START: &str = "]<]minimax[>[</";
27/// Reserved field name used to preserve mixed text within an object element.
28const MIXED_TEXT_FIELD: &str = "$text";
29/// Opening marker of the model's thought.
30const THINK_START: &str = "<mm:think>";
31/// Closing marker of the model's thought.
32const THINK_END: &str = "</mm:think>";
33/// The element tag of a list's items, as the model writes an array.
34const ITEM: &str = "item";
35/// The markers that end a call wherever they stand; no argument value may hold one.
36const CALL_EXITS: [&str; 3] = ["</invoke>", "<invoke name=\"", "</tool_call>"];
37/// How deep the structural tag follows a schema's nesting; below that, a value is any text.
38const DEPTH: usize = 16;
39
40/// MiniMax M3 format parser for tool calls.
41///
42/// Handles the MiniMax M3 specific framing, where the namespace marker
43/// `]<]minimax[>[` is prepended before every structural tag:
44///
45/// ```text
46/// ]<]minimax[>[<tool_call>
47/// ]<]minimax[>[<invoke name="func">
48/// ]<]minimax[>[<key>value]<]minimax[>[</key>
49/// ]<]minimax[>[</invoke>
50/// ]<]minimax[>[</tool_call>
51/// ```
52///
53/// Differences from MiniMax M2:
54/// - Each structural tag is prefixed with the namespace marker `]<]minimax[>[`.
55/// - The start token is `]<]minimax[>[<tool_call>` (not `<minimax:tool_call>`).
56/// - A single tool-call block may contain multiple `<invoke>` tags.
57/// - Parameters are expressed with parameter-name XML tags and may nest
58///   recursively to form objects and arrays.
59///
60/// Reference: the serving engine's `MinimaxM3ToolParser` (`tool_call_start_token =
61/// "]<]minimax[>[<tool_call>"`).
62pub struct MinimaxM3Parser {
63    // Streaming state
64    buffer: String,
65    prev_tool_call_arr: Vec<Value>,
66    current_tool_id: i32,
67    streamed_args_for_tool: Vec<String>,
68    in_tool_call: bool,
69}
70
71/// A parsed parameter value: either leaf text or nested child elements.
72enum ParamValue {
73    Text(String),
74    Elements(Vec<(String, ParamValue)>),
75}
76
77impl MinimaxM3Parser {
78    /// Create a new MiniMax M3 parser.
79    pub fn new() -> Self {
80        Self {
81            buffer: String::new(),
82            prev_tool_call_arr: Vec::new(),
83            current_tool_id: -1,
84            streamed_args_for_tool: Vec::new(),
85            in_tool_call: false,
86        }
87    }
88
89    /// Parse a leaf value from text, coercing by declared schema type when known,
90    /// otherwise inferring the JSON type (number/bool/null) and defaulting to string.
91    fn coerce_leaf(text: &str, declared_type: Option<&str>) -> Value {
92        // An empty container renders as an element with no children and no
93        // text, which reaches the leaf path indistinguishable from an empty
94        // string. `coerce_by_schema_type` cannot parse "" as JSON, so without
95        // this the value would be inferred as `""` and fail the tool's schema.
96        if text.trim().is_empty() {
97            match declared_type {
98                Some("array") => return Value::Array(Vec::new()),
99                Some("object") => return Value::Object(Map::new()),
100                _ => {}
101            }
102        }
103
104        if let Some(value) = helpers::coerce_by_schema_type(text, declared_type) {
105            return value;
106        }
107        Self::infer_value(text)
108    }
109
110    /// Infer a JSON value from a raw text leaf (no schema available).
111    fn infer_value(text: &str) -> Value {
112        match text {
113            "true" | "True" => return Value::Bool(true),
114            "false" | "False" => return Value::Bool(false),
115            "null" | "None" => return Value::Null,
116            _ => {}
117        }
118
119        if let Ok(num) = text.parse::<i64>() {
120            return Value::Number(num.into());
121        }
122        if let Ok(num) = text.parse::<f64>() {
123            if let Some(n) = serde_json::Number::from_f64(num) {
124                return Value::Number(n);
125            }
126        }
127
128        Value::String(text.to_string())
129    }
130
131    /// Length of the longest suffix of `buffer` that is a proper prefix of `token`.
132    ///
133    /// Unlike [`helpers::ends_with_partial_token`] (which returns the shortest such
134    /// match), this prefers the longest match. The M3 start token contains repeated
135    /// `]` characters, so a shortest-match would mis-align and leak the marker's
136    /// leading bytes as normal text.
137    fn longest_partial_suffix(buffer: &str, token: &str) -> Option<usize> {
138        if buffer.is_empty() || token.is_empty() {
139            return None;
140        }
141        token
142            .char_indices()
143            .skip(1)
144            .map(|(i, _)| i)
145            .filter(|&i| buffer.ends_with(&token[..i]))
146            .max()
147    }
148
149    /// Whether a buffered tool-call wrapper can still become a valid invoke.
150    ///
151    /// Whitespace is allowed between the wrapper and the invoke marker. Once
152    /// the first non-whitespace bytes diverge from that marker, later input
153    /// cannot turn the candidate into a tool call.
154    fn could_start_invoke(buffer: &str) -> bool {
155        let Some(after_wrapper) = buffer.strip_prefix(TOOL_CALL_START) else {
156            return false;
157        };
158        let candidate = after_wrapper.trim_start();
159        if candidate.is_empty() || INVOKE_START.starts_with(candidate) {
160            return true;
161        }
162
163        let Some(after_invoke) = candidate.strip_prefix(INVOKE_START) else {
164            return false;
165        };
166        after_invoke.is_empty()
167            || after_invoke
168                .chars()
169                .next()
170                .is_some_and(|c| c.is_whitespace() || c == '>')
171    }
172
173    /// Decode common XML entities.
174    fn decode_xml_entities(text: &str) -> String {
175        text.replace("&lt;", "<")
176            .replace("&gt;", ">")
177            .replace("&quot;", "\"")
178            .replace("&apos;", "'")
179            .replace("&amp;", "&")
180    }
181
182    /// Extract the `name="..."` attribute value from an invoke header (the text
183    /// between `]<]minimax[>[<invoke` and the closing `>`).
184    fn parse_invoke_name(header: &str) -> Option<String> {
185        let idx = header.find("name")?;
186        let after = &header[idx + "name".len()..];
187        let after = after.trim_start();
188        let after = after.strip_prefix('=')?.trim_start();
189        if let Some(rest) = after.strip_prefix('"') {
190            let end = rest.find('"')?;
191            Some(rest[..end].trim().to_string())
192        } else if let Some(rest) = after.strip_prefix('\'') {
193            let end = rest.find('\'')?;
194            Some(rest[..end].trim().to_string())
195        } else {
196            let end = after
197                .find(|c: char| c.is_whitespace())
198                .unwrap_or(after.len());
199            let name = after[..end].trim();
200            if name.is_empty() {
201                None
202            } else {
203                Some(name.to_string())
204            }
205        }
206    }
207
208    /// Parse the body of one element (recursively), returning its value and the
209    /// number of bytes consumed up to and including the matching close tag.
210    /// An empty schema-declared container may omit its close immediately before
211    /// its parent closes; in that case the parent marker remains unconsumed.
212    fn parse_element_body(
213        input: &str,
214        name: &str,
215        schema: Option<&Value>,
216        parent_name: Option<&str>,
217    ) -> Option<(ParamValue, usize)> {
218        let close_tag = format!("{ELEMENT_END_START}{name}>");
219        let mut pos = 0;
220        let mut text = String::new();
221        let mut children: Vec<(String, ParamValue)> = Vec::new();
222
223        loop {
224            // Accumulate any text up to the next namespace marker; a body
225            // with no further marker is an unterminated element.
226            let rest = &input[pos..];
227            let text_chunk = &rest[..rest.find(NAMESPACE)?];
228            text.push_str(text_chunk);
229            pos += text_chunk.len();
230
231            let rest = &input[pos..];
232            if rest.starts_with(&close_tag) {
233                pos += close_tag.len();
234                break;
235            }
236            if text.trim().is_empty()
237                && children.is_empty()
238                && matches!(Self::schema_type(schema), Some("array" | "object"))
239                && parent_name.is_some_and(|parent| {
240                    rest.starts_with(&format!("{ELEMENT_END_START}{parent}>"))
241                })
242            {
243                // The parent consumes its own close; the empty child's close
244                // was omitted by the model.
245                break;
246            }
247            if rest.starts_with(ELEMENT_START) {
248                // Child element.
249                let (child_name, child_value, consumed) =
250                    Self::parse_element(rest, schema, Some(name))?;
251                children.push((child_name, child_value));
252                pos += consumed;
253                continue;
254            }
255            // A namespace marker that is neither the expected close nor a child
256            // start: malformed body.
257            return None;
258        }
259
260        let value = if children.is_empty() {
261            ParamValue::Text(text)
262        } else {
263            if !text.trim().is_empty() {
264                Self::push_mixed_text(&mut children, text);
265            }
266            ParamValue::Elements(children)
267        };
268        Some((value, pos))
269    }
270
271    /// Parse a complete element starting at `input` (which must begin with
272    /// `]<]minimax[>[<name>`). Returns `(name, value, consumed_bytes)`.
273    fn parse_element(
274        input: &str,
275        parent_schema: Option<&Value>,
276        parent_name: Option<&str>,
277    ) -> Option<(String, ParamValue, usize)> {
278        let after_start = input.strip_prefix(ELEMENT_START)?;
279        let gt = after_start.find('>')?;
280        let name = after_start[..gt].trim().to_string();
281        if name.is_empty() || name.starts_with('/') {
282            return None;
283        }
284        let body_start = ELEMENT_START.len() + gt + 1;
285        let parent_schema = Self::container_schema(parent_schema).or(parent_schema);
286        let schema = if Self::schema_type(parent_schema) == Some("array") {
287            parent_schema.and_then(|schema| schema.get("items"))
288        } else {
289            Self::property_schema(parent_schema, &name)
290        };
291        let schema = Self::container_schema(schema).or(schema);
292        let (value, body_consumed) =
293            Self::parse_element_body(&input[body_start..], &name, schema, parent_name)?;
294        Some((name, value, body_start + body_consumed))
295    }
296
297    /// Preserve mixed text content under a reserved object field, avoiding a
298    /// collision with an existing child name by prefixing `$`.
299    fn push_mixed_text(children: &mut Vec<(String, ParamValue)>, text: String) {
300        let mut field = MIXED_TEXT_FIELD.to_string();
301        while children.iter().any(|(name, _)| *name == field) {
302            field.insert(0, '$');
303        }
304        children.push((field, ParamValue::Text(text)));
305    }
306
307    /// The scalar `type` declared by a schema node, when it has one.
308    fn schema_type(schema: Option<&Value>) -> Option<&str> {
309        schema?.get("type")?.as_str()
310    }
311
312    /// Resolve a direct container or a transparent composition wrapper.
313    /// Nullable unions must have exactly one container branch; scalar/unknown
314    /// alternatives are ambiguous. Only singleton allOf wrappers are unwrapped:
315    /// choosing one of several conjuncts would discard the others' constraints.
316    fn container_schema(schema: Option<&Value>) -> Option<&Value> {
317        let schema = schema?;
318        if schema.get("type").is_some() {
319            return matches!(Self::schema_type(Some(schema)), Some("array" | "object"))
320                .then_some(schema);
321        }
322
323        let mut compositions = ["oneOf", "anyOf", "allOf"]
324            .into_iter()
325            .filter_map(|key| schema.get(key).map(|branches| (key, branches)));
326        let (keyword, branches) = compositions.next()?;
327        if compositions.next().is_some() {
328            return None;
329        }
330        // Unwrapping must not drop sibling validation or structural keywords.
331        if schema.as_object()?.keys().any(|key| {
332            key != keyword
333                && !matches!(
334                    key.as_str(),
335                    "title"
336                        | "description"
337                        | "default"
338                        | "examples"
339                        | "deprecated"
340                        | "readOnly"
341                        | "writeOnly"
342                        | "$comment"
343                )
344        }) {
345            return None;
346        }
347        let branches = branches.as_array()?;
348        if keyword == "allOf" && branches.len() != 1 {
349            return None;
350        }
351        let mut container = None;
352        for branch in branches {
353            if keyword != "allOf" && Self::schema_type(Some(branch)) == Some("null") {
354                continue;
355            }
356            let resolved = Self::container_schema(Some(branch))?;
357            if container.replace(resolved).is_some() {
358                return None;
359            }
360        }
361        container
362    }
363
364    /// Find a named property in this schema or a nested composition branch.
365    fn property_schema<'a>(schema: Option<&'a Value>, name: &str) -> Option<&'a Value> {
366        let schema = schema?;
367        if let Some(property) = schema.get("properties").and_then(|value| value.get(name)) {
368            return Some(property);
369        }
370
371        for keyword in ["oneOf", "anyOf", "allOf"] {
372            for branch in schema
373                .get(keyword)
374                .and_then(Value::as_array)
375                .into_iter()
376                .flatten()
377            {
378                if let Some(property) = Self::property_schema(Some(branch), name) {
379                    return Some(property);
380                }
381            }
382        }
383
384        None
385    }
386
387    /// Convert a parsed parameter value into a JSON value, coercing leaves by
388    /// the schema node they sit under: array elements descend into `items`,
389    /// object members into their `properties` entry.
390    fn value_to_json(value: ParamValue, schema: Option<&Value>) -> Value {
391        let schema = Self::container_schema(schema).or(schema);
392        match value {
393            ParamValue::Text(text) => {
394                let decoded = Self::decode_xml_entities(&text);
395                Self::coerce_leaf(&decoded, Self::schema_type(schema))
396            }
397            ParamValue::Elements(children) => {
398                // Repeated child names under an `array` schema (or with duplicate
399                // keys) collapse to an array; otherwise build an object.
400                if Self::schema_type(schema) == Some("array") {
401                    let items_schema = schema.and_then(|s| s.get("items"));
402                    let items = children
403                        .into_iter()
404                        .map(|(_, v)| Self::value_to_json(v, items_schema))
405                        .collect();
406                    return Value::Array(items);
407                }
408
409                let mut map: Map<String, Value> = Map::new();
410                for (name, child) in children {
411                    let child_schema = Self::property_schema(schema, &name);
412                    let child_json = Self::value_to_json(child, child_schema);
413                    match map.get_mut(&name) {
414                        Some(Value::Array(arr)) => arr.push(child_json),
415                        Some(existing) => {
416                            let prev = existing.take();
417                            *existing = Value::Array(vec![prev, child_json]);
418                        }
419                        None => {
420                            map.insert(name, child_json);
421                        }
422                    }
423                }
424                Value::Object(map)
425            }
426        }
427    }
428
429    /// Parse all parameter elements inside a complete invoke body into a JSON
430    /// object. `params_schema` is the function's `parameters` JSON schema.
431    /// `None` when an element starts but fails to parse: emitting the
432    /// arguments collected so far would run the tool with silently missing
433    /// parameters.
434    fn parse_invoke_params(body: &str, params_schema: Option<&Value>) -> Option<Value> {
435        let mut map: Map<String, Value> = Map::new();
436        let mut pos = 0;
437
438        loop {
439            let rest = &body[pos..];
440            let trimmed = rest.trim_start();
441            let trim_len = rest.len() - trimmed.len();
442            if trimmed.is_empty() {
443                break;
444            }
445            if !trimmed.starts_with(ELEMENT_START) {
446                // Tolerant: ordinary text at a parameter boundary ends the params.
447                break;
448            }
449            pos += trim_len;
450            let (name, value, consumed) = Self::parse_element(&body[pos..], params_schema, None)?;
451            pos += consumed;
452            let json = Self::value_to_json(value, Self::property_schema(params_schema, &name));
453            match map.get_mut(&name) {
454                Some(Value::Array(arr)) => arr.push(json),
455                Some(existing) => {
456                    let prev = existing.take();
457                    *existing = Value::Array(vec![prev, json]);
458                }
459                None => {
460                    map.insert(name, json);
461                }
462            }
463        }
464
465        Some(Value::Object(map))
466    }
467
468    /// Parse a single invoke block (the text between `]<]minimax[>[<invoke` and
469    /// `]<]minimax[>[</invoke>`) into a tool call.
470    fn parse_invoke(block: &str, tools: &[Tool]) -> Option<ToolCall> {
471        // `block` begins at the namespace marker for `<invoke`.
472        let after_invoke = block.strip_prefix(INVOKE_START)?;
473        let gt = after_invoke.find('>')?;
474        let header = &after_invoke[..gt];
475        let name = Self::parse_invoke_name(header)?;
476        let body = &after_invoke[gt + 1..];
477
478        let params_schema = tools
479            .iter()
480            .find(|t| t.function.name == name)
481            .map(|t| &t.function.parameters);
482        let arguments = Self::parse_invoke_params(body, params_schema)?;
483        let arguments_str = serde_json::to_string(&arguments).ok()?;
484
485        Some(ToolCall {
486            function: FunctionCall {
487                name,
488                arguments: arguments_str,
489            },
490        })
491    }
492
493    /// Parse all complete tool-call blocks in `text`.
494    /// Returns the tool calls and the byte position of the first tool-call block.
495    fn parse_tool_calls(text: &str, tools: &[Tool]) -> (Vec<ToolCall>, Option<usize>) {
496        let mut calls = Vec::new();
497        let mut first_pos = None;
498        let mut search_from = 0;
499
500        while let Some(rel_start) = text[search_from..].find(TOOL_CALL_START) {
501            let block_start = search_from + rel_start;
502            let inner_start = block_start + TOOL_CALL_START.len();
503            let Some(rel_end) = text[inner_start..].find(TOOL_CALL_END) else {
504                break;
505            };
506            let inner_end = inner_start + rel_end;
507            let inner = &text[inner_start..inner_end];
508
509            // Extract each invoke block within the tool-call wrapper.
510            let mut invoke_from = 0;
511            while let Some(rel_inv) = inner[invoke_from..].find(INVOKE_START) {
512                let inv_start = invoke_from + rel_inv;
513                let Some(rel_inv_end) = inner[inv_start..].find(INVOKE_END) else {
514                    break;
515                };
516                let inv_end = inv_start + rel_inv_end;
517                let inv_block = &inner[inv_start..inv_end];
518                if let Some(call) = Self::parse_invoke(inv_block, tools) {
519                    if first_pos.is_none() {
520                        first_pos = Some(block_start);
521                    }
522                    calls.push(call);
523                }
524                invoke_from = inv_end + INVOKE_END.len();
525            }
526
527            search_from = inner_end + TOOL_CALL_END.len();
528        }
529
530        (calls, first_pos)
531    }
532
533    /// Shared non-streaming parse. `tools` empty means infer types from text.
534    fn parse_complete_inner(text: &str, tools: &[Tool]) -> (String, Vec<ToolCall>) {
535        if !text.contains(TOOL_CALL_START) {
536            return (text.to_string(), vec![]);
537        }
538        let (calls, first_pos) = Self::parse_tool_calls(text, tools);
539        if calls.is_empty() {
540            return (text.to_string(), vec![]);
541        }
542        let normal_text = match first_pos {
543            Some(pos) => text[..pos].to_string(),
544            None => text.to_string(),
545        };
546        (normal_text, calls)
547    }
548
549    /// Constrain the forced calls in the model's own syntax: one block,
550    /// `]<]minimax[>[<tool_call>` and a newline, one invoke or more, the
551    /// separator and `</tool_call>`; an invoke is the separator,
552    /// `<invoke name="NAME">`, one element per schema property in the
553    /// schema's order (optional unless the schema requires it), the
554    /// separator, `</invoke>` and a newline; an element is the separator and
555    /// `<key>`, the value, the separator and `</key>`. A value follows the
556    /// type its schema pins: a string as text free of the separator, a string
557    /// `enum` as one of its values, a number as the JSON its schema accepts, a
558    /// boolean as one of the words the parser reads, an object as nested
559    /// elements, an array as `<item>` elements, `null` as nothing (the
560    /// template leaves such an argument out), anything untyped as free text
561    /// up to the closing tag. A nameless tool is skipped; with no named tool
562    /// the block is empty, which parses to no calls. The separator stays in
563    /// the trigger: this parser reads the block only with it.
564    ///
565    /// The forced block is written at once, so the tag opens with what the
566    /// model writes before a call when the template leaves the thought to it
567    /// (its default): the thought block, or the bare `</mm:think>` the model
568    /// writes for an empty one, or nothing. A prompt that ends inside the
569    /// thought gets the registry's [`Self::reasoning_prefix`] in front as
570    /// well (see `ParserRegistry::generate_tool_constraint`).
571    pub fn build_structural_tag(tools: &[Tool], at_least_one: bool) -> Value {
572        let invokes: Vec<Value> = tools
573            .iter()
574            .filter(|tool| !tool.function.name.is_empty())
575            .map(|tool| Elements::of(&tool.function.parameters).invoke(&tool.function.name))
576            .collect();
577        // An empty list of invokes is unsatisfiable; with no named tool, admit
578        // only an empty block.
579        let invokes = if invokes.is_empty() {
580            json!({ "type": "const_string", "value": "" })
581        } else {
582            json!({
583                "type": "tags_with_separator",
584                "tags": invokes,
585                "separator": "",
586                "at_least_one": true,
587            })
588        };
589        json!({
590            "format": {
591                "type": "sequence",
592                "elements": [
593                    Self::before_the_block(),
594                    {
595                        "type": "triggered_tags",
596                        "triggers": [TOOL_CALL_START],
597                        "tags": [{
598                            "type": "tag",
599                            "begin": format!("{TOOL_CALL_START}\n"),
600                            "content": invokes,
601                            "end": TOOL_CALL_END,
602                        }],
603                        "at_least_one": at_least_one,
604                    },
605                ],
606            }
607        })
608    }
609
610    /// What the model writes before the block when the template leaves the
611    /// thought to it: its thought between `<mm:think>` and `</mm:think>`,
612    /// free of the markers and the separator, or the bare close it writes
613    /// for an empty thought, or nothing at all.
614    fn before_the_block() -> Value {
615        json!({
616            "type": "optional",
617            "content": {
618                "type": "or",
619                "elements": [
620                    {
621                        "type": "tag",
622                        "begin": THINK_START,
623                        "content": {
624                            "type": "any_text",
625                            "excludes": [THINK_START, THINK_END, NAMESPACE],
626                        },
627                        "end": THINK_END,
628                    },
629                    { "type": "const_string", "value": THINK_END },
630                ],
631            },
632        })
633    }
634
635    /// What precedes a forced call on a prompt that ends inside the thought,
636    /// by where the engine starts the grammar; the registry emits
637    /// `sequence[prefix, calls]`. Where the engine applies the grammar only
638    /// after its reasoning parser has seen the model's own `</mm:think>`,
639    /// nothing precedes the calls. Where it applies the grammar from the
640    /// first token, the prefix is the thought, free of the think markers and
641    /// the separator, closed by `</mm:think>`, so the call follows the
642    /// thought. Where the worker does not say, the prefix is free text that
643    /// writes no separator, so no call marker, with nothing owed at its end:
644    /// the thought and its close on the one kind of engine, nothing on the
645    /// other.
646    pub fn reasoning_prefix(start: GrammarStart) -> Option<Value> {
647        match start {
648            GrammarStart::AfterReasoning => None,
649            GrammarStart::Unknown => Some(json!({
650                "type": "any_text",
651                "excludes": [NAMESPACE],
652            })),
653            GrammarStart::FirstToken => Some(json!({
654                "type": "tag",
655                "begin": "",
656                "content": {
657                    "type": "any_text",
658                    "excludes": [THINK_START, THINK_END, NAMESPACE],
659                },
660                "end": THINK_END,
661            })),
662        }
663    }
664}
665
666/// The elements of one invoke, written from the tool's `parameters` schema.
667///
668/// Local references (`#/$defs/Name`, `#/definitions/Name`) are followed, the pointer's escapes
669/// undone; a bare `#` is not, since it would name the fragment's own document once the engine
670/// compiles a fragment alone; a reference back into a schema already being written, directly or
671/// through another definition, is written as free text where it recurs, so a schema that refers
672/// to itself stays bounded, the depth cap the outer bound.
673///
674/// Kept in step with the `symphony` crate's `grammar/xml.rs`, which writes the same model's block
675/// from its table: the two spell the same output, and a change to one belongs in the other, until
676/// one builder serves both paths.
677struct Elements<'a> {
678    root: &'a Value,
679    /// The pointers of the references on the path being written, outermost first.
680    path: Vec<&'a str>,
681}
682
683impl<'a> Elements<'a> {
684    fn of(root: &'a Value) -> Self {
685        Self {
686            root,
687            path: Vec::new(),
688        }
689    }
690
691    /// One invoke: the separator and the opener with the tool's name, the
692    /// elements the schema shapes, the separator, `</invoke>` and a newline.
693    fn invoke(mut self, name: &str) -> Value {
694        let content = match self.follow(self.root) {
695            Some((schema, pushed)) => {
696                let content = self.members(schema, "</invoke>", DEPTH);
697                self.unwind(pushed);
698                content
699            }
700            None => Self::free_text("</invoke>"),
701        };
702        json!({
703            "type": "tag",
704            "begin": format!("{INVOKE_START} name=\"{name}\">"),
705            "content": content,
706            "end": format!("{INVOKE_END}\n"),
707        })
708    }
709
710    /// The schema a local pointer names: an entry under `$defs` or `definitions`, the pointer's
711    /// escapes (`~1` for `/`, `~0` for `~`) undone; nothing for a bare `#`, a pointer elsewhere,
712    /// or a missing target.
713    fn definition(&self, pointer: &str) -> Option<&'a Value> {
714        let mut segments = pointer.strip_prefix("#/")?.split('/');
715        let block = match segments.next()? {
716            block @ ("$defs" | "definitions") => block,
717            _ => return None,
718        };
719        let mut node = self.root.get(block)?;
720        for segment in segments {
721            node = node.get(segment.replace("~1", "/").replace("~0", "~"))?;
722        }
723        Some(node)
724    }
725
726    /// `schema` with its references followed, and how many of them the path took on (the caller
727    /// unwinds them once the schema is written); `None` when a reference re-enters a schema on
728    /// the path, or the hops exceed the depth cap: that node is written as free text. A pointer
729    /// that names nothing stops the following, and the schema stays what it is: untyped.
730    fn follow(&mut self, schema: &'a Value) -> Option<(&'a Value, usize)> {
731        let mut schema = schema;
732        let mut pushed = 0;
733        while let Some(pointer) = schema.get("$ref").and_then(Value::as_str) {
734            let Some(target) = self.definition(pointer) else {
735                break;
736            };
737            if self.path.contains(&pointer) || pushed == DEPTH {
738                self.unwind(pushed);
739                return None;
740            }
741            self.path.push(pointer);
742            pushed += 1;
743            schema = target;
744        }
745        Some((schema, pushed))
746    }
747
748    /// The path without its last `pushed` references.
749    fn unwind(&mut self, pushed: usize) {
750        let keep = self.path.len().saturating_sub(pushed);
751        self.path.truncate(keep);
752    }
753
754    /// An object's elements: one per property in the schema's order, each
755    /// optional unless the schema requires it, a property typed `null` left
756    /// out; free text up to `close` for a schema without properties.
757    fn members(&mut self, schema: &'a Value, close: &str, depth: usize) -> Value {
758        let properties = schema
759            .get("properties")
760            .and_then(Value::as_object)
761            .filter(|properties| !properties.is_empty());
762        let Some(properties) = properties else {
763            return Self::free_text(close);
764        };
765        let required: Vec<&str> = schema
766            .get("required")
767            .and_then(Value::as_array)
768            .map(|names| names.iter().filter_map(Value::as_str).collect())
769            .unwrap_or_default();
770        let elements: Vec<Value> = properties
771            .iter()
772            .filter_map(|(key, schema)| {
773                let element = self.element(key, schema, depth)?;
774                Some(if required.contains(&key.as_str()) {
775                    element
776                } else {
777                    json!({ "type": "optional", "content": element })
778                })
779            })
780            .collect();
781        if elements.is_empty() {
782            return json!({ "type": "const_string", "value": "" });
783        }
784        json!({ "type": "sequence", "elements": elements })
785    }
786
787    /// One element: the separator and `<key>`, the value, the separator and
788    /// `</key>`; nothing for a property typed `null`.
789    fn element(&mut self, key: &str, schema: &'a Value, depth: usize) -> Option<Value> {
790        let value = self.value(schema, &format!("</{key}>"), depth)?;
791        Some(json!({
792            "type": "sequence",
793            "elements": [
794                { "type": "const_string", "value": format!("{ELEMENT_START}{key}>") },
795                value,
796                { "type": "const_string", "value": format!("{ELEMENT_END_START}{key}>") },
797            ],
798        }))
799    }
800
801    /// The value between an element's tags, by the type its schema pins, or
802    /// `None` for `null`, which is written as nothing.
803    fn value(&mut self, schema: &'a Value, close: &str, depth: usize) -> Option<Value> {
804        let Some(depth) = depth.checked_sub(1) else {
805            return Some(Self::free_text(close));
806        };
807        let Some((schema, pushed)) = self.follow(schema) else {
808            return Some(Self::free_text(close));
809        };
810        let value = match MinimaxM3Parser::schema_type(Some(schema)) {
811            Some("null") => None,
812            Some("boolean") => Some(Self::one_of(["true", "false", "True", "False"])),
813            Some("object") => Some(self.members(schema, close, depth)),
814            Some("array") => Some(match schema.get("items") {
815                Some(items) => self.items(items, depth),
816                None => Self::free_text(close),
817            }),
818            Some("string") => Some(match schema.get("enum").and_then(Value::as_array) {
819                Some(values) if !values.is_empty() && values.iter().all(Value::is_string) => {
820                    Self::one_of(values.iter().filter_map(Value::as_str))
821                }
822                _ => Self::text(close),
823            }),
824            Some("integer" | "number") => Some(self.fragment(schema)),
825            _ => Some(Self::free_text(close)),
826        };
827        self.unwind(pushed);
828        value
829    }
830
831    /// A list's items: any number of `<item>` elements, each a value of the
832    /// items' type, an item typed `null` an empty element.
833    fn items(&mut self, items: &'a Value, depth: usize) -> Value {
834        let open = format!("{ELEMENT_START}{ITEM}>");
835        let close = format!("{ELEMENT_END_START}{ITEM}>");
836        let item = match self.value(items, &format!("</{ITEM}>"), depth) {
837            Some(value) => json!({
838                "type": "sequence",
839                "elements": [
840                    { "type": "const_string", "value": open },
841                    value,
842                    { "type": "const_string", "value": close },
843                ],
844            }),
845            None => json!({ "type": "const_string", "value": format!("{open}{close}") }),
846        };
847        json!({ "type": "star", "content": item })
848    }
849
850    /// A number's schema as the engine gets it: with the root's definitions beside it when it
851    /// still holds a local reference, so the pointer resolves once the engine compiles the
852    /// fragment alone.
853    fn fragment(&self, schema: &'a Value) -> Value {
854        let mut fragment = schema.clone();
855        if Self::holds_reference(schema) {
856            if let Some(object) = fragment.as_object_mut() {
857                for name in ["$defs", "definitions"] {
858                    if let Some(block) = self.root.get(name) {
859                        object
860                            .entry(name.to_string())
861                            .or_insert_with(|| block.clone());
862                    }
863                }
864            }
865        }
866        json!({ "type": "json_schema", "json_schema": fragment })
867    }
868
869    /// Whether `schema` holds a `$ref` anywhere.
870    fn holds_reference(schema: &Value) -> bool {
871        match schema {
872            Value::Object(object) => {
873                object.contains_key("$ref") || object.values().any(Self::holds_reference)
874            }
875            Value::Array(items) => items.iter().any(Self::holds_reference),
876            _ => false,
877        }
878    }
879
880    /// One of the words.
881    fn one_of(words: impl IntoIterator<Item = &'a str>) -> Value {
882        let words: Vec<Value> = words
883            .into_iter()
884            .map(|word| json!({ "type": "const_string", "value": word }))
885            .collect();
886        json!({ "type": "or", "elements": words })
887    }
888
889    /// A string as it is: text that holds neither the separator, nor `close`,
890    /// nor a marker that ends the call.
891    fn text(close: &str) -> Value {
892        Self::excluding(&[NAMESPACE, close])
893    }
894
895    /// A value the schema does not shape: text that holds neither `close` nor
896    /// a marker that ends the call, separators and nested tags included,
897    /// which the parser reads as it infers.
898    fn free_text(close: &str) -> Value {
899        Self::excluding(&[close])
900    }
901
902    /// Any text that holds none of `first` and none of the markers that end
903    /// the call, each listed once.
904    fn excluding(first: &[&str]) -> Value {
905        let mut excludes: Vec<&str> = Vec::new();
906        for text in first.iter().chain(CALL_EXITS.iter()) {
907            if !excludes.contains(text) {
908                excludes.push(text);
909            }
910        }
911        json!({ "type": "any_text", "excludes": excludes })
912    }
913}
914
915impl Default for MinimaxM3Parser {
916    fn default() -> Self {
917        Self::new()
918    }
919}
920
921#[async_trait]
922impl ToolParser for MinimaxM3Parser {
923    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
924        Ok(Self::parse_complete_inner(text, &[]))
925    }
926
927    async fn parse_complete_with_tools(
928        &self,
929        text: &str,
930        tools: &[Tool],
931    ) -> ParserResult<(String, Vec<ToolCall>)> {
932        Ok(Self::parse_complete_inner(text, tools))
933    }
934
935    async fn parse_incremental(
936        &mut self,
937        chunk: &str,
938        tools: &[Tool],
939    ) -> ParserResult<StreamingParseResult> {
940        self.buffer.push_str(chunk);
941        let mut normal_text = String::new();
942        let mut calls = Vec::new();
943
944        loop {
945            // Outside a tool call: emit normal text until a start token appears.
946            if !self.in_tool_call {
947                if let Some(start) = self.buffer.find(TOOL_CALL_START) {
948                    normal_text.push_str(&self.buffer[..start]);
949                    self.buffer = self.buffer[start..].to_string();
950                    self.in_tool_call = true;
951                    continue;
952                }
953
954                // No start token: flush text, holding back a potential partial token.
955                if let Some(partial_len) =
956                    Self::longest_partial_suffix(&self.buffer, TOOL_CALL_START)
957                {
958                    let end = self.buffer.len() - partial_len;
959                    normal_text.push_str(&self.buffer[..end]);
960                    self.buffer = self.buffer[end..].to_string();
961                } else {
962                    normal_text.push_str(&self.buffer);
963                    self.buffer.clear();
964                }
965                break;
966            }
967
968            // Inside a tool call: wait for the complete end token before emitting.
969            let Some(end_rel) = self.buffer.find(TOOL_CALL_END) else {
970                if !Self::could_start_invoke(&self.buffer) {
971                    // Release the false wrapper, then resume the normal-text
972                    // scan so a later marker (including a partial one) is still
973                    // recognized rather than flushed as ordinary content.
974                    normal_text.push_str(TOOL_CALL_START);
975                    self.buffer.drain(..TOOL_CALL_START.len());
976                    self.in_tool_call = false;
977                    continue;
978                }
979                break;
980            };
981            let block_end = end_rel + TOOL_CALL_END.len();
982            let block = self.buffer[..block_end].to_string();
983            self.buffer = self.buffer[block_end..].to_string();
984            self.in_tool_call = false;
985
986            let (block_calls, _) = Self::parse_tool_calls(&block, tools);
987            if block_calls.is_empty() {
988                // No invoke parsed: match the non-streaming path, which
989                // returns malformed blocks as text rather than dropping them.
990                normal_text.push_str(&block);
991                continue;
992            }
993            for call in block_calls {
994                if self.current_tool_id == -1 {
995                    self.current_tool_id = 0;
996                } else {
997                    self.current_tool_id += 1;
998                }
999                let tool_id = self.current_tool_id as usize;
1000                helpers::ensure_capacity(
1001                    self.current_tool_id,
1002                    &mut self.prev_tool_call_arr,
1003                    &mut self.streamed_args_for_tool,
1004                );
1005
1006                let args = call.function.arguments.clone();
1007                if tool_id < self.streamed_args_for_tool.len() {
1008                    self.streamed_args_for_tool[tool_id].clone_from(&args);
1009                }
1010                let parsed_args: Value =
1011                    serde_json::from_str(&args).unwrap_or_else(|_| Value::Object(Map::new()));
1012                if tool_id < self.prev_tool_call_arr.len() {
1013                    self.prev_tool_call_arr[tool_id] = serde_json::json!({
1014                        "name": call.function.name,
1015                        "arguments": parsed_args,
1016                    });
1017                }
1018
1019                // Emit name then full arguments for this completed invoke.
1020                calls.push(ToolCallItem {
1021                    tool_index: tool_id,
1022                    name: Some(call.function.name),
1023                    parameters: String::new(),
1024                });
1025                calls.push(ToolCallItem {
1026                    tool_index: tool_id,
1027                    name: None,
1028                    parameters: args,
1029                });
1030            }
1031        }
1032
1033        Ok(StreamingParseResult { normal_text, calls })
1034    }
1035
1036    fn has_tool_markers(&self, text: &str) -> bool {
1037        text.contains(TOOL_CALL_START)
1038    }
1039
1040    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
1041        helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
1042    }
1043
1044    fn take_unstreamed_normal_text(&mut self) -> String {
1045        // Completed blocks are removed from `buffer`, so anything left here is
1046        // an independent, incomplete candidate and must be returned verbatim.
1047        // Leave the parser ready to process ordinary text if it is reused.
1048        self.in_tool_call = false;
1049        std::mem::take(&mut self.buffer)
1050    }
1051
1052    fn reset(&mut self) {
1053        self.buffer.clear();
1054        self.prev_tool_call_arr.clear();
1055        self.current_tool_id = -1;
1056        self.streamed_args_for_tool.clear();
1057        self.in_tool_call = false;
1058    }
1059}