From 21c11c5fdd29cbec690d55050f58425d6691ea1b Mon Sep 17 00:00:00 2001 From: Luxferre Date: Mon, 7 Sep 2026 15:19:23 +0300 Subject: [PATCH] Make prompt-augmented tool calling robust across non-native spaces --- gr2gw.go | 923 +++++++++++++++++++++++++++++++++++++++++++++----- gr2gw_test.go | 228 +++++++++++++ 2 files changed, 1065 insertions(+), 86 deletions(-) diff --git a/gr2gw.go b/gr2gw.go index 7258b1c..caec639 100644 --- a/gr2gw.go +++ b/gr2gw.go @@ -461,20 +461,42 @@ func BuildToolInstruction(tools []Tool, toolChoice interface{}) string { if tcStr, ok := toolChoice.(string); ok && tcStr == "none" { return "" } - toolsBytes, _ := json.MarshalIndent(tools, "", " ") - sampleFnName := "" + var cleanToolDefs []map[string]interface{} + sampleFnName := "function_name" + sampleArgs := `{"param1": "value1"}` + for _, t := range tools { if fnMap, ok := t.Function.(map[string]interface{}); ok { - if n, ok := fnMap["name"].(string); ok && n != "" { - sampleFnName = n - break + cleanToolDefs = append(cleanToolDefs, fnMap) + if sampleFnName == "function_name" { + if n, ok := fnMap["name"].(string); ok && n != "" { + sampleFnName = n + if params, ok := fnMap["parameters"].(map[string]interface{}); ok { + if props, ok := params["properties"].(map[string]interface{}); ok { + sampleArgMap := make(map[string]interface{}) + for propName := range props { + sampleArgMap[propName] = "value" + break + } + if len(sampleArgMap) > 0 { + if b, err := json.Marshal(sampleArgMap); err == nil { + sampleArgs = string(b) + } + } + } + } + } } + } else { + cleanToolDefs = append(cleanToolDefs, map[string]interface{}{ + "type": t.Type, + "function": t.Function, + }) } } - if sampleFnName == "" { - sampleFnName = "function_name" - } + + toolsBytes, _ := json.MarshalIndent(cleanToolDefs, "", " ") directive := "If a tool is relevant, emit the tool call in tags with JSON content. If no tools are relevant, answer the query directly." if tcStr, ok := toolChoice.(string); ok && tcStr == "required" { @@ -493,10 +515,11 @@ Available Tools: Syntax: -{"name": "%s", "arguments": {...}} +{"name": "%s", "arguments": %s} -%s`, string(toolsBytes), sampleFnName, directive) +When a tool result is returned in , formulate your answer based on it. +%s`, string(toolsBytes), sampleFnName, sampleArgs, directive) } func TransformMessages(req ChatCompletionRequest) (processed []ChatMessage, toolInstruction string, hasSystem bool) { @@ -662,7 +685,16 @@ var ToolTagPairs = []ToolTagPair{ {Start: "", End: ""}, {Start: "", End: ""}, {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, + {Start: "", End: ""}, {Start: "[TOOL_CALLS]", End: "[/TOOL_CALLS]"}, + {Start: "[TOOL_CALL]", End: "[/TOOL_CALL]"}, } func getToolStartPrefixes() []string { @@ -677,21 +709,397 @@ func getToolStartPrefixes() []string { } } } + extraStarts := []string{ + "= n { + break + } + + keyStart := i + for i < n && ((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= '0' && s[i] <= '9') || s[i] == '_') { + i++ + } + key := s[keyStart:i] + if key == "" { + break + } + + for i < n && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i >= n || s[i] != '=' { + break + } + i++ + + for i < n && (s[i] == ' ' || s[i] == '\t') { + i++ + } + if i >= n { + break + } + + if s[i] == '"' || s[i] == '\'' { + quote := s[i] + i++ + var valBuilder strings.Builder + escaped := false + for i < n { + c := s[i] + if escaped { + valBuilder.WriteByte(c) + escaped = false + i++ + continue + } + if c == '\\' { + escaped = true + i++ + continue + } + if c == quote { + break + } + valBuilder.WriteByte(c) + i++ + } + if i < n && s[i] == quote { + i++ + } + res[key] = valBuilder.String() + } else if s[i] == '{' || s[i] == '[' { + openChar := s[i] + closeChar := byte('}') + if openChar == '[' { + closeChar = ']' + } + valStart := i + depth := 0 + inStr := false + var strQuote byte + escaped := false + for i < n { + c := s[i] + if escaped { + escaped = false + i++ + continue + } + if c == '\\' { + escaped = true + i++ + continue + } + if inStr { + if c == strQuote { + inStr = false + } + i++ + continue + } + if c == '"' || c == '\'' { + inStr = true + strQuote = c + i++ + continue + } + if c == openChar { + depth++ + } else if c == closeChar { + depth-- + if depth == 0 { + i++ + break + } + } + i++ + } + subStr := s[valStart:i] + var subJSON interface{} + cleanedSub := repairPythonJSON(subStr) + if json.Unmarshal([]byte(cleanedSub), &subJSON) == nil { + res[key] = subJSON + } else { + res[key] = subStr + } + } else { + valStart := i + for i < n && s[i] != ',' && s[i] != ')' && s[i] != '\n' { + i++ + } + rawVal := strings.TrimSpace(s[valStart:i]) + if rawVal == "True" || rawVal == "true" { + res[key] = true + } else if rawVal == "False" || rawVal == "false" { + res[key] = false + } else if rawVal == "None" || rawVal == "null" { + res[key] = nil + } else if num, err := strconv.ParseFloat(rawVal, 64); err == nil { + res[key] = num + } else { + res[key] = rawVal + } + } + } + + return res, len(res) > 0 +} + +func parsePythonFunctionCall(input string) (ToolCall, bool) { + s := strings.TrimSpace(input) + if strings.HasPrefix(s, "```") { + s = cleanJSONBlock(s) + } + reCall := regexp.MustCompile(`^(?:tools\.|functions\.)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*)\)$`) + matches := reCall.FindStringSubmatch(s) + if len(matches) < 3 { + return ToolCall{}, false + } + + fnName := matches[1] + rawArgs := strings.TrimSpace(matches[2]) + + if rawArgs == "" { + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: "{}", + }, + }, true + } + + if strings.HasPrefix(rawArgs, "{") && strings.HasSuffix(rawArgs, "}") { + repaired := repairPythonJSON(rawArgs) + var dummy map[string]interface{} + if json.Unmarshal([]byte(repaired), &dummy) == nil { + b, _ := json.Marshal(dummy) + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: string(b), + }, + }, true + } + } + + argMap, ok := parsePythonKwargs(rawArgs) + if ok && len(argMap) > 0 { + b, err := json.Marshal(argMap) + if err == nil { + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: string(b), + }, + }, true + } + } + + if (strings.HasPrefix(rawArgs, `"`) && strings.HasSuffix(rawArgs, `"`)) || + (strings.HasPrefix(rawArgs, `'`) && strings.HasSuffix(rawArgs, `'`)) { + val := rawArgs[1 : len(rawArgs)-1] + b, _ := json.Marshal(map[string]interface{}{"input": val}) + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: string(b), + }, + }, true + } + + return ToolCall{}, false +} + +func parseMultiplePythonFunctionCalls(input string) ([]ToolCall, bool) { + s := strings.TrimSpace(input) + if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") { + s = s[1 : len(s)-1] + } + reCalls := regexp.MustCompile(`(?:tools\.|functions\.)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)`) + matches := reCalls.FindAllString(s, -1) + if len(matches) == 0 { + return nil, false + } + var calls []ToolCall + for _, m := range matches { + if tc, ok := parsePythonFunctionCall(m); ok { + calls = append(calls, tc) + } + } + if len(calls) > 0 { + return calls, true + } + return nil, false +} + +func parseReActToolCall(input string) (ToolCall, bool) { + reAction := regexp.MustCompile(`(?i)(?:Action|Command):\s*([a-zA-Z0-9_.-]+)\s*\n+(?:Action Input|Arguments|Parameters|Args):\s*(\{[\s\S]*?\}|\[[\s\S]*?\]|[^\n]+)`) + matches := reAction.FindStringSubmatch(input) + if len(matches) < 3 { + return ToolCall{}, false + } + + fnName := strings.TrimSpace(matches[1]) + fnName = strings.TrimPrefix(fnName, "tools.") + fnName = strings.TrimPrefix(fnName, "functions.") + rawArgs := strings.TrimSpace(matches[2]) + + if strings.HasPrefix(rawArgs, "{") && strings.HasSuffix(rawArgs, "}") { + repaired := repairPythonJSON(rawArgs) + var dummy map[string]interface{} + if json.Unmarshal([]byte(repaired), &dummy) == nil { + b, _ := json.Marshal(dummy) + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: string(b), + }, + }, true + } + } + + if argMap, ok := parsePythonKwargs(rawArgs); ok && len(argMap) > 0 { + b, _ := json.Marshal(argMap) + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: string(b), + }, + }, true + } + + b, _ := json.Marshal(map[string]interface{}{"input": rawArgs}) + return ToolCall{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: fnName, + Arguments: string(b), + }, + }, true +} + func repairToolCallJSON(input string) (ToolCall, bool) { s := strings.TrimSpace(input) - reName := regexp.MustCompile(`"(?:name|function|action|call)"\s*:\s*"([^"]+)"`) + if !strings.HasPrefix(s, "{") || !strings.HasSuffix(s, "}") { + return ToolCall{}, false + } + reName := regexp.MustCompile(`"(?:name|function|function_name|action|command|call|tool|tool_name)"\s*:\s*"([^"]+)"`) matches := reName.FindStringSubmatch(s) if len(matches) < 2 { return ToolCall{}, false } fnName := matches[1] - reArgsObj := regexp.MustCompile(`"(?:arguments|parameters|args|input)"\s*:\s*(\{[\s\S]*\})`) + reArgsObj := regexp.MustCompile(`"(?:arguments|parameters|params|args|input|action_input|inputs|properties)"\s*:\s*(\{[\s\S]*\})`) argMatches := reArgsObj.FindStringSubmatch(s) argsStr := "{}" if len(argMatches) >= 2 { @@ -699,9 +1107,12 @@ func repairToolCallJSON(input string) (ToolCall, bool) { var dummy map[string]interface{} if json.Unmarshal([]byte(candidate), &dummy) == nil { argsStr = candidate + } else if json.Unmarshal([]byte(repairPythonJSON(candidate)), &dummy) == nil { + b, _ := json.Marshal(dummy) + argsStr = string(b) } } else { - reArgsStr := regexp.MustCompile(`"(?:arguments|parameters|args|input)"\s*:\s*"((?:\\.|[^"\\])*)"`) + reArgsStr := regexp.MustCompile(`"(?:arguments|parameters|params|args|input|action_input|inputs|properties)"\s*:\s*"((?:\\.|[^"\\])*)"`) strMatches := reArgsStr.FindStringSubmatch(s) if len(strMatches) >= 2 { var unescaped string @@ -724,23 +1135,34 @@ func repairToolCallJSON(input string) (ToolCall, bool) { func parseSingleToolCall(jsonStr string) (ToolCall, bool) { cleaned := cleanJSONBlock(jsonStr) var raw map[string]interface{} - if err := json.Unmarshal([]byte(cleaned), &raw); err == nil { + err := json.Unmarshal([]byte(cleaned), &raw) + if err != nil { + repaired := repairPythonJSON(cleaned) + err = json.Unmarshal([]byte(repaired), &raw) + } + if err == nil { sanitizedRaw, ok := sanitizeJSONValue(raw).(map[string]interface{}) if !ok { sanitizedRaw = raw } - for _, wrapperKey := range []string{"function", "function_call", "tool_call"} { + for _, wrapperKey := range []string{"function", "function_call", "tool_call", "tool", "call", "command", "action"} { if fnObj, ok := sanitizedRaw[wrapperKey].(map[string]interface{}); ok { - if nameVal, ok := fnObj["name"].(string); ok && nameVal != "" { + nameVal := "" + for _, nk := range []string{"name", "function", "function_name", "action", "command", "call", "tool", "tool_name"} { + if n, ok := fnObj[nk].(string); ok && n != "" { + nameVal = n + break + } + } + if nameVal != "" { argsStr := "{}" var argsVal interface{} - if a, hasA := fnObj["arguments"]; hasA { - argsVal = a - } else if p, hasP := fnObj["parameters"]; hasP { - argsVal = p - } else if args, hasArgs := fnObj["args"]; hasArgs { - argsVal = args + for _, ak := range []string{"arguments", "parameters", "params", "args", "input", "action_input", "inputs", "properties"} { + if a, hasA := fnObj[ak]; hasA { + argsVal = a + break + } } if argsVal != nil { if s, isStr := argsVal.(string); isStr { @@ -748,6 +1170,9 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) { if json.Unmarshal([]byte(s), &innerObj) == nil { b, _ := json.Marshal(sanitizeJSONValue(innerObj)) argsStr = string(b) + } else if json.Unmarshal([]byte(repairPythonJSON(s)), &innerObj) == nil { + b, _ := json.Marshal(sanitizeJSONValue(innerObj)) + argsStr = string(b) } else { argsStr = strings.TrimSpace(s) } @@ -769,7 +1194,7 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) { } nameVal := "" - for _, key := range []string{"name", "function", "action", "call"} { + for _, key := range []string{"name", "function", "function_name", "tool", "tool_name", "action", "command", "call"} { if n, ok := sanitizedRaw[key].(string); ok && n != "" { nameVal = n break @@ -779,7 +1204,7 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) { if nameVal != "" { argsStr := "{}" var argsVal interface{} - for _, key := range []string{"arguments", "parameters", "args", "input"} { + for _, key := range []string{"arguments", "parameters", "params", "args", "input", "action_input", "inputs", "properties"} { if a, ok := sanitizedRaw[key]; ok { argsVal = a break @@ -791,6 +1216,9 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) { if json.Unmarshal([]byte(s), &innerObj) == nil { b, _ := json.Marshal(sanitizeJSONValue(innerObj)) argsStr = string(b) + } else if json.Unmarshal([]byte(repairPythonJSON(s)), &innerObj) == nil { + b, _ := json.Marshal(sanitizeJSONValue(innerObj)) + argsStr = string(b) } else { argsStr = strings.TrimSpace(s) } @@ -801,7 +1229,7 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) { } else { argsMap := make(map[string]interface{}) for k, v := range sanitizedRaw { - if k != "name" && k != "function" && k != "type" && k != "action" && k != "call" { + if k != "name" && k != "function" && k != "function_name" && k != "type" && k != "action" && k != "command" && k != "call" && k != "tool" && k != "tool_name" && k != "id" { argsMap[k] = v } } @@ -821,7 +1249,13 @@ func parseSingleToolCall(jsonStr string) (ToolCall, bool) { } } - return repairToolCallJSON(cleaned) + if tc, ok := repairToolCallJSON(cleaned); ok { + return tc, true + } + if tc, ok := parsePythonFunctionCall(cleaned); ok { + return tc, true + } + return parseReActToolCall(cleaned) } func parseMultipleToolCalls(raw string) ([]ToolCall, bool) { @@ -830,9 +1264,12 @@ func parseMultipleToolCalls(raw string) ([]ToolCall, bool) { return nil, false } - // 1. Direct JSON array: [{"name":...}, ...] var rawList []interface{} - if err := json.Unmarshal([]byte(cleaned), &rawList); err == nil { + err := json.Unmarshal([]byte(cleaned), &rawList) + if err != nil { + err = json.Unmarshal([]byte(repairPythonJSON(cleaned)), &rawList) + } + if err == nil { var calls []ToolCall for _, item := range rawList { b, err := json.Marshal(item) @@ -847,10 +1284,13 @@ func parseMultipleToolCalls(raw string) ([]ToolCall, bool) { } } - // 2. Wrapper object with tool_calls / calls array var rawMap map[string]interface{} - if err := json.Unmarshal([]byte(cleaned), &rawMap); err == nil { - for _, listKey := range []string{"tool_calls", "calls", "functions"} { + err = json.Unmarshal([]byte(cleaned), &rawMap) + if err != nil { + err = json.Unmarshal([]byte(repairPythonJSON(cleaned)), &rawMap) + } + if err == nil { + for _, listKey := range []string{"tool_calls", "calls", "functions", "actions", "commands"} { if subArr, ok := rawMap[listKey].([]interface{}); ok && len(subArr) > 0 { var calls []ToolCall for _, item := range subArr { @@ -868,16 +1308,166 @@ func parseMultipleToolCalls(raw string) ([]ToolCall, bool) { } } - // 3. Single tool call if tc, ok := parseSingleToolCall(cleaned); ok { return []ToolCall{tc}, true } + if calls, ok := parseMultiplePythonFunctionCalls(cleaned); ok && len(calls) > 0 { + return calls, true + } + return nil, false } func parseXMLToolCall(block string) ([]ToolCall, bool) { inner := strings.TrimSpace(block) + + var tagFnName string + reTagAttr := regexp.MustCompile(`^<[a-zA-Z0-9_.:-]+\s+[^>]*?(?:name|function)=["']([^"']+)["'][^>]*>`) + if m := reTagAttr.FindStringSubmatch(inner); len(m) >= 2 { + tagFnName = m[1] + } else { + reColonTag := regexp.MustCompile(`^<(?:call|tool_call|function_call):([a-zA-Z0-9_-]+)>`) + if m := reColonTag.FindStringSubmatch(inner); len(m) >= 2 { + tagFnName = m[1] + } else { + reEqTag := regexp.MustCompile(`^`) + if m := reEqTag.FindStringSubmatch(inner); len(m) >= 2 { + tagFnName = m[1] + } + } + } + + if tagFnName != "" { + reOpen := regexp.MustCompile(`^<[^>]+>`) + reClose := regexp.MustCompile(`]+>\s*$`) + innerStripped := reOpen.ReplaceAllString(inner, "") + innerStripped = reClose.ReplaceAllString(innerStripped, "") + innerStripped = cleanJSONBlock(innerStripped) + + reParam := regexp.MustCompile(`<(?:parameter|param)\s+name=["']([^"']+)["']>([\s\S]*?)`) + paramMatches := reParam.FindAllStringSubmatch(innerStripped, -1) + if len(paramMatches) > 0 { + paramMap := make(map[string]interface{}) + for _, pm := range paramMatches { + k := pm[1] + v := strings.TrimSpace(pm[2]) + if num, err := strconv.ParseFloat(v, 64); err == nil && !strings.HasPrefix(v, "0") { + paramMap[k] = num + } else if v == "true" { + paramMap[k] = true + } else if v == "false" { + paramMap[k] = false + } else { + paramMap[k] = v + } + } + b, _ := json.Marshal(paramMap) + return []ToolCall{{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: tagFnName, + Arguments: string(b), + }, + }}, true + } + + var argsStr string + for _, argKey := range []string{"arguments", "parameters", "args", "input", "action_input"} { + openTag := "<" + argKey + ">" + closeTag := "" + if strings.Contains(innerStripped, openTag) && strings.Contains(innerStripped, closeTag) { + aStart := strings.Index(innerStripped, openTag) + len(openTag) + aEnd := strings.Index(innerStripped, closeTag) + if aStart < aEnd { + argsStr = strings.TrimSpace(innerStripped[aStart:aEnd]) + break + } + } + } + if argsStr == "" { + argsStr = innerStripped + } + + if strings.HasPrefix(argsStr, "{") && strings.HasSuffix(argsStr, "}") { + repaired := repairPythonJSON(argsStr) + var dummy map[string]interface{} + if json.Unmarshal([]byte(repaired), &dummy) == nil { + b, _ := json.Marshal(dummy) + return []ToolCall{{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: tagFnName, + Arguments: string(b), + }, + }}, true + } + } + + if argsStr != "" && !json.Valid([]byte(argsStr)) { + reTagOpen := regexp.MustCompile(`<([a-zA-Z0-9_-]+)>`) + openMatches := reTagOpen.FindAllStringSubmatchIndex(argsStr, -1) + if len(openMatches) > 0 { + xmlMap := make(map[string]interface{}) + for _, match := range openMatches { + tagName := argsStr[match[2]:match[3]] + closeTag := "" + closeIdx := strings.Index(argsStr[match[1]:], closeTag) + if closeIdx != -1 { + v := strings.TrimSpace(argsStr[match[1] : match[1]+closeIdx]) + if num, err := strconv.ParseFloat(v, 64); err == nil && !strings.HasPrefix(v, "0") { + xmlMap[tagName] = num + } else if v == "true" { + xmlMap[tagName] = true + } else if v == "false" { + xmlMap[tagName] = false + } else { + xmlMap[tagName] = v + } + } + } + if len(xmlMap) > 0 { + if b, err := json.Marshal(xmlMap); err == nil { + return []ToolCall{{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: tagFnName, + Arguments: string(b), + }, + }}, true + } + } + } + } + + if argMap, ok := parsePythonKwargs(argsStr); ok && len(argMap) > 0 { + b, _ := json.Marshal(argMap) + return []ToolCall{{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: tagFnName, + Arguments: string(b), + }, + }}, true + } + + if strings.TrimSpace(argsStr) == "" { + argsStr = "{}" + } + return []ToolCall{{ + ID: "call_" + GenerateUUID()[:8], + Type: "function", + Function: ToolCallFunction{ + Name: tagFnName, + Arguments: argsStr, + }, + }}, true + } + for _, pair := range ToolTagPairs { inner = strings.ReplaceAll(inner, pair.Start, "") inner = strings.ReplaceAll(inner, pair.End, "") @@ -889,20 +1479,30 @@ func parseXMLToolCall(block string) ([]ToolCall, bool) { } var fnName string - if strings.Contains(inner, "") && strings.Contains(inner, "") { - nStart := strings.Index(inner, "") + len("") - nEnd := strings.Index(inner, "") - if nStart < nEnd { - fnName = strings.TrimSpace(inner[nStart:nEnd]) + for _, tagKey := range []string{"name", "function", "action", "command"} { + openTag := "<" + tagKey + ">" + closeTag := "" + if strings.Contains(inner, openTag) && strings.Contains(inner, closeTag) { + nStart := strings.Index(inner, openTag) + len(openTag) + nEnd := strings.Index(inner, closeTag) + if nStart < nEnd { + fnName = strings.TrimSpace(inner[nStart:nEnd]) + break + } } } var argsStr string - if strings.Contains(inner, "") && strings.Contains(inner, "") { - aStart := strings.Index(inner, "") + len("") - aEnd := strings.Index(inner, "") - if aStart < aEnd { - argsStr = strings.TrimSpace(inner[aStart:aEnd]) + for _, argKey := range []string{"arguments", "parameters", "args", "input", "action_input"} { + openTag := "<" + argKey + ">" + closeTag := "" + if strings.Contains(inner, openTag) && strings.Contains(inner, closeTag) { + aStart := strings.Index(inner, openTag) + len(openTag) + aEnd := strings.Index(inner, closeTag) + if aStart < aEnd { + argsStr = strings.TrimSpace(inner[aStart:aEnd]) + break + } } } @@ -959,10 +1559,29 @@ func scrubToolMarkers(text string) string { s = strings.ReplaceAll(s, pair.Start, "") s = strings.ReplaceAll(s, pair.End, "") } - reTags := regexp.MustCompile(`(?s)<(?:name|function|action|call)>[^<]*`) + reTagsWithAttrs := regexp.MustCompile(`(?i)]*)?>`) + s = reTagsWithAttrs.ReplaceAllString(s, "") + + reColonTags := regexp.MustCompile(`(?i)]*)?>`) + s = reColonTags.ReplaceAllString(s, "") + + reEqTags := regexp.MustCompile(`(?i)`) + s = reEqTags.ReplaceAllString(s, "") + + reBracketTags := regexp.MustCompile(`(?i)\[/?TOOL_CALLS?\]`) + s = reBracketTags.ReplaceAllString(s, "") + + reTags := regexp.MustCompile(`(?s)<(?:name|function|action|command|call)>[^<]*`) s = reTags.ReplaceAllString(s, "") - reArgTags := regexp.MustCompile(`(?s)<(?:arguments|parameters|args|input)>[\s\S]*?`) + reArgTags := regexp.MustCompile(`(?s)<(?:arguments|parameters|args|input|action_input|inputs)>[\s\S]*?`) s = reArgTags.ReplaceAllString(s, "") + reParamTags := regexp.MustCompile(`(?s)<(?:parameter|param)(?:\s+[^>]*)?>[\s\S]*?`) + s = reParamTags.ReplaceAllString(s, "") + + reReAct := regexp.MustCompile(`(?i)(?:Action|Command):\s*[a-zA-Z0-9_.-]+`) + s = reReAct.ReplaceAllString(s, "") + reReActInput := regexp.MustCompile(`(?i)(?:Action Input|Arguments|Parameters|Args):\s*`) + s = reReActInput.ReplaceAllString(s, "") extraTags := []string{ "", "", @@ -970,6 +1589,9 @@ func scrubToolMarkers(text string) string { "", "", "", "", "", "", + "", "", + "", "", + "", "", } for _, tag := range extraTags { s = strings.ReplaceAll(s, tag, "") @@ -999,50 +1621,78 @@ func scrubToolMarkers(text string) string { func ExtractToolCallBlocks(content string) (blocks []string, remaining string) { remaining = content - for _, pair := range ToolTagPairs { - for strings.Contains(remaining, pair.Start) { - sIdx := strings.Index(remaining, pair.Start) - rest := remaining[sIdx+len(pair.Start):] + reDynamicOpen := regexp.MustCompile(`(?i)<(?:tool_calls?|toolCalls?|function_calls?|functionCalls?|invoke|call|command|commands|action|function)(?:\s+[^>]*)?>|<(?:call|tool_call|function_call):[a-zA-Z0-9_-]+(?:\s+[^>]*)?>||\[TOOL_CALLS?\]`) - relNextSIdx := strings.Index(rest, pair.Start) - var nextSIdx int - if relNextSIdx != -1 { - nextSIdx = sIdx + len(pair.Start) + relNextSIdx + for { + loc := reDynamicOpen.FindStringIndex(remaining) + if loc == nil { + break + } + sIdx := loc[0] + openTag := remaining[loc[0]:loc[1]] + rest := remaining[loc[1]:] + + var closeTagPattern string + if strings.HasPrefix(strings.ToLower(openTag), "[tool_call") { + closeTagPattern = `(?i)\[/TOOL_CALLS?\]` + } else if strings.HasPrefix(openTag, "` + } else if strings.HasPrefix(openTag, "") + tagSub := openTag[1:gtIdx] + prefix := openTag[1:colonIdx] + closeTagPattern = fmt.Sprintf(`(?i)|`, regexp.QuoteMeta(tagSub), regexp.QuoteMeta(prefix)) + } else { + reTagName := regexp.MustCompile(`^<([a-zA-Z0-9_]+)`) + if m := reTagName.FindStringSubmatch(openTag); len(m) >= 2 { + tagName := m[1] + closeTagPattern = fmt.Sprintf(`(?i)`, regexp.QuoteMeta(tagName)) } else { - nextSIdx = -1 + closeTagPattern = `(?i)` } + } - relEIdx := strings.Index(rest, pair.End) - var eIdx int - if relEIdx != -1 { - eIdx = sIdx + len(pair.Start) + relEIdx - } else { - eIdx = -1 + reClose := regexp.MustCompile(closeTagPattern) + closeLoc := reClose.FindStringIndex(rest) + + var blockText string + if closeLoc != nil { + blockEndPos := loc[1] + closeLoc[1] + blockText = remaining[sIdx:blockEndPos] + + before := remaining[:sIdx] + after := remaining[blockEndPos:] + + reFenceOpen := regexp.MustCompile("(?s)\\n?\\s*```(?:xml|json|ya?ml)?\\s*$") + reFenceClose := regexp.MustCompile("^\\s*```\\s*\\n?") + if reFenceOpen.MatchString(before) && reFenceClose.MatchString(after) { + before = reFenceOpen.ReplaceAllString(before, "") + after = reFenceClose.ReplaceAllString(after, "") } - - var blockText string - if eIdx != -1 && (nextSIdx == -1 || eIdx < nextSIdx) { - blockEndPos := eIdx + len(pair.End) - blockText = remaining[sIdx:blockEndPos] - before := remaining[:sIdx] - after := remaining[blockEndPos:] - - reFenceOpen := regexp.MustCompile("(?s)\\n?\\s*```(?:xml|json|ya?ml)?\\s*$") - reFenceClose := regexp.MustCompile("^\\s*```\\s*\\n?") - if reFenceOpen.MatchString(before) && reFenceClose.MatchString(after) { - before = reFenceOpen.ReplaceAllString(before, "") - after = reFenceClose.ReplaceAllString(after, "") - } - remaining = strings.TrimSpace(before + after) - } else if nextSIdx != -1 { - blockEndPos := nextSIdx + remaining = strings.TrimSpace(before + after) + } else { + nextLoc := reDynamicOpen.FindStringIndex(rest) + if nextLoc != nil { + blockEndPos := loc[1] + nextLoc[0] blockText = remaining[sIdx:blockEndPos] remaining = strings.TrimSpace(remaining[:sIdx] + remaining[blockEndPos:]) } else { blockText = remaining[sIdx:] remaining = strings.TrimSpace(remaining[:sIdx]) } + } + blocks = append(blocks, blockText) + } + + if len(blocks) == 0 { + reAction := regexp.MustCompile(`(?i)(?:Action|Command):\s*[a-zA-Z0-9_.-]+\s*\n+(?:Action Input|Arguments|Parameters|Args):\s*(?:\{[\s\S]*?\}|\[[\s\S]*?\]|[^\n]+)`) + if loc := reAction.FindStringIndex(remaining); loc != nil { + blockText := remaining[loc[0]:loc[1]] + before := remaining[:loc[0]] + after := remaining[loc[1]:] + remaining = strings.TrimSpace(before + " " + after) blocks = append(blocks, blockText) } } @@ -1065,7 +1715,54 @@ func DetectToolCalls(content string) ([]ToolCall, string, bool) { return calls, remaining, true } - if tcs, ok := parseMultipleToolCalls(strings.TrimSpace(content)); ok && len(tcs) > 0 { + reFenced := regexp.MustCompile("(?s)```(?:json|xml)?\\s*([\\s\\S]*?)\\s*```") + matches := reFenced.FindAllStringSubmatchIndex(content, -1) + if len(matches) > 0 { + var fencedCalls []ToolCall + rem := content + for _, loc := range matches { + fenceTotal := content[loc[0]:loc[1]] + fenceInner := strings.TrimSpace(content[loc[2]:loc[3]]) + if tcs, ok := parseMultipleToolCalls(fenceInner); ok && len(tcs) > 0 { + fencedCalls = append(fencedCalls, tcs...) + rem = strings.Replace(rem, fenceTotal, "", 1) + } + } + if len(fencedCalls) > 0 { + return fencedCalls, scrubToolMarkers(rem), true + } + } + + trimmed := strings.TrimSpace(content) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + if tcs, ok := parseMultipleToolCalls(trimmed); ok && len(tcs) > 0 { + return tcs, "", true + } + } + + reCallInContent := regexp.MustCompile(`(?m)^(?:tools\.|functions\.)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)\s*$`) + callLocs := reCallInContent.FindAllStringIndex(content, -1) + if len(callLocs) > 0 { + var pyCalls []ToolCall + rem := content + for _, loc := range callLocs { + callStr := strings.TrimSpace(content[loc[0]:loc[1]]) + if tc, ok := parsePythonFunctionCall(callStr); ok { + pyCalls = append(pyCalls, tc) + rem = strings.Replace(rem, content[loc[0]:loc[1]], "", 1) + } + } + if len(pyCalls) > 0 { + return pyCalls, scrubToolMarkers(rem), true + } + } + + if tc, ok := parseReActToolCall(content); ok { + rem := scrubToolMarkers(content) + return []ToolCall{tc}, rem, true + } + + if tcs, ok := parseMultiplePythonFunctionCalls(trimmed); ok && len(tcs) > 0 { return tcs, "", true } @@ -1594,6 +2291,9 @@ func (f *StreamToolCallFilter) cleanTrailingAfterToolCall() { } } + reDynamicClose := regexp.MustCompile(`(?i)^\s*]*)?>|^\s*]*)?>|^\s*\[/TOOL_CALLS?\]`) + f.buf = reDynamicClose.ReplaceAllString(f.buf, "") + reCloseFence := regexp.MustCompile("^\\s*```\\s*\\n?") f.buf = reCloseFence.ReplaceAllString(f.buf, "") @@ -1626,6 +2326,32 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool } } + reDynamicOpen := regexp.MustCompile(`(?i)<(?:tool_calls?|toolCalls?|function_calls?|functionCalls?|invoke|call|command|commands|action|function)(?:\s+[^>]*)?>|<(?:call|tool_call|function_call):[a-zA-Z0-9_-]+(?:\s+[^>]*)?>||\[TOOL_CALLS?\]`) + if loc := reDynamicOpen.FindStringIndex(f.buf); loc != nil { + if earliestIdx == -1 || loc[0] < earliestIdx { + earliestIdx = loc[0] + matchedTag := f.buf[loc[0]:loc[1]] + var endTag string + if strings.HasPrefix(strings.ToLower(matchedTag), "[tool_call") { + endTag = "[/TOOL_CALLS]" + } else if strings.HasPrefix(matchedTag, "") + tagSub := matchedTag[1:gtIdx] + endTag = "" + } else { + reTagName := regexp.MustCompile(`^<([a-zA-Z0-9_]+)`) + if m := reTagName.FindStringSubmatch(matchedTag); len(m) >= 2 { + endTag = "" + } else { + endTag = "" + } + } + matchedPair = ToolTagPair{Start: matchedTag, End: endTag} + } + } + if earliestIdx != -1 { before := f.buf[:earliestIdx] reTrailingFence := regexp.MustCompile("(?s)\\n?\\s*```(?:xml|json|ya?ml)?\\s*$") @@ -1694,6 +2420,18 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool f.emittedCall = true onToolCall(tc) } + } else if tc3, ok3 := parsePythonFunctionCall(f.toolCallBuf); ok3 { + idxCopy := f.toolIndex + tc3.Index = &idxCopy + f.toolIndex++ + f.emittedCall = true + onToolCall(tc3) + } else if tc4, ok4 := parseReActToolCall(f.toolCallBuf); ok4 { + idxCopy := f.toolIndex + tc4.Index = &idxCopy + f.toolIndex++ + f.emittedCall = true + onToolCall(tc4) } else { onContent(f.activePair.Start + f.toolCallBuf + f.activePair.End) } @@ -1734,6 +2472,18 @@ func (f *StreamToolCallFilter) Flush(onContent func(string), onToolCall func(Too f.emittedCall = true onToolCall(tc) } + } else if tc3, ok3 := parsePythonFunctionCall(f.toolCallBuf); ok3 { + idxCopy := f.toolIndex + tc3.Index = &idxCopy + f.toolIndex++ + f.emittedCall = true + onToolCall(tc3) + } else if tc4, ok4 := parseReActToolCall(f.toolCallBuf); ok4 { + idxCopy := f.toolIndex + tc4.Index = &idxCopy + f.toolIndex++ + f.emittedCall = true + onToolCall(tc4) } else { onContent(f.activePair.Start + f.toolCallBuf) } @@ -3038,10 +3788,11 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } var promptMessageText string + isToolReturn := strings.HasPrefix(lastUserMessage, "") || strings.HasPrefix(lastUserMessage, "Tool result") if disc.HistoryIndex != -1 { if disc.SystemIndex == -1 { if len(nonSystem) > 1 && toolInstruction != "" { - if !strings.HasPrefix(lastUserMessage, "Tool result") { + if !isToolReturn { promptMessageText = fmt.Sprintf("[System Directive: Tool calling mode active.]\n\nQuery: %s", lastUserMessage) } else { promptMessageText = fmt.Sprintf("[System Directive: Tool calling mode active.]\n\n%s", lastUserMessage) @@ -3050,7 +3801,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet promptMessageText = lastUserMessage } } else { - if toolInstruction != "" && !strings.HasPrefix(lastUserMessage, "Tool result") { + if toolInstruction != "" && !isToolReturn { promptMessageText = fmt.Sprintf("Query: %s", lastUserMessage) } else { promptMessageText = lastUserMessage @@ -3060,7 +3811,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet // Single message space: compose multi-turn history into the prompt if len(nonSystem) <= 1 { if systemPromptStr != "" && len(nonSystem) == 1 { - if toolInstruction != "" && !strings.HasPrefix(lastUserMessage, "Tool result") { + if toolInstruction != "" && !isToolReturn { promptMessageText = fmt.Sprintf("%s\n\nQuery: %s", systemPromptStr, lastUserMessage) } else { promptMessageText = systemPromptStr + "\n\n" + lastUserMessage @@ -3088,7 +3839,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet if len(nonSystem) > 0 && nonSystem[len(nonSystem)-1].Role == "assistant" { lastRoleLabel = "Assistant" } - if toolInstruction != "" && lastRoleLabel == "User" && !strings.HasPrefix(lastUserMessage, "Tool result") { + if toolInstruction != "" && lastRoleLabel == "User" && !isToolReturn { sb.WriteString(fmt.Sprintf("# Current Request\nQuery: %s", lastUserMessage)) } else { sb.WriteString(fmt.Sprintf("# Current Request\n%s: %s", lastRoleLabel, lastUserMessage)) @@ -3222,7 +3973,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet data[disc.SystemIndex] = systemPromptStr } - if disc.ThinkLevelIndex >= 0 && disc.ThinkLevelIndex < len(data) { + if disc.ThinkLevelIndex >= 0 && disc.ThinkLevelIndex != disc.MessageIndex && disc.ThinkLevelIndex != disc.HistoryIndex && disc.ThinkLevelIndex < len(data) { thinkLevel := "high" if req.ReasoningEffort != "" { effort := strings.ToLower(req.ReasoningEffort) @@ -3240,7 +3991,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet data[disc.ThinkLevelIndex] = thinkLevel } - if disc.TempIndex >= 0 && disc.TempIndex < len(data) { + if disc.TempIndex >= 0 && disc.TempIndex != disc.MessageIndex && disc.TempIndex != disc.HistoryIndex && disc.TempIndex < len(data) { if req.Temperature != nil { data[disc.TempIndex] = *req.Temperature } else if disc.IsHunyuan3 { @@ -3250,7 +4001,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } } - if disc.MaxTokensIndex >= 0 && disc.MaxTokensIndex < len(data) { + if disc.MaxTokensIndex >= 0 && disc.MaxTokensIndex != disc.MessageIndex && disc.MaxTokensIndex != disc.HistoryIndex && disc.MaxTokensIndex < len(data) { if req.MaxTokens > 0 || req.MaxCompletionTokens > 0 { data[disc.MaxTokensIndex] = ResolveMaxTokens(req) } else if data[disc.MaxTokensIndex] == nil { @@ -3258,7 +4009,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet } } - if disc.TopPIndex >= 0 && disc.TopPIndex < len(data) { + if disc.TopPIndex >= 0 && disc.TopPIndex != disc.MessageIndex && disc.TopPIndex != disc.HistoryIndex && disc.TopPIndex < len(data) { if req.TopP != nil { data[disc.TopPIndex] = *req.TopP } else if disc.IsHunyuan3 { @@ -3272,7 +4023,7 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet data[disc.StreamIndex] = false } - if disc.FunctionsJSONIndex >= 0 && disc.FunctionsJSONIndex < len(data) { + if disc.FunctionsJSONIndex >= 0 && disc.FunctionsJSONIndex != disc.MessageIndex && disc.FunctionsJSONIndex != disc.HistoryIndex && disc.FunctionsJSONIndex != disc.SystemIndex && disc.FunctionsJSONIndex < len(data) { functionsJSONStr := "" if len(req.Tools) > 0 { b, err := json.Marshal(req.Tools) diff --git a/gr2gw_test.go b/gr2gw_test.go index b450001..eac7d4d 100644 --- a/gr2gw_test.go +++ b/gr2gw_test.go @@ -2729,3 +2729,231 @@ func TestFinalizeOutputToolCallSanitization(t *testing.T) { t.Errorf("unexpected tool calls: %+v", tcs4) } } + +func TestPythonFunctionCallParsing(t *testing.T) { + // 1. Direct function call with kwargs + pyCall := `get_weather(city="Paris", units="metric")` + tcs1, rem1, ok1 := DetectToolCalls(pyCall) + if !ok1 || len(tcs1) != 1 { + t.Fatalf("expected 1 call from python call, got %d (ok=%v)", len(tcs1), ok1) + } + if tcs1[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs1[0].Function.Name) + } + var args1 map[string]interface{} + if err := json.Unmarshal([]byte(tcs1[0].Function.Arguments), &args1); err != nil { + t.Fatalf("invalid json args: %v", err) + } + if args1["city"] != "Paris" || args1["units"] != "metric" { + t.Errorf("unexpected args: %+v", args1) + } + if rem1 != "" { + t.Errorf("expected empty remaining, got %q", rem1) + } + + // 2. Python call with single quotes and booleans + pyCall2 := `set_config(key='debug_mode', enabled=True, timeout=30, retries=None)` + tcs2, _, ok2 := DetectToolCalls(pyCall2) + if !ok2 || len(tcs2) != 1 { + t.Fatalf("expected 1 call, got %d", len(tcs2)) + } + var args2 map[string]interface{} + if err := json.Unmarshal([]byte(tcs2[0].Function.Arguments), &args2); err != nil { + t.Fatalf("invalid json args: %v", err) + } + if args2["key"] != "debug_mode" || args2["enabled"] != true || args2["timeout"] != float64(30) || args2["retries"] != nil { + t.Errorf("unexpected args: %+v", args2) + } + + // 3. Python call inside tag + tagPy := ` +get_weather(city="Tokyo") +` + tcs3, _, ok3 := DetectToolCalls(tagPy) + if !ok3 || len(tcs3) != 1 { + t.Fatalf("expected 1 call from tagPy, got %d", len(tcs3)) + } + if tcs3[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs3[0].Function.Name) + } + + // 4. Multiple calls in list + multiPy := `[calc(expr='2+2'), search(q='golang')]` + tcs4, _, ok4 := DetectToolCalls(multiPy) + if !ok4 || len(tcs4) != 2 { + t.Fatalf("expected 2 calls from multiPy, got %d", len(tcs4)) + } + if tcs4[0].Function.Name != "calc" || tcs4[1].Function.Name != "search" { + t.Errorf("unexpected multiPy calls: %+v", tcs4) + } +} + +func TestDynamicXMLTagsWithAttributes(t *testing.T) { + // 1. Anthropic-style with + anthropic := ` +Paris +` + tcs1, _, ok1 := DetectToolCalls(anthropic) + if !ok1 || len(tcs1) != 1 { + t.Fatalf("expected 1 call from anthropic invoke, got %d", len(tcs1)) + } + if tcs1[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs1[0].Function.Name) + } + if !strings.Contains(tcs1[0].Function.Arguments, `"Paris"`) { + t.Errorf("expected Paris in arguments, got %s", tcs1[0].Function.Arguments) + } + + // 2. + attrTag := ` +{"city": "Berlin"} +` + tcs2, _, ok2 := DetectToolCalls(attrTag) + if !ok2 || len(tcs2) != 1 { + t.Fatalf("expected 1 call from attrTag, got %d", len(tcs2)) + } + if tcs2[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs2[0].Function.Name) + } + + // 3. + colonTag := ` +{"city": "Madrid"} +` + tcs3, _, ok3 := DetectToolCalls(colonTag) + if !ok3 || len(tcs3) != 1 { + t.Fatalf("expected 1 call from colonTag, got %d", len(tcs3)) + } + if tcs3[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs3[0].Function.Name) + } + + // 4. + eqTag := ` +{"city": "Rome"} +` + tcs4, _, ok4 := DetectToolCalls(eqTag) + if !ok4 || len(tcs4) != 1 { + t.Fatalf("expected 1 call from eqTag, got %d", len(tcs4)) + } + if tcs4[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs4[0].Function.Name) + } + + // 5. + cmdTag := ` +{"city": "Lisbon"} +` + tcs5, _, ok5 := DetectToolCalls(cmdTag) + if !ok5 || len(tcs5) != 1 { + t.Fatalf("expected 1 call from cmdTag, got %d", len(tcs5)) + } + if tcs5[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs5[0].Function.Name) + } +} + +func TestReActToolCallParsing(t *testing.T) { + react := `I will query the weather database. +Action: get_weather +Action Input: {"city": "Paris"}` + tcs, rem, ok := DetectToolCalls(react) + if !ok || len(tcs) != 1 { + t.Fatalf("expected 1 call from ReAct, got %d", len(tcs)) + } + if tcs[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs[0].Function.Name) + } + if !strings.Contains(rem, "I will query the weather database.") { + t.Errorf("expected preamble commentary preserved, got %q", rem) + } + if strings.Contains(rem, "Action:") || strings.Contains(rem, "Action Input:") { + t.Errorf("expected ReAct markers scrubbed, got %q", rem) + } +} + +func TestPythonSingleQuoteJSON(t *testing.T) { + dirtyJSON := ` +{'name': 'get_weather', 'arguments': {'city': 'Paris', 'active': True}} +` + tcs, _, ok := DetectToolCalls(dirtyJSON) + if !ok || len(tcs) != 1 { + t.Fatalf("expected 1 call from dirtyJSON, got %d", len(tcs)) + } + if tcs[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs[0].Function.Name) + } + var args map[string]interface{} + if err := json.Unmarshal([]byte(tcs[0].Function.Arguments), &args); err != nil { + t.Fatalf("failed to unmarshal parsed args: %v", err) + } + if args["active"] != true || args["city"] != "Paris" { + t.Errorf("unexpected args: %+v", args) + } +} + +func TestFencedJSONToolCallWithoutTags(t *testing.T) { + fenced := "Let me check that for you.\n```json\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n```\n" + tcs, rem, ok := DetectToolCalls(fenced) + if !ok || len(tcs) != 1 { + t.Fatalf("expected 1 call from fenced json, got %d", len(tcs)) + } + if tcs[0].Function.Name != "get_weather" { + t.Errorf("expected get_weather, got %s", tcs[0].Function.Name) + } + if !strings.Contains(rem, "Let me check that for you.") { + t.Errorf("expected commentary preserved, got %q", rem) + } +} + +func TestMultiTurnToolResponseNotWrappedInQuery(t *testing.T) { + gw := &GradioGateway{} + disc := &SpaceDiscovery{ + SpaceURL: "https://test-space.hf.space", + TotalInputs: 2, + MessageIndex: 0, + HistoryIndex: 1, + HistoryFormat: "gradio_messages", + SystemIndex: -1, + FunctionsJSONIndex: -1, + DefaultSystemPrompt: "", + } + + req := ChatCompletionRequest{ + Tools: []Tool{ + { + Type: "function", + Function: map[string]interface{}{ + "name": "get_weather", + }, + }, + }, + Messages: []ChatMessage{ + {Role: "user", Content: "What is the weather in Paris?"}, + { + Role: "assistant", + ToolCalls: []ToolCall{ + {ID: "call_123", Type: "function", Function: ToolCallFunction{Name: "get_weather", Arguments: `{"city":"Paris"}`}}, + }, + }, + {Role: "tool", ToolCallID: "call_123", Name: "get_weather", Content: `{"temp": 22}`}, + }, + } + + payload, err := gw.BuildGradioPayload(disc, req) + if err != nil { + t.Fatalf("BuildGradioPayload failed: %v", err) + } + + msgStr, ok := payload[0].(string) + if !ok { + t.Fatalf("expected string at index 0, got %T", payload[0]) + } + + if strings.Contains(msgStr, "Query: ") { + t.Errorf("message input should NOT contain 'Query: ', got:\n%s", msgStr) + } + if !strings.Contains(msgStr, "") { + t.Errorf("message input should contain tool response, got:\n%s", msgStr) + } +}