package util import ( "encoding/json" "regexp" "strings" ) type ParsedToolCall struct { Name string `json:"name"` Input map[string]any `json:"input"` } type ToolCallParseResult struct { Calls []ParsedToolCall SawToolCallSyntax bool RejectedByPolicy bool RejectedToolNames []string } func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall { return ParseToolCallsDetailed(text, availableToolNames).Calls } func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult { result := ToolCallParseResult{} if strings.TrimSpace(text) == "" { return result } text = stripFencedCodeBlocks(text) if strings.TrimSpace(text) == "" { return result } result.SawToolCallSyntax = looksLikeToolCallSyntax(text) candidates := buildToolCallCandidates(text) var parsed []ParsedToolCall for _, candidate := range candidates { tc := parseToolCallsPayload(candidate) if len(tc) == 0 { tc = parseXMLToolCalls(candidate) } if len(tc) == 0 { tc = parseMarkupToolCalls(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, availableToolNames) result.Calls = calls result.RejectedToolNames = rejectedNames result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 return result } func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall { return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls } func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult { result := ToolCallParseResult{} trimmed := strings.TrimSpace(text) if trimmed == "" { return result } if looksLikeToolExampleContext(trimmed) { return result } result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed) candidates := []string{trimmed} for _, candidate := range candidates { candidate = strings.TrimSpace(candidate) if candidate == "" { continue } parsed := parseToolCallsPayload(candidate) if len(parsed) == 0 { parsed = parseXMLToolCalls(candidate) } if len(parsed) == 0 { parsed = parseMarkupToolCalls(candidate) } if len(parsed) == 0 { parsed = parseTextKVToolCalls(candidate) } if len(parsed) > 0 { result.SawToolCallSyntax = true calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) result.Calls = calls result.RejectedToolNames = rejectedNames result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 return result } } return result } func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) { allowed := map[string]struct{}{} allowedCanonical := map[string]string{} for _, name := range availableToolNames { trimmed := strings.TrimSpace(name) if trimmed == "" { continue } allowed[trimmed] = struct{}{} lower := strings.ToLower(trimmed) if _, exists := allowedCanonical[lower]; !exists { allowedCanonical[lower] = trimmed } } if len(allowed) == 0 { rejectedSet := map[string]struct{}{} rejected := make([]string, 0, len(parsed)) for _, tc := range parsed { if tc.Name == "" { continue } if _, ok := rejectedSet[tc.Name]; ok { continue } rejectedSet[tc.Name] = struct{}{} rejected = append(rejected, tc.Name) } return nil, rejected } out := make([]ParsedToolCall, 0, len(parsed)) rejectedSet := map[string]struct{}{} rejected := make([]string, 0) for _, tc := range parsed { if tc.Name == "" { continue } matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical) if matchedName == "" { if _, ok := rejectedSet[tc.Name]; !ok { rejectedSet[tc.Name] = struct{}{} rejected = append(rejected, tc.Name) } continue } tc.Name = matchedName if tc.Input == nil { tc.Input = map[string]any{} } out = append(out, tc) } return out, rejected } 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 { return parseToolCallList(tc) } if parsed, ok := parseToolCallItem(v); ok { return []ParsedToolCall{parsed} } case []any: return parseToolCallList(v) } return nil } func looksLikeToolCallSyntax(text string) bool { lower := strings.ToLower(text) return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { isHex = false break } } if isHex { out.WriteRune('\\') out.WriteRune('u') for j := 1; j <= 4; j++ { out.WriteRune(runes[i+1+j]) } i += 5 continue } } } } // Not a valid escape sequence, double it out.WriteString("\\\\") } else { out.WriteRune(runes[i]) } } return out.String() } var unquotedKeyPattern = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`) // missingArrayBracketsPattern identifies a sequence of two or more JSON objects separated by commas // that immediately follow a colon, which indicates a missing array bracket `[` `]`. // E.g., "key": {"a": 1}, {"b": 2} -> "key": [{"a": 1}, {"b": 2}] // NOTE: The pattern uses (?:[^{}]|\{[^{}]*\})* to support single-level nested {} objects, // which handles cases like {"content": "x", "input": {"q": "y"}} var missingArrayBracketsPattern = regexp.MustCompile(`(:\s*)(\{(?:[^{}]|\{[^{}]*\})*\}(?:\s*,\s*\{(?:[^{}]|\{[^{}]*\})*\})+)`) func RepairLooseJSON(s string) string { s = strings.TrimSpace(s) if s == "" { return s } // 1. Replace unquoted keys: {key: -> {"key": s = unquotedKeyPattern.ReplaceAllString(s, `$1"$2":`) // 2. Heuristic: Fix missing array brackets for list of objects // e.g., : {obj1}, {obj2} -> : [{obj1}, {obj2}] // This specifically addresses DeepSeek's "list hallucination" s = missingArrayBracketsPattern.ReplaceAllString(s, `$1[$2]`) return s }