Repository: ollama/ollama
Pull request: parsers: qwen3.5 streaming tool-call parsing and add regression test


Changed files:
filename: model/parsers/qwen35.go
patch: @@ -190,7 +190,7 @@ func (p *Qwen35Parser) eat() ([]qwen35Event, bool) {
 				p.state = qwen35ParserStateCollectingContent
 			}
 			return events, true
-		} else if overlapLen := overlap(acc, qwen35ThinkingCloseTag); overlapLen > 0 {
+		} else if overlapLen := max(overlap(acc, qwen35ThinkingCloseTag), overlap(acc, qwen35ToolCallOpenTag)); overlapLen > 0 {
 			beforePartialTag := acc[:len(acc)-overlapLen]
 			trailingWsLen := trailingWhitespaceLen(beforePartialTag)
 			ambiguousStart := len(beforePartialTag) - trailingWsLen

filename: model/parsers/qwen35_test.go
patch: @@ -158,6 +158,133 @@ SF
 	}
 }
 
+func TestQwen35ParserToolCallEmittedInThinkingIsParsedWhenToolCallTagIsSplitAcrossChunks(t *testing.T) {
+	parser := ParserForName("qwen3.5")
+	if parser == nil {
+		t.Fatal("expected qwen3.5 parser")
+	}
+
+	tools := []api.Tool{
+		{
+			Function: api.ToolFunction{
+				Name: "get_weather",
+				Parameters: api.ToolFunctionParameters{
+					Properties: func() *api.ToolPropertiesMap {
+						props := api.NewToolPropertiesMap()
+						props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
+						return props
+					}(),
+				},
+			},
+		},
+	}
+
+	parser.Init(tools, nil, &api.ThinkValue{Value: true})
+	content, thinking, calls, err := parser.Add("Need weather lookup<tool_c", false)
+	if err != nil {
+		t.Fatalf("parse failed: %v", err)
+	}
+	if content != "" {
+		t.Fatalf("expected empty content, got %q", content)
+	}
+	if thinking != "Need weather lookup" {
+		t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
+	}
+	if len(calls) != 0 {
+		t.Fatalf("expected no tool calls in first chunk, got %d", len(calls))
+	}
+
+	content, thinking, calls, err = parser.Add(`all><function=get_weather><parameter=location>
+SF
+</parameter></function></tool_call>`, true)
+	if err != nil {
+		t.Fatalf("parse failed: %v", err)
+	}
+	if content != "" {
+		t.Fatalf("expected empty content, got %q", content)
+	}
+	if thinking != "" {
+		t.Fatalf("expected no additional thinking, got %q", thinking)
+	}
+	if len(calls) != 1 {
+		t.Fatalf("expected 1 tool call, got %d", len(calls))
+	}
+
+	if calls[0].Function.Name != "get_weather" {
+		t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
+	}
+
+	location, ok := calls[0].Function.Arguments.Get("location")
+	if !ok || location != "SF" {
+		t.Fatalf("expected location %q, got %v", "SF", location)
+	}
+}
+
+func TestQwen35ParserFakeoutPartialToolCallThenThinkCloseAcrossChunks(t *testing.T) {
+	parser := ParserForName("qwen3.5")
+	if parser == nil {
+		t.Fatal("expected qwen3.5 parser")
+	}
+
+	tools := []api.Tool{
+		{
+			Function: api.ToolFunction{
+				Name: "get_weather",
+				Parameters: api.ToolFunctionParameters{
+					Properties: func() *api.ToolPropertiesMap {
+						props := api.NewToolPropertiesMap()
+						props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
+						return props
+					}(),
+				},
+			},
+		},
+	}
+
+	parser.Init(tools, nil, &api.ThinkValue{Value: true})
+	content, thinking, calls, err := parser.Add("Need weather lookup<tool_c", false)
+	if err != nil {
+		t.Fatalf("parse failed: %v", err)
+	}
+	if content != "" {
+		t.Fatalf("expected empty content, got %q", content)
+	}
+	if thinking != "Need weather lookup" {
+		t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
+	}
+	if len(calls) != 0 {
+		t.Fatalf("expected no tool calls in first chunk, got %d", len(calls))
+	}
+
+	content, thinking, calls, err = parser.Add("</thi", false)
+	if err != nil {
+		t.Fatalf("parse failed: %v", err)
+	}
+	if content != "" {
+		t.Fatalf("expected empty content, got %q", content)
+	}
+	if thinking != "<tool_c" {
+		t.Fatalf("expected thinking %q, got %q", "<tool_c", thinking)
+	}
+	if len(calls) != 0 {
+		t.Fatalf("expected no tool calls in second chunk, got %d", len(calls))
+	}
+
+	content, thinking, calls, err = parser.Add("nk>", true)
+	if err != nil {
+		t.Fatalf("parse failed: %v", err)
+	}
+	if content != "" {
+		t.Fatalf("expected empty content, got %q", content)
+	}
+	if thinking != "" {
+		t.Fatalf("expected no additional thinking in third chunk, got %q", thinking)
+	}
+	if len(calls) != 0 {
+		t.Fatalf("expected no tool calls in third chunk, got %d", len(calls))
+	}
+}
+
 func TestQwen35ParserToolCallAfterThinkingCloseIsParsed(t *testing.T) {
 	parser := ParserForName("qwen3.5")
 	if parser == nil {