Commit 1c2adc75f for llama.cpp
commit 1c2adc75f619d05efc5d49ce08eaaa96272ebfa2
Author: Cristian Adam <cristian.adam@gmail.com>
Date: Sat Nov 14 23:32:19 2026 +0100
chat : allow Gemma4 tool call during thought (#32232)
The thought channel is normally closed with <channel|>, but the model
may open it and go straight to a tool call without closing it. Stop the
reasoning at <channel|> OR <|tool_call>, so an unclosed thought does
not swallow the following tool call.
Fixes: #32231
Assisted-by: Qwen 3.8 27b
diff --git a/common/parsers/gemma4.cpp b/common/parsers/gemma4.cpp
index 5902e290b..2a2e929e1 100644
--- a/common/parsers/gemma4.cpp
+++ b/common/parsers/gemma4.cpp
@@ -303,7 +303,8 @@ common_chat_params common_chat_params_init_gemma4(const common_chat_template &
data.format = COMMON_CHAT_FORMAT_PEG_GEMMA4;
data.supports_thinking = true;
data.thinking_start_tag = "<|channel>thought";
- data.thinking_end_tags = {"<channel|>"};
+ // a tool call may start before the thought channel is closed; <channel|> stays first, the budget sampler forces it
+ data.thinking_end_tags = {"<channel|>", "<|tool_call>"};
data.preserved_tokens = {
"<|channel>",
@@ -334,9 +335,10 @@ common_chat_params common_chat_params_init_gemma4(const common_chat_template &
auto start = p.rule("start", p.optional(p.literal("<|turn>model\n")));
if (extract_reasoning) {
- p.rule("thought", p.literal("<|channel>thought") + p.space() + p.reasoning(p.until("<channel|>")) + p.literal("<channel|>"));
+ // the model may start a tool call before closing the thought channel
+ p.rule("thought", p.literal("<|channel>thought") + p.space() + p.reasoning(p.until_one_of({"<channel|>", "<|tool_call>"})) + (p.literal("<channel|>") | p.peek(p.literal("<|tool_call>"))));
} else {
- p.rule("thought", p.content(p.literal("<|channel>thought") + p.space() + p.until("<channel|>") + p.literal("<channel|>")));
+ p.rule("thought", p.content(p.literal("<|channel>thought") + p.space() + p.until_one_of({"<channel|>", "<|tool_call>"}) + (p.literal("<channel|>") | p.peek(p.literal("<|tool_call>")))));
}
auto consume_empty_channels = p.gbnf(p.zero_or_more(p.literal("<|channel>") + p.negate(p.literal("thought"))), "");
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index 7752015c8..556cf1b2a 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -3178,6 +3178,17 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
})
.run();
+ // Tool call after reasoning, the model did not close the thought channel (#32231)
+ tst.test(
+ "<|channel>thought\nI'm\nthinking<|tool_call>call:get_time{city:<|\"|>Paris<|\"|>}<tool_call|>")
+ .reasoning_format(COMMON_REASONING_FORMAT_AUTO)
+ .tools({ get_time_tool })
+ .expect_reasoning("I'm\nthinking")
+ .expect_tool_calls({
+ { "get_time", R"({"city": "Paris"})", {} },
+ })
+ .run();
+
// Parallel tool calls
tst.test(
"<|tool_call>call:get_time{city:<|\"|>London<|\"|>}<tool_call|>"