fix: recover tool call blocks from orphaned closing tags
Add fallback path in findToolCallBlock: when no opening+closing pair is found, scan for orphaned </ns:tool_call> closing tags and reconstruct the block start from the preceding <invoke element. This handles LLMs (e.g. MiniMax) that emit the closing tag without a matching opener. Remove bare-invoke regex fallback (reBareBracketMarker, reBareInvoke, reBareCloseTag) since the closing-tag reconstruction supersedes it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9a154266cb
commit
23351852d0
2 changed files with 139 additions and 83 deletions
|
|
@ -1161,6 +1161,33 @@ Finished.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractXMLToolCalls_OrphanedClosingTag(t *testing.T) {
|
||||||
|
// LLM emits [TOOLCALL] marker + <invoke> with orphaned closing tag (no opening tag)
|
||||||
|
text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user/workspace</parameter>\n</invoke>\n</minimax:tool_call>"
|
||||||
|
|
||||||
|
calls := extractXMLToolCalls(text)
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool call, got %d", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].Name != "listdir" {
|
||||||
|
t.Errorf("Name = %q, want %q", calls[0].Name, "listdir")
|
||||||
|
}
|
||||||
|
if calls[0].Arguments["path"] != "/home/user/workspace" {
|
||||||
|
t.Errorf("Arguments[path] = %v, want /home/user/workspace", calls[0].Arguments["path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripXMLToolCalls_OrphanedClosingTag(t *testing.T) {
|
||||||
|
text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user</parameter>\n</invoke>\n</minimax:tool_call>"
|
||||||
|
got := stripXMLToolCalls(text)
|
||||||
|
if strings.Contains(got, "invoke") || strings.Contains(got, "TOOLCALL") || strings.Contains(got, "minimax") {
|
||||||
|
t.Errorf("should remove orphaned closing tag block, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "了解") {
|
||||||
|
t.Errorf("should keep user-facing text, 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)
|
||||||
|
|
|
||||||
|
|
@ -136,8 +136,9 @@ func tagSuffix(tag string) string {
|
||||||
// Uses greedy (longest) match for the closing tag to capture the full block.
|
// Uses greedy (longest) match for the closing tag to capture the full block.
|
||||||
|
|
||||||
var (
|
var (
|
||||||
reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`)
|
reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`)
|
||||||
reCloseTag = regexp.MustCompile(`</([a-zA-Z][\w:.-]*)>`)
|
reCloseTag = regexp.MustCompile(`</([a-zA-Z][\w:.-]*)>`)
|
||||||
|
reBracketMarker = regexp.MustCompile(`\[TOOLCALL\]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// findToolCallBlock finds the first XML block whose tag suffix matches
|
// findToolCallBlock finds the first XML block whose tag suffix matches
|
||||||
|
|
@ -159,6 +160,29 @@ func findToolCallBlock(text string) (blockStart, blockEnd int, content string, f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: look for orphaned closing tags (missing opening tag).
|
||||||
|
// Some LLMs emit the closing </ns:tool_call> without a matching opener.
|
||||||
|
// Reconstruct the block start from the first <invoke preceding the closer.
|
||||||
|
for _, cm := range reCloseTag.FindAllStringSubmatchIndex(text, -1) {
|
||||||
|
closeTagName := text[cm[2]:cm[3]]
|
||||||
|
if !isToolCallTag(tagSuffix(closeTagName)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Found an orphaned toolcall closing tag. Scan backwards for <invoke.
|
||||||
|
before := text[:cm[0]]
|
||||||
|
invokePos := strings.LastIndex(before, "<invoke")
|
||||||
|
if invokePos == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Also consume a preceding [TOOLCALL] marker if present.
|
||||||
|
start := invokePos
|
||||||
|
if loc := reBracketMarker.FindStringIndex(before[:start]); loc != nil && strings.TrimSpace(before[loc[1]:start]) == "" {
|
||||||
|
start = loc[0]
|
||||||
|
}
|
||||||
|
return start, cm[1], text[invokePos:cm[0]], true
|
||||||
|
}
|
||||||
|
|
||||||
return 0, 0, "", false
|
return 0, 0, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,84 +211,89 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
remaining = remaining[blockEnd:]
|
remaining = remaining[blockEnd:]
|
||||||
|
result = append(result, parseInvokeElements(block, &callIdx)...)
|
||||||
// Parse <invoke> elements within the block
|
|
||||||
invokeRemaining := block
|
|
||||||
for {
|
|
||||||
invokeStart := strings.Index(invokeRemaining, "<invoke")
|
|
||||||
if invokeStart == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
invokeEnd := strings.Index(invokeRemaining[invokeStart:], "</invoke>")
|
|
||||||
if invokeEnd == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("</invoke>")]
|
|
||||||
invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len("</invoke>"):]
|
|
||||||
|
|
||||||
// Extract tool name from <invoke name="...">
|
|
||||||
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, "<parameter")
|
|
||||||
if pStart == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pNameStart := strings.Index(paramRemaining[pStart:], `name="`)
|
|
||||||
if pNameStart == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pNameStart += pStart + len(`name="`)
|
|
||||||
pNameEnd := strings.Index(paramRemaining[pNameStart:], `"`)
|
|
||||||
if pNameEnd == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
paramName := paramRemaining[pNameStart : pNameStart+pNameEnd]
|
|
||||||
|
|
||||||
tagClose := strings.Index(paramRemaining[pNameStart:], ">")
|
|
||||||
if tagClose == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
valueStart := pNameStart + tagClose + 1
|
|
||||||
valueEnd := strings.Index(paramRemaining[valueStart:], "</parameter>")
|
|
||||||
if valueEnd == -1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
paramValue := paramRemaining[valueStart : valueStart+valueEnd]
|
|
||||||
args[paramName] = paramValue
|
|
||||||
paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseInvokeElements extracts ToolCall entries from <invoke>...</invoke> blocks.
|
||||||
|
func parseInvokeElements(text string, callIdx *int) []ToolCall {
|
||||||
|
var result []ToolCall
|
||||||
|
invokeRemaining := text
|
||||||
|
for {
|
||||||
|
invokeStart := strings.Index(invokeRemaining, "<invoke")
|
||||||
|
if invokeStart == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
invokeEnd := strings.Index(invokeRemaining[invokeStart:], "</invoke>")
|
||||||
|
if invokeEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("</invoke>")]
|
||||||
|
invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len("</invoke>"):]
|
||||||
|
|
||||||
|
// Extract tool name from <invoke name="...">
|
||||||
|
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, "<parameter")
|
||||||
|
if pStart == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pNameStart := strings.Index(paramRemaining[pStart:], `name="`)
|
||||||
|
if pNameStart == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pNameStart += pStart + len(`name="`)
|
||||||
|
pNameEnd := strings.Index(paramRemaining[pNameStart:], `"`)
|
||||||
|
if pNameEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
paramName := paramRemaining[pNameStart : pNameStart+pNameEnd]
|
||||||
|
|
||||||
|
tagClose := strings.Index(paramRemaining[pNameStart:], ">")
|
||||||
|
if tagClose == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
valueStart := pNameStart + tagClose + 1
|
||||||
|
valueEnd := strings.Index(paramRemaining[valueStart:], "</parameter>")
|
||||||
|
if valueEnd == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
paramValue := paramRemaining[valueStart : valueStart+valueEnd]
|
||||||
|
args[paramName] = paramValue
|
||||||
|
paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 is the exported version for use by the agent loop.
|
// StripXMLToolCalls is the exported version for use by the agent loop.
|
||||||
func StripXMLToolCalls(text string) string {
|
func StripXMLToolCalls(text string) string {
|
||||||
return stripXMLToolCalls(text)
|
return stripXMLToolCalls(text)
|
||||||
|
|
@ -274,15 +303,15 @@ func StripXMLToolCalls(text string) string {
|
||||||
// Prevents raw XML tool calls from leaking to users.
|
// Prevents raw XML tool calls from leaking to users.
|
||||||
func stripXMLToolCalls(text string) string {
|
func stripXMLToolCalls(text string) string {
|
||||||
blockStart, blockEnd, _, found := findToolCallBlock(text)
|
blockStart, blockEnd, _, found := findToolCallBlock(text)
|
||||||
if !found {
|
if found {
|
||||||
return text
|
cleaned := text[:blockStart] + text[blockEnd:]
|
||||||
|
if _, _, _, more := findToolCallBlock(cleaned); more {
|
||||||
|
cleaned = stripXMLToolCalls(cleaned)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
cleaned := text[:blockStart] + text[blockEnd:]
|
|
||||||
// Recursively strip remaining blocks
|
return strings.TrimSpace(text)
|
||||||
if _, _, _, more := findToolCallBlock(cleaned); more {
|
|
||||||
cleaned = stripXMLToolCalls(cleaned)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(cleaned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripToolCallsFromText removes tool call JSON from response text.
|
// stripToolCallsFromText removes tool call JSON from response text.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue