model: add Laguna v8 chat support and fix Metal inference (#17291)

Add a laguna-v8 renderer/parser matching the Laguna XS 2.1 template, and fix v2 handling of embedded thinking and structured tool arguments.

Prevent FP16 overflow in Metal's quantized routed-MoE prefill path by scaling the linear branch and folding the inverse into the routing scale. Other backends and token-generation paths are unchanged.

Add comprehensive v2/v8 Jinja parity and parser tests.
This commit is contained in:
Daniel Hiltgen
2026-07-21 16:06:29 -07:00
committed by GitHub
parent e21d5327b0
commit a18c230189
12 changed files with 1180 additions and 59 deletions

View File

@@ -94,6 +94,17 @@ func (p *LagunaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkVal
return tools
}
// LagunaV8Parser matches the v8 renderer, which closes any assistant history
// turn and emits a fresh assistant generation prompt instead of continuing the
// final assistant message in place.
type LagunaV8Parser struct {
LagunaParser
}
func (p *LagunaV8Parser) Init(tools []api.Tool, _ *api.Message, thinkValue *api.ThinkValue) []api.Tool {
return p.LagunaParser.Init(tools, nil, thinkValue)
}
func (p *LagunaParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
var contentSB, thinkingSB strings.Builder

View File

@@ -20,6 +20,23 @@ func lagunaTestTools() []api.Tool {
}}
}
func lagunaParseChunks(t *testing.T, parser Parser, chunks ...string) (string, string, []api.ToolCall) {
t.Helper()
var content, thinking string
var calls []api.ToolCall
for i, chunk := range chunks {
chunkContent, chunkThinking, chunkCalls, err := parser.Add(chunk, i == len(chunks)-1)
if err != nil {
t.Fatalf("Add(%q, done=%t): %v", chunk, i == len(chunks)-1, err)
}
content += chunkContent
thinking += chunkThinking
calls = append(calls, chunkCalls...)
}
return content, thinking, calls
}
func TestLagunaParserToolCall(t *testing.T) {
parser := ParserForName("laguna")
if parser == nil {
@@ -515,6 +532,27 @@ func TestLagunaParserNonAssistantLastMessageStillPrimesThinking(t *testing.T) {
}
}
func TestLagunaV8ParserAssistantHistoryStillPrimesThinking(t *testing.T) {
// Laguna v8 closes assistant history and emits a fresh generation prompt,
// so an assistant tail message must not switch the parser into prefill mode.
parser := ParserForName("laguna-v8")
if parser == nil {
t.Fatal("expected laguna-v8 parser")
}
if !parser.HasToolSupport() || !parser.HasThinkingSupport() {
t.Fatal("laguna-v8 parser should advertise tools and thinking")
}
parser.Init(nil, &api.Message{Role: "assistant", Content: "Previous."}, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Reasoning.</think>Answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Answer." || thinking != "Reasoning." || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserStripsLeadingContentWhitespace(t *testing.T) {
// No-think prompts prime </think>, so the model emits a leading newline
// before content; the parser drops it.
@@ -575,3 +613,89 @@ func TestLagunaParserSplitToolTag(t *testing.T) {
t.Fatalf("second chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserPartialToolCallFakeoutInContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls := lagunaParseChunks(t, parser, "Document literal <tool_call", " fakeout")
if content != "Document literal <tool_call fakeout" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserPartialToolCallFakeoutInThinking(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, &api.ThinkValue{Value: true})
content, thinking, calls := lagunaParseChunks(t, parser, "<think>Document literal <tool_c", " fakeout")
if content != "" || thinking != "Document literal <tool_c fakeout" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserPartialThinkOpenFakeoutInContent(t *testing.T) {
tests := []struct {
name string
thinkValue *api.ThinkValue
last *api.Message
}{
{
name: "default off",
},
{
name: "explicit off",
thinkValue: &api.ThinkValue{Value: false},
},
{
name: "enabled assistant prefill content",
thinkValue: &api.ThinkValue{Value: true},
last: &api.Message{Role: "assistant", Content: "prefill"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, tt.last, tt.thinkValue)
content, thinking, calls := lagunaParseChunks(t, parser, "Document literal <think", " fakeout")
if content != "Document literal <think fakeout" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
})
}
}
func TestLagunaParserPartialThinkCloseFakeoutAtContentStart(t *testing.T) {
tests := []struct {
name string
thinkValue *api.ThinkValue
last *api.Message
}{
{
name: "default off",
},
{
name: "explicit off",
thinkValue: &api.ThinkValue{Value: false},
},
{
name: "enabled assistant prefill content",
thinkValue: &api.ThinkValue{Value: true},
last: &api.Message{Role: "assistant", Content: "prefill"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, tt.last, tt.thinkValue)
content, thinking, calls := lagunaParseChunks(t, parser, "</think", " fakeout")
if content != "</think fakeout" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
})
}
}

View File

@@ -94,6 +94,8 @@ func ParserForName(name string) Parser {
return &LFM2Parser{hasThinkingSupport: true}
case "laguna":
return &LagunaParser{}
case "laguna-v8":
return &LagunaV8Parser{}
case "cohere":
return &CohereParser{}
default:

View File

@@ -82,15 +82,16 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
sb.WriteString(content)
sb.WriteString("\n</user>\n")
case "assistant":
content, reasoning := lagunaV2AssistantContent(message.Content, message.Thinking)
lastMessage := i == len(messages)-1
prefill := lastMessage && (strings.TrimSpace(content) != "" || strings.TrimSpace(message.Thinking) != "" || len(message.ToolCalls) > 0)
prefill := lastMessage && (strings.TrimSpace(content) != "" || strings.TrimSpace(reasoning) != "" || len(message.ToolCalls) > 0)
sb.WriteString("<assistant>\n")
// Every assistant turn opens with the reasoning block: a full
// <think>…</think> when there is reasoning, otherwise a bare
// </think> marking the turn as direct.
if reasoning := strings.TrimSpace(message.Thinking); reasoning != "" {
if reasoning := strings.TrimSpace(reasoning); reasoning != "" {
sb.WriteString("<think>\n")
sb.WriteString(reasoning)
sb.WriteString("\n</think>\n")
@@ -112,7 +113,7 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
sb.WriteString(name)
sb.WriteString("</arg_key>\n")
sb.WriteString("<arg_value>")
sb.WriteString(formatToolCallArgument(value))
sb.WriteString(formatLagunaToolCallArgument(value))
sb.WriteString("</arg_value>\n")
}
sb.WriteString("</tool_call>\n")
@@ -146,3 +147,136 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
return sb.String(), nil
}
func lagunaV2AssistantContent(content, reasoning string) (string, string) {
parts := strings.Split(content, lagunaThoughtClose)
if len(parts) == 1 {
return content, reasoning
}
if reasoning == "" {
before := strings.TrimRight(parts[0], "\n")
if i := strings.LastIndex(before, lagunaThoughtOpen); i >= 0 {
before = before[i+len(lagunaThoughtOpen):]
}
reasoning = strings.TrimLeft(before, "\n")
}
content = strings.TrimLeft(parts[len(parts)-1], "\n")
return content, reasoning
}
type LagunaV8Renderer struct{}
func (r *LagunaV8Renderer) LeadingBOS() string {
return lagunaBOS
}
func (r *LagunaV8Renderer) Render(messages []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) {
var sb strings.Builder
sb.WriteString(lagunaBOS)
thinkingEnabled := think != nil && think.Bool()
systemMessage := lagunaDefaultSystem
firstMessageIsSystem := len(messages) > 0 && messages[0].Role == "system"
if firstMessageIsSystem {
systemMessage = messages[0].Content
}
hasSystem := strings.TrimSpace(systemMessage) != ""
if hasSystem || len(tools) > 0 || thinkingEnabled {
sb.WriteString("<system>")
if hasSystem {
sb.WriteString(strings.TrimRightFunc(systemMessage, unicode.IsSpace))
if len(tools) > 0 {
sb.WriteString("\n\n")
}
}
if len(tools) > 0 {
sb.WriteString("### Tools\n\n")
sb.WriteString("You may call functions to assist with the user query.\n")
sb.WriteString("All available function signatures are listed below:\n")
sb.WriteString("<available_tools>\n")
for _, tool := range tools {
if b, err := marshalWithSpaces(tool); err == nil {
sb.Write(b)
sb.WriteByte('\n')
}
}
sb.WriteString("</available_tools>")
}
sb.WriteString("</system>\n")
}
for i, message := range messages {
if i == 0 && firstMessageIsSystem {
continue
}
content := message.Content
switch message.Role {
case "user":
sb.WriteString("<user>")
sb.WriteString(content)
sb.WriteString("</user>\n")
case "assistant":
sb.WriteString("<assistant>")
if thinkingEnabled {
sb.WriteString(lagunaThoughtOpen)
sb.WriteString(message.Thinking)
sb.WriteString(lagunaThoughtClose)
} else {
sb.WriteString(lagunaThoughtClose)
}
if content != "" {
sb.WriteString(content)
}
for _, toolCall := range message.ToolCalls {
sb.WriteString("<tool_call>")
sb.WriteString(toolCall.Function.Name)
for name, value := range toolCall.Function.Arguments.All() {
sb.WriteString("<arg_key>")
sb.WriteString(name)
sb.WriteString("</arg_key>")
sb.WriteString("<arg_value>")
sb.WriteString(formatLagunaToolCallArgument(value))
sb.WriteString("</arg_value>")
}
sb.WriteString("</tool_call>")
}
sb.WriteString("</assistant>\n")
case "tool":
sb.WriteString("<tool_response>")
sb.WriteString(content)
sb.WriteString("</tool_response>\n")
case "system":
sb.WriteString("<system>")
sb.WriteString(content)
sb.WriteString("</system>\n")
}
}
sb.WriteString("<assistant>")
if thinkingEnabled {
sb.WriteString(lagunaThoughtOpen)
} else {
sb.WriteString(lagunaThoughtClose)
}
return sb.String(), nil
}
func formatLagunaToolCallArgument(value any) string {
switch v := value.(type) {
case string:
return v
case []byte:
return string(v)
}
if b, err := marshalWithSpaces(value); err == nil {
return string(b)
}
return formatToolCallArgument(value)
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -11,17 +12,23 @@ import (
"github.com/ollama/ollama/api"
)
const (
lagunaV2Template = "testdata/laguna_v2_chat_template.jinja2"
lagunaV8Template = "testdata/laguna_v8_chat_template.jinja2"
)
// lagunaToolJSON is the get_weather tool as serialized into <available_tools>,
// matching lagunaWeatherTool().
const lagunaToolJSON = `{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "City"}}}}}`
const lagunaMathToolJSON = `{"type": "function", "function": {"name": "add", "description": "Add numbers", "parameters": {"type": "object", "required": ["a", "b"], "properties": {"a": {"type": "number", "description": "First number"}, "b": {"type": "number", "description": "Second number"}}}}}`
// TestLagunaRendererReferenceFlowCoverage checks the renderer against the Laguna
// chat template. Each want is byte-for-byte template output (verified by
// rendering chat_template.jinja), except that history tool-calls use the clean
// form — the template leaks Jinja indentation there.
// TestLagunaRendererReferenceFlowCoverage checks the renderer against byte-for-byte
// expected output from the Laguna v2 chat template. VERIFY_JINJA2=1 also verifies
// these expected values against the checked-in Jinja fixture.
func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
weather := lagunaWeatherTool()
think := func(v bool) *api.ThinkValue { return &api.ThinkValue{Value: v} }
verifyJinja2 := lagunaVerifyJinja2(t)
// system header is always emitted; with no system message the default is used
defaultHeader := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n"
@@ -33,6 +40,10 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
think *api.ThinkValue
want string
}{
{
name: "empty_messages",
want: defaultHeader + "<assistant>\n</think>",
},
{
name: "user_only_default",
messages: []api.Message{{Role: "user", Content: "Hello"}},
@@ -59,6 +70,14 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
want: "〈|EOS|〉<system>\n\nStay concise.\n</system>\n" +
"<user>\nHi\n</user>\n<assistant>\n</think>",
},
{
name: "empty_first_system_opts_out_of_header",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Hi"},
},
want: "〈|EOS|〉<user>\nHi\n</user>\n<assistant>\n</think>",
},
{
name: "additional_system",
messages: []api.Message{
@@ -71,6 +90,22 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
"<system>\nSecondary.\n</system>\n" +
"<assistant>\n</think>",
},
{
name: "empty_first_system_with_tools",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Weather?"},
},
tools: weather,
want: "〈|EOS|〉<system>\n\n\n### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>\n\n" +
"For each function call, return an unescaped XML-like object with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" +
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n</tool_call>" +
"\n</system>\n" +
"<user>\nWeather?\n</user>\n<assistant>\n</think>",
},
{
name: "tools_in_header",
messages: []api.Message{
@@ -102,6 +137,19 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
"\n</system>\n" +
"<user>\nWeather?\n</user>\n<assistant>\n</think>",
},
{
name: "multiple_tools_in_header",
messages: []api.Message{{Role: "user", Content: "Add then report weather"}},
tools: append(weather, lagunaMathTool()...),
want: "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n\n### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n" + lagunaMathToolJSON + "\n</available_tools>\n\n" +
"For each function call, return an unescaped XML-like object with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" +
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n</tool_call>" +
"\n</system>\n" +
"<user>\nAdd then report weather\n</user>\n<assistant>\n</think>",
},
{
name: "assistant_history",
messages: []api.Message{
@@ -138,12 +186,80 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
"<user>\nThanks\n</user>\n<assistant>\n<think>",
},
{
name: "final_assistant_prefill",
name: "assistant_extracts_thinking_from_content",
messages: []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
{Role: "user", Content: "Explain"},
{Role: "assistant", Content: "<think>\nPlan\n</think>\nAnswer\n\n"},
{Role: "user", Content: "Next"},
},
want: defaultHeader + "<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n",
think: think(true),
want: defaultHeader +
"<user>\nExplain\n</user>\n" +
"<assistant>\n<think>\nPlan\n</think>\nAnswer\n</assistant>\n" +
"<user>\nNext\n</user>\n<assistant>\n<think>",
},
{
name: "assistant_thinking_metadata_overrides_content_tags",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Thinking: "Use metadata.", Content: "<think>Ignore this</think>\nAnswer"},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>\nExplain\n</user>\n" +
"<assistant>\n<think>\nUse metadata.\n</think>\nAnswer\n</assistant>\n" +
"<user>\nNext\n</user>\n<assistant>\n</think>",
},
{
name: "assistant_whitespace_content_only",
messages: []api.Message{
{Role: "user", Content: "Continue"},
{Role: "assistant", Content: " \n\t "},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>\nContinue\n</user>\n" +
"<assistant>\n</think>\n</assistant>\n" +
"<user>\nNext\n</user>\n<assistant>\n</think>",
},
{
name: "assistant_multiple_tool_calls_mixed_args",
messages: []api.Message{
{Role: "user", Content: "Do calls"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{
Name: "echo",
Arguments: testArgsOrdered([]orderedArg{
{Key: "text", Value: "hello"},
{Key: "count", Value: 2},
}),
}},
{Function: api.ToolCallFunction{
Name: "configure",
Arguments: testArgsOrdered([]orderedArg{
{Key: "flag", Value: true},
{Key: "options", Value: map[string]any{"mode": "fast"}},
}),
}},
},
},
{Role: "user", Content: "Done?"},
},
want: defaultHeader +
"<user>\nDo calls\n</user>\n" +
"<assistant>\n</think>\n" +
"<tool_call>echo\n" +
"<arg_key>text</arg_key>\n<arg_value>hello</arg_value>\n" +
"<arg_key>count</arg_key>\n<arg_value>2</arg_value>\n" +
"</tool_call>\n" +
"<tool_call>configure\n" +
"<arg_key>flag</arg_key>\n<arg_value>true</arg_value>\n" +
"<arg_key>options</arg_key>\n<arg_value>{\"mode\": \"fast\"}</arg_value>\n" +
"</tool_call>\n" +
"</assistant>\n" +
"<user>\nDone?\n</user>\n<assistant>\n</think>",
},
}
@@ -157,22 +273,346 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("renderer output mismatch vs template (-want +got):\n%s", diff)
}
if verifyJinja2 {
jinja := renderLagunaJinja2Template(t, lagunaV2Template, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(jinja, tt.want); diff != "" {
t.Fatalf("hardcoded expected mismatch vs Jinja2 template (-jinja +want):\n%s", diff)
}
if diff := cmp.Diff(jinja, got); diff != "" {
t.Fatalf("renderer output mismatch vs Jinja2 template (-jinja +got):\n%s", diff)
}
}
})
}
}
func TestLagunaRendererMatchesLocalJinjaControlFlow(t *testing.T) {
if os.Getenv("VERIFY_LAGUNA_JINJA2") == "" {
t.Skip("set VERIFY_LAGUNA_JINJA2=1 to compare against the local Laguna chat_template.jinja")
func TestLagunaRendererAssistantPrefill(t *testing.T) {
got, err := (&LagunaRenderer{}).Render([]api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
}, nil, nil)
if err != nil {
t.Fatal(err)
}
python := "/Users/daniel/.codex/worktrees/7038/ollama/.venv/bin/python3"
if _, err := os.Stat(python); err != nil {
t.Fatalf("VERIFY_LAGUNA_JINJA2 requires %s with jinja2 installed", python)
want := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n" +
"<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n"
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("renderer prefill mismatch (-want +got):\n%s", diff)
}
}
func TestLagunaRendererKnownJinja2Differences(t *testing.T) {
if !lagunaVerifyJinja2(t) {
t.Skip("set VERIFY_JINJA2=1 to run Jinja2 difference checks")
}
messages := []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
}
got, err := (&LagunaRenderer{}).Render(messages, nil, nil)
if err != nil {
t.Fatal(err)
}
jinja := renderLagunaJinja2Template(t, lagunaV2Template, messages, nil, nil)
if got == jinja {
t.Fatal("v2 assistant prefill no longer differs from Jinja2 output")
}
wantJinja := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n" +
"<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n</assistant>\n<assistant>\n</think>"
if diff := cmp.Diff(wantJinja, jinja); diff != "" {
t.Fatalf("v2 assistant prefill Jinja2 reference mismatch (-want +jinja):\n%s", diff)
}
}
func TestLagunaV8RendererReferenceFlowCoverage(t *testing.T) {
weather := lagunaWeatherTool()
think := func(v bool) *api.ThinkValue { return &api.ThinkValue{Value: v} }
verifyJinja2 := lagunaVerifyJinja2(t)
defaultHeader := "〈|EOS|〉<system>" + lagunaDefaultSystem + "</system>\n"
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
want string
}{
{
name: "empty_messages",
want: defaultHeader + "<assistant></think>",
},
{
name: "user_only_default",
messages: []api.Message{{Role: "user", Content: "Hello"}},
want: defaultHeader + "<user>Hello</user>\n<assistant></think>",
},
{
name: "user_only_think",
messages: []api.Message{{Role: "user", Content: "Hello"}},
think: think(true),
want: defaultHeader + "<user>Hello</user>\n<assistant><think>",
},
{
name: "user_only_nothink",
messages: []api.Message{{Role: "user", Content: "Hello"}},
think: think(false),
want: defaultHeader + "<user>Hello</user>\n<assistant></think>",
},
{
name: "first_system_is_header",
messages: []api.Message{
{Role: "system", Content: "Stay concise.\n\n"},
{Role: "user", Content: "Hi"},
},
want: "〈|EOS|〉<system>Stay concise.</system>\n" +
"<user>Hi</user>\n<assistant></think>",
},
{
name: "empty_first_system_opts_out_of_header",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Hi"},
},
want: "〈|EOS|〉<user>Hi</user>\n<assistant></think>",
},
{
name: "empty_first_system_with_tools",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Weather?"},
},
tools: weather,
want: "〈|EOS|〉<system>" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Weather?</user>\n<assistant></think>",
},
{
name: "empty_first_system_thinking_enabled",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Hi"},
},
think: think(true),
want: "〈|EOS|〉<system></system>\n<user>Hi</user>\n<assistant><think>",
},
{
name: "additional_system",
messages: []api.Message{
{Role: "system", Content: "Primary."},
{Role: "user", Content: "Hi"},
{Role: "system", Content: "Secondary."},
},
want: "〈|EOS|〉<system>Primary.</system>\n" +
"<user>Hi</user>\n" +
"<system>Secondary.</system>\n" +
"<assistant></think>",
},
{
name: "tools_in_header",
messages: []api.Message{
{Role: "system", Content: "Stay concise."},
{Role: "user", Content: "Weather?"},
},
tools: weather,
think: think(true),
want: "〈|EOS|〉<system>Stay concise.\n\n" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Weather?</user>\n<assistant><think>",
},
{
name: "tools_default",
messages: []api.Message{{Role: "user", Content: "Weather?"}},
tools: weather,
want: "〈|EOS|〉<system>" + lagunaDefaultSystem + "\n\n" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Weather?</user>\n<assistant></think>",
},
{
name: "multiple_tools_in_header",
messages: []api.Message{{Role: "user", Content: "Add then report weather"}},
tools: append(weather, lagunaMathTool()...),
want: "〈|EOS|〉<system>" + lagunaDefaultSystem + "\n\n" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n" + lagunaMathToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Add then report weather</user>\n<assistant></think>",
},
{
name: "assistant_history",
messages: []api.Message{
{Role: "user", Content: "Add these."},
{
Role: "assistant",
Content: "\nCalling the tool.\n",
Thinking: "Need addition.",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "add",
Arguments: testArgsOrdered([]orderedArg{
{Key: "a", Value: 2},
{Key: "b", Value: 3},
}),
},
}},
},
{Role: "tool", Content: "5"},
{Role: "user", Content: "Thanks"},
},
think: think(true),
want: defaultHeader +
"<user>Add these.</user>\n" +
"<assistant>" +
"<think>Need addition.</think>" +
"\nCalling the tool.\n" +
"<tool_call>add" +
"<arg_key>a</arg_key><arg_value>2</arg_value>" +
"<arg_key>b</arg_key><arg_value>3</arg_value>" +
"</tool_call>" +
"</assistant>\n" +
"<tool_response>5</tool_response>\n" +
"<user>Thanks</user>\n<assistant><think>",
},
{
name: "assistant_reasoning_ignored_when_thinking_disabled",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Thinking: "Hidden plan.", Content: "Answer"},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>Explain</user>\n" +
"<assistant></think>Answer</assistant>\n" +
"<user>Next</user>\n<assistant></think>",
},
{
name: "assistant_empty_reasoning_when_thinking_enabled",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Content: "Answer"},
{Role: "user", Content: "Next"},
},
think: think(true),
want: defaultHeader +
"<user>Explain</user>\n" +
"<assistant><think></think>Answer</assistant>\n" +
"<user>Next</user>\n<assistant><think>",
},
{
name: "assistant_preserves_content_whitespace",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Content: "\nAnswer\n"},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>Explain</user>\n" +
"<assistant></think>\nAnswer\n</assistant>\n" +
"<user>Next</user>\n<assistant></think>",
},
{
name: "assistant_multiple_tool_calls_mixed_args",
messages: []api.Message{
{Role: "user", Content: "Do calls"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{
Name: "echo",
Arguments: testArgsOrdered([]orderedArg{
{Key: "text", Value: "hello"},
{Key: "count", Value: 2},
}),
}},
{Function: api.ToolCallFunction{
Name: "configure",
Arguments: testArgsOrdered([]orderedArg{
{Key: "flag", Value: true},
{Key: "options", Value: map[string]any{"mode": "fast"}},
}),
}},
},
},
{Role: "user", Content: "Done?"},
},
want: defaultHeader +
"<user>Do calls</user>\n" +
"<assistant></think>" +
"<tool_call>echo" +
"<arg_key>text</arg_key><arg_value>hello</arg_value>" +
"<arg_key>count</arg_key><arg_value>2</arg_value>" +
"</tool_call>" +
"<tool_call>configure" +
"<arg_key>flag</arg_key><arg_value>true</arg_value>" +
"<arg_key>options</arg_key><arg_value>{\"mode\": \"fast\"}</arg_value>" +
"</tool_call>" +
"</assistant>\n" +
"<user>Done?</user>\n<assistant></think>",
},
{
name: "final_assistant_closes_then_generation_prompt",
messages: []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
},
want: defaultHeader +
"<user>Complete this</user>\n" +
"<assistant></think>Partial</assistant>\n" +
"<assistant></think>",
},
}
renderer := &LagunaV8Renderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, tt.tools, tt.think)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("renderer output mismatch vs template (-want +got):\n%s", diff)
}
if verifyJinja2 {
jinja := renderLagunaJinja2Template(t, lagunaV8Template, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(jinja, tt.want); diff != "" {
t.Fatalf("hardcoded expected mismatch vs Jinja2 template (-jinja +want):\n%s", diff)
}
if diff := cmp.Diff(jinja, got); diff != "" {
t.Fatalf("renderer output mismatch vs Jinja2 template (-jinja +got):\n%s", diff)
}
}
})
}
}
func TestLagunaRendererMatchesJinja2ExpandedParity(t *testing.T) {
if os.Getenv("VERIFY_JINJA2") == "" {
t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks")
}
lagunaVerifyJinja2(t)
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
}{
{
@@ -206,77 +646,198 @@ func TestLagunaRendererMatchesLocalJinjaControlFlow(t *testing.T) {
messages: []api.Message{{Role: "user", Content: "Answer directly."}},
think: &api.ThinkValue{Value: false},
},
{
name: "tools_and_assistant_history",
messages: []api.Message{
{Role: "system", Content: "Stay concise."},
{Role: "user", Content: "Weather?"},
{Role: "assistant", Content: "Calling.", Thinking: "Need weather.", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgsOrdered([]orderedArg{{Key: "location", Value: "Paris"}}),
},
}}},
{Role: "tool", Content: "Sunny"},
{Role: "user", Content: "Thanks"},
},
tools: lagunaWeatherTool(),
think: &api.ThinkValue{Value: true},
},
}
renderer := &LagunaRenderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, nil, tt.think)
if err != nil {
t.Fatal(err)
}
for _, modelDir := range []string{
"/Users/daniel/Models/poolside/laguna-xs-23-04-2026",
} {
want := renderLagunaChatTemplate(t, python, modelDir, tt.messages, tt.think)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("%s mismatch (-chat_template +renderer):\n%s", modelDir, diff)
}
variants := []struct {
name string
renderer Renderer
template string
}{
{name: "v2", renderer: &LagunaRenderer{}, template: lagunaV2Template},
{name: "v8", renderer: &LagunaV8Renderer{}, template: lagunaV8Template},
}
for _, variant := range variants {
t.Run(variant.name, func(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := variant.renderer.Render(tt.messages, tt.tools, tt.think)
if err != nil {
t.Fatal(err)
}
want := renderLagunaJinja2Template(t, variant.template, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("renderer output mismatch vs Jinja2 template (-jinja +got):\n%s", diff)
}
})
}
})
}
}
func renderLagunaChatTemplate(t *testing.T, python, modelDir string, messages []api.Message, think *api.ThinkValue) string {
func lagunaVerifyJinja2(t *testing.T) bool {
t.Helper()
if os.Getenv("VERIFY_JINJA2") == "" {
return false
}
python := lagunaJinjaPython(t)
cmd := exec.Command(python, "-c", "from transformers.utils.chat_template_utils import _compile_jinja_template")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("VERIFY_JINJA2=1 requires transformers chat template support in %s: %v\n%s", python, err, out)
}
return true
}
func lagunaJinjaPython(t *testing.T) string {
t.Helper()
python, err := exec.LookPath("python3")
if err != nil {
t.Fatal("VERIFY_JINJA2=1 requires python3 on PATH")
}
return python
}
func renderLagunaJinja2Template(t *testing.T, templateRelPath string, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string {
t.Helper()
type templateMessage struct {
Role string `json:"role"`
Content string `json:"content"`
templatePath, err := filepath.Abs(templateRelPath)
if err != nil {
t.Fatalf("failed to get template path: %v", err)
}
templateMessages := make([]templateMessage, 0, len(messages))
type jinjaToolCall struct {
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
type jinjaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []jinjaToolCall `json:"tool_calls,omitempty"`
}
jinjaMessages := make([]jinjaMessage, 0, len(messages))
for _, msg := range messages {
templateMessages = append(templateMessages, templateMessage{
Role: msg.Role,
Content: msg.Content,
})
jm := jinjaMessage{
Role: msg.Role,
Content: msg.Content,
Reasoning: msg.Thinking,
ReasoningContent: msg.Thinking,
}
for _, call := range msg.ToolCalls {
jc := jinjaToolCall{}
jc.Function.Name = call.Function.Name
raw, err := call.Function.Arguments.MarshalJSON()
if err != nil {
t.Fatalf("failed to marshal tool args: %v", err)
}
jc.Function.Arguments = json.RawMessage(raw)
jm.ToolCalls = append(jm.ToolCalls, jc)
}
jinjaMessages = append(jinjaMessages, jm)
}
messagesJSON, err := json.Marshal(templateMessages)
messagesJSON, err := json.Marshal(jinjaMessages)
if err != nil {
t.Fatalf("failed to marshal messages: %v", err)
}
enableThinking := "False"
if think != nil && think.Bool() {
enableThinking = "True"
toolsJSON := "None"
if len(tools) > 0 {
b, err := json.Marshal(tools)
if err != nil {
t.Fatalf("failed to marshal tools: %v", err)
}
toolsJSON = string(b)
}
enableThinking := "unset"
if think != nil {
if think.Bool() {
enableThinking = "true"
} else {
enableThinking = "false"
}
}
script := `
import json
import sys
from transformers import AutoTokenizer
from pathlib import Path
from transformers.utils.chat_template_utils import _compile_jinja_template
model_dir = sys.argv[1]
messages = json.loads(sys.argv[2])
enable_thinking = sys.argv[3] == "True"
tok = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True)
print(tok.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=enable_thinking,
), end="")
template_path, messages_json, tools_json, enable_thinking = sys.argv[1:5]
tmpl = _compile_jinja_template(Path(template_path).read_text())
kwargs = {
"messages": json.loads(messages_json),
"add_generation_prompt": True,
}
if tools_json != "None":
kwargs["tools"] = json.loads(tools_json)
if enable_thinking == "true":
kwargs["enable_thinking"] = True
elif enable_thinking == "false":
kwargs["enable_thinking"] = False
print(tmpl.render(**kwargs), end="")
`
cmd := exec.Command(python, "-c", script, modelDir, string(messagesJSON), enableThinking)
cmd := exec.Command(lagunaJinjaPython(t), "-c", script, templatePath, string(messagesJSON), toolsJSON, enableThinking)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("chat_template render failed: %v\nstderr: %s", err, stderr.String())
t.Fatalf("python render failed: %v\nstderr: %s", err, stderr.String())
}
return stdout.String()
}
func TestLagunaTemplateFixturesMatchExpectedVersions(t *testing.T) {
v2, err := os.ReadFile(lagunaV2Template)
if err != nil {
t.Fatalf("failed to read %s: %v", lagunaV2Template, err)
}
v8, err := os.ReadFile(lagunaV8Template)
if err != nil {
t.Fatalf("failed to read %s: %v", lagunaV8Template, err)
}
if !strings.Contains(string(v2), "laguna_glm_thinking_v5/chat_template.jinja") {
t.Fatalf("%s does not look like the v2 Laguna template fixture", lagunaV2Template)
}
if !strings.Contains(string(v8), "laguna_glm_thinking_v8/chat_template.jinja") {
t.Fatalf("%s does not look like the v8 Laguna template fixture", lagunaV8Template)
}
if !strings.Contains(string(v2), "render_assistant_messages_raw") {
t.Fatalf("%s should retain the v2 raw assistant branch", lagunaV2Template)
}
if strings.Contains(string(v8), "render_assistant_messages_raw") {
t.Fatalf("%s unexpectedly contains the v2 raw assistant branch", lagunaV8Template)
}
if diff := cmp.Diff(string(v2), string(v8)); diff == "" {
t.Fatal("Laguna v2 and v8 template fixtures unexpectedly match")
}
}
func lagunaWeatherTool() []api.Tool {
return []api.Tool{{
Type: "function",
@@ -297,3 +858,33 @@ func lagunaWeatherTool() []api.Tool {
},
}}
}
func lagunaMathTool() []api.Tool {
return []api.Tool{{
Type: "function",
Function: api.ToolFunction{
Name: "add",
Description: "Add numbers",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"a", "b"},
Properties: testPropsOrdered([]orderedProp{
{
Key: "a",
Value: api.ToolProperty{
Type: api.PropertyType{"number"},
Description: "First number",
},
},
{
Key: "b",
Value: api.ToolProperty{
Type: api.PropertyType{"number"},
Description: "Second number",
},
},
}),
},
},
}}
}

View File

@@ -109,6 +109,8 @@ func rendererForName(name string) Renderer {
return &LFM2Renderer{IsThinking: true, useImgTags: RenderImgTags}
case "laguna":
return &LagunaRenderer{}
case "laguna-v8":
return &LagunaV8Renderer{}
case "cohere":
return &CohereRenderer{}
default:

View File

@@ -69,6 +69,7 @@ func TestLeadingBOSForRenderer(t *testing.T) {
{name: "lfm2", want: "<|startoftext|>"},
{name: "lfm2-thinking", want: "<|startoftext|>"},
{name: "laguna", want: "〈|EOS|〉"},
{name: "laguna-v8", want: "〈|EOS|〉"},
{name: "deepseek3.1", want: "<begin▁of▁sentence>"},
{name: "cogito", want: "<begin▁of▁sentence>"},
{name: "qwen3-coder", want: ""},

View File

@@ -0,0 +1,132 @@
{#- Iteration on laguna_glm_thinking_v5/chat_template.jinja -#}
{#- Adds a default system message (used when no system message is provided in `messages`). -#}
{{- "〈|EOS|〉" -}}
{%- set enable_thinking = enable_thinking | default(false) -%}
{%- set render_assistant_messages_raw = render_assistant_messages_raw | default(false) -%}
{%- set add_generation_prompt = add_generation_prompt | default(false) -%}
{#- ───── header (system message) ───── -#}
{%- set system_message = "You are a helpful, conversationally-fluent assistant made by Poolside. You are here to be helpful to users through natural language conversations." -%}
{%- if messages and messages[0].role == "system" -%}
{%- set system_message = messages[0].content -%}
{%- endif -%}
{%- if (system_message and system_message.strip()) or tools -%}
{{- "<system>\n" -}}
{%- if system_message and system_message.strip() -%}
{{- "\n" -}}
{{- system_message.rstrip() -}}
{%- endif -%}
{%- if tools -%}
{{- "\n\n### Tools\n\n" -}}
{%- set ns = namespace(tool_string="You may call functions to assist with the user query.\n"
~ "All available function signatures are listed below:\n"
~ "<available_tools>\n") -%}
{%- for tool in tools -%}
{%- set ns.tool_string = ns.tool_string ~ (tool | tojson) ~ "\n" -%}
{%- endfor -%}
{%- if enable_thinking -%}
{%- set tool_string = ns.tool_string + "</available_tools>\n\n" ~
"Wrap your thinking in '<think>', '</think>' tags, followed by a function call. For each function call, return an unescaped XML-like object with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" ~
"<think> your thoughts here </think>\n" ~
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n" ~
"</tool_call>" -%}
{%- else -%}
{%- set tool_string = ns.tool_string + "</available_tools>\n\n" ~
"For each function call, return an unescaped XML-like object " ~
"with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" ~
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n" ~
"</tool_call>" -%}
{%- endif -%}
{{- tool_string -}}
{%- endif -%}
{{- "\n</system>\n" -}}
{%- endif -%}
{#- ───── main loop ───── -#}
{%- for message in messages -%}
{%- set content = message.content if message.content is string else "" -%}
{%- if message.role == "user" -%}
{{- "<user>\n" + content + "\n</user>\n" -}}
{%- elif message.role == "assistant" -%}
{%- generation -%}
{{- "<assistant>\n" -}}
{%- if render_assistant_messages_raw -%}
{#- Raw mode: prepend the generation prompt token, then dump content verbatim. -#}
{#- The generation prompt is <think> when enable_thinking, </think> otherwise. -#}
{#- Only prepend if content doesn't already start with it. -#}
{%- if enable_thinking -%}
{%- if not content.startswith('<think>') -%}
{{- '<think>' -}}
{%- endif -%}
{%- else -%}
{%- if not content.startswith('</think>') -%}
{{- '</think>' -}}
{%- endif -%}
{%- endif -%}
{{- content -}}
{#- Append closing tag if content doesn't already end with it. -#}
{%- if not content.endswith('</assistant>\n') and not content.endswith('</assistant>') -%}
{{- '\n</assistant>' -}}
{%- endif -%}
{{- "\n" -}}
{%- else -%}
{#- Extract reasoning content from message.reasoning (vLLM field name) or message.reasoning_content, or from <think> tags -#}
{%- set reasoning_content = '' %}
{%- if message.reasoning is string %}
{%- set reasoning_content = message.reasoning %}
{%- elif message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- endif %}
{#- Always strip <think> tags from content if present to avoid duplication -#}
{%- if '</think>' in content %}
{%- if not reasoning_content %}
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
{%- endif %}
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
{%- endif %}
{#- Display reasoning content for all messages -#}
{%- if reasoning_content -%}
{{- '<think>\n' + reasoning_content.strip() + '\n</think>\n' -}}
{%- else -%}
{{- '</think>\n' -}}
{%- endif -%}
{#- Display main content -#}
{%- if content.strip() -%}
{{- content.strip() ~ "\n" -}}
{%- endif -%}
{%- if message.tool_calls -%}
{%- for tool_call in message.tool_calls -%}
{%- set function_data = tool_call.function -%}
{{- '<tool_call>' + function_data.name }}
{% set _args = function_data.arguments %}
{%- for k, v in _args.items() -%}
{{- "<arg_key>" ~ k ~ "</arg_key>\n" -}}
{{- "<arg_value>"}}{{ v | tojson(ensure_ascii=False) if v is not string else v }}{{ "</arg_value>\n" -}}
{%- endfor -%}
{{- "</tool_call>\n" -}}
{%- endfor -%}
{%- endif -%}
{{- "</assistant>\n" -}}
{%- endif -%}
{%- endgeneration -%}
{%- elif message.role == "tool" -%}
{{- "<tool_response>\n" + content + "\n</tool_response>\n" -}}
{%- elif message.role == "system" and loop.index0 != 0 -%}
{#- Render additional system messages (skip the first one which is handled separately in the header) -#}
{{- "<system>\n" + content + "\n</system>\n" -}}
{%- endif -%}
{%- endfor -%}
{#- ───── generation prompt ───── -#}
{%- if add_generation_prompt -%}
{{- "<assistant>\n" -}}
{#- ───── Include reasoning mode directive ───── -#}
{%- if not enable_thinking %}
{{- '</think>' -}}
{%- else %}
{{- '<think>' -}}
{%- endif %}
{%- endif -%}

View File

@@ -0,0 +1,93 @@
{#- Iteration on laguna_glm_thinking_v8/chat_template.jinja -#}
{#- No formatting instructions -#}
{{- "〈|EOS|〉" -}}
{%- set enable_thinking = enable_thinking | default(false) -%}
{%- set add_generation_prompt = add_generation_prompt | default(false) -%}
{#- ───── header (system message) ───── -#}
{#- A caller-supplied system message with empty content opts out of the default below, producing no <system> block — used to train without a system message. -#}
{%- set system_message = "You are a helpful, conversationally-fluent assistant made by Poolside. You are here to be helpful to users through natural language conversations." -%}
{%- if messages and messages[0].role == "system" -%}
{%- set system_message = messages[0].content -%}
{%- set messages = messages[1:] -%}
{%- endif -%}
{%- set has_sys = system_message and system_message.strip() -%}
{%- if has_sys or tools or enable_thinking -%}
{{- "<system>" -}}
{%- if has_sys -%}
{{- system_message.rstrip() -}}
{%- if tools -%}{{- "\n\n" -}}{%- endif -%}
{%- endif -%}
{%- if tools -%}
{{- "### Tools\n\n" -}}
{{- "You may call functions to assist with the user query.\n" -}}
{{- "All available function signatures are listed below:\n" -}}
{{- "<available_tools>\n" -}}
{%- for tool in tools -%}
{{- (tool | tojson) ~ "\n" -}}
{%- endfor -%}
{{- "</available_tools>" -}}
{%- endif -%}
{{- "</system>\n" -}}
{%- endif -%}
{#- ───── main loop ───── -#}
{%- for message in messages -%}
{%- set content = message.content if message.content is string else "" -%}
{%- if message.role == "user" -%}
{{- "<user>" + content + "</user>\n" -}}
{%- elif message.role == "assistant" -%}
{%- generation -%}
{{- "<assistant>" -}}
{#- Extract reasoning content from message.reasoning (vLLM field name) or message.reasoning_content -#}
{%- set reasoning_content = '' -%}
{%- if message.reasoning is string -%}
{%- set reasoning_content = message.reasoning -%}
{%- elif message.reasoning_content is string -%}
{%- set reasoning_content = message.reasoning_content -%}
{%- endif -%}
{#- Display reasoning content for all messages if enable_thinking -#}
{%- if enable_thinking -%}
{{- '<think>' + reasoning_content + '</think>' -}}
{%- else -%}
{{- '</think>' -}}
{%- endif -%}
{#- Display main content (trailing newline only when no tool_calls follow) -#}
{%- if content -%}
{{- content -}}
{%- endif -%}
{%- if message.tool_calls -%}
{%- for tool_call in message.tool_calls -%}
{%- set function_data = tool_call.function -%}
{{- '<tool_call>' + function_data.name -}}
{%- set _args = function_data.arguments -%}
{%- for k, v in _args.items() -%}
{{- "<arg_key>" ~ k ~ "</arg_key>" -}}
{{- "<arg_value>" -}}{{- v | tojson(ensure_ascii=False) if v is not string else v -}}{{- "</arg_value>" -}}
{%- endfor -%}
{{- "</tool_call>" -}}
{%- endfor -%}
{%- endif -%}
{{- "</assistant>\n" -}}
{%- endgeneration -%}
{%- elif message.role == "tool" -%}
{{- "<tool_response>" + content + "</tool_response>\n" -}}
{%- elif message.role == "system" -%}
{#- Render additional system messages (the first one, if any, is handled separately in the header and was sliced off above) -#}
{{- "<system>" + content + "</system>\n" -}}
{%- endif -%}
{%- endfor -%}
{#- ───── generation prompt ───── -#}
{%- if add_generation_prompt -%}
{{- "<assistant>" -}}
{#- ───── Include reasoning mode directive ───── -#}
{%- if enable_thinking -%}
{{- '<think>' -}}
{%- else -%}
{{- '</think>' -}}
{%- endif -%}
{%- endif -%}