Skip to main content

tool_parser/parsers/
minimax_m2.rs

1use std::{collections::HashMap, fmt::Write as FmtWrite};
2
3use async_trait::async_trait;
4use openai_protocol::common::Tool;
5use regex::Regex;
6use serde_json::Value;
7
8use crate::{
9    errors::ParserResult,
10    parsers::helpers,
11    traits::ToolParser,
12    types::{FunctionCall, StreamingParseResult, ToolCall, ToolCallItem},
13};
14
15/// MiniMax M2 format parser for tool calls
16///
17/// Handles the MiniMax M2 specific format:
18/// `<minimax:tool_call><invoke name="func"><parameter name="key">value</parameter></invoke></minimax:tool_call>`
19///
20/// Features:
21/// - Namespaced XML tags (`minimax:tool_call`)
22/// - Function wrapped in `<invoke name="...">` tags
23/// - Parameters as `<parameter name="key">value</parameter>`
24/// - Incremental JSON streaming for parameters
25///
26/// Reference: <https://huggingface.co/MiniMaxAI/MiniMax-M2?chat_template=default>
27pub struct MinimaxM2Parser {
28    // Regex patterns
29    tool_call_extractor: Regex,
30    invoke_extractor: Regex,
31    param_extractor: Regex,
32
33    // Streaming state
34    buffer: String,
35    prev_tool_call_arr: Vec<Value>,
36    current_tool_id: i32,
37    streamed_args_for_tool: Vec<String>,
38    current_function_name: String,
39    current_parameters: HashMap<String, Value>,
40    in_tool_call: bool,
41    function_name_sent: bool,
42
43    // Token configuration
44    tool_call_start_token: &'static str,
45    tool_call_end_token: &'static str,
46    invoke_end_token: &'static str,
47}
48
49impl MinimaxM2Parser {
50    /// Parse a value from string with consistent logic
51    #[inline]
52    fn parse_value(text: &str) -> Value {
53        // Try parsing as common literals first
54        match text {
55            "true" | "True" => return Value::Bool(true),
56            "false" | "False" => return Value::Bool(false),
57            "null" | "None" => return Value::Null,
58            _ => {}
59        }
60
61        // Try parsing as number
62        if let Ok(num) = text.parse::<i64>() {
63            return Value::Number(num.into());
64        }
65
66        if let Ok(num) = text.parse::<f64>() {
67            if let Some(n) = serde_json::Number::from_f64(num) {
68                return Value::Number(n);
69            }
70        }
71
72        // Default to string
73        Value::String(text.to_string())
74    }
75
76    /// Create a new MiniMax M2 parser
77    #[expect(
78        clippy::expect_used,
79        reason = "regex patterns are compile-time string literals"
80    )]
81    pub fn new() -> Self {
82        // Use (?s) flag for DOTALL mode to handle newlines
83        let tool_call_pattern = r"(?s)<minimax:tool_call>.*?</minimax:tool_call>";
84        let tool_call_extractor = Regex::new(tool_call_pattern).expect("Valid regex pattern");
85
86        let invoke_pattern = r#"(?s)<invoke\s+name="([^"]+)">(.*?)</invoke>"#;
87        let invoke_extractor = Regex::new(invoke_pattern).expect("Valid regex pattern");
88
89        let param_pattern = r#"(?s)<parameter\s+name="([^"]+)">(.*?)</parameter>"#;
90        let param_extractor = Regex::new(param_pattern).expect("Valid regex pattern");
91
92        Self {
93            tool_call_extractor,
94            invoke_extractor,
95            param_extractor,
96            buffer: String::new(),
97            prev_tool_call_arr: Vec::new(),
98            current_tool_id: -1,
99            streamed_args_for_tool: Vec::new(),
100            current_function_name: String::new(),
101            current_parameters: HashMap::new(),
102            in_tool_call: false,
103            function_name_sent: false,
104            tool_call_start_token: "<minimax:tool_call>",
105            tool_call_end_token: "</minimax:tool_call>",
106            invoke_end_token: "</invoke>",
107        }
108    }
109
110    /// Parse parameter tags, coercing each value by its declared schema type when
111    /// known (so a numeric-looking `string` stays a string), else inferring.
112    fn parse_parameters(
113        &self,
114        params_text: &str,
115        param_types: &HashMap<String, String>,
116    ) -> serde_json::Map<String, Value> {
117        let mut parameters = serde_json::Map::new();
118
119        for capture in self.param_extractor.captures_iter(params_text) {
120            let key = capture.get(1).map_or("", |m| m.as_str()).trim();
121            let value_str = capture.get(2).map_or("", |m| m.as_str());
122
123            let decoded_value = Self::decode_xml_entities(value_str);
124            let value = helpers::coerce_by_schema_type(
125                &decoded_value,
126                param_types.get(key).map(String::as_str),
127            )
128            .unwrap_or_else(|| Self::parse_value(&decoded_value));
129
130            parameters.insert(key.to_string(), value);
131        }
132
133        parameters
134    }
135
136    /// Decode common XML entities
137    fn decode_xml_entities(text: &str) -> String {
138        text.replace("&lt;", "<")
139            .replace("&gt;", ">")
140            .replace("&amp;", "&")
141            .replace("&quot;", "\"")
142            .replace("&apos;", "'")
143    }
144
145    /// Parse every `<invoke>` block in a tool-call wrapper into tool calls.
146    ///
147    /// MiniMax M2 emits parallel calls as multiple `<invoke>` blocks inside a single
148    /// `<minimax:tool_call>` wrapper (Anthropic-style), so we iterate all matches —
149    /// a single capture would drop every call after the first.
150    fn parse_tool_call(&self, block: &str, tools: &[Tool]) -> Vec<ToolCall> {
151        let mut calls = Vec::new();
152        for captures in self.invoke_extractor.captures_iter(block) {
153            // Get function name from invoke tag attribute
154            let func_name = captures.get(1).map_or("", |m| m.as_str()).trim();
155
156            // Get parameters text
157            let params_text = captures.get(2).map_or("", |m| m.as_str());
158
159            // Parse parameters, coerced by this function's declared schema.
160            let param_types = helpers::param_types_for_function(tools, func_name);
161            let parameters = self.parse_parameters(params_text, &param_types);
162
163            match serde_json::to_string(&parameters) {
164                Ok(arguments_str) => calls.push(ToolCall {
165                    function: FunctionCall {
166                        name: func_name.to_string(),
167                        arguments: arguments_str,
168                    },
169                }),
170                Err(e) => tracing::debug!("Failed to serialize tool call arguments: {}", e),
171            }
172        }
173        calls
174    }
175
176    /// Parse all tool calls from text and return first valid position
177    fn parse_tool_calls_from_text(
178        &self,
179        text: &str,
180        tools: &[Tool],
181    ) -> (Vec<ToolCall>, Option<usize>) {
182        let mut tool_calls = Vec::new();
183        let mut first_valid_pos = None;
184
185        for mat in self.tool_call_extractor.find_iter(text) {
186            let calls = self.parse_tool_call(mat.as_str(), tools);
187            if !calls.is_empty() && first_valid_pos.is_none() {
188                first_valid_pos = Some(mat.start());
189            }
190            tool_calls.extend(calls);
191        }
192
193        (tool_calls, first_valid_pos)
194    }
195
196    /// Shared non-streaming parse; `tools` empty means infer types from text.
197    fn parse_complete_inner(&self, text: &str, tools: &[Tool]) -> (String, Vec<ToolCall>) {
198        if !self.has_tool_markers(text) {
199            return (text.to_string(), vec![]);
200        }
201        let (tool_calls, first_valid_tool_pos) = self.parse_tool_calls_from_text(text, tools);
202        if tool_calls.is_empty() {
203            return (text.to_string(), vec![]);
204        }
205        let normal_text = match first_valid_tool_pos {
206            Some(pos) => text[..pos].to_string(),
207            None => text.to_string(),
208        };
209        (normal_text, tool_calls)
210    }
211
212    /// Parse and stream parameters incrementally
213    fn parse_and_stream_parameters(&mut self, text: &str, tools: &[Tool]) -> Vec<ToolCallItem> {
214        let mut calls = Vec::new();
215        let param_types = helpers::param_types_for_function(tools, &self.current_function_name);
216
217        // Find all complete parameter patterns in the buffer
218        let param_matches: Vec<_> = self
219            .param_extractor
220            .captures_iter(text)
221            .map(|cap| {
222                let name = cap.get(1).map_or("", |m| m.as_str()).trim().to_string();
223                let value_str = cap.get(2).map_or("", |m| m.as_str());
224                let decoded = Self::decode_xml_entities(value_str);
225
226                // Coerce by declared type when known; otherwise keep the prior
227                // JSON-first-then-infer behavior for nested objects/arrays.
228                let value = helpers::coerce_by_schema_type(
229                    &decoded,
230                    param_types.get(&name).map(String::as_str),
231                )
232                .unwrap_or_else(|| {
233                    if decoded.starts_with('{') || decoded.starts_with('[') {
234                        serde_json::from_str::<Value>(&decoded)
235                            .unwrap_or_else(|_| Self::parse_value(&decoded))
236                    } else {
237                        Self::parse_value(&decoded)
238                    }
239                });
240
241                (name, value)
242            })
243            .collect();
244
245        // Build new parameters map
246        let mut new_params = HashMap::new();
247        for (name, value) in param_matches {
248            new_params.insert(name, value);
249        }
250
251        // If we have new parameters that weren't in current_parameters, stream them
252        if !new_params.is_empty() && new_params != self.current_parameters {
253            let tool_id = self.current_tool_id as usize;
254
255            // Ensure we have enough capacity
256            while self.streamed_args_for_tool.len() <= tool_id {
257                self.streamed_args_for_tool.push(String::new());
258            }
259
260            // Build incremental JSON with single allocation
261            if self.current_parameters.is_empty() {
262                // First parameters - start JSON object but don't close it
263                let mut json_fragment = String::with_capacity(256);
264                json_fragment.push('{');
265
266                let mut first = true;
267                for (key, value) in &new_params {
268                    if !first {
269                        json_fragment.push_str(", ");
270                    }
271                    // serde_json::to_string for String/Value is infallible; write! to String is infallible
272                    let key_json = serde_json::to_string(key).unwrap_or_default();
273                    let value_json = serde_json::to_string(value).unwrap_or_default();
274                    let _ = write!(&mut json_fragment, "{key_json}: {value_json}");
275                    first = false;
276                }
277
278                calls.push(ToolCallItem {
279                    tool_index: tool_id,
280                    name: None,
281                    parameters: json_fragment.clone(),
282                });
283
284                self.streamed_args_for_tool[tool_id] = json_fragment;
285            } else {
286                // Additional parameters - add them incrementally
287                let new_keys: Vec<_> = new_params
288                    .keys()
289                    .filter(|k| !self.current_parameters.contains_key(*k))
290                    .collect();
291
292                if !new_keys.is_empty() {
293                    let mut json_fragment = String::with_capacity(128);
294
295                    for key in new_keys {
296                        let value = &new_params[key];
297                        // serde_json::to_string for String/Value is infallible; write! to String is infallible
298                        let key_json = serde_json::to_string(key).unwrap_or_default();
299                        let value_json = serde_json::to_string(value).unwrap_or_default();
300                        let _ = write!(&mut json_fragment, ", {key_json}: {value_json}");
301                    }
302
303                    calls.push(ToolCallItem {
304                        tool_index: tool_id,
305                        name: None,
306                        parameters: json_fragment.clone(),
307                    });
308
309                    self.streamed_args_for_tool[tool_id].push_str(&json_fragment);
310                }
311            }
312
313            // Update current parameters
314            self.current_parameters = new_params;
315
316            // Update prev_tool_call_arr
317            while self.prev_tool_call_arr.len() <= tool_id {
318                self.prev_tool_call_arr.push(Value::Null);
319            }
320            self.prev_tool_call_arr[tool_id] = serde_json::json!({
321                "name": self.current_function_name,
322                "arguments": self.current_parameters,
323            });
324        }
325
326        calls
327    }
328}
329
330impl Default for MinimaxM2Parser {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336#[async_trait]
337impl ToolParser for MinimaxM2Parser {
338    async fn parse_complete(&self, text: &str) -> ParserResult<(String, Vec<ToolCall>)> {
339        Ok(self.parse_complete_inner(text, &[]))
340    }
341
342    async fn parse_complete_with_tools(
343        &self,
344        text: &str,
345        tools: &[Tool],
346    ) -> ParserResult<(String, Vec<ToolCall>)> {
347        Ok(self.parse_complete_inner(text, tools))
348    }
349
350    async fn parse_incremental(
351        &mut self,
352        chunk: &str,
353        tools: &[Tool],
354    ) -> ParserResult<StreamingParseResult> {
355        self.buffer.push_str(chunk);
356        let mut normal_text = String::new();
357        let mut calls = Vec::new();
358
359        loop {
360            // If we're not in a tool call and don't see a start token, return normal text
361            if !self.in_tool_call && !self.buffer.contains(self.tool_call_start_token) {
362                // Check if buffer might contain a partial start token at the end
363                if let Some(partial_len) =
364                    helpers::ends_with_partial_token(&self.buffer, self.tool_call_start_token)
365                {
366                    // Return everything except the potential partial token
367                    let end = self.buffer.len() - partial_len;
368                    normal_text = self.buffer[..end].to_string();
369                    self.buffer = self.buffer[end..].to_string();
370                } else {
371                    // No partial token, return all as normal text
372                    normal_text.clone_from(&self.buffer);
373                    self.buffer.clear();
374                }
375                break;
376            }
377
378            // Look for tool call start
379            if !self.in_tool_call {
380                if let Some(start) = self.buffer.find(self.tool_call_start_token) {
381                    normal_text = self.buffer[..start].to_string();
382                    self.buffer =
383                        self.buffer[start + self.tool_call_start_token.len()..].to_string();
384
385                    self.in_tool_call = true;
386                    self.function_name_sent = false;
387                    self.current_function_name.clear();
388                    self.current_parameters.clear();
389
390                    continue;
391                } else {
392                    // No start token found
393                    break;
394                }
395            }
396
397            // We're in a tool call, try to parse function name if not sent yet
398            if !self.function_name_sent {
399                // Between invokes in a wrapper: if the wrapper-end tag arrives before
400                // the next <invoke>, the wrapper is finished — consume it and exit.
401                if let Some(end_pos) = self.buffer.find(self.tool_call_end_token) {
402                    let next_invoke = self.buffer.find("<invoke");
403                    if next_invoke.is_none_or(|i| end_pos < i) {
404                        self.buffer =
405                            self.buffer[end_pos + self.tool_call_end_token.len()..].to_string();
406                        self.in_tool_call = false;
407                        self.current_function_name.clear();
408                        self.current_parameters.clear();
409                        continue;
410                    }
411                }
412
413                // Use regex to extract function name from <invoke name="..."> pattern
414                // Check if we have enough text to match the invoke pattern
415                if let Some(captures) = self.invoke_extractor.captures(&self.buffer) {
416                    let function_name = captures
417                        .get(1)
418                        .map_or("", |m| m.as_str())
419                        .trim()
420                        .to_string();
421
422                    // Forward unknown tool names too — emit a tool_call rather than
423                    // leaking the <invoke> markup into assistant text.
424                    self.current_function_name.clone_from(&function_name);
425                    self.function_name_sent = true;
426
427                    // Initialize tool call tracking
428                    if self.current_tool_id == -1 {
429                        self.current_tool_id = 0;
430                    }
431
432                    // Ensure tracking arrays are large enough
433                    helpers::ensure_capacity(
434                        self.current_tool_id,
435                        &mut self.prev_tool_call_arr,
436                        &mut self.streamed_args_for_tool,
437                    );
438
439                    // Send tool name with empty parameters
440                    calls.push(ToolCallItem {
441                        tool_index: self.current_tool_id as usize,
442                        name: Some(function_name),
443                        parameters: String::new(),
444                    });
445
446                    // Find the position after the opening invoke tag (after the >)
447                    // We only want to remove up to the opening tag, not the full match
448                    if let Some(pos) = self.buffer.find('>') {
449                        self.buffer = self.buffer[pos + 1..].to_string();
450                    }
451                    continue;
452                }
453                // No complete invoke pattern found yet, wait for more text
454                break;
455            }
456
457            // Parse parameters incrementally
458            if self.function_name_sent {
459                // Process parameters and get any calls to emit
460                let buffer_copy = self.buffer.clone(); // TODO: avoid cloning the buffer
461                let parameter_calls = self.parse_and_stream_parameters(&buffer_copy, tools);
462                calls.extend(parameter_calls);
463
464                // Check if tool call is complete (</invoke> found)
465                if let Some(invoke_end) = self.buffer.find(self.invoke_end_token) {
466                    // Add closing brace to complete the JSON object
467                    let tool_id = self.current_tool_id as usize;
468                    if tool_id < self.streamed_args_for_tool.len() {
469                        let current_streamed = &self.streamed_args_for_tool[tool_id];
470                        if current_streamed.is_empty() {
471                            // No parameters: `{}`, as in the non-streaming parse.
472                            calls.push(ToolCallItem {
473                                tool_index: tool_id,
474                                name: None,
475                                parameters: "{}".to_string(),
476                            });
477                            self.streamed_args_for_tool[tool_id].push_str("{}");
478                        } else {
479                            // Parameter fragments leave the outer object open.
480                            // Braces inside values do not close that object.
481                            calls.push(ToolCallItem {
482                                tool_index: tool_id,
483                                name: None,
484                                parameters: "}".to_string(),
485                            });
486                            self.streamed_args_for_tool[tool_id].push('}');
487                        }
488                    }
489
490                    // Move buffer past the </invoke>
491                    self.buffer =
492                        self.buffer[invoke_end + self.invoke_end_token.len()..].to_string();
493
494                    // This invoke is done. Reset per-invoke state and advance the
495                    // tool index; the next loop iteration either parses another
496                    // <invoke> in the same wrapper or closes the wrapper (handled in
497                    // the !function_name_sent branch). MiniMax M2 packs parallel calls
498                    // as multiple <invoke> blocks in one <minimax:tool_call> wrapper.
499                    self.function_name_sent = false;
500                    self.current_function_name.clear();
501                    self.current_parameters.clear();
502                    self.current_tool_id += 1;
503                    continue;
504                }
505                // Tool call not complete yet, wait for more text
506                break;
507            }
508        }
509
510        Ok(StreamingParseResult { normal_text, calls })
511    }
512
513    fn has_tool_markers(&self, text: &str) -> bool {
514        text.contains(self.tool_call_start_token)
515    }
516
517    fn get_unstreamed_tool_args(&self) -> Option<Vec<ToolCallItem>> {
518        helpers::get_unstreamed_args(&self.prev_tool_call_arr, &self.streamed_args_for_tool)
519    }
520
521    fn reset(&mut self) {
522        self.buffer.clear();
523        self.prev_tool_call_arr.clear();
524        self.current_tool_id = -1;
525        self.streamed_args_for_tool.clear();
526        self.current_function_name.clear();
527        self.current_parameters.clear();
528        self.in_tool_call = false;
529        self.function_name_sent = false;
530    }
531}