gemma4: update renderer to match new jinja template (#15490)
* gemma4: update renderer to match new jinja template Google has updated their jinja template for gemma4, and so this change gives us parity with the new template. The parsing also slightly changed upstream, so we make a small change to our parser as well. I've also corrected a few probably existing edge cases, especially around type unions. The upstream output format is weird (a stringified array), but in practice the models seem to understand it well. * gemma4: special case simple `AnyOf`s The upstream template doesn't handle `AnyOf`s, but since in the previous commit we saw type unions work reasonably well, I'm now treating very simple `AnyOf`s as type unions to help in cases where they might be used * fix lint * gemma4: prefer empty instead of `None` We can't currently distinguish between a result being not-present vs. empty. The empty case seems more important (e.g., a legitimately empty tool call) * gemma4: be more careful for tool results with missing IDs
This commit is contained in:
+22
-8
@@ -26,6 +26,7 @@ const (
|
||||
gemma4ThinkingCloseTag = "<channel|>"
|
||||
gemma4ToolCallOpenTag = "<|tool_call>"
|
||||
gemma4ToolCallCloseTag = "<tool_call|>"
|
||||
gemma4ToolResponseTag = "<|tool_response>"
|
||||
gemma4StringDelimiter = `<|"|>`
|
||||
)
|
||||
|
||||
@@ -326,26 +327,39 @@ func (p *Gemma4Parser) eat(done bool) ([]gemma4Event, bool) {
|
||||
|
||||
case Gemma4IgnoringPostToolCallNoise:
|
||||
// We've observed Gemma 4 occasionally emitting extra <tool_call|> tags
|
||||
// after a valid tool call. We suppress leading close tags in this immediate
|
||||
// post-tool-call state so the extra close tags do not leak into assistant
|
||||
// content. The tradeoff is that if the model intentionally begins its next
|
||||
// content span with the literal string "<tool_call|>", we will erroneously
|
||||
// treat it as noise and drop it.
|
||||
// after a valid tool call. We suppress those leading control tags in this
|
||||
// immediate post-tool-call state so they do not leak into assistant
|
||||
// content. The tradeoff is that if the model intentionally begins its next
|
||||
// content span with one of those literal strings, we will erroneously
|
||||
// treat it as noise and drop it. We also suppress a leading
|
||||
// <|tool_response> marker here because the updated upstream parser/template
|
||||
// uses it as a post-tool-call boundary.
|
||||
bufStr = strings.TrimLeftFunc(bufStr, unicode.IsSpace)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
|
||||
for strings.HasPrefix(bufStr, gemma4ToolCallCloseTag) {
|
||||
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolCallCloseTag):], unicode.IsSpace)
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(bufStr, gemma4ToolCallCloseTag):
|
||||
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolCallCloseTag):], unicode.IsSpace)
|
||||
case strings.HasPrefix(bufStr, gemma4ToolResponseTag):
|
||||
bufStr = strings.TrimLeftFunc(bufStr[len(gemma4ToolResponseTag):], unicode.IsSpace)
|
||||
default:
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
goto strippedPostToolCallNoise
|
||||
}
|
||||
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(bufStr)
|
||||
}
|
||||
|
||||
strippedPostToolCallNoise:
|
||||
if bufStr == "" {
|
||||
return events, false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(gemma4ToolCallCloseTag, bufStr) {
|
||||
if strings.HasPrefix(gemma4ToolCallCloseTag, bufStr) || strings.HasPrefix(gemma4ToolResponseTag, bufStr) {
|
||||
if done {
|
||||
p.buffer.Reset()
|
||||
p.state = Gemma4CollectingContent
|
||||
|
||||
@@ -530,6 +530,77 @@ func TestGemma4Parser_IgnoresExtraToolCallCloseTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGemma4Parser_IgnoresToolResponseBoundaryAfterToolCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks []string
|
||||
expectedContent string
|
||||
}{
|
||||
{
|
||||
name: "same_chunk_without_trailing_content",
|
||||
chunks: []string{
|
||||
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_response>`,
|
||||
},
|
||||
expectedContent: "",
|
||||
},
|
||||
{
|
||||
name: "same_chunk_before_real_content",
|
||||
chunks: []string{
|
||||
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_response>Done.`,
|
||||
},
|
||||
expectedContent: "Done.",
|
||||
},
|
||||
{
|
||||
name: "split_across_chunks_before_real_content",
|
||||
chunks: []string{
|
||||
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_res`,
|
||||
`ponse>Done.`,
|
||||
},
|
||||
expectedContent: "Done.",
|
||||
},
|
||||
}
|
||||
|
||||
expectedToolCalls := []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{
|
||||
"location": "Paris",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := &Gemma4Parser{hasThinkingSupport: false}
|
||||
parser.Init(nil, nil, nil)
|
||||
|
||||
var finalContent strings.Builder
|
||||
var finalToolCalls []api.ToolCall
|
||||
|
||||
for i, chunk := range tt.chunks {
|
||||
done := i == len(tt.chunks)-1
|
||||
content, _, toolCalls, err := parser.Add(chunk, done)
|
||||
if err != nil {
|
||||
t.Fatalf("Add() error on chunk %d: %v", i, err)
|
||||
}
|
||||
|
||||
finalContent.WriteString(content)
|
||||
finalToolCalls = append(finalToolCalls, toolCalls...)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.expectedContent, finalContent.String()); diff != "" {
|
||||
t.Errorf("content mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" {
|
||||
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGemma4Parser_StreamingSplitThinkingTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+546
-111
@@ -43,7 +43,7 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
|
||||
if hasSystemRole || len(tools) > 0 || hasThink {
|
||||
sb.WriteString("<|turn>system\n")
|
||||
if hasThink {
|
||||
sb.WriteString("<|think|>")
|
||||
sb.WriteString("<|think|>\n")
|
||||
}
|
||||
if systemMessage != "" {
|
||||
sb.WriteString(strings.TrimSpace(systemMessage))
|
||||
@@ -54,41 +54,80 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
|
||||
sb.WriteString("<turn|>\n")
|
||||
}
|
||||
|
||||
// Each message gets its own <|turn>role\n ... <turn|>\n block,
|
||||
// matching the HF chat template exactly.
|
||||
for _, message := range loopMessages {
|
||||
switch message.Role {
|
||||
case "user":
|
||||
sb.WriteString("<|turn>user\n")
|
||||
r.renderContent(&sb, message, &imageOffset, true)
|
||||
sb.WriteString("<turn|>\n")
|
||||
lastUserIdx := -1
|
||||
for i, message := range loopMessages {
|
||||
if message.Role == "user" {
|
||||
lastUserIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
case "assistant":
|
||||
sb.WriteString("<|turn>model\n")
|
||||
// Tool calls come before content (matching HF template order)
|
||||
var prevMessageType string
|
||||
|
||||
// Consecutive tool messages are folded into the preceding assistant turn,
|
||||
// and adjacent assistant messages continue in the same model turn.
|
||||
for i, message := range loopMessages {
|
||||
if message.Role == "tool" {
|
||||
continue
|
||||
}
|
||||
|
||||
messageHadContent := r.messageHasContent(message)
|
||||
prevMessageType = ""
|
||||
role := message.Role
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
|
||||
continueSameModelTurn := role == "model" && r.previousNonToolRole(loopMessages, i) == "assistant"
|
||||
if !continueSameModelTurn {
|
||||
sb.WriteString("<|turn>" + role + "\n")
|
||||
}
|
||||
|
||||
if message.Role == "assistant" && message.Thinking != "" && i > lastUserIdx && len(message.ToolCalls) > 0 {
|
||||
sb.WriteString("<|channel>thought\n")
|
||||
sb.WriteString(message.Thinking)
|
||||
sb.WriteString("\n<channel|>")
|
||||
}
|
||||
|
||||
if len(message.ToolCalls) > 0 {
|
||||
for _, tc := range message.ToolCalls {
|
||||
sb.WriteString(r.formatToolCall(tc))
|
||||
}
|
||||
// Strip thinking from history (matching HF strip_thinking macro)
|
||||
if message.Content != "" {
|
||||
sb.WriteString(stripThinking(message.Content))
|
||||
prevMessageType = "tool_call"
|
||||
}
|
||||
|
||||
toolResponsesEmitted := false
|
||||
if len(message.ToolCalls) > 0 {
|
||||
for k := i + 1; k < len(loopMessages) && loopMessages[k].Role == "tool"; k++ {
|
||||
sb.WriteString(r.formatToolResponseBlock(r.toolResponseName(loopMessages[k], message.ToolCalls), loopMessages[k].Content))
|
||||
toolResponsesEmitted = true
|
||||
prevMessageType = "tool_response"
|
||||
}
|
||||
sb.WriteString("<turn|>\n")
|
||||
|
||||
case "tool":
|
||||
sb.WriteString("<|turn>tool\n")
|
||||
sb.WriteString(strings.TrimSpace(message.Content))
|
||||
sb.WriteString("<turn|>\n")
|
||||
}
|
||||
|
||||
switch role {
|
||||
case "model":
|
||||
if message.Content != "" || len(message.Images) > 0 {
|
||||
message.Content = stripThinking(message.Content)
|
||||
r.renderContent(&sb, message, &imageOffset, false)
|
||||
}
|
||||
default:
|
||||
sb.WriteString("<|turn>" + message.Role + "\n")
|
||||
sb.WriteString(strings.TrimSpace(message.Content))
|
||||
r.renderContent(&sb, message, &imageOffset, true)
|
||||
}
|
||||
|
||||
if prevMessageType == "tool_call" && !toolResponsesEmitted {
|
||||
sb.WriteString("<|tool_response>")
|
||||
} else if !(toolResponsesEmitted && !messageHadContent) {
|
||||
sb.WriteString("<turn|>\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Generation prompt
|
||||
sb.WriteString("<|turn>model\n")
|
||||
// Generation prompt.
|
||||
if prevMessageType != "tool_response" && prevMessageType != "tool_call" {
|
||||
sb.WriteString("<|turn>model\n")
|
||||
if !hasThink {
|
||||
sb.WriteString("<|channel>thought\n<channel|>")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -130,6 +169,36 @@ func (r *Gemma4Renderer) renderContent(sb *strings.Builder, msg api.Message, ima
|
||||
sb.WriteString(content)
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) previousNonToolRole(messages []api.Message, idx int) string {
|
||||
for i := idx - 1; i >= 0; i-- {
|
||||
if messages[i].Role != "tool" {
|
||||
return messages[i].Role
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) messageHasContent(message api.Message) bool {
|
||||
return message.Content != "" || len(message.Images) > 0
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) toolResponseName(message api.Message, toolCalls []api.ToolCall) string {
|
||||
name := message.ToolName
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
if message.ToolCallID != "" {
|
||||
for _, tc := range toolCalls {
|
||||
if tc.ID == message.ToolCallID {
|
||||
name = tc.Function.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
|
||||
var sb strings.Builder
|
||||
fn := tool.Function
|
||||
@@ -144,7 +213,7 @@ func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
|
||||
|
||||
if fn.Parameters.Properties != nil && fn.Parameters.Properties.Len() > 0 {
|
||||
sb.WriteString("properties:{")
|
||||
r.writeProperties(&sb, fn.Parameters.Properties)
|
||||
r.writeTypedProperties(&sb, fn.Parameters.Properties)
|
||||
sb.WriteString("}")
|
||||
needsComma = true
|
||||
}
|
||||
@@ -178,93 +247,25 @@ func (r *Gemma4Renderer) renderToolDeclaration(tool api.Tool) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) writeProperties(sb *strings.Builder, props *api.ToolPropertiesMap) {
|
||||
keys := make([]string, 0, props.Len())
|
||||
for k := range props.All() {
|
||||
keys = append(keys, k)
|
||||
func (r *Gemma4Renderer) writeTypedProperties(sb *strings.Builder, props *api.ToolPropertiesMap) {
|
||||
if props == nil || props.Len() == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
first := true
|
||||
for _, name := range keys {
|
||||
prop, _ := props.Get(name)
|
||||
if !first {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
|
||||
sb.WriteString(name + ":{")
|
||||
|
||||
hasContent := false
|
||||
if prop.Description != "" {
|
||||
sb.WriteString("description:" + g4Q + prop.Description + g4Q)
|
||||
hasContent = true
|
||||
}
|
||||
|
||||
if len(prop.Type) > 0 {
|
||||
typeName := strings.ToUpper(prop.Type[0])
|
||||
|
||||
switch typeName {
|
||||
case "STRING":
|
||||
if len(prop.Enum) > 0 {
|
||||
if hasContent {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("enum:[")
|
||||
for j, e := range prop.Enum {
|
||||
if j > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + fmt.Sprintf("%v", e) + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
hasContent = true
|
||||
}
|
||||
|
||||
case "OBJECT":
|
||||
// Render nested properties recursively.
|
||||
// Note: the leading comma is hardcoded (matching the template),
|
||||
// and this does NOT set hasContent — the comma before type:
|
||||
// depends only on whether description was present.
|
||||
sb.WriteString(",properties:{")
|
||||
if prop.Properties != nil && prop.Properties.Len() > 0 {
|
||||
r.writeProperties(sb, prop.Properties)
|
||||
}
|
||||
sb.WriteString("}")
|
||||
if len(prop.Required) > 0 {
|
||||
sb.WriteString(",required:[")
|
||||
for j, req := range prop.Required {
|
||||
if j > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + req + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
}
|
||||
|
||||
case "ARRAY":
|
||||
// Render items specification.
|
||||
// Same as OBJECT: leading comma is hardcoded, does NOT set hasContent.
|
||||
if items, ok := prop.Items.(map[string]any); ok && len(items) > 0 {
|
||||
sb.WriteString(",items:{")
|
||||
r.writeItemsSpec(sb, items)
|
||||
sb.WriteString("}")
|
||||
}
|
||||
}
|
||||
|
||||
if hasContent {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("type:" + g4Q + typeName + g4Q)
|
||||
}
|
||||
|
||||
sb.WriteString("}")
|
||||
}
|
||||
r.writeSchemaProperties(sb, typedSchemaPropertiesMap(props))
|
||||
}
|
||||
|
||||
// writeItemsSpec renders the items specification for array-type properties,
|
||||
func typedSchemaPropertiesMap(props *api.ToolPropertiesMap) map[string]any {
|
||||
out := make(map[string]any, props.Len())
|
||||
for key, prop := range props.All() {
|
||||
out[key] = topLevelTypedSchemaValueFromToolProperty(prop)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSchemaItemsSpec renders the items specification for array-type properties,
|
||||
// matching the Jinja2 template's dictsort iteration over items.
|
||||
func (r *Gemma4Renderer) writeItemsSpec(sb *strings.Builder, items map[string]any) {
|
||||
func (r *Gemma4Renderer) writeSchemaItemsSpec(sb *strings.Builder, items map[string]any) {
|
||||
keys := make([]string, 0, len(items))
|
||||
for k := range items {
|
||||
keys = append(keys, k)
|
||||
@@ -283,16 +284,379 @@ func (r *Gemma4Renderer) writeItemsSpec(sb *strings.Builder, items map[string]an
|
||||
first = false
|
||||
|
||||
switch key {
|
||||
case "properties":
|
||||
sb.WriteString("properties:{")
|
||||
if props, ok := r.asSchemaMap(value); ok {
|
||||
r.writeSchemaProperties(sb, props)
|
||||
}
|
||||
sb.WriteString("}")
|
||||
case "required":
|
||||
sb.WriteString("required:[")
|
||||
for i, req := range normalizeStringSlice(value) {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + req + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
case "type":
|
||||
if s, ok := value.(string); ok {
|
||||
sb.WriteString("type:" + g4Q + strings.ToUpper(s) + g4Q)
|
||||
typeNames := normalizeTypeNames(value)
|
||||
if len(typeNames) == 1 {
|
||||
sb.WriteString("type:" + g4Q + typeNames[0] + g4Q)
|
||||
} else if len(typeNames) > 1 {
|
||||
sb.WriteString("type:[")
|
||||
for i, typeName := range typeNames {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + typeName + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
}
|
||||
default:
|
||||
sb.WriteString(key + ":" + r.formatArgValue(value))
|
||||
sb.WriteString(key + ":" + r.formatSchemaValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) writeSchemaProperties(sb *strings.Builder, props map[string]any) {
|
||||
keys := make([]string, 0, len(props))
|
||||
for k := range props {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
first := true
|
||||
for _, name := range keys {
|
||||
if isSchemaStandardKey(name) {
|
||||
continue
|
||||
}
|
||||
prop, ok := r.asSchemaMap(props[name])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
|
||||
sb.WriteString(name + ":{")
|
||||
|
||||
addComma := false
|
||||
if description, ok := prop["description"].(string); ok && description != "" {
|
||||
sb.WriteString("description:" + g4Q + description + g4Q)
|
||||
addComma = true
|
||||
}
|
||||
|
||||
typeNames := normalizeTypeNames(prop["type"])
|
||||
typeName := ""
|
||||
if len(typeNames) > 0 {
|
||||
typeName = typeNames[0]
|
||||
}
|
||||
|
||||
switch typeName {
|
||||
case "STRING":
|
||||
if enumValues := normalizeSlice(prop["enum"]); len(enumValues) > 0 {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
} else {
|
||||
addComma = true
|
||||
}
|
||||
sb.WriteString("enum:[")
|
||||
for i, value := range enumValues {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + fmt.Sprintf("%v", value) + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
}
|
||||
case "ARRAY":
|
||||
if items, ok := r.asSchemaMap(prop["items"]); ok && len(items) > 0 {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
} else {
|
||||
addComma = true
|
||||
}
|
||||
sb.WriteString("items:{")
|
||||
r.writeSchemaItemsSpec(sb, items)
|
||||
sb.WriteString("}")
|
||||
}
|
||||
}
|
||||
|
||||
if nullable, ok := prop["nullable"].(bool); ok && nullable {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
} else {
|
||||
addComma = true
|
||||
}
|
||||
sb.WriteString("nullable:true")
|
||||
}
|
||||
|
||||
if typeName == "OBJECT" {
|
||||
if nestedProps, ok := r.asSchemaMap(prop["properties"]); ok {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
} else {
|
||||
addComma = true
|
||||
}
|
||||
sb.WriteString("properties:{")
|
||||
r.writeSchemaProperties(sb, nestedProps)
|
||||
sb.WriteString("}")
|
||||
} else {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
} else {
|
||||
addComma = true
|
||||
}
|
||||
sb.WriteString("properties:{")
|
||||
r.writeSchemaProperties(sb, prop)
|
||||
sb.WriteString("}")
|
||||
}
|
||||
|
||||
required := normalizeStringSlice(prop["required"])
|
||||
if len(required) > 0 {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
} else {
|
||||
addComma = true
|
||||
}
|
||||
sb.WriteString("required:[")
|
||||
for i, req := range required {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + req + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
}
|
||||
}
|
||||
|
||||
if len(typeNames) > 0 {
|
||||
if addComma {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
if len(typeNames) == 1 {
|
||||
sb.WriteString("type:" + g4Q + typeNames[0] + g4Q)
|
||||
} else {
|
||||
sb.WriteString("type:[")
|
||||
for i, name := range typeNames {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(g4Q + name + g4Q)
|
||||
}
|
||||
sb.WriteString("]")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("}")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) asSchemaMap(value any) (map[string]any, bool) {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
return v, true
|
||||
case *api.ToolPropertiesMap:
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
out := make(map[string]any, v.Len())
|
||||
for key, prop := range v.All() {
|
||||
out[key] = schemaValueFromToolProperty(prop)
|
||||
}
|
||||
return out, true
|
||||
case api.ToolProperty:
|
||||
return schemaValueFromToolProperty(v), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func schemaValueFromToolProperty(prop api.ToolProperty) map[string]any {
|
||||
out := make(map[string]any)
|
||||
if len(prop.Type) > 0 {
|
||||
if len(prop.Type) == 1 {
|
||||
out["type"] = prop.Type[0]
|
||||
} else {
|
||||
out["type"] = []string(prop.Type)
|
||||
}
|
||||
} else if unionTypes, ok := simpleAnyOfTypes(prop); ok {
|
||||
if len(unionTypes) == 1 {
|
||||
out["type"] = unionTypes[0]
|
||||
} else {
|
||||
out["type"] = []string(unionTypes)
|
||||
}
|
||||
}
|
||||
if prop.Description != "" {
|
||||
out["description"] = prop.Description
|
||||
}
|
||||
if len(prop.Enum) > 0 {
|
||||
out["enum"] = prop.Enum
|
||||
}
|
||||
if prop.Items != nil {
|
||||
out["items"] = prop.Items
|
||||
}
|
||||
if prop.Properties != nil {
|
||||
out["properties"] = prop.Properties
|
||||
}
|
||||
if len(prop.Required) > 0 {
|
||||
out["required"] = prop.Required
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func topLevelTypedSchemaValueFromToolProperty(prop api.ToolProperty) map[string]any {
|
||||
out := make(map[string]any)
|
||||
if len(prop.Type) > 0 {
|
||||
// api.ToolProperty intentionally models nullability through type unions
|
||||
// that include "null" rather than OpenAPI 3.0's nullable:true keyword.
|
||||
// Gemma's template accepts nullable:true as well, but our typed top-level
|
||||
// tool properties do not carry that field. For multi-type unions, the
|
||||
// template stringifies the uppercase list rather than emitting a structured
|
||||
// type array. That is odd, but we match upstream here.
|
||||
out["type"] = upstreamTypedPropertyTypeValue(prop.Type)
|
||||
} else if unionTypes, ok := simpleAnyOfTypes(prop); ok {
|
||||
// Gemma's declaration format does not have a dedicated anyOf construct, so
|
||||
// we lower simple unions of bare type branches into the same typed union
|
||||
// form used for api.PropertyType.
|
||||
out["type"] = upstreamTypedPropertyTypeValue(unionTypes)
|
||||
}
|
||||
if prop.Description != "" {
|
||||
out["description"] = prop.Description
|
||||
}
|
||||
if len(prop.Enum) > 0 {
|
||||
out["enum"] = prop.Enum
|
||||
}
|
||||
if prop.Items != nil {
|
||||
out["items"] = prop.Items
|
||||
}
|
||||
if prop.Properties != nil {
|
||||
out["properties"] = typedSchemaPropertiesMap(prop.Properties)
|
||||
}
|
||||
if len(prop.Required) > 0 {
|
||||
out["required"] = prop.Required
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func upstreamTypedPropertyTypeValue(types api.PropertyType) string {
|
||||
if len(types) == 1 {
|
||||
return types[0]
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[")
|
||||
for i, typ := range types {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString("'" + strings.ToUpper(typ) + "'")
|
||||
}
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func simpleAnyOfTypes(prop api.ToolProperty) (api.PropertyType, bool) {
|
||||
if len(prop.AnyOf) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var out api.PropertyType
|
||||
seen := make(map[string]struct{})
|
||||
for _, branch := range prop.AnyOf {
|
||||
if !isBareTypeOnlyToolProperty(branch) || len(branch.Type) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, typ := range branch.Type {
|
||||
if _, ok := seen[typ]; ok {
|
||||
continue
|
||||
}
|
||||
seen[typ] = struct{}{}
|
||||
out = append(out, typ)
|
||||
}
|
||||
}
|
||||
|
||||
return out, len(out) > 0
|
||||
}
|
||||
|
||||
func isBareTypeOnlyToolProperty(prop api.ToolProperty) bool {
|
||||
return len(prop.AnyOf) == 0 &&
|
||||
len(prop.Type) > 0 &&
|
||||
prop.Items == nil &&
|
||||
prop.Description == "" &&
|
||||
len(prop.Enum) == 0 &&
|
||||
prop.Properties == nil &&
|
||||
len(prop.Required) == 0
|
||||
}
|
||||
|
||||
func isSchemaStandardKey(key string) bool {
|
||||
switch key {
|
||||
case "description", "type", "properties", "required", "nullable":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTypeNames(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return []string{strings.ToUpper(v)}
|
||||
case []string:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, strings.ToUpper(item))
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, strings.ToUpper(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case api.PropertyType:
|
||||
return normalizeTypeNames([]string(v))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringSlice(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), v...)
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSlice(value any) []any {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
return v
|
||||
case []string:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatToolCall(tc api.ToolCall) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<|tool_call>call:" + tc.Function.Name + "{")
|
||||
@@ -317,6 +681,10 @@ func (r *Gemma4Renderer) formatToolCall(tc api.ToolCall) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatToolResponseBlock(toolName, response string) string {
|
||||
return "<|tool_response>response:" + toolName + "{value:" + r.formatArgValue(response) + "}<tool_response|>"
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatArgValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
@@ -365,6 +733,73 @@ func (r *Gemma4Renderer) formatMapValue(m map[string]any) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatSchemaValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return g4Q + v + g4Q
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
case int, int64, int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case map[string]any:
|
||||
return r.formatSchemaMapValue(v)
|
||||
case []any:
|
||||
return r.formatSchemaArrayValue(v)
|
||||
case []string:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
out = append(out, item)
|
||||
}
|
||||
return r.formatSchemaArrayValue(out)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatSchemaMapValue(m map[string]any) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("{")
|
||||
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
first := true
|
||||
for _, key := range keys {
|
||||
if !first {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
sb.WriteString(g4Q + key + g4Q + ":" + r.formatSchemaValue(m[key]))
|
||||
}
|
||||
|
||||
sb.WriteString("}")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatSchemaArrayValue(arr []any) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[")
|
||||
for i, item := range arr {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(r.formatSchemaValue(item))
|
||||
}
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) formatArrayValue(arr []any) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[")
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
)
|
||||
|
||||
// The full Jinja2 template is committed as testdata/gemma4_chat_template.jinja2.
|
||||
// Run with VERIFY_JINJA2=1 to verify expected values against the template using Python.
|
||||
// Run with VERIFY_JINJA2=1 to verify expected values against the template using uv + Python.
|
||||
|
||||
func bashRefTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
@@ -183,6 +183,264 @@ func arrayTool() []api.Tool {
|
||||
}}
|
||||
}
|
||||
|
||||
func unionTopLevelTypeTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "maybe_name",
|
||||
Description: "Test nullable union",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"name": {Type: api.PropertyType{"string", "null"}, Description: "Name"},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func anyOfTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "pick_value",
|
||||
Description: "Pick a value",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"value": {
|
||||
AnyOf: []api.ToolProperty{
|
||||
{Type: api.PropertyType{"string"}},
|
||||
{Type: api.PropertyType{"number"}},
|
||||
},
|
||||
Description: "Value",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func arrayObjectItemsTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "upsert_batch",
|
||||
Description: "Upsert batch",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"entries": {
|
||||
Type: api.PropertyType{"array"},
|
||||
Description: "Entries",
|
||||
Items: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "ID",
|
||||
},
|
||||
"count": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func arrayUnionItemsTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "maybe_batch",
|
||||
Description: "Maybe batch",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"values": {
|
||||
Type: api.PropertyType{"array"},
|
||||
Description: "Values",
|
||||
Items: map[string]any{
|
||||
"type": []string{"string", "null"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func arrayNestedItemsTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "plan_batch",
|
||||
Description: "Plan batch",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"steps": {
|
||||
Type: api.PropertyType{"array"},
|
||||
Description: "Steps",
|
||||
Items: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"name"},
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Name",
|
||||
},
|
||||
"config": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Config",
|
||||
"required": []string{"enabled"},
|
||||
"properties": map[string]any{
|
||||
"enabled": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
"tags": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Tags",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func arrayNullableNestedItemsTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "annotate_batch",
|
||||
Description: "Annotate batch",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"entries": {
|
||||
Type: api.PropertyType{"array"},
|
||||
Description: "Entries",
|
||||
Items: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"note"},
|
||||
"properties": map[string]any{
|
||||
"note": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Note",
|
||||
"nullable": true,
|
||||
},
|
||||
"metadata": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Metadata",
|
||||
"nullable": true,
|
||||
"properties": map[string]any{
|
||||
"tag": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Tag",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func nestedArrayObjectItemsTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "schedule_jobs",
|
||||
Description: "Schedule jobs",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"jobs": {
|
||||
Type: api.PropertyType{"array"},
|
||||
Description: "Jobs",
|
||||
Items: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"tasks"},
|
||||
"properties": map[string]any{
|
||||
"tasks": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Tasks",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"command"},
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Command",
|
||||
},
|
||||
"timeout": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Timeout",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func arrayItemsExtraKeysTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "configure_batch",
|
||||
Description: "Configure batch",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]api.ToolProperty{
|
||||
"settings": {
|
||||
Type: api.PropertyType{"array"},
|
||||
Description: "Settings",
|
||||
Items: map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"default": map[string]any{
|
||||
"mode": "auto",
|
||||
},
|
||||
"nullable": true,
|
||||
"required": []string{"mode"},
|
||||
"properties": map[string]any{
|
||||
"mode": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Mode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func configureTool() []api.Tool {
|
||||
return []api.Tool{{
|
||||
Type: "function",
|
||||
@@ -385,7 +643,7 @@ var (
|
||||
enumNoDescDeclRef = `<|tool>declaration:set_level{description:<|"|>Set level<|"|>,parameters:{properties:{level:{enum:[<|"|>low<|"|>,<|"|>high<|"|>],type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
searchDeclRef = `<|tool>declaration:search{description:<|"|>Search<|"|>,parameters:{properties:{limit:{type:<|"|>NUMBER<|"|>},offset:{description:<|"|>Start offset<|"|>,type:<|"|>NUMBER<|"|>},query:{description:<|"|>Search query<|"|>,type:<|"|>STRING<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
arrayNoItemsDeclRef = `<|tool>declaration:tag{description:<|"|>Tag items<|"|>,parameters:{properties:{tags:{description:<|"|>Tags<|"|>,type:<|"|>ARRAY<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
objectNoDescDeclRef = `<|tool>declaration:update{description:<|"|>Update settings<|"|>,parameters:{properties:{settings:{,properties:{verbose:{description:<|"|>Verbose mode<|"|>,type:<|"|>BOOLEAN<|"|>}}type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
objectNoDescDeclRef = `<|tool>declaration:update{description:<|"|>Update settings<|"|>,parameters:{properties:{settings:{properties:{verbose:{description:<|"|>Verbose mode<|"|>,type:<|"|>BOOLEAN<|"|>}},type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
nestedRequiredDeclRef = `<|tool>declaration:create_user{description:<|"|>Create user<|"|>,parameters:{properties:{profile:{description:<|"|>Profile<|"|>,properties:{age:{description:<|"|>Age<|"|>,type:<|"|>NUMBER<|"|>},name:{description:<|"|>Name<|"|>,type:<|"|>STRING<|"|>}},required:[<|"|>name<|"|>],type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
calcDeclRef = `<|tool>declaration:calc{description:<|"|>Calculate<|"|>,parameters:{properties:{value:{description:<|"|>Value<|"|>,type:<|"|>NUMBER<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
rawDeclRef = `<|tool>declaration:raw{description:<|"|>Raw input<|"|>,parameters:{type:<|"|>OBJECT<|"|>}}<tool|>`
|
||||
@@ -396,17 +654,18 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
q := `<|"|>`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []api.Message
|
||||
tools []api.Tool
|
||||
think *api.ThinkValue
|
||||
expected string
|
||||
name string
|
||||
messages []api.Message
|
||||
tools []api.Tool
|
||||
think *api.ThinkValue
|
||||
expected string
|
||||
skipJinja2 bool
|
||||
}{
|
||||
// === Header block paths ===
|
||||
{
|
||||
name: "user_only",
|
||||
messages: []api.Message{{Role: "user", Content: "Hello"}},
|
||||
expected: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "system_user",
|
||||
@@ -414,7 +673,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
expected: "<bos><|turn>system\nYou are helpful.<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\nYou are helpful.<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "developer_user",
|
||||
@@ -422,13 +681,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "developer", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
expected: "<bos><|turn>system\nYou are helpful.<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\nYou are helpful.<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "tools_no_system",
|
||||
messages: []api.Message{{Role: "user", Content: "Hi"}},
|
||||
tools: bashRefTool(),
|
||||
expected: "<bos><|turn>system\n" + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\n" + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "system_tools",
|
||||
@@ -437,13 +696,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
tools: bashRefTool(),
|
||||
expected: "<bos><|turn>system\nYou are helpful." + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\nYou are helpful." + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "thinking_no_system",
|
||||
messages: []api.Message{{Role: "user", Content: "Hi"}},
|
||||
think: thinkTrue(),
|
||||
expected: "<bos><|turn>system\n<|think|><turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
},
|
||||
{
|
||||
name: "thinking_system",
|
||||
@@ -452,14 +711,14 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
think: thinkTrue(),
|
||||
expected: "<bos><|turn>system\n<|think|>You are helpful.<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\n<|think|>\nYou are helpful.<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
},
|
||||
{
|
||||
name: "thinking_tools",
|
||||
messages: []api.Message{{Role: "user", Content: "Hi"}},
|
||||
tools: bashRefTool(),
|
||||
think: thinkTrue(),
|
||||
expected: "<bos><|turn>system\n<|think|>" + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\n<|think|>\n" + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
},
|
||||
{
|
||||
name: "thinking_system_tools",
|
||||
@@ -469,7 +728,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
},
|
||||
tools: bashRefTool(),
|
||||
think: thinkTrue(),
|
||||
expected: "<bos><|turn>system\n<|think|>You are helpful." + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>system\n<|think|>\nYou are helpful." + bashDeclRef + "<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
},
|
||||
|
||||
// === Message loop paths ===
|
||||
@@ -485,7 +744,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
"<|turn>user\nHi<turn|>\n" +
|
||||
"<|turn>model\nHello!<turn|>\n" +
|
||||
"<|turn>user\nMore<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Tool call with structured args → tool response as separate <|turn>tool turn
|
||||
@@ -499,14 +758,30 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
Arguments: testArgs(map[string]any{"command": "ls"}),
|
||||
},
|
||||
}}},
|
||||
{Role: "tool", Content: "file1.txt\nfile2.txt"},
|
||||
{Role: "tool", ToolName: "bash", Content: "file1.txt\nfile2.txt"},
|
||||
},
|
||||
tools: bashRefTool(),
|
||||
expected: "<bos><|turn>system\nYou are helpful." + bashDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nList files<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nfile1.txt\nfile2.txt<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
name: "tool_call_pending_response",
|
||||
messages: []api.Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "List files"},
|
||||
{Role: "assistant", Content: "Let me check.", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
Arguments: testArgs(map[string]any{"command": "ls"}),
|
||||
},
|
||||
}}},
|
||||
},
|
||||
tools: bashRefTool(),
|
||||
expected: "<bos><|turn>system\nYou are helpful." + bashDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nList files<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>Let me check.<|tool_response>",
|
||||
},
|
||||
{
|
||||
// Full round trip: call → response → assistant reply → user follow-up
|
||||
@@ -520,18 +795,18 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
Arguments: testArgs(map[string]any{"command": "ls"}),
|
||||
},
|
||||
}}},
|
||||
{Role: "tool", Content: "file1.txt\nfile2.txt"},
|
||||
{Role: "tool", ToolName: "bash", Content: "file1.txt\nfile2.txt"},
|
||||
{Role: "assistant", Content: "Here are the files."},
|
||||
{Role: "user", Content: "Read file1.txt"},
|
||||
},
|
||||
tools: bashAndReadRefTools(),
|
||||
expected: "<bos><|turn>system\nYou are helpful." + bashDeclRef + readDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nList files<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nfile1.txt\nfile2.txt<turn|>\n" +
|
||||
"<|turn>model\nHere are the files.<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}<tool_response|>" +
|
||||
"Here are the files.<turn|>\n" +
|
||||
"<|turn>user\nRead file1.txt<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Multiple tool calls + multiple tool responses
|
||||
@@ -543,17 +818,17 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})}},
|
||||
{Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})}},
|
||||
}},
|
||||
{Role: "tool", Content: "file1.txt\nfile2.txt"},
|
||||
{Role: "tool", Content: "module example.com/foo"},
|
||||
{Role: "tool", ToolName: "bash", Content: "file1.txt\nfile2.txt"},
|
||||
{Role: "tool", ToolName: "read", Content: "module example.com/foo"},
|
||||
},
|
||||
tools: bashAndReadRefTools(),
|
||||
expected: "<bos><|turn>system\nYou are helpful." + bashDeclRef + readDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nList and read<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_call>call:read{path:" + q + "go.mod" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nfile1.txt\nfile2.txt<turn|>\n" +
|
||||
"<|turn>tool\nmodule example.com/foo<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|tool_call>call:read{path:" + q + "go.mod" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}<tool_response|>" +
|
||||
"<|tool_response>response:read{value:" + q + "module example.com/foo" + q + "}<tool_response|>",
|
||||
skipJinja2: true,
|
||||
},
|
||||
{
|
||||
// Thinking content in assistant history should be stripped
|
||||
@@ -566,7 +841,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
expected: "<bos><|turn>user\nWhat is 2+2?<turn|>\n" +
|
||||
"<|turn>model\n4<turn|>\n" +
|
||||
"<|turn>user\nAnd 3+3?<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
// === Additional edge cases ported from original tests ===
|
||||
{
|
||||
@@ -577,14 +852,14 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", Content: "Let me check.", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "Sunny"},
|
||||
{Role: "tool", ToolName: "get_weather", Content: "Sunny"},
|
||||
},
|
||||
tools: weatherTool(),
|
||||
expected: "<bos><|turn>system\n" + weatherDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nWeather?<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}<tool_call|>Let me check.<turn|>\n" +
|
||||
"<|turn>tool\nSunny<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:get_weather{value:" + q + "Sunny" + q + "}<tool_response|>" +
|
||||
"Let me check.<turn|>\n",
|
||||
},
|
||||
{
|
||||
// Numeric tool call arguments
|
||||
@@ -594,14 +869,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "add", Arguments: testArgs(map[string]any{"a": float64(1), "b": float64(2)})},
|
||||
}}},
|
||||
{Role: "tool", Content: `{"result": 3}`},
|
||||
{Role: "tool", ToolName: "add", Content: `{"result": 3}`},
|
||||
},
|
||||
tools: addTool(),
|
||||
expected: "<bos><|turn>system\n" + addDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nAdd<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:add{a:1,b:2}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\n{\"result\": 3}<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:add{a:1,b:2}<tool_call|>" +
|
||||
"<|tool_response>response:add{value:" + q + `{"result": 3}` + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Boolean tool call argument
|
||||
@@ -611,14 +885,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "set_flag", Arguments: testArgs(map[string]any{"enabled": true})},
|
||||
}}},
|
||||
{Role: "tool", Content: "done"},
|
||||
{Role: "tool", ToolName: "set_flag", Content: "done"},
|
||||
},
|
||||
tools: flagTool(),
|
||||
expected: "<bos><|turn>system\n" + flagDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nSet flag<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:set_flag{enabled:true}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\ndone<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:set_flag{enabled:true}<tool_call|>" +
|
||||
"<|tool_response>response:set_flag{value:" + q + "done" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Tool with enum parameter
|
||||
@@ -626,17 +899,17 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Test"}},
|
||||
tools: modeTool(),
|
||||
expected: "<bos><|turn>system\n" + modeDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nTest<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nTest<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "unicode_content",
|
||||
messages: []api.Message{{Role: "user", Content: "こんにちは"}},
|
||||
expected: "<bos><|turn>user\nこんにちは<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>user\nこんにちは<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
name: "newlines_in_content",
|
||||
messages: []api.Message{{Role: "user", Content: "Line 1\nLine 2\nLine 3"}},
|
||||
expected: "<bos><|turn>user\nLine 1\nLine 2\nLine 3<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>user\nLine 1\nLine 2\nLine 3<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Tool response (raw JSON) followed by user message
|
||||
@@ -646,16 +919,16 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Tokyo"})},
|
||||
}}},
|
||||
{Role: "tool", Content: `{"temperature": 15, "weather": "sunny"}`},
|
||||
{Role: "tool", ToolName: "get_weather", Content: `{"temperature": 15, "weather": "sunny"}`},
|
||||
{Role: "user", Content: "Thanks!"},
|
||||
},
|
||||
tools: weatherTool(),
|
||||
expected: "<bos><|turn>system\n" + weatherDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nWeather?<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\n{\"temperature\": 15, \"weather\": \"sunny\"}<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:get_weather{value:" + q + `{"temperature": 15, "weather": "sunny"}` + q + "}<tool_response|>" +
|
||||
"<|turn>user\nThanks!<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
// === Ordering and whitespace edge cases ===
|
||||
{
|
||||
@@ -666,20 +939,19 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"zzz": "last", "aaa": "first", "mmm": "middle"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "ok"},
|
||||
{Role: "tool", ToolName: "bash", Content: "ok"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{aaa:" + q + "first" + q + ",mmm:" + q + "middle" + q + ",zzz:" + q + "last" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nok<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{aaa:" + q + "first" + q + ",mmm:" + q + "middle" + q + ",zzz:" + q + "last" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "ok" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// User content with whitespace is trimmed
|
||||
name: "user_content_trimmed",
|
||||
messages: []api.Message{{Role: "user", Content: " hello "}},
|
||||
expected: "<bos><|turn>user\nhello<turn|>\n<|turn>model\n",
|
||||
expected: "<bos><|turn>user\nhello<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Empty tool call arguments
|
||||
@@ -689,14 +961,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{})},
|
||||
}}},
|
||||
{Role: "tool", Content: "ok"},
|
||||
{Role: "tool", ToolName: "bash", Content: "ok"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nok<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "ok" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Nested object properties in tool declaration
|
||||
@@ -704,7 +975,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Create"}},
|
||||
tools: nestedTool(),
|
||||
expected: "<bos><|turn>system\n" + nestedDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nCreate<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nCreate<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Array type in tool declaration
|
||||
@@ -712,7 +983,20 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Batch"}},
|
||||
tools: arrayTool(),
|
||||
expected: "<bos><|turn>system\n" + arrayDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nBatch<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nBatch<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Top-level typed union follows the template's odd stringified-list form.
|
||||
name: "typed_property_union_type",
|
||||
messages: []api.Message{{Role: "user", Content: "Hi"}},
|
||||
tools: unionTopLevelTypeTool(),
|
||||
expected: `<bos><|turn>system
|
||||
<|tool>declaration:maybe_name{description:<|"|>Test nullable union<|"|>,parameters:{properties:{name:{description:<|"|>Name<|"|>,type:<|"|>['STRING', 'NULL']<|"|>}},type:<|"|>OBJECT<|"|>}}<tool|><turn|>
|
||||
<|turn>user
|
||||
Hi<turn|>
|
||||
<|turn>model
|
||||
<|channel>thought
|
||||
<channel|>`,
|
||||
},
|
||||
{
|
||||
// Assistant whitespace is trimmed (strip_thinking includes | trim)
|
||||
@@ -725,7 +1009,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
expected: "<bos><|turn>user\nHi<turn|>\n" +
|
||||
"<|turn>model\nspaced<turn|>\n" +
|
||||
"<|turn>user\nMore<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Three sequential tool responses
|
||||
@@ -737,20 +1021,19 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "b"})}},
|
||||
{Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "c"})}},
|
||||
}},
|
||||
{Role: "tool", Content: "result-a"},
|
||||
{Role: "tool", Content: "result-b"},
|
||||
{Role: "tool", Content: "result-c"},
|
||||
{Role: "tool", ToolName: "bash", Content: "result-a"},
|
||||
{Role: "tool", ToolName: "bash", Content: "result-b"},
|
||||
{Role: "tool", ToolName: "bash", Content: "result-c"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nDo three things<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "a" + q + "}<tool_call|>" +
|
||||
"<|tool_call>call:bash{command:" + q + "b" + q + "}<tool_call|>" +
|
||||
"<|tool_call>call:bash{command:" + q + "c" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nresult-a<turn|>\n" +
|
||||
"<|turn>tool\nresult-b<turn|>\n" +
|
||||
"<|turn>tool\nresult-c<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|tool_call>call:bash{command:" + q + "c" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "result-a" + q + "}<tool_response|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "result-b" + q + "}<tool_response|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "result-c" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Assistant with only tool calls, no content field
|
||||
@@ -760,14 +1043,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "files"},
|
||||
{Role: "tool", ToolName: "bash", Content: "files"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nfiles<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "files" + q + "}<tool_response|>",
|
||||
},
|
||||
|
||||
// === Coverage gap cases ===
|
||||
@@ -782,7 +1064,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
expected: "<bos><|turn>user\nHi<turn|>\n" +
|
||||
"<|turn>model\nMiddleDone<turn|>\n" +
|
||||
"<|turn>user\nMore<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Property with no description — just type
|
||||
@@ -790,7 +1072,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Count"}},
|
||||
tools: countTool(),
|
||||
expected: "<bos><|turn>system\n" + countDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nCount<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nCount<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// System message with leading/trailing whitespace is trimmed
|
||||
@@ -800,7 +1082,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
expected: "<bos><|turn>system\nYou are helpful.<turn|>\n" +
|
||||
"<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Deeply nested map in tool call arguments (3 levels)
|
||||
@@ -812,14 +1094,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
"config": map[string]any{"db": map[string]any{"host": "localhost", "port": float64(5432)}},
|
||||
})},
|
||||
}}},
|
||||
{Role: "tool", Content: "ok"},
|
||||
{Role: "tool", ToolName: "configure", Content: "ok"},
|
||||
},
|
||||
tools: configureTool(),
|
||||
expected: "<bos><|turn>system\n" + configureDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:configure{config:{db:{host:" + q + "localhost" + q + ",port:5432}}}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nok<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:configure{config:{db:{host:" + q + "localhost" + q + ",port:5432}}}<tool_call|>" +
|
||||
"<|tool_response>response:configure{value:" + q + "ok" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Array values in tool call arguments
|
||||
@@ -831,14 +1112,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
"ids": []any{float64(1), float64(2), float64(3)},
|
||||
})},
|
||||
}}},
|
||||
{Role: "tool", Content: "done"},
|
||||
{Role: "tool", ToolName: "batch", Content: "done"},
|
||||
},
|
||||
tools: batchArrayTool(),
|
||||
expected: "<bos><|turn>system\n" + batchArrayDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:batch{ids:[1,2,3]}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\ndone<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:batch{ids:[1,2,3]}<tool_call|>" +
|
||||
"<|tool_response>response:batch{value:" + q + "done" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Mixed types in array argument (string, number, bool)
|
||||
@@ -850,14 +1130,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
"ids": []any{"a", float64(1), true},
|
||||
})},
|
||||
}}},
|
||||
{Role: "tool", Content: "done"},
|
||||
{Role: "tool", ToolName: "batch", Content: "done"},
|
||||
},
|
||||
tools: batchArrayTool(),
|
||||
expected: "<bos><|turn>system\n" + batchArrayDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:batch{ids:[" + q + "a" + q + ",1,true]}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\ndone<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:batch{ids:[" + q + "a" + q + ",1,true]}<tool_call|>" +
|
||||
"<|tool_response>response:batch{value:" + q + "done" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Enum property without description
|
||||
@@ -865,7 +1144,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Set"}},
|
||||
tools: enumNoDescTool(),
|
||||
expected: "<bos><|turn>system\n" + enumNoDescDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nSet<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nSet<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// System message that is only whitespace (trims to empty)
|
||||
@@ -875,7 +1154,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
expected: "<bos><|turn>system\n<turn|>\n" +
|
||||
"<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Empty assistant content (empty string, not nil)
|
||||
@@ -888,7 +1167,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
expected: "<bos><|turn>user\nHi<turn|>\n" +
|
||||
"<|turn>model\n<turn|>\n" +
|
||||
"<|turn>user\nMore<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Map argument with string keys (keys NOT escaped with <|"|>)
|
||||
@@ -900,14 +1179,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
"config": map[string]any{"key": "value"},
|
||||
})},
|
||||
}}},
|
||||
{Role: "tool", Content: "ok"},
|
||||
{Role: "tool", ToolName: "configure", Content: "ok"},
|
||||
},
|
||||
tools: configureTool(),
|
||||
expected: "<bos><|turn>system\n" + configureDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:configure{config:{key:" + q + "value" + q + "}}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nok<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:configure{config:{key:" + q + "value" + q + "}}<tool_call|>" +
|
||||
"<|tool_response>response:configure{value:" + q + "ok" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Mixed properties: some with description, some without
|
||||
@@ -915,7 +1193,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Search"}},
|
||||
tools: searchTool(),
|
||||
expected: "<bos><|turn>system\n" + searchDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nSearch<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nSearch<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
|
||||
// === Round 3 coverage gaps ===
|
||||
@@ -927,14 +1205,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", Content: " result "},
|
||||
{Role: "tool", ToolName: "bash", Content: " result "},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nresult<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + " result " + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Empty system message still emits system turn
|
||||
@@ -944,7 +1221,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
expected: "<bos><|turn>system\n<turn|>\n" +
|
||||
"<|turn>user\nHi<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Nested OBJECT property with required field
|
||||
@@ -952,7 +1229,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Create"}},
|
||||
tools: nestedRequiredTool(),
|
||||
expected: "<bos><|turn>system\n" + nestedRequiredDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nCreate<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nCreate<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Non-integer float in tool call argument
|
||||
@@ -962,14 +1239,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "calc", Arguments: testArgs(map[string]any{"value": 3.14})},
|
||||
}}},
|
||||
{Role: "tool", Content: "ok"},
|
||||
{Role: "tool", ToolName: "calc", Content: "ok"},
|
||||
},
|
||||
tools: calcTool(),
|
||||
expected: "<bos><|turn>system\n" + calcDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nCalc<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:calc{value:3.14}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nok<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:calc{value:3.14}<tool_call|>" +
|
||||
"<|tool_response>response:calc{value:" + q + "ok" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Thinking in the last assistant message (stripped before generation prompt)
|
||||
@@ -980,7 +1256,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
},
|
||||
expected: "<bos><|turn>user\nHi<turn|>\n" +
|
||||
"<|turn>model\nResult<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Tool content with newlines and leading/trailing whitespace trimmed
|
||||
@@ -990,14 +1266,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "\n file1\n file2\n"},
|
||||
{Role: "tool", ToolName: "bash", Content: "\n file1\n file2\n"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nfile1\n file2<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "\n file1\n file2\n" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Tool with parameters having only type, no properties
|
||||
@@ -1005,7 +1280,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Raw"}},
|
||||
tools: rawTool(),
|
||||
expected: "<bos><|turn>system\n" + rawDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nRaw<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nRaw<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Multiple required fields at top level
|
||||
@@ -1013,7 +1288,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Move"}},
|
||||
tools: moveTool(),
|
||||
expected: "<bos><|turn>system\n" + moveDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nMove<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nMove<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Assistant content that is ONLY thinking (strips to empty)
|
||||
@@ -1026,7 +1301,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
expected: "<bos><|turn>user\nHi<turn|>\n" +
|
||||
"<|turn>model\n<turn|>\n" +
|
||||
"<|turn>user\nMore<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
|
||||
// === Round 4: final coverage gaps ===
|
||||
@@ -1039,17 +1314,17 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", Content: "<|channel>I should use bash<channel|>", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "file1.txt"},
|
||||
{Role: "tool", ToolName: "bash", Content: "file1.txt"},
|
||||
{Role: "assistant", Content: "Here are the files."},
|
||||
{Role: "user", Content: "Thanks"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
think: thinkTrue(),
|
||||
expected: "<bos><|turn>system\n<|think|>You are helpful." + bashSmallDeclRef + "<turn|>\n" +
|
||||
expected: "<bos><|turn>system\n<|think|>\nYou are helpful." + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nList files<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nfile1.txt<turn|>\n" +
|
||||
"<|turn>model\nHere are the files.<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "file1.txt" + q + "}<tool_response|>" +
|
||||
"<turn|>\nHere are the files.<turn|>\n" +
|
||||
"<|turn>user\nThanks<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
},
|
||||
@@ -1059,17 +1334,15 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
messages: []api.Message{{Role: "user", Content: "Tag"}},
|
||||
tools: arrayNoItemsTool(),
|
||||
expected: "<bos><|turn>system\n" + arrayNoItemsDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nTag<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nTag<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// OBJECT property without description but with nested properties —
|
||||
// template hardcodes leading comma on ,properties: and does NOT
|
||||
// add comma before type: when description is absent
|
||||
// OBJECT property without description but with nested properties
|
||||
name: "object_no_desc_with_properties",
|
||||
messages: []api.Message{{Role: "user", Content: "Update"}},
|
||||
tools: objectNoDescTool(),
|
||||
expected: "<bos><|turn>system\n" + objectNoDescDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nUpdate<turn|>\n<|turn>model\n",
|
||||
"<|turn>user\nUpdate<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
|
||||
// === Round 5: coding agent patterns ===
|
||||
@@ -1082,24 +1355,24 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "mkdir src"})},
|
||||
}}},
|
||||
{Role: "tool", Content: ""},
|
||||
{Role: "tool", ToolName: "bash", Content: ""},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "touch src/main.go"})},
|
||||
}}},
|
||||
{Role: "tool", Content: ""},
|
||||
{Role: "tool", ToolName: "bash", Content: ""},
|
||||
{Role: "assistant", Content: "Done."},
|
||||
{Role: "user", Content: "Thanks"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nSet up the project<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "mkdir src" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\n<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "touch src/main.go" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\n<turn|>\n" +
|
||||
"<|turn>model\nDone.<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "mkdir src" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + q + "}<tool_response|>" +
|
||||
"<|tool_call>call:bash{command:" + q + "touch src/main.go" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + q + "}<tool_response|>" +
|
||||
"Done.<turn|>\n" +
|
||||
"<|turn>user\nThanks<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Tool call with thinking that strips to real remaining content
|
||||
@@ -1109,16 +1382,17 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", Content: "<|channel>I need to check the directory<channel|>Let me list the files.", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "main.go\ngo.mod"},
|
||||
{Role: "tool", ToolName: "bash", Content: "main.go\ngo.mod"},
|
||||
{Role: "user", Content: "OK"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nList files<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>Let me list the files.<turn|>\n" +
|
||||
"<|turn>tool\nmain.go\ngo.mod<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "main.go\ngo.mod" + q + "}<tool_response|>" +
|
||||
"Let me list the files.<turn|>\n" +
|
||||
"<|turn>user\nOK<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|channel>thought\n<channel|>",
|
||||
},
|
||||
{
|
||||
// Argument value containing newlines (multi-line script)
|
||||
@@ -1128,14 +1402,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "echo hello\necho world"})},
|
||||
}}},
|
||||
{Role: "tool", Content: "hello\nworld"},
|
||||
{Role: "tool", ToolName: "bash", Content: "hello\nworld"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nRun it<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "echo hello\necho world" + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nhello\nworld<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + "echo hello\necho world" + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "hello\nworld" + q + "}<tool_response|>",
|
||||
},
|
||||
{
|
||||
// Empty string argument value
|
||||
@@ -1145,22 +1418,20 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": ""})},
|
||||
}}},
|
||||
{Role: "tool", Content: "error"},
|
||||
{Role: "tool", ToolName: "bash", Content: "error"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
expected: "<bos><|turn>system\n" + bashSmallDeclRef + "<turn|>\n" +
|
||||
"<|turn>user\nGo<turn|>\n" +
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + q + "}<tool_call|><turn|>\n" +
|
||||
"<|turn>tool\nerror<turn|>\n" +
|
||||
"<|turn>model\n",
|
||||
"<|turn>model\n<|tool_call>call:bash{command:" + q + q + "}<tool_call|>" +
|
||||
"<|tool_response>response:bash{value:" + q + "error" + q + "}<tool_response|>",
|
||||
},
|
||||
}
|
||||
|
||||
verifyJinja2 := os.Getenv("VERIFY_JINJA2") != ""
|
||||
if verifyJinja2 {
|
||||
// Verify python3 and jinja2 are available
|
||||
if err := exec.Command("python3", "-c", "import jinja2").Run(); err != nil {
|
||||
t.Fatal("VERIFY_JINJA2=1 requires python3 with jinja2: pip install jinja2")
|
||||
if err := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", "import jinja2").Run(); err != nil {
|
||||
t.Fatal("VERIFY_JINJA2=1 requires uv and the ability to run uv with jinja2")
|
||||
}
|
||||
t.Log("VERIFY_JINJA2=1: verifying expected values against Jinja2 template")
|
||||
}
|
||||
@@ -1175,7 +1446,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
|
||||
// When VERIFY_JINJA2=1, also verify the expected value against
|
||||
// the real Jinja2 template rendered by Python.
|
||||
if verifyJinja2 {
|
||||
if verifyJinja2 && !tt.skipJinja2 {
|
||||
jinja2Output := renderWithJinja2(t, tt.messages, tt.tools, tt.think)
|
||||
if jinja2Output != tt.expected || jinja2Output != got {
|
||||
fmt.Fprintf(os.Stderr, "\nJINJA2 OUTPUT for %s (copy-paste as expected):\n%q\n\n", tt.name, jinja2Output)
|
||||
@@ -1189,7 +1460,267 @@ func TestGemma4RendererMatchesReference(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderWithJinja2 shells out to python3 to render messages through the
|
||||
func TestGemma4RendererMatchesJinja2ExpandedParity(t *testing.T) {
|
||||
if os.Getenv("VERIFY_JINJA2") == "" {
|
||||
t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks")
|
||||
}
|
||||
|
||||
if err := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", "import jinja2").Run(); err != nil {
|
||||
t.Fatal("VERIFY_JINJA2=1 requires uv and the ability to run uv with jinja2")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []api.Message
|
||||
tools []api.Tool
|
||||
think *api.ThinkValue
|
||||
}{
|
||||
{
|
||||
name: "adjacent_assistants_continue_same_model_turn",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Start"},
|
||||
{Role: "assistant", Content: "One."},
|
||||
{Role: "assistant", Content: "Two."},
|
||||
{Role: "user", Content: "More"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking_field_on_pending_tool_call",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "List files"},
|
||||
{Role: "assistant", Content: "Let me check.", Thinking: "I should use bash", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
},
|
||||
tools: bashRefTool(),
|
||||
},
|
||||
{
|
||||
name: "thinking_field_ignored_before_later_user",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "List files"},
|
||||
{Role: "assistant", Content: "Let me check.", Thinking: "I should use bash", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", ToolName: "bash", Content: "file1.txt"},
|
||||
{Role: "user", Content: "Thanks"},
|
||||
},
|
||||
tools: bashRefTool(),
|
||||
},
|
||||
{
|
||||
name: "tool_response_name_resolved_from_tool_call_id",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "List and read"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{
|
||||
{
|
||||
ID: "call_bash",
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
},
|
||||
{
|
||||
ID: "call_read",
|
||||
Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})},
|
||||
},
|
||||
}},
|
||||
{Role: "tool", ToolCallID: "call_read", Content: "module example.com/foo"},
|
||||
{Role: "tool", ToolCallID: "call_bash", Content: "file1.txt\nfile2.txt"},
|
||||
},
|
||||
tools: bashAndReadRefTools(),
|
||||
},
|
||||
{
|
||||
name: "adjacent_assistants_after_tool_response_continue_same_model_turn",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Go"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
{Role: "tool", ToolName: "bash", Content: "file1.txt"},
|
||||
{Role: "assistant", Content: "First."},
|
||||
{Role: "assistant", Content: "Second."},
|
||||
{Role: "user", Content: "Next"},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
},
|
||||
{
|
||||
name: "thinking_enabled_with_pending_tool_call",
|
||||
messages: []api.Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "List files"},
|
||||
{Role: "assistant", Thinking: "Use bash", ToolCalls: []api.ToolCall{{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
}}},
|
||||
},
|
||||
tools: bashSmallTool(),
|
||||
think: thinkTrue(),
|
||||
},
|
||||
{
|
||||
name: "array_items_object_with_required",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Upsert entries"},
|
||||
},
|
||||
tools: arrayObjectItemsTool(),
|
||||
},
|
||||
{
|
||||
name: "array_items_object_with_nested_object_and_array_properties",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Plan steps"},
|
||||
},
|
||||
tools: arrayNestedItemsTool(),
|
||||
},
|
||||
{
|
||||
name: "array_items_nested_array_of_objects",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Schedule jobs"},
|
||||
},
|
||||
tools: nestedArrayObjectItemsTool(),
|
||||
},
|
||||
{
|
||||
name: "array_items_union_type",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Maybe batch"},
|
||||
},
|
||||
tools: arrayUnionItemsTool(),
|
||||
},
|
||||
{
|
||||
name: "array_items_nested_nullable_properties",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Annotate batch"},
|
||||
},
|
||||
tools: arrayNullableNestedItemsTool(),
|
||||
},
|
||||
{
|
||||
name: "array_items_extra_keys",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Configure batch"},
|
||||
},
|
||||
tools: arrayItemsExtraKeysTool(),
|
||||
},
|
||||
{
|
||||
name: "typed_property_union_type",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
},
|
||||
tools: unionTopLevelTypeTool(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
renderer := &Gemma4Renderer{useImgTags: RenderImgTags}
|
||||
got, err := renderer.Render(tt.messages, tt.tools, tt.think)
|
||||
assert.NoError(t, err)
|
||||
|
||||
jinja2Output := renderWithJinja2(t, tt.messages, tt.tools, tt.think)
|
||||
assert.Equal(t, jinja2Output, got,
|
||||
"renderer output doesn't match Jinja2 template output")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGemma4RendererKnownJinja2Differences(t *testing.T) {
|
||||
if os.Getenv("VERIFY_JINJA2") == "" {
|
||||
t.Skip("set VERIFY_JINJA2=1 to run Jinja2 difference checks")
|
||||
}
|
||||
|
||||
if err := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", "import jinja2").Run(); err != nil {
|
||||
t.Fatal("VERIFY_JINJA2=1 requires uv and the ability to run uv with jinja2")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []api.Message
|
||||
tools []api.Tool
|
||||
wantJinjaFrag string
|
||||
wantRenderFrag string
|
||||
}{
|
||||
{
|
||||
name: "typed_property_anyof",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "Pick"},
|
||||
},
|
||||
tools: anyOfTool(),
|
||||
wantJinjaFrag: `value:{description:<|"|>Value<|"|>,type:<|"|><|"|>}`,
|
||||
wantRenderFrag: `value:{description:<|"|>Value<|"|>,type:<|"|>['STRING', 'NUMBER']<|"|>}`,
|
||||
},
|
||||
{
|
||||
name: "tool_response_name_not_overridden_without_tool_call_id",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "List and read"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})},
|
||||
},
|
||||
}},
|
||||
{Role: "tool", ToolName: "bash", Content: "payload"},
|
||||
},
|
||||
tools: bashAndReadRefTools(),
|
||||
wantJinjaFrag: `response:read{value:<|"|>payload<|"|>}`,
|
||||
wantRenderFrag: `response:bash{value:<|"|>payload<|"|>}`,
|
||||
},
|
||||
{
|
||||
name: "tool_response_without_name_or_id_uses_unknown",
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: "List and read"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})},
|
||||
},
|
||||
}},
|
||||
{Role: "tool", Content: "payload"},
|
||||
},
|
||||
tools: bashAndReadRefTools(),
|
||||
wantJinjaFrag: `response:read{value:<|"|>payload<|"|>}`,
|
||||
wantRenderFrag: `response:unknown{value:<|"|>payload<|"|>}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
renderer := &Gemma4Renderer{useImgTags: RenderImgTags}
|
||||
got, err := renderer.Render(tt.messages, tt.tools, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
jinja2Output := renderWithJinja2(t, tt.messages, tt.tools, nil)
|
||||
assert.NotEqual(t, jinja2Output, got, "case no longer differs from Jinja2 output")
|
||||
assert.Contains(t, jinja2Output, tt.wantJinjaFrag)
|
||||
assert.Contains(t, got, tt.wantRenderFrag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGemma4RendererNormalizesSimpleAnyOfToTypedUnion(t *testing.T) {
|
||||
renderer := &Gemma4Renderer{useImgTags: RenderImgTags}
|
||||
|
||||
got, err := renderer.Render([]api.Message{{Role: "user", Content: "Pick"}}, anyOfTool(), nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, got, `value:{description:<|"|>Value<|"|>,type:<|"|>['STRING', 'NUMBER']<|"|>}`)
|
||||
}
|
||||
|
||||
func TestGemma4RendererToolResponseWithoutNameOrIDUsesUnknown(t *testing.T) {
|
||||
renderer := &Gemma4Renderer{useImgTags: RenderImgTags}
|
||||
|
||||
got, err := renderer.Render([]api.Message{
|
||||
{Role: "user", Content: "List and read"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{
|
||||
{
|
||||
Function: api.ToolCallFunction{Name: "bash", Arguments: testArgs(map[string]any{"command": "ls"})},
|
||||
},
|
||||
{
|
||||
Function: api.ToolCallFunction{Name: "read", Arguments: testArgs(map[string]any{"path": "go.mod"})},
|
||||
},
|
||||
}},
|
||||
{Role: "tool", Content: "payload"},
|
||||
}, bashAndReadRefTools(), nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, got, `response:unknown{value:<|"|>payload<|"|>}`)
|
||||
assert.NotContains(t, got, `response:read{value:<|"|>payload<|"|>}`)
|
||||
}
|
||||
|
||||
// renderWithJinja2 shells out to uv + Python to render messages through the
|
||||
// Jinja2 chat template. Returns the rendered string.
|
||||
func renderWithJinja2(t *testing.T, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string {
|
||||
t.Helper()
|
||||
@@ -1202,22 +1733,33 @@ func renderWithJinja2(t *testing.T, messages []api.Message, tools []api.Tool, th
|
||||
// Convert messages to the format the Jinja2 template expects.
|
||||
// The template uses message['tool_calls'] with function.arguments as a dict.
|
||||
type jinja2ToolCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments any `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
type jinja2Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []jinja2ToolCall `json:"tool_calls,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
ToolCalls []jinja2ToolCall `json:"tool_calls,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
var jMsgs []jinja2Message
|
||||
for _, m := range messages {
|
||||
jm := jinja2Message{Role: m.Role, Content: m.Content}
|
||||
jm := jinja2Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
Reasoning: m.Thinking,
|
||||
Name: m.ToolName,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
jtc := jinja2ToolCall{}
|
||||
jtc.ID = tc.ID
|
||||
jtc.Function.Name = tc.Function.Name
|
||||
// Convert ToolCallFunctionArguments to a map
|
||||
var args map[string]any
|
||||
@@ -1259,12 +1801,12 @@ if %s:
|
||||
print(tmpl.render(**kwargs), end="")
|
||||
`, templatePath, string(msgsJSON), toolsJSON, toolsJSON, thinking)
|
||||
|
||||
cmd := exec.Command("python3", "-c", script)
|
||||
cmd := exec.Command("uv", "run", "--with", "jinja2", "python", "-c", script)
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("python3 failed: %v\nstderr: %s", err, stderr.String())
|
||||
t.Fatalf("uv run failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return stdout.String()
|
||||
}
|
||||
|
||||
+126
-42
@@ -11,34 +11,15 @@
|
||||
description:<|"|>{{ value['description'] }}<|"|>
|
||||
{%- set add_comma = true -%}
|
||||
{%- endif -%}
|
||||
{%- if value['nullable'] %}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
nullable:true
|
||||
{%- endif -%}
|
||||
{%- if value['type'] | upper == 'STRING' -%}
|
||||
{%- if value['enum'] -%}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
enum:{{ format_argument(value['enum']) }}
|
||||
{%- endif -%}
|
||||
{%- elif value['type'] | upper == 'OBJECT' -%}
|
||||
,properties:{
|
||||
{%- if value['properties'] is defined and value['properties'] is mapping -%}
|
||||
{{- format_parameters(value['properties'], value['required'] | default([])) -}}
|
||||
{%- elif value is mapping -%}
|
||||
{{- format_parameters(value, value['required'] | default([])) -}}
|
||||
{%- endif -%}
|
||||
}
|
||||
{%- if value['required'] -%}
|
||||
,required:[
|
||||
{%- for item in value['required'] | default([]) -%}
|
||||
<|"|>{{- item -}}<|"|>
|
||||
{%- if not loop.last %},{% endif -%}
|
||||
{%- endfor -%}
|
||||
]
|
||||
{%- endif -%}
|
||||
{%- elif value['type'] | upper == 'ARRAY' -%}
|
||||
{%- if value['items'] is mapping and value['items'] -%}
|
||||
,items:{
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
items:{
|
||||
{%- set ns_items = namespace(found_first=false) -%}
|
||||
{%- for item_key, item_value in value['items'] | dictsort -%}
|
||||
{%- if item_value is not none -%}
|
||||
@@ -71,6 +52,32 @@
|
||||
}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- if value['nullable'] %}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
nullable:true
|
||||
{%- endif -%}
|
||||
{%- if value['type'] | upper == 'OBJECT' -%}
|
||||
{%- if value['properties'] is defined and value['properties'] is mapping -%}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
properties:{
|
||||
{{- format_parameters(value['properties'], value['required'] | default([])) -}}
|
||||
}
|
||||
{%- elif value is mapping -%}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
properties:{
|
||||
{{- format_parameters(value, value['required'] | default([])) -}}
|
||||
}
|
||||
{%- endif -%}
|
||||
{%- if value['required'] -%}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
required:[
|
||||
{%- for item in value['required'] | default([]) -%}
|
||||
<|"|>{{- item -}}<|"|>
|
||||
{%- if not loop.last %},{% endif -%}
|
||||
{%- endfor -%}
|
||||
]
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
|
||||
type:<|"|>{{ value['type'] | upper }}<|"|>}
|
||||
{%- endif -%}
|
||||
@@ -150,16 +157,31 @@
|
||||
{{- ns.result | trim -}}
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- macro format_tool_response_block(tool_name, response) -%}
|
||||
{{- '<|tool_response>' -}}
|
||||
{%- if response is mapping -%}
|
||||
{{- 'response:' + tool_name + '{' -}}
|
||||
{%- for key, value in response | dictsort -%}
|
||||
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
|
||||
{%- if not loop.last %},{% endif -%}
|
||||
{%- endfor -%}
|
||||
{{- '}' -}}
|
||||
{%- else -%}
|
||||
{{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
|
||||
{%- endif -%}
|
||||
{{- '<tool_response|>' -}}
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- set ns = namespace(prev_message_type=None) -%}
|
||||
{%- set loop_messages = messages -%}
|
||||
{{ bos_token }}
|
||||
{{- bos_token -}}
|
||||
{#- Handle System/Tool Definitions Block -#}
|
||||
{%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%}
|
||||
{{- '<|turn>system\n' -}}
|
||||
|
||||
{#- Inject Thinking token at the very top of the FIRST system turn -#}
|
||||
{%- if enable_thinking is defined and enable_thinking -%}
|
||||
{{- '<|think|>' -}}
|
||||
{{- '<|think|>\n' -}}
|
||||
{%- set ns.prev_message_type = 'think' -%}
|
||||
{%- endif -%}
|
||||
|
||||
@@ -180,11 +202,41 @@
|
||||
{{- '<turn|>\n' -}}
|
||||
{%- endif %}
|
||||
|
||||
{#- Pre-scan: find last user message index for reasoning guard -#}
|
||||
{%- set ns_turn = namespace(last_user_idx=-1) -%}
|
||||
{%- for i in range(loop_messages | length) -%}
|
||||
{%- if loop_messages[i]['role'] == 'user' -%}
|
||||
{%- set ns_turn.last_user_idx = i -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
|
||||
{#- Loop through messages -#}
|
||||
{%- for message in loop_messages -%}
|
||||
{%- if message['role'] != 'tool' -%}
|
||||
{%- set ns.prev_message_type = None -%}
|
||||
{%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
|
||||
{#- Detect continuation: suppress duplicate <|turn>model when previous non-tool message was also assistant -#}
|
||||
{%- set prev_nt = namespace(role=None, found=false) -%}
|
||||
{%- if loop.index0 > 0 -%}
|
||||
{%- for j in range(loop.index0 - 1, -1, -1) -%}
|
||||
{%- if not prev_nt.found -%}
|
||||
{%- if loop_messages[j]['role'] != 'tool' -%}
|
||||
{%- set prev_nt.role = loop_messages[j]['role'] -%}
|
||||
{%- set prev_nt.found = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
{%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%}
|
||||
{%- if not continue_same_model_turn -%}
|
||||
{{- '<|turn>' + role + '\n' }}
|
||||
{%- endif -%}
|
||||
|
||||
{#- Render reasoning/reasoning_content as thinking channel -#}
|
||||
{%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
|
||||
{%- if thinking_text and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
|
||||
{{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
|
||||
{%- endif -%}
|
||||
|
||||
{%- if message['tool_calls'] -%}
|
||||
{%- for tool_call in message['tool_calls'] -%}
|
||||
@@ -205,23 +257,49 @@
|
||||
{%- set ns.prev_message_type = 'tool_call' -%}
|
||||
{%- endif -%}
|
||||
|
||||
{%- if message['tool_responses'] -%}
|
||||
{#- Tool Response handling -#}
|
||||
{%- set ns_tr_out = namespace(flag=false) -%}
|
||||
{%- if message.get('tool_responses') -%}
|
||||
{#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
|
||||
{%- for tool_response in message['tool_responses'] -%}
|
||||
{{- '<|tool_response>' -}}
|
||||
{%- if tool_response['response'] is mapping -%}
|
||||
{{- 'response:' + tool_response['name'] | default('unknown') + '{' -}}
|
||||
{%- for key, value in tool_response['response'] | dictsort -%}
|
||||
{{- key -}}:{{- format_argument(value, escape_keys=False) -}}
|
||||
{%- if not loop.last %},{% endif -%}
|
||||
{%- endfor -%}
|
||||
{{- '}' -}}
|
||||
{%- else -%}
|
||||
{{- 'response:' + tool_response['name'] | default('unknown') + '{value:' + format_argument(tool_response['response'], escape_keys=False) + '}' -}}
|
||||
{%- endif -%}
|
||||
{{- '<tool_response|>' -}}
|
||||
{{- format_tool_response_block(tool_response['name'] | default('unknown'), tool_response['response']) -}}
|
||||
{%- set ns_tr_out.flag = true -%}
|
||||
{%- set ns.prev_message_type = 'tool_response' -%}
|
||||
{%- endfor -%}
|
||||
{%- elif message.get('tool_calls') -%}
|
||||
{#- OpenAI Chat Completions: forward-scan consecutive role:tool messages -#}
|
||||
{%- set ns_tool_scan = namespace(stopped=false) -%}
|
||||
{%- for k in range(loop.index0 + 1, loop_messages | length) -%}
|
||||
{%- if ns_tool_scan.stopped -%}
|
||||
{%- elif loop_messages[k]['role'] != 'tool' -%}
|
||||
{%- set ns_tool_scan.stopped = true -%}
|
||||
{%- else -%}
|
||||
{%- set follow = loop_messages[k] -%}
|
||||
{#- Resolve tool_call_id to function name -#}
|
||||
{%- set ns_tname = namespace(name=follow.get('name') | default('unknown')) -%}
|
||||
{%- for tc in message['tool_calls'] -%}
|
||||
{%- if tc.get('id') == follow.get('tool_call_id') -%}
|
||||
{%- set ns_tname.name = tc['function']['name'] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{#- Handle content as string or content-parts array -#}
|
||||
{%- set tool_body = follow.get('content') -%}
|
||||
{%- if tool_body is string -%}
|
||||
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
|
||||
{%- elif tool_body is sequence and tool_body is not string -%}
|
||||
{%- set ns_txt = namespace(s='') -%}
|
||||
{%- for part in tool_body -%}
|
||||
{%- if part.get('type') == 'text' -%}
|
||||
{%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
|
||||
{%- else -%}
|
||||
{{- format_tool_response_block(ns_tname.name, tool_body) -}}
|
||||
{%- endif -%}
|
||||
{%- set ns_tr_out.flag = true -%}
|
||||
{%- set ns.prev_message_type = 'tool_response' -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- set ns.prev_message_type = 'tool_response' -%}
|
||||
{%- endif -%}
|
||||
|
||||
{%- if message['content'] is string -%}
|
||||
@@ -239,25 +317,31 @@
|
||||
{{- item['text'] | trim -}}
|
||||
{%- endif -%}
|
||||
{%- elif item['type'] == 'image' -%}
|
||||
{{- '\n\n<|image|>\n\n' -}}
|
||||
{{- '<|image|>' -}}
|
||||
{%- set ns.prev_message_type = 'image' -%}
|
||||
{%- elif item['type'] == 'audio' -%}
|
||||
{{- '<|audio|>' -}}
|
||||
{%- set ns.prev_message_type = 'audio' -%}
|
||||
{%- elif item['type'] == 'video' -%}
|
||||
{{- '\n\n<|video|>\n\n' -}}
|
||||
{{- '<|video|>' -}}
|
||||
{%- set ns.prev_message_type = 'video' -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
|
||||
{%- if not (message['tool_responses'] and not message['content']) -%}
|
||||
{%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
|
||||
{{- '<|tool_response>' -}}
|
||||
{%- elif not (ns_tr_out.flag and not message.get('content')) -%}
|
||||
{{- '<turn|>\n' -}}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if add_generation_prompt -%}
|
||||
{%- if ns.prev_message_type != 'tool_response' -%}
|
||||
{%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
|
||||
{{- '<|turn>model\n' -}}
|
||||
{%- if not enable_thinking | default(false) -%}
|
||||
{{- '<|channel>thought\n<channel|>' -}}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
Reference in New Issue
Block a user