From 3d573cb2be3d4484e1667ef9fd50320e31ced301 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 09:36:10 +0900 Subject: [PATCH] fix: recover tool call blocks from orphaned closing tags Add fallback path in findToolCallBlock: when no opening+closing pair is found, scan for orphaned closing tags and reconstruct the block start from the preceding --- pkg/providers/claude_cli_provider_test.go | 27 +++ pkg/providers/tool_call_extract.go | 195 +++++++++++++--------- 2 files changed, 139 insertions(+), 83 deletions(-) diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 91f9ce429..a89047df6 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -1161,6 +1161,33 @@ Finished.` } } +func TestExtractXMLToolCalls_OrphanedClosingTag(t *testing.T) { + // LLM emits [TOOLCALL] marker + with orphaned closing tag (no opening tag) + text := "了解!確認するね。\n[TOOLCALL]\n\n/home/user/workspace\n\n" + + 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\n/home/user\n\n" + 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) diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go index 9d48f9945..52d68a23b 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/tool_call_extract.go @@ -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(``) + reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`) + reCloseTag = regexp.MustCompile(``) + 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 without a matching opener. + // Reconstruct the block start from the first elements within the block - invokeRemaining := block - for { - invokeStart := strings.Index(invokeRemaining, "") - if invokeEnd == -1 { - break - } - invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("")] - invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len(""):] - - // Extract tool name from - 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, "") - if tagClose == -1 { - break - } - valueStart := pNameStart + tagClose + 1 - valueEnd := strings.Index(paramRemaining[valueStart:], "") - if valueEnd == -1 { - break - } - paramValue := paramRemaining[valueStart : valueStart+valueEnd] - args[paramName] = paramValue - paramRemaining = paramRemaining[valueStart+valueEnd+len(""):] - } - - 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 ... blocks. +func parseInvokeElements(text string, callIdx *int) []ToolCall { + var result []ToolCall + invokeRemaining := text + for { + invokeStart := strings.Index(invokeRemaining, "") + if invokeEnd == -1 { + break + } + invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("")] + invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len(""):] + + // Extract tool name from + 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, "") + if tagClose == -1 { + break + } + valueStart := pNameStart + tagClose + 1 + valueEnd := strings.Index(paramRemaining[valueStart:], "") + if valueEnd == -1 { + break + } + paramValue := paramRemaining[valueStart : valueStart+valueEnd] + args[paramName] = paramValue + paramRemaining = paramRemaining[valueStart+valueEnd+len(""):] + } + + 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.