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 <noreply@anthropic.com>
This commit is contained in:
parent
62cb5c1d15
commit
b8a8f953e3
2 changed files with 211 additions and 36 deletions
|
|
@ -1095,6 +1095,64 @@ func TestStripXMLToolCalls_MismatchedCloseTag(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractXMLToolCalls_UnderscoreOpenTag(t *testing.T) {
|
||||||
|
// Opening tag also uses underscore: <minimax:tool_call>
|
||||||
|
text := `<minimax:tool_call>
|
||||||
|
<invoke name="exec">
|
||||||
|
<parameter name="command">ls -la</parameter>
|
||||||
|
</invoke>
|
||||||
|
</minimax:tool_call>`
|
||||||
|
|
||||||
|
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: <vendor:Tool-Call>
|
||||||
|
text := `<vendor:Tool-Call>
|
||||||
|
<invoke name="read_file">
|
||||||
|
<parameter name="path">/etc/hosts</parameter>
|
||||||
|
</invoke>
|
||||||
|
</vendor:tool-call>`
|
||||||
|
|
||||||
|
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.
|
||||||
|
<minimax:tool_call>
|
||||||
|
<invoke name="exec">
|
||||||
|
<parameter name="command">ls</parameter>
|
||||||
|
</invoke>
|
||||||
|
</minimax:toolcall>
|
||||||
|
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) {
|
func TestStripXMLToolCalls_NoXML(t *testing.T) {
|
||||||
text := "Just regular text."
|
text := "Just regular text."
|
||||||
got := stripXMLToolCalls(text)
|
got := stripXMLToolCalls(text)
|
||||||
|
|
@ -1102,3 +1160,40 @@ func TestStripXMLToolCalls_NoXML(t *testing.T) {
|
||||||
t.Errorf("stripXMLToolCalls() = %q, want %q", got, text)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,35 +68,126 @@ func extractToolCallsFromText(text string) []ToolCall {
|
||||||
// <parameter name="param">value</parameter>
|
// <parameter name="param">value</parameter>
|
||||||
// </invoke>
|
// </invoke>
|
||||||
// </ns:toolcall>
|
// </ns:toolcall>
|
||||||
|
// 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 <ns:name> 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 </ns: patterns and check if the tag name normalizes to "toolcall"
|
||||||
|
prefix := "</" + ns + ":"
|
||||||
|
search := text
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
i := strings.Index(search, prefix)
|
||||||
|
if i == -1 {
|
||||||
|
return -1, 0
|
||||||
|
}
|
||||||
|
afterPrefix := search[i+len(prefix):]
|
||||||
|
end := strings.Index(afterPrefix, ">")
|
||||||
|
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 {
|
func extractXMLToolCalls(text string) []ToolCall {
|
||||||
var result []ToolCall
|
var result []ToolCall
|
||||||
remaining := text
|
remaining := text
|
||||||
callIdx := 0
|
callIdx := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Find next :toolcall> block
|
// Find next opening toolcall tag using normalized matching
|
||||||
idx := strings.Index(remaining, ":toolcall>")
|
openIdx, ns, openLen := findToolCallOpenTag(remaining)
|
||||||
if idx == -1 {
|
if openIdx == -1 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
tagStart := strings.LastIndex(remaining[:idx], "<")
|
afterOpen := remaining[openIdx+openLen:]
|
||||||
if tagStart == -1 {
|
closeIdx, closeLen := findToolCallCloseTag(afterOpen, ns)
|
||||||
break
|
|
||||||
}
|
|
||||||
ns := remaining[tagStart+1 : idx]
|
|
||||||
closeTag := "</" + ns + ":toolcall>"
|
|
||||||
closeIdx := strings.Index(remaining, closeTag)
|
|
||||||
// Fallback: some models use inconsistent close tags (e.g. tool_call vs toolcall)
|
|
||||||
if closeIdx == -1 {
|
|
||||||
closeTag = "</" + ns + ":tool_call>"
|
|
||||||
closeIdx = strings.Index(remaining, closeTag)
|
|
||||||
}
|
|
||||||
if closeIdx == -1 {
|
if closeIdx == -1 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
block := remaining[idx+len(":toolcall>") : closeIdx]
|
block := afterOpen[:closeIdx]
|
||||||
remaining = remaining[closeIdx+len(closeTag):]
|
remaining = afterOpen[closeIdx+closeLen:]
|
||||||
|
|
||||||
// Parse <invoke> elements within the block
|
// Parse <invoke> elements within the block
|
||||||
invokeRemaining := block
|
invokeRemaining := block
|
||||||
|
|
@ -181,32 +272,21 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
// stripXMLToolCalls removes XML tool call blocks (e.g. <ns:toolcall>...</ns:toolcall>)
|
// stripXMLToolCalls removes XML tool call blocks (e.g. <ns:toolcall>...</ns: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.
|
||||||
|
// Uses normalized tag matching so that <ns:toolcall>, <ns:tool_call>, <ns:Tool-Call>
|
||||||
|
// etc. are all recognized and stripped.
|
||||||
func stripXMLToolCalls(text string) string {
|
func stripXMLToolCalls(text string) string {
|
||||||
// Match <vendor:toolcall>...</vendor:toolcall> blocks (any namespace prefix)
|
openIdx, ns, openLen := findToolCallOpenTag(text)
|
||||||
idx := strings.Index(text, ":toolcall>")
|
if openIdx == -1 {
|
||||||
if idx == -1 {
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
// Find the opening tag start: scan backwards for '<'
|
afterOpen := text[openIdx+openLen:]
|
||||||
tagStart := strings.LastIndex(text[:idx], "<")
|
closeIdx, closeLen := findToolCallCloseTag(afterOpen, ns)
|
||||||
if tagStart == -1 {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
// Extract namespace (e.g. "ns" from "<ns:toolcall>")
|
|
||||||
ns := text[tagStart+1 : idx]
|
|
||||||
closeTag := "</" + ns + ":toolcall>"
|
|
||||||
closeIdx := strings.Index(text, closeTag)
|
|
||||||
// Fallback: some models use inconsistent close tags (e.g. tool_call vs toolcall)
|
|
||||||
if closeIdx == -1 {
|
|
||||||
closeTag = "</" + ns + ":tool_call>"
|
|
||||||
closeIdx = strings.Index(text, closeTag)
|
|
||||||
}
|
|
||||||
if closeIdx == -1 {
|
if closeIdx == -1 {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
cleaned := text[:tagStart] + text[closeIdx+len(closeTag):]
|
cleaned := text[:openIdx] + afterOpen[closeIdx+closeLen:]
|
||||||
// Recursively strip if there are more blocks
|
// Recursively strip if there are more blocks
|
||||||
if strings.Contains(cleaned, ":toolcall>") {
|
if openIdx2, _, _ := findToolCallOpenTag(cleaned); openIdx2 != -1 {
|
||||||
cleaned = stripXMLToolCalls(cleaned)
|
cleaned = stripXMLToolCalls(cleaned)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue