From 128cc2c84b6eec9d7ac98f4da72a01738a2db7d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 07:25:46 +0100 Subject: [PATCH] refactor(web-tools): share runtime provider context --- src/agents/tools/web-fetch.ts | 13 +- src/agents/tools/web-search.ts | 35 +---- .../tools/web-tool-runtime-context.test.ts | 144 ++++++++++++++++++ src/agents/tools/web-tool-runtime-context.ts | 123 +++++++++++++++ src/plugins/web-provider-runtime-shared.ts | 62 +++++--- 5 files changed, 327 insertions(+), 50 deletions(-) create mode 100644 src/agents/tools/web-tool-runtime-context.test.ts create mode 100644 src/agents/tools/web-tool-runtime-context.ts diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index c42033ed48b..ffea858032b 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -34,6 +34,7 @@ import { resolveTimeoutSeconds, writeCache, } from "./web-shared.js"; +import { resolveWebFetchToolRuntimeContext } from "./web-tool-runtime-context.js"; const EXTRACT_MODES = ["markdown", "text"] as const; @@ -631,11 +632,17 @@ export function createWebFetchTool(options?: { const resolveProviderFallback = async () => { if (!providerFallbackResolved) { const { resolveWebFetchDefinition } = await loadWebFetchRuntime(); + const { config, preferRuntimeProviders, runtimeWebFetch } = resolveWebFetchToolRuntimeContext( + { + config: options?.config, + runtimeWebFetch: options?.runtimeWebFetch, + }, + ); providerFallbackCache = resolveWebFetchDefinition({ - config: options?.config, + config, sandboxed: options?.sandboxed, - runtimeWebFetch: options?.runtimeWebFetch, - preferRuntimeProviders: true, + runtimeWebFetch, + preferRuntimeProviders, }); providerFallbackResolved = true; } diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index a6f64ef7729..3e2260e51dc 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,12 +1,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js"; -import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js"; import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; -import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; import { resolveWebSearchProviderId, runWebSearch } from "../../web-search/runtime.js"; import type { AnyAgentTool } from "./common.js"; import { asToolParamsRecord, jsonResult } from "./common.js"; import { MAX_SEARCH_COUNT, SEARCH_CACHE } from "./web-search-provider-common.js"; +import { resolveWebSearchToolRuntimeContext } from "./web-tool-runtime-context.js"; const WebSearchSchema = { type: "object", @@ -87,32 +85,11 @@ export function createWebSearchTool(options?: { "Search the web. Returns provider-normalized results for current information lookup.", parameters: WebSearchSchema, execute: async (_toolCallId, args, signal) => { - const runtimeWebSearch = - options?.lateBindRuntimeConfig === true - ? (getActiveRuntimeWebToolsMetadata()?.search ?? options?.runtimeWebSearch) - : options?.runtimeWebSearch; - const runtimeProviderId = - runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured; - const config = - 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 = - !providerSelectionId || - !resolveManifestContractOwnerPluginId({ - contract: "webSearchProviders", - value: providerSelectionId, - origin: "bundled", - config, + const { config, preferRuntimeProviders, runtimeWebSearch } = + resolveWebSearchToolRuntimeContext({ + config: options?.config, + lateBindRuntimeConfig: options?.lateBindRuntimeConfig, + runtimeWebSearch: options?.runtimeWebSearch, }); const result = await runWebSearch({ config, diff --git a/src/agents/tools/web-tool-runtime-context.test.ts b/src/agents/tools/web-tool-runtime-context.test.ts new file mode 100644 index 00000000000..25516f4379f --- /dev/null +++ b/src/agents/tools/web-tool-runtime-context.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getActiveRuntimeWebToolsMetadata: vi.fn(), + getActiveSecretsRuntimeSnapshot: vi.fn(), + resolveManifestContractOwnerPluginId: vi.fn(), +})); + +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 tool runtime context", () => { + beforeEach(() => { + mocks.getActiveRuntimeWebToolsMetadata.mockReset(); + mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue(null); + mocks.getActiveSecretsRuntimeSnapshot.mockReset(); + mocks.getActiveSecretsRuntimeSnapshot.mockReturnValue(null); + mocks.resolveManifestContractOwnerPluginId.mockReset(); + mocks.resolveManifestContractOwnerPluginId.mockReturnValue(undefined); + }); + + it("late-binds search config and metadata from active runtime before captured options", async () => { + const runtimeConfig = { + tools: { web: { search: { provider: "perplexity" } } }, + }; + mocks.getActiveSecretsRuntimeSnapshot.mockReturnValue({ config: runtimeConfig }); + mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue({ + search: { + providerConfigured: "perplexity", + providerSource: "configured", + selectedProvider: "perplexity", + selectedProviderKeySource: "config", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }); + const { resolveWebSearchToolRuntimeContext } = await import("./web-tool-runtime-context.js"); + + const resolved = resolveWebSearchToolRuntimeContext({ + config: { tools: { web: { search: { provider: "brave" } } } }, + lateBindRuntimeConfig: true, + runtimeWebSearch: { + providerConfigured: "brave", + providerSource: "configured", + selectedProvider: "brave", + selectedProviderKeySource: "config", + diagnostics: [], + }, + }); + + expect(resolved.config).toBe(runtimeConfig); + expect(resolved.runtimeWebSearch).toMatchObject({ selectedProvider: "perplexity" }); + expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith( + expect.objectContaining({ + contract: "webSearchProviders", + value: "perplexity", + }), + ); + }); + + it("falls back to captured search config and runtime metadata when active globals are missing", async () => { + const capturedConfig = { + tools: { web: { search: { provider: "brave" } } }, + }; + const { resolveWebSearchToolRuntimeContext } = await import("./web-tool-runtime-context.js"); + + const resolved = resolveWebSearchToolRuntimeContext({ + config: capturedConfig, + lateBindRuntimeConfig: true, + runtimeWebSearch: { + providerConfigured: "brave", + providerSource: "configured", + selectedProvider: "brave", + selectedProviderKeySource: "config", + diagnostics: [], + }, + }); + + expect(resolved.config).toBe(capturedConfig); + expect(resolved.runtimeWebSearch).toMatchObject({ selectedProvider: "brave" }); + expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith( + expect.objectContaining({ + contract: "webSearchProviders", + value: "brave", + }), + ); + }); + + it("uses configured provider ids when runtime metadata is absent", async () => { + const { resolveWebSearchToolRuntimeContext } = await import("./web-tool-runtime-context.js"); + + resolveWebSearchToolRuntimeContext({ + config: { tools: { web: { search: { provider: "Brave" } } } }, + }); + + expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith( + expect.objectContaining({ + contract: "webSearchProviders", + value: "brave", + }), + ); + }); + + it("keeps runtime providers disabled for bundled fetch owners", async () => { + mocks.resolveManifestContractOwnerPluginId.mockReturnValue("firecrawl"); + const { resolveWebFetchToolRuntimeContext } = await import("./web-tool-runtime-context.js"); + + const resolved = resolveWebFetchToolRuntimeContext({ + config: { tools: { web: { fetch: { provider: "firecrawl" } } } }, + }); + + expect(resolved.preferRuntimeProviders).toBe(false); + expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith( + expect.objectContaining({ + contract: "webFetchProviders", + value: "firecrawl", + }), + ); + }); + + it("keeps runtime provider discovery enabled when no provider is selected", async () => { + const { resolveWebFetchToolRuntimeContext } = await import("./web-tool-runtime-context.js"); + + const resolved = resolveWebFetchToolRuntimeContext({ + config: {}, + }); + + expect(resolved.preferRuntimeProviders).toBe(true); + expect(mocks.resolveManifestContractOwnerPluginId).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/tools/web-tool-runtime-context.ts b/src/agents/tools/web-tool-runtime-context.ts new file mode 100644 index 00000000000..45454f781f6 --- /dev/null +++ b/src/agents/tools/web-tool-runtime-context.ts @@ -0,0 +1,123 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js"; +import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js"; +import type { + RuntimeWebFetchMetadata, + RuntimeWebSearchMetadata, +} from "../../secrets/runtime-web-tools.types.js"; +import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; + +type WebProviderKind = "fetch" | "search"; + +type WebProviderRuntimeMetadata = RuntimeWebFetchMetadata | RuntimeWebSearchMetadata; + +type WebProviderContract = "webFetchProviders" | "webSearchProviders"; + +type ResolvedWebToolRuntimeContext = { + config?: OpenClawConfig; + preferRuntimeProviders: boolean; + runtimeMetadata?: TMetadata; +}; + +function resolveConfiguredWebProviderId( + config: OpenClawConfig | undefined, + kind: WebProviderKind, +): string { + const provider = config?.tools?.web?.[kind]?.provider; + return typeof provider === "string" ? provider.trim().toLowerCase() : ""; +} + +function resolveRuntimeWebProviderId(metadata: WebProviderRuntimeMetadata | undefined): string { + return metadata?.selectedProvider ?? metadata?.providerConfigured ?? ""; +} + +function resolveWebProviderContract(kind: WebProviderKind): WebProviderContract { + return kind === "fetch" ? "webFetchProviders" : "webSearchProviders"; +} + +function shouldPreferRuntimeProviders(params: { + config?: OpenClawConfig; + kind: WebProviderKind; + providerSelectionId: string; +}): boolean { + if (!params.providerSelectionId) { + return true; + } + return !resolveManifestContractOwnerPluginId({ + contract: resolveWebProviderContract(params.kind), + value: params.providerSelectionId, + origin: "bundled", + config: params.config, + }); +} + +function resolveWebToolRuntimeContext(params: { + capturedConfig?: OpenClawConfig; + capturedRuntimeMetadata?: TMetadata; + kind: WebProviderKind; + lateBindRuntimeConfig?: boolean; +}): ResolvedWebToolRuntimeContext { + const activeWebTools = + params.lateBindRuntimeConfig === true ? getActiveRuntimeWebToolsMetadata() : null; + const runtimeMetadata = (activeWebTools?.[params.kind] ?? params.capturedRuntimeMetadata) as + | TMetadata + | undefined; + const config = + params.lateBindRuntimeConfig === true + ? (getActiveSecretsRuntimeSnapshot()?.config ?? params.capturedConfig) + : params.capturedConfig; + const providerSelectionId = + resolveRuntimeWebProviderId(runtimeMetadata) || + resolveConfiguredWebProviderId(config, params.kind); + return { + config, + preferRuntimeProviders: shouldPreferRuntimeProviders({ + config, + kind: params.kind, + providerSelectionId, + }), + runtimeMetadata, + }; +} + +export function resolveWebSearchToolRuntimeContext(params: { + config?: OpenClawConfig; + lateBindRuntimeConfig?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; +}): ResolvedWebToolRuntimeContext & { + runtimeWebSearch?: RuntimeWebSearchMetadata; +} { + const resolved = resolveWebToolRuntimeContext({ + capturedConfig: params.config, + capturedRuntimeMetadata: params.runtimeWebSearch, + kind: "search", + lateBindRuntimeConfig: params.lateBindRuntimeConfig, + }); + return { + config: resolved.config, + preferRuntimeProviders: resolved.preferRuntimeProviders, + runtimeMetadata: resolved.runtimeMetadata, + runtimeWebSearch: resolved.runtimeMetadata, + }; +} + +export function resolveWebFetchToolRuntimeContext(params: { + config?: OpenClawConfig; + lateBindRuntimeConfig?: boolean; + runtimeWebFetch?: RuntimeWebFetchMetadata; +}): ResolvedWebToolRuntimeContext & { + runtimeWebFetch?: RuntimeWebFetchMetadata; +} { + const resolved = resolveWebToolRuntimeContext({ + capturedConfig: params.config, + capturedRuntimeMetadata: params.runtimeWebFetch, + kind: "fetch", + lateBindRuntimeConfig: params.lateBindRuntimeConfig, + }); + return { + config: resolved.config, + preferRuntimeProviders: resolved.preferRuntimeProviders, + runtimeMetadata: resolved.runtimeMetadata, + runtimeWebFetch: resolved.runtimeMetadata, + }; +} diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index 689d16a228b..b73e10e7d92 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -64,6 +64,11 @@ type WebProviderRuntimeContext = { onlyPluginIds?: string[]; }; +type RuntimeRegistryWebProviderResolution = { + providers: TEntry[]; + shouldReturn: boolean; +}; + function resolveWebProviderRuntimeContext( params: ResolvePluginWebProvidersParams, deps: ResolveWebProviderRuntimeDeps, @@ -125,6 +130,25 @@ function resolveWebProviderLoadOptions( ); } +function resolveRuntimeRegistryWebProviders(params: { + hasExplicitEmptyScope: boolean; + mapRegistryProviders: ResolveWebProviderRuntimeDeps["mapRegistryProviders"]; + onlyPluginIds?: readonly string[]; + registry: PluginRegistry | undefined; +}): RuntimeRegistryWebProviderResolution | undefined { + if (!params.registry) { + return undefined; + } + const providers = params.mapRegistryProviders({ + registry: params.registry, + onlyPluginIds: params.onlyPluginIds, + }); + return { + providers, + shouldReturn: providers.length > 0 || params.hasExplicitEmptyScope, + }; +} + export function resolvePluginWebProviders( params: ResolvePluginWebProvidersParams, deps: ResolveWebProviderRuntimeDeps, @@ -188,14 +212,16 @@ export function resolvePluginWebProviders( }); const scopedPluginIds = context.onlyPluginIds; const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0; - if (compatible) { - const resolved = deps.mapRegistryProviders({ - registry: compatible, - onlyPluginIds: context.onlyPluginIds, - }); - if (resolved.length > 0 || hasExplicitEmptyScope) { - return resolved; - } + const compatibleProviders = resolveRuntimeRegistryWebProviders({ + hasExplicitEmptyScope, + mapRegistryProviders: deps.mapRegistryProviders, + onlyPluginIds: context.onlyPluginIds, + registry: compatible, + }); + if (compatibleProviders?.shouldReturn) { + return compatibleProviders.providers; + } + if (compatibleProviders) { // 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 @@ -225,16 +251,16 @@ export function resolveRuntimeWebProviders( workspaceDir: params.workspaceDir, requiredPluginIds: params.onlyPluginIds, }); - if (runtimeRegistry) { - 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; - } + const hasExplicitEmptyScope = + params.onlyPluginIds !== undefined && params.onlyPluginIds.length === 0; + const runtimeProviders = resolveRuntimeRegistryWebProviders({ + hasExplicitEmptyScope, + mapRegistryProviders: deps.mapRegistryProviders, + onlyPluginIds: params.onlyPluginIds, + registry: runtimeRegistry, + }); + if (runtimeProviders?.shouldReturn) { + return runtimeProviders.providers; } return resolvePluginWebProviders(params, deps); }