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); }