tools fix

This commit is contained in:
Luxferre
2026-09-07 18:05:32 +03:00
parent 2a0fd63676
commit c731b57dfc
2 changed files with 376 additions and 32 deletions
+132 -25
View File
@@ -1600,6 +1600,9 @@ func scrubToolMarkers(text string) string {
reEmptyFences := regexp.MustCompile("(?s)```(?:xml|json|ya?ml)?\\s*```") reEmptyFences := regexp.MustCompile("(?s)```(?:xml|json|ya?ml)?\\s*```")
s = reEmptyFences.ReplaceAllString(s, "") s = reEmptyFences.ReplaceAllString(s, "")
reCitationDisclaimer := regexp.MustCompile(`(?i)\*?Web evidence was retrieved[^\n*]*\.\*?`)
s = reCitationDisclaimer.ReplaceAllString(s, "")
trimmed := strings.TrimSpace(s) trimmed := strings.TrimSpace(s)
if trimmed == "```xml" || trimmed == "```json" || trimmed == "```" { if trimmed == "```xml" || trimmed == "```json" || trimmed == "```" {
return "" return ""
@@ -1618,8 +1621,10 @@ func scrubToolMarkers(text string) string {
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
func ExtractToolCallBlocks(content string) (blocks []string, remaining string) { func ExtractToolCallBlocks(content string) (blocks []string, preCallText string, remaining string) {
remaining = content remaining = content
var firstPreamble string
firstBlockFound := false
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+[^>]*)?>|<function=[a-zA-Z0-9_-]+>|\[TOOL_CALLS?\]`) 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+[^>]*)?>|<function=[a-zA-Z0-9_-]+>|\[TOOL_CALLS?\]`)
@@ -1670,9 +1675,21 @@ func ExtractToolCallBlocks(content string) (blocks []string, remaining string) {
before = reFenceOpen.ReplaceAllString(before, "") before = reFenceOpen.ReplaceAllString(before, "")
after = reFenceClose.ReplaceAllString(after, "") after = reFenceClose.ReplaceAllString(after, "")
} }
if !firstBlockFound {
preClean := reFenceOpen.ReplaceAllString(before, "")
firstPreamble = strings.TrimRight(preClean, "\r\n ")
firstBlockFound = true
}
remaining = strings.TrimSpace(before + after) remaining = strings.TrimSpace(before + after)
} else { } else {
nextLoc := reDynamicOpen.FindStringIndex(rest) nextLoc := reDynamicOpen.FindStringIndex(rest)
before := remaining[:sIdx]
reFenceOpen := regexp.MustCompile("(?s)\\n?\\s*```(?:xml|json|ya?ml)?\\s*$")
if !firstBlockFound {
preClean := reFenceOpen.ReplaceAllString(before, "")
firstPreamble = strings.TrimRight(preClean, "\r\n ")
firstBlockFound = true
}
if nextLoc != nil { if nextLoc != nil {
blockEndPos := loc[1] + nextLoc[0] blockEndPos := loc[1] + nextLoc[0]
blockText = remaining[sIdx:blockEndPos] blockText = remaining[sIdx:blockEndPos]
@@ -1692,16 +1709,17 @@ func ExtractToolCallBlocks(content string) (blocks []string, remaining string) {
blockText := remaining[loc[0]:loc[1]] blockText := remaining[loc[0]:loc[1]]
before := remaining[:loc[0]] before := remaining[:loc[0]]
after := remaining[loc[1]:] after := remaining[loc[1]:]
firstPreamble = strings.TrimSpace(before)
remaining = strings.TrimSpace(before + " " + after) remaining = strings.TrimSpace(before + " " + after)
blocks = append(blocks, blockText) blocks = append(blocks, blockText)
} }
} }
return blocks, remaining return blocks, firstPreamble, remaining
} }
func DetectToolCalls(content string) ([]ToolCall, string, bool) { func DetectToolCalls(content string) ([]ToolCall, string, bool) {
blocks, remaining := ExtractToolCallBlocks(content) blocks, preCallText, _ := ExtractToolCallBlocks(content)
var calls []ToolCall var calls []ToolCall
for _, block := range blocks { for _, block := range blocks {
@@ -1711,25 +1729,29 @@ func DetectToolCalls(content string) ([]ToolCall, string, bool) {
} }
if len(calls) > 0 { if len(calls) > 0 {
remaining = scrubToolMarkers(remaining) return calls, scrubToolMarkers(preCallText), true
return calls, remaining, true
} }
reFenced := regexp.MustCompile("(?s)```(?:json|xml)?\\s*([\\s\\S]*?)\\s*```") reFenced := regexp.MustCompile("(?s)```(?:json|xml)?\\s*([\\s\\S]*?)\\s*```")
matches := reFenced.FindAllStringSubmatchIndex(content, -1) matches := reFenced.FindAllStringSubmatchIndex(content, -1)
if len(matches) > 0 { if len(matches) > 0 {
var fencedCalls []ToolCall var fencedCalls []ToolCall
rem := content firstFenceStart := -1
for _, loc := range matches { for _, loc := range matches {
fenceTotal := content[loc[0]:loc[1]]
fenceInner := strings.TrimSpace(content[loc[2]:loc[3]]) fenceInner := strings.TrimSpace(content[loc[2]:loc[3]])
if tcs, ok := parseMultipleToolCalls(fenceInner); ok && len(tcs) > 0 { if tcs, ok := parseMultipleToolCalls(fenceInner); ok && len(tcs) > 0 {
if firstFenceStart == -1 {
firstFenceStart = loc[0]
}
fencedCalls = append(fencedCalls, tcs...) fencedCalls = append(fencedCalls, tcs...)
rem = strings.Replace(rem, fenceTotal, "", 1)
} }
} }
if len(fencedCalls) > 0 { if len(fencedCalls) > 0 {
return fencedCalls, scrubToolMarkers(rem), true pre := ""
if firstFenceStart > 0 {
pre = content[:firstFenceStart]
}
return fencedCalls, scrubToolMarkers(pre), true
} }
} }
@@ -1744,22 +1766,33 @@ func DetectToolCalls(content string) ([]ToolCall, string, bool) {
callLocs := reCallInContent.FindAllStringIndex(content, -1) callLocs := reCallInContent.FindAllStringIndex(content, -1)
if len(callLocs) > 0 { if len(callLocs) > 0 {
var pyCalls []ToolCall var pyCalls []ToolCall
rem := content firstCallStart := -1
for _, loc := range callLocs { for _, loc := range callLocs {
callStr := strings.TrimSpace(content[loc[0]:loc[1]]) callStr := strings.TrimSpace(content[loc[0]:loc[1]])
if tc, ok := parsePythonFunctionCall(callStr); ok { if tc, ok := parsePythonFunctionCall(callStr); ok {
if firstCallStart == -1 {
firstCallStart = loc[0]
}
pyCalls = append(pyCalls, tc) pyCalls = append(pyCalls, tc)
rem = strings.Replace(rem, content[loc[0]:loc[1]], "", 1)
} }
} }
if len(pyCalls) > 0 { if len(pyCalls) > 0 {
return pyCalls, scrubToolMarkers(rem), true pre := ""
if firstCallStart > 0 {
pre = content[:firstCallStart]
}
return pyCalls, scrubToolMarkers(pre), true
} }
} }
if tc, ok := parseReActToolCall(content); ok { if tc, ok := parseReActToolCall(content); ok {
rem := scrubToolMarkers(content) reAction := regexp.MustCompile(`(?i)(?:Action|Command):\s*[a-zA-Z0-9_.-]+`)
return []ToolCall{tc}, rem, true loc := reAction.FindStringIndex(content)
pre := ""
if loc != nil && loc[0] > 0 {
pre = content[:loc[0]]
}
return []ToolCall{tc}, scrubToolMarkers(pre), true
} }
if tcs, ok := parseMultiplePythonFunctionCalls(trimmed); ok && len(tcs) > 0 { if tcs, ok := parseMultiplePythonFunctionCalls(trimmed); ok && len(tcs) > 0 {
@@ -2363,7 +2396,7 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool
} else { } else {
before = strings.TrimRight(before, "\r\n") before = strings.TrimRight(before, "\r\n")
} }
if before != "" { if before != "" && !f.emittedCall {
onContent(before) onContent(before)
} }
f.inToolCall = true f.inToolCall = true
@@ -2385,7 +2418,7 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool
safe = reTrailingFence.ReplaceAllString(safe, "") safe = reTrailingFence.ReplaceAllString(safe, "")
safe = strings.TrimRight(safe, "\r\n") safe = strings.TrimRight(safe, "\r\n")
} }
if strings.TrimSpace(safe) != "" { if strings.TrimSpace(safe) != "" && !f.emittedCall {
onContent(safe) onContent(safe)
} }
f.buf = f.buf[len(f.buf)-holdLen:] f.buf = f.buf[len(f.buf)-holdLen:]
@@ -2393,7 +2426,9 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool
} else if len(f.buf) < 16 && strings.TrimSpace(f.buf) == "" { } else if len(f.buf) < 16 && strings.TrimSpace(f.buf) == "" {
break break
} else { } else {
if !f.emittedCall {
onContent(f.buf) onContent(f.buf)
}
f.buf = "" f.buf = ""
break break
} }
@@ -2432,7 +2467,7 @@ func (f *StreamToolCallFilter) Feed(chunk string, onContent func(string), onTool
f.toolIndex++ f.toolIndex++
f.emittedCall = true f.emittedCall = true
onToolCall(tc4) onToolCall(tc4)
} else { } else if !f.emittedCall {
onContent(f.activePair.Start + f.toolCallBuf + f.activePair.End) onContent(f.activePair.Start + f.toolCallBuf + f.activePair.End)
} }
f.toolCallBuf = "" f.toolCallBuf = ""
@@ -2484,18 +2519,13 @@ func (f *StreamToolCallFilter) Flush(onContent func(string), onToolCall func(Too
f.toolIndex++ f.toolIndex++
f.emittedCall = true f.emittedCall = true
onToolCall(tc4) onToolCall(tc4)
} else { } else if !f.emittedCall {
onContent(f.activePair.Start + f.toolCallBuf) onContent(f.activePair.Start + f.toolCallBuf)
} }
f.toolCallBuf = "" f.toolCallBuf = ""
} }
if len(f.buf) > 0 { if len(f.buf) > 0 {
if f.emittedCall { if !f.emittedCall {
clean := scrubToolMarkers(f.buf)
if strings.TrimSpace(clean) != "" {
onContent(strings.TrimSpace(clean))
}
} else {
onContent(f.buf) onContent(f.buf)
} }
f.buf = "" f.buf = ""
@@ -2576,8 +2606,9 @@ type SpaceParamMapping struct {
ComponentType string `json:"component_type,omitempty"` ComponentType string `json:"component_type,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
ParamName string `json:"param_name,omitempty"` ParamName string `json:"param_name,omitempty"`
ParamType string `json:"param_type"` // "message", "history", "system_prompt", "temperature", "max_tokens", "top_p", "think_level", "tools", "stream", "state", "other" ParamType string `json:"param_type"` // "message", "history", "system_prompt", "temperature", "max_tokens", "top_p", "think_level", "tools", "stream", "state", "web_search", "other"
DefaultValue interface{} `json:"default_value,omitempty"` DefaultValue interface{} `json:"default_value,omitempty"`
Choices []string `json:"choices,omitempty"`
} }
type SpaceDiscovery struct { type SpaceDiscovery struct {
@@ -2609,6 +2640,7 @@ type SpaceDiscovery struct {
ThinkLevelIndex int `json:"think_level_index"` // -1 if none ThinkLevelIndex int `json:"think_level_index"` // -1 if none
FunctionsJSONIndex int `json:"functions_json_index"` // -1 if none FunctionsJSONIndex int `json:"functions_json_index"` // -1 if none
PreservedThinkingIndex int `json:"preserved_thinking_index"` // -1 if none PreservedThinkingIndex int `json:"preserved_thinking_index"` // -1 if none
WebSearchIndex int `json:"web_search_index"` // -1 if none
IsHunyuan3 bool `json:"is_hunyuan3"` IsHunyuan3 bool `json:"is_hunyuan3"`
HistoryFormat string `json:"history_format"` // "messages", "pairs", "gradio_messages", "none" HistoryFormat string `json:"history_format"` // "messages", "pairs", "gradio_messages", "none"
ToolCallMode string `json:"tool_call_mode"` // "native_slot", "prompt_augmented_system", "prompt_augmented_first_turn", "prompt_augmented_single_prompt" ToolCallMode string `json:"tool_call_mode"` // "native_slot", "prompt_augmented_system", "prompt_augmented_first_turn", "prompt_augmented_single_prompt"
@@ -2758,6 +2790,7 @@ func NewDefaultSpaceDiscovery(spaceURL string) *SpaceDiscovery {
ThinkLevelIndex: -1, ThinkLevelIndex: -1,
FunctionsJSONIndex: -1, FunctionsJSONIndex: -1,
PreservedThinkingIndex: -1, PreservedThinkingIndex: -1,
WebSearchIndex: -1,
HistoryFormat: "messages", HistoryFormat: "messages",
ToolCallMode: "prompt_augmented_single_prompt", ToolCallMode: "prompt_augmented_single_prompt",
LastDiscovered: time.Now(), LastDiscovered: time.Now(),
@@ -3293,6 +3326,29 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
discovery.DefaultInputs[idx] = val discovery.DefaultInputs[idx] = val
mapping.DefaultValue = val mapping.DefaultValue = val
} }
if chList, ok := comp.Props["choices"].([]interface{}); ok {
for _, ch := range chList {
if chStr, ok := ch.(string); ok {
mapping.Choices = append(mapping.Choices, chStr)
} else if chPair, ok := ch.([]interface{}); ok && len(chPair) > 0 {
if chStr, ok := chPair[0].(string); ok {
mapping.Choices = append(mapping.Choices, chStr)
}
}
}
}
}
if bestEndpointInfo != nil && idx < len(bestEndpointInfo.Parameters) && len(mapping.Choices) == 0 {
p := bestEndpointInfo.Parameters[idx]
if typeMap, ok := p.Type.(map[string]interface{}); ok {
if enumArr, ok := typeMap["enum"].([]interface{}); ok {
for _, e := range enumArr {
if s, ok := e.(string); ok {
mapping.Choices = append(mapping.Choices, s)
}
}
}
}
} }
if strings.Contains(pName, "function") || strings.Contains(pName, "tool") || strings.Contains(pLabel, "tool") || strings.Contains(pLabel, "function") || strings.Contains(cLabel, "tool") || strings.Contains(cLabel, "function") { if strings.Contains(pName, "function") || strings.Contains(pName, "tool") || strings.Contains(pLabel, "tool") || strings.Contains(pLabel, "function") || strings.Contains(cLabel, "tool") || strings.Contains(cLabel, "function") {
@@ -3335,6 +3391,9 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
} else if strings.Contains(pName, "stream") || strings.Contains(cLabel, "stream") { } else if strings.Contains(pName, "stream") || strings.Contains(cLabel, "stream") {
mapping.ParamType = "stream" mapping.ParamType = "stream"
discovery.StreamIndex = idx discovery.StreamIndex = idx
} else if strings.Contains(pName, "search") || strings.Contains(pLabel, "search") || strings.Contains(cLabel, "search") || strings.Contains(pName, "browse") || strings.Contains(pLabel, "browse") || strings.Contains(cLabel, "browse") || strings.Contains(pName, "web") || strings.Contains(pLabel, "web") || strings.Contains(cLabel, "web") {
mapping.ParamType = "web_search"
discovery.WebSearchIndex = idx
} else if cType == "state" { } else if cType == "state" {
mapping.ParamType = "state" mapping.ParamType = "state"
} }
@@ -3402,6 +3461,9 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
if discovery.StreamIndex >= discovery.TotalInputs { if discovery.StreamIndex >= discovery.TotalInputs {
discovery.StreamIndex = -1 discovery.StreamIndex = -1
} }
if discovery.WebSearchIndex >= discovery.TotalInputs {
discovery.WebSearchIndex = -1
}
} }
} }
} }
@@ -3501,6 +3563,19 @@ func InspectSpace(client *http.Client, rawURL, userAgent string) (*SpaceDiscover
} else if strings.Contains(pName, "stream") || strings.Contains(pLabel, "stream") { } else if strings.Contains(pName, "stream") || strings.Contains(pLabel, "stream") {
discovery.StreamIndex = idx discovery.StreamIndex = idx
mapping.ParamType = "stream" mapping.ParamType = "stream"
} else if strings.Contains(pName, "search") || strings.Contains(pLabel, "search") || strings.Contains(pName, "browse") || strings.Contains(pLabel, "browse") || strings.Contains(pName, "web") || strings.Contains(pLabel, "web") {
discovery.WebSearchIndex = idx
mapping.ParamType = "web_search"
}
if typeMap, ok := p.Type.(map[string]interface{}); ok {
if enumArr, ok := typeMap["enum"].([]interface{}); ok {
for _, e := range enumArr {
if s, ok := e.(string); ok {
mapping.Choices = append(mapping.Choices, s)
}
}
}
} }
discovery.ParamMappings = append(discovery.ParamMappings, mapping) discovery.ParamMappings = append(discovery.ParamMappings, mapping)
@@ -4043,6 +4118,38 @@ func (g *GradioGateway) BuildGradioPayload(disc *SpaceDiscovery, req ChatComplet
data[disc.FunctionsJSONIndex] = functionsJSONStr data[disc.FunctionsJSONIndex] = functionsJSONStr
} }
if disc.WebSearchIndex >= 0 && disc.WebSearchIndex != disc.MessageIndex && disc.WebSearchIndex != disc.HistoryIndex && disc.WebSearchIndex != disc.SystemIndex && disc.WebSearchIndex < len(data) && len(req.Tools) > 0 {
var mapping *SpaceParamMapping
for i := range disc.ParamMappings {
if disc.ParamMappings[i].InputIndex == disc.WebSearchIndex {
mapping = &disc.ParamMappings[i]
break
}
}
disabledSet := false
if mapping != nil && len(mapping.Choices) > 0 {
for _, choice := range mapping.Choices {
cLower := strings.ToLower(choice)
if cLower == "direct" || cLower == "off" || cLower == "disabled" || cLower == "none" || cLower == "false" || cLower == "no" || strings.Contains(cLower, "direct") || strings.Contains(cLower, "no search") || strings.Contains(cLower, "disable") {
data[disc.WebSearchIndex] = choice
disabledSet = true
break
}
}
}
if !disabledSet {
switch data[disc.WebSearchIndex].(type) {
case bool:
data[disc.WebSearchIndex] = false
case string:
strVal := strings.ToLower(data[disc.WebSearchIndex].(string))
if strings.Contains(strVal, "search") {
data[disc.WebSearchIndex] = "Direct"
}
}
}
}
return data, nil return data, nil
} }
+243 -6
View File
@@ -149,8 +149,8 @@ func TestStreamToolCallFilter(t *testing.T) {
t.Errorf("expected tool name 'search_web', got %q", toolCalls[0].Function.Name) t.Errorf("expected tool name 'search_web', got %q", toolCalls[0].Function.Name)
} }
fullContent := strings.Join(contentParts, "") fullContent := strings.Join(contentParts, "")
if fullContent != "Searching now: Done." { if fullContent != "Searching now: " {
t.Errorf("expected 'Searching now: Done.', got %q", fullContent) t.Errorf("expected 'Searching now: ' (post-call text suppressed), got %q", fullContent)
} }
} }
@@ -639,8 +639,11 @@ Some postamble.`
if strings.Contains(rem2, "function_call") { if strings.Contains(rem2, "function_call") {
t.Errorf("expected tag stripped from remaining, got %q", rem2) t.Errorf("expected tag stripped from remaining, got %q", rem2)
} }
if !strings.Contains(rem2, "Some preamble") || !strings.Contains(rem2, "Some postamble") { if !strings.Contains(rem2, "Some preamble") {
t.Errorf("expected surrounding text preserved in remaining, got %q", rem2) t.Errorf("expected preamble text preserved in remaining, got %q", rem2)
}
if strings.Contains(rem2, "Some postamble") {
t.Errorf("expected postamble text discarded from remaining on tool call, got %q", rem2)
} }
// 3. [TOOL_CALLS] bracket syntax // 3. [TOOL_CALLS] bracket syntax
@@ -723,8 +726,8 @@ func TestUniversalStreamToolCallFilterVariants(t *testing.T) {
if strings.Contains(fullContent, "TOOL_CALLS") { if strings.Contains(fullContent, "TOOL_CALLS") {
t.Errorf("tag leaked into stream content: %q", fullContent) t.Errorf("tag leaked into stream content: %q", fullContent)
} }
if fullContent != "Preamble text: Completed." { if fullContent != "Preamble text: " {
t.Errorf("unexpected streamed content: %q", fullContent) t.Errorf("unexpected streamed content (expected post-call text suppressed): %q", fullContent)
} }
} }
@@ -3367,3 +3370,237 @@ func TestHunyuan3CallAndStreamingNoInterleaving(t *testing.T) {
} }
} }
func TestDetectToolCallsDiscardsPostambleAndCitationDisclaimer(t *testing.T) {
raw := `<tool_call>
{"name": "get_current_weather", "arguments": {"location": "Tokyo"}}
</tool_call>
*Web evidence was retrieved, but the response did not include a valid source citation.*`
tcs, rem, ok := DetectToolCalls(raw)
if !ok || len(tcs) != 1 {
t.Fatalf("expected 1 tool call, got %d (ok: %v)", len(tcs), ok)
}
if tcs[0].Function.Name != "get_current_weather" {
t.Errorf("expected function get_current_weather, got %q", tcs[0].Function.Name)
}
if rem != "" {
t.Errorf("expected empty remaining content, got %q", rem)
}
frame := GradioOutputFrame{Content: raw}
finalContent, reasoning, finalCalls, finishReason := finalizeOutput(frame)
if finishReason != "tool_calls" {
t.Errorf("expected finish_reason 'tool_calls', got %q", finishReason)
}
if finalContent != nil {
t.Errorf("expected finalContent to be nil, got %v", finalContent)
}
if reasoning != "" {
t.Errorf("expected empty reasoning, got %q", reasoning)
}
if len(finalCalls) != 1 {
t.Fatalf("expected 1 final tool call, got %d", len(finalCalls))
}
}
func TestDetectToolCallsPreservesPreambleBeforeToolCall(t *testing.T) {
raw := "I will check the weather in Tokyo for you.\n" +
"```xml\n" +
"<tool_call>\n" +
"{\"name\": \"get_current_weather\", \"arguments\": {\"location\": \"Tokyo\"}}\n" +
"</tool_call>\n" +
"```\n" +
"*Web evidence was retrieved, but the response did not include a valid source citation.*"
tcs, rem, ok := DetectToolCalls(raw)
if !ok || len(tcs) != 1 {
t.Fatalf("expected 1 tool call, got %d (ok: %v)", len(tcs), ok)
}
if tcs[0].Function.Name != "get_current_weather" {
t.Errorf("expected function get_current_weather, got %q", tcs[0].Function.Name)
}
if rem != "I will check the weather in Tokyo for you." {
t.Errorf("expected preamble preserved without postamble, got %q", rem)
}
frame := GradioOutputFrame{Content: raw}
finalContent, _, finalCalls, finishReason := finalizeOutput(frame)
if finishReason != "tool_calls" {
t.Errorf("expected finish_reason 'tool_calls', got %q", finishReason)
}
if finalContentStr, ok := finalContent.(string); !ok || finalContentStr != "I will check the weather in Tokyo for you." {
t.Errorf("expected finalContent 'I will check the weather in Tokyo for you.', got %v", finalContent)
}
if len(finalCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(finalCalls))
}
}
func TestStreamToolCallFilterPostCallLeakSuppression(t *testing.T) {
// Test 1: Tool call followed by citation disclaimer
filter1 := NewStreamToolCallFilter()
var contentParts1 []string
var toolCalls1 []ToolCall
onContent1 := func(s string) { contentParts1 = append(contentParts1, s) }
onToolCall1 := func(tc ToolCall) { toolCalls1 = append(toolCalls1, tc) }
chunks1 := []string{
"<tool_call>\n",
"{\"name\": \"get_current_weather\", \"arguments\": {\"location\": \"Tokyo\"}}\n",
"</tool_call>\n",
"\n*Web evidence was retrieved, but the response did not include a valid source citation.*",
}
for _, c := range chunks1 {
filter1.Feed(c, onContent1, onToolCall1)
}
filter1.Flush(onContent1, onToolCall1)
if len(toolCalls1) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(toolCalls1))
}
if toolCalls1[0].Function.Name != "get_current_weather" {
t.Errorf("expected get_current_weather, got %q", toolCalls1[0].Function.Name)
}
if len(contentParts1) > 0 {
t.Errorf("expected 0 content parts leaked after tool call, got %v", contentParts1)
}
// Test 2: Parallel tool calls followed by trailing residue
filter2 := NewStreamToolCallFilter()
var contentParts2 []string
var toolCalls2 []ToolCall
onContent2 := func(s string) { contentParts2 = append(contentParts2, s) }
onToolCall2 := func(tc ToolCall) { toolCalls2 = append(toolCalls2, tc) }
chunks2 := []string{
"<tool_call>{\"name\": \"call_a\", \"arguments\": {}}</tool_call>\n",
"<tool_call>{\"name\": \"call_b\", \"arguments\": {}}</tool_call>\n",
"Residual text after parallel calls that should be suppressed.",
}
for _, c := range chunks2 {
filter2.Feed(c, onContent2, onToolCall2)
}
filter2.Flush(onContent2, onToolCall2)
if len(toolCalls2) != 2 {
t.Fatalf("expected 2 tool calls, got %d", len(toolCalls2))
}
if toolCalls2[0].Function.Name != "call_a" || toolCalls2[1].Function.Name != "call_b" {
t.Errorf("unexpected tool calls: %+v", toolCalls2)
}
if len(contentParts2) > 0 {
t.Errorf("expected 0 content parts leaked, got %v", contentParts2)
}
// Test 3: Preamble + Tool call + Postamble
filter3 := NewStreamToolCallFilter()
var contentParts3 []string
var toolCalls3 []ToolCall
onContent3 := func(s string) { contentParts3 = append(contentParts3, s) }
onToolCall3 := func(tc ToolCall) { toolCalls3 = append(toolCalls3, tc) }
chunks3 := []string{
"Thinking about your query: ",
"<tool_call>{\"name\": \"search\", \"arguments\": {\"q\": \"go\"}}</tool_call>",
" Trailing hallucinated answer that must not be emitted.",
}
for _, c := range chunks3 {
filter3.Feed(c, onContent3, onToolCall3)
}
filter3.Flush(onContent3, onToolCall3)
if len(toolCalls3) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(toolCalls3))
}
fullContent3 := strings.Join(contentParts3, "")
if fullContent3 != "Thinking about your query: " {
t.Errorf("expected preamble 'Thinking about your query: ', got %q", fullContent3)
}
}
func TestWebSearchParameterMappingAndSuppression(t *testing.T) {
gw := &GradioGateway{}
// 1. Radio search_mode with choices ["Auto search", "Always search", "Direct"]
discRadio := NewDefaultSpaceDiscovery("https://test-search-space.hf.space")
discRadio.TotalInputs = 3
discRadio.MessageIndex = 0
discRadio.HistoryIndex = 1
discRadio.WebSearchIndex = 2
discRadio.ParamMappings = []SpaceParamMapping{
{InputIndex: 0, ComponentType: "textbox", Label: "message", ParamType: "message"},
{InputIndex: 1, ComponentType: "chatbot", Label: "history", ParamType: "history"},
{
InputIndex: 2,
ComponentType: "radio",
Label: "search_mode",
ParamName: "search_mode",
ParamType: "web_search",
DefaultValue: "Auto search",
Choices: []string{"Auto search", "Always search", "Direct"},
},
}
discRadio.DefaultInputs = []interface{}{"", nil, "Auto search"}
// Normal request without tools retains space default ("Auto search")
reqNoTools := ChatCompletionRequest{
Model: "test-model",
Messages: []ChatMessage{
{Role: "user", Content: "Hello world"},
},
}
dataNoTools, err := gw.BuildGradioPayload(discRadio, reqNoTools)
if err != nil {
t.Fatalf("failed to build payload without tools: %v", err)
}
if dataNoTools[2] != "Auto search" {
t.Errorf("expected 'Auto search' default retained, got %v", dataNoTools[2])
}
// Request with explicit tools disables web search ("Direct")
reqWithTools := ChatCompletionRequest{
Model: "test-model",
Tools: []Tool{
{Type: "function", Function: map[string]interface{}{"name": "get_weather"}},
},
Messages: []ChatMessage{
{Role: "user", Content: "Weather in Tokyo?"},
},
}
dataWithTools, err := gw.BuildGradioPayload(discRadio, reqWithTools)
if err != nil {
t.Fatalf("failed to build payload with tools: %v", err)
}
if dataWithTools[2] != "Direct" {
t.Errorf("expected search_mode disabled to 'Direct', got %v", dataWithTools[2])
}
// 2. Checkbox web_search with bool default
discCheck := NewDefaultSpaceDiscovery("https://test-check-space.hf.space")
discCheck.TotalInputs = 2
discCheck.MessageIndex = 0
discCheck.WebSearchIndex = 1
discCheck.ParamMappings = []SpaceParamMapping{
{InputIndex: 0, ComponentType: "textbox", Label: "message", ParamType: "message"},
{InputIndex: 1, ComponentType: "checkbox", Label: "enable_web_search", ParamType: "web_search", DefaultValue: true},
}
discCheck.DefaultInputs = []interface{}{"", true}
dataCheckNoTools, _ := gw.BuildGradioPayload(discCheck, reqNoTools)
if dataCheckNoTools[1] != true {
t.Errorf("expected bool true retained without tools, got %v", dataCheckNoTools[1])
}
dataCheckWithTools, _ := gw.BuildGradioPayload(discCheck, reqWithTools)
if dataCheckWithTools[1] != false {
t.Errorf("expected bool false set with tools, got %v", dataCheckWithTools[1])
}
}