diff --git a/model/parsers/qwen3coder.go b/model/parsers/qwen3coder.go index 5604988ec..666c8aaaa 100644 --- a/model/parsers/qwen3coder.go +++ b/model/parsers/qwen3coder.go @@ -19,8 +19,9 @@ import ( type qwenParserState int const ( - toolOpenTag = "" - toolCloseTag = "" + toolOpenTag = "" + toolCloseTag = "" + functionOpenStart = " but starts with this ) const ( @@ -138,11 +139,26 @@ func eat(p *Qwen3CoderParser) ([]qwenEvent, bool) { p.acc.WriteString(after) p.state = qwenParserState_CollectingToolContent return events, true - } else if overlap := overlap(p.acc.String(), toolOpenTag); overlap > 0 { + } else if idx := strings.Index(p.acc.String(), functionOpenStart); idx != -1 { + // qwen3-coder sometimes omits but starts with 0 { + events = append(events, qwenEventContent{content: before}) + } + after := p.acc.String()[idx:] + p.acc.Reset() + p.acc.WriteString(after) + p.state = qwenParserState_CollectingToolContent + return events, true + } else if toolOverlap, funcOverlap := overlap(p.acc.String(), toolOpenTag), overlap(p.acc.String(), functionOpenStart); toolOverlap > 0 || funcOverlap > 0 { // we found a partial tool open tag, so we can emit the unambiguous part, // which is the (trailing-whitespace trimmed) content before the partial // tool open tag - beforePartialTag := p.acc.String()[:len(p.acc.String())-overlap] + maxOverlap := max(toolOverlap, funcOverlap) + beforePartialTag := p.acc.String()[:len(p.acc.String())-maxOverlap] trailingWhitespaceLen := trailingWhitespaceLen(beforePartialTag) ambiguousStart := len(beforePartialTag) - trailingWhitespaceLen unambiguous := p.acc.String()[:ambiguousStart] diff --git a/model/parsers/qwen3coder_test.go b/model/parsers/qwen3coder_test.go index 24ccc5e9e..d1ec16d58 100644 --- a/model/parsers/qwen3coder_test.go +++ b/model/parsers/qwen3coder_test.go @@ -343,20 +343,23 @@ func TestQwenParserStreaming(t *testing.T) { }, }, }, - } - - anyOnlies := false - for _, tc := range cases { - if tc.only { - anyOnlies = true - } + // qwen3-coder:30b occasionally leaves off opening tags, but we + // want to parse it anyway + { + desc: "missing opening tag still parses", + steps: []step{ + { + input: "before tool callsome tool content here", + wantEvents: []qwenEvent{ + qwenEventContent{content: "before tool call"}, + qwenEventRawToolCall{raw: "some tool content here"}, + }, + }, + }, + }, } for _, tc := range cases { - if anyOnlies && !tc.only { - continue - } - t.Run(tc.desc, func(t *testing.T) { parser := Qwen3CoderParser{}