diff --git a/common/chat-auto-parser-generator.cpp b/common/chat-auto-parser-generator.cpp index 37ca55c8dfa3..fd2d98248f5b 100644 --- a/common/chat-auto-parser-generator.cpp +++ b/common/chat-auto-parser-generator.cpp @@ -25,6 +25,72 @@ static void foreach_function(const json & tools, const std::function") + p.literal("]]>"); + if (!arguments.value_stop_sequences.empty()) { + return p.choice({ cdata, p.until_one_of(arguments.value_stop_sequences) }); + } + if (!arguments.value_suffix.empty()) { + return p.choice({ cdata, p.until(arguments.value_suffix) }); + } + return cdata; +} + parser_build_context::parser_build_context(common_chat_peg_builder & p, const generation_params & inputs) : p(p), inputs(inputs), @@ -75,8 +141,7 @@ common_chat_params peg_generator::generate_parser(const common_chat_template & // Build grammar if tools are present bool has_tools = autoparser.tools.format.mode != tool_format::NONE && inputs.tools.is_array() && !inputs.tools.empty(); - std::string trigger_marker = !autoparser.tools.format.section_start.empty() ? autoparser.tools.format.section_start : - autoparser.tools.format.per_call_start; + std::string trigger_marker = tool_trigger_marker(autoparser.tools); bool has_response_format = !inputs.json_schema.empty() && inputs.json_schema.is_object(); bool include_grammar = has_response_format || (has_tools && @@ -100,12 +165,26 @@ common_chat_params peg_generator::generate_parser(const common_chat_template & // Set grammar triggers based on tool section markers (fall back to per-call markers) if (data.grammar_lazy) { - data.grammar_triggers = { - { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, trigger_marker } - }; - if (autoparser.tools.format.openai_wrapper_trigger) { - // model emits the OpenAI function wrapper, trigger on it - data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "{\"type\": \"function\"," }); + if (!autoparser.tools.format.tool_start_triggers.empty()) { + for (const auto & t : autoparser.tools.format.tool_start_triggers) { + data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, t }); + } + if (!autoparser.tools.function.compact_name_prefix.empty()) { + const auto & cp = autoparser.tools.function.compact_name_prefix; + const auto pos = cp.find('='); + if (pos != std::string::npos) { + data.grammar_triggers.push_back( + { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, cp.substr(0, pos + 1) }); + } + } + } else { + data.grammar_triggers = { + { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, trigger_marker } + }; + if (autoparser.tools.format.openai_wrapper_trigger) { + // model emits the OpenAI function wrapper, trigger on it + data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "{\"type\": \"function\"," }); + } } } } @@ -147,7 +226,18 @@ common_peg_arena autoparser::build_parser(const generation_params & inputs, cons } else { parser = content.build_parser(ctx); } - return pure_content ? p.prefix(generation_prompt, reasoning.start) + parser : p.prefix(generation_prompt, reasoning.start) << parser; + + // When reasoning extraction is disabled but the generation prompt already contains a + // closed thinking block (e.g. MiniCPM5 with enable_thinking=false), consume the full + // generation prompt prefix so it is not emitted as assistant content. + const bool consume_full_generation_prompt = + !extract_reasoning && !reasoning.end.empty() && generation_prompt.find(reasoning.end) != std::string::npos; + + common_peg_parser generation_prefix = consume_full_generation_prompt ? + p.literal(generation_prompt) : + p.prefix(generation_prompt, reasoning.start); + + return pure_content ? generation_prefix + parser : generation_prefix << parser; }); } @@ -257,7 +347,12 @@ common_peg_parser analyze_tools::build_func_parser(common_chat_peg_builder & p, const common_peg_parser & call_id_section, bool have_call_id, const common_peg_parser & args, std::optional atomic_peek) const { - auto open = p.tool_open(function.name_prefix + p.tool_name(p.literal(name)) + function.name_suffix); + common_peg_parser name_prefix = + (!function.alt_name_prefix.empty() || !function.compact_name_prefix.empty()) ? + build_attr_name_prefix_choice(p, function.name_prefix, function.alt_name_prefix, + function.compact_name_prefix) : + p.literal(function.name_prefix); + auto open = p.tool_open(name_prefix + p.tool_name(p.literal(name)) + function.name_suffix); bool matched_atomic = false; common_peg_parser func_parser = p.eps(); @@ -275,7 +370,10 @@ common_peg_parser analyze_tools::build_func_parser(common_chat_peg_builder & p, } if (!function.close.empty()) { - func_parser = func_parser + p.space() + p.tool_close(p.literal(function.close)); + auto close_parser = format.tolerate_incomplete_xml ? + p.optional(p.literal(function.close)) : + p.literal(function.close); + func_parser = func_parser + p.space() + p.tool_close(close_parser); } else if (!format.per_call_end.empty()) { // When there's no func_close but there is a per_call_end marker, use peek() to ensure // we only emit tool_close when we can actually see the closing marker. This prevents @@ -343,22 +441,24 @@ common_peg_parser analyze_tools::build_tool_parser_tag_json(parser_build_context p.literal(format.section_start) + p.space() + tool_calls + p.space() + (format.section_end.empty() ? p.end() : p.literal(format.section_end))); } - } else { - std::string separator = ", "; // Default + } else if (!format.section_start.empty()) { + std::string separator = format.call_separator.empty() ? ", " : format.call_separator; if (inputs.parallel_tool_calls) { tool_calls = p.trigger_rule("tool-call", format.section_start + tool_choice + p.zero_or_more(separator + tool_choice) + format.section_end); } else { tool_calls = p.trigger_rule("tool-call", format.section_start + tool_choice + format.section_end); } + } else { + tool_calls = build_direct_tool_calls(p, *this, tool_choice, inputs); } if (!require_calls) { tool_calls = p.optional(tool_calls); } - std::string trigger_marker = !format.section_start.empty() ? format.section_start : format.per_call_start; - auto content_before_tools = trigger_marker.empty() ? p.eps() : p.until(trigger_marker); + const std::string trigger_marker = tool_trigger_marker(*this); + auto content_before_tools = trigger_marker.empty() ? p.eps() : p.until(trigger_marker); return ctx.reasoning_parser + p.optional(p.content(content_before_tools)) + tool_calls + p.end(); } @@ -366,7 +466,15 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte auto & p = ctx.p; const auto & inputs = ctx.inputs; - auto until_suffix = p.rule("until-suffix", p.until(arguments.value_suffix)); + const bool tolerant_xml = format.tolerate_incomplete_xml; + + auto until_suffix = tolerant_xml ? p.eps() : + p.rule("until-suffix", p.until(arguments.value_suffix)); + auto tolerant_arg_value = tolerant_xml ? build_tolerant_arg_value_parser(p, arguments) : p.eps(); + auto arg_name_prefix = tolerant_xml ? + build_attr_name_prefix_choice(p, arguments.name_prefix, arguments.alt_name_prefix, + arguments.compact_name_prefix) : + p.eps(); common_peg_parser tool_choice = p.choice(); @@ -376,14 +484,56 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte auto params = func.contains("parameters") ? func.at("parameters") : json::object(); const auto & properties = params.contains("properties") ? params.at("properties") : json::object(); + auto schema_info = common_schema_info(); + schema_info.resolve_refs(params); + + common_peg_parser args_seq = p.eps(); + + if (tolerant_xml) { + common_peg_parser ws = p.space(); + + if (!properties.empty()) { + auto arg_choice = p.choice(); + for (const auto & [param_name, param_schema] : properties.items()) { + const bool is_string = schema_info.resolves_to_string(param_schema); + + auto value_parser = is_string ? + p.tool_arg_string_value(tolerant_arg_value) : + p.tool_arg_json_value(p.schema( + p.json(), "tool-" + name + "-arg-" + param_name + "-schema", param_schema, false)); + + auto arg_close = p.tool_arg_close( + (arguments.value_suffix.empty() ? p.eps() : + p.optional(p.literal(arguments.value_suffix))) + + ws); + + arg_choice |= p.tool_arg( + p.tool_arg_open(arg_name_prefix + p.tool_arg_name(p.literal(param_name)) + + arguments.name_suffix) + + arguments.value_prefix + value_parser + arg_close); + } + args_seq = p.zero_or_more(arg_choice + ws); + } + + auto func_close = function.close.empty() ? + ws : + p.tool_close(p.optional(p.literal(function.close)) + ws); + + auto tool_parser = p.tool( + p.tool_open(build_attr_name_prefix_choice(p, function.name_prefix, function.alt_name_prefix, + function.compact_name_prefix) + + p.tool_name(p.literal(name)) + function.name_suffix) + + p.tool_args(args_seq) + ws + func_close); + + tool_choice |= p.rule("tool-" + name, tool_parser); + return; + } + std::set required; if (params.contains("required")) { params.at("required").get_to(required); } - auto schema_info = common_schema_info(); - schema_info.resolve_refs(params); - // Build parser for each argument, separating required and optional std::vector required_parsers; std::vector optional_parsers; @@ -409,7 +559,6 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte } // Build required arg sequence in definition order - common_peg_parser args_seq = p.eps(); for (size_t i = 0; i < required_parsers.size(); i++) { if (i > 0) { args_seq = args_seq + p.space(); @@ -470,8 +619,8 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte p.literal(format.section_start) + p.space() + tool_calls + p.space() + (format.section_end.empty() ? p.end() : p.literal(format.section_end) + p.space())); } - } else { - std::string separator = ", "; // Default + } else if (!format.section_start.empty()) { + std::string separator = format.call_separator.empty() ? ", " : format.call_separator; if (inputs.parallel_tool_calls) { tool_calls = p.trigger_rule("tool-call", format.section_start + p.space() + tool_choice + @@ -481,14 +630,18 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte tool_calls = p.trigger_rule( "tool-call", format.section_start + p.space() + tool_choice + p.space() + format.section_end); } + } else { + tool_calls = build_direct_tool_calls(p, *this, tool_choice, inputs); } if (!require_tools) { tool_calls = p.optional(tool_calls); } - std::string trigger_marker = !format.section_start.empty() ? format.section_start : format.per_call_start; - auto content_before_tools = trigger_marker.empty() ? p.eps() : p.until(trigger_marker); + const std::string trigger_marker = tool_trigger_marker(*this); + auto content_before_tools = !format.tool_start_triggers.empty() ? + p.until_one_of(format.tool_start_triggers) : + (trigger_marker.empty() ? p.eps() : p.until(trigger_marker)); return ctx.reasoning_parser + p.optional(p.content(content_before_tools)) + tool_calls + p.end(); } diff --git a/common/chat-auto-parser.h b/common/chat-auto-parser.h index 9e8113f24421..edb2c2297d33 100644 --- a/common/chat-auto-parser.h +++ b/common/chat-auto-parser.h @@ -178,6 +178,7 @@ struct tool_format_analysis { std::string section_end; // e.g., "", "" std::string per_call_start; // e.g., "<|tool_call_begin|>", "" (for multi-call templates) std::string per_call_end; // e.g., "<|tool_call_end|>", "" + std::string call_separator; // e.g., "\n", "" — between consecutive tool calls when there is no section wrapper bool fun_name_is_key = false; // In JSON format function name is JSON key, i.e. { "": { ... arguments ... } } bool tools_array_wrapped = false; // Tool calls wrapped in JSON array [...] @@ -189,12 +190,19 @@ struct tool_format_analysis { std::string id_field; std::string gen_id_field; std::vector parameter_order; + + // XML attribute-style tools () may omit outer + // tags or closing markers; use alternate prefixes and content/tool triggers below. + bool tolerate_incomplete_xml = false; + std::vector tool_start_triggers; }; struct tool_function_analysis { std::string name_prefix; // e.g., "", "\"", ":0" std::string close; // e.g., "", "" (for tag-based) + std::string alt_name_prefix; // e.g. " name=\"" when the opening tag is truncated + std::string compact_name_prefix; // e.g. "", "" std::string value_suffix; // e.g., "", "", "" std::string separator; // e.g., "", "\n", "," + std::string alt_name_prefix; + std::string compact_name_prefix; + std::vector value_stop_sequences; }; struct tool_id_analysis { @@ -338,6 +349,9 @@ struct analyze_tools : analyze_base { // Check for and extract specific per-call markers for non-native-JSON templates with parallel call support void check_per_call_markers(); + // Extract separator between consecutive tool calls (when calls are not wrapped in section/per-call markers) + void extract_call_separator(); + // Extract function name markers void extract_function_markers(); diff --git a/common/chat-diff-analyzer.cpp b/common/chat-diff-analyzer.cpp index b166ee5a18f3..b3f80492bd91 100644 --- a/common/chat-diff-analyzer.cpp +++ b/common/chat-diff-analyzer.cpp @@ -42,8 +42,9 @@ static std::vector") == std::string::npos && analysis.reasoning.mode == reasoning_mode::NONE) { analysis.reasoning.mode = reasoning_mode::TAG_BASED; - analysis.reasoning.start = ""; - analysis.reasoning.end = ""; + // Match jinja: \n{reasoning}\n\n\n{content} + analysis.reasoning.start = "\n"; + analysis.reasoning.end = "\n\n\n"; analysis.preserved_tokens.push_back(""); analysis.preserved_tokens.push_back(""); LOG_DBG(ANSI_ORANGE "[Patch: old Qwen/Deepseek thinking template]\n" ANSI_RESET); @@ -173,8 +174,40 @@ static std::vector; calls separated by newline/ + [](const common_chat_template & tmpl, autoparser & analysis) -> void { + if (tmpl.src.find("Tool usage guidelines:") != std::string::npos && + tmpl.src.find(""; + analysis.tools.function.close = ""; + analysis.tools.arguments.name_prefix = ""; + analysis.tools.arguments.value_prefix.clear(); + analysis.tools.arguments.value_suffix = ""; + analysis.tools.arguments.value_stop_sequences = { + "", "", "diff.right; + if (both.find(FUN_SECOND) == std::string::npos) { + return; + } + + const std::string before_second = both.substr(0, both.find(FUN_SECOND)); + std::string closer = !function.close.empty() ? function.close : ""; + const size_t close_pos = before_second.rfind(closer); + if (close_pos != std::string::npos) { + format.call_separator = before_second.substr(close_pos + closer.size()); + } +} + void analyze_tools::extract_function_markers() { json assistant_nocall = json{ { "role", "assistant" }, diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp index cd6a36956cea..5055ae9ac122 100644 --- a/common/jinja/value.cpp +++ b/common/jinja/value.cpp @@ -1108,6 +1108,50 @@ const func_builtins & value_array_t::get_builtins() const { std::reverse(arr.begin(), arr.end()); return is_val(val) ? mk_val(std::move(arr)) : mk_val(std::move(arr)); }}, + {"min", [](const func_args & args) -> value { + args.ensure_count(1, 4); + args.ensure_vals(); + value val_case = args.get_kwarg_or_pos("case_sensitive", 1); + value attribute = args.get_kwarg_or_pos("attribute", 2); + if (!attribute->is_undefined()) { + throw not_implemented_exception("min: attribute not implemented"); + } + // FIXME: min is currently always case sensitive + (void) val_case; + const auto & arr = args.get_pos(0)->as_array(); + if (arr.empty()) { + return mk_val(); + } + value result = arr[0]; + for (size_t i = 1; i < arr.size(); ++i) { + if (value_compare(arr[i], result, value_compare_op::lt)) { + result = arr[i]; + } + } + return result; + }}, + {"max", [](const func_args & args) -> value { + args.ensure_count(1, 4); + args.ensure_vals(); + value val_case = args.get_kwarg_or_pos("case_sensitive", 1); + value attribute = args.get_kwarg_or_pos("attribute", 2); + if (!attribute->is_undefined()) { + throw not_implemented_exception("max: attribute not implemented"); + } + // FIXME: max is currently always case sensitive + (void) val_case; + const auto & arr = args.get_pos(0)->as_array(); + if (arr.empty()) { + return mk_val(); + } + value result = arr[0]; + for (size_t i = 1; i < arr.size(); ++i) { + if (value_compare(arr[i], result, value_compare_op::gt)) { + result = arr[i]; + } + } + return result; + }}, {"unique", array_unique_not_implemented}, }; return builtins; diff --git a/models/templates/MiniCPM5-1B.jinja b/models/templates/MiniCPM5-1B.jinja new file mode 100644 index 000000000000..cb2934c459c6 --- /dev/null +++ b/models/templates/MiniCPM5-1B.jinja @@ -0,0 +1,179 @@ +{{- bos_token }}{%- if tools %} + {%- set tool_definitions %} + {{- "# Tools\n\nYou are provided with function signatures within XML tags:\n" }} + {%- for tool in tools %} + {{- "\n" }} + {{- tool | tojson(ensure_ascii=False) }} + {%- endfor %} + {{- '\n\n\nTool usage guidelines:\n- You may call zero or more functions. If no function calls are needed, just answer normally and do not include any .\n- When calling a function, return an XML object within using:\nparam-value\n- param-value may be multi-line. If it contains <, & or newline characters, wrap it in a CDATA block: ' }} + {%- endset %} + + {{- '<|im_start|>system\n' }} + {%- if messages[0].role == 'system' %} + {%- if '' in messages[0].content %} + {{- messages[0].content.replace('', tool_definitions) }} + {%- else %} + {{- messages[0].content + '\n\n' + tool_definitions }} + {%- endif %} + {%- else %} + {{- tool_definitions.lstrip() }} + {%- endif %} + {{- '<|im_end|>\n' }} +{%- else %} + {%- if messages[0].role == 'system' %} + {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }} + {%- endif %} +{%- endif %} +{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %} +{%- for message in messages[::-1] %} + {%- set index = (messages|length - 1) - loop.index0 %} + {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('') and message.content.endswith('')) %} + {%- set ns.multi_step_tool = false %} + {%- set ns.last_query_index = index %} + {%- endif %} +{%- endfor %} +{%- for message in messages %} + {%- if message.content is string %} + {%- set content = message.content %} + {%- else %} + {%- set content = '' %} + {%- endif %} + {%- if (message.role == "user") or (message.role == "system" and not loop.first) %} + {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }} + {%- elif message.role == "assistant" %} + {%- set reasoning_content = '' %} + {%- if message.reasoning_content is string %} + {%- set reasoning_content = message.reasoning_content %} + {%- else %} + {%- if '' in content %} + {%- set reasoning_content = content.split('')[0].rstrip('\n').split('')[-1].lstrip('\n') %} + {%- set content = content.split('')[-1].lstrip('\n') %} + {%- endif %} + {%- endif %} + + {%- if message.tool_calls %} + {%- set content_parts = content.split('') %} + {%- set processed_content = content_parts[0] %} + {%- set tool_calls_count = message.tool_calls|length %} + {%- set tool_sep_count = content_parts|length - 1 %} + {%- set min_count = [tool_calls_count, tool_sep_count]|min %} + + {%- for i in range(1, content_parts|length) %} + {%- set tool_index = i - 1 %} + {%- if tool_index < tool_calls_count %} + {%- set tool_call = message.tool_calls[tool_index] %} + {%- if tool_call.function %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {%- set single_tool_xml %} + {{- '' }} + {%- if tool_call.arguments %} + {%- set args_dict = tool_call.arguments %} + {%- for param_name, param_value in args_dict.items() %} + {{- '' }} + {%- if param_value is string and ('<' in param_value or '&' in param_value or '\n' in param_value) %} + {{- '' }} + {%- else %} + {{- param_value }} + {%- endif %} + {{- '' }} + {%- endfor %} + {%- endif %} + {{- '' }} + {%- endset %} + {%- set processed_content = processed_content + single_tool_xml + content_parts[i] %} + {%- else %} + {%- set processed_content = processed_content + content_parts[i] %} + {%- endif %} + {%- endfor %} + + {%- if tool_calls_count > tool_sep_count %} + {%- for remaining_index in range(tool_sep_count, tool_calls_count) %} + {%- set tool_call = message.tool_calls[remaining_index] %} + {%- if tool_call.function %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {%- set remaining_tool_xml %} + {{- '' }} + {%- if tool_call.arguments %} + {%- set args_dict = tool_call.arguments %} + {%- for param_name, param_value in args_dict.items() %} + {{- '' }} + {%- if param_value is string and ('<' in param_value or '&' in param_value or '\n' in param_value) %} + {{- '' }} + {%- else %} + {{- param_value }} + {%- endif %} + {{- '' }} + {%- endfor %} + {%- endif %} + {{- '' }} + {%- endset %} + {%- set processed_content = processed_content + remaining_tool_xml %} + {%- endfor %} + {%- endif %} + + {%- set content = processed_content %} + {%- endif %} + + {%- if loop.index0 > ns.last_query_index %} + {%- if reasoning_content %} + {{- '<|im_start|>' + message.role + '\n\n' + reasoning_content.strip('\n') + '\n\n\n' + content.lstrip('\n') }} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + + {%- if message.tool_calls and not has_tool_sep %} + {%- for tool_call in message.tool_calls %} + {%- if (loop.first and content) or (not loop.first) %} + {{- '\n' }} + {%- endif %} + {%- if tool_call.function %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {{- '' }} + {%- if tool_call.arguments %} + {%- set args_dict = tool_call.arguments %} + {%- for param_name, param_value in args_dict.items() %} + {{- '' }} + {%- if param_value is string and ('<' in param_value or '&' in param_value or '\n' in param_value) %} + {{- '' }} + {%- else %} + {{- param_value }} + {%- endif %} + {{- '' }} + {%- endfor %} + {%- endif %} + {{- '' }} + {%- endfor %} + {%- endif %} + {{- '<|im_end|>\n' }} + {%- elif message.role == "tool" %} + {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %} + {{- '<|im_start|>user' }} + {%- endif %} + {{- '\n\n' }} + {%- if message.content is string %} + {{- content }} + {%- else %} + {{- message.content | tojson(ensure_ascii=False) }} + {%- endif %} + {{- '\n' }} + {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %} + {{- '<|im_end|>\n' }} + {%- endif %} + {%- endif %} +{%- endfor %} +{%- if add_generation_prompt %} + {{- '<|im_start|>assistant\n' }} + {%- if enable_thinking is defined %} + {%- if enable_thinking is false %} + {{- '\n\n\n\n' }} + {%- elif enable_thinking is true %} + {{- '\n' }} + {%- endif %} + {%- endif %} +{%- endif %} diff --git a/tests/test-chat-peg-parser.cpp b/tests/test-chat-peg-parser.cpp index 908b13fd0ca7..7b0a86008e7e 100644 --- a/tests/test-chat-peg-parser.cpp +++ b/tests/test-chat-peg-parser.cpp @@ -466,7 +466,8 @@ static void test_example_qwen3_coder(testing & t) { for (auto it = tokens.begin(); it != tokens.end(); it++) { std::string in = std::accumulate(tokens.begin(), it + 1, std::string()); - common_peg_parse_context ctx(in, (it + 1 < tokens.end()) ? COMMON_PEG_PARSE_FLAG_LENIENT : COMMON_PEG_PARSE_FLAG_NONE); + const bool is_partial = (it + 1 < tokens.end()); + common_peg_parse_context ctx(in, is_partial ? COMMON_PEG_PARSE_FLAG_LENIENT : COMMON_PEG_PARSE_FLAG_NONE); auto result = parser.parse(ctx); if (!t.assert_equal("not fail", false, result.fail())) { @@ -564,7 +565,8 @@ static void test_example_qwen3_non_coder(testing & t) { for (auto it = tokens.begin(); it != tokens.end(); it++) { std::string in = std::accumulate(tokens.begin(), it + 1, std::string()); - common_peg_parse_context ctx(in, (it + 1 < tokens.end()) ? COMMON_PEG_PARSE_FLAG_LENIENT : COMMON_PEG_PARSE_FLAG_NONE); + const bool is_partial = (it + 1 < tokens.end()); + common_peg_parse_context ctx(in, is_partial ? COMMON_PEG_PARSE_FLAG_LENIENT : COMMON_PEG_PARSE_FLAG_NONE); auto result = parser.parse(ctx); if (!t.assert_equal("not fail", false, result.fail())) { @@ -822,7 +824,8 @@ static void test_prefix_tool_names(testing & t) { for (auto it = tokens.begin(); it != tokens.end(); it++) { std::string in = std::accumulate(tokens.begin(), it + 1, std::string()); - common_peg_parse_context ctx(in, (it + 1 < tokens.end()) ? COMMON_PEG_PARSE_FLAG_LENIENT : COMMON_PEG_PARSE_FLAG_NONE); + const bool is_partial = (it + 1 < tokens.end()); + common_peg_parse_context ctx(in, is_partial ? COMMON_PEG_PARSE_FLAG_LENIENT : COMMON_PEG_PARSE_FLAG_NONE); auto result = parser.parse(ctx); if (!t.assert_equal("not fail", false, result.fail())) { @@ -981,3 +984,4 @@ static void test_tagged_peg_parser(testing & t) { t.assert_equal("fun_post should be '>'", ">", result.tags["fun_post"]); }); } + diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp index 902a4c135abe..624d2dc879f2 100644 --- a/tests/test-chat.cpp +++ b/tests/test-chat.cpp @@ -478,6 +478,21 @@ static common_chat_tool python_tool{ })", }; +static common_chat_tool category_menu_tool{ + /* .name = */ "category_menu", + /* .description = */ "Show menu items for a category", + /* .parameters = */ R"({ + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Menu category name" + } + }, + "required": ["category"] + })", +}; + static common_chat_tool html_tool{ /* .name = */ "html", /* .description = */ "an html validator", @@ -986,8 +1001,9 @@ struct peg_test_case { common_chat_templates_inputs params; std::string input; common_chat_msg expect; - bool is_partial = false; - bool expect_reconstruction = false; + bool is_partial = false; + bool expect_reconstruction = false; + bool expect_streaming_consistency = true; }; struct make_peg_parser { @@ -1078,36 +1094,38 @@ static void test_peg_parser(common_chat_templates * tmpls, std::string prefix = tc.input.substr(0, safe_len); common_chat_msg msg_current = parser.parse(prefix, is_partial); - for (const auto & diff : common_chat_msg_diff::compute_diffs(msg_prev, msg_current)) { - if (!diff.reasoning_content_delta.empty()) { - msg_accum.reasoning_content += diff.reasoning_content_delta; - } - if (!diff.content_delta.empty()) { - msg_accum.content += diff.content_delta; - } - if (diff.tool_call_index != std::string::npos) { - // During partial parsing, a new tool call may appear with empty name initially - // The name gets filled in as more input is parsed - while (msg_accum.tool_calls.size() <= diff.tool_call_index) { - msg_accum.tool_calls.push_back({ "", "", "" }); - } - // Always update name and id from diff (may change during incremental parsing), but only if the delta - // actually contains them - if (!diff.tool_call_delta.name.empty()) { - msg_accum.tool_calls[diff.tool_call_index].name = diff.tool_call_delta.name; + if (tc.expect_streaming_consistency) { + for (const auto & diff : common_chat_msg_diff::compute_diffs(msg_prev, msg_current)) { + if (!diff.reasoning_content_delta.empty()) { + msg_accum.reasoning_content += diff.reasoning_content_delta; } - if (!diff.tool_call_delta.id.empty()) { - msg_accum.tool_calls[diff.tool_call_index].id = diff.tool_call_delta.id; + if (!diff.content_delta.empty()) { + msg_accum.content += diff.content_delta; } - if (!diff.tool_call_delta.arguments.empty()) { - msg_accum.tool_calls[diff.tool_call_index].arguments += diff.tool_call_delta.arguments; + if (diff.tool_call_index != std::string::npos) { + // During partial parsing, a new tool call may appear with empty name initially + // The name gets filled in as more input is parsed + while (msg_accum.tool_calls.size() <= diff.tool_call_index) { + msg_accum.tool_calls.push_back({ "", "", "" }); + } + // Always update name and id from diff (may change during incremental parsing), but only if the delta + // actually contains them + if (!diff.tool_call_delta.name.empty()) { + msg_accum.tool_calls[diff.tool_call_index].name = diff.tool_call_delta.name; + } + if (!diff.tool_call_delta.id.empty()) { + msg_accum.tool_calls[diff.tool_call_index].id = diff.tool_call_delta.id; + } + if (!diff.tool_call_delta.arguments.empty()) { + msg_accum.tool_calls[diff.tool_call_index].arguments += diff.tool_call_delta.arguments; + } } } - } - try { - assert_msg_equals(msg_current, msg_accum, true); - } catch (std::exception & e) { - throw std::runtime_error((std::string("Error comparing accumulated message to current: ") + e.what()).c_str()); + try { + assert_msg_equals(msg_current, msg_accum, true); + } catch (std::exception & e) { + throw std::runtime_error((std::string("Error comparing accumulated message to current: ") + e.what()).c_str()); + } } msg_prev = msg_current; @@ -1116,7 +1134,11 @@ static void test_peg_parser(common_chat_templates * tmpls, if (!tc.is_partial) { assert_msg_equals(tc.expect, parser.parse(tc.input, false), true); } - assert_msg_equals(tc.expect, msg_accum, true); + if (tc.expect_streaming_consistency) { + assert_msg_equals(tc.expect, msg_accum, true); + } else if (tc.is_partial) { + assert_msg_equals(tc.expect, parser.parse(tc.input, true), true); + } // Test grammar if present in params if (!parser.params_.grammar.empty()) { @@ -1436,6 +1458,11 @@ class peg_test_builder { return *this; } + peg_test_builder & expect_streaming_consistency(bool val = true) { + tc_.expect_streaming_consistency = val; + return *this; + } + // Expect setters peg_test_builder & expect(const common_chat_msg & msg) { tc_.expect = msg; @@ -5518,6 +5545,91 @@ static void test_template_output_peg_parsers(bool detailed_debug) { .expect_content("Hello, world!\nWhat's up?") .run(); } + + // MiniCPM5 - XML tool calls with ... + { + auto tst = peg_tester("models/templates/MiniCPM5-1B.jinja", detailed_debug); + + tst.test("Hello, world!\nWhat's up?") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect(message_assist) + .run(); + + tst.test(R"(print('Hello, World!'))") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect_streaming_consistency(false) + .tools({ python_tool }) + .expect_tool_calls({ { "python", R"#({"code": "print('Hello, World!')"})#", {} } }) + .run(); + + tst.test(R"(Dessert)") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect_streaming_consistency(false) + .tools({ category_menu_tool }) + .expect_tool_calls({ { "category_menu", R"#({"category": "Dessert"})#", {} } }) + .run(); + + tst.test(R"()") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect_streaming_consistency(false) + .tools({ empty_args_tool }) + .expect(simple_assist_msg("", "", "empty_args", "{}")) + .run(); + + tst.test(R"(print('x'))") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect_streaming_consistency(false) + .parallel_tool_calls(true) + .tools({ python_tool }) + .expect_tool_calls({ { "python", R"#({"code": "print('x')"})#", {} } }) + .run(); + + tst.test(R"(I'm thinkingprint('hey'))") + .enable_thinking(true) + .expect_streaming_consistency(false) + .reasoning_format(COMMON_REASONING_FORMAT_AUTO) + .tools({ python_tool }) + .expect_reasoning("I'm thinking") + .expect_tool_calls({ { "python", R"#({"code": "print('hey')"})#", {} } }) + .run(); + + tst.test(R"(print('x') +print('y'))") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect_streaming_consistency(false) + .parallel_tool_calls(true) + .tools({ python_tool }) + .expect_tool_calls({ + { "python", R"#({"code": "print('x')"})#", {} }, + { "python", R"#({"code": "print('y')"})#", {} }, + }) + .run(); + + tst.test(R"(print('hey')") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_NONE) + .expect_streaming_consistency(false) + .tools({ python_tool }) + .is_partial(true) + .expect(simple_assist_msg("", "", "python", R"#({"code": "print('hey')")#")) + .run(); + + tst.test(" thinkingHello, 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(); + } } static void test_template_generation_prompt() { @@ -5665,6 +5777,14 @@ static void test_template_generation_prompt() { check(tmpls, continuation_content(), "<|Assistant|>I'm thinkingHello, "); check(tmpls, continuation_reasoning(), "<|Assistant|>I'm"); } + + { + auto tmpls = read_templates("models/templates/MiniCPM5-1B.jinja"); + check(tmpls, basic(), "<|im_start|>assistant\n\n"); + check(tmpls, continuation_content(), + "<|im_start|>assistant\n\nI'm thinking\n\n\nHello, "); + check(tmpls, continuation_reasoning(), "<|im_start|>assistant\n\nI'm"); + } } // Test the developer role to system workaround with a simple mock template diff --git a/tests/test-jinja.cpp b/tests/test-jinja.cpp index 8039956246c3..fe040efa9dc4 100644 --- a/tests/test-jinja.cpp +++ b/tests/test-jinja.cpp @@ -1558,6 +1558,36 @@ static void test_array_methods(testing & t) { "6" ); + test_template(t, "array|min", + "{{ [tool_calls_count, tool_sep_count]|min }}", + {{"tool_calls_count", 2}, {"tool_sep_count", 1}}, + "1" + ); + + test_template(t, "array|max", + "{{ [tool_calls_count, tool_sep_count]|max }}", + {{"tool_calls_count", 2}, {"tool_sep_count", 1}}, + "2" + ); + + test_template(t, "array|min attribute", + "{{ items|min(attribute='x') }}", + {{"items", json::array({ + json({{"x", 2}}), + json({{"x", 1}}), + })}}, + "{'x': 1}" + ); + + test_template(t, "array|max attribute", + "{{ items|max(attribute='x') }}", + {{"items", json::array({ + json({{"x", 2}}), + json({{"x", 1}}), + })}}, + "{'x': 2}" + ); + // not used by any chat templates // test_template(t, "array.insert()", // "{% set _ = arr.insert(1, 'x') %}{{ arr|join(',') }}",