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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+75
-39
@@ -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 -%}
|
||||
{%- endif -%}
|
||||
|
||||
+74
-38
@@ -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 -%}
|
||||
{%- endif -%}
|
||||
|
||||
Reference in New Issue
Block a user