diff --git a/model/parsers/gemma4.go b/model/parsers/gemma4.go index 1cd151917..38a46f6f2 100644 --- a/model/parsers/gemma4.go +++ b/model/parsers/gemma4.go @@ -26,6 +26,7 @@ const ( gemma4ThinkingCloseTag = "" gemma4ToolCallOpenTag = "<|tool_call>" gemma4ToolCallCloseTag = "" + 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 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 "", 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 diff --git a/model/parsers/gemma4_test.go b/model/parsers/gemma4_test.go index 93221c401..92f15edc3 100644 --- a/model/parsers/gemma4_test.go +++ b/model/parsers/gemma4_test.go @@ -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_response>`, + }, + expectedContent: "", + }, + { + name: "same_chunk_before_real_content", + chunks: []string{ + `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<|tool_response>Done.`, + }, + expectedContent: "Done.", + }, + { + name: "split_across_chunks_before_real_content", + chunks: []string{ + `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<|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 diff --git a/model/renderers/gemma4.go b/model/renderers/gemma4.go index ef3d5348d..7b186af9e 100644 --- a/model/renderers/gemma4.go +++ b/model/renderers/gemma4.go @@ -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("\n") } - // Each message gets its own <|turn>role\n ... \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("\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") + } + + 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("\n") - - case "tool": - sb.WriteString("<|turn>tool\n") - sb.WriteString(strings.TrimSpace(message.Content)) - sb.WriteString("\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("\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") + } + } 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) + "}" +} + 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("[") diff --git a/model/renderers/gemma4_reference_test.go b/model/renderers/gemma4_reference_test.go index ecf24dd0f..05e0517ec 100644 --- a/model/renderers/gemma4_reference_test.go +++ b/model/renderers/gemma4_reference_test.go @@ -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<|"|>}}` 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<|"|>}}` arrayNoItemsDeclRef = `<|tool>declaration:tag{description:<|"|>Tag items<|"|>,parameters:{properties:{tags:{description:<|"|>Tags<|"|>,type:<|"|>ARRAY<|"|>}},type:<|"|>OBJECT<|"|>}}` - objectNoDescDeclRef = `<|tool>declaration:update{description:<|"|>Update settings<|"|>,parameters:{properties:{settings:{,properties:{verbose:{description:<|"|>Verbose mode<|"|>,type:<|"|>BOOLEAN<|"|>}}type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}` + objectNoDescDeclRef = `<|tool>declaration:update{description:<|"|>Update settings<|"|>,parameters:{properties:{settings:{properties:{verbose:{description:<|"|>Verbose mode<|"|>,type:<|"|>BOOLEAN<|"|>}},type:<|"|>OBJECT<|"|>}},type:<|"|>OBJECT<|"|>}}` 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<|"|>}}` calcDeclRef = `<|tool>declaration:calc{description:<|"|>Calculate<|"|>,parameters:{properties:{value:{description:<|"|>Value<|"|>,type:<|"|>NUMBER<|"|>}},type:<|"|>OBJECT<|"|>}}` rawDeclRef = `<|tool>declaration:raw{description:<|"|>Raw input<|"|>,parameters:{type:<|"|>OBJECT<|"|>}}` @@ -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: "<|turn>user\nHello\n<|turn>model\n", + expected: "<|turn>user\nHello\n<|turn>model\n<|channel>thought\n", }, { name: "system_user", @@ -414,7 +673,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, - expected: "<|turn>system\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { name: "developer_user", @@ -422,13 +681,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "developer", Content: "You are helpful."}, {Role: "user", Content: "Hi"}, }, - expected: "<|turn>system\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { name: "tools_no_system", messages: []api.Message{{Role: "user", Content: "Hi"}}, tools: bashRefTool(), - expected: "<|turn>system\n" + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\n" + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { name: "system_tools", @@ -437,13 +696,13 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "user", Content: "Hi"}, }, tools: bashRefTool(), - expected: "<|turn>system\nYou are helpful." + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\nYou are helpful." + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { name: "thinking_no_system", messages: []api.Message{{Role: "user", Content: "Hi"}}, think: thinkTrue(), - expected: "<|turn>system\n<|think|>\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\n<|think|>\n\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_system", @@ -452,14 +711,14 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "user", Content: "Hi"}, }, think: thinkTrue(), - expected: "<|turn>system\n<|think|>You are helpful.\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\n<|think|>\nYou are helpful.\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_tools", messages: []api.Message{{Role: "user", Content: "Hi"}}, tools: bashRefTool(), think: thinkTrue(), - expected: "<|turn>system\n<|think|>" + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\n<|think|>\n" + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", }, { name: "thinking_system_tools", @@ -469,7 +728,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { }, tools: bashRefTool(), think: thinkTrue(), - expected: "<|turn>system\n<|think|>You are helpful." + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", + expected: "<|turn>system\n<|think|>\nYou are helpful." + bashDeclRef + "\n<|turn>user\nHi\n<|turn>model\n", }, // === Message loop paths === @@ -485,7 +744,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { "<|turn>user\nHi\n" + "<|turn>model\nHello!\n" + "<|turn>user\nMore\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\nYou are helpful." + bashDeclRef + "\n" + "<|turn>user\nList files\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}\n" + - "<|turn>tool\nfile1.txt\nfile2.txt\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}", + }, + { + 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: "<|turn>system\nYou are helpful." + bashDeclRef + "\n" + + "<|turn>user\nList files\n" + + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}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: "<|turn>system\nYou are helpful." + bashDeclRef + readDeclRef + "\n" + "<|turn>user\nList files\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}\n" + - "<|turn>tool\nfile1.txt\nfile2.txt\n" + - "<|turn>model\nHere are the files.\n" + + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}" + + "Here are the files.\n" + "<|turn>user\nRead file1.txt\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\nYou are helpful." + bashDeclRef + readDeclRef + "\n" + "<|turn>user\nList and read\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + - "<|tool_call>call:read{path:" + q + "go.mod" + q + "}\n" + - "<|turn>tool\nfile1.txt\nfile2.txt\n" + - "<|turn>tool\nmodule example.com/foo\n" + - "<|turn>model\n", + "<|tool_call>call:read{path:" + q + "go.mod" + q + "}" + + "<|tool_response>response:bash{value:" + q + "file1.txt\nfile2.txt" + q + "}" + + "<|tool_response>response:read{value:" + q + "module example.com/foo" + q + "}", + skipJinja2: true, }, { // Thinking content in assistant history should be stripped @@ -566,7 +841,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { expected: "<|turn>user\nWhat is 2+2?\n" + "<|turn>model\n4\n" + "<|turn>user\nAnd 3+3?\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, // === 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: "<|turn>system\n" + weatherDeclRef + "\n" + "<|turn>user\nWeather?\n" + - "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}Let me check.\n" + - "<|turn>tool\nSunny\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Paris" + q + "}" + + "<|tool_response>response:get_weather{value:" + q + "Sunny" + q + "}" + + "Let me check.\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: "<|turn>system\n" + addDeclRef + "\n" + "<|turn>user\nAdd\n" + - "<|turn>model\n<|tool_call>call:add{a:1,b:2}\n" + - "<|turn>tool\n{\"result\": 3}\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:add{a:1,b:2}" + + "<|tool_response>response:add{value:" + q + `{"result": 3}` + q + "}", }, { // 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: "<|turn>system\n" + flagDeclRef + "\n" + "<|turn>user\nSet flag\n" + - "<|turn>model\n<|tool_call>call:set_flag{enabled:true}\n" + - "<|turn>tool\ndone\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:set_flag{enabled:true}" + + "<|tool_response>response:set_flag{value:" + q + "done" + q + "}", }, { // Tool with enum parameter @@ -626,17 +899,17 @@ func TestGemma4RendererMatchesReference(t *testing.T) { messages: []api.Message{{Role: "user", Content: "Test"}}, tools: modeTool(), expected: "<|turn>system\n" + modeDeclRef + "\n" + - "<|turn>user\nTest\n<|turn>model\n", + "<|turn>user\nTest\n<|turn>model\n<|channel>thought\n", }, { name: "unicode_content", messages: []api.Message{{Role: "user", Content: "こんにちは"}}, - expected: "<|turn>user\nこんにちは\n<|turn>model\n", + expected: "<|turn>user\nこんにちは\n<|turn>model\n<|channel>thought\n", }, { name: "newlines_in_content", messages: []api.Message{{Role: "user", Content: "Line 1\nLine 2\nLine 3"}}, - expected: "<|turn>user\nLine 1\nLine 2\nLine 3\n<|turn>model\n", + expected: "<|turn>user\nLine 1\nLine 2\nLine 3\n<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + weatherDeclRef + "\n" + "<|turn>user\nWeather?\n" + - "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}\n" + - "<|turn>tool\n{\"temperature\": 15, \"weather\": \"sunny\"}\n" + + "<|turn>model\n<|tool_call>call:get_weather{city:" + q + "Tokyo" + q + "}" + + "<|tool_response>response:get_weather{value:" + q + `{"temperature": 15, "weather": "sunny"}` + q + "}" + "<|turn>user\nThanks!\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, // === 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:bash{aaa:" + q + "first" + q + ",mmm:" + q + "middle" + q + ",zzz:" + q + "last" + q + "}\n" + - "<|turn>tool\nok\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{aaa:" + q + "first" + q + ",mmm:" + q + "middle" + q + ",zzz:" + q + "last" + q + "}" + + "<|tool_response>response:bash{value:" + q + "ok" + q + "}", }, { // User content with whitespace is trimmed name: "user_content_trimmed", messages: []api.Message{{Role: "user", Content: " hello "}}, - expected: "<|turn>user\nhello\n<|turn>model\n", + expected: "<|turn>user\nhello\n<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:bash{}\n" + - "<|turn>tool\nok\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{}" + + "<|tool_response>response:bash{value:" + q + "ok" + q + "}", }, { // 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: "<|turn>system\n" + nestedDeclRef + "\n" + - "<|turn>user\nCreate\n<|turn>model\n", + "<|turn>user\nCreate\n<|turn>model\n<|channel>thought\n", }, { // Array type in tool declaration @@ -712,7 +983,20 @@ func TestGemma4RendererMatchesReference(t *testing.T) { messages: []api.Message{{Role: "user", Content: "Batch"}}, tools: arrayTool(), expected: "<|turn>system\n" + arrayDeclRef + "\n" + - "<|turn>user\nBatch\n<|turn>model\n", + "<|turn>user\nBatch\n<|turn>model\n<|channel>thought\n", + }, + { + // 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: `<|turn>system +<|tool>declaration:maybe_name{description:<|"|>Test nullable union<|"|>,parameters:{properties:{name:{description:<|"|>Name<|"|>,type:<|"|>['STRING', 'NULL']<|"|>}},type:<|"|>OBJECT<|"|>}} +<|turn>user +Hi +<|turn>model +<|channel>thought +`, }, { // Assistant whitespace is trimmed (strip_thinking includes | trim) @@ -725,7 +1009,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { expected: "<|turn>user\nHi\n" + "<|turn>model\nspaced\n" + "<|turn>user\nMore\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nDo three things\n" + "<|turn>model\n<|tool_call>call:bash{command:" + q + "a" + q + "}" + "<|tool_call>call:bash{command:" + q + "b" + q + "}" + - "<|tool_call>call:bash{command:" + q + "c" + q + "}\n" + - "<|turn>tool\nresult-a\n" + - "<|turn>tool\nresult-b\n" + - "<|turn>tool\nresult-c\n" + - "<|turn>model\n", + "<|tool_call>call:bash{command:" + q + "c" + q + "}" + + "<|tool_response>response:bash{value:" + q + "result-a" + q + "}" + + "<|tool_response>response:bash{value:" + q + "result-b" + q + "}" + + "<|tool_response>response:bash{value:" + q + "result-c" + q + "}", }, { // 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}\n" + - "<|turn>tool\nfiles\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + "files" + q + "}", }, // === Coverage gap cases === @@ -782,7 +1064,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { expected: "<|turn>user\nHi\n" + "<|turn>model\nMiddleDone\n" + "<|turn>user\nMore\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + countDeclRef + "\n" + - "<|turn>user\nCount\n<|turn>model\n", + "<|turn>user\nCount\n<|turn>model\n<|channel>thought\n", }, { // System message with leading/trailing whitespace is trimmed @@ -800,7 +1082,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\nYou are helpful.\n" + - "<|turn>user\nHi\n<|turn>model\n", + "<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + configureDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:configure{config:{db:{host:" + q + "localhost" + q + ",port:5432}}}\n" + - "<|turn>tool\nok\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:configure{config:{db:{host:" + q + "localhost" + q + ",port:5432}}}" + + "<|tool_response>response:configure{value:" + q + "ok" + q + "}", }, { // 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: "<|turn>system\n" + batchArrayDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:batch{ids:[1,2,3]}\n" + - "<|turn>tool\ndone\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:batch{ids:[1,2,3]}" + + "<|tool_response>response:batch{value:" + q + "done" + q + "}", }, { // 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: "<|turn>system\n" + batchArrayDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:batch{ids:[" + q + "a" + q + ",1,true]}\n" + - "<|turn>tool\ndone\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:batch{ids:[" + q + "a" + q + ",1,true]}" + + "<|tool_response>response:batch{value:" + q + "done" + q + "}", }, { // Enum property without description @@ -865,7 +1144,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { messages: []api.Message{{Role: "user", Content: "Set"}}, tools: enumNoDescTool(), expected: "<|turn>system\n" + enumNoDescDeclRef + "\n" + - "<|turn>user\nSet\n<|turn>model\n", + "<|turn>user\nSet\n<|turn>model\n<|channel>thought\n", }, { // System message that is only whitespace (trims to empty) @@ -875,7 +1154,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\n\n" + - "<|turn>user\nHi\n<|turn>model\n", + "<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { // Empty assistant content (empty string, not nil) @@ -888,7 +1167,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { expected: "<|turn>user\nHi\n" + "<|turn>model\n\n" + "<|turn>user\nMore\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + configureDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:configure{config:{key:" + q + "value" + q + "}}\n" + - "<|turn>tool\nok\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:configure{config:{key:" + q + "value" + q + "}}" + + "<|tool_response>response:configure{value:" + q + "ok" + q + "}", }, { // 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: "<|turn>system\n" + searchDeclRef + "\n" + - "<|turn>user\nSearch\n<|turn>model\n", + "<|turn>user\nSearch\n<|turn>model\n<|channel>thought\n", }, // === 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}\n" + - "<|turn>tool\nresult\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + " result " + q + "}", }, { // Empty system message still emits system turn @@ -944,7 +1221,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "user", Content: "Hi"}, }, expected: "<|turn>system\n\n" + - "<|turn>user\nHi\n<|turn>model\n", + "<|turn>user\nHi\n<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + nestedRequiredDeclRef + "\n" + - "<|turn>user\nCreate\n<|turn>model\n", + "<|turn>user\nCreate\n<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + calcDeclRef + "\n" + "<|turn>user\nCalc\n" + - "<|turn>model\n<|tool_call>call:calc{value:3.14}\n" + - "<|turn>tool\nok\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:calc{value:3.14}" + + "<|tool_response>response:calc{value:" + q + "ok" + q + "}", }, { // Thinking in the last assistant message (stripped before generation prompt) @@ -980,7 +1256,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { }, expected: "<|turn>user\nHi\n" + "<|turn>model\nResult\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}\n" + - "<|turn>tool\nfile1\n file2\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + "\n file1\n file2\n" + q + "}", }, { // 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: "<|turn>system\n" + rawDeclRef + "\n" + - "<|turn>user\nRaw\n<|turn>model\n", + "<|turn>user\nRaw\n<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + moveDeclRef + "\n" + - "<|turn>user\nMove\n<|turn>model\n", + "<|turn>user\nMove\n<|turn>model\n<|channel>thought\n", }, { // Assistant content that is ONLY thinking (strips to empty) @@ -1026,7 +1301,7 @@ func TestGemma4RendererMatchesReference(t *testing.T) { expected: "<|turn>user\nHi\n" + "<|turn>model\n\n" + "<|turn>user\nMore\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, // === Round 4: final coverage gaps === @@ -1039,17 +1314,17 @@ func TestGemma4RendererMatchesReference(t *testing.T) { {Role: "assistant", Content: "<|channel>I should use bash", 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: "<|turn>system\n<|think|>You are helpful." + bashSmallDeclRef + "\n" + + expected: "<|turn>system\n<|think|>\nYou are helpful." + bashSmallDeclRef + "\n" + "<|turn>user\nList files\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}\n" + - "<|turn>tool\nfile1.txt\n" + - "<|turn>model\nHere are the files.\n" + + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + "file1.txt" + q + "}" + + "\nHere are the files.\n" + "<|turn>user\nThanks\n" + "<|turn>model\n", }, @@ -1059,17 +1334,15 @@ func TestGemma4RendererMatchesReference(t *testing.T) { messages: []api.Message{{Role: "user", Content: "Tag"}}, tools: arrayNoItemsTool(), expected: "<|turn>system\n" + arrayNoItemsDeclRef + "\n" + - "<|turn>user\nTag\n<|turn>model\n", + "<|turn>user\nTag\n<|turn>model\n<|channel>thought\n", }, { - // 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: "<|turn>system\n" + objectNoDescDeclRef + "\n" + - "<|turn>user\nUpdate\n<|turn>model\n", + "<|turn>user\nUpdate\n<|turn>model\n<|channel>thought\n", }, // === 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nSet up the project\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "mkdir src" + q + "}\n" + - "<|turn>tool\n\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "touch src/main.go" + q + "}\n" + - "<|turn>tool\n\n" + - "<|turn>model\nDone.\n" + + "<|turn>model\n<|tool_call>call:bash{command:" + q + "mkdir src" + q + "}" + + "<|tool_response>response:bash{value:" + q + q + "}" + + "<|tool_call>call:bash{command:" + q + "touch src/main.go" + q + "}" + + "<|tool_response>response:bash{value:" + q + q + "}" + + "Done.\n" + "<|turn>user\nThanks\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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 directoryLet 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nList files\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}Let me list the files.\n" + - "<|turn>tool\nmain.go\ngo.mod\n" + + "<|turn>model\n<|tool_call>call:bash{command:" + q + "ls" + q + "}" + + "<|tool_response>response:bash{value:" + q + "main.go\ngo.mod" + q + "}" + + "Let me list the files.\n" + "<|turn>user\nOK\n" + - "<|turn>model\n", + "<|turn>model\n<|channel>thought\n", }, { // 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nRun it\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + "echo hello\necho world" + q + "}\n" + - "<|turn>tool\nhello\nworld\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{command:" + q + "echo hello\necho world" + q + "}" + + "<|tool_response>response:bash{value:" + q + "hello\nworld" + q + "}", }, { // 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: "<|turn>system\n" + bashSmallDeclRef + "\n" + "<|turn>user\nGo\n" + - "<|turn>model\n<|tool_call>call:bash{command:" + q + q + "}\n" + - "<|turn>tool\nerror\n" + - "<|turn>model\n", + "<|turn>model\n<|tool_call>call:bash{command:" + q + q + "}" + + "<|tool_response>response:bash{value:" + q + "error" + q + "}", }, } 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() } diff --git a/model/renderers/testdata/gemma4_chat_template.jinja2 b/model/renderers/testdata/gemma4_chat_template.jinja2 index bad629b31..98da08eb6 100644 --- a/model/renderers/testdata/gemma4_chat_template.jinja2 +++ b/model/renderers/testdata/gemma4_chat_template.jinja2 @@ -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 -%} + {{- '' -}} +{%- 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 @@ {{- '\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' -}} + {%- 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 -%} - {{- '' -}} + {{- 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')) -%} {{- '\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' -}} + {%- endif -%} {%- endif -%} {%- endif -%}