Compare commits

...

1 Commits

Author SHA1 Message Date
Devon Rifkin
b202a9b4ce qwen3-coder parser: allow missing opening tool call tag 2026-02-02 12:53:45 -08:00
2 changed files with 34 additions and 15 deletions

View File

@@ -19,8 +19,9 @@ import (
type qwenParserState int
const (
toolOpenTag = "<tool_call>"
toolCloseTag = "</tool_call>"
toolOpenTag = "<tool_call>"
toolCloseTag = "</tool_call>"
functionOpenStart = "<function=" // qwen3-coder sometimes omits <tool_call> 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 <tool_call> but starts with <function=
// we treat this as the start of a tool call, keeping the <function= prefix
// since it's part of the raw tool call content
before := p.acc.String()[:idx]
before = strings.TrimRightFunc(before, unicode.IsSpace)
if len(before) > 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]

View File

@@ -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 <tool_call> tags, but we
// want to parse it anyway
{
desc: "missing <tool_call> opening tag still parses",
steps: []step{
{
input: "before tool call<function=get_current_temperature>some tool content here</function></tool_call>",
wantEvents: []qwenEvent{
qwenEventContent{content: "before tool call"},
qwenEventRawToolCall{raw: "<function=get_current_temperature>some tool content here</function>"},
},
},
},
},
}
for _, tc := range cases {
if anyOnlies && !tc.only {
continue
}
t.Run(tc.desc, func(t *testing.T) {
parser := Qwen3CoderParser{}