Files
goclaw/internal/tools/web_search_config.go
Serply 407e564b91 feat(tools): add Serply as a web_search provider (#1567)
* feat(tools): add Serply as a web_search provider

Serply is a SERP API returning Google web results on a tenant-supplied
key. It joins the existing chain the same way Exa, Tavily and Brave do:
keyed from config_secrets, skipped silently when no key is stored, and
selectable through the provider parameter for cross-engine corroboration.

Freshness maps onto Google's tbs recency filter, which Serply forwards
upstream. Only the pd/pw/pm/py shortcuts are sent: upstream ignores the
equivalent cdr date range and answers with unfiltered results, so a
range leaves tbs unset rather than naming a filter the results do not
honour.

The settings form gained a fifth sortable provider, so the locked
DuckDuckGo card now derives its position number instead of hardcoding
it, and a stored provider_order is reconciled against the full provider
list rather than against Parallel alone.

* test(tools): use a locally named round trip stub in the serply test

The serply cancellation test borrowed parallelRoundTripFunc from
web_search_parallel_test.go. Define serplyRoundTripFunc alongside the
test that uses it so the file does not depend on another provider's
test helper.
2026-09-26 10:33:07 +07:00

104 lines
2.6 KiB
Go

package tools
import (
"slices"
"strings"
)
// buildProviderByName returns the SearchProvider for a known name.
// Returns nil for unknown names. DDG ignores apiKey (not required).
// maxResults <= 0 falls back to defaultSearchCount.
func buildProviderByName(name, apiKey string, maxResults int) SearchProvider {
if maxResults <= 0 {
maxResults = defaultSearchCount
}
switch name {
case searchProviderExa:
return newExaSearchProvider(apiKey, maxResults)
case searchProviderTavily:
return newTavilySearchProvider(apiKey, maxResults)
case searchProviderBrave:
return newBraveSearchProvider(apiKey, maxResults)
case searchProviderParallel:
return newParallelSearchProvider(maxResults)
case searchProviderSerply:
return newSerplySearchProvider(apiKey, maxResults)
case searchProviderDuckDuckGo:
return newDuckDuckGoSearchProvider(maxResults)
default:
return nil
}
}
// NormalizeWebSearchProviderOrder normalizes user-specified provider order.
// Explicit providers appear first in their specified order, remaining known
// providers are appended (DuckDuckGo always last as free fallback).
func NormalizeWebSearchProviderOrder(order []string) []string {
result := make([]string, 0, len(defaultSearchProviderOrder))
seen := make(map[string]bool, len(defaultSearchProviderOrder))
for _, raw := range order {
id := strings.ToLower(strings.TrimSpace(raw))
if id == searchProviderDuckDuckGo || id == "" {
continue // DDG always last
}
if !isKnownSearchProvider(id) || seen[id] {
continue
}
result = append(result, id)
seen[id] = true
}
// Append remaining known providers not yet listed (except DDG).
for _, id := range defaultSearchProviderOrder {
if id == searchProviderDuckDuckGo {
continue
}
if !seen[id] {
result = append(result, id)
}
}
return append(result, searchProviderDuckDuckGo)
}
func isKnownSearchProvider(id string) bool {
return id == searchProviderParallel || slices.Contains(defaultSearchProviderOrder, id)
}
// --- Shared provider helpers ---
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func coalesceSearchText(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func clampProviderResultCount(requested, providerMax int) int {
if requested <= 0 {
requested = defaultSearchCount
}
if providerMax > 0 && requested > providerMax {
return providerMax
}
return requested
}
func normalizeProviderMaxResults(value int) int {
if value <= 0 {
return defaultSearchCount
}
if value > maxSearchCount {
return maxSearchCount
}
return value
}