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 := "" + ns + ":toolcall>"
+ 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.