fix gemma4 chat template rendering

This commit is contained in:
ParthSareen
2026-07-14 01:39:20 -07:00
parent 4f7786d0ba
commit 0f5944a5ac
4 changed files with 266 additions and 80 deletions
+22 -2
View File
@@ -86,7 +86,10 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
sb.WriteString("<|turn>" + role + "\n")
}
if message.Role == "assistant" && message.Thinking != "" && i > lastUserIdx && len(message.ToolCalls) > 0 {
// Preserve thinking generated within the active user turn, including an
// assistant continuation after a tool response. Thoughts from earlier
// user turns remain stripped by default.
if message.Role == "assistant" && message.Thinking != "" && i > lastUserIdx {
sb.WriteString("<|channel>thought\n")
sb.WriteString(message.Thinking)
sb.WriteString("\n<channel|>")
@@ -125,7 +128,13 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
if prevMessageType == "tool_call" && !toolResponsesEmitted {
sb.WriteString("<|tool_response>")
} else if !(toolResponsesEmitted && !messageHadContent) {
} else if role == "model" && r.nextNonToolRole(loopMessages, i) == "assistant" &&
(len(message.ToolCalls) == 0 || toolResponsesEmitted) {
// The canonical template keeps adjacent assistant messages in one
// model turn. In particular, an assistant message that includes both
// content and completed tool calls must not close the turn before the
// following assistant continuation.
} else if !(toolResponsesEmitted && !messageHadContent && r.nextNonToolRole(loopMessages, i) == "") {
sb.WriteString("<turn|>\n")
}
}
@@ -190,6 +199,15 @@ func (r *Gemma4Renderer) previousNonToolRole(messages []api.Message, idx int) st
return ""
}
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
}
}
return ""
}
func (r *Gemma4Renderer) messageHasContent(message api.Message) bool {
return strings.TrimSpace(message.Content) != "" || len(message.Images) > 0
}
@@ -699,6 +717,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:
+96 -1
View File
@@ -988,7 +988,7 @@ 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>user\nThanks!<turn|>\n" +
"<turn|>\n<|turn>user\nThanks!<turn|>\n" +
"<|turn>model\n",
},
// === Ordering and whitespace edge cases ===
@@ -1561,6 +1561,101 @@ 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 TestGemma4RendererPreservesActiveToolChainThinking(t *testing.T) {
messages := []api.Message{
{Role: "user", Content: "Use the tool"},
{Role: "assistant", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "true"})},
}}},
{Role: "tool", ToolName: "bash", Content: "done"},
{Role: "assistant", Thinking: "The tool completed successfully.", Content: "Done."},
}
for _, tt := range []struct {
name string
renderer *Gemma4Renderer
}{
{name: "small", renderer: &Gemma4Renderer{}},
{name: "31b", renderer: &Gemma4Renderer{emptyBlockOnNothink: true}},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.renderer.Render(messages, bashSmallTool(), nil)
assert.NoError(t, err)
assert.Contains(t, got, "<|channel>thought\nThe tool completed successfully.\n<channel|>Done.")
})
}
}
func TestGemma4RendererDoesNotReinjectHistoricalToolThinking(t *testing.T) {
const historicalThought = "do not replay this thought"
messages := []api.Message{
{Role: "user", Content: "First request"},
{Role: "assistant", Thinking: historicalThought, ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "true"})},
}}},
{Role: "tool", ToolName: "bash", Content: "ok"},
{Role: "user", Content: "Second request"},
}
for _, tt := range []struct {
name string
renderer *Gemma4Renderer
}{
{name: "small", renderer: &Gemma4Renderer{}},
{name: "31b", renderer: &Gemma4Renderer{emptyBlockOnNothink: true}},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.renderer.Render(messages, bashSmallTool(), nil)
assert.NoError(t, err)
assert.NotContains(t, got, historicalThought)
})
}
}
func TestGemma4RendererMatchesJinja2ExpandedParity(t *testing.T) {
if os.Getenv("VERIFY_JINJA2") == "" {
t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks")
+75 -39
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 -%}
{%- endif -%}
+73 -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,21 @@
{%- 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 +252,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 +268,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 +283,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 +301,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 +320,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 +348,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 -%}