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)
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ func tagSuffix(tag string) string {
|
||||||
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,9 +211,16 @@ 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
|
return result
|
||||||
invokeRemaining := block
|
}
|
||||||
|
|
||||||
|
// parseInvokeElements extracts ToolCall entries from <invoke>...</invoke> blocks.
|
||||||
|
func parseInvokeElements(text string, callIdx *int) []ToolCall {
|
||||||
|
var result []ToolCall
|
||||||
|
invokeRemaining := text
|
||||||
for {
|
for {
|
||||||
invokeStart := strings.Index(invokeRemaining, "<invoke")
|
invokeStart := strings.Index(invokeRemaining, "<invoke")
|
||||||
if invokeStart == -1 {
|
if invokeStart == -1 {
|
||||||
|
|
@ -248,9 +279,9 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
Type: "function",
|
Type: "function",
|
||||||
Name: toolName,
|
Name: toolName,
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
|
|
@ -260,8 +291,6 @@ func extractXMLToolCalls(text string) []ToolCall {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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:]
|
cleaned := text[:blockStart] + text[blockEnd:]
|
||||||
// Recursively strip remaining blocks
|
|
||||||
if _, _, _, more := findToolCallBlock(cleaned); more {
|
if _, _, _, more := findToolCallBlock(cleaned); more {
|
||||||
cleaned = stripXMLToolCalls(cleaned)
|
cleaned = stripXMLToolCalls(cleaned)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripToolCallsFromText removes tool call JSON from response text.
|
// stripToolCallsFromText removes tool call JSON from response text.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue