diff --git a/common/chat.cpp b/common/chat.cpp
index 6da59f4dbd2c..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");
@@ -1856,7 +1861,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 +1885,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 = "" + DSML + "function_calls>";
+ const std::string FC_START = "<" + DSML + tool_calls_tag + ">";
+ const std::string FC_END = "" + DSML + tool_calls_tag + ">";
const std::string INVOKE_START = "<" + DSML + "invoke";
const std::string INVOKE_END = "" + DSML + "invoke>";
const std::string PARAM_START = "<" + DSML + "parameter";
@@ -1902,112 +1908,149 @@ 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;
- }
+ 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();
- if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
- return generation_prompt + reasoning + p.content(p.rest()) + end;
- }
+ std::set required;
+ if (params.contains("required")) {
+ params.at("required").get_to(required);
+ }
- 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();
+ auto schema_info = common_schema_info();
+ schema_info.resolve_refs(params);
+
+ 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);
+
+ 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);
+ const auto arg_idx = arg_parsers.size();
+ arg_parsers.push_back(named_arg);
+ if (is_required) {
+ required_indices.push_back(arg_idx);
+ }
+ }
- std::set required;
- if (params.contains("required")) {
- params.at("required").get_to(required);
- }
+ auto contains_index = [](const std::vector & values, size_t needle) {
+ for (const auto value : values) {
+ if (value == needle) {
+ return true;
+ }
+ }
+ return false;
+ };
- 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);
- }
- }
+ std::function &)> build_args_seq =
+ [&](const std::vector & remaining_required) {
+ if (arg_parsers.empty()) {
+ return p.eps();
+ }
- 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];
- }
+ 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;
+ }
- 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);
- }
+ 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 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 args_seq = build_args_seq(required_indices);
- tool_choice |= p.rule("tool-" + name, func_parser);
- });
+ 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)));
- auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
+ tool_choice |= p.rule("tool-" + name, func_parser);
+ });
- 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));
+ auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
+
+ common_peg_parser tool_calls = p.eps();
+ if (inputs.parallel_tool_calls) {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice +
+ p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
+ } else {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
+ }
+
+ if (!require_tools) {
+ tool_calls = p.optional(tool_calls);
+ }
+
+ auto content_before_tools = p.content(p.until(FC_START));
+ 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();
@@ -2214,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:
@@ -2597,13 +2705,20 @@ 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 (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");
+ }
+
// Gemma4 format detection
if (src.find("'<|tool_call>call:'") != std::string::npos) {
if (src.find("{#- OpenAI Chat Completions:") == std::string::npos) {
@@ -2685,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' + dsml_token + 'parameter>\n...\n' + dsml_token + 'invoke>\n<' + dsml_token + 'invoke name="$TOOL_NAME2">\n...\n' + dsml_token + 'invoke>\n' + dsml_token + 'tool_calls>\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 5f71e5da6e39..d3ed71c6c93b 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -3963,6 +3963,122 @@ 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|DSML|parameter>
+ // |DSML|invoke>
+ // |DSML|tool_calls>
+ {
+ 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|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>")
+ .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|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "<|DSML|invoke name=\"get_weather\">\n"
+ "<|DSML|parameter name=\"city\" string=\"true\">Paris|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>")
+ .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(
+ "Optional first\n\n"
+ "<|DSML|tool_calls>\n"
+ "<|DSML|invoke name=\"magic_int\">\n"
+ "<|DSML|parameter name=\"name\" string=\"true\">foo bar|DSML|parameter>\n"
+ "<|DSML|parameter name=\"ref\" string=\"false\">42|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>")
+ .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"
+ "<|DSML|invoke name=\"get_time\">\n"
+ "<|DSML|parameter name=\"city\" string=\"true\">Tokyo|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>")
+ .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|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>")
+ .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|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>",
+ 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
{
auto tst = peg_tester("models/templates/GLM-4.6.jinja", detailed_debug);
@@ -5812,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");