diff --git a/model/renderers/gemma4.go b/model/renderers/gemma4.go index 84353ebd4..ee193f93b 100644 --- a/model/renderers/gemma4.go +++ b/model/renderers/gemma4.go @@ -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") @@ -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("\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: diff --git a/model/renderers/gemma4_reference_test.go b/model/renderers/gemma4_reference_test.go index ed91bd419..c05f55a33 100644 --- a/model/renderers/gemma4_reference_test.go +++ b/model/renderers/gemma4_reference_test.go @@ -988,7 +988,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { "<|turn>user\nWeather?\n" + "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}" + "<|tool_response>response:get_weather{value:" + q + `{"temperature": 15, "weather": "sunny"}` + q + "}" + - "<|turn>user\nThanks!\n" + + "\n<|turn>user\nThanks!\n" + "<|turn>model\n", }, // === Ordering and whitespace edge cases === @@ -1561,6 +1561,101 @@ func TestGemma4LargeRendererOmitsEmptyThoughtBlockWhenThinkingEnabled(t *testing assert.NotContains(t, got, "<|channel>thought\n") } +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 := "<|turn>system\n" + bashSmallDeclRef + "\n" + + "<|turn>user\nUse the tool\n" + + "<|turn>model\nFirst." + + "<|tool_call>call:bash{command:null}" + + "<|tool_response>response:bash{value:" + q + "done" + q + "}" + + "Checking.Done.\n" + + "<|turn>user\nNext\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"}, + } + + 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.\n<|tool_call>") + assert.NotContains(t, got, "Checking.\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.\nDone.") + }) + } +} + +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") diff --git a/model/renderers/testdata/gemma4_31b_chat_template.jinja2 b/model/renderers/testdata/gemma4_31b_chat_template.jinja2 index f62ca843a..4741bf6e4 100644 --- a/model/renderers/testdata/gemma4_31b_chat_template.jinja2 +++ b/model/renderers/testdata/gemma4_31b_chat_template.jinja2 @@ -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 @@ {{- '' -}} {%- 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' -}} {%- 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 -%} {{- '}' -}} {%- 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) -%} {{- '\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' -}} {%- endif -%} + {%- elif ns.prev_message_type == 'tool_response' and enable_thinking -%} + {{- '<|channel>thought\n' -}} {%- endif -%} -{%- endif -%} \ No newline at end of file +{%- endif -%} diff --git a/model/renderers/testdata/gemma4_e2b_chat_template.jinja2 b/model/renderers/testdata/gemma4_e2b_chat_template.jinja2 index dc032e46e..fbe3b59b6 100644 --- a/model/renderers/testdata/gemma4_e2b_chat_template.jinja2 +++ b/model/renderers/testdata/gemma4_e2b_chat_template.jinja2 @@ -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 @@ {{- '' -}} {%- 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' -}} {%- 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 -%} {{- '}' -}} {%- 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) -%} {{- '\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 -%} \ No newline at end of file +{%- endif -%}