refactor: remove legacy function call support and simplify tool sieve logic

This commit is contained in:
CJACK
2026-04-19 04:38:48 +08:00
parent 146d59e7bf
commit 0c644d1f4d
21 changed files with 55 additions and 2747 deletions

View File

@@ -4,7 +4,7 @@ import (
"testing"
)
// ─── FormatOpenAIStreamToolCalls ─────────────────────────────────────
// --- FormatOpenAIStreamToolCalls ---
func TestFormatOpenAIStreamToolCalls(t *testing.T) {
formatted := FormatOpenAIStreamToolCalls([]ParsedToolCall{
@@ -22,15 +22,7 @@ func TestFormatOpenAIStreamToolCalls(t *testing.T) {
}
}
// ─── ParseToolCalls more edge cases ──────────────────────────────────
func TestParseToolCallsNoToolNames(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
calls := ParseToolCalls(text, nil)
if len(calls) != 1 {
t.Fatalf("expected 1 call with nil tool names, got %d", len(calls))
}
}
// --- ParseToolCalls edge cases ---
func TestParseToolCallsEmptyText(t *testing.T) {
calls := ParseToolCalls("", []string{"search"})
@@ -38,55 +30,3 @@ func TestParseToolCallsEmptyText(t *testing.T) {
t.Fatalf("expected 0 calls for empty text, got %d", len(calls))
}
}
func TestParseToolCallsMultipleTools(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}},{"name":"get_weather","input":{"city":"beijing"}}]}`
calls := ParseToolCalls(text, []string{"search", "get_weather"})
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
}
func TestParseToolCallsInputAsString(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":"{\"q\":\"golang\"}"}]}`
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Input["q"] != "golang" {
t.Fatalf("expected parsed string input, got %#v", calls[0].Input)
}
}
func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
text := `{"tool_calls":[{"function":{"name":"calc","arguments":{"x":1,"y":2}}}]}`
calls := ParseToolCalls(text, []string{"calc"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "calc" {
t.Fatalf("expected calc, got %q", calls[0].Name)
}
}
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
calls := ParseStandaloneToolCalls(fenced, []string{"search"})
if len(calls) != 0 {
t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls))
}
}
// ─── looksLikeToolExampleContext ─────────────────────────────────────
func TestLooksLikeToolExampleContextNone(t *testing.T) {
if looksLikeToolExampleContext("I will call the tool now") {
t.Fatal("expected false for non-example context")
}
}
func TestLooksLikeToolExampleContextFenced(t *testing.T) {
if !looksLikeToolExampleContext("```json") {
t.Fatal("expected true for fenced code block context")
}
}

View File

@@ -1,205 +0,0 @@
package toolcall
import (
"regexp"
"strings"
)
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[\\s\\S]*?```")
//nolint:unused // retained for future markup tool-call heuristics.
var markupToolSyntaxPattern = regexp.MustCompile(`(?i)<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b|(?:[a-z0-9_:-]+:)?function_calls\b|(?:[a-z0-9_:-]+:)?tool_use\b)`)
func buildToolCallCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
candidates := []string{trimmed}
// fenced code block candidates: ```json ... ```
for _, match := range fencedJSONPattern.FindAllStringSubmatch(trimmed, -1) {
if len(match) >= 2 {
candidates = append(candidates, strings.TrimSpace(match[1]))
}
}
// best-effort extraction around tool call keywords in mixed text payloads.
candidates = append(candidates, extractToolCallObjects(trimmed)...)
// best-effort object slice: from first '{' to last '}'
first := strings.Index(trimmed, "{")
last := strings.LastIndex(trimmed, "}")
if first >= 0 && last > first {
candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1]))
}
// best-effort array slice: from first '[' to last ']'
firstArr := strings.Index(trimmed, "[")
lastArr := strings.LastIndex(trimmed, "]")
if firstArr >= 0 && lastArr > firstArr {
candidates = append(candidates, strings.TrimSpace(trimmed[firstArr:lastArr+1]))
}
// legacy regex extraction fallback
if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 {
candidates = append(candidates, "{"+`"tool_calls":[`+m[1]+"]}")
}
uniq := make([]string, 0, len(candidates))
seen := map[string]struct{}{}
for _, c := range candidates {
if c == "" {
continue
}
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
uniq = append(uniq, c)
}
return uniq
}
func extractToolCallObjects(text string) []string {
if text == "" {
return nil
}
lower := strings.ToLower(text)
out := []string{}
offset := 0
keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""}
for {
bestIdx := -1
matchedKeyword := ""
for _, kw := range keywords {
idx := strings.Index(lower[offset:], kw)
if idx >= 0 {
absIdx := offset + idx
if bestIdx < 0 || absIdx < bestIdx {
bestIdx = absIdx
matchedKeyword = kw
}
}
}
if bestIdx < 0 {
break
}
idx := bestIdx
// Avoid backtracking too far to prevent OOM on malicious or very long strings
searchLimit := idx - 2000
if searchLimit < offset {
searchLimit = offset
}
start := strings.LastIndex(text[searchLimit:idx], "{")
if start >= 0 {
start += searchLimit
}
if start < 0 {
offset = idx + len(matchedKeyword)
continue
}
foundObj := false
for start >= searchLimit {
candidate, end, ok := extractJSONObject(text, start)
if ok {
// Move forward to avoid repeatedly matching the same object.
offset = end
out = append(out, strings.TrimSpace(candidate))
foundObj = true
break
}
// Try previous '{'
if start > searchLimit {
prevStart := strings.LastIndex(text[searchLimit:start], "{")
if prevStart >= 0 {
start = searchLimit + prevStart
continue
}
}
break
}
if !foundObj {
offset = idx + len(matchedKeyword)
}
}
return out
}
func extractJSONObject(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '{' {
return "", 0, false
}
depth := 0
quote := byte(0)
escaped := false
// Limit scan length to avoid OOM on unclosed objects
maxLen := start + 50000
if maxLen > len(text) {
maxLen = len(text)
}
for i := start; i < maxLen; i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '{' {
depth++
continue
}
if ch == '}' {
depth--
if depth == 0 {
return text[start : i+1], i + 1, true
}
}
}
return "", 0, false
}
func looksLikeToolExampleContext(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return false
}
return strings.Contains(t, "```")
}
func shouldSkipToolCallParsingForCodeFenceExample(text string) bool {
if !looksLikeToolCallSyntax(text) {
return false
}
stripped := strings.TrimSpace(stripFencedCodeBlocks(text))
return !looksLikeToolCallSyntax(stripped)
}
//nolint:unused // retained for future markup tool-call heuristics.
func looksLikeMarkupToolSyntax(text string) bool {
return markupToolSyntaxPattern.MatchString(text)
}
func stripFencedCodeBlocks(text string) string {
if text == "" {
return ""
}
return fencedCodeBlockPattern.ReplaceAllString(text, " ")
}

View File

@@ -68,8 +68,31 @@ func parseMarkupToolCalls(text string) []ParsedToolCall {
}
func parseMarkupSingleToolCall(attrs string, inner string) ParsedToolCall {
if parsed := parseToolCallsPayload(inner); len(parsed) > 0 {
return parsed[0]
// Try parsing inner content as a JSON tool call object.
if raw := strings.TrimSpace(inner); raw != "" && strings.HasPrefix(raw, "{") {
var obj map[string]any
if err := json.Unmarshal([]byte(raw), &obj); err == nil {
name, _ := obj["name"].(string)
if name == "" {
if fn, ok := obj["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
}
}
if name == "" {
if fc, ok := obj["functionCall"].(map[string]any); ok {
name, _ = fc["name"].(string)
}
}
if strings.TrimSpace(name) != "" {
input := parseToolCallInput(obj["input"])
if input == nil || len(input) == 0 {
if args, ok := obj["arguments"]; ok {
input = parseToolCallInput(args)
}
}
return ParsedToolCall{Name: strings.TrimSpace(name), Input: input}
}
}
}
name := ""

View File

@@ -1,35 +0,0 @@
package toolcall
import (
"regexp"
"strings"
)
//nolint:unused // retained for policy-level tool-name matching compatibility.
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
//nolint:unused // retained for policy-level tool-name matching compatibility.
func resolveAllowedToolNameWithLooseMatch(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
if _, ok := allowed[name]; ok {
return name
}
lower := strings.ToLower(strings.TrimSpace(name))
if canonical, ok := allowedCanonical[lower]; ok {
return canonical
}
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
return canonical
}
}
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
if loose == "" {
return ""
}
for candidateLower, canonical := range allowedCanonical {
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
return canonical
}
}
return ""
}

View File

@@ -1,7 +1,6 @@
package toolcall
import (
"encoding/json"
"strings"
)
@@ -22,126 +21,33 @@ func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
}
func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult {
result := ToolCallParseResult{}
if strings.TrimSpace(text) == "" {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
if shouldSkipToolCallParsingForCodeFenceExample(text) {
return result
}
candidates := buildToolCallCandidates(text)
for _, candidate := range candidates {
if !isLikelyJSONToolPayloadCandidate(candidate) {
continue
}
tc := parseToolCallsPayload(candidate)
if len(tc) == 0 {
continue
}
parsed := tc
calls, rejectedNames := filterToolCallsDetailed(parsed)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
result.SawToolCallSyntax = true
return result
}
var parsed []ParsedToolCall
for _, candidate := range candidates {
tc := parseXMLToolCalls(candidate)
if len(tc) == 0 {
tc = parseMarkupToolCalls(candidate)
}
if len(tc) == 0 {
tc = parseToolCallsPayload(candidate)
}
if len(tc) == 0 {
tc = parseTextKVToolCalls(candidate)
}
if len(tc) > 0 {
parsed = tc
result.SawToolCallSyntax = true
break
}
}
if len(parsed) == 0 {
parsed = parseXMLToolCalls(text)
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(text)
if len(parsed) == 0 {
return result
}
}
result.SawToolCallSyntax = true
}
calls, rejectedNames := filterToolCallsDetailed(parsed)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result
return parseToolCallsDetailedXMLOnly(text)
}
func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls
}
func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult {
return parseToolCallsDetailedXMLOnly(text)
}
func parseToolCallsDetailedXMLOnly(text string) ToolCallParseResult {
result := ToolCallParseResult{}
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
if shouldSkipToolCallParsingForCodeFenceExample(trimmed) {
return result
}
candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates {
if !isLikelyJSONToolPayloadCandidate(candidate) {
continue
}
parsed = parseToolCallsPayload(candidate)
if len(parsed) == 0 {
continue
}
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
parsed = parseXMLToolCalls(candidate)
if len(parsed) == 0 {
parsed = parseMarkupToolCalls(candidate)
}
if len(parsed) == 0 {
parsed = parseToolCallsPayload(candidate)
}
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(candidate)
}
if len(parsed) > 0 {
break
}
parsed := parseXMLToolCalls(trimmed)
if len(parsed) == 0 {
parsed = parseMarkupToolCalls(trimmed)
}
if len(parsed) == 0 {
parsed = parseXMLToolCalls(trimmed)
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(trimmed)
if len(parsed) == 0 {
return result
}
}
return result
}
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed)
result.Calls = calls
@@ -164,70 +70,16 @@ func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []strin
return out, nil
}
//nolint:unused // retained for policy-level tool-name matching compatibility.
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
return resolveAllowedToolNameWithLooseMatch(name, allowed, allowedCanonical)
}
func parseToolCallsPayload(payload string) []ParsedToolCall {
var decoded any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
// Try to repair backslashes first! Because LLMs often mix these two problems.
repaired := repairInvalidJSONBackslashes(payload)
// Try loose repair on top of that
repaired = RepairLooseJSON(repaired)
if err := json.Unmarshal([]byte(repaired), &decoded); err != nil {
return nil
}
}
switch v := decoded.(type) {
case map[string]any:
if tc, ok := v["tool_calls"]; ok {
if isLikelyChatMessageEnvelope(v) {
return nil
}
return parseToolCallList(tc)
}
if parsed, ok := parseToolCallItem(v); ok {
return []ParsedToolCall{parsed}
}
case []any:
return parseToolCallList(v)
}
return nil
}
func isLikelyChatMessageEnvelope(v map[string]any) bool {
if v == nil {
return false
}
if _, ok := v["tool_calls"]; !ok {
return false
}
if role, ok := v["role"].(string); ok {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant", "tool", "user", "system":
return true
}
}
if _, ok := v["tool_call_id"]; ok {
return true
}
if _, ok := v["content"]; ok {
return true
}
return false
}
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "\"function\"") ||
strings.Contains(lower, "functioncall") ||
strings.Contains(lower, "\"tool_use\"") ||
return strings.Contains(lower, "<tool_calls") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_calls") ||
strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<function_name") ||
strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "function.name:")
strings.Contains(lower, "<tool_use") ||
strings.Contains(lower, "<attempt_completion") ||
strings.Contains(lower, "<ask_followup_question") ||
strings.Contains(lower, "<new_task") ||
strings.Contains(lower, "<result")
}

View File

@@ -1,87 +0,0 @@
package toolcall
import "strings"
func isLikelyJSONToolPayloadCandidate(candidate string) bool {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return false
}
if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
return false
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "\"function\"") ||
strings.Contains(lower, "functioncall") ||
strings.Contains(lower, "\"tool_use\"")
}
func parseToolCallList(v any) []ParsedToolCall {
items, ok := v.([]any)
if !ok {
return nil
}
out := make([]ParsedToolCall, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
if tc, ok := parseToolCallItem(m); ok {
out = append(out, tc)
}
}
if len(out) == 0 {
return nil
}
return out
}
func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
name, _ := m["name"].(string)
inputRaw, hasInput := m["input"]
if fnCall, ok := m["functionCall"].(map[string]any); ok {
if name == "" {
name, _ = fnCall["name"].(string)
}
if !hasInput {
if v, ok := fnCall["args"]; ok {
inputRaw = v
hasInput = true
}
}
if !hasInput {
if v, ok := fnCall["arguments"]; ok {
inputRaw = v
hasInput = true
}
}
}
if fn, ok := m["function"].(map[string]any); ok {
if name == "" {
name, _ = fn["name"].(string)
}
if !hasInput {
if v, ok := fn["arguments"]; ok {
inputRaw = v
hasInput = true
}
}
}
if !hasInput {
for _, key := range []string{"arguments", "args", "parameters", "params"} {
if v, ok := m[key]; ok {
inputRaw = v
break
}
}
}
if strings.TrimSpace(name) == "" {
return ParsedToolCall{}, false
}
return ParsedToolCall{
Name: strings.TrimSpace(name),
Input: parseToolCallInput(inputRaw),
}, true
}

View File

@@ -5,89 +5,6 @@ import (
"testing"
)
func TestParseToolCalls(t *testing.T) {
text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix`
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "search" {
t.Fatalf("unexpected tool name: %s", calls[0].Name)
}
if calls[0].Input["q"] != "golang" {
t.Fatalf("unexpected args: %#v", calls[0].Input)
}
}
func TestParseToolCallsIgnoresFencedJSON(t *testing.T) {
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) {
text := `{"tool_calls":[{"function":{"name":"get_weather","arguments":"{\"city\":\"beijing\"}"}}]}`
calls := ParseToolCalls(text, []string{"get_weather"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "get_weather" {
t.Fatalf("unexpected tool name: %s", calls[0].Name)
}
if calls[0].Input["city"] != "beijing" {
t.Fatalf("unexpected args: %#v", calls[0].Input)
}
}
func TestParseToolCallsKeepsUnknownToolName(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 1 || calls[0].Name != "unknown" {
t.Fatalf("expected unknown tool to be preserved, got %#v", calls)
}
}
func TestParseToolCallsKeepsOriginalToolNameCase(t *testing.T) {
text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
}
func TestParseToolCallsDetailedDoesNotRejectByPolicy(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
res := ParseToolCallsDetailed(text, []string{"search"})
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
}
if res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
}
if len(res.Calls) != 1 || res.Calls[0].Name != "unknown" {
t.Fatalf("expected call to be preserved, got %#v", res.Calls)
}
}
func TestParseToolCallsDetailedAllowsWhenAllowListEmpty(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
res := ParseToolCallsDetailed(text, nil)
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
}
if res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
}
if len(res.Calls) != 1 || res.Calls[0].Name != "search" {
t.Fatalf("expected calls when allow-list is empty, got %#v", res.Calls)
}
}
func TestFormatOpenAIToolCalls(t *testing.T) {
formatted := FormatOpenAIToolCalls([]ParsedToolCall{{Name: "search", Input: map[string]any{"q": "x"}}})
if len(formatted) != 1 {
@@ -99,55 +16,6 @@ func TestFormatOpenAIToolCalls(t *testing.T) {
}
}
func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected standalone parser to parse mixed prose payload, got %#v", calls)
}
standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
calls := ParseStandaloneToolCalls(standalone, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected standalone parser to match, got %#v", calls)
}
}
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) {
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
func TestParseStandaloneToolCallsIgnoresChatTranscriptEnvelope(t *testing.T) {
transcript := `[{"role":"user","content":"请展示完整会话"},{"role":"assistant","content":null,"tool_calls":[{"function":{"name":"search","arguments":"{\"q\":\"go\"}"}}]}]`
if calls := ParseStandaloneToolCalls(transcript, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected transcript envelope not to trigger tool call parse, got %#v", calls)
}
}
func TestParseToolCallsAllowsQualifiedToolName(t *testing.T) {
text := `{"tool_calls":[{"name":"mcp.search_web","input":{"q":"golang"}}]}`
calls := ParseToolCalls(text, []string{"search_web"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "mcp.search_web" {
t.Fatalf("expected original tool name mcp.search_web, got %q", calls[0].Name)
}
}
func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) {
text := `{"tool_calls":[{"name":"read-file","input":{"path":"README.md"}}]}`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "read-file" {
t.Fatalf("expected original tool name read-file, got %q", calls[0].Name)
}
}
func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
text := `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command><description>show cwd</description></parameters></tool_call>`
calls := ParseToolCalls(text, []string{"bash"})
@@ -223,20 +91,6 @@ func TestParseToolCallsDoesNotTreatParameterNameTagAsToolName(t *testing.T) {
}
}
func TestParseToolCallsPrefersJSONPayloadOverIncidentalXMLInString(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"latest <tool_call><tool_name>wrong</tool_name><parameters>{\"x\":1}</parameters></tool_call>"}}]}`
calls := ParseToolCallsDetailed(text, []string{"search"}).Calls
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search" {
t.Fatalf("expected tool name search, got %q", calls[0].Name)
}
if calls[0].Input["q"] == nil {
t.Fatalf("expected q argument from json payload, got %#v", calls[0].Input)
}
}
func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) {
text := `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command></parameters></tool_call>`
res := ParseToolCallsDetailed(text, []string{"bash"})
@@ -318,34 +172,6 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
}
}
func TestParseToolCallsSupportsGeminiFunctionCallJSON(t *testing.T) {
text := `{"functionCall":{"name":"search_web","args":{"query":"latest"}}}`
calls := ParseToolCalls(text, []string{"search_web"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected search_web, got %q", calls[0].Name)
}
if calls[0].Input["query"] != "latest" {
t.Fatalf("expected query argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsClaudeToolUseJSON(t *testing.T) {
text := `{"type":"tool_use","name":"read_file","input":{"path":"README.md"}}`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "read_file" {
t.Fatalf("expected read_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "README.md" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
text := `<tool_use><function name="search_web"><parameter name="query">test</parameter></function></tool_use>`
calls := ParseToolCalls(text, []string{"search_web"})
@@ -495,104 +321,6 @@ func TestRepairLooseJSON(t *testing.T) {
}
}
func TestParseToolCallsWithUnquotedKeys(t *testing.T) {
text := `这里是列表:{tool_calls: [{"name": "todowrite", "input": {"todos": "test"}}]}`
availableTools := []string{"todowrite"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(parsed))
}
if parsed[0].Name != "todowrite" {
t.Errorf("expected tool todowrite, got %s", parsed[0].Name)
}
}
func TestParseToolCallsWithInvalidBackslashes(t *testing.T) {
// DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings
// Note: using raw string to simulate what AI actually sends in the stream
text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}`
availableTools := []string{"execute_command"}
parsed := ParseToolCalls(text, availableTools)
// If standard JSON fails, buildToolCallCandidates should still extract the object,
// and parseToolCallsPayload should repair it.
if len(parsed) != 1 {
// If it still fails, let's see why
candidates := buildToolCallCandidates(text)
t.Logf("Candidates: %v", candidates)
t.Fatalf("expected 1 tool call, got %d", len(parsed))
}
cmd, ok := parsed[0].Input["command"].(string)
if !ok {
t.Fatalf("expected command string in input, got %v", parsed[0].Input)
}
expected := "cd D:\\git_codes && dir"
if cmd != expected {
t.Errorf("expected command %q, got %q", expected, cmd)
}
}
func TestParseToolCallsWithDeepSeekHallucination(t *testing.T) {
// 模拟 DeepSeek 典型的幻觉输出:未加引号的键名 + 包含 Windows 路径的嵌套 JSON 字符串 + 漏掉列表的方括号
text := `检测到实施意图——实现经典算法。需在misc/目录创建Python文件。
关键约束:
1. Windows UTF-8编码处理
2. 必须用绝对路径导入
3. 禁止write覆盖已有文件misc/目录允许创建新文件)
将任务分解并委托:
- 研究8皇后算法模式并行探索
- 实现带可视化输出的解决方案unspecified-high
先创建todo列表追踪步骤。
{tool_calls: [{"name": "todowrite", "input": {"todos": {"content": "研究8皇后问题算法模式回溯法和输出格式", "status": "pending", "priority": "high"}, {"content": "在misc/目录创建8皇后Python脚本包含完整解决方案和可视化输出", "status": "pending", "priority": "high"}, {"content": "验证脚本正确性(运行测试)", "status": "pending", "priority": "medium"}}}]}`
availableTools := []string{"todowrite"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
cands := buildToolCallCandidates(text)
for i, c := range cands {
t.Logf("CAND %d: %s", i, c)
repaired := RepairLooseJSON(c)
t.Logf(" REPAIRED: %s", repaired)
}
t.Fatalf("expected 1 tool call, got %d. Candidates: %v", len(parsed), buildToolCallCandidates(text))
}
if parsed[0].Name != "todowrite" {
t.Errorf("expected tool name 'todowrite', got %q", parsed[0].Name)
}
todos, ok := parsed[0].Input["todos"].([]any)
if !ok {
t.Fatalf("expected 'todos' to be parsed as a list, got %T: %#v", parsed[0].Input["todos"], parsed[0].Input["todos"])
}
if len(todos) != 3 {
t.Errorf("expected 3 todo items, got %d", len(todos))
}
}
func TestParseToolCallsWithMixedWindowsPaths(t *testing.T) {
// 更复杂的案例:嵌套 JSON 字符串中的反斜杠未转义
text := `关键约束: 1. Windows UTF-8编码处理 2. 必须用绝对路径导入 D:\git_codes\ds2api\misc
{tool_calls: [{"name": "write_file", "input": "{\"path\": \"D:\\git_codes\\ds2api\\misc\\queens.py\", \"content\": \"print('hello')\"}"}]}`
availableTools := []string{"write_file"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
t.Fatalf("expected 1 tool call from mixed text with paths, got %d", len(parsed))
}
path, _ := parsed[0].Input["path"].(string)
// 在解析后的 Go map 中,反斜杠应该被还原
if !strings.Contains(path, "D:\\git_codes") && !strings.Contains(path, "D:/git_codes") {
t.Errorf("expected path to contain Windows style separators, got %q", path)
}
}
func TestParseToolCallInputRepairsControlCharsInPath(t *testing.T) {
in := `{"path":"D:\tmp\new\readme.txt","content":"line1\nline2"}`
parsed := parseToolCallInput(in)
@@ -703,15 +431,3 @@ func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) {
t.Fatalf("expected html entities to be unescaped in command, got %q", cmd)
}
}
func TestParseToolCallsJSONPayloadKeepsLiteralEntities(t *testing.T) {
text := `{"tool_calls":[{"name":"bash","input":{"command":"echo &gt; literally"}}]}`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected one call, got %#v", calls)
}
cmd, _ := calls[0].Input["command"].(string)
if cmd != "echo &gt; literally" {
t.Fatalf("expected json payload to keep literal entities, got %q", cmd)
}
}

View File

@@ -1,55 +0,0 @@
package toolcall
import (
"regexp"
"strings"
)
var textKVNamePattern = regexp.MustCompile(`(?is)function\.name:\s*([a-zA-Z0-9_\-.]+)`)
func parseTextKVToolCalls(text string) []ParsedToolCall {
var out []ParsedToolCall
matches := textKVNamePattern.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return nil
}
for i, match := range matches {
name := text[match[2]:match[3]]
offset := match[1]
endSearch := len(text)
if i+1 < len(matches) {
endSearch = matches[i+1][0]
}
searchArea := text[offset:endSearch]
argIdx := strings.Index(searchArea, "function.arguments:")
if argIdx < 0 {
continue
}
startIdx := offset + argIdx + len("function.arguments:")
braceIdx := strings.IndexByte(text[startIdx:endSearch], '{')
if braceIdx < 0 {
continue
}
actualStart := startIdx + braceIdx
objJson, _, ok := extractJSONObject(text, actualStart)
if !ok {
continue
}
input := parseToolCallInput(objJson)
out = append(out, ParsedToolCall{
Name: name,
Input: input,
})
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -1,61 +0,0 @@
package toolcall
import (
"testing"
)
func TestParseTextKVToolCalls_Basic(t *testing.T) {
text := `
status: already_called
origin: assistant
not_user_input: true
tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b
function.name: execute_command
function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}
Some other text thinking...
`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "execute_command" {
t.Fatalf("unexpected name: %s", calls[0].Name)
}
if calls[0].Input["command"] != "cd scripts && python check_syntax.py example.py" {
t.Fatalf("unexpected command arg: %v", calls[0].Input["command"])
}
}
func TestParseTextKVToolCalls_Multiple(t *testing.T) {
text := `
function.name: read_file
function.arguments: {
"path": "abc.txt"
}
function.name: bash
function.arguments: {"command": "ls"}
`
calls := ParseToolCalls(text, []string{"read_file", "bash"})
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Fatalf("unexpected 1st name: %s", calls[0].Name)
}
if calls[1].Name != "bash" {
t.Fatalf("unexpected 2nd name: %s", calls[1].Name)
}
}
func TestParseTextKVToolCalls_Standalone(t *testing.T) {
text := "function.name: read_file\nfunction.arguments: {\"path\":\"README.md\"}"
calls := ParseStandaloneToolCalls(text, []string{"read_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Fatalf("unexpected name: %s", calls[0].Name)
}
}