From 0b36150cd32e7686fddf8787cb950600368dc633 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:06:05 +0900 Subject: [PATCH] feat(providers): parse XML tool calls as fallback for minimax When the provider returns no structured tool_calls but Content contains XML blocks like , 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 --- pkg/providers/claude_cli_provider_test.go | 86 ++++++++++++++++ pkg/providers/http_provider.go | 10 +- pkg/providers/tool_call_extract.go | 117 ++++++++++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 063530deb..874b22bee 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -979,3 +979,89 @@ func TestFindMatchingBrace(t *testing.T) { } } } + +// --- XML tool call extract/strip tests --- + +func TestExtractXMLToolCalls_Single(t *testing.T) { + text := ` + +echo hello + +` + + 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 := ` + +golang testing + + +go test ./... +30 + +` + + 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. + + +echo hello + + +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) + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 6a731ccc7..beca92552 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -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 } diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go index 7436cb908..e08f31fc8 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/tool_call_extract.go @@ -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. ) +// 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: +// +// +// +// value +// +// +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 := "" + closeIdx := strings.Index(remaining, closeTag) + if closeIdx == -1 { + break + } + + block := remaining[idx+len(":toolcall>") : closeIdx] + remaining = remaining[closeIdx+len(closeTag):] + + // Parse elements within the block + invokeRemaining := block + for { + invokeStart := strings.Index(invokeRemaining, "") + if invokeEnd == -1 { + break + } + invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("")] + invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len(""):] + + // Extract tool name from + 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, " of the tag + tagClose := strings.Index(paramRemaining[pNameStart:], ">") + if tagClose == -1 { + break + } + valueStart := pNameStart + tagClose + 1 + valueEnd := strings.Index(paramRemaining[valueStart:], "") + if valueEnd == -1 { + break + } + paramValue := paramRemaining[valueStart : valueStart+valueEnd] + args[paramName] = paramValue + paramRemaining = paramRemaining[valueStart+valueEnd+len(""):] + } + + // 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. ...) // from response text. Some providers embed raw XML tool calls in Content alongside // structured tool_calls; this prevents them from leaking to users.