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

@ -138,6 +138,7 @@ func tagSuffix(tag string) string {
var (
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,9 +211,16 @@ func extractXMLToolCalls(text string) []ToolCall {
break
}
remaining = remaining[blockEnd:]
result = append(result, parseInvokeElements(block, &callIdx)...)
}
// Parse <invoke> elements within the block
invokeRemaining := block
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 {
@ -248,9 +279,9 @@ func extractXMLToolCalls(text string) []ToolCall {
}
argsJSON, _ := json.Marshal(args)
callIdx++
*callIdx++
result = append(result, ToolCall{
ID: fmt.Sprintf("xmltc_%d", callIdx),
ID: fmt.Sprintf("xmltc_%d", *callIdx),
Type: "function",
Name: toolName,
Arguments: args,
@ -260,8 +291,6 @@ func extractXMLToolCalls(text string) []ToolCall {
},
})
}
}
return result
}
@ -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:]
// 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.