refactor: unify normalization to alpha-only, simplify XML block detection
- normalizeAlpha / NormalizeToolName: keep only lowercase a-z - Replace findToolCallOpenTag + findToolCallCloseTag with single findToolCallBlock using regex + greedy close tag matching - Both XML extraction and stripping use the same findToolCallBlock - Edit distance still used for fuzzy "toolcall" tag identification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ee15656e6c
commit
9c4968e2cc
3 changed files with 130 additions and 110 deletions
|
|
@ -1161,6 +1161,27 @@ func TestStripXMLToolCalls_NoXML(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAlpha(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input, want string
|
||||||
|
}{
|
||||||
|
{"toolcall", "toolcall"},
|
||||||
|
{"tool_call", "toolcall"},
|
||||||
|
{"Tool-Call", "toolcall"},
|
||||||
|
{"ReadFile", "readfile"},
|
||||||
|
{"read_file", "readfile"},
|
||||||
|
{"EXEC", "exec"},
|
||||||
|
{"web123search", "websearch"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := normalizeAlpha(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("normalizeAlpha(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLevenshtein(t *testing.T) {
|
func TestLevenshtein(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
a, b string
|
a, b string
|
||||||
|
|
@ -1184,14 +1205,26 @@ func TestLevenshtein(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsToolCallTag(t *testing.T) {
|
func TestIsToolCallTag(t *testing.T) {
|
||||||
// Should match
|
// Should match — toolcall variants
|
||||||
for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} {
|
for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} {
|
||||||
if !isToolCallTag(name) {
|
if !isToolCallTag(name) {
|
||||||
t.Errorf("isToolCallTag(%q) = false, want true", name)
|
t.Errorf("isToolCallTag(%q) = false, want true", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Should match — function_call variants
|
||||||
|
for _, name := range []string{"function_call", "FunctionCall", "functioncall", "FUNCTION_CALL"} {
|
||||||
|
if !isToolCallTag(name) {
|
||||||
|
t.Errorf("isToolCallTag(%q) = false, want true", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Should match — tool_use variants
|
||||||
|
for _, name := range []string{"tool_use", "ToolUse", "tooluse", "TOOL_USE"} {
|
||||||
|
if !isToolCallTag(name) {
|
||||||
|
t.Errorf("isToolCallTag(%q) = false, want true", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Should NOT match
|
// Should NOT match
|
||||||
for _, name := range []string{"invoke", "parameter", "function", "result", "hello"} {
|
for _, name := range []string{"invoke", "parameter", "function", "result", "hello", "content"} {
|
||||||
if isToolCallTag(name) {
|
if isToolCallTag(name) {
|
||||||
t.Errorf("isToolCallTag(%q) = true, want false", name)
|
t.Errorf("isToolCallTag(%q) = true, want false", name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package providers
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -57,17 +58,22 @@ func extractToolCallsFromText(text string) []ToolCall {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractXMLToolCalls parses XML tool call blocks (e.g. <ns:toolcall>)
|
// --- Shared helpers for XML tool call extraction ---
|
||||||
// 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.
|
// normalizeAlpha keeps only lowercase ASCII letters.
|
||||||
//
|
// "tool_call" → "toolcall", "Tool-Call" → "toolcall", "ReadFile" → "readfile".
|
||||||
// Expected format:
|
func normalizeAlpha(s string) string {
|
||||||
//
|
var b strings.Builder
|
||||||
// <ns:toolcall>
|
for _, r := range s {
|
||||||
// <invoke name="tool_name">
|
if r >= 'A' && r <= 'Z' {
|
||||||
// <parameter name="param">value</parameter>
|
b.WriteRune(r + 32)
|
||||||
// </invoke>
|
} else if r >= 'a' && r <= 'z' {
|
||||||
// </ns:toolcall>
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// levenshtein computes the edit distance between two strings.
|
// levenshtein computes the edit distance between two strings.
|
||||||
// O(n*m) where n,m are string lengths — negligible for short tag names.
|
// O(n*m) where n,m are string lengths — negligible for short tag names.
|
||||||
func levenshtein(a, b string) int {
|
func levenshtein(a, b string) int {
|
||||||
|
|
@ -97,97 +103,85 @@ func levenshtein(a, b string) int {
|
||||||
return prev[lb]
|
return prev[lb]
|
||||||
}
|
}
|
||||||
|
|
||||||
// isToolCallTag returns true if name is close enough to "toolcall" by edit
|
// Known tool call tag patterns (already alpha-normalized).
|
||||||
// distance (threshold ≤ 2). Case-insensitive. Catches variants like
|
// Providers may use different names: tool_call, function_call, tool_use, etc.
|
||||||
// "tool_call", "Tool-Call", "toolCall", "ToolCall", etc.
|
var toolCallPatterns = []string{"toolcall", "functioncall", "tooluse"}
|
||||||
|
|
||||||
|
// isToolCallTag returns true if the tag name is close to any known tool call
|
||||||
|
// pattern after alpha normalization + edit distance (threshold ≤ 2).
|
||||||
func isToolCallTag(name string) bool {
|
func isToolCallTag(name string) bool {
|
||||||
const threshold = 2
|
const threshold = 2
|
||||||
return levenshtein(strings.ToLower(name), "toolcall") <= threshold
|
norm := normalizeAlpha(name)
|
||||||
|
for _, pat := range toolCallPatterns {
|
||||||
|
if levenshtein(norm, pat) <= threshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// findToolCallOpenTag finds the next opening toolcall tag using fuzzy
|
// tagSuffix returns the part after the last ':' (namespace separator),
|
||||||
// matching (edit distance). Scans for <ns:name> patterns where name is
|
// or the whole string if there is no ':'.
|
||||||
// close to "toolcall". Returns the index of '<', the namespace, and the
|
func tagSuffix(tag string) string {
|
||||||
// full tag length, or idx=-1 if not found.
|
if i := strings.LastIndex(tag, ":"); i >= 0 {
|
||||||
func findToolCallOpenTag(text string) (idx int, ns string, tagLen int) {
|
return tag[i+1:]
|
||||||
search := text
|
}
|
||||||
offset := 0
|
return tag
|
||||||
for {
|
}
|
||||||
lt := strings.Index(search, "<")
|
|
||||||
if lt == -1 {
|
// --- XML block detection via regex ---
|
||||||
return -1, "", 0
|
//
|
||||||
}
|
// Strategy: find <TAG>…</TAG> pairs using regex, then check if the tag
|
||||||
// Skip closing tags and comments
|
// suffix normalizes to something close to "toolcall" (edit distance ≤ 2).
|
||||||
if lt+1 < len(search) && (search[lt+1] == '/' || search[lt+1] == '!') {
|
// Uses greedy (longest) match for the closing tag to capture the full block.
|
||||||
offset += lt + 2
|
|
||||||
search = search[lt+2:]
|
var (
|
||||||
|
reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`)
|
||||||
|
reCloseTag = regexp.MustCompile(`</([a-zA-Z][\w:.-]*)>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// findToolCallBlock finds the first XML block whose tag suffix matches
|
||||||
|
// "toolcall" by edit distance. Returns the block boundaries and the inner
|
||||||
|
// content, or found=false.
|
||||||
|
func findToolCallBlock(text string) (blockStart, blockEnd int, content string, found bool) {
|
||||||
|
for _, om := range reOpenTag.FindAllStringSubmatchIndex(text, -1) {
|
||||||
|
tagName := text[om[2]:om[3]]
|
||||||
|
if !isToolCallTag(tagSuffix(tagName)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
gt := strings.Index(search[lt:], ">")
|
// Found a toolcall opening tag. Search for the last matching close tag (greedy).
|
||||||
if gt == -1 {
|
afterOpen := text[om[1]:]
|
||||||
return -1, "", 0
|
closes := reCloseTag.FindAllStringSubmatchIndex(afterOpen, -1)
|
||||||
}
|
for i := len(closes) - 1; i >= 0; i-- {
|
||||||
tagContent := search[lt+1 : lt+gt] // e.g. "minimax:tool_call"
|
closeTagName := afterOpen[closes[i][2]:closes[i][3]]
|
||||||
colon := strings.Index(tagContent, ":")
|
if isToolCallTag(tagSuffix(closeTagName)) {
|
||||||
if colon != -1 {
|
return om[0], om[1] + closes[i][1], afterOpen[:closes[i][0]], true
|
||||||
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):]
|
|
||||||
}
|
}
|
||||||
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- XML tool call extraction ---
|
||||||
|
//
|
||||||
|
// Expected format:
|
||||||
|
//
|
||||||
|
// <ns:toolcall>
|
||||||
|
// <invoke name="tool_name">
|
||||||
|
// <parameter name="param">value</parameter>
|
||||||
|
// </invoke>
|
||||||
|
// </ns:toolcall>
|
||||||
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 opening toolcall tag using normalized matching
|
_, blockEnd, block, found := findToolCallBlock(remaining)
|
||||||
openIdx, ns, openLen := findToolCallOpenTag(remaining)
|
if !found {
|
||||||
if openIdx == -1 {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
afterOpen := remaining[openIdx+openLen:]
|
remaining = remaining[blockEnd:]
|
||||||
closeIdx, closeLen := findToolCallCloseTag(afterOpen, ns)
|
|
||||||
if closeIdx == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
block := afterOpen[:closeIdx]
|
|
||||||
remaining = afterOpen[closeIdx+closeLen:]
|
|
||||||
|
|
||||||
// Parse <invoke> elements within the block
|
// Parse <invoke> elements within the block
|
||||||
invokeRemaining := block
|
invokeRemaining := block
|
||||||
|
|
@ -234,7 +228,6 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
}
|
}
|
||||||
paramName := paramRemaining[pNameStart : pNameStart+pNameEnd]
|
paramName := paramRemaining[pNameStart : pNameStart+pNameEnd]
|
||||||
|
|
||||||
// Find closing > of the <parameter ...> tag
|
|
||||||
tagClose := strings.Index(paramRemaining[pNameStart:], ">")
|
tagClose := strings.Index(paramRemaining[pNameStart:], ">")
|
||||||
if tagClose == -1 {
|
if tagClose == -1 {
|
||||||
break
|
break
|
||||||
|
|
@ -249,9 +242,7 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
|
paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build Arguments JSON string
|
|
||||||
argsJSON, _ := json.Marshal(args)
|
argsJSON, _ := json.Marshal(args)
|
||||||
|
|
||||||
callIdx++
|
callIdx++
|
||||||
result = append(result, ToolCall{
|
result = append(result, ToolCall{
|
||||||
ID: fmt.Sprintf("xmltc_%d", callIdx),
|
ID: fmt.Sprintf("xmltc_%d", callIdx),
|
||||||
|
|
@ -269,24 +260,16 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripXMLToolCalls removes XML tool call blocks (e.g. <ns:toolcall>...</ns:toolcall>)
|
// stripXMLToolCalls removes XML tool call blocks from response text.
|
||||||
// from response text. Some providers embed raw XML tool calls in Content alongside
|
// Prevents raw XML tool calls 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 {
|
||||||
openIdx, ns, openLen := findToolCallOpenTag(text)
|
blockStart, blockEnd, _, found := findToolCallBlock(text)
|
||||||
if openIdx == -1 {
|
if !found {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
afterOpen := text[openIdx+openLen:]
|
cleaned := text[:blockStart] + text[blockEnd:]
|
||||||
closeIdx, closeLen := findToolCallCloseTag(afterOpen, ns)
|
// Recursively strip remaining blocks
|
||||||
if closeIdx == -1 {
|
if _, _, _, more := findToolCallBlock(cleaned); more {
|
||||||
return text
|
|
||||||
}
|
|
||||||
cleaned := text[:openIdx] + afterOpen[closeIdx+closeLen:]
|
|
||||||
// Recursively strip if there are more blocks
|
|
||||||
if openIdx2, _, _ := findToolCallOpenTag(cleaned); openIdx2 != -1 {
|
|
||||||
cleaned = stripXMLToolCalls(cleaned)
|
cleaned = stripXMLToolCalls(cleaned)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,18 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NormalizeToolName strips underscores and hyphens and lowercases for
|
// NormalizeToolName keeps only lowercase ASCII letters.
|
||||||
// fuzzy tool name matching. LLMs sometimes call "readfile" instead of
|
// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile".
|
||||||
// "read_file", etc.
|
|
||||||
func NormalizeToolName(s string) string {
|
func NormalizeToolName(s string) string {
|
||||||
s = strings.ToLower(s)
|
var b strings.Builder
|
||||||
s = strings.ReplaceAll(s, "_", "")
|
for _, r := range s {
|
||||||
s = strings.ReplaceAll(s, "-", "")
|
if r >= 'A' && r <= 'Z' {
|
||||||
return s
|
b.WriteRune(r + 32)
|
||||||
|
} else if r >= 'a' && r <= 'z' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolRegistry struct {
|
type ToolRegistry struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue