diff --git a/examples/server/parsers/bailing_parser.hpp b/examples/server/parsers/bailing_parser.hpp new file mode 100644 index 00000000..e953062b --- /dev/null +++ b/examples/server/parsers/bailing_parser.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include "json.hpp" +#include +#include + +using json = nlohmann::ordered_json; + +// +// Bailing / Ling-Flash Function Calling Parser (arg_key / arg_value XML format) +// +// Rationale: thinking models of the Bailing family (verified: Ling-3.0-Flash) +// intermittently emit their tool call inside the thinking block instead of the +// content position: +// +// ...narration... +// web_search +// query +// some query +// limit +// 3 +// +// +// The PEG layer consumes the thinking region as reasoning_content before the +// tool stage runs, so the call is lost: the response carries empty content, +// no structured tool calls, and the call text parked in reasoning_content. +// This parser recovers well-formed blocks from that region. It mirrors the +// behavior validated client-side (Hermes reasoning_tool_rescue.py, 330 +// rescued turns over a 2h probe with no loop breakage): +// - only complete open-to-close blocks; truncated fragments are skipped, +// - function name must be a bare identifier (first stripped line), +// - argument values stay raw strings; the client coerces simple scalars +// against its schemas downstream, +// - never throws: malformed input yields fewer (or zero) calls. +// NOTE: a block naming the literal tag ("tool_call" as function name) is +// rescued like any other name; the client decides dispatch. Malformed +// emissions of that shape produced downstream arg errors, never loop breaks. +// +namespace bailing { + +static constexpr int k_max_blocks_per_message = 8; + +// Parse Bailing XML-style tool calls from free text (typically reasoning). +// Returns a JSON array of {"id": "", "type": "function", +// {"function": {"name": ..., "arguments": ""}}. +// ids are intentionally empty: common_chat_msg::set_tool_call_ids fills them +// through the normal path when the caller appends before that call. +static json parse_tool_calls(const std::string & text) { + json tool_calls = json::array(); + + try { + if (text.find("") == std::string::npos) { + return tool_calls; + } + std::regex block_regex(R"(([\s\S]*?))"); + std::regex pair_regex( + R"(([\s\S]*?)\s*([\s\S]*?))"); + std::regex name_regex(R"(^[A-Za-z0-9_\-]{1,64}$)"); + + auto trim = [](std::string s) { + const char * ws = " \t\n\r"; + s.erase(0, s.find_first_not_of(ws)); + if (!s.empty()) { + s.erase(s.find_last_not_of(ws) + 1); + } + return s; + }; + + int blocks = 0; + std::sregex_iterator it(text.begin(), text.end(), block_regex); + std::sregex_iterator end; + for (; it != end && blocks < k_max_blocks_per_message; ++it, ++blocks) { + std::string block = (*it)[1].str(); + + // Function name: first stripped line of the block. + std::string first_line; + { + size_t nl = block.find('\n'); + first_line = trim(nl == std::string::npos ? block : block.substr(0, nl)); + } + if (!std::regex_match(first_line, name_regex)) { + continue; + } + + json args = json::object(); + std::sregex_iterator pit(block.begin(), block.end(), pair_regex); + for (; pit != end; ++pit) { + std::string key = trim((*pit)[1].str()); + std::string value = trim((*pit)[2].str()); + if (!key.empty()) { + args[key] = value; // last duplicate wins + } + } + + json tool_call = { + {"id", ""}, + {"type", "function"}, + {"function", { + {"name", first_line}, + {"arguments", args.dump()}, + }}, + }; + tool_calls.push_back(tool_call); + } + } catch (const std::exception &) { + return json::array(); + } + + return tool_calls; +} + +// True when the text carries at least one complete Bailing tool-call block. +static bool has_complete_block(const std::string & text) { + try { + std::regex block_regex(R"([\s\S]*?)"); + return std::regex_search(text, block_regex); + } catch (const std::exception &) { + return false; + } +} + +} // namespace bailing diff --git a/examples/server/server-context.cpp b/examples/server/server-context.cpp index c53e5d15..40a8e119 100644 --- a/examples/server/server-context.cpp +++ b/examples/server/server-context.cpp @@ -3,6 +3,7 @@ #include "server-common.h" #include "server-task.h" #include "server-queue.h" +#include "parsers/bailing_parser.hpp" #include "common.h" #include "llama.h" @@ -716,6 +717,40 @@ const common_chat_msg& server_slot::update_chat_msg(bool is_partial, std::vector /* is_partial= */ stop != STOP_TYPE_EOS, params.chat_parser_params); if (!new_msg.empty()) { + // BAILING-RESCUE (Ling/Bailing family): thinking models sometimes emit + // arg_key/arg_value XML inside the thinking block; the PEG + // layer consumes that region as reasoning_content before the tool stage, + // so the call is lost (empty content, no tool calls). Recover + // well-formed blocks here so they execute instead of stalling the + // agent loop. Strictly additive: final (non-partial) parses only, and + // only when structured calls are absent and content is empty. Appended + // before set_tool_call_ids so ids flow through the normal path. + // Revert: delete this block (logic lives in parsers/bailing_parser.hpp). + if (!is_partial && new_msg.tool_calls.empty() && new_msg.content.empty() + && !new_msg.reasoning_content.empty()) { + const json rescued = bailing::parse_tool_calls(new_msg.reasoning_content); + for (const auto & tc_json : rescued) { + if (!tc_json.contains("function") || !tc_json["function"].is_object()) { + continue; + } + const auto & fn = tc_json["function"]; + if (!fn.contains("name") || !fn["name"].is_string()) { + continue; + } + common_chat_tool_call tc; + tc.id = tc_json.value("id", ""); + tc.name = fn["name"].get(); + tc.arguments = fn.value("arguments", std::string("{}")); + if (tc.name.empty()) { + continue; + } + new_msg.tool_calls.push_back(std::move(tc)); + } + if (!rescued.empty() && !new_msg.tool_calls.empty()) { + LLAMA_LOG_WARN("Bailing rescue: promoted %d tool call(s) trapped in reasoning_content\n", + (int) new_msg.tool_calls.size()); + } + } //new_msg.ensure_tool_call_ids_set(generated_tool_call_ids, gen_tool_call_id); new_msg.set_tool_call_ids(generated_tool_call_ids, gen_tool_call_id); chat_msg = new_msg; diff --git a/examples/server/test-bailing-parser.cpp b/examples/server/test-bailing-parser.cpp new file mode 100644 index 00000000..d835da28 --- /dev/null +++ b/examples/server/test-bailing-parser.cpp @@ -0,0 +1,51 @@ +// Standalone harness for bailing_parser.hpp (no full build needed). +#include +#include + +#include "parsers/bailing_parser.hpp" + +static int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cout << "FAIL line " << __LINE__ << ": " #cond "\n"; failures++; } } while (0) + +int main() { + // 1. Exact live-captured trapped call (Ling-3.0-Flash via ik_llama). + std::string trapped = + "web_search\n" + "limit\n" + "3query\n" + "Proxmox GPU passthrough troubleshooting 2026\n" + ""; + json calls = bailing::parse_tool_calls(trapped); + CHECK(calls.size() == 1); + CHECK(calls[0]["function"]["name"] == "web_search"); + json args = json::parse(calls[0]["function"]["arguments"].get()); + CHECK(args["query"] == "Proxmox GPU passthrough troubleshooting 2026"); + CHECK(args["limit"] == "3"); // raw strings; client coerces downstream + CHECK(calls[0]["id"] == ""); // filled later by set_tool_call_ids + CHECK(bailing::has_complete_block(trapped)); + + // 2. Truncated block: no rescue. + CHECK(bailing::parse_tool_calls("web_search\nq").empty()); + CHECK(!bailing::has_complete_block("web_search\nq")); + + // 3. Tag name as function name is rescued like any identifier (client decides). + json weird = bailing::parse_tool_calls( + "tool_call\nq\nx\n"); + CHECK(weird.size() == 1 && weird[0]["function"]["name"] == "tool_call"); + + // 4. Garbage function name rejected. + CHECK(bailing::parse_tool_calls("prompt: ANT").empty()); + + // 5. Multiple blocks, cap respected. + std::string multi; + for (int i = 0; i < 20; i++) multi += trapped; + CHECK(bailing::parse_tool_calls(multi).size() == 8); + + // 6. Plain text: nothing. + CHECK(bailing::parse_tool_calls("Just thinking aloud here.").empty()); + CHECK(bailing::parse_tool_calls("").empty()); + + if (failures == 0) std::cout << "ALL BAILING PARSER CHECKS PASSED\n"; + return failures; +}