From 7212fcb6ab4eec173b5ecb24b996099bf2ba835a Mon Sep 17 00:00:00 2001 From: Thiago Padilha Date: Sat, 4 Jul 2026 07:27:40 -0300 Subject: [PATCH 1/2] common: specialize DeepSeek V4 DSML parsing DeepSeek V4 uses the same DSML invoke and parameter markup as DeepSeek V3.2, but the outer call block is named tool_calls instead of function_calls. The generic autoparser can infer parts of this format, but it is not compatible with DeepSeek V4 reasoning semantics: it treats the reasoning prefix as an optional closed block, so a partial generation that has opened but has not emitted can still fall through into tool parsing. That lets DSML-looking text inside the thinking stream be interpreted as an executable tool call. DeepSeek only intends DSML tool calls to be actionable after the thinking block has closed. DwarfStar handles this with a manual parser, and the same behavior is needed here to avoid running tool calls produced inside unfinished reasoning and to preserve parallel DSML invokes. Reuse the existing DeepSeek DSML parser for V4 by parameterizing the outer block name. The parser now accepts tool_calls in addition to function_calls, handles the V4 no-thinking prefill, keeps schema enforcement for real tool calls after reasoning closes, supports repeated invokes when parallel tool calls are enabled, and treats an unclosed block as reasoning-only. Add DeepSeek V4 parser coverage for plain content, a single DSML tool call, parallel DSML tool calls, and DSML markup inside an unclosed reasoning block. Validation: git diff --cached --check; build/bin/test-chat. Assisted-by: Codex --- common/chat.cpp | 193 ++++++++++++++++++++++++-------------------- tests/test-chat.cpp | 69 ++++++++++++++++ 2 files changed, 173 insertions(+), 89 deletions(-) diff --git a/common/chat.cpp b/common/chat.cpp index 6da59f4dbd2c..8f19513343e2 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -1856,7 +1856,8 @@ static common_chat_params common_chat_params_init_gigachat_v3( } static common_chat_params common_chat_params_init_deepseek_v3_2(const common_chat_template & tmpl, - const autoparser::generation_params & inputs) { + const autoparser::generation_params & inputs, + const std::string & tool_calls_tag = "function_calls") { common_chat_params data; data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs); @@ -1879,8 +1880,8 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha const std::string DSML = "|DSML|"; const std::string THINK_START = ""; const std::string THINK_END = ""; - const std::string FC_START = "<" + DSML + "function_calls>"; - const std::string FC_END = ""; + const std::string FC_START = "<" + DSML + tool_calls_tag + ">"; + const std::string FC_END = ""; const std::string INVOKE_START = "<" + DSML + "invoke"; const std::string INVOKE_END = ""; const std::string PARAM_START = "<" + DSML + "parameter"; @@ -1902,112 +1903,117 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha auto generation_prompt = p.literal(GEN_PROMPT); auto end = p.end(); - auto reasoning = p.eps(); - if (extract_reasoning && inputs.enable_thinking) { - reasoning = p.optional(THINK_START + p.reasoning(p.until(THINK_END)) + THINK_END); - } else if (extract_reasoning) { - // Thinking disabled but reasoning extraction requested: the generation prompt - // contains an empty pair that must still be consumed. - reasoning = p.optional(p.literal(THINK_START) + p.until(THINK_END) + p.literal(THINK_END)); - } - + auto after_reasoning = p.eps(); 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 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(); - const auto & props = params.contains("properties") ? params.at("properties") : json::object(); + after_reasoning = response_format; + } else if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) { + after_reasoning = p.content(p.rest()); + } else { + 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(); + const auto & props = params.contains("properties") ? params.at("properties") : json::object(); - std::set required; - if (params.contains("required")) { - params.at("required").get_to(required); - } + std::set required; + if (params.contains("required")) { + params.at("required").get_to(required); + } - auto schema_info = common_schema_info(); - schema_info.resolve_refs(params); - - std::vector required_parsers; - std::vector optional_parsers; - for (const auto & [param_name, param_schema] : props.items()) { - bool is_required = required.find(param_name) != required.end(); - bool is_string = schema_info.resolves_to_string(param_schema); - - auto arg = p.tool_arg( - p.tool_arg_open( - p.literal(PARAM_START + " name=\"") + - p.tool_arg_name(p.literal(param_name)) + - p.literal("\" string=\"" + std::string(is_string ? "true" : "false") + "\">")) + - (is_string - ? p.tool_arg_string_value(p.until(PARAM_END)) - : p.tool_arg_json_value(p.schema(p.json(), - "tool-" + name + "-arg-" + param_name + "-schema", - param_schema, false))) + - p.tool_arg_close(p.literal(PARAM_END))); - - auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg); - if (is_required) { - required_parsers.push_back(named_arg); - } else { - optional_parsers.push_back(named_arg); + auto schema_info = common_schema_info(); + schema_info.resolve_refs(params); + + std::vector required_parsers; + std::vector optional_parsers; + for (const auto & [param_name, param_schema] : props.items()) { + bool is_required = required.find(param_name) != required.end(); + bool is_string = schema_info.resolves_to_string(param_schema); + + auto arg = p.tool_arg( + p.tool_arg_open( + p.literal(PARAM_START + " name=\"") + + p.tool_arg_name(p.literal(param_name)) + + p.literal("\" string=\"" + std::string(is_string ? "true" : "false") + "\">")) + + (is_string + ? p.tool_arg_string_value(p.until(PARAM_END)) + : p.tool_arg_json_value(p.schema(p.json(), + "tool-" + name + "-arg-" + param_name + "-schema", + param_schema, false))) + + p.tool_arg_close(p.literal(PARAM_END))); + + auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg); + if (is_required) { + required_parsers.push_back(named_arg); + } else { + optional_parsers.push_back(named_arg); + } } - } - 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(); + 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(); + } + args_seq = args_seq + required_parsers[i]; } - args_seq = args_seq + required_parsers[i]; - } - if (!optional_parsers.empty()) { - common_peg_parser any_opt = p.choice(); - for (const auto & opt : optional_parsers) { - any_opt |= opt; + if (!optional_parsers.empty()) { + common_peg_parser any_opt = p.choice(); + for (const auto & opt : optional_parsers) { + any_opt |= opt; + } + args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1); } - args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1); - } - common_peg_parser invoke_body = args_seq; - auto func_parser = p.tool( - p.tool_open(p.literal(INVOKE_START + " name=\"") + - p.tool_name(p.literal(name)) + p.literal("\">\n")) + - invoke_body + p.space() + - p.tool_close(p.literal(INVOKE_END))); + common_peg_parser invoke_body = args_seq; + auto func_parser = p.tool( + p.tool_open(p.literal(INVOKE_START + " name=\"") + + p.tool_name(p.literal(name)) + p.literal("\">\n")) + + invoke_body + p.space() + + p.tool_close(p.literal(INVOKE_END))); - tool_choice |= p.rule("tool-" + name, func_parser); - }); + tool_choice |= p.rule("tool-" + name, func_parser); + }); - auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED; + 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)); + 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)); + after_reasoning = content_before_tools + tool_calls; } - if (!require_tools) { - tool_calls = p.optional(tool_calls); + if (extract_reasoning && inputs.enable_thinking) { + auto closed_reasoning = p.literal(THINK_START) + p.reasoning(p.until(THINK_END)) + p.literal(THINK_END) + after_reasoning; + auto open_reasoning = p.literal(THINK_START) + p.reasoning(p.rest()); + return generation_prompt + p.choice({ closed_reasoning, open_reasoning }) + end; + } else if (extract_reasoning) { + // V3.2 pre-fills , while V4 pre-fills directly. + auto reasoning = p.optional(p.choice({ + p.literal(THINK_START) + p.until(THINK_END) + p.literal(THINK_END), + p.literal(THINK_END), + })); + return generation_prompt + reasoning + after_reasoning + end; } - auto content_before_tools = p.content(p.until(FC_START)); - return generation_prompt + reasoning + content_before_tools + tool_calls + end; + return generation_prompt + after_reasoning + end; }); data.parser = parser.save(); @@ -2604,6 +2610,15 @@ std::optional common_chat_try_specialized_template( return common_chat_params_init_deepseek_v3_2(tmpl, params); } + // DeepSeek V4 format detection: same DSML invoke/parameter shape as V3.2, + // but the outer block is tool_calls instead of function_calls. + if (src.find("dsml_token") != std::string::npos && + src.find("tool_calls>") != std::string::npos && + src.find("DSML") != std::string::npos) { + LOG_DBG("Using specialized template: DeepSeek V4\n"); + return common_chat_params_init_deepseek_v3_2(tmpl, params, "tool_calls"); + } + // Gemma4 format detection if (src.find("'<|tool_call>call:'") != std::string::npos) { if (src.find("{#- OpenAI Chat Completions:") == std::string::npos) { diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp index 5f71e5da6e39..f358692c7e6d 100644 --- a/tests/test-chat.cpp +++ b/tests/test-chat.cpp @@ -3963,6 +3963,75 @@ static void test_template_output_peg_parsers(bool detailed_debug) { .run(); } + // DeepSeek V4 tests - format uses DSML markup: + // <|DSML|tool_calls> + // <|DSML|invoke name="foo"> + // <|DSML|parameter name="bar" string="true|false">value + // + // + { + auto tst = peg_tester("models/templates/deepseek-ai-DeepSeek-V4.jinja", detailed_debug); + + tst.test("Hello, world!\nWhat's up?") + .enable_thinking(false) + .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK) + .expect(message_assist) + .run(); + + tst.test( + "Let me check the time\n\n" + "<|DSML|tool_calls>\n" + "<|DSML|invoke name=\"get_time\">\n" + "<|DSML|parameter name=\"city\" string=\"true\">Tokyo\n" + "\n" + "") + .enable_thinking(true) + .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK) + .tools({ get_time_tool }) + .expect(message_with_tool_calls_and_reasoning("get_time", R"({"city": "Tokyo"})", "Let me check the time")) + .run(); + + tst.test( + "Calling both\n\n" + "<|DSML|tool_calls>\n" + "<|DSML|invoke name=\"get_time\">\n" + "<|DSML|parameter name=\"city\" string=\"true\">Paris\n" + "\n" + "<|DSML|invoke name=\"get_weather\">\n" + "<|DSML|parameter name=\"city\" string=\"true\">Paris\n" + "\n" + "") + .enable_thinking(true) + .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK) + .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"})" } })) + .run(); + + tst.test( + "Still thinking\n\n" + "<|DSML|tool_calls>\n" + "<|DSML|invoke name=\"get_time\">\n" + "<|DSML|parameter name=\"city\" string=\"true\">Tokyo\n" + "\n" + "") + .enable_thinking(true) + .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK) + .tools({ get_time_tool }) + .expect_reasoning( + "Still thinking\n\n" + "<|DSML|tool_calls>\n" + "<|DSML|invoke name=\"get_time\">\n" + "<|DSML|parameter name=\"city\" string=\"true\">Tokyo\n" + "\n" + "") + .expect_content("") + .expect_tool_calls({}) + .run(); + } + // GLM-4.6 tests - format: function_name\n...\n...\n { auto tst = peg_tester("models/templates/GLM-4.6.jinja", detailed_debug); From 8b00186fe03463258cb3ebf8d0eebeca02d1be5f Mon Sep 17 00:00:00 2001 From: Thiago Padilha Date: Sat, 4 Jul 2026 07:52:13 -0300 Subject: [PATCH 2/2] common: align DeepSeek DSML tool encoding DeepSeek V4 encoding documents several tool conversation invariants that the current template and specialized parser were close to, but did not fully match. First, previous assistant reasoning is part of tool-call history. The V4 template only preserved reasoning for assistant turns after the last user message, so an assistant turn with tool calls could be rendered without its reasoning once tool results and a follow-up user message were present. Keep reasoning whenever tools are available, while preserving the existing continuation behavior for non-tool conversations. Second, tool results are encoded inside user turns and must be ordered according to the preceding assistant tool_calls array. OpenAI-compatible clients can deliver role:tool messages in completion order rather than call order, so normalize contiguous tool result messages before rendering DeepSeek DSML templates. Third, DSML parameters are name-tagged, but the specialized PEG parser required all required parameters first in schema order and only allowed optional parameters afterward. That rejected valid model output with optional parameters before required parameters, even though the parameter names make the order unambiguous. Build the argument grammar from the set of remaining required parameters so parameters can appear in any order while still requiring each required parameter to be present. Add DeepSeek V4 coverage for optional-before-required parsing, missing required parameter rejection, continuation prompts, reasoning preservation across tool calls, and tool result ordering. Validation: cmake --build build --target test-chat -j 8; git diff --check; build/bin/test-chat --template DeepSeek-V4; build/bin/test-chat. Assisted-by: Codex --- common/chat.cpp | 152 +++++++++++++++--- .../templates/deepseek-ai-DeepSeek-V4.jinja | 7 +- tests/test-chat.cpp | 95 +++++++++++ 3 files changed, 228 insertions(+), 26 deletions(-) diff --git a/common/chat.cpp b/common/chat.cpp index 8f19513343e2..74f99a575e2d 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -70,6 +70,11 @@ static bool has_content_or_tool_calls(const common_chat_msg & msg) { return !msg.content.empty() || !msg.tool_calls.empty(); } +static bool common_chat_template_uses_deepseek_dsml(const std::string & src) { + return src.find("dsml_token") != std::string::npos && + src.find("DSML") != std::string::npos; +} + std::string common_chat_msg::render_content(const std::string & delimiter) const { if (!content.empty() && !content_parts.empty()) { throw std::runtime_error("Cannot specify both content and content_parts"); @@ -1928,8 +1933,8 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha auto schema_info = common_schema_info(); schema_info.resolve_refs(params); - std::vector required_parsers; - std::vector optional_parsers; + std::vector required_indices; + std::vector arg_parsers; for (const auto & [param_name, param_schema] : props.items()) { bool is_required = required.find(param_name) != required.end(); bool is_string = schema_info.resolves_to_string(param_schema); @@ -1947,28 +1952,60 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha p.tool_arg_close(p.literal(PARAM_END))); auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg); + const auto arg_idx = arg_parsers.size(); + arg_parsers.push_back(named_arg); if (is_required) { - required_parsers.push_back(named_arg); - } else { - optional_parsers.push_back(named_arg); + required_indices.push_back(arg_idx); } } - 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(); + auto contains_index = [](const std::vector & values, size_t needle) { + for (const auto value : values) { + if (value == needle) { + return true; + } } - args_seq = args_seq + required_parsers[i]; - } + return false; + }; - if (!optional_parsers.empty()) { - common_peg_parser any_opt = p.choice(); - for (const auto & opt : optional_parsers) { - any_opt |= opt; - } - args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1); - } + std::function &)> build_args_seq = + [&](const std::vector & remaining_required) { + if (arg_parsers.empty()) { + return p.eps(); + } + + common_peg_parser skippable_args = p.choice(); + bool has_skippable_args = false; + for (size_t i = 0; i < arg_parsers.size(); i++) { + if (contains_index(remaining_required, i)) { + continue; + } + skippable_args |= arg_parsers[i]; + has_skippable_args = true; + } + + auto skipped = has_skippable_args ? p.repeat(skippable_args + p.space(), 0, -1) : p.eps(); + if (remaining_required.empty()) { + return skipped; + } + + common_peg_parser args_choice = p.choice(); + for (size_t i = 0; i < remaining_required.size(); i++) { + std::vector next_required; + next_required.reserve(remaining_required.size() - 1); + for (size_t j = 0; j < remaining_required.size(); j++) { + if (i != j) { + next_required.push_back(remaining_required[j]); + } + } + + auto required_arg = arg_parsers[remaining_required[i]] + p.space(); + args_choice |= skipped + required_arg + build_args_seq(next_required); + } + return args_choice; + }; + + common_peg_parser args_seq = build_args_seq(required_indices); common_peg_parser invoke_body = args_seq; auto func_parser = p.tool( @@ -2220,6 +2257,71 @@ static void requires_non_null_content(json & messages) { } } +static void sort_tool_results_by_previous_tool_calls(json & messages) { + GGML_ASSERT(messages.is_array()); + + for (size_t i = 0; i + 1 < messages.size(); i++) { + auto & assistant = messages[i]; + if (!assistant.is_object() || + assistant.value("role", "") != "assistant" || + !assistant.contains("tool_calls") || + !assistant.at("tool_calls").is_array() || + assistant.at("tool_calls").empty()) { + continue; + } + + size_t begin = i + 1; + size_t end = begin; + while (end < messages.size() && + messages[end].is_object() && + messages[end].value("role", "") == "tool") { + end++; + } + if (begin == end) { + continue; + } + + std::vector used(end - begin, false); + std::vector ordered; + ordered.reserve(end - begin); + + for (const auto & tool_call : assistant.at("tool_calls")) { + if (!tool_call.is_object() || + !tool_call.contains("id") || + !tool_call.at("id").is_string()) { + continue; + } + + const auto id = tool_call.at("id").get(); + for (size_t j = begin; j < end; j++) { + auto & tool_result = messages[j]; + if (used[j - begin] || + !tool_result.contains("tool_call_id") || + !tool_result.at("tool_call_id").is_string() || + tool_result.at("tool_call_id").get() != id) { + continue; + } + + ordered.push_back(tool_result); + used[j - begin] = true; + break; + } + } + + for (size_t j = begin; j < end; j++) { + if (!used[j - begin]) { + ordered.push_back(messages[j]); + } + } + + for (size_t j = 0; j < ordered.size(); j++) { + messages[begin + j] = ordered[j]; + } + + i = end - 1; + } +} + // Gemma4 uses a custom tool_responses field instead of role:tool messages. // // This will transform a sequence of messages: @@ -2603,18 +2705,16 @@ std::optional common_chat_try_specialized_template( // DeepSeek V3.2 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. - if (src.find("dsml_token") != std::string::npos && - src.find("function_calls") != std::string::npos && - src.find("DSML") != std::string::npos) { + if (common_chat_template_uses_deepseek_dsml(src) && + src.find("function_calls") != std::string::npos) { LOG_DBG("Using specialized template: DeepSeek V3.2\n"); return common_chat_params_init_deepseek_v3_2(tmpl, params); } // DeepSeek V4 format detection: same DSML invoke/parameter shape as V3.2, // but the outer block is tool_calls instead of function_calls. - if (src.find("dsml_token") != std::string::npos && - src.find("tool_calls>") != std::string::npos && - src.find("DSML") != std::string::npos) { + if (common_chat_template_uses_deepseek_dsml(src) && + src.find("tool_calls>") != std::string::npos) { LOG_DBG("Using specialized template: DeepSeek V4\n"); return common_chat_params_init_deepseek_v3_2(tmpl, params, "tool_calls"); } @@ -2700,6 +2800,10 @@ static common_chat_params common_chat_templates_apply_jinja(const struct common_ workaround::func_args_not_string(params.messages); } + if (common_chat_template_uses_deepseek_dsml(src)) { + workaround::sort_tool_results_by_previous_tool_calls(params.messages); + } + params.extra_context = common_chat_extra_context(); for (auto el : inputs.chat_template_kwargs) { params.extra_context[el.first] = json::parse(el.second); diff --git a/models/templates/deepseek-ai-DeepSeek-V4.jinja b/models/templates/deepseek-ai-DeepSeek-V4.jinja index f19f787b1b7e..b2ef0cc50004 100644 --- a/models/templates/deepseek-ai-DeepSeek-V4.jinja +++ b/models/templates/deepseek-ai-DeepSeek-V4.jinja @@ -11,6 +11,7 @@ {%- set dsml_token = '|DSML|' -%} {%- set thinking_start_token = '' -%} {%- set thinking_end_token = '' -%} +{%- set has_tools = false -%} {%- set tools_header = '## Tools\n\nYou have access to a set of tools to help answer the user\'s question. You can invoke tools by writing a "<' + dsml_token + 'tool_calls>" block like the following:\n\n<' + dsml_token + 'tool_calls>\n<' + dsml_token + 'invoke name="$TOOL_NAME">\n<' + dsml_token + 'parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE\n...\n\n<' + dsml_token + 'invoke name="$TOOL_NAME2">\n...\n\n\n\nString parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.\n\nIf thinking_mode is enabled (triggered by ' + thinking_start_token + '), you MUST output your complete reasoning inside ' + thinking_start_token + '...' + thinking_end_token + ' BEFORE any tool calls or final response.\n\nOtherwise, output directly after ' + thinking_end_token + ' with tool calls or final response.\n\n### Available Tool Schemas\n\n' -%} {%- set tools_footer = '\nYou MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.\n' -%} {%- set ns = namespace(system_prompt='', is_first_sp=true) -%} @@ -25,6 +26,7 @@ {%- endif -%} {%- endfor -%} {%- if tools is defined and tools -%} + {%- set has_tools = true -%} {%- set ts = namespace(schemas='') -%} {%- for tool in tools -%} {%- if tool['type'] == 'function' -%} @@ -67,7 +69,8 @@ {%- set state.in_user = false -%} {{- '<|Assistant|>' -}} {%- set is_after_last_user = loop.index0 > last_user_idx.value -%} - {%- if is_after_last_user and thinking -%} + {%- set preserve_reasoning = thinking and (has_tools or is_after_last_user) -%} + {%- if preserve_reasoning -%} {{- thinking_start_token -}} {%- if message['reasoning_content'] is defined and message['reasoning_content'] -%} {{- message['reasoning_content'] -}} @@ -109,4 +112,4 @@ {%- else -%} {{- thinking_end_token -}} {%- endif -%} -{%- endif -%} \ No newline at end of file +{%- endif -%} diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp index f358692c7e6d..d3ed71c6c93b 100644 --- a/tests/test-chat.cpp +++ b/tests/test-chat.cpp @@ -4010,6 +4010,23 @@ static void test_template_output_peg_parsers(bool detailed_debug) { { { "get_time", R"({"city": "Paris"})" }, { "get_weather", R"({"city": "Paris"})" } })) .run(); + tst.test( + "Optional first\n\n" + "<|DSML|tool_calls>\n" + "<|DSML|invoke name=\"magic_int\">\n" + "<|DSML|parameter name=\"name\" string=\"true\">foo bar\n" + "<|DSML|parameter name=\"ref\" string=\"false\">42\n" + "\n" + "") + .enable_thinking(true) + .reasoning_format(COMMON_REASONING_FORMAT_DEEPSEEK) + .tools({ magic_int_tool }) + .expect_reasoning("Optional first") + .expect_tool_calls({ + { "magic_int", R"({"name": "foo bar", "ref": 42})", {} }, + }) + .run(); + tst.test( "Still thinking\n\n" "<|DSML|tool_calls>\n" @@ -4030,6 +4047,36 @@ static void test_template_output_peg_parsers(bool detailed_debug) { .expect_content("") .expect_tool_calls({}) .run(); + + { + auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V4.jinja"); + + common_chat_templates_inputs inputs; + inputs.messages = { message_user }; + inputs.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK; + inputs.enable_thinking = true; + inputs.tools = { magic_int_tool }; + + make_peg_parser parser(tmpls.get(), inputs, detailed_debug); + + bool got_error = false; + try { + parser.parse( + "Missing required\n\n" + "<|DSML|tool_calls>\n" + "<|DSML|invoke name=\"magic_int\">\n" + "<|DSML|parameter name=\"name\" string=\"true\">foo bar\n" + "\n" + "", + false); + } catch (const std::runtime_error &) { + got_error = true; + } + + if (!got_error) { + throw std::runtime_error("Expected DeepSeek V4 parser to reject missing required parameter"); + } + } } // GLM-4.6 tests - format: function_name\n...\n...\n @@ -5881,6 +5928,54 @@ static void test_template_generation_prompt() { check(tmpls, continuation_reasoning(), "<|Assistant|>I'm"); } + { + auto tmpls = read_templates("models/templates/deepseek-ai-DeepSeek-V4.jinja"); + check(tmpls, basic(), "<|Assistant|>"); + check(tmpls, continuation_content(), "<|Assistant|>I'm thinkingHello, "); + check(tmpls, continuation_reasoning(), "<|Assistant|>I'm"); + + common_chat_msg user_start; + user_start.role = "user"; + user_start.content = "Check time and weather."; + + common_chat_msg assistant_tools; + assistant_tools.role = "assistant"; + assistant_tools.reasoning_content = "Need both"; + assistant_tools.tool_calls = { + { "get_time", R"({"city":"Paris"})", "call_time" }, + { "get_weather", R"({"city":"Paris"})", "call_weather" }, + }; + + common_chat_msg weather_result; + weather_result.role = "tool"; + weather_result.content = "weather result"; + weather_result.tool_call_id = "call_weather"; + + common_chat_msg time_result; + time_result.role = "tool"; + time_result.content = "time result"; + time_result.tool_call_id = "call_time"; + + common_chat_msg user_continue; + user_continue.role = "user"; + user_continue.content = "Continue."; + + common_chat_templates_inputs inputs; + inputs.messages = { user_start, assistant_tools, weather_result, time_result, user_continue }; + inputs.tools = { get_time_tool, get_weather_tool }; + + auto params = common_chat_templates_apply(tmpls.get(), inputs); + assert_contains(params.prompt, "<|Assistant|>Need both\n\n<|DSML|tool_calls>"); + + const auto time_pos = params.prompt.find("time result"); + const auto weather_pos = params.prompt.find("weather result"); + if (time_pos == std::string::npos || weather_pos == std::string::npos || time_pos > weather_pos) { + LOG_ERR("Expected tool results in tool-call order\nActual: %s\n", params.prompt.c_str()); + common_log_flush(common_log_main()); + throw std::runtime_error("Test failed"); + } + } + { auto tmpls = read_templates("models/templates/openbmb-MiniCPM5-1B.jinja"); check(tmpls, basic(), "<|im_start|>assistant\n\n");