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:
dj-oyu 2026-02-22 09:36:10 +09:00
parent d1d09d84c3
commit 3d573cb2be
2 changed files with 139 additions and 83 deletions

View file

@ -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) {
text := "Just regular text."
got := stripXMLToolCalls(text)

View file

@ -136,8 +136,9 @@ func tagSuffix(tag string) string {
// Uses greedy (longest) match for the closing tag to capture the full block.
var (
reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`)
reCloseTag = regexp.MustCompile(`</([a-zA-Z][\w:.-]*)>`)
reOpenTag = 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
@ -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
}
@ -187,84 +211,89 @@ func extractXMLToolCalls(text string) []ToolCall {
break
}
remaining = remaining[blockEnd:]
// 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),
},
})
}
result = append(result, parseInvokeElements(block, &callIdx)...)
}
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.
func StripXMLToolCalls(text string) string {
return stripXMLToolCalls(text)
@ -274,15 +303,15 @@ func StripXMLToolCalls(text string) string {
// Prevents raw XML tool calls from leaking to users.
func stripXMLToolCalls(text string) string {
blockStart, blockEnd, _, found := findToolCallBlock(text)
if !found {
return text
if found {
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
if _, _, _, more := findToolCallBlock(cleaned); more {
cleaned = stripXMLToolCalls(cleaned)
}
return strings.TrimSpace(cleaned)
return strings.TrimSpace(text)
}
// stripToolCallsFromText removes tool call JSON from response text.