model: align gemma4 chat template handling (#17182)

Incorporate the upstream Gemma4 chat template refinements for tool-calling stability, turn closure, and multi-turn reasoning. This updates the native renderer and checked-in HF template fixtures to keep adjacent assistant/tool continuations in the same model turn, add the post-tool thought-channel cue when thinking is enabled, and match Google's default of not replaying historical thinking before a later user turn.

Also preserve null tool arguments through Gemma4 rendering/parsing and extend the Jinja2 parity coverage for these upstream behaviors.
This commit is contained in:
Daniel Hiltgen
2026-07-14 15:42:04 -07:00
committed by GitHub
parent d49b96d9ab
commit 8a0016f826
6 changed files with 322 additions and 86 deletions
+6
View File
@@ -74,6 +74,12 @@ func (p *Gemma4Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkVal
return tools
}
if lastMessage != nil && lastMessage.Role == "tool" {
p.state = Gemma4CollectingThinking
p.needsChannelNameStrip = false
return tools
}
if prefill && lastMessage.Content != "" {
p.state = Gemma4CollectingContent
return tools
+37
View File
@@ -102,6 +102,23 @@ func TestGemma4Parser(t *testing.T) {
},
},
},
{
name: "tool_call_with_null_arg",
input: `<|tool_call>call:set_optional{value:null,nested:{value:null}}<tool_call|>`,
expectedToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "set_optional",
Arguments: testArgs(map[string]any{
"value": nil,
"nested": map[string]any{
"value": nil,
},
}),
},
},
},
},
{
name: "tool_call_with_nested_object",
input: `<|tool_call>call:process{config:{enabled:true,name:<|"|>test<|"|>}}<tool_call|>`,
@@ -508,6 +525,26 @@ func TestGemma4Parser_Streaming(t *testing.T) {
}
}
func TestGemma4Parser_ToolResponseContinuationStartsInThinking(t *testing.T) {
parser := &Gemma4Parser{hasThinkingSupport: true}
parser.Init(nil, &api.Message{Role: "tool", Content: "go.mod\ngo.sum\n"}, &api.ThinkValue{Value: true})
content, thinking, toolCalls, err := parser.Add("The user asked for files.<channel|>go.mod and go.sum", true)
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if diff := cmp.Diff("go.mod and go.sum", content); diff != "" {
t.Errorf("content mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff("The user asked for files.", thinking); diff != "" {
t.Errorf("thinking mismatch (-want +got):\n%s", diff)
}
if len(toolCalls) != 0 {
t.Fatalf("expected no tool calls, got %+v", toolCalls)
}
}
func TestGemma4Parser_StreamingToolCall(t *testing.T) {
parser := &Gemma4Parser{hasThinkingSupport: false}
parser.Init(nil, nil, nil)
+17 -6
View File
@@ -28,7 +28,7 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
var sb strings.Builder
imageOffset := 0
// BOS token Gemma 4 models have add_bos_token=false in their tokenizer
// BOS token: Gemma 4 models have add_bos_token=false in their tokenizer
// config, so the tokenizer does not auto-prepend BOS. We must emit it
// explicitly in the rendered prompt, matching the HF chat template.
sb.WriteString("<bos>")
@@ -67,6 +67,7 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
}
var prevMessageType string
var prevNonToolRole string
// Consecutive tool messages are folded into the preceding assistant turn,
// and adjacent assistant messages continue in the same model turn.
@@ -81,12 +82,12 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
role = "model"
}
continueSameModelTurn := role == "model" && r.previousNonToolRole(loopMessages, i) == "assistant"
continueSameModelTurn := role == "model" && prevNonToolRole == "assistant"
if !continueSameModelTurn {
sb.WriteString("<|turn>" + role + "\n")
}
if message.Role == "assistant" && message.Thinking != "" && i > lastUserIdx && len(message.ToolCalls) > 0 {
if message.Role == "assistant" && message.Thinking != "" && i > lastUserIdx {
sb.WriteString("<|channel>thought\n")
sb.WriteString(message.Thinking)
sb.WriteString("\n<channel|>")
@@ -123,11 +124,15 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
messageHadContent = r.messageHasContent(message)
}
nextNonToolRole := r.nextNonToolRole(loopMessages, i)
continuesIntoNext := role == "model" && nextNonToolRole == "assistant" && (len(message.ToolCalls) == 0 || toolResponsesEmitted)
if prevMessageType == "tool_call" && !toolResponsesEmitted {
sb.WriteString("<|tool_response>")
} else if !(toolResponsesEmitted && !messageHadContent) {
} else if !continuesIntoNext && !(toolResponsesEmitted && !messageHadContent && nextNonToolRole == "") {
sb.WriteString("<turn|>\n")
}
prevNonToolRole = message.Role
}
// Generation prompt.
@@ -136,6 +141,8 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
if r.emptyBlockOnNothink && !hasThink {
sb.WriteString("<|channel>thought\n<channel|>")
}
} else if prevMessageType == "tool_response" && hasThink {
sb.WriteString("<|channel>thought\n")
}
return sb.String(), nil
@@ -181,8 +188,8 @@ func (r *Gemma4Renderer) renderToolResponseContent(msg api.Message, imageOffset
return sb.String()
}
func (r *Gemma4Renderer) previousNonToolRole(messages []api.Message, idx int) string {
for i := idx - 1; i >= 0; i-- {
func (r *Gemma4Renderer) nextNonToolRole(messages []api.Message, idx int) string {
for i := idx + 1; i < len(messages); i++ {
if messages[i].Role != "tool" {
return messages[i].Role
}
@@ -699,6 +706,8 @@ func (r *Gemma4Renderer) formatToolResponseBlock(toolName, response string) stri
func (r *Gemma4Renderer) formatArgValue(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return g4Q + v + g4Q
case bool:
@@ -747,6 +756,8 @@ func (r *Gemma4Renderer) formatMapValue(m map[string]any) string {
func (r *Gemma4Renderer) formatSchemaValue(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return g4Q + v + g4Q
case bool:
+113 -3
View File
@@ -906,7 +906,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
},
// === Additional edge cases ported from original tests ===
{
// Assistant content with tool call template emits tool_calls before content
// Assistant content with tool call: template emits tool_calls before content
name: "assistant_content_with_tool_call",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
@@ -988,9 +988,23 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
"<|turn>user\nWeather?<turn|>\n" +
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}<tool_call|>" +
"<|tool_response>response:get_weather{value:" + q + `{"temperature": 15, "weather": "sunny"}` + q + "}<tool_response|>" +
"<turn|>\n" +
"<|turn>user\nThanks!<turn|>\n" +
"<|turn>model\n",
},
{
name: "adjacent_assistants_continue_same_model_turn",
messages: []api.Message{
{Role: "user", Content: "Start"},
{Role: "assistant", Content: "One."},
{Role: "assistant", Content: "Two."},
{Role: "user", Content: "More"},
},
expected: "<bos><|turn>user\nStart<turn|>\n" +
"<|turn>model\nOne.Two.<turn|>\n" +
"<|turn>user\nMore<turn|>\n" +
"<|turn>model\n",
},
// === Ordering and whitespace edge cases ===
{
// Tool call arguments are sorted alphabetically
@@ -1127,7 +1141,7 @@ Hi<turn|>
"<|turn>model\n",
},
{
// Property with no description just type
// Property with no description: just type
name: "property_no_description",
messages: []api.Message{{Role: "user", Content: "Count"}},
tools: countTool(),
@@ -1407,7 +1421,7 @@ Hi<turn|>
// === Round 5: coding agent patterns ===
{
// Chained tool calls assistant calls tool, gets result, calls another
// Chained tool calls: assistant calls tool, gets result, calls another
// tool, gets result, then the model responds. No user messages in between.
name: "chained_tool_calls",
messages: []api.Message{
@@ -1486,6 +1500,58 @@ Hi<turn|>
"<|turn>model\n<|tool_call>call:bash{command:" + q + q + "}<tool_call|>" +
"<|tool_response>response:bash{value:" + q + "error" + q + "}<tool_response|>",
},
{
name: "null_tool_arguments",
messages: []api.Message{
{Role: "user", Content: "Set optional"},
{Role: "assistant", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{Name: "set_optional", Arguments: testArgs(map[string]any{
"nested": map[string]any{"value": nil},
"value": nil,
})},
}}},
},
expected: "<bos><|turn>user\nSet optional<turn|>\n" +
"<|turn>model\n<|tool_call>call:set_optional{nested:{value:null},value:null}<tool_call|><|tool_response>",
},
{
name: "historical_tool_call_thinking_omitted_before_later_user",
messages: []api.Message{
{Role: "user", Content: "List files"},
{Role: "assistant", Thinking: "Need the file list before answering.", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
}}},
{Role: "tool", ToolName: "bash", Content: "file1.txt"},
{Role: "user", Content: "Now answer"},
},
tools: bashSmallTool(),
think: thinkTrue(),
expected: "<bos><|turn>system\n<|think|>\n" + bashSmallDeclRef + "<turn|>\n" +
"<|turn>user\nList files<turn|>\n" +
"<|turn>model\n" +
"<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
"<|tool_response>response:bash{value:" + q + "file1.txt" + q + "}<tool_response|>" +
"<turn|>\n" +
"<|turn>user\nNow answer<turn|>\n" +
"<|turn>model\n",
},
{
name: "terminal_tool_response_adds_thinking_cue_when_thinking_enabled",
messages: []api.Message{
{Role: "user", Content: "List files"},
{Role: "assistant", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
}}},
{Role: "tool", ToolName: "bash", Content: "file1.txt"},
},
tools: bashSmallTool(),
think: thinkTrue(),
expected: "<bos><|turn>system\n<|think|>\n" + bashSmallDeclRef + "<turn|>\n" +
"<|turn>user\nList files<turn|>\n" +
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
"<|tool_response>response:bash{value:" + q + "file1.txt" + q + "}<tool_response|>" +
"<|channel>thought\n",
},
}
verifyJinja2 := os.Getenv("VERIFY_JINJA2") != ""
@@ -1561,6 +1627,50 @@ func TestGemma4LargeRendererOmitsEmptyThoughtBlockWhenThinkingEnabled(t *testing
assert.NotContains(t, got, "<|channel>thought\n<channel|>")
}
func TestGemma4RendererCanonicalToolContinuations(t *testing.T) {
q := `<|"|>`
messages := []api.Message{
{Role: "user", Content: "Use the tool"},
{Role: "assistant", Content: "First."},
{Role: "assistant", Content: "Checking.", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "bash",
Arguments: testArgs(map[string]any{"command": nil}),
},
}}},
{Role: "tool", ToolName: "bash", Content: "done"},
{Role: "assistant", Content: "Done."},
{Role: "user", Content: "Next"},
}
base := "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
"<|turn>user\nUse the tool<turn|>\n" +
"<|turn>model\nFirst." +
"<|tool_call>call:bash{command:null}<tool_call|>" +
"<|tool_response>response:bash{value:" + q + "done" + q + "}<tool_response|>" +
"Checking.Done.<turn|>\n" +
"<|turn>user\nNext<turn|>\n<|turn>model\n"
tests := []struct {
name string
renderer *Gemma4Renderer
want string
}{
{name: "small", renderer: &Gemma4Renderer{}, want: base},
{name: "31b", renderer: &Gemma4Renderer{emptyBlockOnNothink: true}, want: base + "<|channel>thought\n<channel|>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.renderer.Render(messages, bashSmallTool(), nil)
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
assert.NotContains(t, got, "First.<turn|>\n<|tool_call>")
assert.NotContains(t, got, "Checking.<turn|>\nDone.")
})
}
}
func TestGemma4RendererMatchesJinja2ExpandedParity(t *testing.T) {
if os.Getenv("VERIFY_JINJA2") == "" {
t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks")
+74 -38
View File
@@ -1,3 +1,9 @@
{#
Template: Google Gemma 4 Canonical Chat Template
Author: Google Gemma Engineering Team
Published: 2026-07-09
Context: Fixed tool-calling loops, turn closures, and thinking content-ordering.
#}
{%- macro format_parameters(properties, required, filter_keys=false) -%}
{%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
{%- set ns = namespace(found_first=false) -%}
@@ -116,7 +122,9 @@
}
{%- endmacro -%}
{%- macro format_argument(argument, escape_keys=True) -%}
{%- if argument is string -%}
{%- if argument is none -%}
{{- 'null' -}}
{%- elif argument is string -%}
{{- '<|"|>' + argument + '<|"|>' -}}
{%- elif argument is boolean -%}
{{- 'true' if argument else 'false' -}}
@@ -172,18 +180,21 @@
{{- '<tool_response|>' -}}
{%- endmacro -%}
{%- set ns = namespace(prev_message_type=None) -%}
{#- ===== SETUP ===== -#}
{%- set ns = namespace(prev_message_type=None, prev_non_tool_role=None) -%}
{%- set loop_messages = messages -%}
{%- set enable_thinking = enable_thinking | default(false) -%}
{%- set preserve_thinking = preserve_thinking | default(false) -%}
{{- bos_token -}}
{#- Handle System/Tool Definitions Block -#}
{%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%}
{%- if enable_thinking or tools or (messages and messages[0]['role'] in ['system', 'developer']) -%}
{{- '<|turn>system\n' -}}
{#- Inject Thinking token at the very top of the FIRST system turn -#}
{%- if enable_thinking is defined and enable_thinking -%}
{%- if enable_thinking -%}
{{- '<|think|>\n' -}}
{%- set ns.prev_message_type = 'think' -%}
{%- endif -%}
{%- if messages[0]['role'] in ['system', 'developer'] -%}
{%- if messages and messages[0]['role'] in ['system', 'developer'] -%}
{%- if messages[0]['content'] is string -%}
{{- messages[0]['content'] | trim -}}
{%- elif messages[0]['content'] is sequence -%}
@@ -217,31 +228,22 @@
{%- if message['role'] != 'tool' -%}
{%- set ns.prev_message_type = None -%}
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
{#- Detect continuation: suppress duplicate <|turn>model when previous non-tool message was also assistant -#}
{%- set prev_nt = namespace(role=None, found=false) -%}
{%- if loop.index0 > 0 -%}
{%- for j in range(loop.index0 - 1, -1, -1) -%}
{%- if not prev_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set prev_nt.role = loop_messages[j]['role'] -%}
{%- set prev_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%}
{#- Detect continuation using tracked state - O(1) instead of O(n) backward scan -#}
{%- set continue_same_model_turn = (role == 'model' and ns.prev_non_tool_role == 'assistant') -%}
{%- if not continue_same_model_turn -%}
{{- '<|turn>' + role + '\n' }}
{%- endif -%}
{#- Render reasoning/reasoning_content as thinking channel -#}
{%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
{%- if thinking_text and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
{%- set thinking_gate = (loop.index0 > ns_turn.last_user_idx) or (preserve_thinking and message.get('tool_calls')) -%}
{%- if thinking_text and thinking_gate -%}
{{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
{%- endif -%}
{%- if message['tool_calls'] -%}
{%- for tool_call in message['tool_calls'] -%}
{%- if message.get('tool_calls') -%}
{%- for tool_call in message.get('tool_calls') -%}
{%- set function = tool_call['function'] -%}
{{- '<|tool_call>call:' + function['name'] + '{' -}}
{%- if function['arguments'] is mapping -%}
@@ -251,8 +253,13 @@
{%- set ns_args.found_first = true -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- endfor -%}
{%- elif function['arguments'] is string -%}
{{- function['arguments'] -}}
{%- elif function['arguments'] is none -%}
{%- else -%}
{{- raise_exception(
"chat_template: tool_calls[].function.arguments must be a "
"JSON object (mapping), not a string. Deserialize arguments "
"before passing to the template."
) -}}
{%- endif -%}
{{- '}<tool_call|>' -}}
{%- endfor -%}
@@ -262,8 +269,8 @@
{%- set ns_tr_out = namespace(flag=false) -%}
{%- if message.get('tool_responses') -%}
{#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
{%- for tool_response in message['tool_responses'] -%}
{{- format_tool_response_block(tool_response['name'] | default('unknown'), tool_response['response']) -}}
{%- for tool_response in message.get('tool_responses') -%}
{{- format_tool_response_block(tool_response['name'] | default('unknown', true), tool_response['response']) -}}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endfor -%}
@@ -277,8 +284,8 @@
{%- else -%}
{%- set follow = loop_messages[k] -%}
{#- Resolve tool_call_id to function name -#}
{%- set ns_tname = namespace(name=follow.get('name') | default('unknown')) -%}
{%- for tc in message['tool_calls'] -%}
{%- set ns_tname = namespace(name=follow.get('name') or 'unknown') -%}
{%- for tc in message.get('tool_calls') -%}
{%- if tc.get('id') == follow.get('tool_call_id') -%}
{%- set ns_tname.name = tc['function']['name'] -%}
{%- endif -%}
@@ -295,6 +302,15 @@
{%- endif -%}
{%- endfor -%}
{{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
{%- for part in tool_body -%}
{%- if part.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- elif part.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- elif part.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- endif -%}
{%- endfor -%}
{%- else -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- endif -%}
@@ -305,29 +321,26 @@
{%- endif -%}
{%- set captured_content -%}
{%- if message['content'] is string -%}
{%- if message.get('content') is string -%}
{%- if role == 'model' -%}
{{- strip_thinking(message['content']) -}}
{%- else -%}
{{- message['content'] | trim -}}
{%- endif -%}
{%- elif message['content'] is sequence -%}
{%- elif message.get('content') is sequence -%}
{%- for item in message['content'] -%}
{%- if item['type'] == 'text' -%}
{%- if item.get('type') == 'text' -%}
{%- if role == 'model' -%}
{{- strip_thinking(item['text']) -}}
{%- else -%}
{{- item['text'] | trim -}}
{%- endif -%}
{%- elif item['type'] == 'image' -%}
{%- elif item.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- set ns.prev_message_type = 'image' -%}
{%- elif item['type'] == 'audio' -%}
{%- elif item.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- set ns.prev_message_type = 'audio' -%}
{%- elif item['type'] == 'video' -%}
{%- elif item.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- set ns.prev_message_type = 'video' -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
@@ -336,19 +349,42 @@
{{- captured_content -}}
{%- set has_content = captured_content | trim | length > 0 -%}
{#- Forward-scan: find next non-tool message role for continuation detection -#}
{%- set next_nt = namespace(role=None, found=false) -%}
{%- for j in range(loop.index0 + 1, loop_messages | length) -%}
{%- if not next_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set next_nt.role = loop_messages[j]['role'] -%}
{%- set next_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- set continues_into_next = (
role == 'model'
and next_nt.role == 'assistant'
and (not message.get('tool_calls') or ns_tr_out.flag)
) -%}
{%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
{{- '<|tool_response>' -}}
{%- elif not (ns_tr_out.flag and not has_content) -%}
{%- elif continues_into_next -%}
{%- elif not (ns_tr_out.flag and not has_content and not next_nt.found) -%}
{{- '<turn|>\n' -}}
{%- endif -%}
{#- Track previous non-tool role for next iteration (avoids O(n) backward scan) -#}
{%- set ns.prev_non_tool_role = message['role'] -%}
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}
{%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
{{- '<|turn>model\n' -}}
{%- if not enable_thinking | default(false) -%}
{%- if not enable_thinking -%}
{{- '<|channel>thought\n<channel|>' -}}
{%- endif -%}
{%- elif ns.prev_message_type == 'tool_response' and enable_thinking -%}
{{- '<|channel>thought\n' -}}
{%- endif -%}
{%- endif -%}
+73 -37
View File
@@ -1,3 +1,9 @@
{#
Template: Google Gemma 4 Canonical Chat Template
Author: Google Gemma Engineering Team
Published: 2026-07-09
Context: Fixed tool-calling loops, turn closures, and thinking content-ordering.
#}
{%- macro format_parameters(properties, required, filter_keys=false) -%}
{%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
{%- set ns = namespace(found_first=false) -%}
@@ -116,7 +122,9 @@
}
{%- endmacro -%}
{%- macro format_argument(argument, escape_keys=True) -%}
{%- if argument is string -%}
{%- if argument is none -%}
{{- 'null' -}}
{%- elif argument is string -%}
{{- '<|"|>' + argument + '<|"|>' -}}
{%- elif argument is boolean -%}
{{- 'true' if argument else 'false' -}}
@@ -172,18 +180,21 @@
{{- '<tool_response|>' -}}
{%- endmacro -%}
{%- set ns = namespace(prev_message_type=None) -%}
{#- ===== SETUP ===== -#}
{%- set ns = namespace(prev_message_type=None, prev_non_tool_role=None) -%}
{%- set loop_messages = messages -%}
{%- set enable_thinking = enable_thinking | default(false) -%}
{%- set preserve_thinking = preserve_thinking | default(false) -%}
{{- bos_token -}}
{#- Handle System/Tool Definitions Block -#}
{%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%}
{%- if enable_thinking or tools or (messages and messages[0]['role'] in ['system', 'developer']) -%}
{{- '<|turn>system\n' -}}
{#- Inject Thinking token at the very top of the FIRST system turn -#}
{%- if enable_thinking is defined and enable_thinking -%}
{%- if enable_thinking -%}
{{- '<|think|>\n' -}}
{%- set ns.prev_message_type = 'think' -%}
{%- endif -%}
{%- if messages[0]['role'] in ['system', 'developer'] -%}
{%- if messages and messages[0]['role'] in ['system', 'developer'] -%}
{%- if messages[0]['content'] is string -%}
{{- messages[0]['content'] | trim -}}
{%- elif messages[0]['content'] is sequence -%}
@@ -217,31 +228,22 @@
{%- if message['role'] != 'tool' -%}
{%- set ns.prev_message_type = None -%}
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
{#- Detect continuation: suppress duplicate <|turn>model when previous non-tool message was also assistant -#}
{%- set prev_nt = namespace(role=None, found=false) -%}
{%- if loop.index0 > 0 -%}
{%- for j in range(loop.index0 - 1, -1, -1) -%}
{%- if not prev_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set prev_nt.role = loop_messages[j]['role'] -%}
{%- set prev_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
{%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%}
{#- Detect continuation using tracked state - O(1) instead of O(n) backward scan -#}
{%- set continue_same_model_turn = (role == 'model' and ns.prev_non_tool_role == 'assistant') -%}
{%- if not continue_same_model_turn -%}
{{- '<|turn>' + role + '\n' }}
{%- endif -%}
{#- Render reasoning/reasoning_content as thinking channel -#}
{%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
{%- if thinking_text and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
{%- set thinking_gate = (loop.index0 > ns_turn.last_user_idx) or (preserve_thinking and message.get('tool_calls')) -%}
{%- if thinking_text and thinking_gate -%}
{{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
{%- endif -%}
{%- if message['tool_calls'] -%}
{%- for tool_call in message['tool_calls'] -%}
{%- if message.get('tool_calls') -%}
{%- for tool_call in message.get('tool_calls') -%}
{%- set function = tool_call['function'] -%}
{{- '<|tool_call>call:' + function['name'] + '{' -}}
{%- if function['arguments'] is mapping -%}
@@ -251,8 +253,13 @@
{%- set ns_args.found_first = true -%}
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
{%- endfor -%}
{%- elif function['arguments'] is string -%}
{{- function['arguments'] -}}
{%- elif function['arguments'] is none -%}
{%- else -%}
{{- raise_exception(
"chat_template: tool_calls[].function.arguments must be a "
"JSON object (mapping), not a string. Deserialize arguments "
"before passing to the template."
) -}}
{%- endif -%}
{{- '}<tool_call|>' -}}
{%- endfor -%}
@@ -262,8 +269,8 @@
{%- set ns_tr_out = namespace(flag=false) -%}
{%- if message.get('tool_responses') -%}
{#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
{%- for tool_response in message['tool_responses'] -%}
{{- format_tool_response_block(tool_response['name'] | default('unknown'), tool_response['response']) -}}
{%- for tool_response in message.get('tool_responses') -%}
{{- format_tool_response_block(tool_response['name'] | default('unknown', true), tool_response['response']) -}}
{%- set ns_tr_out.flag = true -%}
{%- set ns.prev_message_type = 'tool_response' -%}
{%- endfor -%}
@@ -277,8 +284,8 @@
{%- else -%}
{%- set follow = loop_messages[k] -%}
{#- Resolve tool_call_id to function name -#}
{%- set ns_tname = namespace(name=follow.get('name') | default('unknown')) -%}
{%- for tc in message['tool_calls'] -%}
{%- set ns_tname = namespace(name=follow.get('name') or 'unknown') -%}
{%- for tc in message.get('tool_calls') -%}
{%- if tc.get('id') == follow.get('tool_call_id') -%}
{%- set ns_tname.name = tc['function']['name'] -%}
{%- endif -%}
@@ -295,6 +302,15 @@
{%- endif -%}
{%- endfor -%}
{{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
{%- for part in tool_body -%}
{%- if part.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- elif part.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- elif part.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- endif -%}
{%- endfor -%}
{%- else -%}
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
{%- endif -%}
@@ -305,29 +321,26 @@
{%- endif -%}
{%- set captured_content -%}
{%- if message['content'] is string -%}
{%- if message.get('content') is string -%}
{%- if role == 'model' -%}
{{- strip_thinking(message['content']) -}}
{%- else -%}
{{- message['content'] | trim -}}
{%- endif -%}
{%- elif message['content'] is sequence -%}
{%- elif message.get('content') is sequence -%}
{%- for item in message['content'] -%}
{%- if item['type'] == 'text' -%}
{%- if item.get('type') == 'text' -%}
{%- if role == 'model' -%}
{{- strip_thinking(item['text']) -}}
{%- else -%}
{{- item['text'] | trim -}}
{%- endif -%}
{%- elif item['type'] == 'image' -%}
{%- elif item.get('type') in ['image', 'image_url'] -%}
{{- '<|image|>' -}}
{%- set ns.prev_message_type = 'image' -%}
{%- elif item['type'] == 'audio' -%}
{%- elif item.get('type') in ['audio', 'input_audio'] -%}
{{- '<|audio|>' -}}
{%- set ns.prev_message_type = 'audio' -%}
{%- elif item['type'] == 'video' -%}
{%- elif item.get('type') == 'video' -%}
{{- '<|video|>' -}}
{%- set ns.prev_message_type = 'video' -%}
{%- endif -%}
{%- endfor -%}
{%- endif -%}
@@ -336,16 +349,39 @@
{{- captured_content -}}
{%- set has_content = captured_content | trim | length > 0 -%}
{#- Forward-scan: find next non-tool message role for continuation detection -#}
{%- set next_nt = namespace(role=None, found=false) -%}
{%- for j in range(loop.index0 + 1, loop_messages | length) -%}
{%- if not next_nt.found -%}
{%- if loop_messages[j]['role'] != 'tool' -%}
{%- set next_nt.role = loop_messages[j]['role'] -%}
{%- set next_nt.found = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{%- set continues_into_next = (
role == 'model'
and next_nt.role == 'assistant'
and (not message.get('tool_calls') or ns_tr_out.flag)
) -%}
{%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
{{- '<|tool_response>' -}}
{%- elif not (ns_tr_out.flag and not has_content) -%}
{%- elif continues_into_next -%}
{%- elif not (ns_tr_out.flag and not has_content and not next_nt.found) -%}
{{- '<turn|>\n' -}}
{%- endif -%}
{#- Track previous non-tool role for next iteration (avoids O(n) backward scan) -#}
{%- set ns.prev_non_tool_role = message['role'] -%}
{%- endif -%}
{%- endfor -%}
{%- if add_generation_prompt -%}
{%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
{{- '<|turn>model\n' -}}
{%- elif ns.prev_message_type == 'tool_response' and enable_thinking -%}
{{- '<|channel>thought\n' -}}
{%- endif -%}
{%- endif -%}