From b8a8f953e370fe8f2f675f4c33d39ffd4e61b7c7 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:50:01 +0900 Subject: [PATCH] fix: use edit distance for fuzzy XML tool call tag matching Replace hardcoded normalization with Levenshtein distance to handle any spelling variation of toolcall tags (tool_call, Tool-Call, etc.) in both opening and closing positions. Co-Authored-By: Claude Opus 4.6 --- pkg/providers/claude_cli_provider_test.go | 95 ++++++++++++++ pkg/providers/tool_call_extract.go | 152 +++++++++++++++++----- 2 files changed, 211 insertions(+), 36 deletions(-) diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 9f237cb68..e4ee3de38 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -1095,6 +1095,64 @@ func TestStripXMLToolCalls_MismatchedCloseTag(t *testing.T) { } } +func TestExtractXMLToolCalls_UnderscoreOpenTag(t *testing.T) { + // Opening tag also uses underscore: + text := ` + +ls -la + +` + + 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"] != "ls -la" { + t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "ls -la") + } +} + +func TestExtractXMLToolCalls_HyphenTag(t *testing.T) { + // Hypothetical: + text := ` + +/etc/hosts + +` + + calls := extractXMLToolCalls(text) + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Name != "read_file" { + t.Errorf("Name = %q, want %q", calls[0].Name, "read_file") + } +} + +func TestStripXMLToolCalls_UnderscoreOpenTag(t *testing.T) { + text := `Here is the result. + + +ls + + +Finished.` + + got := stripXMLToolCalls(text) + if strings.Contains(got, "tool_call") || strings.Contains(got, "toolcall") { + t.Errorf("should remove XML block, got %q", got) + } + if !strings.Contains(got, "Here is the result.") { + t.Errorf("should keep text before, got %q", got) + } + if !strings.Contains(got, "Finished.") { + t.Errorf("should keep text after, got %q", got) + } +} + func TestStripXMLToolCalls_NoXML(t *testing.T) { text := "Just regular text." got := stripXMLToolCalls(text) @@ -1102,3 +1160,40 @@ func TestStripXMLToolCalls_NoXML(t *testing.T) { t.Errorf("stripXMLToolCalls() = %q, want %q", got, text) } } + +func TestLevenshtein(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"abc", "", 3}, + {"", "abc", 3}, + {"toolcall", "toolcall", 0}, + {"toolcall", "tool_call", 1}, + {"toolcall", "tool-call", 1}, + {"toolcall", "ToolCall", 2}, // T and C + {"kitten", "sitting", 3}, + } + for _, tt := range tests { + got := levenshtein(tt.a, tt.b) + if got != tt.want { + t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestIsToolCallTag(t *testing.T) { + // Should match + for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} { + if !isToolCallTag(name) { + t.Errorf("isToolCallTag(%q) = false, want true", name) + } + } + // Should NOT match + for _, name := range []string{"invoke", "parameter", "function", "result", "hello"} { + if isToolCallTag(name) { + t.Errorf("isToolCallTag(%q) = true, want false", name) + } + } +} diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go index 93740c573..2011da66d 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/tool_call_extract.go @@ -68,35 +68,126 @@ func extractToolCallsFromText(text string) []ToolCall { // value // // +// levenshtein computes the edit distance between two strings. +// O(n*m) where n,m are string lengths — negligible for short tag names. +func levenshtein(a, b string) int { + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr := make([]int, lb+1) + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min(curr[j-1]+1, min(prev[j]+1, prev[j-1]+cost)) + } + prev = curr + } + return prev[lb] +} + +// isToolCallTag returns true if name is close enough to "toolcall" by edit +// distance (threshold ≤ 2). Case-insensitive. Catches variants like +// "tool_call", "Tool-Call", "toolCall", "ToolCall", etc. +func isToolCallTag(name string) bool { + const threshold = 2 + return levenshtein(strings.ToLower(name), "toolcall") <= threshold +} + +// findToolCallOpenTag finds the next opening toolcall tag using fuzzy +// matching (edit distance). Scans for patterns where name is +// close to "toolcall". Returns the index of '<', the namespace, and the +// full tag length, or idx=-1 if not found. +func findToolCallOpenTag(text string) (idx int, ns string, tagLen int) { + search := text + offset := 0 + for { + lt := strings.Index(search, "<") + if lt == -1 { + return -1, "", 0 + } + // Skip closing tags and comments + if lt+1 < len(search) && (search[lt+1] == '/' || search[lt+1] == '!') { + offset += lt + 2 + search = search[lt+2:] + continue + } + gt := strings.Index(search[lt:], ">") + if gt == -1 { + return -1, "", 0 + } + tagContent := search[lt+1 : lt+gt] // e.g. "minimax:tool_call" + colon := strings.Index(tagContent, ":") + if colon != -1 { + nsCandidate := tagContent[:colon] + nameCandidate := tagContent[colon+1:] + if isToolCallTag(nameCandidate) { + fullTag := "<" + tagContent + ">" + return offset + lt, nsCandidate, len(fullTag) + } + } + offset += lt + gt + 1 + search = search[lt+gt+1:] + } +} + +// findToolCallCloseTag finds the close tag for a toolcall block using +// fuzzy matching (edit distance). Returns the index and length of the close tag, or -1. +func findToolCallCloseTag(text, ns string) (idx int, tagLen int) { + // Scan for all ") + if end == -1 { + return -1, 0 + } + tagName := afterPrefix[:end] + if isToolCallTag(tagName) { + fullTag := prefix + tagName + ">" + return offset + i, len(fullTag) + } + offset += i + len(prefix) + search = search[i+len(prefix):] + } +} + 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 { + // Find next opening toolcall tag using normalized matching + openIdx, ns, openLen := findToolCallOpenTag(remaining) + if openIdx == -1 { break } - tagStart := strings.LastIndex(remaining[:idx], "<") - if tagStart == -1 { - break - } - ns := remaining[tagStart+1 : idx] - closeTag := "" - closeIdx := strings.Index(remaining, closeTag) - // Fallback: some models use inconsistent close tags (e.g. tool_call vs toolcall) - if closeIdx == -1 { - closeTag = "" - closeIdx = strings.Index(remaining, closeTag) - } + afterOpen := remaining[openIdx+openLen:] + closeIdx, closeLen := findToolCallCloseTag(afterOpen, ns) if closeIdx == -1 { break } - block := remaining[idx+len(":toolcall>") : closeIdx] - remaining = remaining[closeIdx+len(closeTag):] + block := afterOpen[:closeIdx] + remaining = afterOpen[closeIdx+closeLen:] // Parse elements within the block invokeRemaining := block @@ -181,32 +272,21 @@ func extractXMLToolCalls(text string) []ToolCall { // 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. +// Uses normalized tag matching so that , , +// etc. are all recognized and stripped. func stripXMLToolCalls(text string) string { - // Match ... blocks (any namespace prefix) - idx := strings.Index(text, ":toolcall>") - if idx == -1 { + openIdx, ns, openLen := findToolCallOpenTag(text) + if openIdx == -1 { return text } - // Find the opening tag start: scan backwards for '<' - tagStart := strings.LastIndex(text[:idx], "<") - if tagStart == -1 { - return text - } - // Extract namespace (e.g. "ns" from "") - ns := text[tagStart+1 : idx] - closeTag := "" - closeIdx := strings.Index(text, closeTag) - // Fallback: some models use inconsistent close tags (e.g. tool_call vs toolcall) - if closeIdx == -1 { - closeTag = "" - closeIdx = strings.Index(text, closeTag) - } + afterOpen := text[openIdx+openLen:] + closeIdx, closeLen := findToolCallCloseTag(afterOpen, ns) if closeIdx == -1 { return text } - cleaned := text[:tagStart] + text[closeIdx+len(closeTag):] + cleaned := text[:openIdx] + afterOpen[closeIdx+closeLen:] // Recursively strip if there are more blocks - if strings.Contains(cleaned, ":toolcall>") { + if openIdx2, _, _ := findToolCallOpenTag(cleaned); openIdx2 != -1 { cleaned = stripXMLToolCalls(cleaned) } return strings.TrimSpace(cleaned)