diff --git a/internal/tools/web_search.go b/internal/tools/web_search.go index cb3b4621..7cd6eb99 100644 --- a/internal/tools/web_search.go +++ b/internal/tools/web_search.go @@ -163,6 +163,10 @@ func (t *WebSearchTool) Parameters() 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', '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"}, } @@ -183,6 +187,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *Resul searchLang, _ := args["search_lang"].(string) uiLang, _ := args["ui_lang"].(string) freshness, _ := args["freshness"].(string) + requestedProvider, _ := args["provider"].(string) params := searchParams{ Query: query, @@ -193,18 +198,40 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *Resul Freshness: freshness, } - // Check cache (scoped per channel to prevent cross-channel cache poisoning) + // Check cache (scoped per channel + provider to prevent cross-engine cache mixing) channel := ToolChannelFromCtx(ctx) - cacheKey := fmt.Sprintf("%s:%s", channel, buildSearchCacheKey(params)) + 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) + 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) - // Try providers in order (first success wins) + // 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) diff --git a/internal/tools/web_search_provider_param_test.go b/internal/tools/web_search_provider_param_test.go new file mode 100644 index 00000000..281e297b --- /dev/null +++ b/internal/tools/web_search_provider_param_test.go @@ -0,0 +1,149 @@ +package tools + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// searchStubProvider returns a fixed result so we can assert which provider ran. +type searchStubProvider struct { + name string + called *bool + result searchResult + failure error +} + +func (s *searchStubProvider) Name() string { return s.name } +func (s *searchStubProvider) Search(_ context.Context, _ searchParams) ([]searchResult, error) { + if s.called != nil { + *s.called = true + } + if s.failure != nil { + return nil, s.failure + } + return []searchResult{s.result}, nil +} + +// makeStubbedTool installs a fixed chain of searchStubProviders for the master tenant +// so Execute() bypasses the real provider chain resolution. +func makeStubbedTool(t *testing.T, providers ...*searchStubProvider) (*WebSearchTool, context.Context) { + t.Helper() + tool := &WebSearchTool{ + secrets: newFakeSecretsStore(), + cache: newWebCache(defaultCacheMaxEntries, defaultCacheTTL), + chainCache: newTenantChainCache(), + } + tid := uuid.New() + ctx := store.WithTenantID(context.Background(), tid) + chain := make([]SearchProvider, len(providers)) + for i, p := range providers { + chain[i] = p + } + tool.chainCache.Set(tid, chain) + return tool, ctx +} + +// TestExecute_ProviderParam_NarrowsChainToOne — when caller passes provider="exa", +// the brave provider must NOT be called even though it sits earlier in the chain. +func TestExecute_ProviderParam_NarrowsChainToOne(t *testing.T) { + braveCalled, exaCalled := false, false + tool, ctx := makeStubbedTool(t, + &searchStubProvider{name: "brave", called: &braveCalled, result: searchResult{Title: "brave-hit", URL: "https://b.example/x"}}, + &searchStubProvider{name: "exa", called: &exaCalled, result: searchResult{Title: "exa-hit", URL: "https://e.example/x"}}, + ) + + res := tool.Execute(ctx, map[string]any{ + "query": "solana dex volume", + "provider": "exa", + }) + + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if braveCalled { + t.Errorf("brave was called but provider=\"exa\" requested a narrowed chain") + } + if !exaCalled { + t.Errorf("exa not called despite provider=\"exa\"") + } + if !strings.Contains(res.ForLLM, "exa-hit") { + t.Errorf("expected exa result in output, got: %s", res.ForLLM) + } +} + +// TestExecute_ProviderParam_CaseInsensitive — "EXA", "Exa", "exa" all work. +func TestExecute_ProviderParam_CaseInsensitive(t *testing.T) { + for _, want := range []string{"EXA", "Exa", "exa"} { + t.Run(want, func(t *testing.T) { + exaCalled := false + tool, ctx := makeStubbedTool(t, + &searchStubProvider{name: "brave", called: nil, result: searchResult{Title: "brave-hit"}}, + &searchStubProvider{name: "exa", called: &exaCalled, result: searchResult{Title: "exa-hit"}}, + ) + res := tool.Execute(ctx, map[string]any{"query": "q", "provider": want}) + if res.IsError { + t.Fatalf("error: %s", res.ForLLM) + } + if !exaCalled { + t.Errorf("exa should be called for provider=%q", want) + } + }) + } +} + +// TestExecute_ProviderParam_Unknown — caller asking for a provider not in the +// tenant chain gets a clear error listing what IS available. +func TestExecute_ProviderParam_Unknown(t *testing.T) { + tool, ctx := makeStubbedTool(t, + &searchStubProvider{name: "brave", result: searchResult{}}, + &searchStubProvider{name: "exa", result: searchResult{}}, + ) + res := tool.Execute(ctx, map[string]any{"query": "q", "provider": "google"}) + if !res.IsError { + t.Fatalf("expected error for unknown provider, got success: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "google") || !strings.Contains(res.ForLLM, "brave") || !strings.Contains(res.ForLLM, "exa") { + t.Errorf("error should name unknown provider AND list available ones, got: %s", res.ForLLM) + } +} + +// TestExecute_NoProviderParam_FallsBackToFirstSuccessWins — omitting provider +// preserves the existing behaviour: chain order is honoured, first success wins. +func TestExecute_NoProviderParam_FallsBackToFirstSuccessWins(t *testing.T) { + braveCalled, exaCalled := false, false + tool, ctx := makeStubbedTool(t, + &searchStubProvider{name: "brave", called: &braveCalled, result: searchResult{Title: "brave-hit"}}, + &searchStubProvider{name: "exa", called: &exaCalled, result: searchResult{Title: "exa-hit"}}, + ) + res := tool.Execute(ctx, map[string]any{"query": "q"}) + if res.IsError { + t.Fatalf("error: %s", res.ForLLM) + } + if !braveCalled { + t.Errorf("brave should be called first when no provider param") + } + if exaCalled { + t.Errorf("exa should NOT be called when brave succeeded (first-success-wins)") + } +} + +// TestExecute_ProviderParam_CacheIsolated — same query but different `provider` +// args must NOT collide in the cache, otherwise cross-engine corroboration +// would just replay one engine's result twice. +func TestExecute_ProviderParam_CacheIsolated(t *testing.T) { + braveCalled, exaCalled := false, false + tool, ctx := makeStubbedTool(t, + &searchStubProvider{name: "brave", called: &braveCalled, result: searchResult{Title: "brave-hit"}}, + &searchStubProvider{name: "exa", called: &exaCalled, result: searchResult{Title: "exa-hit"}}, + ) + _ = tool.Execute(ctx, map[string]any{"query": "q", "provider": "brave"}) + _ = tool.Execute(ctx, map[string]any{"query": "q", "provider": "exa"}) + if !braveCalled || !exaCalled { + t.Fatalf("both providers should run for distinct provider args (brave=%v, exa=%v)", braveCalled, exaCalled) + } +}