Repository: ollama/ollama
Pull request: model/parsers: Close think block if tool block starts in Qwen3.5

This change fixes https://github.com/ollama/ollama/issues/14745 when the model starts a `tool_call` block without closing the `think` block:

```
Thinking: I need to mock Valkey for tests. Let me check how the app initializes Valkey and update TESTING.md with this information.
<tool_call>
<function=bash>
<parameter=command>
grep "redacted" | head -20
</parameter>
<parameter=description>
Find Valkey initialization
</parameter>
</function>
</tool_call>
```

I saw this behavior while using Opencode + Ollama 0.18.2 + qwen3.5:9b. I have been working for a couple of days with the same setup and this patch without problems.

Issues that can be fixed with this PR as well:

- https://github.com/ollama/ollama/issues/14745
- https://github.com/ollama/ollama/issues/14601
- Partial https://github.com/ollama/ollama/issues/14493
- https://github.com/ollama/ollama/issues/14492

external:
- https://github.com/openclaw/openclaw/issues/45000

Regards, and thank you all for this great tool.

Changed files:
filename: model/parsers/qwen35.go
patch: @@ -21,6 +21,7 @@ const (
 const (
 	qwen35ThinkingOpenTag  = "<think>"
 	qwen35ThinkingCloseTag = "</think>"
+	qwen35ToolCallOpenTag  = "<tool_call>"
 )
 
 // Qwen35Parser handles qwen3.5 reasoning extraction and delegates post-thinking
@@ -202,6 +203,14 @@ func (p *Qwen35Parser) eat() ([]qwen35Event, bool) {
 				events = append(events, qwen35EventThinkingContent{content: unambiguous})
 			}
 			return events, false
+		} else if strings.Contains(acc, qwen35ToolCallOpenTag) {
+			// qwen3.5:9b model forgets sometimes to use </think> tag before the <tool_call> block starts
+			// this condition ends the Think block and continues with the <tool_call> when the tag
+			// is found
+			thinking, tooling := p.splitAtTag(qwen35ToolCallOpenTag, true)
+			p.buffer.Reset()
+			p.buffer.WriteString(thinking + qwen35ThinkingCloseTag + qwen35ToolCallOpenTag + tooling)
+			return events, true
 		}
 
 		whitespaceLen := trailingWhitespaceLen(acc)

filename: model/parsers/qwen35_test.go
patch: @@ -108,7 +108,7 @@ func TestQwen35ParserAssistantPrefillStartsInContent(t *testing.T) {
 	}
 }
 
-func TestQwen35ParserToolCallEmittedInThinkingIsNotParsed(t *testing.T) {
+func TestQwen35ParserToolCallEmittedInThinkingIsParsed(t *testing.T) {
 	parser := ParserForName("qwen3.5")
 	if parser == nil {
 		t.Fatal("expected qwen3.5 parser")
@@ -141,14 +141,20 @@ SF
 	if content != "" {
 		t.Fatalf("expected empty content, got %q", content)
 	}
-	expectedThinking := `Need weather lookup<tool_call><function=get_weather><parameter=location>
-SF
-</parameter></function></tool_call>`
-	if thinking != expectedThinking {
-		t.Fatalf("expected thinking %q, got %q", expectedThinking, thinking)
+	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 before </think>, got %d", len(calls))
+	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)
 	}
 }