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|>"