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:
parent
c8d79c2355
commit
0e8d31c0f6
3 changed files with 211 additions and 2 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,14 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Strip provider-specific XML tool call artifacts (e.g. minimax)
|
// If provider returned no structured tool_calls but Content has XML
|
||||||
// that leak into Content alongside structured tool_calls.
|
// 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)
|
resp.Content = stripXMLToolCalls(resp.Content)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,6 +57,122 @@ func extractToolCallsFromText(text string) []ToolCall {
|
||||||
return result
|
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>)
|
// 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
|
// from response text. Some providers embed raw XML tool calls in Content alongside
|
||||||
// structured tool_calls; this prevents them from leaking to users.
|
// structured tool_calls; this prevents them from leaking to users.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue