package functions import ( "encoding/json" "errors" "io" "regexp" "slices" "strings" "unicode/utf8" "github.com/mudler/LocalAI/pkg/functions/grammars" "github.com/mudler/LocalAI/pkg/utils" "github.com/mudler/xlog" ) // @Description GrammarConfig contains configuration for grammar parsing type GrammarConfig struct { // ParallelCalls enables the LLM to return multiple function calls in the same response ParallelCalls bool `yaml:"parallel_calls,omitempty" json:"parallel_calls,omitempty"` DisableParallelNewLines bool `yaml:"disable_parallel_new_lines,omitempty" json:"disable_parallel_new_lines,omitempty"` // MixedMode enables the LLM to return strings and not only JSON objects // This is useful for models to not constraining returning only JSON and also messages back to the user MixedMode bool `yaml:"mixed_mode,omitempty" json:"mixed_mode,omitempty"` // NoMixedFreeString disables the mixed mode for free strings // In this way if the LLM selects a free string, it won't be mixed necessarily with JSON objects. // For example, if enabled the LLM or returns a JSON object or a free string, but not a mix of both // If disabled(default): the LLM can return a JSON object surrounded by free strings (e.g. `this is the JSON result: { "bar": "baz" } for your question`). This forces the LLM to return at least a JSON object, but its not going to be strict NoMixedFreeString bool `yaml:"no_mixed_free_string,omitempty" json:"no_mixed_free_string,omitempty"` // NoGrammar disables the grammar parsing and parses the responses directly from the LLM NoGrammar bool `yaml:"disable,omitempty" json:"disable,omitempty"` // Prefix is the suffix to append to the grammar when being generated // This is useful when models prepend a tag before returning JSON Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` // ExpectStringsAfterJSON enables mixed string suffix ExpectStringsAfterJSON bool `yaml:"expect_strings_after_json,omitempty" json:"expect_strings_after_json,omitempty"` // PropOrder selects what order to print properties // for instance name,arguments will make print { "name": "foo", "arguments": { "bar": "baz" } } // instead of { "arguments": { "bar": "baz" }, "name": "foo" } PropOrder string `yaml:"properties_order,omitempty" json:"properties_order,omitempty"` // SchemaType can be configured to use a specific schema type to force the grammar // available : json, llama3.1 SchemaType string `yaml:"schema_type,omitempty" json:"schema_type,omitempty"` GrammarTriggers []GrammarTrigger `yaml:"triggers,omitempty" json:"triggers,omitempty"` } // @Description GrammarTrigger defines a trigger word for grammar parsing type GrammarTrigger struct { // Trigger is the string that triggers the grammar Word string `yaml:"word,omitempty" json:"word,omitempty"` } // @Description FunctionsConfig is the configuration for the tool/function call. // It includes setting to map the function name and arguments from the response // and, for instance, also if processing the requests with BNF grammars. type FunctionsConfig struct { // DisableNoAction disables the "no action" tool // By default we inject a tool that does nothing and is used to return an answer from the LLM DisableNoAction bool `yaml:"disable_no_action,omitempty" json:"disable_no_action,omitempty"` // Grammar is the configuration for the grammar GrammarConfig GrammarConfig `yaml:"grammar,omitempty" json:"grammar,omitempty"` // NoActionFunctionName is the name of the function that does nothing. It defaults to "answer" NoActionFunctionName string `yaml:"no_action_function_name,omitempty" json:"no_action_function_name,omitempty"` // NoActionDescriptionName is the name of the function that returns the description of the no action function NoActionDescriptionName string `yaml:"no_action_description_name,omitempty" json:"no_action_description_name,omitempty"` // ResponseRegex is a named regex to extract the function name and arguments from the response ResponseRegex []string `yaml:"response_regex,omitempty" json:"response_regex,omitempty"` // JSONRegexMatch is a regex to extract the JSON object from the response JSONRegexMatch []string `yaml:"json_regex_match,omitempty" json:"json_regex_match,omitempty"` // ArgumentRegex is a named regex to extract the arguments from the response. Use ArgumentRegexKey and ArgumentRegexValue to set the names of the named regex for key and value of the arguments. ArgumentRegex []string `yaml:"argument_regex,omitempty" json:"argument_regex,omitempty"` // ArgumentRegex named regex names for key and value extractions. default: key and value ArgumentRegexKey string `yaml:"argument_regex_key_name,omitempty" json:"argument_regex_key_name,omitempty"` // default: key ArgumentRegexValue string `yaml:"argument_regex_value_name,omitempty" json:"argument_regex_value_name,omitempty"` // default: value // ReplaceFunctionResults allow to replace strings in the results before parsing them ReplaceFunctionResults []ReplaceResult `yaml:"replace_function_results,omitempty" json:"replace_function_results,omitempty"` // ReplaceLLMResult allow to replace strings in the results before parsing them ReplaceLLMResult []ReplaceResult `yaml:"replace_llm_results,omitempty" json:"replace_llm_results,omitempty"` // CaptureLLMResult is a regex to extract a string from the LLM response // that is used as return string when using tools. // This is useful for e.g. if the LLM outputs a reasoning and we want to get the reasoning as a string back CaptureLLMResult []string `yaml:"capture_llm_results,omitempty" json:"capture_llm_results,omitempty"` // FunctionName enable the LLM to return { "name": "function_name", "arguments": { "arg1": "value1", "arg2": "value2" } } // instead of { "function": "function_name", "arguments": { "arg1": "value1", "arg2": "value2" } }. // This might be useful for certain models trained with the function name as the first token. FunctionNameKey string `yaml:"function_name_key,omitempty" json:"function_name_key,omitempty"` FunctionArgumentsKey string `yaml:"function_arguments_key,omitempty" json:"function_arguments_key,omitempty"` // XMLFormatPreset is an optional preset format name to force (e.g., "qwen3-coder", "glm-4.5", "minimax-m2") // If empty, auto-detection will try all formats XMLFormatPreset string `yaml:"xml_format_preset,omitempty" json:"xml_format_preset,omitempty"` // XMLFormat is an optional custom XML format configuration // If set, only this format will be tried (overrides XMLFormatPreset) XMLFormat *XMLToolCallFormat `yaml:"xml_format,omitempty" json:"xml_format,omitempty"` } // @Description ReplaceResult defines a key-value replacement for function results type ReplaceResult struct { Key string `yaml:"key,omitempty" json:"key,omitempty"` Value string `yaml:"value,omitempty" json:"value,omitempty"` } // @Description XMLToolCallFormat defines the structure for parsing XML-style tool calls // This mirrors llama.cpp's xml_tool_call_format structure type XMLToolCallFormat struct { // ScopeStart is the optional wrapper start tag (e.g., "") ScopeStart string `yaml:"scope_start,omitempty" json:"scope_start,omitempty"` // ToolStart is the tool call start tag (e.g., "", "", "\">") ToolSep string `yaml:"tool_sep,omitempty" json:"tool_sep,omitempty"` // KeyStart is the parameter key start tag (e.g., "") KeyStart string `yaml:"key_start,omitempty" json:"key_start,omitempty"` // KeyValSep is the separator between key and value (e.g., ">", "") KeyValSep string `yaml:"key_val_sep,omitempty" json:"key_val_sep,omitempty"` // ValEnd is the parameter value end tag (e.g., "", "") ValEnd string `yaml:"val_end,omitempty" json:"val_end,omitempty"` // ToolEnd is the tool call end tag (e.g., "", "") ToolEnd string `yaml:"tool_end,omitempty" json:"tool_end,omitempty"` // ScopeEnd is the optional wrapper end tag (e.g., "") ScopeEnd string `yaml:"scope_end,omitempty" json:"scope_end,omitempty"` // KeyValSep2 is the optional second separator (for GLM 4.5 format: "\n") KeyValSep2 *string `yaml:"key_val_sep2,omitempty" json:"key_val_sep2,omitempty"` // RawArgVal indicates whether to treat values as raw strings (true) vs JSON (false), nil means both allowed RawArgVal *bool `yaml:"raw_argval,omitempty" json:"raw_argval,omitempty"` // LastValEnd is the alternative value end for last parameter LastValEnd *string `yaml:"last_val_end,omitempty" json:"last_val_end,omitempty"` // LastToolEnd is the alternative tool end for last tool call LastToolEnd *string `yaml:"last_tool_end,omitempty" json:"last_tool_end,omitempty"` // TrimRawArgVal indicates whether to trim whitespace from raw values TrimRawArgVal bool `yaml:"trim_raw_argval,omitempty" json:"trim_raw_argval,omitempty"` // AllowToolcallInThink allows tool calls inside thinking/reasoning blocks AllowToolcallInThink bool `yaml:"allow_toolcall_in_think,omitempty" json:"allow_toolcall_in_think,omitempty"` } type FuncCallResults struct { Name string Arguments string } func (g FunctionsConfig) GrammarOptions() []func(o *grammars.GrammarOption) { opts := []func(o *grammars.GrammarOption){} if g.GrammarConfig.MixedMode { opts = append(opts, grammars.EnableMaybeString) } if g.GrammarConfig.ParallelCalls { opts = append(opts, grammars.EnableMaybeArray) } if g.GrammarConfig.DisableParallelNewLines { opts = append(opts, grammars.DisableParallelNewLines) } if g.GrammarConfig.Prefix != "" { opts = append(opts, grammars.SetPrefix(g.GrammarConfig.Prefix)) } if g.GrammarConfig.NoMixedFreeString { opts = append(opts, grammars.NoMixedFreeString) } if g.GrammarConfig.ExpectStringsAfterJSON { opts = append(opts, grammars.ExpectStringsAfterJSON) } if g.GrammarConfig.SchemaType != "" { opts = append(opts, grammars.WithSchemaType(grammars.NewType(g.GrammarConfig.SchemaType))) } if g.FunctionNameKey != "" { opts = append(opts, grammars.WithFunctionName(g.FunctionNameKey)) } opts = append(opts, grammars.SetPropOrder(g.GrammarConfig.PropOrder)) return opts } func CleanupLLMResult(llmresult string, functionConfig FunctionsConfig) string { xlog.Debug("LLM result", "result", llmresult) for _, item := range functionConfig.ReplaceLLMResult { k, v := item.Key, item.Value xlog.Debug("Replacing", "key", k, "value", v) re := regexp.MustCompile(k) llmresult = re.ReplaceAllString(llmresult, v) } xlog.Debug("LLM result(processed)", "result", llmresult) return llmresult } func ParseTextContent(llmresult string, functionConfig FunctionsConfig) string { xlog.Debug("ParseTextContent", "result", llmresult) xlog.Debug("CaptureLLMResult", "config", functionConfig.CaptureLLMResult) for _, r := range functionConfig.CaptureLLMResult { // We use a regex to extract the JSON object from the response var respRegex = regexp.MustCompile(r) match := respRegex.FindStringSubmatch(llmresult) if len(match) >= 1 { m := strings.TrimSpace(match[1]) return m } } return "" } // ParseJSON is a function that parses a JSON string that might contain multiple JSON objects // and syntax errors in between by shifting the offset // This for e.g. allow to parse // { "foo": "bar" } invalid { "baz": "qux" } // into // [ { "foo": "bar" }, { "baz": "qux" } ] // Credits to Michael Yang (https://github.com/mxyng) for the original implementation // This is a slightly reworked version, improved for readability and error handling // ParseJSON parses JSON objects from a string, supporting multiple JSON objects // Now defaults to iterative parser for better streaming support // Falls back to legacy parser if iterative parser fails func ParseJSON(s string) ([]map[string]any, error) { // Try iterative parser first (non-partial mode for complete parsing) results, err := ParseJSONIterative(s, false) if err == nil && len(results) > 0 { return results, nil } // Fall back to legacy parser for backward compatibility return parseJSONLegacy(s) } // ParseJSONIterative parses JSON using the iterative parser // Supports partial parsing for streaming scenarios // Returns objects and arrays (matching llama.cpp behavior) func ParseJSONIterative(s string, isPartial bool) ([]map[string]any, error) { parser := NewChatMsgParser(s, isPartial) var results []map[string]any // Try to parse JSON values one by one for parser.Pos() < len(parser.Input()) { jsonValue, isPartialJSON, _, err := parser.TryConsumeJSON() if err != nil { // If it's a partial exception and we're in partial mode, return what we have if _, ok := err.(*ChatMsgPartialException); ok && isPartial { break } // For non-partial errors or when not in partial mode, try legacy parsing return parseJSONLegacy(s) } if jsonValue != nil { // Convert to map[string]any if it's an object, or handle arrays if obj, ok := jsonValue.(map[string]any); ok { results = append(results, obj) } else if arr, ok := jsonValue.([]any); ok { // Handle arrays: extract objects from array for _, item := range arr { if obj, ok := item.(map[string]any); ok { results = append(results, obj) } } } } if isPartialJSON { break } // Skip whitespace between JSON values parser.ConsumeSpaces() } if len(results) > 0 { return results, nil } // Fallback to legacy parsing if iterative parser found nothing return parseJSONLegacy(s) } // parseJSONLegacy is the original decoder-based JSON parsing (kept for compatibility) func parseJSONLegacy(s string) ([]map[string]any, error) { var objs []map[string]any offset := 0 for offset < len(s) { var obj map[string]any decoder := json.NewDecoder(strings.NewReader(s[offset:])) err := decoder.Decode(&obj) switch { case errors.Is(err, io.EOF): return objs, nil case err == nil: offset += int(decoder.InputOffset()) objs = append(objs, obj) default: // handle the error type var syntaxErr *json.SyntaxError var unmarshalTypeErr *json.UnmarshalTypeError switch { case errors.As(err, &syntaxErr): offset += int(syntaxErr.Offset) case errors.As(err, &unmarshalTypeErr): offset += int(unmarshalTypeErr.Offset) default: return objs, err } } } return objs, nil } // GetXMLFormatPreset returns a preset XML format by name, or nil if not found // This is exported for use in chat.go streaming integration func GetXMLFormatPreset(name string) *XMLToolCallFormat { formats := getAllXMLFormats() for _, format := range formats { if format.name == name { return format.format } } return nil } // xmlFormatPreset holds a preset format with its name type xmlFormatPreset struct { name string format *XMLToolCallFormat } // getAllXMLFormats returns all preset XML formats matching llama.cpp's formats func getAllXMLFormats() []xmlFormatPreset { falseVal := false commaSpace := ", " emptyValEnd := "" return []xmlFormatPreset{ { name: "functionary", format: &XMLToolCallFormat{ ScopeStart: "", ToolStart: "", KeyStart: "", // Parameters are JSON, not XML tags KeyValSep: "", ValEnd: "", ToolEnd: "", ScopeEnd: "", RawArgVal: &falseVal, // JSON only }, }, { name: "qwen3-coder", format: &XMLToolCallFormat{ ScopeStart: "", ToolStart: "", KeyStart: "", ValEnd: "", ToolEnd: "", ScopeEnd: "", TrimRawArgVal: true, }, }, { name: "glm-4.5", format: &XMLToolCallFormat{ ScopeStart: "", ToolStart: "", ToolSep: "", KeyStart: "", KeyValSep: "", KeyValSep2: func() *string { s := ""; return &s }(), ValEnd: "", ToolEnd: "", ScopeEnd: "", }, }, { name: "minimax-m2", format: &XMLToolCallFormat{ ScopeStart: "", ToolStart: "", KeyStart: "", ValEnd: "", ToolEnd: "", ScopeEnd: "", }, }, { name: "kimi-k2", format: &XMLToolCallFormat{ ScopeStart: "<|tool_calls_section_begin|>", ToolStart: "<|tool_call_begin|>", ToolSep: "<|tool_call_argument_begin|>{", KeyStart: "\"", KeyValSep: "\":", ValEnd: ",", ToolEnd: "}<|tool_call_end|>", ScopeEnd: "<|tool_calls_section_end|>", LastValEnd: &emptyValEnd, RawArgVal: &falseVal, AllowToolcallInThink: true, // Kimi-K2 supports tool calls in thinking blocks }, }, { name: "apriel-1.5", format: &XMLToolCallFormat{ ScopeStart: "[", ToolStart: "{\"name\": \"", ToolSep: "\", \"arguments\": {", KeyStart: "\"", KeyValSep: "\": ", ValEnd: commaSpace, ToolEnd: "}, ", ScopeEnd: "]", LastValEnd: &emptyValEnd, LastToolEnd: func() *string { s := "}"; return &s }(), RawArgVal: &falseVal, }, }, { name: "xiaomi-mimo", format: &XMLToolCallFormat{ ScopeStart: "", ToolStart: "\n{\"name\": \"", ToolSep: "\", \"arguments\": {", KeyStart: "\"", KeyValSep: "\": ", ValEnd: commaSpace, ToolEnd: "}\n", ScopeEnd: "", LastValEnd: &emptyValEnd, RawArgVal: &falseVal, }, }, } } // parseXMLAutoDetect tries all preset formats in sequence and returns results from the first one that succeeds func parseXMLAutoDetect(s string) ([]FuncCallResults, error) { formats := getAllXMLFormats() for _, preset := range formats { results, err := parseXMLWithFormat(s, preset.format) if err == nil && len(results) > 0 { xlog.Debug("XML auto-detection succeeded", "format", preset.name, "count", len(results)) return results, nil } } return nil, nil } // ParseXML is a function that parses XML-style tool calls from a string that might contain // text and valid XML tool calls. If format is nil, it will auto-detect by trying all formats. // Returns a slice of FuncCallResults with function names and JSON-encoded arguments. // Now defaults to iterative parser for better streaming and partial parsing support. // Falls back to regex parser if iterative parser fails for backward compatibility. func ParseXML(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { // Try iterative parser first (non-partial mode for complete parsing) results, err := ParseXMLIterative(s, format, false) if err == nil && len(results) > 0 { return results, nil } // Fall back to regex parser for backward compatibility if format == nil { return parseXMLAutoDetect(s) } return parseXMLWithFormat(s, format) } // ParseXMLIterative parses XML tool calls using the iterative parser // This provides better streaming and partial parsing support func ParseXMLIterative(s string, format *XMLToolCallFormat, isPartial bool) ([]FuncCallResults, error) { parser := NewChatMsgParser(s, isPartial) // Auto-detect format if not provided if format == nil { formats := getAllXMLFormats() for _, fmtPreset := range formats { if fmtPreset.format != nil { // Try parsing with this format parser.MoveTo(0) parser.ClearTools() success, err := parser.TryConsumeXMLToolCalls(fmtPreset.format) if err != nil { // Check if it's a partial exception (recoverable) if _, ok := err.(*ChatMsgPartialException); ok { // Partial parse, return what we have return parser.ToolCalls(), nil } // Try next format continue } if success && len(parser.ToolCalls()) > 0 { return parser.ToolCalls(), nil } } } // No format matched, return empty return []FuncCallResults{}, nil } // Use specified format success, err := parser.TryConsumeXMLToolCalls(format) if err != nil { // Check if it's a partial exception (recoverable) if _, ok := err.(*ChatMsgPartialException); ok { // Partial parse, return what we have return parser.ToolCalls(), nil } return nil, err } if !success { return []FuncCallResults{}, nil } return parser.ToolCalls(), nil } // ParseXMLPartial parses XML tool calls that may be incomplete (for streaming support) // It returns both complete results and partial results that can be emitted during streaming // Reference: llama.cpp's partial parsing support // Uses iterative parser for better partial detection func ParseXMLPartial(s string, format *XMLToolCallFormat) (*PartialXMLResult, error) { // Use iterative parser with partial flag enabled for better streaming support results, err := ParseXMLIterative(s, format, true) if err != nil { return nil, err } // Check if the input ends with incomplete XML tags (indicating partial content) isPartial := false trimmed := strings.TrimSpace(s) // Auto-detect format if not provided to check for partial content if format == nil { formats := getAllXMLFormats() for _, fmtPreset := range formats { if fmtPreset.format != nil { format = fmtPreset.format break } } } if format != nil { // Check if string ends with incomplete tool_end or val_end // Also check for incomplete tags like ") if !strings.HasSuffix(trimmed, format.ToolEnd) { if format.LastToolEnd != nil && !strings.HasSuffix(trimmed, *format.LastToolEnd) { // Check if it starts with tool_end but is incomplete if len(trimmed) > 0 && len(format.ToolEnd) > 0 { suffix := trimmed[max(0, len(trimmed)-len(format.ToolEnd)):] if strings.HasPrefix(format.ToolEnd, suffix) && suffix != format.ToolEnd { isPartial = true } } } // Also check for incomplete closing tags (ends with < but not complete) if strings.HasSuffix(trimmed, "<") || strings.HasSuffix(trimmed, " 0 && len(format.ValEnd) > 0 { suffix := trimmed[max(0, len(trimmed)-len(format.ValEnd)):] if strings.HasPrefix(format.ValEnd, suffix) && suffix != format.ValEnd { isPartial = true } } } // Check for incomplete closing tags if strings.HasSuffix(trimmed, "<") || strings.HasSuffix(trimmed, " b { return a } return b } // parseXMLWithFormat parses XML tool calls using a specific format configuration // Returns parsed results and error. Handles errors gracefully by continuing to parse other tool calls. func parseXMLWithFormat(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { var results []FuncCallResults // Handle Functionary format (JSON parameters inside XML tags) if format.KeyStart == "" && format.ToolStart == ") if format.ToolStart == "" && format.ToolSep == "" && format.KeyStart == "" { return parseGLM45Format(s, format) } // Build regex patterns from format configuration // Escape special regex characters in format strings escapeRegex := func(str string) string { return regexp.QuoteMeta(str) } // Build scope pattern (optional) // llama.cpp validates that only whitespace appears before scope_start var scopePattern *regexp.Regexp if format.ScopeStart != "" { // Match scope_start with optional whitespace before it, but validate it's only whitespace scopeRegex := `(?s)(\s*)` + escapeRegex(format.ScopeStart) + `\s*(.*?)\s*` + escapeRegex(format.ScopeEnd) scopePattern = regexp.MustCompile(scopeRegex) } // Build tool call patterns - try both primary and alternative tool_end var toolCallPatterns []*regexp.Regexp buildToolCallPattern := func(toolEnd string) string { toolCallRegex := `(?s)` + escapeRegex(format.ToolStart) if format.ToolSep != "" { // Tool name is between ToolStart and ToolSep // Use non-greedy match to capture function name until ToolSep // We can't use [^...] for multi-character strings, so use .*? with ToolSep toolCallRegex += `(.*?)` + escapeRegex(format.ToolSep) toolCallRegex += `(.*?)` + escapeRegex(toolEnd) } else { // Tool name might be on a separate line (GLM 4.5) or after ToolStart // For GLM 4.5: \nfunction_name\n... // Match function name until we find key_start or newline if format.KeyStart != "" { // Match whitespace/newlines, then function name, then whitespace, then key_start // We'll capture the function name and the rest (including key_start) toolCallRegex += `\s*([^\n` + escapeRegex(format.KeyStart) + `]+?)\s*` + escapeRegex(format.KeyStart) + `(.*?)` + escapeRegex(toolEnd) } else { // Match until newline toolCallRegex += `\s*([^\n]+)\s*(.*?)` + escapeRegex(toolEnd) } } return toolCallRegex } // Primary pattern with tool_end toolCallPatterns = append(toolCallPatterns, regexp.MustCompile(buildToolCallPattern(format.ToolEnd))) // Alternative pattern with last_tool_end if specified if format.LastToolEnd != nil && *format.LastToolEnd != "" { toolCallPatterns = append(toolCallPatterns, regexp.MustCompile(buildToolCallPattern(*format.LastToolEnd))) } // Extract content to search in searchContent := s if scopePattern != nil { scopeMatches := scopePattern.FindAllStringSubmatch(s, -1) if len(scopeMatches) == 0 { // Scope not found // If scope_end is not empty/whitespace, this might be an error // But scope is optional, so try parsing without scope if strings.TrimSpace(format.ScopeEnd) != "" { // Scope expected but not found - this might indicate incomplete input // For now, try parsing without scope (scope is optional) xlog.Debug("scope_start not found but scope_end is non-empty", "scope_end", format.ScopeEnd) } searchContent = s } else { // Process each scope match separately for _, scopeMatch := range scopeMatches { if len(scopeMatch) >= 3 { // scopeMatch[1] is the whitespace before scope_start (we validate it's only whitespace) // scopeMatch[2] is the content inside the scope prelude := scopeMatch[1] // Validate that prelude contains only whitespace (llama.cpp behavior) allWhitespace := true for _, r := range prelude { if !strings.ContainsRune(" \t\n\r", r) { allWhitespace = false break } } if !allWhitespace { // Non-whitespace before scope_start, skip this match // This matches llama.cpp's behavior (line 394) xlog.Debug("non-whitespace before scope_start, skipping match", "prelude", prelude) continue } scopeContent := scopeMatch[2] // Validate scope_end is present in the match (scope pattern should include it) // The regex pattern already includes scope_end, so if we matched, it should be there // But we can verify the match is complete // Find all tool calls within this scope - try both patterns var toolCallMatches [][]string for _, pattern := range toolCallPatterns { matches := pattern.FindAllStringSubmatch(scopeContent, -1) toolCallMatches = append(toolCallMatches, matches...) } for _, match := range toolCallMatches { if len(match) >= 3 { functionName := strings.TrimSpace(match[1]) // Handle Kimi-K2 function name prefix stripping: "functions.name:index" -> "name" if strings.HasPrefix(functionName, "functions.") { // Remove "functions." prefix functionName = functionName[10:] // Remove ":index" suffix if present if idx := strings.LastIndex(functionName, ":"); idx != -1 { // Check if what follows ":" is all digits suffix := functionName[idx+1:] if len(suffix) > 0 { allDigits := true for _, r := range suffix { if r < '0' || r > '9' { allDigits = false break } } if allDigits { functionName = functionName[:idx] } } } } var functionContent string if format.ToolSep == "" && format.KeyStart != "" { // Content includes key_start, so prepend it functionContent = format.KeyStart + match[2] } else { functionContent = match[2] } // Check for empty tool call: if tool_end appears in function name or content is empty // This matches llama.cpp's behavior (lines 419-424) if strings.Contains(functionName, format.ToolEnd) || (format.LastToolEnd != nil && strings.Contains(functionName, *format.LastToolEnd)) { // Empty tool call - emit with empty arguments cleanName := strings.TrimSpace(functionName) if idx := strings.Index(cleanName, format.ToolEnd); idx != -1 { cleanName = strings.TrimSpace(cleanName[:idx]) } else if format.LastToolEnd != nil { if idx := strings.Index(cleanName, *format.LastToolEnd); idx != -1 { cleanName = strings.TrimSpace(cleanName[:idx]) } } results = append(results, FuncCallResults{ Name: cleanName, Arguments: "{}", }) continue } // Check if content is empty or only whitespace if strings.TrimSpace(functionContent) == "" { // Empty tool call - emit with empty arguments results = append(results, FuncCallResults{ Name: functionName, Arguments: "{}", }) continue } // Parse parameters based on format args, err := parseXMLParametersWithFormat(functionContent, format) if err != nil { xlog.Debug("error parsing XML parameters", "error", err, "content", functionContent) continue } // If no parameters were parsed and content was not empty, still create tool call with empty args if len(args) == 0 && strings.TrimSpace(functionContent) != "" { // Check if there's any parameter-like content that just didn't match if !strings.Contains(functionContent, format.KeyStart) { argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) continue } } argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) } } } } return results, nil } } // No scope, find all tool calls directly in the string - try both patterns var toolCallMatches [][]string for _, pattern := range toolCallPatterns { matches := pattern.FindAllStringSubmatch(searchContent, -1) toolCallMatches = append(toolCallMatches, matches...) } if len(toolCallMatches) == 0 { return nil, nil } // Process each tool call for _, match := range toolCallMatches { if len(match) < 3 { continue } // Validate tool_end is complete (exact size match) // This matches llama.cpp's behavior (line 595) fullMatch := match[0] expectedToolEnd := format.ToolEnd if format.LastToolEnd != nil && strings.HasSuffix(fullMatch, *format.LastToolEnd) { expectedToolEnd = *format.LastToolEnd } if !strings.HasSuffix(fullMatch, expectedToolEnd) { // tool_end not found at end, skip this match xlog.Debug("tool_end validation failed", "expected", expectedToolEnd, "match", fullMatch) continue } // Verify the tool_end is exactly the expected size (not a partial match) // Extract the tool_end from the end of the match if len(fullMatch) < len(expectedToolEnd) { // Match is shorter than expected tool_end, skip continue } actualToolEnd := fullMatch[len(fullMatch)-len(expectedToolEnd):] if actualToolEnd != expectedToolEnd { // tool_end doesn't match exactly, skip xlog.Debug("tool_end size validation failed", "expected", expectedToolEnd, "actual", actualToolEnd) continue } functionName := strings.TrimSpace(match[1]) // Handle Kimi-K2 function name prefix stripping: "functions.name:index" -> "name" if strings.HasPrefix(functionName, "functions.") { // Remove "functions." prefix functionName = functionName[10:] // Remove ":index" suffix if present if idx := strings.LastIndex(functionName, ":"); idx != -1 { // Check if what follows ":" is all digits suffix := functionName[idx+1:] if len(suffix) > 0 { allDigits := true for _, r := range suffix { if r < '0' || r > '9' { allDigits = false break } } if allDigits { functionName = functionName[:idx] } } } } var functionContent string if len(match) >= 3 { if format.ToolSep == "" && format.KeyStart != "" { // For GLM 4.5 format, match[2] contains the content starting from key_start functionContent = match[2] } else { functionContent = match[2] } } // Check for empty tool call: if tool_end appears in function name prelude or content is empty // This matches llama.cpp's behavior (lines 419-424) // If the function name contains tool_end, it indicates the tool call has no arguments if strings.Contains(functionName, format.ToolEnd) || (format.LastToolEnd != nil && strings.Contains(functionName, *format.LastToolEnd)) { // Empty tool call - emit with empty arguments results = append(results, FuncCallResults{ Name: strings.TrimSpace(strings.Split(functionName, format.ToolEnd)[0]), Arguments: "{}", }) continue } // Check if content is empty or only whitespace (another indicator of empty tool call) if strings.TrimSpace(functionContent) == "" { // Empty tool call - emit with empty arguments results = append(results, FuncCallResults{ Name: functionName, Arguments: "{}", }) continue } // Parse parameters based on format args, err := parseXMLParametersWithFormat(functionContent, format) if err != nil { xlog.Debug("error parsing XML parameters", "error", err, "content", functionContent) continue } // If no parameters were parsed and content was not empty, still create tool call with empty args // This handles cases where parameters exist but couldn't be parsed if len(args) == 0 && strings.TrimSpace(functionContent) != "" { // Check if there's any parameter-like content that just didn't match // If not, treat as empty tool call if !strings.Contains(functionContent, format.KeyStart) { argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) continue } } argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) } return results, nil } // parseGLM45Format handles GLM 4.5 format: \nfunction_name\n......... func parseGLM45Format(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { var results []FuncCallResults // Pattern: \nfunction_name\n......... pattern := regexp.MustCompile(`(?s)\s*([^\n<]+)\s*(.*?)\s*`) matches := pattern.FindAllStringSubmatch(s, -1) for _, match := range matches { if len(match) >= 3 { functionName := strings.TrimSpace(match[1]) // Handle Kimi-K2 function name prefix stripping: "functions.name:index" -> "name" if strings.HasPrefix(functionName, "functions.") { // Remove "functions." prefix functionName = functionName[10:] // Remove ":index" suffix if present if idx := strings.LastIndex(functionName, ":"); idx != -1 { // Check if what follows ":" is all digits suffix := functionName[idx+1:] if len(suffix) > 0 { allDigits := true for _, r := range suffix { if r < '0' || r > '9' { allDigits = false break } } if allDigits { functionName = functionName[:idx] } } } } functionContent := match[2] // Check for empty tool call: if content is empty or only whitespace if strings.TrimSpace(functionContent) == "" { // Empty tool call - emit with empty arguments results = append(results, FuncCallResults{ Name: functionName, Arguments: "{}", }) continue } // Parse parameters using GLM 4.5 format args, err := parseXMLParametersWithFormat(functionContent, format) if err != nil { xlog.Debug("error parsing GLM 4.5 parameters", "error", err, "content", functionContent) continue } // If no parameters were parsed, still create tool call with empty args if len(args) == 0 { argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) continue } argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) } } return results, nil } // parseFunctionaryFormat handles Functionary format: {"key": "value"} func parseFunctionaryFormat(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { var results []FuncCallResults // Pattern: JSON pattern := regexp.MustCompile(`(?s)]+)>(.*?)`) matches := pattern.FindAllStringSubmatch(s, -1) for _, match := range matches { if len(match) >= 3 { functionName := strings.TrimSpace(match[1]) jsonContent := strings.TrimSpace(match[2]) // Parse JSON content as arguments var args map[string]any if err := json.Unmarshal([]byte(jsonContent), &args); err != nil { xlog.Debug("error parsing Functionary JSON", "error", err, "content", jsonContent) continue } argsJSON, _ := json.Marshal(args) results = append(results, FuncCallResults{ Name: functionName, Arguments: string(argsJSON), }) } } return results, nil } // parseJSONLikeXMLFormat handles formats like Apriel-1.5, Xiaomi-MiMo, Kimi-K2 that have JSON-like structure func parseJSONLikeXMLFormat(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { var results []FuncCallResults // Build pattern to match the JSON-like structure escapeRegex := func(str string) string { return regexp.QuoteMeta(str) } // Pattern: scope_start + tool_start + name + tool_sep + arguments + tool_end + scope_end var pattern *regexp.Regexp if format.ScopeStart != "" { patternStr := `(?s)` + escapeRegex(format.ScopeStart) + `(.*?)` + escapeRegex(format.ScopeEnd) pattern = regexp.MustCompile(patternStr) } else { patternStr := `(?s)` + escapeRegex(format.ToolStart) + `([^"]+)"` + escapeRegex(format.ToolSep) + `(.*?)` + escapeRegex(format.ToolEnd) pattern = regexp.MustCompile(patternStr) } matches := pattern.FindAllStringSubmatch(s, -1) for _, match := range matches { if len(match) < 2 { continue } // Extract JSON content jsonContent := match[1] if format.ScopeStart != "" { // Need to extract individual tool calls from the array // Pattern: {"name": "...", "arguments": {...}} toolPattern := regexp.MustCompile(`(?s)\{\s*"name"\s*:\s*"([^"]+)"\s*,\s*"arguments"\s*:\s*(\{.*?\})\s*\}`) toolMatches := toolPattern.FindAllStringSubmatch(jsonContent, -1) for _, toolMatch := range toolMatches { if len(toolMatch) >= 3 { functionName := strings.TrimSpace(toolMatch[1]) argsJSON := toolMatch[2] results = append(results, FuncCallResults{ Name: functionName, Arguments: argsJSON, }) } } } else { // Single tool call namePattern := regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`) nameMatch := namePattern.FindStringSubmatch(jsonContent) if len(nameMatch) >= 2 { functionName := strings.TrimSpace(nameMatch[1]) argsPattern := regexp.MustCompile(`"arguments"\s*:\s*(\{.*\})`) argsMatch := argsPattern.FindStringSubmatch(jsonContent) argsJSON := "{}" if len(argsMatch) >= 2 { argsJSON = argsMatch[1] } results = append(results, FuncCallResults{ Name: functionName, Arguments: argsJSON, }) } } } return results, nil } // utf8TruncateSafe truncates a string at a safe UTF-8 boundary // This prevents truncation in the middle of multi-byte characters // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 27-58 func utf8TruncateSafe(s string) string { if len(s) == 0 { return s } // Check if the string ends at a valid UTF-8 boundary // If not, truncate to the last valid boundary for i := len(s); i > 0 && i > len(s)-4; i-- { if utf8.ValidString(s[:i]) { return s[:i] } } // If we can't find a valid boundary in the last 4 bytes, truncate conservatively if len(s) > 3 { return s[:len(s)-3] } return "" } // PartialXMLResult represents a partial XML parsing result that can be emitted during streaming type PartialXMLResult struct { Results []FuncCallResults IsPartial bool PartialArg string // The argument that was partially parsed } // XML_TOOL_CALL_PARTIAL_FLAG is a marker used to indicate partial JSON in tool calls // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp line 314 const XML_TOOL_CALL_PARTIAL_FLAG = "XML_TOOL_CALL_PARTIAL_FLAG" // partialJSON cleans up partial JSON by removing incomplete parts marked with XML_TOOL_CALL_PARTIAL_FLAG // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 314-330 func partialJSON(jsonStr string) (string, bool) { pos := strings.LastIndex(jsonStr, XML_TOOL_CALL_PARTIAL_FLAG) if pos == -1 { return jsonStr, false } // Check that only valid JSON characters follow the flag for i := pos + len(XML_TOOL_CALL_PARTIAL_FLAG); i < len(jsonStr); i++ { ch := jsonStr[i] if ch != '\'' && ch != '"' && ch != '}' && ch != ':' && ch != ']' && !strings.ContainsRune(" \t\n\r", rune(ch)) { return jsonStr, false } } // Remove the flag and everything after it if pos > 0 && jsonStr[pos-1] == '"' { pos-- } return jsonStr[:pos], true } // genPartialJSON generates partial JSON with XML_TOOL_CALL_PARTIAL_FLAG marker // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 332-343 func genPartialJSON(args map[string]any, functionName string, rest string, needle string) (string, bool) { // Add the partial argument with the flag args[rest+needle] = XML_TOOL_CALL_PARTIAL_FLAG jsonBytes, err := json.Marshal(args) if err != nil { return "", false } jsonStr := string(jsonBytes) // Try to clean up the partial JSON if cleaned, isPartial := partialJSON(jsonStr); isPartial { return cleaned, true } return jsonStr, false } // parseXMLParametersWithFormat extracts parameters from XML content based on format configuration func parseXMLParametersWithFormat(content string, format *XMLToolCallFormat) (map[string]any, error) { args := make(map[string]any) // Handle GLM 4.5 format: keyvalue if format.KeyValSep2 != nil && *format.KeyValSep2 == "" { return parseGLM45Parameters(content, format) } // Special case: If content is already valid JSON and format expects JSON (like Kimi-K2), // try to parse it as JSON first if format.KeyStart == "\"" && format.KeyValSep == "\":" && (format.RawArgVal == nil || !*format.RawArgVal) { // Try parsing as complete JSON object first content = strings.TrimSpace(content) if strings.HasPrefix(content, "{") && strings.HasSuffix(content, "}") { var jsonArgs map[string]any if err := json.Unmarshal([]byte(content), &jsonArgs); err == nil { // Successfully parsed as JSON, return it return jsonArgs, nil } } } // Handle standard parameter format: value or value if format.KeyStart != "" { return parseStandardParameters(content, format) } return args, nil } // parseMsgWithXMLToolCalls parses content with reasoning blocks and XML tool calls // This handles or tags and extracts tool calls // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 654-872 func parseMsgWithXMLToolCalls(s string, format *XMLToolCallFormat, startThink string, endThink string) ([]FuncCallResults, string, error) { if startThink == "" { startThink = "" } if endThink == "" { endThink = "" } var results []FuncCallResults var reasoningContent strings.Builder var content strings.Builder // Simple approach: find reasoning blocks and tool calls // For more complex scenarios, we'd need iterative parsing thinkStartIdx := strings.Index(s, startThink) if thinkStartIdx == -1 { // No reasoning blocks, just parse tool calls xmlResults, err := parseXMLWithFormat(s, format) return xmlResults, "", err } // Process content before first thinking block if thinkStartIdx > 0 { preContent := s[:thinkStartIdx] xmlResults, _ := parseXMLWithFormat(preContent, format) results = append(results, xmlResults...) content.WriteString(preContent) } // Process thinking blocks and tool calls pos := 0 for pos < len(s) { thinkStart := strings.Index(s[pos:], startThink) if thinkStart == -1 { // No more thinking blocks, process rest remaining := s[pos:] xmlResults, _ := parseXMLWithFormat(remaining, format) results = append(results, xmlResults...) content.WriteString(remaining) break } thinkStart += pos thinkEnd := strings.Index(s[thinkStart+len(startThink):], endThink) if thinkEnd == -1 { // Unclosed thinking block if format.AllowToolcallInThink { // Allow tool calls in unclosed thinking block thinkingContent := s[thinkStart+len(startThink):] reasoningContent.WriteString(thinkingContent) // Try to parse tool calls from thinking content xmlResults, _ := parseXMLWithFormat(thinkingContent, format) results = append(results, xmlResults...) } else { // Skip tool calls in unclosed thinking block content.WriteString(s[pos:thinkStart]) } break } thinkEnd += thinkStart + len(startThink) // Extract thinking content thinkingContent := s[thinkStart+len(startThink) : thinkEnd] reasoningContent.WriteString(thinkingContent) // Check for tool calls between thinking blocks betweenContent := s[pos:thinkStart] if len(betweenContent) > 0 { xmlResults, _ := parseXMLWithFormat(betweenContent, format) results = append(results, xmlResults...) content.WriteString(betweenContent) } // Check for tool calls after thinking block pos = thinkEnd + len(endThink) } return results, reasoningContent.String(), nil } // parseGLM45Parameters handles GLM 4.5 format with and pairs func parseGLM45Parameters(content string, format *XMLToolCallFormat) (map[string]any, error) { args := make(map[string]any) // Pattern: keyvalue pattern := regexp.MustCompile(`(?s)(.*?)\s*(.*?)`) matches := pattern.FindAllStringSubmatch(content, -1) for _, match := range matches { if len(match) >= 3 { paramName := strings.TrimSpace(match[1]) paramValue := strings.TrimSpace(match[2]) args[paramName] = parseParameterValue(paramValue, format) } } return args, nil } // parseStandardParameters handles standard parameter formats func parseStandardParameters(content string, format *XMLToolCallFormat) (map[string]any, error) { args := make(map[string]any) escapeRegex := func(str string) string { return regexp.QuoteMeta(str) } // Build parameter patterns - try both primary and alternative endings var parameterPatterns []*regexp.Regexp if strings.Contains(format.KeyStart, "=") { // Format: value patternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^>]+)` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(format.ValEnd) parameterPatterns = append(parameterPatterns, regexp.MustCompile(patternStr)) // Add alternative ending if specified if format.LastValEnd != nil && *format.LastValEnd != "" { altPatternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^>]+)` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(*format.LastValEnd) parameterPatterns = append(parameterPatterns, regexp.MustCompile(altPatternStr)) } } else if strings.Contains(format.KeyStart, "name=\"") { // Format: value patternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^"]+)"` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(format.ValEnd) parameterPatterns = append(parameterPatterns, regexp.MustCompile(patternStr)) // Add alternative ending if specified if format.LastValEnd != nil && *format.LastValEnd != "" { altPatternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^"]+)"` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(*format.LastValEnd) parameterPatterns = append(parameterPatterns, regexp.MustCompile(altPatternStr)) } } else { // Fallback: try to match key_start...key_val_sep...val_end patternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^` + escapeRegex(format.KeyValSep) + `]+)` + escapeRegex(format.KeyValSep) if format.KeyValSep2 != nil { patternStr += escapeRegex(*format.KeyValSep2) } patternStr += `(.*?)` + escapeRegex(format.ValEnd) parameterPatterns = append(parameterPatterns, regexp.MustCompile(patternStr)) // Add alternative ending if specified if format.LastValEnd != nil && *format.LastValEnd != "" { altPatternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^` + escapeRegex(format.KeyValSep) + `]+)` + escapeRegex(format.KeyValSep) if format.KeyValSep2 != nil { altPatternStr += escapeRegex(*format.KeyValSep2) } altPatternStr += `(.*?)` + escapeRegex(*format.LastValEnd) parameterPatterns = append(parameterPatterns, regexp.MustCompile(altPatternStr)) } } // Track which parameters we've parsed to avoid duplicates // Use a map to store position info so we can handle last_val_end correctly type paramMatch struct { name string value string position int } var allMatches []paramMatch // Collect all matches from all patterns for _, pattern := range parameterPatterns { matches := pattern.FindAllStringSubmatch(content, -1) for _, match := range matches { if len(match) >= 3 { paramName := strings.TrimSpace(match[1]) paramValue := strings.TrimSpace(match[2]) // Find the position of this match in the content pos := strings.Index(content, match[0]) if pos != -1 { allMatches = append(allMatches, paramMatch{ name: paramName, value: paramValue, position: pos, }) } } } } // Sort by position to process in order // If we have last_val_end, the last parameter should use it // For now, we'll use the first match for each parameter name (primary pattern takes precedence) seenParams := make(map[string]bool) for _, match := range allMatches { if !seenParams[match.name] { args[match.name] = parseParameterValue(match.value, format) seenParams[match.name] = true } } return args, nil } // parseParameterValue parses a parameter value based on format configuration // Implements JSON-first parsing: tries JSON parsing first (if raw_argval is false/null), // validates JSON is complete, then falls back to text parsing. // This matches llama.cpp's behavior in chat-parser-xml-toolcall.cpp lines 501-555 func parseParameterValue(paramValue string, format *XMLToolCallFormat) any { // Trim if configured if format.TrimRawArgVal { paramValue = strings.TrimSpace(paramValue) } // Handle raw_argval option if format.RawArgVal != nil { if *format.RawArgVal { // Raw string only - no JSON parsing return paramValue } // raw_argval is false - JSON only, must be valid JSON var jsonValue any if err := json.Unmarshal([]byte(paramValue), &jsonValue); err == nil { // Valid JSON - return parsed value (including primitives) return jsonValue } // JSON parsing failed but raw_argval is false - return as string anyway // (llama.cpp would throw an error, but we're more lenient) return paramValue } // Default: raw_argval is nil - try JSON first, fallback to text // This matches llama.cpp's behavior where both are allowed when raw_argval is nullopt var jsonValue any if err := json.Unmarshal([]byte(paramValue), &jsonValue); err != nil { // Not valid JSON, treat as plain text string return paramValue } // Valid JSON was parsed - return the parsed value // This includes objects, arrays, and primitives (null, true, false, numbers, strings) // This matches llama.cpp's behavior where JSON values (including primitives) are used as-is return jsonValue } func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncCallResults { xlog.Debug("LLM result", "result", llmresult) for _, item := range functionConfig.ReplaceFunctionResults { k, v := item.Key, item.Value xlog.Debug("Replacing", "key", k, "value", v) re := regexp.MustCompile(k) llmresult = re.ReplaceAllString(llmresult, v) } xlog.Debug("LLM result(function cleanup)", "result", llmresult) functionNameKey := defaultFunctionNameKey functionArgumentsKey := defaultFunctionArgumentsKey if functionConfig.FunctionNameKey != "" { functionNameKey = functionConfig.FunctionNameKey } if functionConfig.FunctionArgumentsKey != "" { functionArgumentsKey = functionConfig.FunctionArgumentsKey } results := []FuncCallResults{} llmResults := []string{} returnResult := func(results []string) (result []FuncCallResults, e error) { // As we have to change the result before processing, we can't stream the answer token-by-token (yet?) result = make([]FuncCallResults, 0) for _, s := range results { var ss []map[string]any s = utils.EscapeNewLines(s) ss, err := ParseJSON(s) //err := json.Unmarshal([]byte(s), &ss) if err != nil { xlog.Debug("unable to unmarshal llm result in a single object or an array of JSON objects", "error", err, "escapedLLMResult", s) } xlog.Debug("Function return", "result", s, "parsed", ss) for _, s := range ss { // The grammar defines the function name as "function", while OpenAI returns "name" func_name, ok := s[functionNameKey] if !ok { continue //return result, fmt.Errorf("unable to find function name in result") } // Arguments from grammar result is a map[string]interface{}, but OpenAI expects a stringified JSON object // We marshal it to JSON string here to match OpenAI's format args, ok := s[functionArgumentsKey] if !ok { continue //return result, fmt.Errorf("unable to find arguments in result") } // Marshal arguments to JSON string (handles both object and string cases) var d []byte if argsStr, ok := args.(string); ok { // Already a string, use it directly d = []byte(argsStr) } else { // Object, marshal to JSON d, _ = json.Marshal(args) } funcName, ok := func_name.(string) if !ok { continue //return result, fmt.Errorf("unable to cast function name to string") } result = append(result, FuncCallResults{Name: funcName, Arguments: string(d)}) } } return result, nil } // the response is a string that we have to parse result := make(map[string]string) if len(functionConfig.JSONRegexMatch) != 0 { for _, r := range functionConfig.JSONRegexMatch { // We use a regex to extract the JSON object from the response var respRegex = regexp.MustCompile(r) match := respRegex.FindAllStringSubmatch(llmresult, -1) var allMatches []string for _, m := range match { if len(m) > 1 { // we match the first group allMatches = append(allMatches, m[1]) } } if len(allMatches) > 0 { llmResults = append(llmResults, allMatches...) break } } } if len(functionConfig.ResponseRegex) > 0 { // We use named regexes here to extract the function name and arguments // obviously, this expects the LLM to be stable and return correctly formatted JSON // Pre-compile regexes for better performance compiledRegexes := make([]*regexp.Regexp, 0, len(functionConfig.ResponseRegex)) for _, r := range functionConfig.ResponseRegex { compiledRegexes = append(compiledRegexes, regexp.MustCompile(r)) } for _, respRegex := range compiledRegexes { matches := respRegex.FindAllStringSubmatch(llmresult, -1) for _, match := range matches { for i, name := range respRegex.SubexpNames() { if i != 0 && name != "" && len(match) > i { result[name] = match[i] } } functionName := result[functionNameKey] if functionName == "" { return results } results = append(results, FuncCallResults{Name: result[functionNameKey], Arguments: ParseFunctionCallArgs(result[functionArgumentsKey], functionConfig)}) } } } else { if len(llmResults) == 0 { llmResults = append(llmResults, llmresult) } results, _ = returnResult(llmResults) } // Determine which XML format to use (if any) var xmlFormat *XMLToolCallFormat if functionConfig.XMLFormat != nil { // Custom format specified xmlFormat = functionConfig.XMLFormat xlog.Debug("Using custom XML format") } else if functionConfig.XMLFormatPreset != "" { // Preset format specified xmlFormat = GetXMLFormatPreset(functionConfig.XMLFormatPreset) if xmlFormat == nil { xlog.Debug("Unknown XML format preset, falling back to auto-detection", "preset", functionConfig.XMLFormatPreset) } else { xlog.Debug("Using XML format preset", "preset", functionConfig.XMLFormatPreset) } } // If xmlFormat is still nil, ParseXML will auto-detect // If no results from JSON parsing, try XML parsing // This handles cases where the response contains XML tool calls instead of JSON, // or mixed content with XML tool calls // Skip XML parsing if JSONRegexMatch or ResponseRegex was used and found results (to avoid double-parsing) // ResponseRegex extracts content that might look like XML (e.g., args) // but we've already parsed it, so we shouldn't try XML parsing on the same content skipXMLParsing := (len(functionConfig.JSONRegexMatch) > 0 || len(functionConfig.ResponseRegex) > 0) && len(results) > 0 if len(results) == 0 && !skipXMLParsing { xmlResults, err := ParseXML(llmresult, xmlFormat) if err == nil && len(xmlResults) > 0 { xlog.Debug("Found XML tool calls", "count", len(xmlResults)) results = append(results, xmlResults...) } } else if len(results) > 0 && !skipXMLParsing { // Even if we found JSON results, check for XML tool calls in the response // This handles mixed content scenarios (text + JSON + XML) // But skip if JSONRegexMatch or ResponseRegex was used (they already extracted the content) xmlResults, err := ParseXML(llmresult, xmlFormat) if err == nil && len(xmlResults) > 0 { xlog.Debug("Found additional XML tool calls alongside JSON", "xml_count", len(xmlResults)) results = append(results, xmlResults...) } } return results } func ParseFunctionCallArgs(functionArguments string, functionConfig FunctionsConfig) string { // Clean up double curly braces (common issue with template engines) // Replace {{ with { and }} with } but only if they appear at the start/end // This handles cases like {{"key":"value"}} -> {"key":"value"} cleaned := functionArguments //if strings.HasPrefix(cleaned, "{{") && strings.HasSuffix(cleaned, "}}") { // Check if it's double braces at the boundaries // cleaned = strings.TrimPrefix(cleaned, "{") // cleaned = strings.TrimSuffix(cleaned, "}") //} if len(functionConfig.ArgumentRegex) == 0 { return cleaned } // We use named regexes here to extract the function argument key value pairs and convert this to valid json. // TODO: there might be responses where an object as a value is expected/required. This is currently not handled. args := make(map[string]string) agrsRegexKeyName := "key" agrsRegexValueName := "value" if functionConfig.ArgumentRegexKey != "" { agrsRegexKeyName = functionConfig.ArgumentRegexKey } if functionConfig.ArgumentRegexValue != "" { agrsRegexValueName = functionConfig.ArgumentRegexValue } for _, r := range functionConfig.ArgumentRegex { var respRegex = regexp.MustCompile(r) var nameRange []string = respRegex.SubexpNames() var keyIndex = slices.Index(nameRange, agrsRegexKeyName) var valueIndex = slices.Index(nameRange, agrsRegexValueName) matches := respRegex.FindAllStringSubmatch(functionArguments, -1) for _, match := range matches { args[match[keyIndex]] = match[valueIndex] } } jsonBytes, _ := json.Marshal(args) return string(jsonBytes) }