fix: MiniMax-M3 streaming parser when tool calls start before </mm:think> (#2085)

* fix: improve MiniMax-M3 streaming parser

* fix: guard MiniMax-M3 tool args against marker leaks

---------

Co-authored-by: Smart <smart@augmented-special.services>
This commit is contained in:
Jun Yamog
2026-07-09 18:12:47 +03:00
committed by GitHub
co-authored by Smart
parent 6d30fa2fe6
commit 3bb0e9f09c
6 changed files with 311 additions and 14 deletions
+52
View File
@@ -75,6 +75,29 @@ static std::string escape_json_string_inner(const std::string & s) {
return escaped;
}
static bool truncate_json_strings_at_marker(ordered_json & value, const std::string & marker) {
bool changed = false;
if (value.is_string()) {
std::string s = value.get<std::string>();
size_t p = s.find(marker);
if (p != std::string::npos) {
value = s.substr(0, p);
changed = true;
}
} else if (value.is_array()) {
for (auto & item : value) {
changed = truncate_json_strings_at_marker(item, marker) || changed;
}
} else if (value.is_object()) {
for (auto & item : value.items()) {
changed = truncate_json_strings_at_marker(item.value(), marker) || changed;
}
}
return changed;
}
// Convert Python-style single-quoted strings to JSON double-quoted strings
// Only converts outer string delimiters, properly handling escape sequences:
// - {'key': 'value'} -> {"key": "value"}
@@ -249,6 +272,35 @@ void common_chat_peg_mapper::from_ast(const common_peg_ast_arena & arena,
}
}
void common_chat_peg_minimax_m3_mapper::from_ast(const common_peg_ast_arena & arena,
const common_peg_parse_result & parse_result) {
common_chat_peg_mapper::from_ast(arena, parse_result);
// MiniMax-M3 tool calls are emitted from the thinking/action phase.
if (!result.tool_calls.empty() && !result.content.empty()) {
result.reasoning_content += result.content;
result.content.clear();
}
// MiniMax-M3 sometimes starts emitting a new namespaced tool marker before
// closing a string argument. Keep that marker out of executable tool text.
static const std::string ns_marker = "]<]minimax[>[";
for (auto & tool_call : result.tool_calls) {
if (tool_call.arguments.find(ns_marker) == std::string::npos) {
continue;
}
try {
ordered_json args = ordered_json::parse(tool_call.arguments);
if (truncate_json_strings_at_marker(args, ns_marker)) {
tool_call.arguments = args.dump();
}
} catch (const ordered_json::exception &) {
// Leave malformed JSON to existing downstream validation.
}
}
}
void common_chat_peg_mapper::map(const common_peg_ast_node & node) {
// Handle reasoning/content tags
bool is_reasoning = node.tag == common_chat_peg_builder::REASONING;
+6 -1
View File
@@ -40,6 +40,12 @@ 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:
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);
};
struct content_structure;
struct tool_call_structure;
@@ -195,4 +201,3 @@ struct tagged_peg_parser {
tagged_peg_parser build_tagged_peg_parser(
const std::function<common_peg_parser(common_peg_parser_builder & builder)> & fn);
+62 -12
View File
@@ -162,12 +162,31 @@ std::vector<common_chat_msg_diff> common_chat_msg_diff::compute_diffs(const comm
// TODO: these can become expensive for long messages - how to optimize?
if (msg_prv.reasoning_content != msg_new.reasoning_content) {
auto & diff = diffs.emplace_back();
diff.reasoning_content_delta = string_diff(msg_prv.reasoning_content, msg_new.reasoning_content);
std::string reasoning_content_delta = string_diff(msg_prv.reasoning_content, msg_new.reasoning_content);
// Partial parsing can reclassify bytes that were previously streamed as
// content into reasoning_content once more context arrives. Streaming
// deltas are append-only, so remove that already-streamed content prefix
// from the reasoning delta instead of emitting the same bytes twice.
if (!msg_prv.content.empty() &&
string_starts_with(reasoning_content_delta, msg_prv.content) &&
string_starts_with(msg_prv.content, msg_new.content)) {
reasoning_content_delta = reasoning_content_delta.substr(msg_prv.content.size());
}
// string_diff() can return an empty delta for shrink/reclassification
// cases. Do not emit empty append-only chunks.
if (!reasoning_content_delta.empty()) {
auto & diff = diffs.emplace_back();
diff.reasoning_content_delta = std::move(reasoning_content_delta);
}
}
if (msg_prv.content != msg_new.content) {
auto & diff = diffs.emplace_back();
diff.content_delta = string_diff(msg_prv.content, msg_new.content);
std::string content_delta = string_diff(msg_prv.content, msg_new.content);
// Same empty-delta rule as above, without the reasoning/content overlap
// trim: there may be no new content bytes to append.
if (!content_delta.empty()) {
auto & diff = diffs.emplace_back();
diff.content_delta = std::move(content_delta);
}
}
if (msg_new.tool_calls.size() < msg_prv.tool_calls.size()) {
@@ -680,6 +699,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");
}
@@ -1938,7 +1959,7 @@ static common_chat_params common_chat_params_init_minimax_m3(const common_chat_t
common_chat_params data;
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
data.format = COMMON_CHAT_FORMAT_PEG_MINIMAX_M3;
data.supports_thinking = true;
data.thinking_start_tag = "<mm:think>";
data.thinking_end_tag = "</mm:think>";
@@ -1967,11 +1988,35 @@ static common_chat_params common_chat_params_init_minimax_m3(const common_chat_t
auto generation_prompt = p.prefix(inputs.generation_prompt, THINK_START);
auto end = p.end();
auto thinking_body = [&]() {
return has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE
? p.until_one_of({ THINK_END, FC_START })
: p.until(THINK_END);
};
auto reasoning = p.eps();
if (extract_reasoning && inputs.enable_thinking) {
reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.reasoning(p.until(THINK_END)) + THINK_END);
} else if (extract_reasoning) {
reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.until(THINK_END) + p.literal(THINK_END));
if (extract_reasoning) {
// The generation prompt usually opens <mm:think>, but MiniMax-M3 may
// also emit an unopened block ending in </mm:think>. Keep both forms
// quarantined as reasoning instead of leaking them into visible text.
auto opened = p.literal(THINK_START) +
p.reasoning(thinking_body()) +
p.optional(p.literal(THINK_END));
auto unopened = p.reasoning(thinking_body()) + p.literal(THINK_END);
reasoning = p.optional(p.choice({ opened, unopened }));
if (inputs.enable_thinking) {
// During streaming, the closing tag may not have arrived yet; if
// a tool call starts first, stop reasoning at the tool marker.
auto partial = p.reasoning(thinking_body());
reasoning = p.optional(p.choice({ opened, unopened, partial }));
}
} else if (inputs.enable_thinking) {
auto opened = p.content(p.literal(THINK_START) +
thinking_body() +
p.optional(p.literal(THINK_END)));
auto unopened = p.content(thinking_body()) + p.literal(THINK_END);
reasoning = p.optional(p.choice({ opened, unopened }));
}
if (has_response_format) {
@@ -2056,16 +2101,17 @@ static common_chat_params common_chat_params_init_minimax_m3(const common_chat_t
auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
common_peg_parser tool_calls = p.eps();
common_peg_parser tool_call_envelope = p.eps();
if (inputs.parallel_tool_calls) {
tool_calls = p.trigger_rule("tool-call",
tool_call_envelope = 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",
tool_call_envelope = p.trigger_rule("tool-call",
p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
}
auto tool_calls = tool_call_envelope;
if (!require_tools) {
tool_calls = p.optional(tool_calls);
}
@@ -2780,6 +2826,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);
}
@@ -2801,6 +2849,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);
}
+1
View File
@@ -160,6 +160,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
};
+189
View File
@@ -65,6 +65,9 @@ static void test_cohere_analysis(testing & t);
// End-to-end Cohere2MoE (North Code) dedicated PEG parser coverage.
static void test_cohere2moe_parser(testing & t);
// End-to-end MiniMax-M3 dedicated PEG parser coverage.
static void test_minimax_m3_partial_reasoning_parser(testing & t);
// SmolLM3 template analysis tests
static void test_smollm3_analysis(testing & t);
@@ -102,6 +105,7 @@ int main(int argc, char * argv[]) {
t.test("seed_oss_diffs", test_seed_oss_tool_analysis);
t.test("cohere", test_cohere_analysis);
t.test("cohere2moe_parser", test_cohere2moe_parser);
t.test("minimax_m3_partial_reasoning_parser", test_minimax_m3_partial_reasoning_parser);
t.test("nemotron", test_nemotron_analysis);
t.test("smollm3", test_smollm3_analysis);
t.test("standard_json_tools", test_standard_json_tools_formats);
@@ -1971,6 +1975,191 @@ static void test_tagged_args_with_embedded_quotes(testing & t) {
}
}
// End-to-end coverage for MiniMax-M3 partial reasoning streams:
// template apply -> PEG parse -> assert message and streaming diff. The partial
// case mirrors a real leaked <mm:think> stream where </mm:think> had not arrived.
static void test_minimax_m3_partial_reasoning_parser(testing & t) {
const std::string template_str = R"(
{%- set ns_token = ']<]minimax[>[' -%}
{%- set toolcall_begin_token = ns_token ~ '<tool_call>' -%}
{%- set toolcall_end_token = ns_token ~ '</tool_call>' -%}
{%- for message in messages -%}
{{- message.role ~ ': ' ~ message.content ~ '\n' -}}
{%- endfor -%}
{%- if tools -%}
{{- toolcall_begin_token ~ ns_token ~ '<invoke name="example">' ~ ns_token ~ '</invoke>' ~ toolcall_end_token -}}
{%- endif -%}
{%- if add_generation_prompt -%}
{{- '<mm:think>' -}}
{%- endif -%}
)";
common_chat_templates_inputs inputs;
common_chat_msg user;
user.role = "user";
user.content = "Please inspect the extension state.";
inputs.messages = { user };
inputs.add_generation_prompt = true;
inputs.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK;
inputs.enable_thinking = true;
inputs.tools = {
common_chat_tool{
/* .name = */ "example",
/* .description = */ "Example tool",
/* .parameters = */ R"({"type":"object","properties":{}})",
},
common_chat_tool{
/* .name = */ "bash",
/* .description = */ "Run shell commands",
/* .parameters = */ R"({"type":"object","properties":{"command":{"type":"string"}},"required":["command"]})",
},
};
common_chat_templates_ptr tmpls(common_chat_templates_init(/* model = */ nullptr, template_str));
auto params = common_chat_templates_apply(tmpls.get(), inputs);
t.assert_equal("MiniMax-M3 parser selected", COMMON_CHAT_FORMAT_PEG_MINIMAX_M3, params.format);
t.assert_true("MiniMax-M3 parser generated", !params.parser.empty());
common_peg_arena arena;
arena.load(params.parser);
common_chat_parser_params parser_params(params);
parser_params.parser = arena;
const std::string partial_reasoning =
"The user is asking why I'm implementing F3 directly. Looking at the context:\n\n"
"1. The user said earlier: \"yes we do F3 manually, by";
auto partial_msg = common_chat_parse(partial_reasoning, /* is_partial = */ true, parser_params);
t.assert_equal("partial reasoning", partial_reasoning, partial_msg.reasoning_content);
t.assert_equal("partial visible content", std::string(), partial_msg.content);
t.assert_true("partial has no tool calls", partial_msg.tool_calls.empty());
auto diffs = common_chat_msg_diff::compute_diffs(common_chat_msg{}, partial_msg);
t.assert_equal("partial emits one diff", 1u, diffs.size());
if (diffs.size() == 1) {
t.assert_equal("partial diff is reasoning", partial_reasoning, diffs[0].reasoning_content_delta);
t.assert_equal("partial diff has no visible content", std::string(), diffs[0].content_delta);
t.assert_equal("partial diff has no tool index", std::string::npos, diffs[0].tool_call_index);
}
const std::string closed_output = partial_reasoning + "</mm:think>You're right to question.";
auto closed_msg = common_chat_parse(closed_output, /* is_partial = */ false, parser_params);
t.assert_equal("closed reasoning", partial_reasoning, closed_msg.reasoning_content);
t.assert_equal("closed visible content", std::string("You're right to question."), closed_msg.content);
t.assert_true("closed has no tool calls", closed_msg.tool_calls.empty());
const std::string command =
"# Deploy to electra and erabus\n"
"SSH_AUTH_SOCK=/run/user/1001/ssh-agent.socket scp index.ts electra:/tmp/index.ts";
const std::string tool_output =
"Let me do that."
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"bash\">"
"]<]minimax[>[<command>" + command + "]<]minimax[>[</command>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>";
auto tool_msg = common_chat_parse(tool_output, /* is_partial = */ false, parser_params);
t.assert_equal("tool reasoning excludes markers", std::string("Let me do that."), tool_msg.reasoning_content);
t.assert_equal("tool visible content", std::string(), tool_msg.content);
t.assert_equal("tool call count", 1u, tool_msg.tool_calls.size());
if (tool_msg.tool_calls.size() == 1) {
t.assert_equal("tool call name", std::string("bash"), tool_msg.tool_calls[0].name);
t.assert_equal("tool call args", json({{"command", command}}).dump(), tool_msg.tool_calls[0].arguments);
}
const std::string cmux_command =
"cmux rpc surface.scrollback '{\"surface_id\":\"20CD49E8-A0FA-42E3-B349-7CCCC1075425\","
"\"workspace_id\":\"826FA4BA-01E5-4D84-893A-D70B0DAF9970\"}' 2>&1 | head -3";
const std::string malformed_nested_tool_output =
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"bash\">"
"]<]minimax[>[<command>" + cmux_command +
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>老实\n"
"</command>]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>\n"
"]<]minimax[>[<tool_call>\n"
"bash\n"
"</parameter>]<]minimax[>[</command>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>";
auto malformed_nested_tool_msg = common_chat_parse(malformed_nested_tool_output,
/* is_partial = */ false,
parser_params);
t.assert_equal("malformed nested tool call count", 1u, malformed_nested_tool_msg.tool_calls.size());
if (malformed_nested_tool_msg.tool_calls.size() == 1) {
t.assert_equal("malformed nested tool call name", std::string("bash"), malformed_nested_tool_msg.tool_calls[0].name);
t.assert_equal(
"malformed nested tool args truncate namespace leak",
json({{"command", cmux_command}}).dump(),
malformed_nested_tool_msg.tool_calls[0].arguments);
t.assert_true("malformed nested tool args contain no minimax marker",
malformed_nested_tool_msg.tool_calls[0].arguments.find("]<]minimax[>[") == std::string::npos);
}
inputs.enable_thinking = false;
auto params_thinking_off = common_chat_templates_apply(tmpls.get(), inputs);
common_peg_arena thinking_off_arena;
thinking_off_arena.load(params_thinking_off.parser);
common_chat_parser_params thinking_off_parser_params(params_thinking_off);
thinking_off_parser_params.parser = thinking_off_arena;
const std::string leaked_reasoning =
"Right pane is up. Now let me think about the workflow lessons and update "
"kickass + smartass to include them explicitly.\n\n"
"Looking at kickass.md:\n"
"- Rule 4 covers L7 partially\n"
"- Rule 2 covers L15\n\n"
"Let me update kickass and smartass.";
const std::string tool_prelude =
"Let me update kickass and smartass with the workflow lessons explicitly. "
"First check what's there:";
const std::string unopened_closed_tool_output =
leaked_reasoning + "</mm:think>" + tool_prelude +
"]<]minimax[>[<tool_call>\n"
"]<]minimax[>[<invoke name=\"bash\">"
"]<]minimax[>[<command>cat /home/leaf/.pi/agent/agents/kickass.md 2>&1]<]minimax[>[</command>"
"]<]minimax[>[</invoke>\n"
"]<]minimax[>[</tool_call>";
auto unopened_closed_tool_msg = common_chat_parse(unopened_closed_tool_output, /* is_partial = */ false, thinking_off_parser_params);
t.assert_equal("unopened closed tool reasoning", leaked_reasoning + tool_prelude, unopened_closed_tool_msg.reasoning_content);
t.assert_equal("unopened closed tool visible content", std::string(), unopened_closed_tool_msg.content);
t.assert_equal("unopened closed tool call count", 1u, unopened_closed_tool_msg.tool_calls.size());
if (unopened_closed_tool_msg.tool_calls.size() == 1) {
t.assert_equal("unopened closed tool name", std::string("bash"), unopened_closed_tool_msg.tool_calls[0].name);
t.assert_equal(
"unopened closed tool args",
json({{"command", "cat /home/leaf/.pi/agent/agents/kickass.md 2>&1"}}).dump(),
unopened_closed_tool_msg.tool_calls[0].arguments);
}
const std::string migrated_prefix =
"Chain 6 started. JakASS is reading the prompt. Now sleeping 1 hour:";
auto migrated_content_msg = common_chat_parse(migrated_prefix, /* is_partial = */ true, thinking_off_parser_params);
t.assert_equal("migrated prefix parser treats as reasoning", migrated_prefix, migrated_content_msg.reasoning_content);
t.assert_equal("migrated prefix parser has no visible content", std::string(), migrated_content_msg.content);
auto migrated_reasoning_msg = common_chat_parse(migrated_prefix + "</mm:think>",
/* is_partial = */ true,
thinking_off_parser_params);
t.assert_equal("migrated prefix later reasoning", migrated_prefix, migrated_reasoning_msg.reasoning_content);
t.assert_equal("migrated prefix later not visible", std::string(), migrated_reasoning_msg.content);
common_chat_msg streamed_as_content;
streamed_as_content.role = "assistant";
streamed_as_content.content = migrated_prefix;
auto migrated_diffs = common_chat_msg_diff::compute_diffs(streamed_as_content, migrated_reasoning_msg);
t.assert_equal("migrated prefix emits no duplicate diff", 0u, migrated_diffs.size());
}
// End-to-end coverage for the dedicated Cohere2MoE (North Code) parser:
// template apply -> PEG parse -> assert message. Exercises the reasoning-mode
// matrix, including the unopened-thinking-under---reasoning-off case (#1968
+1 -1
View File
@@ -294,7 +294,7 @@ static void test_minimax_m3_native_tool_parser(void) {
common_chat_templates_ptr tmpls(common_chat_templates_init(/* model= */ nullptr, template_str));
auto params = common_chat_templates_apply(tmpls.get(), inputs);
assert(params.format == COMMON_CHAT_FORMAT_PEG_NATIVE);
assert(params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3);
assert(!params.parser.empty());
common_peg_arena arena;