From a86bd319541a5d0c76cab87c2faadfccd6a16650 Mon Sep 17 00:00:00 2001 From: Andrei Rusu de Castro Date: Wed, 9 Sep 2026 21:38:55 +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/parsers/qwen3-coder.cpp | 12 +++++++++- tests/test-chat.cpp | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/common/parsers/qwen3-coder.cpp b/common/parsers/qwen3-coder.cpp index 7938a2027932..a51b1b517049 100644 --- a/common/parsers/qwen3-coder.cpp +++ b/common/parsers/qwen3-coder.cpp @@ -106,8 +106,18 @@ common_chat_params common_chat_params_init_qwen3_coder(const common_chat_templat auto types = param.schema->value_types(); + const auto * enum_schema = dynamic_cast(param.schema.get()); + const bool string_enum = enum_schema && std::all_of(enum_schema->values.begin(), enum_schema->values.end(), + [](const json & value) { return value.is_string(); }); + auto arg_value = p.eps(); - if (!types.has(common_chat_schema::TYPE_STRING)) { + if (string_enum) { + auto values = p.choice(); + for (const auto & value : enum_schema->values) { + values |= p.literal(value.get()) + p.peek(p.literal("\n\n")); + } + arg_value = p.tool_arg_string_value(values) + arg_close; + } else if (!types.has(common_chat_schema::TYPE_STRING)) { arg_value = p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", doc, *param.schema)) + arg_close; } else if (types.is_only(common_chat_schema::TYPE_STRING)) { arg_value = arg_string; diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp index 30a7237e314b..f7932ee26f6d 100644 --- a/tests/test-chat.cpp +++ b/tests/test-chat.cpp @@ -7320,6 +7320,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; @@ -7392,6 +7435,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();