mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 12:13:51 +00:00
* 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.
296 lines
8.7 KiB
Go
296 lines
8.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// Matching TS src/agents/tools/web-search.ts constants.
|
|
const (
|
|
defaultSearchCount = 5
|
|
maxSearchCount = 10
|
|
searchTimeoutSeconds = 30
|
|
braveSearchEndpoint = "https://api.search.brave.com/res/v1/web/search"
|
|
exaSearchEndpoint = "https://api.exa.ai/search"
|
|
tavilySearchEndpoint = "https://api.tavily.com/search"
|
|
serplySearchEndpoint = "https://api.serply.io/v1/search"
|
|
webSearchUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
const (
|
|
searchProviderExa = "exa"
|
|
searchProviderTavily = "tavily"
|
|
searchProviderBrave = "brave"
|
|
searchProviderParallel = "parallel"
|
|
searchProviderSerply = "serply"
|
|
searchProviderDuckDuckGo = "duckduckgo"
|
|
)
|
|
|
|
var defaultSearchProviderOrder = []string{
|
|
searchProviderExa,
|
|
searchProviderTavily,
|
|
searchProviderBrave,
|
|
searchProviderSerply,
|
|
searchProviderDuckDuckGo,
|
|
}
|
|
|
|
// SearchProvider abstracts a web search backend.
|
|
type SearchProvider interface {
|
|
Search(ctx context.Context, params searchParams) ([]searchResult, error)
|
|
Name() string
|
|
}
|
|
|
|
type searchParams struct {
|
|
Query string
|
|
Count int
|
|
Country string
|
|
SearchLang string
|
|
UILang string
|
|
Freshness string
|
|
}
|
|
|
|
type searchResult struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// --- Freshness validation (matching TS) ---
|
|
|
|
var (
|
|
freshnessShortcuts = map[string]bool{"pd": true, "pw": true, "pm": true, "py": true}
|
|
freshnessRangeRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$`)
|
|
)
|
|
|
|
func normalizeFreshness(value string) string {
|
|
v := strings.ToLower(strings.TrimSpace(value))
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
if freshnessShortcuts[v] {
|
|
return v
|
|
}
|
|
if m := freshnessRangeRe.FindStringSubmatch(v); len(m) == 3 {
|
|
start, errS := time.Parse("2006-01-02", m[1])
|
|
end, errE := time.Parse("2006-01-02", m[2])
|
|
if errS == nil && errE == nil && !start.After(end) {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- WebSearchTool ---
|
|
|
|
// WebSearchTool implements the web_search tool with per-tenant provider chain
|
|
// resolution. Providers are resolved per-request from config_secrets and
|
|
// builtin_tool_tenant_configs.settings, cached per tenant for 60 seconds.
|
|
type WebSearchTool struct {
|
|
secrets store.ConfigSecretsStore
|
|
cache *webCache
|
|
chainCache *tenantChainCache
|
|
}
|
|
|
|
// NewWebSearchTool constructs a WebSearchTool. msgBus may be nil (e.g. desktop
|
|
// edition) — cache invalidation then relies on TTL alone.
|
|
func NewWebSearchTool(secrets store.ConfigSecretsStore, msgBus *bus.MessageBus) *WebSearchTool {
|
|
t := &WebSearchTool{
|
|
secrets: secrets,
|
|
cache: newWebCache(defaultCacheMaxEntries, defaultCacheTTL),
|
|
chainCache: newTenantChainCache(),
|
|
}
|
|
|
|
if msgBus != nil {
|
|
msgBus.Subscribe("web_search:cache_invalidate", func(event bus.Event) {
|
|
if event.Name != protocol.EventCacheInvalidate {
|
|
return
|
|
}
|
|
payload, ok := event.Payload.(bus.CacheInvalidatePayload)
|
|
if !ok || payload.Kind != bus.CacheKindBuiltinTools || payload.Key != "web_search" {
|
|
return
|
|
}
|
|
if payload.TenantID == uuid.Nil {
|
|
// Master admin write — wipe all tenants.
|
|
t.chainCache.InvalidateAll()
|
|
} else {
|
|
t.chainCache.Invalidate(payload.TenantID)
|
|
}
|
|
})
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
func (t *WebSearchTool) Name() string { return "web_search" }
|
|
|
|
func (t *WebSearchTool) Description() string {
|
|
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
|
|
}
|
|
|
|
func (t *WebSearchTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]any{
|
|
"type": "string",
|
|
"description": "Search query string.",
|
|
},
|
|
"count": map[string]any{
|
|
"type": "number",
|
|
"description": "Number of results to return (1-10).",
|
|
"minimum": 1.0,
|
|
"maximum": float64(maxSearchCount),
|
|
},
|
|
"country": map[string]any{
|
|
"type": "string",
|
|
"description": "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
|
},
|
|
"search_lang": map[string]any{
|
|
"type": "string",
|
|
"description": "ISO language code for search results (e.g., 'de', 'en', 'fr').",
|
|
},
|
|
"ui_lang": map[string]any{
|
|
"type": "string",
|
|
"description": "ISO language code for UI elements.",
|
|
},
|
|
"freshness": map[string]any{
|
|
"type": "string",
|
|
"description": "Filter results by discovery time. Supports 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), and date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
|
|
},
|
|
"provider": map[string]any{
|
|
"type": "string",
|
|
"description": "Optional: force a specific provider (e.g., 'tavily', 'exa', 'brave', 'parallel', 'serply', 'duckduckgo'). When omitted, the tenant's configured provider chain is used (first-success-wins). Use this to force cross-engine corroboration — call once with each provider and compare results.",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
}
|
|
}
|
|
|
|
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
query, _ := args["query"].(string)
|
|
if query == "" {
|
|
return ErrorResult("query is required")
|
|
}
|
|
|
|
count := defaultSearchCount
|
|
if c, ok := args["count"].(float64); ok && int(c) >= 1 && int(c) <= maxSearchCount {
|
|
count = int(c)
|
|
}
|
|
|
|
country, _ := args["country"].(string)
|
|
searchLang, _ := args["search_lang"].(string)
|
|
uiLang, _ := args["ui_lang"].(string)
|
|
freshness, _ := args["freshness"].(string)
|
|
requestedProvider, _ := args["provider"].(string)
|
|
|
|
params := searchParams{
|
|
Query: query,
|
|
Count: count,
|
|
Country: country,
|
|
SearchLang: searchLang,
|
|
UILang: uiLang,
|
|
Freshness: freshness,
|
|
}
|
|
|
|
// Check cache (scoped per channel + provider to prevent cross-engine cache mixing)
|
|
channel := ToolChannelFromCtx(ctx)
|
|
cacheKey := fmt.Sprintf("%s:%s:%s", channel, requestedProvider, buildSearchCacheKey(params))
|
|
if cached, ok := t.cache.get(cacheKey); ok {
|
|
slog.Debug("web_search cache hit", "query", query, "provider", requestedProvider)
|
|
return NewResult(cached)
|
|
}
|
|
|
|
// Resolve per-request provider chain from tenant config_secrets + settings overlay.
|
|
chain := t.resolveChain(ctx)
|
|
|
|
// If caller explicitly named a provider, narrow the chain to just that one.
|
|
// This unlocks cross-engine corroboration (caller invokes once per engine
|
|
// and compares results) — without a provider param, the first-success-wins
|
|
// chain hides everything after the first hit.
|
|
if requestedProvider != "" {
|
|
filtered := make([]SearchProvider, 0, 1)
|
|
for _, p := range chain {
|
|
if strings.EqualFold(p.Name(), requestedProvider) {
|
|
filtered = append(filtered, p)
|
|
break
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
available := make([]string, 0, len(chain))
|
|
for _, p := range chain {
|
|
available = append(available, p.Name())
|
|
}
|
|
return ErrorResult(fmt.Sprintf("provider %q not configured for this tenant; available: %v", requestedProvider, available))
|
|
}
|
|
chain = filtered
|
|
}
|
|
|
|
// Try providers in order (first success wins, unless narrowed above)
|
|
var lastErr error
|
|
for _, provider := range chain {
|
|
results, err := provider.Search(ctx, params)
|
|
if err != nil {
|
|
slog.Warn("web_search provider failed", "provider", provider.Name(), "error", err)
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
formatted := formatSearchResults(query, results, provider.Name())
|
|
wrapped := wrapExternalContent(formatted, "Web Search", false)
|
|
|
|
t.cache.set(cacheKey, wrapped)
|
|
return NewResult(wrapped)
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return ErrorResult(fmt.Sprintf("all search providers failed: %v", lastErr))
|
|
}
|
|
return ErrorResult("no search providers configured")
|
|
}
|
|
|
|
func buildSearchCacheKey(p searchParams) string {
|
|
parts := []string{
|
|
p.Query,
|
|
fmt.Sprintf("%d", p.Count),
|
|
orDefault(p.Country, "default"),
|
|
orDefault(p.SearchLang, "default"),
|
|
orDefault(p.UILang, "default"),
|
|
orDefault(p.Freshness, "default"),
|
|
}
|
|
return strings.Join(parts, ":")
|
|
}
|
|
|
|
func orDefault(s, def string) string {
|
|
if s == "" {
|
|
return def
|
|
}
|
|
return s
|
|
}
|
|
|
|
func formatSearchResults(query string, results []searchResult, provider string) string {
|
|
if len(results) == 0 {
|
|
return fmt.Sprintf("No results found for: %s", query)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Search results for: %s (via %s)\n\n", query, provider))
|
|
for i, r := range results {
|
|
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n", i+1, r.Title, r.URL))
|
|
if r.Description != "" {
|
|
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
|
|
}
|
|
sb.WriteByte('\n')
|
|
}
|
|
return sb.String()
|
|
}
|