mirror of
https://github.com/ollama/ollama.git
synced 2026-07-16 21:25:46 -05:00
model: improvements to laguna-xs.2 parser/renderer (#16362)
This commit is contained in:
@@ -39,6 +39,7 @@ type LagunaParser struct {
|
||||
thinkingEnabled bool
|
||||
thinkingSuppressed bool
|
||||
allowLeadingThinkOpen bool
|
||||
atContentStart bool
|
||||
}
|
||||
|
||||
func (p *LagunaParser) HasToolSupport() bool {
|
||||
@@ -68,10 +69,28 @@ func (p *LagunaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkVal
|
||||
p.tools = tools
|
||||
p.callIndex = 0
|
||||
p.buffer.Reset()
|
||||
p.thinkingEnabled = thinkValue == nil || thinkValue.Bool()
|
||||
p.thinkingSuppressed = thinkValue != nil && !thinkValue.Bool()
|
||||
p.state = lagunaParserStateContent
|
||||
p.allowLeadingThinkOpen = false
|
||||
// The prompt primes the reasoning mode: <think> when thinking is enabled
|
||||
// (so the model's output begins with reasoning and no opening tag), or
|
||||
// </think> otherwise. Thinking defaults off, matching the chat template.
|
||||
p.thinkingEnabled = thinkValue != nil && thinkValue.Bool()
|
||||
p.thinkingSuppressed = !p.thinkingEnabled
|
||||
p.atContentStart = true
|
||||
|
||||
// When the request ends with an assistant prefill, the renderer continues
|
||||
// that turn in place instead of emitting a fresh generation prompt: it has
|
||||
// already written the closing </think>, so the model resumes with content
|
||||
// (or a tool call), not reasoning. Start in the content state even when
|
||||
// thinking is enabled, otherwise the continuation would be reported as
|
||||
// thinking until done and clients would receive an empty answer.
|
||||
assistantPrefill := lastMessage != nil && lastMessage.Role == "assistant"
|
||||
|
||||
if p.thinkingEnabled && !assistantPrefill {
|
||||
p.state = lagunaParserStateThinking
|
||||
p.allowLeadingThinkOpen = true
|
||||
} else {
|
||||
p.state = lagunaParserStateContent
|
||||
p.allowLeadingThinkOpen = false
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -118,19 +137,28 @@ func (p *LagunaParser) consumeThinking(done bool) (bool, string) {
|
||||
if p.allowLeadingThinkOpen {
|
||||
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
|
||||
if strings.HasPrefix(trimmed, lagunaThinkingOpenTag) {
|
||||
// the model echoed the primed <think>; drop it
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingOpenTag), unicode.IsSpace))
|
||||
p.allowLeadingThinkOpen = false
|
||||
return true, ""
|
||||
}
|
||||
if strings.HasPrefix(lagunaThinkingOpenTag, trimmed) && !done {
|
||||
// possibly a partial opening tag; keep it (minus leading space) and wait
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(trimmed)
|
||||
return false, ""
|
||||
}
|
||||
// reasoning begins here: drop the leading whitespace the model emits
|
||||
// after the primed <think> (its trained format is "<think>\n…").
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(trimmed)
|
||||
p.allowLeadingThinkOpen = false
|
||||
acc = trimmed
|
||||
}
|
||||
|
||||
if idx := strings.Index(acc, lagunaThinkingCloseTag); idx != -1 {
|
||||
thinking := acc[:idx]
|
||||
thinking := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
|
||||
after := strings.TrimLeftFunc(acc[idx+len(lagunaThinkingCloseTag):], unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(after)
|
||||
@@ -148,6 +176,7 @@ func (p *LagunaParser) consumeThinking(done bool) (bool, string) {
|
||||
if done {
|
||||
p.buffer.Reset()
|
||||
p.state = lagunaParserStateContent
|
||||
acc = strings.TrimRightFunc(acc, unicode.IsSpace)
|
||||
return acc != "", acc
|
||||
}
|
||||
|
||||
@@ -164,6 +193,17 @@ func (p *LagunaParser) consumeThinking(done bool) (bool, string) {
|
||||
}
|
||||
|
||||
func (p *LagunaParser) consumeContent(done bool) (bool, string, []api.ToolCall, error) {
|
||||
if p.atContentStart {
|
||||
// Drop the leading whitespace the model emits before content (its trained
|
||||
// format puts a newline after the primed/closed </think>).
|
||||
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(trimmed)
|
||||
if trimmed == "" {
|
||||
return false, "", nil, nil
|
||||
}
|
||||
p.atContentStart = false
|
||||
}
|
||||
acc := p.buffer.String()
|
||||
if p.thinkingEnabled || p.thinkingSuppressed {
|
||||
if idx := strings.Index(acc, lagunaThinkingOpenTag); idx != -1 {
|
||||
@@ -248,6 +288,7 @@ func (p *LagunaParser) consumeContent(done bool) (bool, string, []api.ToolCall,
|
||||
}
|
||||
if done {
|
||||
p.buffer.Reset()
|
||||
acc = strings.TrimRightFunc(acc, unicode.IsSpace)
|
||||
return acc != "", acc, nil, nil
|
||||
}
|
||||
overlapLen := max(overlap(acc, lagunaToolCallOpenTag), overlap(acc, lagunaUserOpenTag))
|
||||
|
||||
@@ -375,30 +375,32 @@ func TestLagunaParserUserTaggedNonToolContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserThinkingDefaultsOn(t *testing.T) {
|
||||
func TestLagunaParserThinkingDefaultsOff(t *testing.T) {
|
||||
// Thinking defaults off (matching the chat template); an emitted <think>
|
||||
// block is suppressed rather than surfaced as reasoning.
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, nil, nil)
|
||||
content, thinking, calls, err := parser.Add("<think>Need to reason.</think>\nDirect answer.", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Direct answer." || thinking != "Need to reason." || len(calls) != 0 {
|
||||
if content != "Direct answer." || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserThinkingDefaultsOnWhenToolsPresent(t *testing.T) {
|
||||
func TestLagunaParserThinkingDefaultsOffWhenToolsPresent(t *testing.T) {
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(lagunaTestTools(), nil, nil)
|
||||
content, thinking, calls, err := parser.Add("<think>Need to reason.</think>\n<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>Paris</arg_value>\n</tool_call>", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if thinking != "Need to reason." || len(calls) != 1 {
|
||||
if thinking != "" || len(calls) != 1 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("content=%q, want thinking block suppressed from content when default thinking is enabled", content)
|
||||
t.Fatalf("content=%q, want the suppressed think block and the tool call both removed from content", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +423,7 @@ func TestLagunaParserThinkingExplicitlyDisabledDropsLeadingCloseTag(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
|
||||
if content != "Tokyo" || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
@@ -433,31 +435,122 @@ func TestLagunaParserThinkingEnabledDropsLeadingCloseTag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
|
||||
if content != "Tokyo" || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserThinkingDefaultOnDropsLeadingCloseTag(t *testing.T) {
|
||||
func TestLagunaParserThinkingDefaultOffDropsLeadingCloseTag(t *testing.T) {
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, nil, nil)
|
||||
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
|
||||
if content != "Tokyo" || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserThinkingEnabledUntaggedAnswerIsContent(t *testing.T) {
|
||||
func TestLagunaParserThinkingEnabledUntaggedAnswerIsThinking(t *testing.T) {
|
||||
// With thinking enabled the prompt primes <think>, so the model's output
|
||||
// begins as reasoning even without an opening tag.
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, nil, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add("Direct answer.", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Direct answer." || thinking != "" || len(calls) != 0 {
|
||||
if content != "" || thinking != "Direct answer." || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserAssistantPrefillContinuesAsContent(t *testing.T) {
|
||||
// With thinking enabled but the request ending in an assistant prefill, the
|
||||
// renderer continues the turn in place after a closed </think>, so the
|
||||
// untagged continuation is content, not thinking. (Regression: previously
|
||||
// Init ignored lastMessage and reported it all as thinking, leaving the
|
||||
// client with an empty answer.)
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, &api.Message{Role: "assistant", Content: "The answer is"}, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add(" 42.", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "42." || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserAssistantPrefillContinuesAsToolCall(t *testing.T) {
|
||||
// An assistant prefill with thinking enabled resumes past the closed
|
||||
// </think>, so a tool call emitted by the continuation is parsed from the
|
||||
// content state rather than being swallowed as thinking.
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(lagunaTestTools(), &api.Message{Role: "assistant", Thinking: "earlier reasoning"}, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add("<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>Paris</arg_value>\n</tool_call>", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "" || thinking != "" || len(calls) != 1 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
if calls[0].Function.Name != "get_weather" {
|
||||
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserNonAssistantLastMessageStillPrimesThinking(t *testing.T) {
|
||||
// A trailing user message is not a prefill: the renderer primes <think>, so
|
||||
// the untagged continuation is still reasoning.
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, &api.Message{Role: "user", Content: "hi"}, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add("Direct answer.", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "" || thinking != "Direct answer." || 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.
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, nil, &api.ThinkValue{Value: false})
|
||||
content, thinking, calls, err := parser.Add("\nHello! How can I help?", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Hello! How can I help?" || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserStripsTrailingContentWhitespace(t *testing.T) {
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, nil, &api.ThinkValue{Value: false})
|
||||
content, _, _, err := parser.Add("Hello there.\n\n", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "Hello there." {
|
||||
t.Fatalf("content=%q, want trailing whitespace stripped", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLagunaParserPrimedThinkingThenContent(t *testing.T) {
|
||||
// Thinking enabled: the prompt primes <think>, so output is
|
||||
// "\n{reasoning}\n</think>\n{answer}" with no opening tag.
|
||||
parser := ParserForName("laguna")
|
||||
parser.Init(nil, nil, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add("\nReasoning here.\n</think>\nThe answer.", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "The answer." || thinking != "Reasoning here." || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package renderers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
@@ -10,6 +11,10 @@ const (
|
||||
lagunaBOS = "〈|EOS|〉"
|
||||
lagunaThoughtOpen = "<think>"
|
||||
lagunaThoughtClose = "</think>"
|
||||
|
||||
// Default system message from the Laguna chat template, used when the
|
||||
// request supplies no system message.
|
||||
lagunaDefaultSystem = "You are a helpful, conversationally-fluent assistant made by Poolside. You are here to be helpful to users through natural language conversations."
|
||||
)
|
||||
|
||||
type LagunaRenderer struct{}
|
||||
@@ -22,40 +27,50 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
var sb strings.Builder
|
||||
sb.WriteString(lagunaBOS)
|
||||
|
||||
thinkingEnabled := think == nil || think.Bool()
|
||||
systemMessage := ""
|
||||
// The template signals thinking through the generation-prompt token
|
||||
// (<think> vs </think>), not through the system message. It defaults off.
|
||||
thinkingEnabled := think != nil && think.Bool()
|
||||
|
||||
// ── header (system message) ──
|
||||
// The template seeds a default system message and lets an explicit leading
|
||||
// system message override it. The header is emitted whenever there is a
|
||||
// system message or tools to advertise.
|
||||
systemMessage := lagunaDefaultSystem
|
||||
firstMessageIsSystem := len(messages) > 0 && messages[0].Role == "system"
|
||||
if firstMessageIsSystem {
|
||||
systemMessage = strings.TrimRight(messages[0].Content, "\n")
|
||||
systemMessage = messages[0].Content
|
||||
}
|
||||
|
||||
sb.WriteString("<system>\n")
|
||||
if thinkingEnabled {
|
||||
sb.WriteString("You should use chain-of-thought reasoning. Put your reasoning inside <think> </think> tags before your response.")
|
||||
} else {
|
||||
sb.WriteString("You should respond directly without using chain-of-thought reasoning tags.")
|
||||
}
|
||||
if strings.TrimSpace(systemMessage) != "" {
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString(systemMessage)
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
sb.WriteString("\n\n### 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')
|
||||
}
|
||||
if strings.TrimSpace(systemMessage) != "" || len(tools) > 0 {
|
||||
sb.WriteString("<system>\n")
|
||||
if strings.TrimSpace(systemMessage) != "" {
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString(strings.TrimRightFunc(systemMessage, unicode.IsSpace))
|
||||
}
|
||||
sb.WriteString("</available_tools>\n\n")
|
||||
sb.WriteString("For each function call, return a json object with function name and arguments within '<tool_call>' and '</tool_call>' tags:\n")
|
||||
sb.WriteString("<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>")
|
||||
if len(tools) > 0 {
|
||||
sb.WriteString("\n\n### 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>\n\n")
|
||||
if thinkingEnabled {
|
||||
sb.WriteString("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")
|
||||
sb.WriteString("<think> your thoughts here </think>\n")
|
||||
} else {
|
||||
sb.WriteString("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")
|
||||
}
|
||||
sb.WriteString("<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n</tool_call>")
|
||||
}
|
||||
sb.WriteString("\n</system>\n")
|
||||
}
|
||||
sb.WriteString("\n</system>\n")
|
||||
|
||||
// ── main loop ──
|
||||
for i, message := range messages {
|
||||
if i == 0 && firstMessageIsSystem {
|
||||
continue
|
||||
@@ -68,18 +83,26 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
sb.WriteString("\n</user>\n")
|
||||
case "assistant":
|
||||
lastMessage := i == len(messages)-1
|
||||
prefill := lastMessage && (content != "" || message.Thinking != "" || len(message.ToolCalls) > 0)
|
||||
prefill := lastMessage && (strings.TrimSpace(content) != "" || strings.TrimSpace(message.Thinking) != "" || len(message.ToolCalls) > 0)
|
||||
|
||||
sb.WriteString("<assistant>\n")
|
||||
if thinkingEnabled && message.Thinking != "" {
|
||||
sb.WriteString(lagunaThoughtOpen)
|
||||
sb.WriteString(message.Thinking)
|
||||
sb.WriteString(lagunaThoughtClose)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if strings.Trim(content, "\n") != "" {
|
||||
sb.WriteString(strings.Trim(content, "\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 != "" {
|
||||
sb.WriteString("<think>\n")
|
||||
sb.WriteString(reasoning)
|
||||
sb.WriteString("\n</think>\n")
|
||||
} else {
|
||||
sb.WriteString("</think>\n")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) != "" {
|
||||
sb.WriteString(strings.TrimSpace(content))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
sb.WriteString("<tool_call>")
|
||||
sb.WriteString(toolCall.Function.Name)
|
||||
@@ -94,6 +117,7 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
}
|
||||
sb.WriteString("</tool_call>\n")
|
||||
}
|
||||
|
||||
if !prefill {
|
||||
sb.WriteString("</assistant>\n")
|
||||
}
|
||||
@@ -108,8 +132,17 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
}
|
||||
}
|
||||
|
||||
// ── generation prompt ──
|
||||
// Continue an assistant prefill in place; otherwise open a fresh assistant
|
||||
// turn and prime the reasoning mode (<think> when thinking, else </think>).
|
||||
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
|
||||
sb.WriteString("<assistant>\n")
|
||||
if thinkingEnabled {
|
||||
sb.WriteString(lagunaThoughtOpen)
|
||||
} else {
|
||||
sb.WriteString(lagunaThoughtClose)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
@@ -11,13 +11,20 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
lagunaDirectDirective = "You should respond directly without using chain-of-thought reasoning tags."
|
||||
lagunaThinkDirective = "You should use chain-of-thought reasoning. Put your reasoning inside <think> </think> tags before your response."
|
||||
)
|
||||
// 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"}}}}}`
|
||||
|
||||
// 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.
|
||||
func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
weather := lagunaWeatherTool()
|
||||
think := func(v bool) *api.ThinkValue { return &api.ThinkValue{Value: v} }
|
||||
|
||||
// system header is always emitted; with no system message the default is used
|
||||
defaultHeader := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -27,36 +34,21 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "user_only_thinking_default_on",
|
||||
name: "user_only_default",
|
||||
messages: []api.Message{{Role: "user", Content: "Hello"}},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\n</system>\n" +
|
||||
"<user>\nHello\n</user>\n" +
|
||||
"<assistant>\n",
|
||||
want: defaultHeader + "<user>\nHello\n</user>\n<assistant>\n</think>",
|
||||
},
|
||||
{
|
||||
name: "user_only_thinking_enabled",
|
||||
name: "user_only_think",
|
||||
messages: []api.Message{{Role: "user", Content: "Hello"}},
|
||||
think: &api.ThinkValue{Value: true},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\n</system>\n" +
|
||||
"<user>\nHello\n</user>\n" +
|
||||
"<assistant>\n",
|
||||
think: think(true),
|
||||
want: defaultHeader + "<user>\nHello\n</user>\n<assistant>\n<think>",
|
||||
},
|
||||
{
|
||||
name: "user_only_thinking_disabled",
|
||||
name: "user_only_nothink",
|
||||
messages: []api.Message{{Role: "user", Content: "Hello"}},
|
||||
think: &api.ThinkValue{Value: false},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaDirectDirective +
|
||||
"\n</system>\n" +
|
||||
"<user>\nHello\n</user>\n" +
|
||||
"<assistant>\n",
|
||||
think: think(false),
|
||||
want: defaultHeader + "<user>\nHello\n</user>\n<assistant>\n</think>",
|
||||
},
|
||||
{
|
||||
name: "first_system_is_header",
|
||||
@@ -64,29 +56,20 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
{Role: "system", Content: "Stay concise.\n\n"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\nStay concise." +
|
||||
"\n</system>\n" +
|
||||
"<user>\nHi\n</user>\n" +
|
||||
"<assistant>\n",
|
||||
want: "〈|EOS|〉<system>\n\nStay concise.\n</system>\n" +
|
||||
"<user>\nHi\n</user>\n<assistant>\n</think>",
|
||||
},
|
||||
{
|
||||
name: "additional_system_message_renders_in_loop",
|
||||
name: "additional_system",
|
||||
messages: []api.Message{
|
||||
{Role: "system", Content: "Primary."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
{Role: "system", Content: "Secondary."},
|
||||
},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\nPrimary." +
|
||||
"\n</system>\n" +
|
||||
want: "〈|EOS|〉<system>\n\nPrimary.\n</system>\n" +
|
||||
"<user>\nHi\n</user>\n" +
|
||||
"<system>\nSecondary.\n</system>\n" +
|
||||
"<assistant>\n",
|
||||
"<assistant>\n</think>",
|
||||
},
|
||||
{
|
||||
name: "tools_in_header",
|
||||
@@ -95,46 +78,32 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
{Role: "user", Content: "Weather?"},
|
||||
},
|
||||
tools: weather,
|
||||
think: &api.ThinkValue{Value: true},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\nStay concise." +
|
||||
"\n\n### Tools\n\n" +
|
||||
think: think(true),
|
||||
want: "〈|EOS|〉<system>\n\nStay 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" +
|
||||
`{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "City"}}}}}` + "\n" +
|
||||
"</available_tools>\n\n" +
|
||||
"For each function call, return a json object with function name and arguments within '<tool_call>' and '</tool_call>' tags:\n" +
|
||||
"<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>" +
|
||||
"<available_tools>\n" + lagunaToolJSON + "\n</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>" +
|
||||
"\n</system>\n" +
|
||||
"<user>\nWeather?\n</user>\n" +
|
||||
"<assistant>\n",
|
||||
"<user>\nWeather?\n</user>\n<assistant>\n<think>",
|
||||
},
|
||||
{
|
||||
name: "tools_default_thinking_on_when_unspecified",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Weather?"},
|
||||
},
|
||||
tools: weather,
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\n\n### Tools\n\n" +
|
||||
name: "tools_default",
|
||||
messages: []api.Message{{Role: "user", Content: "Weather?"}},
|
||||
tools: weather,
|
||||
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" +
|
||||
`{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "City"}}}}}` + "\n" +
|
||||
"</available_tools>\n\n" +
|
||||
"For each function call, return a json object with function name and arguments within '<tool_call>' and '</tool_call>' tags:\n" +
|
||||
"<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>" +
|
||||
"<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",
|
||||
"<user>\nWeather?\n</user>\n<assistant>\n</think>",
|
||||
},
|
||||
{
|
||||
name: "assistant_history_with_thinking_content_tool_and_response",
|
||||
name: "assistant_history",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Add these."},
|
||||
{
|
||||
@@ -154,14 +123,11 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
{Role: "tool", Content: "5"},
|
||||
{Role: "user", Content: "Thanks"},
|
||||
},
|
||||
think: &api.ThinkValue{Value: true},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\n</system>\n" +
|
||||
think: think(true),
|
||||
want: defaultHeader +
|
||||
"<user>\nAdd these.\n</user>\n" +
|
||||
"<assistant>\n" +
|
||||
"<think>Need addition.</think>\n" +
|
||||
"<think>\nNeed addition.\n</think>\n" +
|
||||
"Calling the tool.\n" +
|
||||
"<tool_call>add\n" +
|
||||
"<arg_key>a</arg_key>\n<arg_value>2</arg_value>\n" +
|
||||
@@ -169,21 +135,15 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
"</tool_call>\n" +
|
||||
"</assistant>\n" +
|
||||
"<tool_response>\n5\n</tool_response>\n" +
|
||||
"<user>\nThanks\n</user>\n" +
|
||||
"<assistant>\n",
|
||||
"<user>\nThanks\n</user>\n<assistant>\n<think>",
|
||||
},
|
||||
{
|
||||
name: "final_assistant_prefill_is_continued",
|
||||
name: "final_assistant_prefill",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Complete this"},
|
||||
{Role: "assistant", Content: "Partial"},
|
||||
},
|
||||
want: "" +
|
||||
"〈|EOS|〉<system>\n" +
|
||||
lagunaThinkDirective +
|
||||
"\n</system>\n" +
|
||||
"<user>\nComplete this\n</user>\n" +
|
||||
"<assistant>\nPartial\n",
|
||||
want: defaultHeader + "<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -195,7 +155,7 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Fatalf("renderer output mismatch (-want +got):\n%s", diff)
|
||||
t.Fatalf("renderer output mismatch vs template (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -286,9 +246,9 @@ func renderLagunaChatTemplate(t *testing.T, python, modelDir string, messages []
|
||||
t.Fatalf("failed to marshal messages: %v", err)
|
||||
}
|
||||
|
||||
enableThinking := "True"
|
||||
if think != nil && !think.Bool() {
|
||||
enableThinking = "False"
|
||||
enableThinking := "False"
|
||||
if think != nil && think.Bool() {
|
||||
enableThinking = "True"
|
||||
}
|
||||
|
||||
script := `
|
||||
|
||||
Reference in New Issue
Block a user