From 00abf1be1f5d45c9daa4eed3c6aa287be740437a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Fri, 27 Feb 2026 12:15:00 +0100 Subject: [PATCH] fix(qwen3.5): add qwen3.5 preset and mimick llama.cpp's PEG (#8668) Signed-off-by: Ettore Di Giacinto --- pkg/functions/parse.go | 129 +++++++++++++++++++++++++++++++++--- pkg/functions/parse_test.go | 36 ++++++++++ 2 files changed, 157 insertions(+), 8 deletions(-) diff --git a/pkg/functions/parse.go b/pkg/functions/parse.go index 9f14208f1..107759ca7 100644 --- a/pkg/functions/parse.go +++ b/pkg/functions/parse.go @@ -375,6 +375,20 @@ func getAllXMLFormats() []xmlFormatPreset { TrimRawArgVal: true, }, }, + { + name: "qwen3.5", + format: &XMLToolCallFormat{ + ScopeStart: "", + ToolStart: "", + KeyStart: "", + ValEnd: "", + ToolEnd: "", + ScopeEnd: "", + 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 " 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. ...) 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 { diff --git a/pkg/functions/parse_test.go b/pkg/functions/parse_test.go index de08fffc3..bb68f2ffb 100644 --- a/pkg/functions/parse_test.go +++ b/pkg/functions/parse_test.go @@ -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 () precedes tool block (Qwen3.5-style)", func() { + input := ` +I need to run a command. + + + + +echo hello + + +` + 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 () precedes tool block (Qwen3.5-style)", func() { + input := ` +I need to run a command. + + + + +echo hello + + +` + 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}}