use serde_json::{Map, Number, Value};
use uuid::Uuid;
use super::super::ToolDefinition;
use super::super::config::MiniMaxM3ParserConfig;
use super::parsed_value::{coerce_integer_literal, raw_number_literal};
use super::response::{CalledFunction, ToolCallResponse, ToolCallType};
const MIN_PARTIAL_TOOL_CALL_START_LEN: usize = 3;
pub fn detect_tool_call_start_minimax_m3(chunk: &str, config: &MiniMaxM3ParserConfig) -> bool {
let tool_call_start = tool_call_start(config);
let invoke_start = invoke_start(config);
for token in [tool_call_start.as_str(), invoke_start.as_str()] {
if chunk.contains(token) || chunk_ends_with_partial_m3_marker(chunk, token) {
return true;
}
}
false
}
fn chunk_ends_with_partial_m3_marker(chunk: &str, marker: &str) -> bool {
for (i, _) in marker.char_indices().skip(1) {
if i < MIN_PARTIAL_TOOL_CALL_START_LEN {
continue;
}
if chunk.ends_with(&marker[..i]) {
return true;
}
}
false
}
pub fn find_tool_call_end_position_minimax_m3(
chunk: &str,
config: &MiniMaxM3ParserConfig,
) -> usize {
let tool_call_end = tool_call_end(config);
chunk
.find(tool_call_end.as_str())
.map(|pos| pos + tool_call_end.len())
.unwrap_or(chunk.len())
}
pub fn try_tool_call_parse_minimax_m3(
message: &str,
config: &MiniMaxM3ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(Vec<ToolCallResponse>, Option<String>)> {
let Some(start_pos) = message.find(tool_call_start(config).as_str()) else {
if let Some(marker_idx) = first_orphan_minimax_m3_marker_index(message, config) {
if let Some((prefix, calls)) = recover_orphan_invokes_in_span(message, config, tools)? {
return Ok((calls, Some(prefix)));
}
return Ok((vec![], Some(message[..marker_idx].trim_end().to_string())));
}
return Ok((vec![], Some(message.to_string())));
};
let pre_block_span = &message[..start_pos];
let (prefix, mut calls) = if let Some((prefix, recovered)) =
recover_orphan_invokes_in_span(pre_block_span, config, tools)?
{
(prefix, recovered)
} else if let Some(marker_idx) = first_orphan_minimax_m3_marker_index(pre_block_span, config) {
(
pre_block_span[..marker_idx].trim_end().to_string(),
Vec::new(),
)
} else {
(pre_block_span.to_string(), Vec::new())
};
let block_start = start_pos + tool_call_start(config).len();
let tool_call_end = tool_call_end(config);
let (block, complete) =
if let Some(end_rel) = message[block_start..].find(tool_call_end.as_str()) {
let block_end = block_start + end_rel;
(&message[block_start..block_end], true)
} else {
(&message[block_start..], false)
};
if !complete && !config.allow_eof_recovery {
return Ok((vec![], Some(prefix)));
}
calls.extend(parse_invokes(block, config, tools)?);
Ok((calls, Some(prefix)))
}
fn tool_call_start(config: &MiniMaxM3ParserConfig) -> String {
format!("{}<{}>", config.namespace_token, config.tool_call_tag)
}
fn tool_call_end(config: &MiniMaxM3ParserConfig) -> String {
format!("{}</{}>", config.namespace_token, config.tool_call_tag)
}
fn invoke_start(config: &MiniMaxM3ParserConfig) -> String {
format!("{}<invoke", config.namespace_token)
}
fn invoke_end(config: &MiniMaxM3ParserConfig) -> String {
format!("{}</invoke>", config.namespace_token)
}
fn parameter_start(config: &MiniMaxM3ParserConfig) -> String {
format!("{}<", config.namespace_token)
}
fn first_orphan_minimax_m3_marker_index(
text: &str,
config: &MiniMaxM3ParserConfig,
) -> Option<usize> {
[
tool_call_end(config),
invoke_start(config),
invoke_end(config),
parameter_start(config),
]
.iter()
.filter_map(|marker| text.find(marker.as_str()))
.min()
}
fn recover_orphan_invokes_in_span(
span: &str,
config: &MiniMaxM3ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Option<(String, Vec<ToolCallResponse>)>> {
let Some(marker_idx) = first_orphan_minimax_m3_marker_index(span, config) else {
return Ok(None);
};
let marker_tail = &span[marker_idx..];
if !marker_tail.starts_with(invoke_start(config).as_str()) {
return Ok(None);
}
if !marker_tail.contains(tool_call_end(config).as_str()) && !config.allow_eof_recovery {
return Ok(None);
}
let calls = parse_invokes(marker_tail, config, tools)?;
if calls.is_empty() {
return Ok(None);
}
Ok(Some((span[..marker_idx].trim_end().to_string(), calls)))
}
fn parse_invokes(
block: &str,
config: &MiniMaxM3ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Vec<ToolCallResponse>> {
let invoke_start = invoke_start(config);
let invoke_end = invoke_end(config);
let mut calls = Vec::new();
let mut cursor = 0;
while let Some(start_rel) = block[cursor..].find(invoke_start.as_str()) {
let tag_attrs_start = cursor + start_rel + invoke_start.len();
let Some(tag_end_rel) = block[tag_attrs_start..].find('>') else {
break;
};
let tag_attrs = &block[tag_attrs_start..tag_attrs_start + tag_end_rel];
let function_name = parse_invoke_name(tag_attrs);
let body_start = tag_attrs_start + tag_end_rel + 1;
let Some(body_end_rel) = block[body_start..].find(invoke_end.as_str()) else {
break;
};
let body_end = body_start + body_end_rel;
let function_body = &block[body_start..body_end];
if let Some(function_name) = function_name
&& !function_name.is_empty()
{
let arguments = parse_parameters(&function_name, function_body, config, tools)?;
calls.push(ToolCallResponse {
id: format!("call-{}", Uuid::new_v4()),
tp: ToolCallType::Function,
function: CalledFunction {
name: function_name,
arguments: serde_json::to_string(&Value::Object(arguments))?,
},
});
}
cursor = body_end + invoke_end.len();
}
Ok(calls)
}
fn parse_invoke_name(tag_attrs: &str) -> Option<String> {
let attrs = tag_attrs.trim_start();
let after_name = attrs.strip_prefix("name")?.trim_start();
let value = after_name.strip_prefix('=')?.trim_start();
if let Some(value) = value.strip_prefix('"') {
return value.find('"').map(|end| value[..end].trim().to_string());
}
if let Some(value) = value.strip_prefix('\'') {
return value.find('\'').map(|end| value[..end].trim().to_string());
}
let end = value.find(char::is_whitespace).unwrap_or(value.len());
if end == 0 {
None
} else {
Some(value[..end].trim().to_string())
}
}
fn parse_parameters(
function_name: &str,
body: &str,
config: &MiniMaxM3ParserConfig,
tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Map<String, Value>> {
let parameter_start = parameter_start(config);
let param_config = get_arguments_config(function_name, tools);
let mut parameters = Map::new();
let mut cursor = 0;
while let Some(start_rel) = body[cursor..].find(parameter_start.as_str()) {
let start = cursor + start_rel + parameter_start.len();
if body[start..].starts_with('/') {
cursor = start + 1;
continue;
}
let Some(name_end_rel) = body[start..].find('>') else {
break;
};
let parameter_name = &body[start..start + name_end_rel];
if parameter_name.is_empty() || parameter_name.contains(char::is_whitespace) {
cursor = start + name_end_rel + 1;
continue;
}
let value_start = start + name_end_rel + 1;
let parameter_end = format!("{}</{}>", config.namespace_token, parameter_name);
let Some(value_end_rel) = body[value_start..].find(parameter_end.as_str()) else {
break;
};
let value_end = value_start + value_end_rel;
let raw_value = &body[value_start..value_end];
let schema = param_config.get(parameter_name);
let value = parse_parameter_value(raw_value, schema, config);
insert_parameter(&mut parameters, parameter_name.to_string(), value);
cursor = value_end + parameter_end.len();
}
Ok(parameters)
}
fn insert_parameter(parameters: &mut Map<String, Value>, key: String, value: Value) {
if let Some(existing) = parameters.remove(&key) {
let merged = match existing {
Value::Array(mut values) => {
values.push(value);
Value::Array(values)
}
existing => Value::Array(vec![existing, value]),
};
parameters.insert(key, merged);
} else {
parameters.insert(key, value);
}
}
fn parse_parameter_value(
raw: &str,
schema: Option<&Value>,
config: &MiniMaxM3ParserConfig,
) -> Value {
if raw.contains(parameter_start(config).as_str()) {
parse_nested_minimax_xml(raw, schema.cloned(), config)
} else {
convert_scalar_value(raw, schema)
}
}
fn parse_nested_minimax_xml(
raw: &str,
schema: Option<Value>,
config: &MiniMaxM3ParserConfig,
) -> Value {
let chunks: Vec<&str> = raw.split(config.namespace_token.as_str()).collect();
let leading_text = chunks.first().copied().unwrap_or_default();
let root_value = if schema_has_type(schema.as_ref(), "array")
&& chunks
.get(1)
.is_some_and(|chunk| chunk.starts_with("<item>"))
{
Some(StackValue::Array(Vec::new()))
} else {
Some(StackValue::Object(Map::new()))
};
let mut stack = vec![StackItem {
tag: None,
value: root_value,
texts: if leading_text.is_empty() {
Vec::new()
} else {
vec![leading_text.to_string()]
},
schema,
}];
for (chunk_index, chunk) in chunks.iter().enumerate().skip(1) {
if chunk.starts_with("</") {
let (tag, trailing_text) = split_end_tag_chunk(chunk);
while stack.len() > 1 {
let item = stack.pop().expect("stack has child item");
let matched = item.tag.as_deref() == Some(tag.as_str());
stack
.last_mut()
.expect("stack has parent item")
.append(item);
if matched {
break;
}
}
if !trailing_text.is_empty() {
stack
.last_mut()
.expect("stack has current item")
.append_text(trailing_text);
}
} else if chunk.starts_with('<') {
let (tag, trailing_text) = split_start_tag_chunk(chunk);
if tag.is_empty() {
continue;
}
let child_schema = stack
.last()
.expect("stack has current item")
.schema_for_child(tag.as_str());
let child_value = if schema_has_type(child_schema.as_ref(), "array")
&& chunks
.get(chunk_index + 1)
.is_some_and(|next| next.starts_with("<item>"))
{
Some(StackValue::Array(Vec::new()))
} else if schema_has_type(child_schema.as_ref(), "object") {
Some(StackValue::Object(Map::new()))
} else {
None
};
stack.push(StackItem {
tag: Some(tag),
value: child_value,
texts: if trailing_text.is_empty() {
Vec::new()
} else {
vec![trailing_text.to_string()]
},
schema: child_schema,
});
} else if !chunk.is_empty() {
stack
.last_mut()
.expect("stack has current item")
.append_text(chunk);
}
}
while stack.len() > 1 {
let item = stack.pop().expect("stack has child item");
stack
.last_mut()
.expect("stack has parent item")
.append(item);
}
stack.pop().expect("root item exists").into_value()
}
fn split_start_tag_chunk(chunk: &str) -> (String, &str) {
let Some(gt) = chunk.find('>') else {
return (chunk.trim_start_matches('<').to_string(), "");
};
(chunk[1..gt].to_string(), &chunk[gt + 1..])
}
fn split_end_tag_chunk(chunk: &str) -> (String, &str) {
let Some(gt) = chunk.find('>') else {
return (chunk.trim_start_matches("</").to_string(), "");
};
(chunk[2..gt].to_string(), &chunk[gt + 1..])
}
#[derive(Debug)]
enum StackValue {
Object(Map<String, Value>),
Array(Vec<Value>),
}
#[derive(Debug)]
struct StackItem {
tag: Option<String>,
value: Option<StackValue>,
texts: Vec<String>,
schema: Option<Value>,
}
impl StackItem {
fn into_value(self) -> Value {
match self.value {
None => convert_scalar_value(self.texts.join("").as_str(), self.schema.as_ref()),
Some(StackValue::Object(mut map)) => {
if !self.texts.is_empty() {
let mut text_key = "$text".to_string();
while map.contains_key(&text_key) {
text_key = format!("${text_key}");
}
map.insert(text_key, Value::String(self.texts.join("")));
}
Value::Object(map)
}
Some(StackValue::Array(values)) => Value::Array(values),
}
}
fn append(&mut self, item: StackItem) {
let key = item.tag.clone().unwrap_or_default();
let value = item.into_value();
match self.value.as_mut() {
None => {
let mut map = Map::new();
map.insert(key, value);
self.value = Some(StackValue::Object(map));
}
Some(StackValue::Object(map)) => insert_parameter(map, key, value),
Some(StackValue::Array(values)) => values.push(value),
}
}
fn append_text(&mut self, text: &str) {
if let Some(StackValue::Array(values)) = self.value.as_mut() {
let item_schema = schema_array_item(self.schema.as_ref());
values.push(convert_scalar_value(text, item_schema.as_ref()));
} else {
self.texts.push(text.to_string());
}
}
fn schema_for_child(&self, tag: &str) -> Option<Value> {
if tag == "item"
&& let Some(item_schema) = schema_array_item(self.schema.as_ref())
{
return Some(item_schema);
}
let schema = self.schema.as_ref()?;
if let Some(child_schema) = schema
.get("properties")
.and_then(|properties| properties.get(tag))
{
return Some(child_schema.clone());
}
schema
.get("additionalProperties")
.filter(|additional| additional.is_object())
.cloned()
}
}
fn get_arguments_config(func_name: &str, tools: Option<&[ToolDefinition]>) -> Map<String, Value> {
let Some(tools) = tools else {
return Map::new();
};
for tool in tools {
if tool.name == func_name {
let Some(params) = &tool.parameters else {
return Map::new();
};
if let Some(properties) = params.get("properties").and_then(Value::as_object) {
return properties.clone();
}
if let Some(params_obj) = params.as_object() {
return params_obj.clone();
}
return Map::new();
}
}
tracing::warn!("Tool '{}' is not defined in the tools list.", func_name);
Map::new()
}
fn convert_scalar_value(raw: &str, schema: Option<&Value>) -> Value {
let value = html_unescape(raw);
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("null") {
return Value::Null;
}
let Some(schema) = schema else {
return Value::String(value);
};
if schema_has_type(Some(schema), "string") || schema_has_type(Some(schema), "enum") {
return Value::String(value);
}
if schema_has_type(Some(schema), "integer") {
return coerce_integer_literal(trimmed)
.and_then(|parsed| serde_json::to_value(parsed).ok())
.unwrap_or(Value::String(value));
}
if schema_has_type(Some(schema), "number") {
if let Some(parsed) = coerce_integer_literal(trimmed)
&& let Ok(json) = serde_json::to_value(parsed)
{
return json;
}
if let Ok(number) = trimmed.parse::<f64>()
&& let Some(number) = Number::from_f64(number)
{
return Value::Number(number);
}
if let Some(parsed) = raw_number_literal(trimmed)
&& let Ok(json) = serde_json::to_value(parsed)
{
return json;
}
return Value::String(value);
}
if schema_has_type(Some(schema), "boolean") {
return match trimmed.to_ascii_lowercase().as_str() {
"true" => Value::Bool(true),
"1" => Value::Bool(true),
"false" => Value::Bool(false),
"0" => Value::Bool(false),
_ => Value::String(value),
};
}
if schema_has_type(Some(schema), "object") {
if trimmed.is_empty() {
return Value::Object(Map::new());
}
if let Ok(json) = serde_json::from_str::<Value>(trimmed) {
return json;
}
}
if schema_has_type(Some(schema), "array") {
if trimmed.is_empty() {
return Value::Array(Vec::new());
}
if let Ok(json) = serde_json::from_str::<Value>(trimmed) {
return json;
}
}
Value::String(value)
}
fn schema_has_type(schema: Option<&Value>, expected: &str) -> bool {
let Some(schema) = schema else {
return false;
};
if let Some(ty) = schema.get("type") {
if ty.as_str() == Some(expected) {
return true;
}
if let Some(types) = ty.as_array()
&& types.iter().any(|ty| ty.as_str() == Some(expected))
{
return true;
}
}
for key in ["anyOf", "oneOf"] {
if let Some(options) = schema.get(key).and_then(Value::as_array)
&& options
.iter()
.any(|option| schema_has_type(Some(option), expected))
{
return true;
}
}
false
}
fn schema_array_item(schema: Option<&Value>) -> Option<Value> {
schema
.and_then(|schema| schema.get("items"))
.cloned()
.or_else(|| {
schema.and_then(|schema| {
for key in ["anyOf", "oneOf"] {
if let Some(options) = schema.get(key).and_then(Value::as_array) {
for option in options {
if let Some(items) = option.get("items") {
return Some(items.clone());
}
}
}
}
None
})
})
}
fn html_unescape(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
}
#[cfg(test)]
mod tests {
use super::*;
fn recovery_config() -> MiniMaxM3ParserConfig {
MiniMaxM3ParserConfig {
allow_eof_recovery: true,
..Default::default()
}
}
fn call_name_and_args(call: &ToolCallResponse) -> (String, Value) {
(
call.function.name.clone(),
serde_json::from_str(&call.function.arguments).expect("valid JSON arguments"),
)
}
#[test]
fn detect_tool_call_start_minimax_m3_rejects_short_suffix_false_positives() {
let config = MiniMaxM3ParserConfig::default();
assert!(!detect_tool_call_start_minimax_m3(
"See reference [1]",
&config
));
assert!(!detect_tool_call_start_minimax_m3(
"ordinary text ending in ]",
&config
));
assert!(!detect_tool_call_start_minimax_m3(
"ordinary text ending in ]<",
&config
));
assert!(detect_tool_call_start_minimax_m3(
"split opener ]<]",
&config
));
assert!(detect_tool_call_start_minimax_m3(
"full opener ]<]minimax[>[<tool_call>",
&config
));
assert!(detect_tool_call_start_minimax_m3(
"bare invoke ]<]minimax[>[<invoke",
&config
));
assert!(detect_tool_call_start_minimax_m3(
"split bare invoke ]<]minimax[>[<inv",
&config
));
}
#[test]
fn recovers_complete_bare_invoke_without_outer_tool_call() {
let config = recovery_config();
let input = r#"prefix ]<]minimax[>[<invoke name="get_weather">
]<]minimax[>[<location>NYC]<]minimax[>[</location>
]<]minimax[>[</invoke>
]<]minimax[>[</invoke>"#;
let (calls, normal_text) = try_tool_call_parse_minimax_m3(input, &config, None).unwrap();
assert_eq!(normal_text.as_deref(), Some("prefix"));
assert_eq!(calls.len(), 1);
let (name, args) = call_name_and_args(&calls[0]);
assert_eq!(name, "get_weather");
assert_eq!(args["location"], "NYC");
}
#[test]
fn non_recovery_path_strips_bare_invoke_without_claiming_call() {
let config = MiniMaxM3ParserConfig::default();
let input = r#"prefix ]<]minimax[>[<invoke name="get_weather">
]<]minimax[>[<location>NYC]<]minimax[>[</location>
]<]minimax[>[</invoke>"#;
let (calls, normal_text) = try_tool_call_parse_minimax_m3(input, &config, None).unwrap();
assert!(calls.is_empty());
assert_eq!(normal_text.as_deref(), Some("prefix"));
}
#[test]
fn strips_incomplete_bare_invoke_marker_tail() {
let config = recovery_config();
let input = r#"prefix ]<]minimax[>[<invoke name="get_weather">
]<]minimax[>[<location>NY"#;
let (calls, normal_text) = try_tool_call_parse_minimax_m3(input, &config, None).unwrap();
assert!(calls.is_empty());
assert_eq!(normal_text.as_deref(), Some("prefix"));
}
#[test]
fn parses_nested_arguments_with_custom_namespace_token() {
let config = MiniMaxM3ParserConfig {
namespace_token: "NS|".to_string(),
..recovery_config()
};
let input = r#"NS|<tool_call>
NS|<invoke name="create_order">
NS|<shipping>NS|<city>SingaporeNS|</city>NS|<zip>18956NS|</zip>NS|</shipping>
NS|</invoke>
NS|</tool_call>"#;
let tools = vec![ToolDefinition {
name: "create_order".to_string(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"shipping": {
"type": "object",
"properties": {
"city": { "type": "string" },
"zip": { "type": "integer" }
}
}
}
})),
strict: None,
}];
let (calls, normal_text) =
try_tool_call_parse_minimax_m3(input, &config, Some(&tools)).unwrap();
assert_eq!(normal_text.as_deref(), Some(""));
assert_eq!(calls.len(), 1);
let (name, args) = call_name_and_args(&calls[0]);
assert_eq!(name, "create_order");
assert_eq!(args["shipping"]["city"], "Singapore");
assert_eq!(args["shipping"]["zip"], 18956);
}
}