mirror of https://github.com/ollama/ollama
simplify renderer and parser, add tests
This commit is contained in:
parent
49d415eb2f
commit
383b028911
|
|
@ -12,6 +12,7 @@ type Nemotron3NanoParserState int
|
|||
|
||||
const (
|
||||
Nemotron3NanoCollectingThinking Nemotron3NanoParserState = iota
|
||||
Nemotron3NanoSkipWhitespaceAfterThinking
|
||||
Nemotron3NanoCollectingContent
|
||||
Nemotron3NanoCollectingToolCalls
|
||||
)
|
||||
|
|
@ -23,19 +24,14 @@ const (
|
|||
)
|
||||
|
||||
type Nemotron3NanoParser struct {
|
||||
state Nemotron3NanoParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
hasThinkingSupport bool
|
||||
state Nemotron3NanoParserState
|
||||
buffer strings.Builder
|
||||
tools []api.Tool
|
||||
HasThinking bool
|
||||
}
|
||||
|
||||
func (p *Nemotron3NanoParser) HasToolSupport() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Nemotron3NanoParser) HasThinkingSupport() bool {
|
||||
return p.hasThinkingSupport
|
||||
}
|
||||
func (p *Nemotron3NanoParser) HasToolSupport() bool { return true }
|
||||
func (p *Nemotron3NanoParser) HasThinkingSupport() bool { return p.HasThinking }
|
||||
|
||||
func (p *Nemotron3NanoParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.tools = tools
|
||||
|
|
@ -115,133 +111,103 @@ func (p *Nemotron3NanoParser) parseEvents() []nemotronEvent {
|
|||
return all
|
||||
}
|
||||
|
||||
// emitWithPartialCheck extracts unambiguous content before a potential partial tag
|
||||
func (p *Nemotron3NanoParser) emitWithPartialCheck(bufStr, tag string) (unambiguous, ambiguous string) {
|
||||
if overlapLen := overlap(bufStr, tag); overlapLen > 0 {
|
||||
beforePartialTag := bufStr[:len(bufStr)-overlapLen]
|
||||
trailingLen := trailingWhitespaceLen(beforePartialTag)
|
||||
return bufStr[:len(beforePartialTag)-trailingLen], bufStr[len(beforePartialTag)-trailingLen:]
|
||||
}
|
||||
wsLen := trailingWhitespaceLen(bufStr)
|
||||
return bufStr[:len(bufStr)-wsLen], bufStr[len(bufStr)-wsLen:]
|
||||
}
|
||||
|
||||
func (p *Nemotron3NanoParser) eat() ([]nemotronEvent, bool) {
|
||||
var events []nemotronEvent
|
||||
bufStr := p.buffer.String()
|
||||
if bufStr == "" {
|
||||
return events, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
switch p.state {
|
||||
case Nemotron3NanoCollectingThinking:
|
||||
if strings.Contains(bufStr, nemotronThinkClose) {
|
||||
split := strings.SplitN(bufStr, nemotronThinkClose, 2)
|
||||
thinking := split[0]
|
||||
thinking = strings.TrimRightFunc(thinking, unicode.IsSpace)
|
||||
|
||||
remaining := split[1]
|
||||
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
|
||||
|
||||
thinking := strings.TrimRightFunc(split[0], unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(remaining)
|
||||
p.state = Nemotron3NanoCollectingContent
|
||||
|
||||
if len(thinking) > 0 {
|
||||
events = append(events, nemotronEventThinkingContent{content: thinking})
|
||||
remainder := strings.TrimLeftFunc(split[1], unicode.IsSpace)
|
||||
p.buffer.WriteString(remainder)
|
||||
// Transition to whitespace-skipping state if buffer is empty,
|
||||
// otherwise go directly to content collection
|
||||
if remainder == "" {
|
||||
p.state = Nemotron3NanoSkipWhitespaceAfterThinking
|
||||
} else {
|
||||
p.state = Nemotron3NanoCollectingContent
|
||||
}
|
||||
return events, true
|
||||
} else if overlapLen := overlap(bufStr, nemotronThinkClose); overlapLen > 0 {
|
||||
beforePartialTag := bufStr[:len(bufStr)-overlapLen]
|
||||
trailingLen := trailingWhitespaceLen(beforePartialTag)
|
||||
ambiguousStart := len(beforePartialTag) - trailingLen
|
||||
|
||||
unambiguous := bufStr[:ambiguousStart]
|
||||
ambiguous := bufStr[ambiguousStart:]
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(ambiguous)
|
||||
if len(unambiguous) > 0 {
|
||||
events = append(events, nemotronEventThinkingContent{content: unambiguous})
|
||||
if thinking != "" {
|
||||
return []nemotronEvent{nemotronEventThinkingContent{content: thinking}}, true
|
||||
}
|
||||
return events, false
|
||||
} else {
|
||||
whitespaceLen := trailingWhitespaceLen(bufStr)
|
||||
ambiguousStart := len(bufStr) - whitespaceLen
|
||||
|
||||
unambiguous := bufStr[:ambiguousStart]
|
||||
ambiguous := bufStr[ambiguousStart:]
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(ambiguous)
|
||||
if len(unambiguous) > 0 {
|
||||
events = append(events, nemotronEventThinkingContent{content: unambiguous})
|
||||
}
|
||||
return events, false
|
||||
return nil, true
|
||||
}
|
||||
unambig, ambig := p.emitWithPartialCheck(bufStr, nemotronThinkClose)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(ambig)
|
||||
if unambig != "" {
|
||||
return []nemotronEvent{nemotronEventThinkingContent{content: unambig}}, false
|
||||
}
|
||||
return nil, false
|
||||
|
||||
// We only want to skip whitespace between thinking and content
|
||||
case Nemotron3NanoSkipWhitespaceAfterThinking:
|
||||
bufStr = strings.TrimLeftFunc(bufStr, unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
if bufStr == "" {
|
||||
return nil, false
|
||||
}
|
||||
p.state = Nemotron3NanoCollectingContent
|
||||
return nil, true
|
||||
|
||||
case Nemotron3NanoCollectingContent:
|
||||
switch {
|
||||
case strings.Contains(bufStr, nemotronToolCallOpen):
|
||||
if strings.Contains(bufStr, nemotronToolCallOpen) {
|
||||
split := strings.SplitN(bufStr, nemotronToolCallOpen, 2)
|
||||
contentBefore := strings.TrimRightFunc(split[0], unicode.IsSpace)
|
||||
remaining := split[1]
|
||||
|
||||
content := strings.TrimRightFunc(split[0], unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(remaining)
|
||||
p.buffer.WriteString(split[1])
|
||||
p.state = Nemotron3NanoCollectingToolCalls
|
||||
|
||||
if len(contentBefore) > 0 {
|
||||
events = append(events, nemotronEventContent{content: contentBefore})
|
||||
if content != "" {
|
||||
return []nemotronEvent{nemotronEventContent{content: content}}, true
|
||||
}
|
||||
return events, true
|
||||
default:
|
||||
// Check for partial tool call tag
|
||||
if overlapLen := overlap(bufStr, nemotronToolCallOpen); overlapLen > 0 {
|
||||
beforePartialTag := bufStr[:len(bufStr)-overlapLen]
|
||||
trailingLen := trailingWhitespaceLen(beforePartialTag)
|
||||
ambiguousStart := len(beforePartialTag) - trailingLen
|
||||
|
||||
unambiguous := bufStr[:ambiguousStart]
|
||||
ambiguous := bufStr[ambiguousStart:]
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(ambiguous)
|
||||
if len(unambiguous) > 0 {
|
||||
events = append(events, nemotronEventContent{content: unambiguous})
|
||||
}
|
||||
return events, false
|
||||
}
|
||||
|
||||
// Otherwise emit content, withholding trailing whitespace
|
||||
whitespaceLen := trailingWhitespaceLen(bufStr)
|
||||
ambiguousStart := len(bufStr) - whitespaceLen
|
||||
|
||||
unambiguous := bufStr[:ambiguousStart]
|
||||
ambiguous := bufStr[ambiguousStart:]
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(ambiguous)
|
||||
if len(unambiguous) > 0 {
|
||||
events = append(events, nemotronEventContent{content: unambiguous})
|
||||
}
|
||||
return events, false
|
||||
return nil, true
|
||||
}
|
||||
unambig, ambig := p.emitWithPartialCheck(bufStr, nemotronToolCallOpen)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(ambig)
|
||||
if unambig != "" {
|
||||
return []nemotronEvent{nemotronEventContent{content: unambig}}, false
|
||||
}
|
||||
return nil, false
|
||||
|
||||
case Nemotron3NanoCollectingToolCalls:
|
||||
// Look for complete tool call: <function=name>...</function>
|
||||
if strings.Contains(bufStr, nemotronToolCallClose) {
|
||||
// We have a complete tool call block
|
||||
split := strings.SplitN(bufStr, nemotronToolCallClose, 2)
|
||||
toolContent := split[0]
|
||||
remaining := strings.TrimLeftFunc(split[1], unicode.IsSpace)
|
||||
|
||||
// Parse the tool call
|
||||
if toolCall, err := p.parseToolCall(toolContent); err == nil {
|
||||
events = append(events, nemotronEventToolCall{toolCall: toolCall})
|
||||
}
|
||||
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(remaining)
|
||||
|
||||
// Check if there are more tool calls
|
||||
if strings.Contains(remaining, nemotronToolCallOpen) {
|
||||
// Stay in tool call state
|
||||
return events, true
|
||||
var events []nemotronEvent
|
||||
if tc, err := p.parseToolCall(split[0]); err == nil {
|
||||
events = append(events, nemotronEventToolCall{toolCall: tc})
|
||||
}
|
||||
|
||||
p.state = Nemotron3NanoCollectingContent
|
||||
if !strings.Contains(remaining, nemotronToolCallOpen) {
|
||||
p.state = Nemotron3NanoCollectingContent
|
||||
}
|
||||
return events, true
|
||||
}
|
||||
return events, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return events, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,486 @@
|
|||
package parsers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestNemotron3NanoParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
thinkValue *api.ThinkValue
|
||||
expectedContent string
|
||||
expectedThinking string
|
||||
expectedCalls []api.ToolCall
|
||||
}{
|
||||
{
|
||||
name: "simple content - no thinking",
|
||||
input: "Hello, how can I help you?",
|
||||
thinkValue: nil,
|
||||
expectedContent: "Hello, how can I help you?",
|
||||
},
|
||||
{
|
||||
name: "simple content - thinking disabled",
|
||||
input: "Hello, how can I help you?",
|
||||
thinkValue: &api.ThinkValue{Value: false},
|
||||
expectedContent: "Hello, how can I help you?",
|
||||
},
|
||||
{
|
||||
name: "thinking then content",
|
||||
input: "Let me think about this...</think>\nHere is my answer.",
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Let me think about this...",
|
||||
expectedContent: "Here is my answer.",
|
||||
},
|
||||
{
|
||||
name: "thinking with newlines",
|
||||
input: "Step 1: Analyze\nStep 2: Process\nStep 3: Conclude</think>\nThe answer is 42.",
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Step 1: Analyze\nStep 2: Process\nStep 3: Conclude",
|
||||
expectedContent: "The answer is 42.",
|
||||
},
|
||||
{
|
||||
name: "simple tool call",
|
||||
input: "<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content then tool call",
|
||||
input: "Let me check the weather.\n<tool_call>\n<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: nil,
|
||||
expectedContent: "Let me check the weather.",
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "NYC"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool call with multiple parameters",
|
||||
input: "<tool_call>\n<function=book_flight>\n<parameter=from>\nSFO\n</parameter>\n<parameter=to>\nNYC\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "book_flight",
|
||||
Arguments: map[string]any{
|
||||
"from": "SFO",
|
||||
"to": "NYC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple tool calls",
|
||||
input: "<tool_call>\n<function=get_weather>\n<parameter=city>\nSan Francisco\n</parameter>\n</function>\n</tool_call>\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nNew York\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "San Francisco"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "New York"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking then tool call",
|
||||
input: "I should check the weather...</think>\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "I should check the weather...",
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking content then tool call",
|
||||
input: "Let me think...</think>\nI'll check for you.\n<tool_call>\n<function=search>\n<parameter=query>\ntest\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Let me think...",
|
||||
expectedContent: "I'll check for you.",
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "search",
|
||||
Arguments: map[string]any{"query": "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool call with multiline parameter value",
|
||||
input: "<tool_call>\n<function=create_note>\n<parameter=content>\nLine 1\nLine 2\nLine 3\n</parameter>\n</function>\n</tool_call>",
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "create_note",
|
||||
Arguments: map[string]any{"content": "Line 1\nLine 2\nLine 3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: tt.thinkValue != nil && tt.thinkValue.Bool()}
|
||||
p.Init(nil, nil, tt.thinkValue)
|
||||
|
||||
content, thinking, calls, err := p.Add(tt.input, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Drain remaining content
|
||||
finalContent, finalThinking, finalCalls, err := p.Add("", true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on done: %v", err)
|
||||
}
|
||||
content += finalContent
|
||||
thinking += finalThinking
|
||||
calls = append(calls, finalCalls...)
|
||||
|
||||
if diff := cmp.Diff(content, tt.expectedContent); diff != "" {
|
||||
t.Errorf("content mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(thinking, tt.expectedThinking); diff != "" {
|
||||
t.Errorf("thinking mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(calls, tt.expectedCalls); diff != "" {
|
||||
t.Errorf("calls mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNemotron3NanoParser_Streaming(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks []string
|
||||
thinkValue *api.ThinkValue
|
||||
expectedContent string
|
||||
expectedThinking string
|
||||
expectedCalls []api.ToolCall
|
||||
}{
|
||||
{
|
||||
name: "streaming content character by character",
|
||||
chunks: []string{"H", "e", "l", "l", "o", ",", " ", "w", "o", "r", "l", "d", "!"},
|
||||
thinkValue: nil,
|
||||
expectedContent: "Hello, world!",
|
||||
},
|
||||
{
|
||||
name: "streaming content small tokens",
|
||||
chunks: []string{"Hel", "lo", ", ", "how ", "can", " I", " help", " you", " today", "?"},
|
||||
thinkValue: nil,
|
||||
expectedContent: "Hello, how can I help you today?",
|
||||
},
|
||||
{
|
||||
name: "streaming thinking then content - granular",
|
||||
chunks: []string{"Let", " me", " th", "ink", " about", " this", "...", "<", "/", "think", ">", "\n", "Here", " is", " my", " answer", "."},
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Let me think about this...",
|
||||
expectedContent: "Here is my answer.",
|
||||
},
|
||||
{
|
||||
name: "streaming thinking with newlines - granular",
|
||||
chunks: []string{"Step", " 1", ":", " Ana", "lyze\n", "Step", " 2", ":", " Pro", "cess", "</", "thi", "nk>", "\n", "The", " ans", "wer."},
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Step 1: Analyze\nStep 2: Process",
|
||||
expectedContent: "The answer.",
|
||||
},
|
||||
{
|
||||
name: "streaming tool call - highly granular",
|
||||
chunks: []string{"<", "tool", "_", "call", ">", "\n", "<", "func", "tion", "=", "get", "_", "weather", ">", "\n", "<", "param", "eter", "=", "city", ">", "\n", "Par", "is", "\n", "</", "param", "eter", ">", "\n", "</", "func", "tion", ">", "\n", "</", "tool", "_", "call", ">"},
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "streaming content then tool call - granular",
|
||||
chunks: []string{"Let", " me", " check", " the", " weather", ".", "\n<", "tool_call", ">", "\n", "<function=", "get_weather", ">", "\n", "<parameter=", "city", ">", "\n", "NYC", "\n", "</parameter>", "\n", "</function>", "\n", "</tool_call>"},
|
||||
thinkValue: nil,
|
||||
expectedContent: "Let me check the weather.",
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "NYC"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool call tag split character by character",
|
||||
chunks: []string{"<", "t", "o", "o", "l", "_", "c", "a", "l", "l", ">", "\n", "<", "f", "u", "n", "c", "t", "i", "o", "n", "=", "t", "e", "s", "t", ">", "\n", "<", "/", "f", "u", "n", "c", "t", "i", "o", "n", ">", "\n", "<", "/", "t", "o", "o", "l", "_", "c", "a", "l", "l", ">"},
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "test",
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking close tag split character by character",
|
||||
chunks: []string{"I", "'", "m", " ", "t", "h", "i", "n", "k", "i", "n", "g", ".", ".", ".", "<", "/", "t", "h", "i", "n", "k", ">", "\n", "D", "o", "n", "e", "!"},
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "I'm thinking...",
|
||||
expectedContent: "Done!",
|
||||
},
|
||||
{
|
||||
name: "multiple whitespace after think tag - separate chunks",
|
||||
chunks: []string{"Thinking...", "</think>", "\n", "\n", " ", "Content here."},
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Thinking...",
|
||||
expectedContent: "Content here.",
|
||||
},
|
||||
{
|
||||
name: "tool call with multiple parameters - streaming",
|
||||
chunks: []string{"<tool_", "call>\n", "<function", "=book_", "flight>", "\n<para", "meter=", "from>\n", "SFO\n", "</param", "eter>", "\n<param", "eter=to", ">\nNYC", "\n</para", "meter>", "\n</func", "tion>\n", "</tool_", "call>"},
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "book_flight",
|
||||
Arguments: map[string]any{
|
||||
"from": "SFO",
|
||||
"to": "NYC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking then content then tool call - streaming",
|
||||
chunks: []string{"Ana", "lyzing", " your", " request", "...", "</", "think", ">\n", "I'll", " check", " that", " for", " you", ".", "\n", "<tool", "_call", ">\n", "<function", "=search", ">\n", "<parameter", "=query", ">\n", "test", " query", "\n</", "parameter", ">\n", "</function", ">\n", "</tool", "_call", ">"},
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expectedThinking: "Analyzing your request...",
|
||||
expectedContent: "I'll check that for you.",
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "search",
|
||||
Arguments: map[string]any{"query": "test query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple tool calls - streaming",
|
||||
chunks: []string{
|
||||
"<tool_call>", "\n", "<function=", "get_weather>", "\n",
|
||||
"<parameter=", "city>\n", "San Fran", "cisco\n", "</parameter>", "\n",
|
||||
"</function>", "\n", "</tool_call>", "\n",
|
||||
"<tool_", "call>\n", "<function", "=get_weather", ">\n",
|
||||
"<param", "eter=city", ">\nNew", " York\n", "</parameter>\n",
|
||||
"</function>\n", "</tool_call>",
|
||||
},
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "San Francisco"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "New York"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool call with multiline parameter - streaming",
|
||||
chunks: []string{"<tool_call>\n", "<function=", "create_note>\n", "<parameter=", "content>\n", "Line 1", "\nLine", " 2\n", "Line 3", "\n</parameter>\n", "</function>\n", "</tool_call>"},
|
||||
thinkValue: nil,
|
||||
expectedCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "create_note",
|
||||
Arguments: map[string]any{"content": "Line 1\nLine 2\nLine 3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: tt.thinkValue != nil && tt.thinkValue.Bool()}
|
||||
p.Init(nil, nil, tt.thinkValue)
|
||||
|
||||
var allContent string
|
||||
var allThinking string
|
||||
var allCalls []api.ToolCall
|
||||
|
||||
for _, chunk := range tt.chunks {
|
||||
content, thinking, calls, err := p.Add(chunk, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
allContent += content
|
||||
allThinking += thinking
|
||||
allCalls = append(allCalls, calls...)
|
||||
}
|
||||
|
||||
// Drain
|
||||
content, thinking, calls, err := p.Add("", true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on done: %v", err)
|
||||
}
|
||||
allContent += content
|
||||
allThinking += thinking
|
||||
allCalls = append(allCalls, calls...)
|
||||
|
||||
if diff := cmp.Diff(allContent, tt.expectedContent); diff != "" {
|
||||
t.Errorf("content mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(allThinking, tt.expectedThinking); diff != "" {
|
||||
t.Errorf("thinking mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(allCalls, tt.expectedCalls); diff != "" {
|
||||
t.Errorf("calls mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNemotron3NanoParser_HasToolSupport(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{}
|
||||
if !p.HasToolSupport() {
|
||||
t.Error("expected HasToolSupport to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNemotron3NanoParser_HasThinkingSupport(t *testing.T) {
|
||||
t.Run("with thinking enabled", func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: true}
|
||||
if !p.HasThinkingSupport() {
|
||||
t.Error("expected HasThinkingSupport to return true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with thinking disabled", func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: false}
|
||||
if p.HasThinkingSupport() {
|
||||
t.Error("expected HasThinkingSupport to return false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNemotron3NanoParser_Init(t *testing.T) {
|
||||
t.Run("starts in thinking state when enabled", func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: true}
|
||||
p.Init(nil, nil, &api.ThinkValue{Value: true})
|
||||
if p.state != Nemotron3NanoCollectingThinking {
|
||||
t.Errorf("expected state Nemotron3NanoCollectingThinking, got %v", p.state)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("starts in content state when thinking disabled", func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: true}
|
||||
p.Init(nil, nil, &api.ThinkValue{Value: false})
|
||||
if p.state != Nemotron3NanoCollectingContent {
|
||||
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("starts in content state when nil thinkValue", func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: true}
|
||||
p.Init(nil, nil, nil)
|
||||
if p.state != Nemotron3NanoCollectingContent {
|
||||
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("starts in content state with assistant prefill", func(t *testing.T) {
|
||||
p := &Nemotron3NanoParser{HasThinking: true}
|
||||
prefill := &api.Message{Role: "assistant", Content: "Starting..."}
|
||||
p.Init(nil, prefill, &api.ThinkValue{Value: true})
|
||||
if p.state != Nemotron3NanoCollectingContent {
|
||||
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNemotron3NanoParser_WithTools(t *testing.T) {
|
||||
tools := []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"city": {Type: api.PropertyType{"string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
p := &Nemotron3NanoParser{}
|
||||
returnedTools := p.Init(tools, nil, nil)
|
||||
|
||||
if diff := cmp.Diff(returnedTools, tools); diff != "" {
|
||||
t.Errorf("tools mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// Parse a tool call
|
||||
input := "<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>"
|
||||
_, _, calls, err := p.Add(input, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expectedCalls := []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(calls, expectedCalls); diff != "" {
|
||||
t.Errorf("calls mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
|
@ -63,9 +63,9 @@ func ParserForName(name string) Parser {
|
|||
case "olmo3-think":
|
||||
return &Olmo3ThinkParser{}
|
||||
case "nemotron-3-nano":
|
||||
return &Nemotron3NanoParser{hasThinkingSupport: false}
|
||||
return &Nemotron3NanoParser{HasThinking: false}
|
||||
case "nemotron-3-nano-thinking":
|
||||
return &Nemotron3NanoParser{hasThinkingSupport: true}
|
||||
return &Nemotron3NanoParser{HasThinking: true}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,6 @@ func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool,
|
|||
// thinking is enabled: model must support it AND user must request it
|
||||
enableThinking := r.IsThinking && (thinkValue != nil && thinkValue.Bool())
|
||||
|
||||
// truncate_history_thinking: drop thinking from historical assistant messages
|
||||
truncateHistoryThinking := true
|
||||
|
||||
// Find the last user message index
|
||||
lastUserIdx := -1
|
||||
for i, msg := range messages {
|
||||
if msg.Role == "user" {
|
||||
lastUserIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
// Extract system message if present
|
||||
var systemMessage string
|
||||
var loopMessages []api.Message
|
||||
|
|
@ -39,16 +28,14 @@ func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool,
|
|||
loopMessages = messages
|
||||
}
|
||||
|
||||
// Recalculate lastUserIdx for loopMessages
|
||||
lastUserIdxInLoop := -1
|
||||
// Find last user message index for thinking truncation
|
||||
lastUserIdx := -1
|
||||
for i, msg := range loopMessages {
|
||||
if msg.Role == "user" {
|
||||
lastUserIdxInLoop = i
|
||||
lastUserIdx = i
|
||||
}
|
||||
}
|
||||
_ = lastUserIdx // silence unused variable warning
|
||||
|
||||
// Write system message - always include the system block
|
||||
sb.WriteString("<|im_start|>system\n")
|
||||
if systemMessage != "" {
|
||||
sb.WriteString(systemMessage)
|
||||
|
|
@ -65,79 +52,18 @@ func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool,
|
|||
for i, message := range loopMessages {
|
||||
switch message.Role {
|
||||
case "assistant":
|
||||
// Build content with reasoning handling
|
||||
var content string
|
||||
if message.Thinking != "" {
|
||||
content = "<think>\n" + message.Thinking + "\n</think>\n" + message.Content
|
||||
} else {
|
||||
content = message.Content
|
||||
// Allow downstream logic to handle broken thought, only handle coherent reasoning here
|
||||
if !strings.Contains(content, "<think>") && !strings.Contains(content, "</think>") {
|
||||
content = "<think></think>" + content
|
||||
}
|
||||
}
|
||||
// Build content with thinking tags
|
||||
content := r.buildContent(message)
|
||||
shouldTruncate := i < lastUserIdx
|
||||
|
||||
if len(message.ToolCalls) > 0 {
|
||||
// Assistant message with tool calls
|
||||
sb.WriteString("<|im_start|>assistant\n")
|
||||
|
||||
includeContent := !(truncateHistoryThinking && i < lastUserIdxInLoop)
|
||||
if content != "" {
|
||||
if includeContent {
|
||||
sb.WriteString(strings.TrimSpace(content) + "\n")
|
||||
} else {
|
||||
// Truncate thinking
|
||||
c := content
|
||||
if strings.Contains(c, "</think>") {
|
||||
// Keep only content after the last closing think
|
||||
parts := strings.Split(c, "</think>")
|
||||
c = parts[len(parts)-1]
|
||||
} else if strings.Contains(c, "<think>") {
|
||||
// If <think> was opened but never closed, drop the trailing think segment
|
||||
parts := strings.Split(c, "<think>")
|
||||
c = parts[0]
|
||||
}
|
||||
c = "<think></think>" + strings.TrimSpace(c)
|
||||
if len(c) > len("<think></think>") {
|
||||
sb.WriteString(c + "\n")
|
||||
} else {
|
||||
sb.WriteString("<think></think>")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("<think></think>")
|
||||
}
|
||||
|
||||
// Write tool calls
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
sb.WriteString("<tool_call>\n<function=" + toolCall.Function.Name + ">\n")
|
||||
for argName, argValue := range toolCall.Function.Arguments {
|
||||
sb.WriteString("<parameter=" + argName + ">\n")
|
||||
valueStr := r.formatArgValue(argValue)
|
||||
sb.WriteString(valueStr + "\n</parameter>\n")
|
||||
}
|
||||
sb.WriteString("</function>\n</tool_call>\n")
|
||||
}
|
||||
sb.WriteString(r.formatContent(content, shouldTruncate, true))
|
||||
r.writeToolCalls(&sb, message.ToolCalls)
|
||||
sb.WriteString("<|im_end|>\n")
|
||||
} else {
|
||||
// Assistant message without tool calls
|
||||
if !(truncateHistoryThinking && i < lastUserIdxInLoop) {
|
||||
sb.WriteString("<|im_start|>assistant\n" + strings.TrimSpace(content) + "<|im_end|>\n")
|
||||
} else {
|
||||
// Truncate thinking - keep only content after </think>
|
||||
c := content
|
||||
if strings.Contains(c, "<think>") && strings.Contains(c, "</think>") {
|
||||
parts := strings.Split(c, "</think>")
|
||||
// Trim the content after </think> before concatenating
|
||||
c = "<think></think>" + strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
c = strings.TrimSpace(c)
|
||||
if c != "" {
|
||||
sb.WriteString("<|im_start|>assistant\n" + c + "<|im_end|>\n")
|
||||
} else {
|
||||
sb.WriteString("<|im_start|>assistant\n<|im_end|>\n")
|
||||
}
|
||||
}
|
||||
formatted := r.formatContent(content, shouldTruncate, false)
|
||||
sb.WriteString("<|im_start|>assistant\n" + formatted + "<|im_end|>\n")
|
||||
}
|
||||
|
||||
case "user", "system":
|
||||
|
|
@ -234,6 +160,59 @@ func (r *Nemotron3NanoRenderer) renderTools(tools []api.Tool) string {
|
|||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Nemotron3NanoRenderer) buildContent(message api.Message) string {
|
||||
if message.Thinking != "" {
|
||||
return "<think>\n" + message.Thinking + "\n</think>\n" + message.Content
|
||||
}
|
||||
content := message.Content
|
||||
if !strings.Contains(content, "<think>") && !strings.Contains(content, "</think>") {
|
||||
return "<think></think>" + content
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (r *Nemotron3NanoRenderer) formatContent(content string, truncate bool, addNewline bool) string {
|
||||
if content == "" {
|
||||
return "<think></think>"
|
||||
}
|
||||
|
||||
if !truncate {
|
||||
if addNewline {
|
||||
return strings.TrimSpace(content) + "\n"
|
||||
}
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// Truncate thinking - keep only content after </think>
|
||||
c := content
|
||||
if strings.Contains(c, "</think>") {
|
||||
parts := strings.Split(c, "</think>")
|
||||
c = parts[len(parts)-1]
|
||||
} else if strings.Contains(c, "<think>") {
|
||||
parts := strings.Split(c, "<think>")
|
||||
c = parts[0]
|
||||
}
|
||||
c = "<think></think>" + strings.TrimSpace(c)
|
||||
|
||||
if addNewline && len(c) > len("<think></think>") {
|
||||
return c + "\n"
|
||||
}
|
||||
if c == "<think></think>" {
|
||||
return c
|
||||
}
|
||||
return strings.TrimSpace(c)
|
||||
}
|
||||
|
||||
func (r *Nemotron3NanoRenderer) writeToolCalls(sb *strings.Builder, toolCalls []api.ToolCall) {
|
||||
for _, tc := range toolCalls {
|
||||
sb.WriteString("<tool_call>\n<function=" + tc.Function.Name + ">\n")
|
||||
for name, value := range tc.Function.Arguments {
|
||||
sb.WriteString("<parameter=" + name + ">\n" + r.formatArgValue(value) + "\n</parameter>\n")
|
||||
}
|
||||
sb.WriteString("</function>\n</tool_call>\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Nemotron3NanoRenderer) formatArgValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case map[string]any, []any:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
package renderers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestNemotron3NanoRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgs []api.Message
|
||||
tools []api.Tool
|
||||
thinkValue *api.ThinkValue
|
||||
isThinking bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic user message - thinking mode",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n<|im_end|>\n" +
|
||||
"<|im_start|>user\nHello!<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "basic user message - no thinking",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
isThinking: false,
|
||||
expected: "<|im_start|>system\n<|im_end|>\n" +
|
||||
"<|im_start|>user\nHello!<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>",
|
||||
},
|
||||
{
|
||||
name: "with system message",
|
||||
msgs: []api.Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" +
|
||||
"<|im_start|>user\nHello!<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "multi-turn conversation",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
{Role: "assistant", Content: "Hello! How can I help?"},
|
||||
{Role: "user", Content: "Tell me a joke"},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n<|im_end|>\n" +
|
||||
"<|im_start|>user\nHi<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>Hello! How can I help?<|im_end|>\n" +
|
||||
"<|im_start|>user\nTell me a joke<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "with tools",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "What's the weather in Paris?"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Description: "Get the current weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"city"},
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"city": {Type: api.PropertyType{"string"}, Description: "The city name"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
|
||||
"<function>\n<name>get_weather</name>\n" +
|
||||
"<description>Get the current weather</description>\n" +
|
||||
"<parameters>\n" +
|
||||
"<parameter>\n<name>city</name>\n<type>string</type>\n<description>The city name</description>\n</parameter>\n" +
|
||||
"<required>[\"city\"]</required>\n" +
|
||||
"</parameters>\n</function>\n</tools>\n\n" +
|
||||
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
|
||||
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
|
||||
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
|
||||
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
|
||||
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
|
||||
"- Required parameters MUST be specified\n" +
|
||||
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
|
||||
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
|
||||
"</IMPORTANT><|im_end|>\n" +
|
||||
"<|im_start|>user\nWhat's the weather in Paris?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "tool call with response",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "What's the weather in Paris?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: "Sunny, 72F"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Description: "Get the current weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"city"},
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"city": {Type: api.PropertyType{"string"}, Description: "The city name"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
|
||||
"<function>\n<name>get_weather</name>\n" +
|
||||
"<description>Get the current weather</description>\n" +
|
||||
"<parameters>\n" +
|
||||
"<parameter>\n<name>city</name>\n<type>string</type>\n<description>The city name</description>\n</parameter>\n" +
|
||||
"<required>[\"city\"]</required>\n" +
|
||||
"</parameters>\n</function>\n</tools>\n\n" +
|
||||
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
|
||||
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
|
||||
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
|
||||
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
|
||||
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
|
||||
"- Required parameters MUST be specified\n" +
|
||||
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
|
||||
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
|
||||
"</IMPORTANT><|im_end|>\n" +
|
||||
"<|im_start|>user\nWhat's the weather in Paris?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
|
||||
"<|im_start|>user\n<tool_response>\nSunny, 72F\n</tool_response>\n<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "assistant with content and tool call",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Let me check that for you.",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: "Sunny"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"city": {Type: api.PropertyType{"string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
|
||||
"<function>\n<name>get_weather</name>\n" +
|
||||
"<parameters>\n" +
|
||||
"<parameter>\n<name>city</name>\n<type>string</type>\n</parameter>\n" +
|
||||
"</parameters>\n</function>\n</tools>\n\n" +
|
||||
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
|
||||
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
|
||||
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
|
||||
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
|
||||
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
|
||||
"- Required parameters MUST be specified\n" +
|
||||
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
|
||||
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
|
||||
"</IMPORTANT><|im_end|>\n" +
|
||||
"<|im_start|>user\nWhat's the weather?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>Let me check that for you.\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
|
||||
"<|im_start|>user\n<tool_response>\nSunny\n</tool_response>\n<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "thinking in history is truncated",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
{Role: "assistant", Content: "Hello!", Thinking: "Let me think about this..."},
|
||||
{Role: "user", Content: "How are you?"},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n<|im_end|>\n" +
|
||||
"<|im_start|>user\nHi<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>Hello!<|im_end|>\n" +
|
||||
"<|im_start|>user\nHow are you?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "parallel tool calls",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Weather in Paris and London?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "Paris"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"city": "London"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: "Sunny"},
|
||||
{Role: "tool", Content: "Rainy"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"city": {Type: api.PropertyType{"string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
|
||||
"<function>\n<name>get_weather</name>\n" +
|
||||
"<parameters>\n" +
|
||||
"<parameter>\n<name>city</name>\n<type>string</type>\n</parameter>\n" +
|
||||
"</parameters>\n</function>\n</tools>\n\n" +
|
||||
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
|
||||
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
|
||||
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
|
||||
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
|
||||
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
|
||||
"- Required parameters MUST be specified\n" +
|
||||
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
|
||||
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
|
||||
"</IMPORTANT><|im_end|>\n" +
|
||||
"<|im_start|>user\nWeather in Paris and London?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nLondon\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
|
||||
"<|im_start|>user\n<tool_response>\nSunny\n</tool_response>\n<tool_response>\nRainy\n</tool_response>\n<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
{
|
||||
name: "thinking disabled even when model supports it",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
isThinking: true, // model supports thinking
|
||||
thinkValue: nil, // but user didn't request it
|
||||
expected: "<|im_start|>system\n<|im_end|>\n" +
|
||||
"<|im_start|>user\nHello!<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think></think>",
|
||||
},
|
||||
{
|
||||
name: "complex message history with thinking, tools, tool calls, tool results and content",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "What's the weather in Paris and London? Also, what's 2+2?"},
|
||||
{Role: "assistant", Content: "", Thinking: "I need to check the weather for both cities and calculate 2+2. Let me start with the weather calls.", ToolCalls: []api.ToolCall{
|
||||
{Function: api.ToolCallFunction{Name: "get_weather", Arguments: api.ToolCallFunctionArguments{"city": "Paris"}}},
|
||||
{Function: api.ToolCallFunction{Name: "get_weather", Arguments: api.ToolCallFunctionArguments{"city": "London"}}},
|
||||
}},
|
||||
{Role: "tool", Content: "Sunny, 22°C", ToolCallID: "call1"},
|
||||
{Role: "tool", Content: "Rainy, 15°C", ToolCallID: "call2"},
|
||||
{Role: "assistant", Content: "", Thinking: "Now I have the weather data. Let me calculate 2+2.", ToolCalls: []api.ToolCall{
|
||||
{Function: api.ToolCallFunction{Name: "calculate", Arguments: api.ToolCallFunctionArguments{"expression": "2+2"}}},
|
||||
}},
|
||||
{Role: "tool", Content: "4", ToolCallID: "call3"},
|
||||
{Role: "assistant", Content: "Based on the weather data, Paris is sunny at 22°C and London is rainy at 15°C. Also, 2+2 equals 4.", Thinking: "Perfect! I have all the information needed to provide a complete answer."},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"city": {Type: api.PropertyType{"string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "calculate",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"expression": {Type: api.PropertyType{"string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isThinking: true,
|
||||
thinkValue: &api.ThinkValue{Value: true},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
|
||||
"<function>\n<name>get_weather</name>\n" +
|
||||
"<parameters>\n" +
|
||||
"<parameter>\n<name>city</name>\n<type>string</type>\n</parameter>\n" +
|
||||
"</parameters>\n</function>\n" +
|
||||
"<function>\n<name>calculate</name>\n" +
|
||||
"<parameters>\n" +
|
||||
"<parameter>\n<name>expression</name>\n<type>string</type>\n</parameter>\n" +
|
||||
"</parameters>\n</function>\n</tools>\n\n" +
|
||||
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
|
||||
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
|
||||
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
|
||||
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
|
||||
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
|
||||
"- Required parameters MUST be specified\n" +
|
||||
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
|
||||
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
|
||||
"</IMPORTANT><|im_end|>\n" +
|
||||
"<|im_start|>user\nWhat's the weather in Paris and London? Also, what's 2+2?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>\nI need to check the weather for both cities and calculate 2+2. Let me start with the weather calls.\n</think>\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n" +
|
||||
"<tool_call>\n<function=get_weather>\n<parameter=city>\nLondon\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
|
||||
"<|im_start|>user\n<tool_response>\nSunny, 22°C\n</tool_response>\n<tool_response>\nRainy, 15°C\n</tool_response>\n<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>\nNow I have the weather data. Let me calculate 2+2.\n</think>\n" +
|
||||
"<tool_call>\n<function=calculate>\n<parameter=expression>\n2+2\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
|
||||
"<|im_start|>user\n<tool_response>\n4\n</tool_response>\n<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>\nPerfect! I have all the information needed to provide a complete answer.\n</think>\n" +
|
||||
"Based on the weather data, Paris is sunny at 22°C and London is rainy at 15°C. Also, 2+2 equals 4.<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n<think>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
renderer := &Nemotron3NanoRenderer{IsThinking: tt.isThinking}
|
||||
rendered, err := renderer.Render(tt.msgs, tt.tools, tt.thinkValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(rendered, tt.expected); diff != "" {
|
||||
t.Errorf("mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue