Skip to content

Commit da87e9b

Browse files
authored
common/chat : unify and fix LFM2/LFM2.5 tool parser (ggml-org#24178)
1 parent e82beaa commit da87e9b

4 files changed

Lines changed: 143 additions & 133 deletions

File tree

common/chat-peg-parser.cpp

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ static std::string normalize_quotes_to_json(const std::string & input) {
8787
bool in_single_quoted = false;
8888
bool in_double_quoted = false;
8989

90+
auto is_word_char = [](char ch) { return std::isalnum(static_cast<unsigned char>(ch)) || ch == '_'; };
91+
9092
for (size_t i = 0; i < input.size(); ++i) {
9193
char c = input[i];
9294

@@ -151,6 +153,29 @@ static std::string normalize_quotes_to_json(const std::string & input) {
151153
in_single_quoted = true;
152154
result += '"';
153155
}
156+
} else if (!in_single_quoted && !in_double_quoted && (c == 'T' || c == 'F' || c == 'N') &&
157+
(i == 0 || !is_word_char(input[i - 1]))) {
158+
// Python literals -> JSON; prefix match keeps streamed partials monotonic.
159+
static constexpr std::pair<std::string_view, std::string_view> literals[] = {
160+
{ "True", "true" }, { "False", "false" }, { "None", "null" },
161+
};
162+
size_t n = 0;
163+
while (i + n < input.size() && is_word_char(input[i + n])) {
164+
++n;
165+
}
166+
std::string_view token(input.data() + i, n);
167+
bool matched = false;
168+
for (const auto & [py, js] : literals) {
169+
if (py.substr(0, n) == token) {
170+
result += js.substr(0, n);
171+
i += n - 1;
172+
matched = true;
173+
break;
174+
}
175+
}
176+
if (!matched) {
177+
result += c;
178+
}
154179
} else {
155180
result += c;
156181
}
@@ -353,12 +378,8 @@ void common_chat_peg_mapper::map(const common_peg_ast_node & node) {
353378
}
354379
value_to_add += escape_json_string_inner(value_content);
355380
} else if (!value_content.empty()) {
356-
// For potential containers, normalize Python-style single quotes to JSON double quotes
357-
bool is_potential_container = value_content[0] == '[' || value_content[0] == '{';
358-
if (is_potential_container) {
359-
value_content = normalize_container_value(value_content);
360-
}
361-
value_to_add += value_content;
381+
// Pythonic scalars/containers -> JSON.
382+
value_to_add += normalize_container_value(value_content);
362383
}
363384

364385
args_target() += value_to_add;
@@ -466,11 +487,34 @@ common_peg_parser common_chat_peg_builder::standard_constructed_tools(
466487
return force_tool_calls ? section : optional(section);
467488
}
468489

490+
// Like python_value(), but the leaf also accepts JSON-cased true/false/null, used by LFM2/LFM2.5
491+
common_peg_parser common_chat_peg_builder::python_or_json_value() {
492+
return rule("python-or-json-value", [this]() {
493+
auto ws = space();
494+
auto value = python_or_json_value();
495+
496+
auto member = sequence({ python_string(), ws, literal(":"), ws, value });
497+
auto members = sequence({ member, zero_or_more(sequence({ ws, literal(","), ws, member })) });
498+
auto dict = rule("python-or-json-dict", [&]() {
499+
return sequence({ literal("{"), ws, choice({ literal("}"), sequence({ members, ws, literal("}") }) }), ws });
500+
});
501+
502+
auto elements = sequence({ value, zero_or_more(sequence({ literal(","), ws, value })) });
503+
auto array = rule("python-or-json-array", [&]() {
504+
return sequence({ literal("["), ws, choice({ literal("]"), sequence({ elements, ws, literal("]") }) }), ws });
505+
});
506+
507+
return choice({ dict, array, python_string(), python_number(),
508+
python_bool(), python_null(), json_bool(), json_null() });
509+
});
510+
}
511+
469512
// Python-style tool calls: name(arg1="value1", arg2=123)
470513
// Used only by LFM2 for now, so we don't merge it into autoparser
471514
common_peg_parser common_chat_peg_builder::python_style_tool_calls(
472515
const ordered_json & tools,
473-
bool parallel_tool_calls) {
516+
bool parallel_tool_calls,
517+
bool allow_json_literals) {
474518
if (!tools.is_array() || tools.empty()) {
475519
return eps();
476520
}
@@ -504,7 +548,7 @@ common_peg_parser common_chat_peg_builder::python_style_tool_calls(
504548
if (is_string_type) {
505549
arg_value_parser = string_value_parser;
506550
} else {
507-
arg_value_parser = tool_arg_value(python_value());
551+
arg_value_parser = tool_arg_value(allow_json_literals ? python_or_json_value() : python_value());
508552
}
509553

510554
// Full argument: name="value" or name=value

common/chat-peg-parser.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,13 @@ class common_chat_peg_builder : public common_peg_parser_builder {
132132
// Helper for Python-style function call format: name(arg1="value1", arg2=123)
133133
// Used by LFM2 and similar templates
134134
common_peg_parser python_style_tool_calls(const nlohmann::ordered_json & tools,
135-
bool parallel_tool_calls);
135+
bool parallel_tool_calls,
136+
bool allow_json_literals);
136137

137138
private:
139+
// Python values plus JSON true/false/null.
140+
common_peg_parser python_or_json_value();
141+
138142
// Implementation helpers for standard_json_tools — one per JSON tool call layout mode
139143
common_peg_parser build_json_tools_function_is_key(const nlohmann::ordered_json & tools,
140144
const std::string & args_key,
@@ -195,4 +199,3 @@ struct tagged_peg_parser {
195199

196200
tagged_peg_parser build_tagged_peg_parser(
197201
const std::function<common_peg_parser(common_peg_parser_builder & builder)> & fn);
198-

common/chat.cpp

Lines changed: 26 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,42 +1608,40 @@ static common_chat_params common_chat_params_init_kimi_k2(const common_chat_temp
16081608
return data;
16091609
}
16101610

1611-
// LFM2 format: uses <|tool_list_start|>[...]<|tool_list_end|> in system prompt
1612-
// and <|tool_call_start|>[name(arg="val")]<|tool_call_end|> for tool calls.
1613-
// - Reasoning: <think>{reasoning}</think> (optional)
1614-
// - Content: text before a tool call (optional)
1615-
// - Tool calls: Python-style, e.g. [function_name(arg1="value1", arg2="value2")]
1616-
// Tool calls can appear multiple times (parallel tool calls supported)
1617-
static common_chat_params common_chat_params_init_lfm2(const common_chat_template & tmpl,
1618-
const autoparser::generation_params & inputs) {
1611+
// LFM2/LFM2.5 parser. Tool calls are almost Python-style and parallel-capable
1612+
// (except dotted names and JSON literals true/false/null).
1613+
// Always wrapped in <|tool_call_start|>[name(args)]<|tool_call_end|> with optional <think> reasoning.
1614+
// tool_list_tokens preserves LFM2 system tool-list markers.
1615+
static common_chat_params common_chat_params_init_lfm2(const common_chat_template & tmpl,
1616+
const autoparser::generation_params & inputs,
1617+
bool tool_list_tokens) {
16191618
common_chat_params data;
16201619

1621-
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
1622-
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
1623-
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
1624-
data.supports_thinking = true;
1625-
data.preserved_tokens = {
1626-
"<|tool_list_start|>",
1627-
"<|tool_list_end|>",
1628-
"<|tool_call_start|>",
1629-
"<|tool_call_end|>",
1630-
"<think>",
1631-
"</think>",
1632-
};
1633-
1634-
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
1635-
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
1636-
auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE;
1637-
16381620
const std::string TOOL_CALL_START = "<|tool_call_start|>";
16391621
const std::string TOOL_CALL_END = "<|tool_call_end|>";
1622+
const std::string TOOL_LIST_START = "<|tool_list_start|>";
1623+
const std::string TOOL_LIST_END = "<|tool_list_end|>";
16401624
const std::string THINK_START = "<think>";
16411625
const std::string THINK_END = "</think>";
16421626
const std::string GEN_PROMPT = "<|im_start|>assistant\n";
16431627

1628+
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
1629+
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
1630+
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
1631+
data.supports_thinking = true;
1632+
data.preserved_tokens = { TOOL_CALL_START, TOOL_CALL_END, THINK_START, THINK_END };
1633+
if (tool_list_tokens) {
1634+
data.preserved_tokens.push_back(TOOL_LIST_START);
1635+
data.preserved_tokens.push_back(TOOL_LIST_END);
1636+
}
1637+
16441638
data.thinking_start_tag = THINK_START;
16451639
data.thinking_end_tag = THINK_END;
16461640

1641+
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
1642+
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
1643+
auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE;
1644+
16471645
if (inputs.has_continuation()) {
16481646
const auto & msg = inputs.continue_msg;
16491647

@@ -1670,7 +1668,7 @@ static common_chat_params common_chat_params_init_lfm2(const common_chat_templat
16701668
auto tool_calls = p.rule("tool-calls",
16711669
p.trigger_rule("tool-call",
16721670
p.literal(TOOL_CALL_START) +
1673-
p.python_style_tool_calls(inputs.tools, inputs.parallel_tool_calls) +
1671+
p.python_style_tool_calls(inputs.tools, inputs.parallel_tool_calls, /* allow_json_literals = */ true) +
16741672
p.literal(TOOL_CALL_END)
16751673
)
16761674
);
@@ -1697,93 +1695,6 @@ static common_chat_params common_chat_params_init_lfm2(const common_chat_templat
16971695
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, TOOL_CALL_START }
16981696
};
16991697
}
1700-
return data;
1701-
}
1702-
1703-
// LFM2.5 format: uses plain "List of tools: [...]" in system prompt, no wrapper tokens.
1704-
// Tool calls are bare [name(arg="val")], though model may optionally emit <|tool_call_start|>.
1705-
// - Reasoning: <think>{reasoning}</think> (optional)
1706-
// - Content: text before a tool call (optional)
1707-
// - Tool calls: Python-style, e.g. [function_name(arg1="value1", arg2="value2")]
1708-
// Tool calls can appear multiple times (parallel tool calls supported)
1709-
static common_chat_params common_chat_params_init_lfm2_5(const common_chat_template & tmpl,
1710-
const autoparser::generation_params & inputs) {
1711-
common_chat_params data;
1712-
1713-
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
1714-
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
1715-
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
1716-
data.supports_thinking = true;
1717-
data.preserved_tokens = {
1718-
"<|tool_call_start|>",
1719-
"<|tool_call_end|>",
1720-
"<think>",
1721-
"</think>",
1722-
};
1723-
1724-
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
1725-
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
1726-
auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE;
1727-
1728-
const std::string THINK_START = "<think>";
1729-
const std::string THINK_END = "</think>";
1730-
const std::string GEN_PROMPT = "<|im_start|>assistant\n";
1731-
1732-
data.thinking_start_tag = THINK_START;
1733-
data.thinking_end_tag = THINK_END;
1734-
1735-
if (inputs.has_continuation()) {
1736-
const auto & msg = inputs.continue_msg;
1737-
1738-
data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
1739-
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
1740-
data.generation_prompt += THINK_END + msg.render_content();
1741-
}
1742-
1743-
data.prompt += data.generation_prompt;
1744-
}
1745-
1746-
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
1747-
auto generation_prompt = p.literal(GEN_PROMPT);
1748-
auto end = p.end();
1749-
1750-
auto reasoning = p.eps();
1751-
if (extract_reasoning && inputs.enable_thinking) {
1752-
reasoning = p.optional(THINK_START + p.reasoning(p.until(THINK_END)) + THINK_END);
1753-
}
1754-
1755-
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
1756-
return generation_prompt + reasoning + p.content(p.rest()) + end;
1757-
}
1758-
1759-
auto tool_calls = p.rule("tool-calls",
1760-
p.trigger_rule("tool-call",
1761-
p.python_style_tool_calls(inputs.tools, inputs.parallel_tool_calls)
1762-
)
1763-
);
1764-
1765-
auto content = p.content(p.until_one_of({"<|tool_call_start|>", "["}));
1766-
auto maybe_start = p.optional(p.literal("<|tool_call_start|>"));
1767-
return generation_prompt + reasoning + content + maybe_start + tool_calls + end;
1768-
});
1769-
1770-
data.parser = parser.save();
1771-
1772-
if (include_grammar) {
1773-
data.grammar_lazy = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
1774-
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
1775-
foreach_function(inputs.tools, [&](const json & tool) {
1776-
const auto & function = tool.at("function");
1777-
auto schema = function.at("parameters");
1778-
builder.resolve_refs(schema);
1779-
});
1780-
parser.build_grammar(builder, data.grammar_lazy);
1781-
});
1782-
foreach_function(inputs.tools, [&](const json & tool) {
1783-
const std::string name = tool.at("function").at("name");
1784-
data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "[" + name + "(" });
1785-
});
1786-
}
17871698

17881699
return data;
17891700
}
@@ -2298,14 +2209,14 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
22982209

22992210
if (is_lfm2_template(src)) {
23002211
LOG_DBG("Using specialized template: LFM2\n");
2301-
return common_chat_params_init_lfm2(tmpl, params);
2212+
return common_chat_params_init_lfm2(tmpl, params, /* tool_list_tokens = */ true);
23022213
}
23032214

23042215
// LFM2.5 format detection: template uses plain "List of tools: [...]" with no special tokens
23052216
if (src.find("List of tools: [") != std::string::npos &&
23062217
src.find("<|tool_list_start|>") == std::string::npos) {
23072218
LOG_DBG("Using specialized template: LFM2.5\n");
2308-
return common_chat_params_init_lfm2_5(tmpl, params);
2219+
return common_chat_params_init_lfm2(tmpl, params, /* tool_list_tokens = */ false);
23092220
}
23102221

23112222
// GigaChatV3 format detection

0 commit comments

Comments
 (0)