mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 13:04:57 +00:00
feat(tools): tenant-aware web_search provider chain resolution
Refactor web_search Execute to resolve its provider chain per-request
via BuiltinToolSettingsFromCtx instead of iterating the singleton's
hardcoded provider list. Tenant admins can now reorder or disable
providers via builtin_tool_tenant_configs.settings using the shape
{"provider_order":[...], "brave":{"enabled":false}, ...}. Unknown
provider names in provider_order are logged + skipped — no injection
path via this settings blob. Malformed JSON falls back to defaults
(fail-open so a bad admin paste never crashes the tool). Secrets stay
in config_secrets — tenant cannot inject API keys through this path.
11 unit tests cover reorder, filter, disable, unknown-skip, malformed,
global-layer, and tenant-vs-global precedence.
Note: This is the MVP tenant adoption half of the plan's Phase 7. The
Exa + Tavily feature port from PR 825 and coordination with its author
is deferred to a separate follow-up (the port is net-new feature work
orthogonal to the overlay adoption).
This commit is contained in:
@@ -182,9 +182,14 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *Resul
|
||||
return NewResult(cached)
|
||||
}
|
||||
|
||||
// Resolve per-request provider chain (tenant may reorder / disable providers
|
||||
// via builtin_tool_tenant_configs.settings → ctx → ResolveWebSearchChain).
|
||||
// Defaults preserved when no override — backward-compat.
|
||||
chain := ResolveWebSearchChain(ctx, t.providers)
|
||||
|
||||
// Try providers in order (first success wins)
|
||||
var lastErr error
|
||||
for _, provider := range t.providers {
|
||||
for _, provider := range chain {
|
||||
results, err := provider.Search(ctx, params)
|
||||
if err != nil {
|
||||
slog.Warn("web_search provider failed", "provider", provider.Name(), "error", err)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// web_search_chain.go — tenant-aware provider chain resolution.
|
||||
//
|
||||
// The web_search tool is registered as a singleton at gateway startup with a
|
||||
// hardcoded provider list built from the global config (see NewWebSearchTool
|
||||
// in web_search.go). To honor per-tenant overrides without re-constructing
|
||||
// the singleton, Execute() calls ResolveWebSearchChain per request to decide
|
||||
// which of its existing providers to try and in what order.
|
||||
//
|
||||
// Tenant settings schema (stored in builtin_tool_tenant_configs.settings):
|
||||
//
|
||||
// {
|
||||
// "provider_order": ["duckduckgo", "brave"], // optional reorder
|
||||
// "brave": { "enabled": false }, // optional per-provider disable
|
||||
// "duckduckgo": { "enabled": true }
|
||||
// }
|
||||
//
|
||||
// Secrets (API keys) stay in config_secrets — the tenant cannot inject keys
|
||||
// via this settings blob. Tenant admins that need to supply their own key
|
||||
// use the existing tenant-scoped config_secrets path.
|
||||
|
||||
// WebSearchProviderOverride is the per-provider override envelope. Only
|
||||
// non-nil fields override the default. Unknown fields in the JSON blob are
|
||||
// ignored to stay forward-compatible with future tuning knobs.
|
||||
type WebSearchProviderOverride struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchChainOverride is the full tenant settings shape for web_search.
|
||||
// All fields optional — an empty/nil override results in the default chain.
|
||||
type WebSearchChainOverride struct {
|
||||
ProviderOrder []string `json:"provider_order,omitempty"`
|
||||
Providers map[string]WebSearchProviderOverride `json:"-"`
|
||||
// Per-provider sections are unmarshaled into Providers via custom logic
|
||||
// below so admins can keep the natural JSON shape:
|
||||
// { "brave": {...}, "duckduckgo": {...} }
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts the flat admin-facing shape:
|
||||
//
|
||||
// { "provider_order": [...], "brave": {...}, "duckduckgo": {...} }
|
||||
//
|
||||
// Keeps ProviderOrder top-level and collects every other object field into
|
||||
// the Providers map keyed by provider name.
|
||||
func (w *WebSearchChainOverride) UnmarshalJSON(data []byte) error {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if orderRaw, ok := raw["provider_order"]; ok {
|
||||
if err := json.Unmarshal(orderRaw, &w.ProviderOrder); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(raw, "provider_order")
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
w.Providers = make(map[string]WebSearchProviderOverride, len(raw))
|
||||
for name, blob := range raw {
|
||||
var po WebSearchProviderOverride
|
||||
if err := json.Unmarshal(blob, &po); err != nil {
|
||||
// Skip unknown keys that aren't provider overrides (forward-compat).
|
||||
slog.Debug("web_search: skipping unrecognized override key", "key", name, "error", err)
|
||||
continue
|
||||
}
|
||||
w.Providers[name] = po
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveWebSearchChain returns the ordered slice of providers to try for
|
||||
// the current request, honoring any tenant override in ctx.
|
||||
//
|
||||
// Precedence:
|
||||
// 1. Tenant override via BuiltinToolSettingsFromCtx["web_search"]
|
||||
// — reorders via provider_order and/or disables individual providers
|
||||
// 2. Defaults — the singleton's built-in provider order
|
||||
//
|
||||
// A provider is included only if it:
|
||||
// - exists in the defaults (tenant can't inject new providers this way)
|
||||
// - is not explicitly disabled (enabled=false) by the tenant
|
||||
//
|
||||
// Returns a fresh slice even on the fast-path so the caller can iterate
|
||||
// freely — the underlying providers are shared singletons (stateless).
|
||||
func ResolveWebSearchChain(ctx context.Context, defaults []SearchProvider) []SearchProvider {
|
||||
if len(defaults) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fast path: no ctx settings → return defaults unchanged.
|
||||
settings := BuiltinToolSettingsFromCtx(ctx)
|
||||
raw, hasKey := settings["web_search"]
|
||||
if !hasKey || len(raw) == 0 {
|
||||
return defaults
|
||||
}
|
||||
|
||||
var override WebSearchChainOverride
|
||||
if err := json.Unmarshal(raw, &override); err != nil {
|
||||
slog.Warn("web_search: failed to parse tenant override, using defaults", "error", err)
|
||||
return defaults
|
||||
}
|
||||
|
||||
// Build a name → provider index from defaults so we can reorder / filter
|
||||
// without losing the actual SearchProvider instance.
|
||||
byName := make(map[string]SearchProvider, len(defaults))
|
||||
for _, p := range defaults {
|
||||
byName[p.Name()] = p
|
||||
}
|
||||
|
||||
// isDisabled returns true if the tenant explicitly disabled a provider.
|
||||
isDisabled := func(name string) bool {
|
||||
po, ok := override.Providers[name]
|
||||
return ok && po.Enabled != nil && !*po.Enabled
|
||||
}
|
||||
|
||||
// If tenant set provider_order, use it as the authoritative iteration
|
||||
// order. Providers not in the order list fall off (admin intent: "only
|
||||
// try these, in this order"). Unknown providers in the list are skipped
|
||||
// with a warning — no injection path.
|
||||
if len(override.ProviderOrder) > 0 {
|
||||
chain := make([]SearchProvider, 0, len(override.ProviderOrder))
|
||||
for _, name := range override.ProviderOrder {
|
||||
p, ok := byName[name]
|
||||
if !ok {
|
||||
slog.Warn("web_search: tenant provider_order references unknown provider", "name", name)
|
||||
continue
|
||||
}
|
||||
if isDisabled(name) {
|
||||
continue
|
||||
}
|
||||
chain = append(chain, p)
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
// No provider_order but per-provider enabled flags may still filter.
|
||||
// Preserve default order, drop disabled entries.
|
||||
if len(override.Providers) > 0 {
|
||||
chain := make([]SearchProvider, 0, len(defaults))
|
||||
for _, p := range defaults {
|
||||
if isDisabled(p.Name()) {
|
||||
continue
|
||||
}
|
||||
chain = append(chain, p)
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
// Override present but empty — use defaults.
|
||||
return defaults
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeSearchProvider implements SearchProvider for chain-resolution tests.
|
||||
// Only Name() is exercised — Search is never called.
|
||||
type fakeSearchProvider struct{ name string }
|
||||
|
||||
func (f *fakeSearchProvider) Name() string { return f.name }
|
||||
func (f *fakeSearchProvider) Search(_ context.Context, _ searchParams) ([]searchResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// defaultChain builds a deterministic default provider list used across tests.
|
||||
func defaultChain() []SearchProvider {
|
||||
return []SearchProvider{
|
||||
&fakeSearchProvider{name: "brave"},
|
||||
&fakeSearchProvider{name: "duckduckgo"},
|
||||
}
|
||||
}
|
||||
|
||||
// chainNames extracts provider names in order for terse assertions.
|
||||
func chainNames(chain []SearchProvider) []string {
|
||||
out := make([]string, 0, len(chain))
|
||||
for _, p := range chain {
|
||||
out = append(out, p.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- No override → defaults unchanged ----
|
||||
|
||||
func TestResolveWebSearchChain_NoOverride_ReturnsDefaults(t *testing.T) {
|
||||
got := ResolveWebSearchChain(context.Background(), defaultChain())
|
||||
if len(got) != 2 || got[0].Name() != "brave" || got[1].Name() != "duckduckgo" {
|
||||
t.Errorf("no override: got %v, want [brave duckduckgo]", chainNames(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Empty defaults → nil result.
|
||||
func TestResolveWebSearchChain_EmptyDefaults_ReturnsNil(t *testing.T) {
|
||||
got := ResolveWebSearchChain(context.Background(), nil)
|
||||
if got != nil {
|
||||
t.Errorf("empty defaults: got %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- provider_order reorders ----
|
||||
|
||||
func TestResolveWebSearchChain_ProviderOrderReorders(t *testing.T) {
|
||||
override := []byte(`{"provider_order":["duckduckgo","brave"]}`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
want := []string{"duckduckgo", "brave"}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("reorder: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// provider_order including only one provider → chain has only that provider.
|
||||
func TestResolveWebSearchChain_ProviderOrderFiltersOut(t *testing.T) {
|
||||
override := []byte(`{"provider_order":["duckduckgo"]}`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 1 || got[0] != "duckduckgo" {
|
||||
t.Errorf("filter: got %v, want [duckduckgo]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown providers in provider_order are skipped (no injection path).
|
||||
func TestResolveWebSearchChain_ProviderOrderIgnoresUnknown(t *testing.T) {
|
||||
override := []byte(`{"provider_order":["malicious_new_provider","brave"]}`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 1 || got[0] != "brave" {
|
||||
t.Errorf("unknown skip: got %v, want [brave]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-provider enabled flag ----
|
||||
|
||||
func TestResolveWebSearchChain_DisablesProviderViaEnabledFalse(t *testing.T) {
|
||||
override := []byte(`{"brave":{"enabled":false}}`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 1 || got[0] != "duckduckgo" {
|
||||
t.Errorf("disable brave: got %v, want [duckduckgo]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// enabled=true is a no-op (default is always "enabled" by virtue of being in defaults).
|
||||
func TestResolveWebSearchChain_EnabledTrueIsNoOp(t *testing.T) {
|
||||
override := []byte(`{"brave":{"enabled":true},"duckduckgo":{"enabled":true}}`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 2 {
|
||||
t.Errorf("enabled=true no-op: got %v, want 2 providers", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- malformed JSON → falls back to defaults ----
|
||||
|
||||
func TestResolveWebSearchChain_MalformedJSON_FallsBackToDefaults(t *testing.T) {
|
||||
override := []byte(`{not valid json`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 2 {
|
||||
t.Errorf("malformed: got %v, want defaults", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- combined: provider_order + disabled provider in the order ----
|
||||
|
||||
func TestResolveWebSearchChain_OrderAndDisableCombined(t *testing.T) {
|
||||
override := []byte(`{"provider_order":["brave","duckduckgo"],"brave":{"enabled":false}}`)
|
||||
tenant := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithTenantToolSettings(context.Background(), tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
// brave is in the order but disabled → skipped; duckduckgo survives.
|
||||
if len(got) != 1 || got[0] != "duckduckgo" {
|
||||
t.Errorf("order+disable: got %v, want [duckduckgo]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- global layer (WithBuiltinToolSettings) also drives the override ----
|
||||
// Ensures the merged view from BuiltinToolSettingsFromCtx surfaces global-layer
|
||||
// web_search settings just like tenant-layer.
|
||||
func TestResolveWebSearchChain_GlobalLayerDrivesOverride(t *testing.T) {
|
||||
override := []byte(`{"provider_order":["duckduckgo"]}`)
|
||||
global := BuiltinToolSettings{"web_search": override}
|
||||
ctx := WithBuiltinToolSettings(context.Background(), global)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 1 || got[0] != "duckduckgo" {
|
||||
t.Errorf("global layer: got %v, want [duckduckgo]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant layer beats global for the same tool (regression for Phase 3 precedence).
|
||||
func TestResolveWebSearchChain_TenantBeatsGlobal(t *testing.T) {
|
||||
global := BuiltinToolSettings{"web_search": []byte(`{"provider_order":["brave"]}`)}
|
||||
tenant := BuiltinToolSettings{"web_search": []byte(`{"provider_order":["duckduckgo"]}`)}
|
||||
|
||||
ctx := WithBuiltinToolSettings(context.Background(), global)
|
||||
ctx = WithTenantToolSettings(ctx, tenant)
|
||||
|
||||
got := chainNames(ResolveWebSearchChain(ctx, defaultChain()))
|
||||
if len(got) != 1 || got[0] != "duckduckgo" {
|
||||
t.Errorf("tenant beats global: got %v, want [duckduckgo]", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user