From fe2e0575ca48e3a78ada13a1847add5d404daf32 Mon Sep 17 00:00:00 2001 From: Andrei Rusu de Castro Date: Wed, 9 Sep 2026 21:38:52 +0200 Subject: [PATCH] chat : enforce string enums in Qwen XML tool arguments Use allowed XML literals instead of the unrestricted string production. Require the parameter-close boundary when matching alternatives with shared prefixes. Cover rejected values, empty and escaped strings, parsing, streaming and ordinary-string behavior in the existing chat suite. Assisted-by: GPT-6 Astra --- common/chat.cpp | 10 ++++++++++ tests/test-chat.cpp | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/common/chat.cpp b/common/chat.cpp index 743ecde0a77e..ce3e1cb22fd8 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -1270,6 +1270,16 @@ static common_chat_params common_chat_params_init_qwen3_coder(const common_chat_ 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; + if (param_schema.contains("enum") && param_schema.at("enum").is_array() && + !param_schema.at("enum").empty() && + std::all_of(param_schema.at("enum").begin(), param_schema.at("enum").end(), + [](const json & value) { return value.is_string(); })) { + auto values = p.choice(); + for (const auto & value : param_schema.at("enum")) { + values |= p.literal(value.get()) + p.peek(p.literal("\n\n")); + } + arg_value = p.tool_arg_string_value(values) + arg_close; + } auto arg_rule = p.rule(rule_name, p.tool_arg(arg_open + arg_value)); diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp index 7918f0ffcf48..41d2c005e5cf 100644 --- a/tests/test-chat.cpp +++ b/tests/test-chat.cpp @@ -7154,6 +7154,49 @@ static void test_msg_diffs_compute() { } } +static void test_qwen_string_enums() { + const auto xml = [](const std::string & value) { + return "\n\n\n" + value + + "\n\n\n"; + }; + for (const std::string path : {"models/templates/Qwen3-Coder.jinja", "models/templates/Qwen3.5-4B.jinja"}) { + auto tmpls = read_templates(path); + common_chat_templates_inputs in; + in.messages = {message_user}; + in.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK; + in.parallel_tool_calls = true; + auto schema = json::parse(R"({"type":"object","properties":{"action":{"type":"string","enum":["load","reload","re","","quoted\"value","line\nbreak"]}},"required":["action"],"additionalProperties":false})"); + in.tools = {{"records", "Access records.", schema.dump()}}; + auto params = common_chat_templates_apply(tmpls.get(), in); + for (const std::string value : {"erase", "loading", "reloaded", " load", "load "}) { + auto grammar = build_grammar(params.grammar); + assert_equals(true, grammar != nullptr); + assert_equals(false, match_string(xml(value), grammar.get())); + } + for (const auto & value : schema["properties"]["action"]["enum"]) { + const auto text = value.get(); + auto grammar = build_grammar(params.grammar); + assert_equals(true, match_string(xml(text), grammar.get())); + test_peg_parser(tmpls.get(), [&](peg_test_case & tc) { + tc.params = in; + tc.input = (path.find("Qwen3-Coder") == std::string::npos ? "\n\n" : "") + xml(text); + tc.expect = simple_assist_msg("", "", "records", json({{"action", text}}).dump()); + }, false); + } + test_peg_parser(tmpls.get(), [&](peg_test_case & tc) { + tc.params = in; + tc.input = (path.find("Qwen3-Coder") == std::string::npos ? "\n\n" : "") + xml("re") + "\n" + xml("reload"); + tc.expect.role = "assistant"; + tc.expect.tool_calls = {{"records", R"({"action":"re"})", ""}, {"records", R"({"action":"reload"})", ""}}; + }, false); + schema["properties"]["action"].erase("enum"); + in.tools = {{"records", "Access records.", schema.dump()}}; + params = common_chat_templates_apply(tmpls.get(), in); + auto grammar = build_grammar(params.grammar); + assert_equals(true, match_string(xml("erase"), grammar.get())); + } +} + int main(int argc, char ** argv) { bool detailed_debug = false; bool only_run_filtered = false; @@ -7226,6 +7269,7 @@ int main(int argc, char ** argv) { } else #endif { + test_qwen_string_enums(); test_msg_diffs_compute(); test_msgs_oaicompat_json_conversion(); test_msg_token_delimiters_split();