From 3ac6efd0683ed8d043a380dafd692af981b5842b Mon Sep 17 00:00:00 2001 From: Jun Yamog Date: Tue, 14 Jul 2026 09:53:06 +0000 Subject: [PATCH] fix(server): parse bare tool-name XML calls --- server/src/server/tool_parser.cpp | 25 ++++++++++++++++++++++ server/test/test_server_unit.cpp | 35 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/server/src/server/tool_parser.cpp b/server/src/server/tool_parser.cpp index 35442515b..c4a29b184 100644 --- a/server/src/server/tool_parser.cpp +++ b/server/src/server/tool_parser.cpp @@ -8,6 +8,7 @@ // 5. call:?{relaxed-JSON args} (gemma plain-text emissions) // 6. Bare JSON objects with name+arguments fields // 7. Whole-response JSON args for exactly one declared tool +// 8. ...V... (bare tool tag) // // Pattern 5 runs *before* pattern 6 so that args like // call:outer{"name": "inner", "arguments": {}} @@ -184,6 +185,11 @@ static const std::regex & re_function_signature() { return r; } +static const std::regex & re_bare_tool_name_xml() { + static std::regex r(R"(<([A-Za-z_][\w.\-]*?)>([\s\S]*?)(?:|))"); + return r; +} + static const std::regex & re_tool_code() { static std::regex r(R"(([\s\S]*?))"); return r; @@ -756,6 +762,25 @@ ToolParseResult parse_tool_calls(const std::string & text, const json & tools) { } } + // Pattern 3b: ...params.... Some agents/models + // emit the selected tool name as the XML tag itself, then close with the + // Qwen tag. Only accept requested tools and real parameter + // tags so arbitrary XML-ish prose remains visible text. + if (tools.is_array() && !tools.empty()) { + auto begin = std::sregex_iterator(text.begin(), text.end(), re_bare_tool_name_xml()); + auto end = std::sregex_iterator(); + for (auto it = begin; it != end; ++it) { + size_t pos = it->position(); + if (overlaps(removals, pos)) continue; + std::string fn_name = (*it)[1].str(); + std::string params = (*it)[2].str(); + if (!tool_allowed(tools, fn_name)) continue; + if (params.find("length()); + } + } + // Pattern 4: {JSON} { auto begin = std::sregex_iterator(text.begin(), text.end(), re_tool_code()); diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 58bd272f3..bc9668fac 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -335,6 +335,40 @@ static void test_parse_bare_function_xml() { } } +static void test_parse_bare_tool_name_xml_with_function_close() { + std::string text = + "\n\n\nLet me find the correct line range for the tests array.\n\n\n" + "\n" + "\n" + "grep -n \"f5.test\" /workspace/project/tests/bootstrap.cjs\n" + "\n" + "\n"; + json tools = json::array({ + {{"type", "function"}, + {"function", { + {"name", "bash"}, + {"parameters", { + {"type", "object"}, + {"properties", { + {"command", {{"type", "string"}}} + }} + }} + }}} + }); + auto result = parse_tool_calls(text, tools); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + TEST_ASSERT(result.tool_calls[0].name == "bash"); + auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["command"] == + "grep -n \"f5.test\" /workspace/project/tests/bootstrap.cjs"); + } + TEST_ASSERT(result.cleaned_text.find("") == std::string::npos); + TEST_ASSERT(result.cleaned_text.find("") == std::string::npos); + TEST_ASSERT(result.cleaned_text.find("Let me find the correct line range") != + std::string::npos); +} + static void test_parse_json_tool_call() { std::string text = "{\"name\": \"search\", \"arguments\": {\"query\": \"hello world\"}}"; @@ -4423,6 +4457,7 @@ int main() { std::fprintf(stderr, "\n── Tool parser ──\n"); RUN_TEST(test_parse_tool_call_xml); RUN_TEST(test_parse_bare_function_xml); + RUN_TEST(test_parse_bare_tool_name_xml_with_function_close); RUN_TEST(test_parse_json_tool_call); RUN_TEST(test_parse_single_tool_bare_json_args); RUN_TEST(test_parse_single_tool_bare_json_args_allows_empty_optional_object);