common/chat: add specialized minimax m3 parser (#26210)

This commit is contained in:
Aldehir Rojas 2026-07-28 04:27:20 -05:00 committed by GitHub
parent d6b61ac0d3
commit 6ba5ef2470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1100 additions and 0 deletions

View file

@ -1056,3 +1056,141 @@ void common_chat_peg_gemma4_mapper::visit(const common_peg_ast_arena & arena, co
visit(arena, child_id);
}
}
static void minimax_m3_collect(const common_peg_ast_arena & arena,
const common_peg_ast_node & node,
const std::string & tag,
std::vector<common_peg_ast_id> & out) {
for (auto child_id : node.children) {
const auto & child = arena.get(child_id);
if (child.tag == tag) {
out.push_back(child_id);
} else {
minimax_m3_collect(arena, child, tag, out);
}
}
}
static common_peg_ast_id minimax_m3_value_of(const common_peg_ast_arena & arena, const common_peg_ast_node & node) {
for (auto child_id : node.children) {
const auto & tag = arena.get(child_id).tag;
if (tag == common_chat_peg_builder::TOOL_ARG_VALUE ||
tag == common_chat_peg_builder::TOOL_ARG_STRING_VALUE ||
tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_OBJECT ||
tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_ARRAY) {
return child_id;
}
}
return COMMON_PEG_INVALID_AST_ID;
}
static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed);
static std::string minimax_m3_member_to_json(const common_peg_ast_arena & arena, const common_peg_ast_node & node) {
auto name_id = arena.find_by_tag(node, common_chat_peg_builder::TOOL_ARG_NAME);
if (name_id == COMMON_PEG_INVALID_AST_ID) {
return "";
}
return ordered_json(arena.get(name_id).text).dump() + ":" +
minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, node), !node.is_partial);
}
static std::string minimax_m3_container_to_json(const common_peg_ast_arena & arena,
const common_peg_ast_node & node,
bool is_object,
bool closed) {
const std::string tag = is_object ? common_chat_peg_builder::TOOL_ARG
: common_chat_peg_minimax_m3_mapper::TOOL_ARG_ITEM;
std::vector<common_peg_ast_id> entries;
minimax_m3_collect(arena, node, tag, entries);
std::string result = is_object ? "{" : "[";
bool add_comma = false;
for (auto entry_id : entries) {
const auto & entry = arena.get(entry_id);
std::string text;
if (is_object) {
text = minimax_m3_member_to_json(arena, entry);
} else {
text = minimax_m3_value_to_json(arena, minimax_m3_value_of(arena, entry), !entry.is_partial);
}
if (text.empty()) {
continue;
}
if (add_comma) {
result += ",";
}
add_comma = true;
result += text;
}
if (closed) {
result += is_object ? "}" : "]";
}
return result;
}
static std::string minimax_m3_value_to_json(const common_peg_ast_arena & arena, common_peg_ast_id id, bool closed) {
if (id == COMMON_PEG_INVALID_AST_ID) {
return "";
}
const auto & node = arena.get(id);
if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_OBJECT) {
return minimax_m3_container_to_json(arena, node, /* is_object = */ true, closed);
}
if (node.tag == common_chat_peg_minimax_m3_mapper::TOOL_ARG_ARRAY) {
return minimax_m3_container_to_json(arena, node, /* is_object = */ false, closed);
}
if (node.tag == common_chat_peg_builder::TOOL_ARG_STRING_VALUE) {
return "\"" + escape_json_string_inner(std::string(node.text)) + (closed ? "\"" : "");
}
// Numbers and booleans are written verbatim by the template
return std::string(node.text);
}
void common_chat_peg_minimax_m3_mapper::from_ast(const common_peg_ast_arena & arena,
const common_peg_parse_result & result) {
for (const auto & node : result.nodes) {
visit(arena, node);
}
}
void common_chat_peg_minimax_m3_mapper::visit(const common_peg_ast_arena & arena, common_peg_ast_id id) {
const auto & node = arena.get(id);
if (node.tag == common_chat_peg_builder::REASONING) {
result.reasoning_content += std::string(node.text);
return;
}
if (node.tag == common_chat_peg_builder::CONTENT) {
result.content += std::string(node.text);
return;
}
if (node.tag == common_chat_peg_builder::TOOL) {
auto name_id = arena.find_by_tag(node, common_chat_peg_builder::TOOL_NAME);
if (name_id != COMMON_PEG_INVALID_AST_ID) {
common_chat_tool_call call;
call.name = std::string(arena.get(name_id).text);
call.arguments = minimax_m3_container_to_json(arena, node, /* is_object = */ true, !node.is_partial);
result.tool_calls.push_back(call);
}
return;
}
for (auto child_id : node.children) {
visit(arena, child_id);
}
}

View file

@ -40,6 +40,18 @@ class common_chat_peg_gemma4_mapper : public common_chat_peg_mapper {
void visit(const common_peg_ast_arena & arena, common_peg_ast_id id);
};
class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper {
public:
static constexpr const char * TOOL_ARG_OBJECT = "tool-arg-object";
static constexpr const char * TOOL_ARG_ARRAY = "tool-arg-array";
static constexpr const char * TOOL_ARG_ITEM = "tool-arg-item";
common_chat_peg_minimax_m3_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {}
virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result);
private:
void visit(const common_peg_ast_arena & arena, common_peg_ast_id id);
};
struct content_structure;
struct tool_call_structure;

View file

@ -816,6 +816,8 @@ const char * common_chat_format_name(common_chat_format format) {
return "peg-native";
case COMMON_CHAT_FORMAT_PEG_GEMMA4:
return "peg-gemma4";
case COMMON_CHAT_FORMAT_PEG_MINIMAX_M3:
return "peg-minimax-m3";
default:
throw std::runtime_error("Unknown chat format");
}
@ -2270,6 +2272,264 @@ static common_chat_params common_chat_params_init_cohere2moe(const common_chat_t
return data;
}
static common_chat_params common_chat_params_init_minimax_m3(const common_chat_template & tmpl,
const autoparser::generation_params & inputs) {
common_chat_params data;
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
data.format = COMMON_CHAT_FORMAT_PEG_MINIMAX_M3;
data.supports_thinking = true;
data.thinking_start_tag = "<mm:think>";
data.thinking_end_tags = {"</mm:think>"};
// M3 prefixes every tool tag with the namespace token "]<]minimax[>[";
// params use the parameter name as the tag (<file_path>...</file_path>).
const std::string NS = "]<]minimax[>[";
const std::string THINK_START = "<mm:think>";
const std::string THINK_END = "</mm:think>";
const std::string FC_START = NS + "<tool_call>";
const std::string FC_END = NS + "</tool_call>";
const std::string INVOKE_END = NS + "</invoke>";
data.preserved_tokens = {
NS,
"<tool_call>",
"</tool_call>",
THINK_START,
THINK_END,
};
data.message_delimiters = {
{ COMMON_CHAT_ROLE_ASSISTANT, "]~b]ai" },
{ COMMON_CHAT_ROLE_USER, "]~b]user" },
{ COMMON_CHAT_ROLE_TOOL, "]~b]tool" },
{ COMMON_CHAT_ROLE_SYSTEM, "]~b]developer" },
{ COMMON_CHAT_ROLE_SYSTEM, "]~b]system" },
};
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
const std::string GEN_PROMPT = data.generation_prompt;
using mm3 = common_chat_peg_minimax_m3_mapper;
if (inputs.has_continuation()) {
const auto & msg = inputs.continue_msg;
data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
data.generation_prompt += THINK_END + msg.render_content();
}
data.prompt += data.generation_prompt;
}
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
auto generation_prompt = p.prefix(GEN_PROMPT, THINK_START);
auto end = p.end();
auto reasoning = p.eps();
if (extract_reasoning) {
auto block = inputs.enable_thinking
? p.literal(THINK_START) + p.space() +
p.ac(p.reasoning(p.until(THINK_END)) + p.literal(THINK_END), THINK_END)
: p.literal(THINK_START) + p.ac(p.until(THINK_END) + p.literal(THINK_END), THINK_END);
// A turn without reasoning is prefixed with a bare </mm:think>, written either by the
// generation prompt (thinking_mode = "disabled") or by the model itself.
reasoning = p.optional(p.choice({ block, p.literal(THINK_END) }));
}
if (has_response_format) {
auto response_format = p.rule("response-format",
p.literal("```json") + p.space() +
p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)) +
p.space() + p.literal("```"));
return generation_prompt + reasoning + response_format + end;
}
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
return generation_prompt + reasoning + p.content(p.rest()) + end;
}
auto alternatives_of = [](const json & schema) -> std::optional<json> {
for (const auto * keyword : { "oneOf", "anyOf" }) {
if (schema.contains(keyword) && schema.at(keyword).is_array() && !schema.at(keyword).empty()) {
return schema.at(keyword);
}
}
return std::nullopt;
};
auto tool_choice = p.choice();
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
std::string name = function.at("name");
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
auto schema_info = common_schema_info();
schema_info.resolve_refs(params);
// The template expands argument values recursively in XML (see the to_xml() macro)
std::function<common_peg_parser(const json &, const std::string &, const std::string &)> value_of;
std::function<common_peg_parser(const json &, const std::string &)> members_of;
auto element_of = [&](const std::string & tag, const json & schema, const std::string & rule_name) {
const std::string close = NS + "</" + tag + ">";
return p.rule(rule_name,
p.tool_arg(
p.tool_arg_open(
p.literal(NS + "<") +
p.tool_arg_name(p.literal(tag)) +
p.literal(">")) +
value_of(schema, rule_name, close)));
};
value_of = [&](const json & schema,
const std::string & rule_name,
const std::string & close) -> common_peg_parser {
auto close_tag = p.tool_arg_close(p.literal(close));
// A string accepts anything, so a union with a string alternative is a string
if (schema_info.resolves_to_string(schema)) {
return p.ac(p.tool_arg_string_value(p.until(close)) + close_tag, close);
}
if (auto alternatives = alternatives_of(schema)) {
std::vector<common_peg_parser> choices;
size_t index = 0;
for (const auto & alternative : *alternatives) {
const std::string alt_name = rule_name + "-" + std::to_string(index++);
// There is a risk that this breaks streaming deltas, but that's a risk we
// assume to provide tool arg streaming.
choices.push_back(value_of(alternative, alt_name, close));
}
return p.choice(choices);
}
const std::string type = schema.contains("type") && schema.at("type").is_string()
? schema.at("type").get<std::string>()
: "";
if (type == "object" && schema.contains("properties")) {
return p.tag(mm3::TOOL_ARG_OBJECT, members_of(schema, rule_name)) + p.space() + close_tag;
}
if (type == "array" && schema.contains("items")) {
const std::string item_close = NS + "</item>";
auto item = p.rule(rule_name + "-item",
p.tag(mm3::TOOL_ARG_ITEM,
p.literal(NS + "<item>") +
value_of(schema.at("items"), rule_name + "-item", item_close)));
return p.tag(mm3::TOOL_ARG_ARRAY, p.repeat(p.space() + item, 0, -1)) + p.space() + close_tag;
}
return p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", schema, false)) + close_tag;
};
// Required properties in schema order, then any number of optional ones in any order.
members_of = [&](const json & schema, const std::string & rule_prefix) -> common_peg_parser {
const auto & props = schema.at("properties");
std::set<std::string> required;
if (schema.contains("required")) {
schema.at("required").get_to(required);
}
std::vector<common_peg_parser> required_elements;
std::vector<common_peg_parser> optional_elements;
for (const auto & [key, key_schema] : props.items()) {
auto element = element_of(key, key_schema, rule_prefix + "-" + key);
if (required.find(key) != required.end()) {
required_elements.push_back(element);
} else {
optional_elements.push_back(element);
}
}
common_peg_parser members = p.eps();
for (size_t i = 0; i < required_elements.size(); i++) {
if (i > 0) {
members = members + p.space();
}
members = members + required_elements[i];
}
if (!optional_elements.empty()) {
common_peg_parser any_optional = p.choice();
for (const auto & element : optional_elements) {
any_optional |= element;
}
members = members + p.repeat(p.space() + any_optional, 0, -1);
}
return members;
};
common_peg_parser invoke_body =
params.contains("properties") ? members_of(params, "tool-" + name + "-arg") : p.eps();
auto func_parser = p.tool(
p.tool_open(p.literal(NS + "<invoke name=\"") +
p.tool_name(p.literal(name)) + p.literal("\">")) +
p.space() + invoke_body + p.space() +
p.tool_close(p.literal(INVOKE_END)));
tool_choice |= p.rule("tool-" + name, func_parser);
});
auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
common_peg_parser tool_calls = p.eps();
if (inputs.parallel_tool_calls) {
tool_calls = p.trigger_rule("tool-call",
p.literal(FC_START) + p.space() + tool_choice +
p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
} else {
tool_calls = p.trigger_rule("tool-call",
p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
}
if (!require_tools) {
tool_calls = p.optional(tool_calls);
}
auto content_before_tools = p.content(p.until(FC_START));
return generation_prompt + reasoning + content_before_tools + tool_calls + end;
});
data.parser = parser.save();
if (include_grammar) {
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
data.grammar_triggers = {
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, FC_START },
};
}
return data;
}
namespace workaround {
static void map_developer_role_to_system(json & messages) {
@ -2707,6 +2967,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_gigachat_v3(tmpl, params);
}
// MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
// markup delimiters, so detect the template and use a dedicated parser.
if (src.find("]<]minimax[>[") != std::string::npos &&
src.find("<tool_call>") != std::string::npos &&
src.find("<invoke name=") != std::string::npos) {
LOG_DBG("Using specialized template: MiniMax-M3\n");
return common_chat_params_init_minimax_m3(tmpl, params);
}
// DeepSeek V3.2/V4 format detection: template defines dsml_token and uses it for tool calls.
// The template source contains the token as a variable assignment, not as a literal in markup.
// V3.2 names the tool call block "function_calls", V4 names it "tool_calls".
@ -2998,6 +3267,8 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
std::unique_ptr<common_chat_peg_mapper> mapper;
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
} else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) {
mapper = std::make_unique<common_chat_peg_minimax_m3_mapper>(msg);
} else {
mapper = std::make_unique<common_chat_peg_mapper>(msg);
}
@ -3020,6 +3291,8 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
std::unique_ptr<common_chat_peg_mapper> mapper;
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
} else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) {
mapper = std::make_unique<common_chat_peg_minimax_m3_mapper>(msg);
} else {
mapper = std::make_unique<common_chat_peg_mapper>(msg);
}

View file

@ -233,6 +233,7 @@ enum common_chat_format {
COMMON_CHAT_FORMAT_PEG_SIMPLE,
COMMON_CHAT_FORMAT_PEG_NATIVE,
COMMON_CHAT_FORMAT_PEG_GEMMA4,
COMMON_CHAT_FORMAT_PEG_MINIMAX_M3,
COMMON_CHAT_FORMAT_COUNT, // Not a format, just the # formats
};

View file

@ -0,0 +1,247 @@
{# ---------- special token variables ---------- #}
{%- set ns_token = ']<]minimax[>[' -%}
{%- set bod_token = ']~!b[' -%}
{%- set bos_token = ']~b]' -%}
{%- set eos_token = '[e~[' -%}
{%- set toolcall_begin_token = ns_token ~ '<tool_call>' -%}
{%- set toolcall_end_token = ns_token ~ '</tool_call>' -%}
{%- set think_begin_token = '<mm:think>' -%}
{%- set think_end_token = '</mm:think>' -%}
{%- set image_token = ']<]image[>[' -%}
{%- set video_token = ']<]video[>[' -%}
{#- Thinking mode: "enabled" / "disabled" / "adaptive" / not defined -#}
{#- Recursive XML renderer for tool_call arguments ======================== -#}
{#- None values are intentionally skipped in mapping iteration so that
`<key>null</key>` (which would round-trip to the literal string "null")
never appears in the rendered tool_call. The convention is: omit the
field entirely. The top-level `_args` loop applies the same rule.
The `val is none` branch below is a safety net only — upstream cleaning
(drop_none_in_tool_arguments) should ensure no None ever reaches here. -#}
{%- macro to_xml(val, ns) -%}
{%- if val is mapping -%}
{%- for k, v in val.items() if v is not none -%}
{{ ns }}<{{ k }}>{{ to_xml(v, ns) }}{{ ns }}</{{ k }}>
{%- endfor -%}
{%- elif val is iterable and val is not string -%}
{%- for item in val -%}
{{ ns }}<item>{{ to_xml(item, ns) }}{{ ns }}</item>
{%- endfor -%}
{%- elif val is none -%}
{#- Should be unreachable when upstream cleaning is applied. -#}
{%- elif val is boolean -%}
{{ val | tojson }}
{%- else -%}
{{ val }}
{%- endif -%}
{%- endmacro -%}
{#- Tool Rendering Functions ============================================== -#}
{%- macro render_tool_namespace(namespace_name, tool_list) -%}
{%- for tool in tool_list -%}
<tool>{{ tool.function | tojson(ensure_ascii=False) }}</tool>
{% endfor -%}
{%- endmacro -%}
{%- macro visible_text(content) -%}
{%- if content is string -%}
{{ content }}
{%- elif content is iterable and content is not mapping -%}
{%- for item in content -%}
{%- if item is mapping and item.type == 'text' -%}
{{- item.text }}
{%- elif item is mapping and item.type == 'image' -%}
{{- image_token }}
{%- elif item is mapping and item.type == 'video' -%}
{{- video_token}}
{%- elif item is string -%}
{{- item }}
{%- endif -%}
{%- endfor -%}
{%- elif content is none -%}
{{- '' }}
{%- else -%}
{{- content }}
{%- endif -%}
{%- endmacro -%}
{#- System Message Construction ============================================ -#}
{%- macro build_system_message(system_message) -%}
{%- if system_message and system_message.content -%}
{{- visible_text(system_message.content) }}
{%- else -%}
{{- 'Your model version is MiniMax-M3, developed by MiniMax. Knowledge cutoff: January 2026. Founded in early 2022, MiniMax is a global AI foundation model company committed to advancing the frontiers of AI towards AGI.' }}
{%- endif -%}
{#- Thinking mode instructions -#}
{{- '\n\n<thinking_instructions>\n' }}
{{- 'You have a thinking capability that allows you to reason step by step before responding. When thinking is enabled, wrap your reasoning in ' ~ think_begin_token ~ think_end_token ~ ' tags before your response. When thinking is disabled, begin your response directly after the ' ~ think_end_token ~ ' prefix. When thinking is adaptive, decide on your own whether to think for the current turn.\n' }}
{%- if thinking_mode is defined -%}
{%- if thinking_mode == "enabled" -%}
{{- 'Current thinking mode: enabled. You MUST think step by step before every response, including after receiving function/tool results.\n' }}
{%- elif thinking_mode == "disabled" -%}
{{- 'Current thinking mode: disabled. Do not output any thinking process.\n' }}
{%- elif thinking_mode == "adaptive" -%}
{{- 'Current thinking mode: adaptive. You are encouraged to think for complex decision-making, multi-step reasoning, or when analyzing function/tool results.\n' }}
{%- endif -%}
{%- else -%}
{{- 'Current thinking mode: adaptive. You are encouraged to think for complex decision-making, multi-step reasoning, or when analyzing function/tool results.\n' }}
{%- endif -%}
{{- '</thinking_instructions>' }}
{%- endmacro -%}
{%- macro build_developer_message(developer_message) -%}
{%- if developer_message and developer_message.content -%}
{{- visible_text(developer_message.content) }}
{%- else -%}
{%- if model_identity is not defined -%}
{%- set model_identity = "You are a helpful assistant." -%}
{%- endif -%}
{{- model_identity }}
{%- endif -%}
{%- endmacro -%}
{#- Main Template Logic ================================================= -#}
{#- Role mapping: root -> system sp (high priority), system/developer -> developer sp (low priority) -#}
{%- set system_message = none -%}
{%- set developer_message = none -%}
{%- set conversation_messages = messages -%}
{%- if messages and messages[0].role == "root" -%}
{%- set system_message = messages[0] -%}
{%- set conversation_messages = messages[1:] -%}
{%- if conversation_messages and conversation_messages[0].role in ["system", "developer"] -%}
{%- set developer_message = conversation_messages[0] -%}
{%- set conversation_messages = conversation_messages[1:] -%}
{%- endif -%}
{%- elif messages and messages[0].role in ["system", "developer"] -%}
{%- set developer_message = messages[0] -%}
{%- set conversation_messages = messages[1:] -%}
{%- endif -%}
{#- Render system sp (higher priority, root role only) -#}
{{- bod_token ~ bos_token ~ 'system' ~ '\n' }}
{{- build_system_message(system_message) }}
{{- eos_token ~ '\n' }}
{#- Render developer sp (lower priority: system/developer role + tools) -#}
{{- bos_token ~ 'developer' ~ '\n' }}
{{- build_developer_message(developer_message) }}
{%- if tools -%}
{{- '\n\n' ~ '# Tools' ~ '\n' ~ 'You may call one or more tools to assist with the user query.\nHere are the tools available in JSONSchema format:' ~ '\n' }}
{{- '\n' ~ '<tools>' ~ '\n' }}
{{- render_tool_namespace("functions", tools) }}
{{- '</tools>' ~ '\n\n' }}
{{- 'To call tools, wrap all invocations in a single ' ~ toolcall_begin_token ~ toolcall_end_token ~ ' block. Parameter values containing nested objects or arrays are recursively expanded into XML elements. Example:\n' }}
{{- '\n' ~ toolcall_begin_token ~ '\n' }}
{{- ns_token + '<invoke name="tool-name-1">' }}
{{- ns_token + '<param-1>value-1' + ns_token + '</param-1>' }}
{{- ns_token + '<param-2>' }}
{{- ns_token + '<item>' }}
{{- ns_token + '<key-a>val-a' + ns_token + '</key-a>' }}
{{- ns_token + '<key-b>val-b' + ns_token + '</key-b>' }}
{{- ns_token + '</item>' }}
{{- ns_token + '</param-2>' }}
{{- ns_token + '</invoke>\n' }}
{{- ns_token + '<invoke name="tool-name-2">' }}
{{- ns_token + '<param-1>value-1' + ns_token + '</param-1>' }}
{{- ns_token + '</invoke>\n' }}
{{- toolcall_end_token }}
{%- endif -%}
{{- eos_token ~ '\n' }}
{#- Render messages -#}
{%- set last_tool_call = namespace(name=none) -%}
{%- for message in conversation_messages -%}
{%- if message.role == 'assistant' -%}
{{- bos_token ~ 'ai' ~ '\n' }}
{%- set reasoning_content = '' %}
{%- set content = visible_text(message.content) %}
{%- if message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- else %}
{%- if think_end_token in content %}
{%- set reasoning_content = content.split(think_end_token)[0].strip('\n').split(think_begin_token)[-1].strip('\n') %}
{%- set content = content.split(think_end_token)[-1].strip('\n') %}
{%- endif %}
{%- endif %}
{%- if reasoning_content -%}
{#- Render thinking for every assistant turn (all-turn visible) -#}
{{- think_begin_token ~ reasoning_content ~ think_end_token }}
{%- else -%}
{#- No thinking rendered → prefix with think_end_token -#}
{{- think_end_token }}
{%- endif -%}
{%- if content -%}
{{- content }}
{%- endif -%}
{%- if message.tool_calls -%}
{{- toolcall_begin_token ~ '\n' }}
{%- for tool_call in message.tool_calls -%}
{%- if tool_call.function -%}
{%- set tool_call = tool_call.function -%}
{%- endif -%}
{{- ns_token + '<invoke name="' + tool_call.name + '">' }}
{%- set _args = tool_call.arguments -%}
{%- for k, v in _args.items() if v is not none %}
{{- ns_token + '<' + k + '>' -}}
{{- to_xml(v, ns_token) -}}
{{- ns_token + '</' + k + '>' }}
{%- endfor -%}
{{- ns_token + '</invoke>' ~ '\n' }}
{%- endfor -%}
{{- toolcall_end_token }}
{%- if message.tool_calls[-1].function -%}
{%- set last_tool_call.name = message.tool_calls[-1].function.name -%}
{%- else -%}
{%- set last_tool_call.name = message.tool_calls[-1].name -%}
{%- endif -%}
{%- else -%}
{%- set last_tool_call.name = none -%}
{%- endif -%}
{{- eos_token ~ '\n' }}
{%- elif message.role == 'tool' -%}
{%- if last_tool_call.name is none -%}
{{- raise_exception("Message has tool role, but there was no previous assistant message with a tool call!") }}
{%- endif -%}
{%- if loop.first or (conversation_messages[loop.index0 - 1].role != 'tool') -%}
{{- bos_token ~ 'tool' }}
{%- endif -%}
{{- '\n<response>' }}
{%- if message.content is string -%}
{{- message.content }}
{%- else -%}
{%- for tr in message.content -%}
{%- if tr is mapping and tr.type is defined and tr.type == 'image' -%}
{{- image_token }}
{%- elif tr is mapping and tr.type is defined and tr.type == 'video' -%}
{{- video_token }}
{%- else -%}
{{- tr.output if tr.output is defined else (tr.text if tr.type == 'text' and tr.text is defined else tr) }}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{{- '</response>' }}
{%- if loop.last or (conversation_messages[loop.index0 + 1].role != 'tool') -%}
{{- eos_token ~ '\n' -}}
{%- endif -%}
{%- elif message.role == 'user' -%}
{{- bos_token ~ 'user' ~ '\n' }}
{{- visible_text(message.content) }}
{{- eos_token ~ '\n' }}
{%- endif -%}
{%- endfor -%}
{#- Generation prompt -#}
{%- if add_generation_prompt -%}
{{- bos_token ~ 'ai' ~ '\n' }}
{%- if thinking_mode is defined and thinking_mode == "disabled" -%}
{{- think_end_token }}
{%- elif thinking_mode is defined and thinking_mode == "adaptive" -%}
{#- adaptive: no prefix, let model decide -#}
{%- elif thinking_mode is defined and thinking_mode == "enabled" -%}
{#- enabled or not defined: default to think -#}
{{- think_begin_token }}
{%- else -%}
{#- adaptive: no prefix, let model decide -#}
{%- endif -%}
{%- endif -%}

View file

@ -730,6 +730,71 @@ static common_chat_tool imaginary_number_tool{
})",
};
static common_chat_tool nested_args_tool{
/* .name = */ "nested_args",
/* .description = */ "Tool with nested array arguments",
/* .parameters = */ R"({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": { "type": "string" }
},
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"label": { "type": "string" }
},
"required": ["id", "label"]
}
}
},
"required": ["tags", "entries"]
})",
};
static common_chat_tool union_args_tool{
/* .name = */ "union_args",
/* .description = */ "Tool with union arguments",
/* .parameters = */ R"({
"type": "object",
"properties": {
"filter": {
"anyOf": [
{ "type": "array", "items": { "type": "string" } },
{
"type": "object",
"properties": {
"field": { "type": "string" },
"op": { "type": "string" }
},
"required": ["field", "op"]
}
]
},
"label": {
"oneOf": [
{ "type": "string" },
{ "type": "object", "properties": { "text": { "type": "string" } } }
]
},
"limit": {
"oneOf": [
{ "type": "integer" },
{
"type": "object",
"properties": { "max": { "type": "integer" } },
"required": ["max"]
}
]
}
}
})",
};
static common_chat_tool nullable_string_tool{
/* .name = */ "set_nullable_str",
/* .description = */ "Set a nullable string value",
@ -4850,6 +4915,370 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
.run();
}
// MiniMax-M3 tests - namespaced XML invoke format, the parameter name is the tag
// Format:
// ]<]minimax[>[<tool_call>
// ]<]minimax[>[<invoke name="get_time">]<]minimax[>[<city>Tokyo]<]minimax[>[</city>]<]minimax[>[</invoke>
// ]<]minimax[>[</tool_call>
// Reasoning uses <mm:think>...</mm:think>. The generation prompt is only "]~b]ai\n", so the model
// opens the thinking block itself; a turn without reasoning is prefixed with a bare </mm:think>.
{
auto tst = peg_tester("models/templates/MiniMax-M3.jinja", detailed_debug);
// Content only (bare </mm:think> prefix)
tst.test("</mm:think>Hello, world!\nWhat's up?")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.expect(message_assist)
.expect_reconstruction()
.run();
// Thinking + content
tst.test("<mm:think>I'm\nthinking</mm:think>Hello, world!\nWhat's up?")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.expect(message_assist_thoughts)
.expect_reconstruction()
.run();
// Thinking + tool call (single, string param)
tst.test(
"<mm:think>Let me check the time</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"get_time\">"
"]<]minimax[>[<city>Tokyo]<]minimax[>[</city>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ get_time_tool })
.expect(message_with_tool_calls_and_reasoning("get_time", R"({"city": "Tokyo"})", "Let me check the time"))
.expect_reconstruction()
.run();
// Tool call without reasoning, integer param
tst.test(
"</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"special_function\">"
"]<]minimax[>[<arg1>1]<]minimax[>[</arg1>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ special_function_tool })
.expect(message_assist_call)
.expect_reconstruction()
.run();
// Tool call with no parameters
tst.test(
"<mm:think>Let's call a tool:</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"empty_args\">"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ empty_args_tool })
.expect(message_with_reasoning_and_tool_call("Let's call a tool:", "empty_args", "{}"))
.expect_reconstruction()
.run();
// Multiple parallel tool calls in one block
tst.test(
"<mm:think>Calling both</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"get_time\">"
"]<]minimax[>[<city>Paris]<]minimax[>[</city>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[<invoke name=\"get_weather\">"
"]<]minimax[>[<city>Paris]<]minimax[>[</city>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.parallel_tool_calls(true)
.tools({ get_time_tool, get_weather_tool })
.expect(message_with_reasoning_content_and_multiple_tool_calls(
"Calling both", "",
{ { "get_time", R"({"city": "Paris"})" }, { "get_weather", R"({"city": "Paris"})" } }))
.expect_reconstruction()
.run();
// Content before the tool call block
tst.test(
"<mm:think>Thinking about it</mm:think>"
"Let me call the function."
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"special_function\">"
"]<]minimax[>[<arg1>1]<]minimax[>[</arg1>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ special_function_tool })
.expect_reasoning("Thinking about it")
.expect_content("Let me call the function.")
.expect_tool_calls({
{ "special_function", R"({"arg1": 1})", {} },
})
.expect_reconstruction()
.run();
// Negative number
tst.test(
"<mm:think>Test negative</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"magic_int\">"
"]<]minimax[>[<ref>-14]<]minimax[>[</ref>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ magic_int_tool })
.expect_reasoning("Test negative")
.expect_tool_calls({
{ "magic_int", R"({"ref": -14})", {} },
})
.expect_reconstruction()
.run();
// Decimal number
tst.test(
"<mm:think>Test decimal</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"amount\">"
"]<]minimax[>[<orig>3.14]<]minimax[>[</orig>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ amount_tool })
.expect_reasoning("Test decimal")
.expect_tool_calls({
{ "amount", R"({"orig": 3.14})", {} },
})
.expect_reconstruction()
.run();
// Boolean
tst.test(
"<mm:think>Test boolean</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"toggle\">"
"]<]minimax[>[<enabled>true]<]minimax[>[</enabled>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ toggle_tool })
.expect_reasoning("Test boolean")
.expect_tool_calls({
{ "toggle", R"({"enabled": true})", {} },
})
.expect_reconstruction()
.run();
// Multiple params of mixed types (required int first, then optional string)
tst.test(
"<mm:think>Multi-arg call</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"magic_int\">"
"]<]minimax[>[<ref>42]<]minimax[>[</ref>"
"]<]minimax[>[<name>foo bar]<]minimax[>[</name>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ magic_int_tool })
.expect_reasoning("Multi-arg call")
.expect_tool_calls({
{ "magic_int", R"({"ref": 42, "name": "foo bar"})", {} },
})
.expect_reconstruction()
.run();
// Nested object param, expanded into one element per key
tst.test(
"<mm:think>Nested object</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"imaginary_number\">"
"]<]minimax[>[<number>"
"]<]minimax[>[<real>1.5]<]minimax[>[</real>"
"]<]minimax[>[<imaginary>-2.5]<]minimax[>[</imaginary>"
"]<]minimax[>[</number>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ imaginary_number_tool })
.expect_reasoning("Nested object")
.expect_tool_calls({
{ "imaginary_number", R"({"number": {"real": 1.5, "imaginary": -2.5}})", {} },
})
.expect_reconstruction()
.run();
// Array params, expanded into <item> elements (of scalars and of objects)
tst.test(
"<mm:think>Nested arrays</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"nested_args\">"
"]<]minimax[>[<tags>"
"]<]minimax[>[<item>alpha]<]minimax[>[</item>"
"]<]minimax[>[<item>beta]<]minimax[>[</item>"
"]<]minimax[>[</tags>"
"]<]minimax[>[<entries>"
"]<]minimax[>[<item>"
"]<]minimax[>[<id>1]<]minimax[>[</id>"
"]<]minimax[>[<label>one]<]minimax[>[</label>"
"]<]minimax[>[</item>"
"]<]minimax[>[</entries>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ nested_args_tool })
.expect_reasoning("Nested arrays")
.expect_tool_calls({
{ "nested_args", R"({"tags": ["alpha", "beta"], "entries": [{"id": 1, "label": "one"}]})", {} },
})
.expect_reconstruction()
.run();
// Union params (anyOf/oneOf), expanded as a choice of the alternatives
tst.test(
"<mm:think>Union array</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"union_args\">"
"]<]minimax[>[<filter>"
"]<]minimax[>[<item>alpha]<]minimax[>[</item>"
"]<]minimax[>[<item>beta]<]minimax[>[</item>"
"]<]minimax[>[</filter>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ union_args_tool })
.expect_reasoning("Union array")
.expect_tool_calls({
{ "union_args", R"({"filter": ["alpha", "beta"]})", {} },
})
.expect_reconstruction()
.run();
// oneOf between a scalar and an object
tst.test(
"<mm:think>Union scalar</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"union_args\">"
"]<]minimax[>[<limit>5]<]minimax[>[</limit>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ union_args_tool })
.expect_reasoning("Union scalar")
.expect_tool_calls({
{ "union_args", R"({"limit": 5})", {} },
})
.expect_reconstruction()
.run();
tst.test(
"<mm:think>Union nested</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"union_args\">"
"]<]minimax[>[<limit>"
"]<]minimax[>[<max>10]<]minimax[>[</max>"
"]<]minimax[>[</limit>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ union_args_tool })
.expect_reasoning("Union nested")
.expect_tool_calls({
{ "union_args", R"({"limit": {"max": 10}})", {} },
})
.expect_reconstruction()
.run();
// A union with a string alternative is a string
tst.test(
"<mm:think>Union string</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"union_args\">"
"]<]minimax[>[<label>hi]<]minimax[>[</label>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ union_args_tool })
.expect_reasoning("Union string")
.expect_tool_calls({
{ "union_args", R"({"label": "hi"})", {} },
})
.expect_reconstruction()
.run();
// ... even when the value looks structured
tst.test(
"<mm:think>Union string</mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"union_args\">"
"]<]minimax[>[<label>"
"]<]minimax[>[<text>hi]<]minimax[>[</text>"
"]<]minimax[>[</label>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ union_args_tool })
.expect_reasoning("Union string")
.expect_tool_calls({
{ "union_args", R"({"label": "]<]minimax[>[<text>hi]<]minimax[>[</text>"})", {} },
})
.expect_reconstruction()
.run();
// Edge case: empty reasoning followed by a tool call
tst.test(
"<mm:think></mm:think>"
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"get_time\">"
"]<]minimax[>[<city>XYZCITY]<]minimax[>[</city>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>")
.enable_thinking(true)
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.tools({ get_time_tool })
.expect(message_with_tool_calls("get_time", R"({"city": "XYZCITY"})"))
.run();
// Continuation tests
tst.test("world!\nWhat's up?")
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.enable_thinking(true)
.messages({ message_user, message_assist_prefill_content })
.add_generation_prompt(false)
.continue_final_message(COMMON_CHAT_CONTINUATION_CONTENT)
.expect_reasoning("I'm thinking")
.expect_content("Hello, world!\nWhat's up?")
.run();
tst.test(" thinking</mm:think>Hello, world!\nWhat's up?")
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
.enable_thinking(true)
.messages({ message_user, message_assist_prefill_reasoning })
.add_generation_prompt(false)
.continue_final_message(COMMON_CHAT_CONTINUATION_REASONING)
.expect_reasoning("I'm thinking")
.expect_content("Hello, world!\nWhat's up?")
.run();
}
// NVIDIA-Nemotron-Nano-v2 tests - <TOOLCALL>...</TOOLCALL> format
// Format: <TOOLCALL>[{"name": "func", "arguments": {...}}]</TOOLCALL>
{