diff --git a/common/chat-peg-parser.cpp b/common/chat-peg-parser.cpp index a2865d94d..6f1e0a7b7 100644 --- a/common/chat-peg-parser.cpp +++ b/common/chat-peg-parser.cpp @@ -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(); + 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; diff --git a/common/chat-peg-parser.h b/common/chat-peg-parser.h index be92f17d9..ef47f6c1b 100644 --- a/common/chat-peg-parser.h +++ b/common/chat-peg-parser.h @@ -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 & fn); - diff --git a/common/chat.cpp b/common/chat.cpp index 2dfbe2385..eda1b1e49 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -162,12 +162,31 @@ std::vector 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 = ""; data.thinking_end_tag = ""; @@ -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 , but MiniMax-M3 may + // also emit an unopened block ending in . 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 mapper; if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) { mapper = std::make_unique(msg); + } else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) { + mapper = std::make_unique(msg); } else { mapper = std::make_unique(msg); } @@ -2801,6 +2849,8 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars std::unique_ptr mapper; if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) { mapper = std::make_unique(msg); + } else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) { + mapper = std::make_unique(msg); } else { mapper = std::make_unique(msg); } diff --git a/common/chat.h b/common/chat.h index 9e450e177..3fe4af4d4 100644 --- a/common/chat.h +++ b/common/chat.h @@ -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 }; diff --git a/tests/test-chat-auto-parser.cpp b/tests/test-chat-auto-parser.cpp index 1826658b1..dc4a43e31 100644 --- a/tests/test-chat-auto-parser.cpp +++ b/tests/test-chat-auto-parser.cpp @@ -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 stream where 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 ~ '' -%} +{%- set toolcall_end_token = ns_token ~ '' -%} +{%- for message in messages -%} +{{- message.role ~ ': ' ~ message.content ~ '\n' -}} +{%- endfor -%} +{%- if tools -%} +{{- toolcall_begin_token ~ ns_token ~ '' ~ ns_token ~ '' ~ toolcall_end_token -}} +{%- endif -%} +{%- if add_generation_prompt -%} +{{- '' -}} +{%- 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 + "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[>[\n" + "]<]minimax[>[" + "]<]minimax[>[" + command + "]<]minimax[>[" + "]<]minimax[>[\n" + "]<]minimax[>["; + + 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[>[\n" + "]<]minimax[>[" + "]<]minimax[>[" + cmux_command + + "]<]minimax[>[\n" + "]<]minimax[>[\n" + "]<]minimax[>[老实\n" + "]<]minimax[>[\n" + "]<]minimax[>[\n" + "]<]minimax[>[\n" + "bash\n" + "]<]minimax[>[" + "]<]minimax[>[\n" + "]<]minimax[>["; + + 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 + "" + tool_prelude + + "]<]minimax[>[\n" + "]<]minimax[>[" + "]<]minimax[>[cat /home/leaf/.pi/agent/agents/kickass.md 2>&1]<]minimax[>[" + "]<]minimax[>[\n" + "]<]minimax[>["; + + 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 + "", + /* 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 diff --git a/tests/test-chat-template.cpp b/tests/test-chat-template.cpp index a7514b52a..de5041c65 100644 --- a/tests/test-chat-template.cpp +++ b/tests/test-chat-template.cpp @@ -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;