feat(providers): parse XML tool calls as fallback for minimax

When the provider returns no structured tool_calls but Content contains
XML blocks like <minimax:toolcall>, extract and parse them into ToolCall
structs. This handles cases where minimax returns tool calls only as XML.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-20 16:06:05 +09:00
parent 7d283d640c
commit 0b36150cd3
3 changed files with 211 additions and 2 deletions

View file

@ -979,3 +979,89 @@ func TestFindMatchingBrace(t *testing.T) {
}
}
}
// --- XML tool call extract/strip tests ---
func TestExtractXMLToolCalls_Single(t *testing.T) {
text := `<minimax:toolcall>
<invoke name="exec">
<parameter name="command">echo hello</parameter>
</invoke>
</minimax:toolcall>`
calls := extractXMLToolCalls(text)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Name != "exec" {
t.Errorf("Name = %q, want %q", calls[0].Name, "exec")
}
if calls[0].Arguments["command"] != "echo hello" {
t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "echo hello")
}
if calls[0].Function == nil || calls[0].Function.Name != "exec" {
t.Errorf("Function.Name should be exec")
}
}
func TestExtractXMLToolCalls_Multiple(t *testing.T) {
text := `<minimax:toolcall>
<invoke name="web_search">
<parameter name="query">golang testing</parameter>
</invoke>
<invoke name="exec">
<parameter name="command">go test ./...</parameter>
<parameter name="timeout">30</parameter>
</invoke>
</minimax:toolcall>`
calls := extractXMLToolCalls(text)
if len(calls) != 2 {
t.Fatalf("expected 2 tool calls, got %d", len(calls))
}
if calls[0].Name != "web_search" {
t.Errorf("[0].Name = %q, want %q", calls[0].Name, "web_search")
}
if calls[1].Name != "exec" {
t.Errorf("[1].Name = %q, want %q", calls[1].Name, "exec")
}
if calls[1].Arguments["timeout"] != "30" {
t.Errorf("[1].Arguments[timeout] = %v, want %q", calls[1].Arguments["timeout"], "30")
}
}
func TestExtractXMLToolCalls_NoXML(t *testing.T) {
calls := extractXMLToolCalls("just regular text")
if len(calls) != 0 {
t.Errorf("expected 0 tool calls, got %d", len(calls))
}
}
func TestStripXMLToolCalls(t *testing.T) {
text := `Let me run that.
<minimax:toolcall>
<invoke name="exec">
<parameter name="command">echo hello</parameter>
</invoke>
</minimax:toolcall>
Done.`
got := stripXMLToolCalls(text)
if strings.Contains(got, "toolcall") {
t.Errorf("should remove XML block, got %q", got)
}
if !strings.Contains(got, "Let me run that.") {
t.Errorf("should keep text before, got %q", got)
}
if !strings.Contains(got, "Done.") {
t.Errorf("should keep text after, got %q", got)
}
}
func TestStripXMLToolCalls_NoXML(t *testing.T) {
text := "Just regular text."
got := stripXMLToolCalls(text)
if got != text {
t.Errorf("stripXMLToolCalls() = %q, want %q", got, text)
}
}

View file

@ -27,8 +27,14 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
if err != nil {
return nil, err
}
// Strip provider-specific XML tool call artifacts (e.g. minimax)
// that leak into Content alongside structured tool_calls.
// If provider returned no structured tool_calls but Content has XML
// tool call blocks (e.g. minimax), parse them as a fallback.
if len(resp.ToolCalls) == 0 {
if xmlCalls := extractXMLToolCalls(resp.Content); len(xmlCalls) > 0 {
resp.ToolCalls = xmlCalls
}
}
// Strip XML tool call artifacts from Content regardless.
resp.Content = stripXMLToolCalls(resp.Content)
return resp, nil
}

View file

@ -2,6 +2,7 @@ package providers
import (
"encoding/json"
"fmt"
"strings"
)
@ -56,6 +57,122 @@ func extractToolCallsFromText(text string) []ToolCall {
return result
}
// extractXMLToolCalls parses XML tool call blocks (e.g. <minimax:toolcall>)
// into structured ToolCall objects. Used as a fallback when the provider returns
// tool calls as XML in Content but not in the structured tool_calls field.
//
// Expected format:
//
// <vendor:toolcall>
// <invoke name="tool_name">
// <parameter name="param">value</parameter>
// </invoke>
// </vendor:toolcall>
func extractXMLToolCalls(text string) []ToolCall {
var result []ToolCall
remaining := text
callIdx := 0
for {
// Find next :toolcall> block
idx := strings.Index(remaining, ":toolcall>")
if idx == -1 {
break
}
tagStart := strings.LastIndex(remaining[:idx], "<")
if tagStart == -1 {
break
}
ns := remaining[tagStart+1 : idx]
closeTag := "</" + ns + ":toolcall>"
closeIdx := strings.Index(remaining, closeTag)
if closeIdx == -1 {
break
}
block := remaining[idx+len(":toolcall>") : closeIdx]
remaining = remaining[closeIdx+len(closeTag):]
// Parse <invoke> elements within the block
invokeRemaining := block
for {
invokeStart := strings.Index(invokeRemaining, "<invoke")
if invokeStart == -1 {
break
}
invokeEnd := strings.Index(invokeRemaining[invokeStart:], "</invoke>")
if invokeEnd == -1 {
break
}
invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("</invoke>")]
invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len("</invoke>"):]
// Extract tool name from <invoke name="...">
nameStart := strings.Index(invokeBody, `name="`)
if nameStart == -1 {
continue
}
nameStart += len(`name="`)
nameEnd := strings.Index(invokeBody[nameStart:], `"`)
if nameEnd == -1 {
continue
}
toolName := invokeBody[nameStart : nameStart+nameEnd]
// Extract parameters
args := make(map[string]interface{})
paramRemaining := invokeBody
for {
pStart := strings.Index(paramRemaining, "<parameter")
if pStart == -1 {
break
}
pNameStart := strings.Index(paramRemaining[pStart:], `name="`)
if pNameStart == -1 {
break
}
pNameStart += pStart + len(`name="`)
pNameEnd := strings.Index(paramRemaining[pNameStart:], `"`)
if pNameEnd == -1 {
break
}
paramName := paramRemaining[pNameStart : pNameStart+pNameEnd]
// Find closing > of the <parameter ...> tag
tagClose := strings.Index(paramRemaining[pNameStart:], ">")
if tagClose == -1 {
break
}
valueStart := pNameStart + tagClose + 1
valueEnd := strings.Index(paramRemaining[valueStart:], "</parameter>")
if valueEnd == -1 {
break
}
paramValue := paramRemaining[valueStart : valueStart+valueEnd]
args[paramName] = paramValue
paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
}
// Build Arguments JSON string
argsJSON, _ := json.Marshal(args)
callIdx++
result = append(result, ToolCall{
ID: fmt.Sprintf("xmltc_%d", callIdx),
Type: "function",
Name: toolName,
Arguments: args,
Function: &FunctionCall{
Name: toolName,
Arguments: string(argsJSON),
},
})
}
}
return result
}
// stripXMLToolCalls removes XML tool call blocks (e.g. <minimax:toolcall>...</minimax:toolcall>)
// from response text. Some providers embed raw XML tool calls in Content alongside
// structured tool_calls; this prevents them from leaking to users.