fix(qwen3.5): add qwen3.5 preset and mimick llama.cpp's PEG (#8668)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto authored and GitHub committed 2026-02-27 12:15:00 +01:00
1 parent 959458f0db
commit 00abf1be1f
2 files changed
+157 -8

No files matched your search

+121 -8
View File
@@ -375,6 +375,20 @@ func getAllXMLFormats() []xmlFormatPreset {
TrimRawArgVal: true,
},
},
{
name: "qwen3.5",
format: &XMLToolCallFormat{
ScopeStart: "<tool_call>",
ToolStart: "<function=",
ToolSep: ">",
KeyStart: "<parameter=",
KeyValSep: ">",
ValEnd: "</parameter>",
ToolEnd: "</function>",
ScopeEnd: "</tool_call>",
TrimRawArgVal: true,
},
},
{
name: "glm-4.5",
format: &XMLToolCallFormat{
@@ -483,9 +497,70 @@ func ParseXML(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) {
return parseXMLWithFormat(s, format)
}
// getScopeOrToolStart returns the string to search for to start the tool-calls section
// (ScopeStart if set, else ToolStart). Used to mimic llama.cpp's "content until <tool_call>" order.
func getScopeOrToolStart(format *XMLToolCallFormat) string {
if format == nil {
return ""
}
if format.ScopeStart != "" {
return format.ScopeStart
}
return format.ToolStart
}
// tryParseXMLFromScopeStart finds the first occurrence of scopeStart (or format.ToolStart),
// splits the input there, and parses only the suffix as XML tool calls. Returns (toolCalls, true)
// if any tool calls were parsed, else (nil, false). This mimics llama.cpp's PEG order so that
// reasoning or content before the tool block does not cause "whitespace only before scope" to fail.
func tryParseXMLFromScopeStart(s string, format *XMLToolCallFormat, isPartial bool) ([]FuncCallResults, bool) {
if format == nil {
return nil, false
}
scopeStart := getScopeOrToolStart(format)
if scopeStart == "" {
return nil, false
}
idx := strings.Index(s, scopeStart)
if idx < 0 {
return nil, false
}
toolCallsPart := s[idx:]
parser := NewChatMsgParser(toolCallsPart, isPartial)
success, err := parser.TryConsumeXMLToolCalls(format)
if err != nil {
if _, ok := err.(*ChatMsgPartialException); ok && isPartial {
return parser.ToolCalls(), len(parser.ToolCalls()) > 0
}
return nil, false
}
if success && len(parser.ToolCalls()) > 0 {
return parser.ToolCalls(), true
}
return nil, false
}
// ParseXMLIterative parses XML tool calls using the iterative parser
// This provides better streaming and partial parsing support
// This provides better streaming and partial parsing support.
// When format is nil or when format is set, tries "find scope/tool start, split, parse suffix"
// first (llama.cpp PEG order) so that content before the tool block does not cause parse failure.
func ParseXMLIterative(s string, format *XMLToolCallFormat, isPartial bool) ([]FuncCallResults, error) {
// Try split-on-scope first so reasoning/content before tool block is skipped
if format != nil {
if results, ok := tryParseXMLFromScopeStart(s, format, isPartial); ok {
return results, nil
}
} else {
formats := getAllXMLFormats()
for _, fmtPreset := range formats {
if fmtPreset.format != nil {
if results, ok := tryParseXMLFromScopeStart(s, fmtPreset.format, isPartial); ok {
return results, nil
}
}
}
}
parser := NewChatMsgParser(s, isPartial)
// Auto-detect format if not provided
@@ -1621,16 +1696,54 @@ func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncC
// but we've already parsed it, so we shouldn't try XML parsing on the same content
skipXMLParsing := (len(functionConfig.JSONRegexMatch) > 0 || len(functionConfig.ResponseRegex) > 0) && len(results) > 0
if len(results) == 0 && !skipXMLParsing {
xmlResults, err := ParseXML(llmresult, xmlFormat)
if err == nil && len(xmlResults) > 0 {
xlog.Debug("Found XML tool calls", "count", len(xmlResults))
results = append(results, xmlResults...)
// Mimic llama.cpp PEG order: try "find scope/tool start, split, parse suffix" first so that
// reasoning or content before the tool block (e.g. <think>...</think>) does not cause parse failure.
if xmlFormat != nil {
if xmlResults, ok := tryParseXMLFromScopeStart(llmresult, xmlFormat, false); ok {
xlog.Debug("Found XML tool calls (split-on-scope)", "count", len(xmlResults))
results = append(results, xmlResults...)
}
} else {
formats := getAllXMLFormats()
for _, fmtPreset := range formats {
if fmtPreset.format != nil {
if xmlResults, ok := tryParseXMLFromScopeStart(llmresult, fmtPreset.format, false); ok {
xlog.Debug("Found XML tool calls (split-on-scope, auto-detect)", "format", fmtPreset.name, "count", len(xmlResults))
results = append(results, xmlResults...)
break
}
}
}
}
if len(results) == 0 {
xmlResults, err := ParseXML(llmresult, xmlFormat)
if err == nil && len(xmlResults) > 0 {
xlog.Debug("Found XML tool calls", "count", len(xmlResults))
results = append(results, xmlResults...)
}
}
} else if len(results) > 0 && !skipXMLParsing {
// Even if we found JSON results, check for XML tool calls in the response
// This handles mixed content scenarios (text + JSON + XML)
// But skip if JSONRegexMatch or ResponseRegex was used (they already extracted the content)
xmlResults, err := ParseXML(llmresult, xmlFormat)
// Try split-on-scope first (llama.cpp order), then full ParseXML
var xmlResults []FuncCallResults
var err error
if xmlFormat != nil {
xmlResults, _ = tryParseXMLFromScopeStart(llmresult, xmlFormat, false)
}
if len(xmlResults) == 0 && xmlFormat == nil {
formats := getAllXMLFormats()
for _, fmtPreset := range formats {
if fmtPreset.format != nil {
xmlResults, _ = tryParseXMLFromScopeStart(llmresult, fmtPreset.format, false)
if len(xmlResults) > 0 {
break
}
}
}
}
if len(xmlResults) == 0 {
xmlResults, err = ParseXML(llmresult, xmlFormat)
}
if err == nil && len(xmlResults) > 0 {
// Check if JSON is inside XML tags, if so, skip it
for _, result := range xmlResults {
+36
View File
@@ -779,6 +779,42 @@ value
Expect(results[0].Name).To(Equal("glob"))
Expect(results[0].Arguments).To(Equal(`{"pattern":"**/package.json"}`))
})
It("should parse tool calls when reasoning (<think>) precedes tool block (Qwen3.5-style)", func() {
input := `<think>
I need to run a command.
</think>
<tool_call>
<function=bash>
<parameter=script>
echo hello
</parameter>
</function>
</tool_call>`
cfg := FunctionsConfig{}
results := ParseFunctionCall(input, cfg)
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("bash"))
Expect(results[0].Arguments).To(ContainSubstring("echo hello"))
})
It("should parse tool calls when reasoning (<think>) precedes tool block (Qwen3.5-style)", func() {
input := `<think>
I need to run a command.
</think>
<tool_call>
<function=bash>
<parameter=script>
echo hello
</parameter>
</function>
</tool_call>`
cfg := FunctionsConfig{}
cfg.XMLFormatPreset = "qwen3.5"
results := ParseFunctionCall(input, cfg)
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("bash"))
Expect(results[0].Arguments).To(ContainSubstring("echo hello"))
})
It("should parse XML tool calls alongside JSON tool calls", func() {
input := `{"name": "add", "arguments": {"x": 5, "y": 3}}