fix(server): rescue Bailing/Ling tool calls trapped in reasoning

Thinking models of the Bailing family intermittently emit <tool_call>
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 structured calls. update_chat_msg now promotes
well-formed blocks to tool calls on final parses with empty content.
Strictly additive; ids flow through set_tool_call_ids. Verified with a
standalone harness against live-captured payloads.
This commit is contained in:
Marvin 2026-09-07 20:52:38 -03:00
parent bc75f9e602
commit 2e84b9942f
3 changed files with 208 additions and 0 deletions

View File

@ -0,0 +1,122 @@
#pragma once
#include "json.hpp"
#include <string>
#include <regex>
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:
//
// <think>...narration...
// <tool_call>web_search
// <arg_key>query</arg_key>
// <arg_value>some query</arg_value>
// <arg_key>limit</arg_key>
// <arg_value>3</arg_value>
// </tool_call></think>
//
// 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": "<json object string>"}}.
// 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("<tool_call>") == std::string::npos) {
return tool_calls;
}
std::regex block_regex(R"(<tool_call>([\s\S]*?)</tool_call>)");
std::regex pair_regex(
R"(<arg_key>([\s\S]*?)</arg_key>\s*<arg_value>([\s\S]*?)</arg_value>)");
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"(<tool_call>[\s\S]*?</tool_call>)");
return std::regex_search(text, block_regex);
} catch (const std::exception &) {
return false;
}
}
} // namespace bailing

View File

@ -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
// <tool_call> 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<std::string>();
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;

View File

@ -0,0 +1,51 @@
// Standalone harness for bailing_parser.hpp (no full build needed).
#include <cassert>
#include <iostream>
#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 =
"<tool_call>web_search\n"
"<arg_key>limit</arg_key>\n"
"<arg_value>3</arg_value><arg_key>query</arg_key>\n"
"<arg_value>Proxmox GPU passthrough troubleshooting 2026</arg_value>\n"
"</tool_call>";
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<std::string>());
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("<tool_call>web_search\n<arg_key>q").empty());
CHECK(!bailing::has_complete_block("<tool_call>web_search\n<arg_key>q"));
// 3. Tag name as function name is rescued like any identifier (client decides).
json weird = bailing::parse_tool_calls(
"<tool_call>tool_call\n<arg_key>q</arg_key>\n<arg_value>x</arg_value>\n</tool_call>");
CHECK(weird.size() == 1 && weird[0]["function"]["name"] == "tool_call");
// 4. Garbage function name rejected.
CHECK(bailing::parse_tool_calls("<tool_call>prompt: ANT</role></tool_call>").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;
}