mirror of
https://github.com/ollama/ollama.git
synced 2026-04-17 21:54:08 +02:00
The existing strict gemma4 tool parser is still the primary path, but if this fails, we try to repair by fixing some of the most commonly seen mistakes these models seem to make in practice. We repair by building up a set of candidates, and use the first candidate that parses. Repairs cover: - missing Gemma string delimiters - single-quoted string values, including a dangling Gemma delimiter - raw terminal string values (if the corresponding tool schema indicates it should be a string) - missing object close only after a concrete repair Add regression coverage for malformed tool calls from issue #15315 and focused unit tests for the individual repair helpers and candidate pipeline.
1257 lines
34 KiB
Go
1257 lines
34 KiB
Go
package parsers
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
"github.com/ollama/ollama/api"
|
|
)
|
|
|
|
func TestGemma4Parser(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expectedContent string
|
|
expectedThinking string
|
|
expectedToolCalls []api.ToolCall
|
|
thinkingEnabled bool
|
|
lastMessage *api.Message
|
|
}{
|
|
{
|
|
name: "simple_content",
|
|
input: "This is a simple response.",
|
|
expectedContent: "This is a simple response.",
|
|
},
|
|
{
|
|
name: "thinking_then_content",
|
|
input: "<|channel>thought\nLet me think about this...<channel|>The answer is 42.",
|
|
expectedContent: "The answer is 42.",
|
|
expectedThinking: "Let me think about this...",
|
|
thinkingEnabled: true,
|
|
},
|
|
{
|
|
name: "multiple_thinking_blocks",
|
|
input: "<|channel>first thought<channel|><|channel>second thought<channel|>Final answer.",
|
|
expectedContent: "Final answer.",
|
|
expectedThinking: "first thoughtsecond thought",
|
|
thinkingEnabled: true,
|
|
},
|
|
{
|
|
name: "thinking_only_no_content",
|
|
input: "<|channel>just thinking<channel|>",
|
|
expectedContent: "",
|
|
expectedThinking: "just thinking",
|
|
thinkingEnabled: true,
|
|
},
|
|
{
|
|
name: "tool_call_simple",
|
|
input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "Paris",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_multiple_args",
|
|
input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>,units:<|"|>metric<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "Paris",
|
|
"units": "metric",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_number_arg",
|
|
input: `<|tool_call>call:set_temp{value:42}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "set_temp",
|
|
Arguments: testArgs(map[string]any{
|
|
"value": 42.0,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_boolean_arg",
|
|
input: `<|tool_call>call:toggle{enabled:true}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "toggle",
|
|
Arguments: testArgs(map[string]any{
|
|
"enabled": true,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_nested_object",
|
|
input: `<|tool_call>call:process{config:{enabled:true,name:<|"|>test<|"|>}}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "process",
|
|
Arguments: testArgs(map[string]any{
|
|
"config": map[string]any{
|
|
"enabled": true,
|
|
"name": "test",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_array",
|
|
input: `<|tool_call>call:process{items:[<|"|>a<|"|>,<|"|>b<|"|>]}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "process",
|
|
Arguments: testArgs(map[string]any{
|
|
"items": []any{"a", "b"},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_array_of_multiple_gemma_quoted_strings",
|
|
input: `<|tool_call>call:process{items:[<|"|>a<|"|>,<|"|>b "quoted"<|"|>,<|"|>c<|"|>]}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "process",
|
|
Arguments: testArgs(map[string]any{
|
|
"items": []any{"a", `b "quoted"`, "c"},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_multiline_string_arg",
|
|
input: `<|tool_call>call:bash{command:<|"|>date
|
|
<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: testArgs(map[string]any{
|
|
"command": "date\n",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_escaped_double_quotes_in_string_arg",
|
|
input: `<|tool_call>call:search{query:<|"|>say \"hello\"<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `say \"hello\"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_unescaped_double_quotes_in_string_arg",
|
|
input: `<|tool_call>call:search{query:<|"|>say "hello"<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `say "hello"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_multiple_unescaped_double_quote_segments",
|
|
input: `<|tool_call>call:search{query:<|"|>say "hello", then "goodbye"<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `say "hello", then "goodbye"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_mixed_escaped_and_unescaped_double_quotes",
|
|
input: `<|tool_call>call:search{query:<|"|>first \"quoted\" then "raw"<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `first \"quoted\" then "raw"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_done_flush_without_close_tag_with_unescaped_double_quotes",
|
|
input: `<|tool_call>call:search{query:<|"|>say "hello" and "bye"<|"|>}`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `say "hello" and "bye"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_mixed_raw_and_gemma_quoted_values",
|
|
input: `<|tool_call>call:search{query:"raw \"quoted\"",note:<|"|>gemma "quoted"<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "search",
|
|
Arguments: testArgs(map[string]any{
|
|
"query": `raw "quoted"`,
|
|
"note": `gemma "quoted"`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_array_of_objects_and_mixed_quotes",
|
|
input: `<|tool_call>call:plan{steps:[{title:<|"|>step "one"<|"|>,done:false},{title:<|"|>step \"two\"<|"|>,done:true}]}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "plan",
|
|
Arguments: testArgs(map[string]any{
|
|
"steps": []any{
|
|
map[string]any{
|
|
"title": `step "one"`,
|
|
"done": false,
|
|
},
|
|
map[string]any{
|
|
"title": `step \"two\"`,
|
|
"done": true,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "tool_call_with_windows_path_single_backslashes",
|
|
input: `<|tool_call>call:open_file{path:<|"|>C:\users\bob\file.txt<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "open_file",
|
|
Arguments: testArgs(map[string]any{
|
|
"path": `C:\users\bob\file.txt`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple_tool_calls",
|
|
input: `<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><|tool_call>call:get_weather{location:<|"|>London<|"|>}<tool_call|>`,
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "Paris",
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "London",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "thinking_then_tool_call",
|
|
input: "<|channel>thought\nI need to check the weather<channel|><|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}<tool_call|>",
|
|
expectedThinking: "I need to check the weather",
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "Paris",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
thinkingEnabled: true,
|
|
},
|
|
{
|
|
name: "content_then_tool_call",
|
|
input: `Let me check that for you.<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|>`,
|
|
expectedContent: "Let me check that for you.",
|
|
expectedToolCalls: []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "Paris",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "thinking_disabled_channel_tags_as_content",
|
|
input: "<|channel>this is not thinking<channel|>actual content",
|
|
expectedContent: "actual content",
|
|
thinkingEnabled: false,
|
|
},
|
|
{
|
|
name: "prefill_content_only",
|
|
input: "Continuing content.",
|
|
expectedContent: "Continuing content.",
|
|
lastMessage: &api.Message{
|
|
Role: "assistant",
|
|
Content: "Previous content",
|
|
},
|
|
thinkingEnabled: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
parser := &Gemma4Parser{hasThinkingSupport: true}
|
|
parser.Init(nil, tt.lastMessage, &api.ThinkValue{Value: tt.thinkingEnabled})
|
|
|
|
content, thinking, toolCalls, err := parser.Add(tt.input, true)
|
|
if err != nil {
|
|
t.Fatalf("Add() error = %v", err)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.expectedContent, content); diff != "" {
|
|
t.Errorf("content mismatch (-want +got):\n%s", diff)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" {
|
|
t.Errorf("thinking mismatch (-want +got):\n%s", diff)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.expectedToolCalls, toolCalls, argsComparer); diff != "" {
|
|
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGemma4Parser_Streaming(t *testing.T) {
|
|
parser := &Gemma4Parser{hasThinkingSupport: true}
|
|
parser.Init(nil, nil, &api.ThinkValue{Value: true})
|
|
|
|
chunks := []string{
|
|
"<|channel>thought",
|
|
"\nLet me think",
|
|
"...<channel|>The answer",
|
|
" is 42.",
|
|
}
|
|
|
|
var finalContent, finalThinking strings.Builder
|
|
|
|
for i, chunk := range chunks {
|
|
done := i == len(chunks)-1
|
|
content, thinking, _, err := parser.Add(chunk, done)
|
|
if err != nil {
|
|
t.Fatalf("Add() error on chunk %d: %v", i, err)
|
|
}
|
|
|
|
finalContent.WriteString(content)
|
|
finalThinking.WriteString(thinking)
|
|
}
|
|
|
|
if finalContent.String() != "The answer is 42." {
|
|
t.Errorf("expected content %q, got %q", "The answer is 42.", finalContent.String())
|
|
}
|
|
|
|
if finalThinking.String() != "Let me think..." {
|
|
t.Errorf("expected thinking %q, got %q", "Let me think...", finalThinking.String())
|
|
}
|
|
}
|
|
|
|
func TestGemma4Parser_StreamingToolCall(t *testing.T) {
|
|
parser := &Gemma4Parser{hasThinkingSupport: false}
|
|
parser.Init(nil, nil, nil)
|
|
|
|
chunks := []string{
|
|
`<|tool_call>call:get_`,
|
|
`weather{location:<|"|>Par`,
|
|
`is<|"|>}<tool_call|>`,
|
|
}
|
|
|
|
var finalContent strings.Builder
|
|
var finalToolCalls []api.ToolCall
|
|
|
|
for i, chunk := range chunks {
|
|
done := i == len(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 finalContent.String() != "" {
|
|
t.Errorf("expected no content, got %q", finalContent.String())
|
|
}
|
|
|
|
expectedToolCalls := []api.ToolCall{
|
|
{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{
|
|
"location": "Paris",
|
|
}),
|
|
},
|
|
},
|
|
}
|
|
|
|
if diff := cmp.Diff(expectedToolCalls, finalToolCalls, argsComparer); diff != "" {
|
|
t.Errorf("tool calls mismatch (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestGemma4Parser_IgnoresExtraToolCallCloseTags(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_call|>`,
|
|
},
|
|
expectedContent: "",
|
|
},
|
|
{
|
|
name: "same_chunk_before_real_content",
|
|
chunks: []string{
|
|
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><tool_call|>Done.`,
|
|
},
|
|
expectedContent: "Done.",
|
|
},
|
|
{
|
|
name: "split_across_chunks_before_real_content",
|
|
chunks: []string{
|
|
`<|tool_call>call:get_weather{location:<|"|>Paris<|"|>}<tool_call|><tool_`,
|
|
`call|>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
|
|
chunks []string
|
|
expectedContent string
|
|
expectedThinking string
|
|
}{
|
|
{
|
|
name: "split_channel_open_tag",
|
|
chunks: []string{
|
|
"<|chan",
|
|
"nel>thinking here<channel|>content",
|
|
},
|
|
expectedContent: "content",
|
|
expectedThinking: "thinking here",
|
|
},
|
|
{
|
|
name: "split_channel_close_tag",
|
|
chunks: []string{
|
|
"<|channel>thinking here<chan",
|
|
"nel|>content",
|
|
},
|
|
expectedContent: "content",
|
|
expectedThinking: "thinking here",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
parser := &Gemma4Parser{hasThinkingSupport: true}
|
|
parser.Init(nil, nil, &api.ThinkValue{Value: true})
|
|
|
|
var finalContent, finalThinking strings.Builder
|
|
for i, chunk := range tt.chunks {
|
|
done := i == len(tt.chunks)-1
|
|
content, thinking, _, err := parser.Add(chunk, done)
|
|
if err != nil {
|
|
t.Fatalf("Add() error on chunk %d: %v", i, err)
|
|
}
|
|
finalContent.WriteString(content)
|
|
finalThinking.WriteString(thinking)
|
|
}
|
|
|
|
if finalContent.String() != tt.expectedContent {
|
|
t.Errorf("expected content %q, got %q", tt.expectedContent, finalContent.String())
|
|
}
|
|
if finalThinking.String() != tt.expectedThinking {
|
|
t.Errorf("expected thinking %q, got %q", tt.expectedThinking, finalThinking.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGemma4ArgsToJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple_string",
|
|
input: `{location:<|"|>Paris<|"|>}`,
|
|
expected: `{"location":"Paris"}`,
|
|
},
|
|
{
|
|
name: "multiple_args",
|
|
input: `{location:<|"|>Paris<|"|>,units:<|"|>metric<|"|>}`,
|
|
expected: `{"location":"Paris","units":"metric"}`,
|
|
},
|
|
{
|
|
name: "number_value",
|
|
input: `{value:42}`,
|
|
expected: `{"value":42}`,
|
|
},
|
|
{
|
|
name: "boolean_value",
|
|
input: `{enabled:true}`,
|
|
expected: `{"enabled":true}`,
|
|
},
|
|
{
|
|
name: "nested_object",
|
|
input: `{config:{enabled:true,name:<|"|>test<|"|>}}`,
|
|
expected: `{"config":{"enabled":true,"name":"test"}}`,
|
|
},
|
|
{
|
|
name: "array_value",
|
|
input: `{items:[<|"|>a<|"|>,<|"|>b<|"|>]}`,
|
|
expected: `{"items":["a","b"]}`,
|
|
},
|
|
{
|
|
name: "array_value_with_multiple_gemma_quoted_strings",
|
|
input: `{items:[<|"|>a<|"|>,<|"|>b "quoted"<|"|>,<|"|>c<|"|>]}`,
|
|
expected: `{"items":["a","b \"quoted\"","c"]}`,
|
|
},
|
|
{
|
|
name: "empty_object",
|
|
input: `{}`,
|
|
expected: `{}`,
|
|
},
|
|
{
|
|
name: "mixed_types",
|
|
input: `{name:<|"|>test<|"|>,count:5,active:true,tags:[<|"|>a<|"|>]}`,
|
|
expected: `{"name":"test","count":5,"active":true,"tags":["a"]}`,
|
|
},
|
|
{
|
|
name: "null_value",
|
|
input: `{value:null}`,
|
|
expected: `{"value":null}`,
|
|
},
|
|
{
|
|
name: "multiline_string_value",
|
|
input: `{command:<|"|>date
|
|
<|"|>}`,
|
|
expected: `{"command":"date\n"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_escaped_double_quotes",
|
|
input: `{query:<|"|>say \"hello\"<|"|>}`,
|
|
expected: `{"query":"say \\\"hello\\\""}`,
|
|
},
|
|
{
|
|
name: "string_value_with_unescaped_double_quotes",
|
|
input: `{query:<|"|>say "hello"<|"|>}`,
|
|
expected: `{"query":"say \"hello\""}`,
|
|
},
|
|
{
|
|
name: "string_value_with_multiple_unescaped_double_quote_segments",
|
|
input: `{query:<|"|>say "hello", then "goodbye"<|"|>}`,
|
|
expected: `{"query":"say \"hello\", then \"goodbye\""}`,
|
|
},
|
|
{
|
|
name: "string_value_with_mixed_escaped_and_unescaped_double_quotes",
|
|
input: `{query:<|"|>first \"quoted\" then "raw"<|"|>}`,
|
|
expected: `{"query":"first \\\"quoted\\\" then \"raw\""}`,
|
|
},
|
|
{
|
|
name: "string_value_with_punctuation_and_structural_chars",
|
|
input: `{query:<|"|>a,b:{c}[d]<|"|>}`,
|
|
expected: `{"query":"a,b:{c}[d]"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_windows_path_backslashes",
|
|
input: `{path:<|"|>C:\\Temp\\file.txt<|"|>}`,
|
|
expected: `{"path":"C:\\\\Temp\\\\file.txt"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_windows_path_single_backslashes",
|
|
input: `{path:<|"|>C:\users\bob<|"|>}`,
|
|
expected: `{"path":"C:\\users\\bob"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_escaped_forward_slashes",
|
|
input: `{url:<|"|>https:\/\/example.com\/a<|"|>}`,
|
|
expected: `{"url":"https:\\/\\/example.com\\/a"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_unicode_escape_sequence",
|
|
input: `{s:<|"|>snowman:\u2603<|"|>}`,
|
|
expected: `{"s":"snowman:\\u2603"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_unknown_escape_sequence",
|
|
input: `{s:<|"|>bad \x escape<|"|>}`,
|
|
expected: `{"s":"bad \\x escape"}`,
|
|
},
|
|
{
|
|
name: "string_value_with_invalid_unicode_escape_sequence",
|
|
input: `{s:<|"|>bad \uZZZZ escape<|"|>}`,
|
|
expected: `{"s":"bad \\uZZZZ escape"}`,
|
|
},
|
|
{
|
|
name: "raw_quoted_string_with_escaped_quotes",
|
|
input: `{q:"say \"hi\" and \"bye\""}`,
|
|
expected: `{"q":"say \"hi\" and \"bye\""}`,
|
|
},
|
|
{
|
|
name: "nested_mixed_raw_and_gemma_quoted_values",
|
|
input: `{meta:{title:<|"|>t "1"<|"|>,note:"n \"2\""},items:[<|"|>x "3"<|"|>,"y \"4\""]}`,
|
|
expected: `{"meta":{"title":"t \"1\"","note":"n \"2\""},"items":["x \"3\"","y \"4\""]}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := gemma4ArgsToJSON(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("expected %q, got %q", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepairGemma4MissingStringDelimiter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "closes_before_object_close",
|
|
input: `{command:<|"|>ls}`,
|
|
expected: `{command:<|"|>ls<|"|>}`,
|
|
},
|
|
{
|
|
name: "closes_terminal_value_after_previous_property",
|
|
input: `{path:<|"|>/tmp<|"|>,command:<|"|>ls}`,
|
|
expected: `{path:<|"|>/tmp<|"|>,command:<|"|>ls<|"|>}`,
|
|
},
|
|
{
|
|
name: "closes_at_end",
|
|
input: `{command:<|"|>ls`,
|
|
expected: `{command:<|"|>ls<|"|>`,
|
|
},
|
|
{
|
|
name: "preserves_valid_gemma_quoted_string",
|
|
input: `{command:<|"|>ls<|"|>}`,
|
|
expected: `{command:<|"|>ls<|"|>}`,
|
|
},
|
|
{
|
|
name: "preserves_input_without_gemma_delimiter",
|
|
input: `{command:ls}`,
|
|
expected: `{command:ls}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := repairGemma4MissingStringDelimiter(tt.input)
|
|
if got != tt.expected {
|
|
t.Fatalf("repairGemma4MissingStringDelimiter(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepairGemma4MissingObjectClose(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "adds_object_close",
|
|
input: `{content:<|"|>hello<|"|>`,
|
|
expected: `{content:<|"|>hello<|"|>}`,
|
|
},
|
|
{
|
|
name: "adds_object_close_before_trailing_space",
|
|
input: "{content:<|\"|>hello<|\"|> ",
|
|
expected: "{content:<|\"|>hello<|\"|>} ",
|
|
},
|
|
{
|
|
name: "preserves_existing_object_close",
|
|
input: `{content:<|"|>hello<|"|>}`,
|
|
expected: `{content:<|"|>hello<|"|>}`,
|
|
},
|
|
{
|
|
name: "preserves_non_object_input",
|
|
input: `content:<|"|>hello<|"|>`,
|
|
expected: `content:<|"|>hello<|"|>`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := repairGemma4MissingObjectClose(tt.input)
|
|
if got != tt.expected {
|
|
t.Fatalf("repairGemma4MissingObjectClose(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepairGemma4SingleQuotedValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "converts_single_quoted_value",
|
|
input: `{pattern:':\s*\w+'}`,
|
|
expected: `{pattern:<|"|>:\s*\w+<|"|>}`,
|
|
},
|
|
{
|
|
name: "converts_middle_single_quoted_value",
|
|
input: `{include:<|"|>*.py<|"|>,pattern:'abc',path:<|"|>/tmp<|"|>}`,
|
|
expected: `{include:<|"|>*.py<|"|>,pattern:<|"|>abc<|"|>,path:<|"|>/tmp<|"|>}`,
|
|
},
|
|
{
|
|
name: "drops_dangling_gemma_delimiter_after_single_quoted_value",
|
|
input: `{pattern:'abc'<|"|>}`,
|
|
expected: `{pattern:<|"|>abc<|"|>}`,
|
|
},
|
|
{
|
|
name: "preserves_gemma_quoted_value",
|
|
input: `{pattern:<|"|>abc<|"|>}`,
|
|
expected: `{pattern:<|"|>abc<|"|>}`,
|
|
},
|
|
{
|
|
name: "preserves_json_quoted_value",
|
|
input: `{pattern:"abc"}`,
|
|
expected: `{pattern:"abc"}`,
|
|
},
|
|
{
|
|
name: "preserves_unterminated_single_quote",
|
|
input: `{pattern:'abc}`,
|
|
expected: `{pattern:'abc}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := repairGemma4SingleQuotedValues(tt.input)
|
|
if got != tt.expected {
|
|
t.Fatalf("repairGemma4SingleQuotedValues(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRepairGemma4RawTerminalStringValue(t *testing.T) {
|
|
numberProps := api.NewToolPropertiesMap()
|
|
numberProps.Set("content", api.ToolProperty{Type: api.PropertyType{"number"}})
|
|
numberTool := api.Tool{
|
|
Type: "function",
|
|
Function: api.ToolFunction{
|
|
Name: "write",
|
|
Parameters: api.ToolFunctionParameters{
|
|
Type: "object",
|
|
Properties: numberProps,
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
toolName string
|
|
tools []api.Tool
|
|
expected string
|
|
expectedOK bool
|
|
}{
|
|
{
|
|
name: "wraps_known_string_property_terminal_value",
|
|
input: "{content:\n\n# Title",
|
|
toolName: "write",
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content")},
|
|
expected: "{content:<|\"|>\n\n# Title<|\"|>",
|
|
expectedOK: true,
|
|
},
|
|
{
|
|
name: "stops_before_next_known_property",
|
|
input: `{content:hello,mode:<|"|>fast<|"|>}`,
|
|
toolName: "write",
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content", "mode")},
|
|
expected: `{content:<|"|>hello<|"|>,mode:<|"|>fast<|"|>}`,
|
|
expectedOK: true,
|
|
},
|
|
{
|
|
name: "does_not_repair_without_schema",
|
|
input: `{content:hello`,
|
|
toolName: "write",
|
|
tools: nil,
|
|
expectedOK: false,
|
|
},
|
|
{
|
|
name: "does_not_repair_non_string_property",
|
|
input: `{content:hello`,
|
|
toolName: "write",
|
|
tools: []api.Tool{numberTool},
|
|
expectedOK: false,
|
|
},
|
|
{
|
|
name: "does_not_repair_already_structured_value",
|
|
input: `{content:<|"|>hello<|"|>}`,
|
|
toolName: "write",
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content")},
|
|
expectedOK: false,
|
|
},
|
|
{
|
|
name: "does_not_repair_json_literal_start",
|
|
input: `{content:123}`,
|
|
toolName: "write",
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content")},
|
|
expectedOK: false,
|
|
},
|
|
{
|
|
name: "does_not_repair_unknown_tool",
|
|
input: `{content:hello`,
|
|
toolName: "missing",
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content")},
|
|
expectedOK: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := repairGemma4RawTerminalStringValue(tt.input, tt.toolName, tt.tools)
|
|
if ok != tt.expectedOK {
|
|
t.Fatalf("repairGemma4RawTerminalStringValue ok = %t, want %t", ok, tt.expectedOK)
|
|
}
|
|
if got != tt.expected {
|
|
t.Fatalf("repairGemma4RawTerminalStringValue got %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGemma4RepairCandidates(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
toolName string
|
|
tools []api.Tool
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "missing_string_delimiter_candidate",
|
|
input: `{command:<|"|>ls}`,
|
|
toolName: "bash",
|
|
tools: []api.Tool{gemma4TestStringTool("bash", "command")},
|
|
expected: []string{`{command:<|"|>ls<|"|>}`},
|
|
},
|
|
{
|
|
name: "single_quoted_value_candidate",
|
|
input: `{pattern:'abc'<|"|>}`,
|
|
toolName: "grep",
|
|
tools: []api.Tool{gemma4TestStringTool("grep", "pattern")},
|
|
expected: []string{`{pattern:<|"|>abc<|"|>}`},
|
|
},
|
|
{
|
|
name: "raw_string_candidate_also_gets_missing_object_close",
|
|
input: `{content:hello`,
|
|
toolName: "write",
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content")},
|
|
expected: []string{
|
|
`{content:hello`,
|
|
`{content:<|"|>hello<|"|>}`,
|
|
},
|
|
},
|
|
{
|
|
name: "does_not_add_missing_object_close_without_another_repair",
|
|
input: `{n:1`,
|
|
toolName: "count",
|
|
tools: []api.Tool{gemma4TestStringTool("count", "name")},
|
|
expected: []string{`{n:1`},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := gemma4RepairCandidates(tt.input, tt.toolName, tt.tools)
|
|
if diff := cmp.Diff(tt.expected, got); diff != "" {
|
|
t.Fatalf("gemma4RepairCandidates mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func gemma4TestStringTool(name string, argNames ...string) api.Tool {
|
|
props := api.NewToolPropertiesMap()
|
|
for _, argName := range argNames {
|
|
props.Set(argName, api.ToolProperty{Type: api.PropertyType{"string"}})
|
|
}
|
|
|
|
return api.Tool{
|
|
Type: "function",
|
|
Function: api.ToolFunction{
|
|
Name: name,
|
|
Parameters: api.ToolFunctionParameters{
|
|
Type: "object",
|
|
Properties: props,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestGemma4Parser_HasToolSupport(t *testing.T) {
|
|
parser := &Gemma4Parser{}
|
|
if !parser.HasToolSupport() {
|
|
t.Error("Gemma4Parser should support tools")
|
|
}
|
|
}
|
|
|
|
func TestGemma4Parser_HasThinkingSupport(t *testing.T) {
|
|
parser := &Gemma4Parser{hasThinkingSupport: true}
|
|
if !parser.HasThinkingSupport() {
|
|
t.Error("Gemma4Parser with thinking support should report it")
|
|
}
|
|
|
|
parser2 := &Gemma4Parser{hasThinkingSupport: false}
|
|
if parser2.HasThinkingSupport() {
|
|
t.Error("Gemma4Parser without thinking support should not report it")
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_InvalidRawQuotedEscape(t *testing.T) {
|
|
_, err := parseGemma4ToolCall(`call:open_file{path:"C:\users\bob\file.txt"}`, nil)
|
|
if err == nil {
|
|
t.Fatal("expected parseGemma4ToolCall to reject malformed raw-quoted JSON escapes")
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_QuotedScalarsStayStrings(t *testing.T) {
|
|
toolCall, err := parseGemma4ToolCall(`call:foo{n:<|"|>1<|"|>,b:<|"|>true<|"|>,z:<|"|>null<|"|>}`, nil)
|
|
if err != nil {
|
|
t.Fatalf("parseGemma4ToolCall returned error: %v", err)
|
|
}
|
|
|
|
want := api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "foo",
|
|
Arguments: testArgs(map[string]any{
|
|
"n": "1",
|
|
"b": "true",
|
|
"z": "null",
|
|
}),
|
|
},
|
|
}
|
|
|
|
if diff := cmp.Diff(want, toolCall, argsComparer); diff != "" {
|
|
t.Fatalf("quoted scalar handling differed from the reference implementation (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_UnquotedScalarsKeepStructuredTypes(t *testing.T) {
|
|
toolCall, err := parseGemma4ToolCall(`call:foo{n:1,b:true,z:null}`, nil)
|
|
if err != nil {
|
|
t.Fatalf("parseGemma4ToolCall returned error: %v", err)
|
|
}
|
|
|
|
want := api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "foo",
|
|
Arguments: testArgs(map[string]any{
|
|
"n": 1.0,
|
|
"b": true,
|
|
"z": nil,
|
|
}),
|
|
},
|
|
}
|
|
|
|
if diff := cmp.Diff(want, toolCall, argsComparer); diff != "" {
|
|
t.Fatalf("unquoted scalar handling differed from the reference implementation (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_ReferenceImplementationExample(t *testing.T) {
|
|
toolCall, err := parseGemma4ToolCall(`call:get_current_temperature{detail_level:0,location:<|"|>Paris, France<|"|>,unit:<|"|>celsius<|"|>}`, nil)
|
|
if err != nil {
|
|
t.Fatalf("parseGemma4ToolCall returned error: %v", err)
|
|
}
|
|
|
|
want := api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_current_temperature",
|
|
Arguments: testArgs(map[string]any{
|
|
"detail_level": 0.0,
|
|
"location": "Paris, France",
|
|
"unit": "celsius",
|
|
}),
|
|
},
|
|
}
|
|
|
|
if diff := cmp.Diff(want, toolCall, argsComparer); diff != "" {
|
|
t.Fatalf("tool call handling differed from the reference implementation (-want +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_RepairsIssue15315Examples(t *testing.T) {
|
|
writeContent := "\n\n# Project Style Guide for Autonomous Agents' Code Generation (AGENTS.md)\n\n" +
|
|
"This document captures the *de facto* coding standards observed across the `src/` and `components/` source code, designed to ensure consistency for all generated code and modules consumed by the agent system."
|
|
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
tools []api.Tool
|
|
want api.ToolCall
|
|
}{
|
|
{
|
|
name: "raw multiline string",
|
|
// Source: https://github.com/ollama/ollama/issues/15315#issue-4203625511
|
|
content: "call:write{content:" + writeContent,
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content")},
|
|
want: api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "write",
|
|
Arguments: testArgs(map[string]any{
|
|
"content": writeContent,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "single quoted value with dangling gemma string delimiter",
|
|
content: `call:grep{include:<|"|>*.py<|"|>,output_mode:<|"|>content<|"|>,path:<|"|>/data/robotics/experiment1<|"|>,pattern:':\s*\w+'<|"|>}`,
|
|
tools: []api.Tool{gemma4TestStringTool("grep", "include", "output_mode", "path", "pattern")},
|
|
// Source: https://github.com/ollama/ollama/issues/15315#issue-4203625511
|
|
want: api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "grep",
|
|
Arguments: testArgs(map[string]any{
|
|
"include": "*.py",
|
|
"output_mode": "content",
|
|
"path": "/data/robotics/experiment1",
|
|
"pattern": `:\s*\w+`,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "unclosed gemma string before object close",
|
|
content: `call:bash{command:<|"|>ls}`,
|
|
tools: []api.Tool{gemma4TestStringTool("bash", "command")},
|
|
// Source: https://github.com/ollama/ollama/issues/15315#issuecomment-4194547092
|
|
want: api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: testArgs(map[string]any{
|
|
"command": "ls",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := parseGemma4ToolCall(tt.content, tt.tools)
|
|
if err != nil {
|
|
t.Fatalf("parseGemma4ToolCall returned error: %v", err)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.want, got, argsComparer); diff != "" {
|
|
t.Fatalf("tool call mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_RepairsMultipleProperties(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
tools []api.Tool
|
|
want api.ToolCall
|
|
}{
|
|
{
|
|
name: "single_quoted_middle_property",
|
|
content: `call:grep{include:<|"|>*.py<|"|>,pattern:'abc',path:<|"|>/tmp<|"|>}`,
|
|
tools: []api.Tool{gemma4TestStringTool("grep", "include", "pattern", "path")},
|
|
want: api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "grep",
|
|
Arguments: testArgs(map[string]any{
|
|
"include": "*.py",
|
|
"pattern": "abc",
|
|
"path": "/tmp",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "unclosed_gemma_string_terminal_property",
|
|
content: `call:bash{path:<|"|>/tmp<|"|>,command:<|"|>ls}`,
|
|
tools: []api.Tool{gemma4TestStringTool("bash", "path", "command")},
|
|
want: api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "bash",
|
|
Arguments: testArgs(map[string]any{
|
|
"path": "/tmp",
|
|
"command": "ls",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "raw_string_before_next_property",
|
|
content: `call:write{content:hello,mode:<|"|>fast<|"|>}`,
|
|
tools: []api.Tool{gemma4TestStringTool("write", "content", "mode")},
|
|
want: api.ToolCall{
|
|
Function: api.ToolCallFunction{
|
|
Name: "write",
|
|
Arguments: testArgs(map[string]any{
|
|
"content": "hello",
|
|
"mode": "fast",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := parseGemma4ToolCall(tt.content, tt.tools)
|
|
if err != nil {
|
|
t.Fatalf("parseGemma4ToolCall returned error: %v", err)
|
|
}
|
|
|
|
if diff := cmp.Diff(tt.want, got, argsComparer); diff != "" {
|
|
t.Fatalf("tool call mismatch (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_DoesNotRepairNonTerminalUnclosedGemmaString(t *testing.T) {
|
|
// TODO(drifkin): our current examples show unclosed gemma strings as the last
|
|
// values, but if we find examples where there's an unclosed non-last value,
|
|
// we should consider repairing it. This test shows that we don't yet repair
|
|
// this type (the heuristics of where to close are much more complicated)
|
|
_, err := parseGemma4ToolCall(`call:example{first:<|"|>one,second:<|"|>two<|"|>}`, []api.Tool{
|
|
gemma4TestStringTool("example", "first", "second"),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected non-terminal unclosed Gemma string to remain unsupported")
|
|
}
|
|
}
|
|
|
|
func TestParseGemma4ToolCall_InvalidRawQuotedStructuralString(t *testing.T) {
|
|
_, err := parseGemma4ToolCall(`call:foo{q:"a,b:c"}`, nil)
|
|
if err == nil {
|
|
t.Fatal("expected parseGemma4ToolCall to reject raw-quoted strings with structural text that the reference implementation does not support")
|
|
}
|
|
}
|