From c76d8f5a7c3b93f8543b532b506e221e56c806b6 Mon Sep 17 00:00:00 2001 From: Joey Krug Date: Sun, 3 May 2026 23:18:31 -0400 Subject: [PATCH] fix(web-search): keep first-class web_search runtime providers visible When createWebSearchTool is wired with lateBindRuntimeConfig: true, the first-class assistant tool now lives off whatever runtime is active at execute time. That works in the gateway process where runtime metadata and the active secrets snapshot are populated, but in agent contexts that do not share that in-process state, both fall through to undefined and the tool returned "web_search is disabled or no provider is available" even though `openclaw capability web search` and direct provider runtime execution succeeded. Two fixes: - src/agents/tools/web-search.ts: when late-binding, fall back to options.runtimeWebSearch when the active runtime web tools metadata is null, and fall back to options.config when getActiveSecretsRuntimeSnapshot is null. Derive a configured provider id from config.tools.web.search.provider and use it together with the runtime selection when deciding preferRuntimeProviders, so an explicit Brave/ Perplexity selection still discovers the configured plugin even when no runtime provider id is bound. - src/plugins/web-provider-runtime-shared.ts: the active gateway plugin registry may be otherwise compatible with the active config while contributing zero web providers (channels, memory, harnesses, and sidecars without Brave/web). Treating that empty active registry as authoritative meant first-class tools resolved to "no provider". Fall through to the scoped provider plugin load when the active registry returns no providers. Explicit `onlyPluginIds: []` still short-circuits to [] to preserve the empty-scope contract. Adds regression tests for both seams. --- src/agents/tools/web-search.late-bind.test.ts | 165 ++++++++++++++++++ src/agents/tools/web-search.ts | 15 +- .../web-provider-runtime-shared.test.ts | 114 ++++++++++++ src/plugins/web-provider-runtime-shared.ts | 22 ++- 4 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 src/agents/tools/web-search.late-bind.test.ts diff --git a/src/agents/tools/web-search.late-bind.test.ts b/src/agents/tools/web-search.late-bind.test.ts new file mode 100644 index 00000000000..b5fc3fd3798 --- /dev/null +++ b/src/agents/tools/web-search.late-bind.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runWebSearch: vi.fn(), + resolveManifestContractOwnerPluginId: vi.fn(), + getActiveRuntimeWebToolsMetadata: vi.fn(), + getActiveSecretsRuntimeSnapshot: vi.fn(), +})); + +vi.mock("../../web-search/runtime.js", () => ({ + resolveWebSearchProviderId: vi.fn(() => "mock"), + runWebSearch: mocks.runWebSearch, +})); + +vi.mock("../../plugins/plugin-registry.js", () => ({ + resolveManifestContractOwnerPluginId: mocks.resolveManifestContractOwnerPluginId, +})); + +vi.mock("../../secrets/runtime-web-tools-state.js", () => ({ + getActiveRuntimeWebToolsMetadata: mocks.getActiveRuntimeWebToolsMetadata, +})); + +vi.mock("../../secrets/runtime.js", () => ({ + getActiveSecretsRuntimeSnapshot: mocks.getActiveSecretsRuntimeSnapshot, +})); + +describe("web_search late-bound runtime fallback", () => { + beforeEach(() => { + mocks.runWebSearch.mockReset(); + mocks.runWebSearch.mockResolvedValue({ + provider: "brave", + result: { ok: true }, + }); + mocks.resolveManifestContractOwnerPluginId.mockReset(); + mocks.resolveManifestContractOwnerPluginId.mockReturnValue(undefined); + mocks.getActiveRuntimeWebToolsMetadata.mockReset(); + mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue(null); + mocks.getActiveSecretsRuntimeSnapshot.mockReset(); + mocks.getActiveSecretsRuntimeSnapshot.mockReturnValue(null); + }); + + it("falls back to options.runtimeWebSearch when active runtime web tools metadata is absent", async () => { + const { createWebSearchTool } = await import("./web-search.js"); + const tool = createWebSearchTool({ + config: {}, + lateBindRuntimeConfig: true, + runtimeWebSearch: { + selectedProvider: "brave", + providerConfigured: "brave", + providerSource: "plugin", + diagnostics: [], + }, + }); + + await tool?.execute("call-search", { query: "openclaw" }, undefined); + + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeWebSearch: expect.objectContaining({ selectedProvider: "brave" }), + }), + ); + }); + + it("falls back to options.config when getActiveSecretsRuntimeSnapshot is null", async () => { + const { createWebSearchTool } = await import("./web-search.js"); + const fallbackConfig = { + tools: { web: { search: { provider: "brave" } } }, + }; + const tool = createWebSearchTool({ + config: fallbackConfig, + lateBindRuntimeConfig: true, + }); + + await tool?.execute("call-search", { query: "openclaw" }, undefined); + + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ + config: fallbackConfig, + }), + ); + }); + + it("uses configured provider id from config when no runtime selection is present", async () => { + const { createWebSearchTool } = await import("./web-search.js"); + const config = { + tools: { web: { search: { provider: "Brave" } } }, + }; + const tool = createWebSearchTool({ + config, + lateBindRuntimeConfig: true, + }); + + await tool?.execute("call-search", { query: "openclaw" }, undefined); + + expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith( + expect.objectContaining({ value: "brave" }), + ); + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ preferRuntimeProviders: true }), + ); + }); + + it("does not prefer runtime providers when no provider id is selected anywhere", async () => { + const { createWebSearchTool } = await import("./web-search.js"); + const tool = createWebSearchTool({ + config: {}, + lateBindRuntimeConfig: true, + }); + + await tool?.execute("call-search", { query: "openclaw" }, undefined); + + expect(mocks.resolveManifestContractOwnerPluginId).not.toHaveBeenCalled(); + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ preferRuntimeProviders: false }), + ); + }); + + it("does not prefer runtime providers when the configured provider is a bundled manifest owner", async () => { + mocks.resolveManifestContractOwnerPluginId.mockReturnValue("openclaw-bundled-brave"); + const { createWebSearchTool } = await import("./web-search.js"); + const config = { + tools: { web: { search: { provider: "brave" } } }, + }; + const tool = createWebSearchTool({ + config, + lateBindRuntimeConfig: true, + }); + + await tool?.execute("call-search", { query: "openclaw" }, undefined); + + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ preferRuntimeProviders: false }), + ); + }); + + it("prefers active runtime metadata over options.runtimeWebSearch when present", async () => { + mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue({ + search: { + selectedProvider: "perplexity", + providerConfigured: "perplexity", + providerSource: "plugin", + diagnostics: [], + }, + }); + const { createWebSearchTool } = await import("./web-search.js"); + const tool = createWebSearchTool({ + config: {}, + lateBindRuntimeConfig: true, + runtimeWebSearch: { + selectedProvider: "brave", + providerConfigured: "brave", + providerSource: "plugin", + diagnostics: [], + }, + }); + + await tool?.execute("call-search", { query: "openclaw" }, undefined); + + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeWebSearch: expect.objectContaining({ selectedProvider: "perplexity" }), + }), + ); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 4e1392195d6..4a948ca725a 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -89,7 +89,7 @@ export function createWebSearchTool(options?: { execute: async (_toolCallId, args, signal) => { const runtimeWebSearch = options?.lateBindRuntimeConfig === true - ? getActiveRuntimeWebToolsMetadata()?.search + ? (getActiveRuntimeWebToolsMetadata()?.search ?? options?.runtimeWebSearch) : options?.runtimeWebSearch; const runtimeProviderId = runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured; @@ -97,11 +97,20 @@ export function createWebSearchTool(options?: { options?.lateBindRuntimeConfig === true ? (getActiveSecretsRuntimeSnapshot()?.config ?? options?.config) : options?.config; + // The active gateway plugin registry may omit the configured search + // provider; fall back to the provider id captured in config so the + // first-class assistant tool still resolves the right plugin instead of + // reporting "no provider available". + const configuredProviderId = + typeof config?.tools?.web?.search?.provider === "string" + ? config.tools.web.search.provider.trim().toLowerCase() + : ""; + const providerSelectionId = runtimeProviderId || configuredProviderId; const preferRuntimeProviders = - !runtimeProviderId || + Boolean(providerSelectionId) && !resolveManifestContractOwnerPluginId({ contract: "webSearchProviders", - value: runtimeProviderId, + value: providerSelectionId, origin: "bundled", config, }); diff --git a/src/plugins/web-provider-runtime-shared.test.ts b/src/plugins/web-provider-runtime-shared.test.ts index dd977d1dc47..dda9a61beba 100644 --- a/src/plugins/web-provider-runtime-shared.test.ts +++ b/src/plugins/web-provider-runtime-shared.test.ts @@ -332,6 +332,120 @@ describe("web-provider-runtime-shared", () => { ); }); + it("falls back to a scoped provider load when the active runtime registry has no web providers", () => { + const activeRegistry = { source: "active" }; + const fallbackRegistry = { source: "fallback" }; + const mapRegistryProviders = vi.fn(({ registry }) => + registry === fallbackRegistry ? ["brave"] : [], + ); + mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never); + mocks.loadOpenClawPlugins.mockReturnValue(fallbackRegistry as never); + + const result = resolvePluginWebProviders( + { + config: {}, + }, + { + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => undefined, + mapRegistryProviders, + }, + ); + + expect(result).toEqual(["brave"]); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + expect(mapRegistryProviders).toHaveBeenCalledTimes(2); + }); + + it("does not fall back when the active runtime registry returns empty under an explicit empty scope", () => { + const activeRegistry = { source: "active" }; + const mapRegistryProviders = vi.fn(() => []); + mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never); + + const result = resolvePluginWebProviders( + { + config: {}, + onlyPluginIds: [], + }, + { + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => [], + mapRegistryProviders, + }, + ); + + expect(result).toEqual([]); + expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); + }); + + it("falls back when the direct runtime registry has no web providers", () => { + const activeRegistry = { source: "active" }; + const fallbackRegistry = { source: "fallback" }; + const mapRegistryProviders = vi.fn(({ registry }) => + registry === fallbackRegistry ? ["brave"] : [], + ); + mocks.getLoadedRuntimePluginRegistry.mockImplementation((args: unknown) => { + const requiredPluginIds = (args as { requiredPluginIds?: readonly string[] }) + ?.requiredPluginIds; + if (requiredPluginIds === undefined) { + return activeRegistry as never; + } + return undefined; + }); + mocks.loadOpenClawPlugins.mockReturnValue(fallbackRegistry as never); + + const result = resolveRuntimeWebProviders( + { + config: {}, + }, + { + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => undefined, + mapRegistryProviders, + }, + ); + + expect(result).toEqual(["brave"]); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + }); + + it("does not fall back when direct runtime registry returns empty under an explicit empty scope", () => { + const activeRegistry = { source: "active" }; + const mapRegistryProviders = vi.fn(() => []); + mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never); + + const result = resolveRuntimeWebProviders( + { + config: {}, + onlyPluginIds: [], + }, + { + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => [], + mapRegistryProviders, + }, + ); + + expect(result).toEqual([]); + expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); + }); + it("keeps explicit setup web provider cache opt-outs", () => { const loadedRegistry = { source: "setup" }; const mapRegistryProviders = vi.fn(() => ["provider"]); diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index 23e0ed7b623..689d16a228b 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -186,17 +186,26 @@ export function resolvePluginWebProviders( workspaceDir: context.workspaceDir, requiredPluginIds: context.loadPluginIds, }); + const scopedPluginIds = context.onlyPluginIds; + const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0; if (compatible) { - return deps.mapRegistryProviders({ + const resolved = deps.mapRegistryProviders({ registry: compatible, onlyPluginIds: context.onlyPluginIds, }); + if (resolved.length > 0 || hasExplicitEmptyScope) { + return resolved; + } + // The active gateway plugin registry may be otherwise compatible with this + // config while contributing zero web providers (for example when channels, + // memory, harnesses, and sidecars are loaded but Brave/web providers are + // not). Do not treat that empty active registry as authoritative: fall + // through to a scoped provider load below so first-class assistant tools + // still see the configured provider. } if (isPluginRegistryLoadInFlight(loadOptions)) { return []; } - const scopedPluginIds = context.onlyPluginIds; - const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0; if (hasExplicitEmptyScope) { return []; } @@ -217,10 +226,15 @@ export function resolveRuntimeWebProviders( requiredPluginIds: params.onlyPluginIds, }); if (runtimeRegistry) { - return deps.mapRegistryProviders({ + const resolved = deps.mapRegistryProviders({ registry: runtimeRegistry, onlyPluginIds: params.onlyPluginIds, }); + const hasExplicitEmptyScope = + params.onlyPluginIds !== undefined && params.onlyPluginIds.length === 0; + if (resolved.length > 0 || hasExplicitEmptyScope) { + return resolved; + } } return resolvePluginWebProviders(params, deps); }