Skip to content

Commit 1d6d4cf

Browse files
authored
fix: tool call parsing for LFM2 and LFM2.5 models (ggml-org#21242)
* fix: tool call parsing for LFM2 and LFM2.5 models' * refactor: add test / break out lfm2 and lfm2.5 parsing logic
1 parent 744c0c7 commit 1d6d4cf

3 files changed

Lines changed: 197 additions & 9 deletions

File tree

common/chat.cpp

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,11 +1274,12 @@ static common_chat_params common_chat_params_init_kimi_k2(const common_chat_temp
12741274
return data;
12751275
}
12761276

1277-
// LFM2 format:
1278-
// - Reasoning: <think>{reasoning}</think> (optional, only if enable_thinking is true)
1279-
// - Content: text after reasoning (optional)
1280-
// - Tool calls: <|tool_call_start|>[function_name(arg1="value1", arg2="value2")]<|tool_call_end|>
1281-
// Tool calls can appear multiple times (parallel tool calls)
1277+
// LFM2 format: uses <|tool_list_start|>[...]<|tool_list_end|> in system prompt
1278+
// and <|tool_call_start|>[name(arg="val")]<|tool_call_end|> for tool calls.
1279+
// - Reasoning: <think>{reasoning}</think> (optional)
1280+
// - Content: text before a tool call (optional)
1281+
// - Tool calls: Python-style, e.g. [function_name(arg1="value1", arg2="value2")]
1282+
// Tool calls can appear multiple times (parallel tool calls supported)
12821283
static common_chat_params common_chat_params_init_lfm2(const common_chat_template & tmpl,
12831284
const autoparser::generation_params & inputs) {
12841285
common_chat_params data;
@@ -1319,9 +1320,9 @@ static common_chat_params common_chat_params_init_lfm2(const common_chat_templat
13191320
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
13201321
return generation_prompt + reasoning + p.content(p.rest()) + end;
13211322
}
1322-
13231323
auto tool_calls = p.rule("tool-calls",
1324-
p.trigger_rule("tool-call", p.literal(TOOL_CALL_START) +
1324+
p.trigger_rule("tool-call",
1325+
p.literal(TOOL_CALL_START) +
13251326
p.python_style_tool_calls(inputs.tools, inputs.parallel_tool_calls) +
13261327
p.literal(TOOL_CALL_END)
13271328
)
@@ -1349,6 +1350,80 @@ static common_chat_params common_chat_params_init_lfm2(const common_chat_templat
13491350
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, TOOL_CALL_START }
13501351
};
13511352
}
1353+
return data;
1354+
}
1355+
1356+
// LFM2.5 format: uses plain "List of tools: [...]" in system prompt, no wrapper tokens.
1357+
// Tool calls are bare [name(arg="val")], though model may optionally emit <|tool_call_start|>.
1358+
// - Reasoning: <think>{reasoning}</think> (optional)
1359+
// - Content: text before a tool call (optional)
1360+
// - Tool calls: Python-style, e.g. [function_name(arg1="value1", arg2="value2")]
1361+
// Tool calls can appear multiple times (parallel tool calls supported)
1362+
static common_chat_params common_chat_params_init_lfm2_5(const common_chat_template & tmpl,
1363+
const autoparser::generation_params & inputs) {
1364+
common_chat_params data;
1365+
1366+
data.prompt = common_chat_template_direct_apply(tmpl, inputs);
1367+
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
1368+
data.supports_thinking = true;
1369+
data.preserved_tokens = {
1370+
"<|tool_call_start|>",
1371+
"<|tool_call_end|>",
1372+
"<think>",
1373+
"</think>",
1374+
};
1375+
1376+
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
1377+
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
1378+
auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE;
1379+
1380+
const std::string THINK_START = "<think>";
1381+
const std::string THINK_END = "</think>";
1382+
1383+
data.thinking_start_tag = THINK_START;
1384+
data.thinking_end_tag = THINK_END;
1385+
1386+
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
1387+
auto generation_prompt = p.prefix(inputs.generation_prompt, THINK_START);
1388+
auto end = p.end();
1389+
1390+
auto reasoning = p.eps();
1391+
if (extract_reasoning && inputs.enable_thinking) {
1392+
reasoning = p.optional(THINK_START + p.reasoning(p.until(THINK_END)) + THINK_END);
1393+
}
1394+
1395+
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
1396+
return generation_prompt + reasoning + p.content(p.rest()) + end;
1397+
}
1398+
1399+
auto tool_calls = p.rule("tool-calls",
1400+
p.trigger_rule("tool-call",
1401+
p.python_style_tool_calls(inputs.tools, inputs.parallel_tool_calls)
1402+
)
1403+
);
1404+
1405+
auto content = p.content(p.until_one_of({"<|tool_call_start|>", "["}));
1406+
auto maybe_start = p.optional(p.literal("<|tool_call_start|>"));
1407+
return generation_prompt + reasoning + content + maybe_start + tool_calls + end;
1408+
});
1409+
1410+
data.parser = parser.save();
1411+
1412+
if (include_grammar) {
1413+
data.grammar_lazy = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
1414+
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
1415+
foreach_function(inputs.tools, [&](const json & tool) {
1416+
const auto & function = tool.at("function");
1417+
auto schema = function.at("parameters");
1418+
builder.resolve_refs(schema);
1419+
});
1420+
parser.build_grammar(builder, data.grammar_lazy);
1421+
});
1422+
foreach_function(inputs.tools, [&](const json & tool) {
1423+
const std::string name = tool.at("function").at("name");
1424+
data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "[" + name + "(" });
1425+
});
1426+
}
13521427

13531428
return data;
13541429
}
@@ -1530,14 +1605,21 @@ static std::optional<common_chat_params> try_specialized_template(
15301605
return common_chat_params_init_kimi_k2(tmpl, params);
15311606
}
15321607

1533-
// LFM2 - uses <|tool_list_start|>/<|tool_list_end|> markers and <|tool_call_start|>[name(args)]<|tool_call_end|> format
1534-
// Detection: template has "<|tool_list_start|>" and "<|tool_list_end|>" markers
1608+
// LFM2 format detection: template uses <|tool_list_start|>[...]<|tool_list_end|> around the tool list
1609+
// and <|tool_call_start|>[...]<|tool_call_end|> around each tool call
15351610
if (src.find("<|tool_list_start|>") != std::string::npos &&
15361611
src.find("<|tool_list_end|>") != std::string::npos) {
15371612
LOG_DBG("Using specialized template: LFM2\n");
15381613
return common_chat_params_init_lfm2(tmpl, params);
15391614
}
15401615

1616+
// LFM2.5 format detection: template uses plain "List of tools: [...]" with no special tokens
1617+
if (src.find("List of tools: [") != std::string::npos &&
1618+
src.find("<|tool_list_start|>") == std::string::npos) {
1619+
LOG_DBG("Using specialized template: LFM2.5\n");
1620+
return common_chat_params_init_lfm2_5(tmpl, params);
1621+
}
1622+
15411623
// GigaChatV3 format detection
15421624
if (src.find("<|role_sep|>") != std::string::npos &&
15431625
src.find("<|message_sep|>") != std::string::npos &&
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{{- bos_token -}}
2+
{%- set keep_past_thinking = keep_past_thinking | default(false) -%}
3+
{%- set ns = namespace(system_prompt="") -%}
4+
{%- if messages[0]["role"] == "system" -%}
5+
{%- set ns.system_prompt = messages[0]["content"] -%}
6+
{%- set messages = messages[1:] -%}
7+
{%- endif -%}
8+
{%- if tools -%}
9+
{%- set ns.system_prompt = ns.system_prompt + ("\n" if ns.system_prompt else "") + "List of tools: [" -%}
10+
{%- for tool in tools -%}
11+
{%- if tool is not string -%}
12+
{%- set tool = tool | tojson -%}
13+
{%- endif -%}
14+
{%- set ns.system_prompt = ns.system_prompt + tool -%}
15+
{%- if not loop.last -%}
16+
{%- set ns.system_prompt = ns.system_prompt + ", " -%}
17+
{%- endif -%}
18+
{%- endfor -%}
19+
{%- set ns.system_prompt = ns.system_prompt + "]" -%}
20+
{%- endif -%}
21+
{%- if ns.system_prompt -%}
22+
{{- "<|im_start|>system\n" + ns.system_prompt + "<|im_end|>\n" -}}
23+
{%- endif -%}
24+
{%- set ns.last_assistant_index = -1 -%}
25+
{%- for message in messages -%}
26+
{%- if message["role"] == "assistant" -%}
27+
{%- set ns.last_assistant_index = loop.index0 -%}
28+
{%- endif -%}
29+
{%- endfor -%}
30+
{%- for message in messages -%}
31+
{{- "<|im_start|>" + message["role"] + "\n" -}}
32+
{%- set content = message["content"] -%}
33+
{%- if content is not string -%}
34+
{%- set content = content | tojson -%}
35+
{%- endif -%}
36+
{%- if message["role"] == "assistant" and not keep_past_thinking and loop.index0 != ns.last_assistant_index -%}
37+
{%- if "</think>" in content -%}
38+
{%- set content = content.split("</think>")[-1] | trim -%}
39+
{%- endif -%}
40+
{%- endif -%}
41+
{{- content + "<|im_end|>\n" -}}
42+
{%- endfor -%}
43+
{%- if add_generation_prompt -%}
44+
{{- "<|im_start|>assistant\n" -}}
45+
{%- endif -%}

tests/test-chat.cpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,67 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
27122712
.run();
27132713
}
27142714

2715+
// LFM2.5 tests - uses plain "List of tools: [...]" and bare [name(args)] without wrapper tokens
2716+
{
2717+
auto tst = peg_tester("models/templates/LFM2.5-Instruct.jinja", detailed_debug);
2718+
2719+
// Basic content only
2720+
tst.test("Hello, world!\nWhat's up?").expect(message_assist).run();
2721+
2722+
// Single tool call without reasoning
2723+
tst.test("[special_function(arg1=1)]")
2724+
.tools({ special_function_tool })
2725+
.expect(message_assist_call)
2726+
.run();
2727+
2728+
// Tool call with string argument
2729+
tst.test("[get_time(city=\"XYZCITY\")]")
2730+
.tools({ get_time_tool })
2731+
.expect(message_with_tool_calls("get_time", "{\"city\":\"XYZCITY\"}"))
2732+
.run();
2733+
2734+
// Tool call with reasoning (enable_thinking=true)
2735+
tst.test("<think>I'm\nthinking</think>[special_function(arg1=1)]")
2736+
.enable_thinking(true)
2737+
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2738+
.tools({ special_function_tool })
2739+
.expect(message_assist_call_thoughts)
2740+
.run();
2741+
2742+
// Multiple tool calls (parallel)
2743+
tst.test("[special_function(arg1=1), special_function_with_opt(arg1=1, arg2=2)]")
2744+
.parallel_tool_calls(true)
2745+
.tools({
2746+
special_function_tool, special_function_tool_with_optional_param
2747+
})
2748+
.expect_tool_calls({
2749+
{ "special_function", R"({"arg1": 1})", {} },
2750+
{ "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
2751+
})
2752+
.run();
2753+
2754+
// Tool call with content before tool call
2755+
tst.test("Let me check the time.[get_time(city=\"Paris\")]")
2756+
.tools({ get_time_tool })
2757+
.expect(message_with_reasoning_content_and_multiple_tool_calls(
2758+
"", "Let me check the time.", { { "get_time", "{\"city\":\"Paris\"}" } }
2759+
))
2760+
.run();
2761+
2762+
// Partial tool call (streaming)
2763+
tst.test("[special_function(arg1=")
2764+
.tools({ special_function_tool })
2765+
.is_partial(true)
2766+
.expect(simple_assist_msg("", "", "special_function", "{\"arg1\": "))
2767+
.run();
2768+
2769+
// Tool call with empty arguments
2770+
tst.test("[empty_args()]")
2771+
.tools({ empty_args_tool })
2772+
.expect(simple_assist_msg("", "", "empty_args", "{}"))
2773+
.run();
2774+
}
2775+
27152776
// Apertus-8B-Instruct tests - FUNC_NAME_AS_KEY format
27162777
// Format: <|tools_prefix|>[{"function_name": {...arguments...}}]<|tools_suffix|>
27172778
{

0 commit comments

Comments
 (0)