package functions import ( "encoding/json" "errors" "io" "regexp" "slices" "strings" "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"` // DisablePEGParser disables the PEG parser and falls back to the legacy iterative parser DisablePEGParser bool `yaml:"disable_peg_parser,omitempty" json:"disable_peg_parser,omitempty"` // ToolFormatMarkers holds auto-detected markers from the C++ backend (via gRPC). // When set, these are used to build the PEG parser dynamically instead of using presets. ToolFormatMarkers *ToolFormatMarkers `yaml:"-" json:"-"` } // ToolFormatMarkers holds auto-detected tool format markers from the C++ autoparser. type ToolFormatMarkers struct { FormatType string // "json_native", "tag_with_json", "tag_with_tagged" // Tool section markers SectionStart string SectionEnd string PerCallStart string PerCallEnd string // Function name markers FuncNamePrefix string FuncNameSuffix string FuncClose string // Argument markers ArgNamePrefix string ArgNameSuffix string ArgValuePrefix string ArgValueSuffix string ArgSeparator string ArgsStart string ArgsEnd string // JSON format fields NameField string ArgsField string IDField string FunNameIsKey bool ToolsArrayWrapped bool FunctionField string ParameterOrder []string // Generated ID field GenIDField string // Call ID markers CallIDPosition string // "none", "pre_func_name", "between_func_and_args", "post_args" CallIDPrefix string CallIDSuffix string // Reasoning markers ReasoningStart string ReasoningEnd string // Content markers ContentStart string ContentEnd string } // @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 ID 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: "qwen3.5", 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, }, }, } } // ParseXML parses XML-formatted tool calls from an LLM response string. // Tries the iterative parser first, then falls back to the PEG parser. func ParseXML(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { results, err := ParseXMLIterative(s, format, false) if err == nil && len(results) > 0 { return results, nil } // Fall back to PEG parser for formats that the iterative parser doesn't handle pegResults := ParseFunctionCallPEG(s, FunctionsConfig{XMLFormat: format}) if len(pegResults) > 0 { return pegResults, nil } return results, err } // getScopeOrToolStart returns the scope start marker if set, else the tool start marker. func getScopeOrToolStart(format *XMLToolCallFormat) string { if format == nil { return "" } if format.ScopeStart != "" { return format.ScopeStart } return format.ToolStart } // tryParseXMLFromScopeStart finds the first occurrence of scopeStart (or format.ToolStart), // splits the input there, and parses only the suffix as XML tool calls. Returns (toolCalls, true) // if any tool calls were parsed, else (nil, false). This mimics llama.cpp's PEG order so that // reasoning or content before the tool block does not cause "whitespace only before scope" to fail. func tryParseXMLFromScopeStart(s string, format *XMLToolCallFormat, isPartial bool) ([]FuncCallResults, bool) { if format == nil { return nil, false } scopeStart := getScopeOrToolStart(format) if scopeStart == "" { return nil, false } idx := strings.Index(s, scopeStart) if idx < 0 { return nil, false } toolCallsPart := s[idx:] parser := NewChatMsgParser(toolCallsPart, isPartial) success, err := parser.TryConsumeXMLToolCalls(format) if err != nil { if _, ok := err.(*ChatMsgPartialException); ok && isPartial { return parser.ToolCalls(), len(parser.ToolCalls()) > 0 } return nil, false } if success && len(parser.ToolCalls()) > 0 { return parser.ToolCalls(), true } return nil, false } // ParseXMLIterative parses XML tool calls using the iterative parser // This provides better streaming and partial parsing support. // When format is nil or when format is set, tries "find scope/tool start, split, parse suffix" // first (llama.cpp PEG order) so that content before the tool block does not cause parse failure. func ParseXMLIterative(s string, format *XMLToolCallFormat, isPartial bool) ([]FuncCallResults, error) { // Try split-on-scope first so reasoning/content before tool block is skipped if format != nil { if results, ok := tryParseXMLFromScopeStart(s, format, isPartial); ok { return results, nil } } else { formats := getAllXMLFormats() for _, fmtPreset := range formats { if fmtPreset.format != nil { if results, ok := tryParseXMLFromScopeStart(s, fmtPreset.format, isPartial); ok { return results, nil } } } } 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 } // 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 } // 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 } // 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{} extractJSON := 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, _ = extractJSON(llmResults) } // Try PEG parser (unless disabled) — this is the primary tool call parser pegFound := false if !functionConfig.DisablePEGParser { xlog.Debug("[ParseFunctionCall] trying PEG parser") pegResults := ParseFunctionCallPEG(llmresult, functionConfig) if len(pegResults) > 0 { xlog.Debug("[ParseFunctionCall] PEG parser found tool calls", "count", len(pegResults)) results = mergeResults(results, pegResults) pegFound = true } else { xlog.Debug("[ParseFunctionCall] PEG parser found no tool calls") } } else { xlog.Debug("[ParseFunctionCall] PEG parser disabled, skipping") } // Fallback: try iterative XML parser only when PEG didn't find results // and the input looks like it contains XML tool call markers. // This handles edge cases like trailing content after tool calls. if !pegFound && (strings.Contains(llmresult, "") || strings.Contains(llmresult, " 0 { // Filter out malformed results where the name looks like JSON var validResults []FuncCallResults for _, r := range xmlResults { if !strings.HasPrefix(strings.TrimSpace(r.Name), "{") { validResults = append(validResults, r) } } if len(validResults) > 0 { xlog.Debug("[ParseFunctionCall] XML fallback found tool calls", "count", len(validResults)) results = mergeResults(results, validResults) } } } return results } // mergeResults combines two result slices, deduplicating by name+arguments. func mergeResults(existing, additional []FuncCallResults) []FuncCallResults { seen := make(map[string]bool) for _, r := range existing { seen[r.Name+"|"+r.Arguments] = true } for _, r := range additional { key := r.Name + "|" + r.Arguments if !seen[key] { existing = append(existing, r) seen[key] = true } } return existing } 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) }