Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1246,9 +1246,24 @@ static common_chat_params common_chat_params_init_qwen3_coder(const common_chat_

// Tool call parser
if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) {
auto arg_close = p.tool_arg_close(p.literal("\n</parameter>\n"));
// Newlines around tags are optional (MiMo-V2.6-Distill-Qwen-9B emits none). A value ends
// at </parameter> only when the next tag follows, so a literal </parameter> is kept.
std::vector<std::string> arg_close_scan;
for (const char * nl : { "\n", "" }) {
for (const char * ws : { "", "\n", "\n\n", "\r\n", " " }) {
for (const char * next : { "<parameter=", "</function>" }) {
arg_close_scan.push_back(std::string(nl) + "</parameter>" + ws + next);
}
}
}
auto arg_close = p.tool_arg_close(p.optional(p.literal("\n")) + p.literal("</parameter>"));
// peek: do not close the value early while streaming
auto next_tag = p.peek(p.space() + p.choice({ p.literal("<parameter="), p.literal("</function>") }));
auto arg_value = p.tool_arg_string_value(p.until_one_of(arg_close_scan)) +
p.tool_arg_close(p.optional(p.literal("\n")) + p.literal("</parameter>") + next_tag);
// grammar: newline form or packed form
auto arg_string = p.rule("xml-arg-string",
p.ac(p.tool_arg_string_value(p.until("\n</parameter>\n")) + arg_close, "\n</parameter>\n"));
p.choice({ p.ac(arg_value, "\n</parameter>\n"), p.ac(arg_value, "</parameter>") }) + p.space());

auto tool_choice = p.choice();
foreach_function(inputs.tools, [&](const json & tool) {
Expand All @@ -1265,11 +1280,12 @@ static common_chat_params common_chat_params_init_qwen3_coder(const common_chat_
foreach_parameter(function, [&](const std::string & param_name, const json & param_schema, bool is_required) {
auto rule_name = "tool-" + name + "-arg-" + param_name;

auto arg_open = p.tool_arg_open("<parameter=" + p.tool_arg_name(p.literal(param_name)) + ">\n");
auto arg_open = p.tool_arg_open("<parameter=" + p.tool_arg_name(p.literal(param_name)) +
">" + p.optional(p.literal("\n")));

auto arg_value = schema_info.resolves_to_string(param_schema) ?
arg_string :
p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", param_schema)) + arg_close;
p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", param_schema)) + arg_close + p.space();

auto arg_rule = p.rule(rule_name, p.tool_arg(arg_open + arg_value));

Expand All @@ -1283,21 +1299,23 @@ static common_chat_params common_chat_params_init_qwen3_coder(const common_chat_
args = args + p.zero_or_more(p.choice(optional_args));
}

auto func = p.tool(p.tool_open("<function=" + p.tool_name(p.literal(name)) + ">\n") +
auto func = p.tool(p.tool_open("<function=" + p.tool_name(p.literal(name)) +
">" + p.optional(p.literal("\n"))) +
p.tool_args(args) +
p.tool_close(p.literal("</function>\n")));
p.tool_close(p.literal("</function>") + p.space()));

tool_choice |= p.rule("tool-" + name, func);
});

auto min_calls = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED ? 1 : 0;

auto tool_call_body = tool_choice + "</tool_call>" + p.space();
auto tool_call = p.rule("tool-call", "<tool_call>\n" + tool_call_body);
auto tool_call_open = p.literal("<tool_call>") + p.optional(p.literal("\n"));
auto tool_call = p.rule("tool-call", tool_call_open + tool_call_body);

// Qwen3-Coder models may occasionally omit the <tool_call> token.
auto tool_call_first = is_qwen3_coder ?
p.rule("tool-call-first", p.optional(p.literal("<tool_call>\n")) + tool_call_body) :
p.rule("tool-call-first", p.optional(tool_call_open) + tool_call_body) :
tool_call;

auto calls = inputs.parallel_tool_calls ? tool_call_first + p.zero_or_more(tool_call) : tool_call_first;
Expand Down
47 changes: 47 additions & 0 deletions tests/test-chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3511,6 +3511,53 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
.expect_reconstruction()
.run();

// Packed, no newlines (MiMo-V2.6-Distill-Qwen-9B)
tst.test("<tool_call><function=special_function><parameter=arg1>1</parameter></function></tool_call>")
.tools({ special_function_tool })
.expect(message_assist_call)
.run();

// Half-packed
tst.test(
"<tool_call>\n"
"<function=special_function>\n"
"<parameter=arg1>\n"
"1</parameter></function></tool_call>")
.tools({ special_function_tool })
.expect(message_assist_call)
.run();

// Literal </parameter> inside a string value
tst.test(
"<tool_call>\n"
"<function=python>\n"
"<parameter=code>\n"
"print(\"</parameter>\")\n"
"</parameter>\n"
"</function>\n"
"</tool_call>")
.tools({ python_tool })
.expect_tool_calls({
{ "python", R"j({"code": "print(\"</parameter>\")"})j", {} },
})
.expect_reconstruction()
.run();

// Packed parallel calls
tst.test(
"<tool_call><function=special_function><parameter=arg1>1</parameter></function></tool_call>"
"<tool_call><function=special_function_with_opt><parameter=arg1>1</parameter>"
"<parameter=arg2>2</parameter></function></tool_call>")
.parallel_tool_calls(true)
.tools({
special_function_tool, special_function_tool_with_optional_param
})
.expect_tool_calls({
{ "special_function", R"({"arg1": 1})", {} },
{ "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
})
.run();

// Some models skip the opening <tool_call> and go straight to <function=>
tst.test(
"<function=special_function>\n"
Expand Down
Loading