From 4bba2888e7c1a1095c9f3839df6ea5f0a4644669 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:30:41 -0700 Subject: [PATCH] feat(plugins): add web search runtime capability --- docs/tools/plugin.md | 26 +++ extensions/test-utils/plugin-runtime-mock.ts | 4 + src/agents/tools/web-search.ts | 149 ++------------ .../tools/web-tools.enabled-defaults.test.ts | 14 ++ src/plugins/runtime/index.test.ts | 26 ++- src/plugins/runtime/index.ts | 5 + src/plugins/runtime/types-core.ts | 4 + src/plugins/web-search-providers.test.ts | 45 +++- src/plugins/web-search-providers.ts | 61 ++++-- src/web-search/runtime.test.ts | 46 +++++ src/web-search/runtime.ts | 194 ++++++++++++++++++ 11 files changed, 409 insertions(+), 165 deletions(-) create mode 100644 src/web-search/runtime.test.ts create mode 100644 src/web-search/runtime.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 0e9e831023c..8ab2ba87e1f 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -782,6 +782,32 @@ Notes: - Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input). - `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias. +For web search, plugins can consume the shared runtime helper instead of +reaching into the agent tool wiring: + +```ts +const providers = api.runtime.webSearch.listProviders({ + config: api.config, +}); + +const result = await api.runtime.webSearch.search({ + config: api.config, + args: { + query: "OpenClaw plugin runtime helpers", + count: 5, + }, +}); +``` + +Plugins can also register web-search providers via +`api.registerWebSearchProvider(...)`. + +Notes: + +- Keep provider selection, credential resolution, and shared request semantics in core. +- Use web-search providers for vendor-specific search transports. +- `api.runtime.webSearch.*` is the preferred shared surface for feature/channel plugins that need search behavior without depending on the agent tool wrapper. + ## Gateway HTTP routes Plugins can expose HTTP endpoints with `api.registerHttpRoute(...)`. diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index a5003620a59..c9f2c44cf10 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -115,6 +115,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial = transcribeAudioFile: vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"], }, + webSearch: { + listProviders: vi.fn() as unknown as PluginRuntime["webSearch"]["listProviders"], + search: vi.fn() as unknown as PluginRuntime["webSearch"]["search"], + }, stt: { transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], }, diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 869da014d45..62993704377 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,148 +1,29 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; -import { logVerbose } from "../../globals.js"; -import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js"; import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; -import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; +import { __testing as runtimeTesting } from "../../web-search/runtime.js"; import type { AnyAgentTool } from "./common.js"; -import { jsonResult } from "./common.js"; -import { __testing as coreTesting } from "./web-search-core.js"; - -type WebSearchConfig = NonNullable["web"] extends infer Web - ? Web extends { search?: infer Search } - ? Search - : undefined - : undefined; - -function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { - const search = cfg?.tools?.web?.search; - if (!search || typeof search !== "object") { - return undefined; - } - return search as WebSearchConfig; -} - -function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean { - if (typeof params.search?.enabled === "boolean") { - return params.search.enabled; - } - if (params.sandboxed) { - return true; - } - return true; -} - -function readProviderEnvValue(envVars: string[]): string | undefined { - for (const envVar of envVars) { - const value = normalizeSecretInput(process.env[envVar]); - if (value) { - return value; - } - } - return undefined; -} - -function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean { - const providers = resolvePluginWebSearchProviders({ - bundledAllowlistCompat: true, - }); - const provider = providers.find((entry) => entry.id === providerId); - if (!provider) { - return false; - } - const rawValue = provider.getCredentialValue(search as Record | undefined); - const fromConfig = normalizeSecretInput( - normalizeResolvedSecretInputString({ - value: rawValue, - path: - providerId === "brave" - ? "tools.web.search.apiKey" - : `tools.web.search.${providerId}.apiKey`, - }), - ); - return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); -} - -function resolveSearchProvider(search?: WebSearchConfig): string { - const providers = resolvePluginWebSearchProviders({ - bundledAllowlistCompat: true, - }); - const raw = - search && "provider" in search && typeof search.provider === "string" - ? search.provider.trim().toLowerCase() - : ""; - - if (raw) { - const explicit = providers.find((provider) => provider.id === raw); - if (explicit) { - return explicit.id; - } - } - - if (!raw) { - for (const provider of providers) { - if (!hasProviderCredential(provider.id, search)) { - continue; - } - logVerbose( - `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, - ); - return provider.id; - } - } - - return providers[0]?.id ?? "brave"; -} +import { + __testing as coreTesting, + createWebSearchTool as createWebSearchToolCore, +} from "./web-search-core.js"; export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; runtimeWebSearch?: RuntimeWebSearchMetadata; }): AnyAgentTool | null { - const search = resolveSearchConfig(options?.config); - if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { - return null; - } - - const providers = resolvePluginWebSearchProviders({ - config: options?.config, - bundledAllowlistCompat: true, - }); - if (providers.length === 0) { - return null; - } - - const providerId = - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? - resolveSearchProvider(search); - const provider = - providers.find((entry) => entry.id === providerId) ?? - providers.find((entry) => entry.id === resolveSearchProvider(search)) ?? - providers[0]; - if (!provider) { - return null; - } - - const definition = provider.createTool({ - config: options?.config, - searchConfig: search as Record | undefined, - runtimeMetadata: options?.runtimeWebSearch, - }); - if (!definition) { - return null; - } - - return { - label: "Web Search", - name: "web_search", - description: definition.description, - parameters: definition.parameters, - execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)), - }; + return createWebSearchToolCore(options); } export const __testing = { ...coreTesting, - resolveSearchProvider, + resolveSearchProvider: ( + search?: OpenClawConfig["tools"] extends infer Tools + ? Tools extends { web?: infer Web } + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined + : undefined, + ) => runtimeTesting.resolveWebSearchProviderId({ search }), }; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index c416804fa11..d06f65e0deb 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -325,9 +325,16 @@ describe("web_search provider proxy dispatch", () => { describe("web_search perplexity Search API", () => { const priorFetch = global.fetch; + const savedEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; + }); afterEach(() => { vi.unstubAllEnvs(); + process.env = { ...savedEnv }; global.fetch = priorFetch; webSearchTesting.SEARCH_CACHE.clear(); }); @@ -462,9 +469,16 @@ describe("web_search perplexity Search API", () => { describe("web_search perplexity OpenRouter compatibility", () => { const priorFetch = global.fetch; + const savedEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; + }); afterEach(() => { vi.unstubAllEnvs(); + process.env = { ...savedEnv }; global.fetch = priorFetch; webSearchTesting.SEARCH_CACHE.clear(); }); diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 9f7613881a5..2022ac07d37 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -2,14 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { onAgentEvent } from "../../infra/agent-events.js"; import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; +import * as execModule from "../../process/exec.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; - -const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), -})); - import { clearGatewaySubagentRuntime, createPluginRuntime, @@ -18,20 +12,24 @@ import { describe("plugin runtime command execution", () => { beforeEach(() => { - runCommandWithTimeoutMock.mockClear(); + vi.restoreAllMocks(); clearGatewaySubagentRuntime(); }); it("exposes runtime.system.runCommandWithTimeout by default", async () => { const commandResult = { + pid: 12345, stdout: "hello\n", stderr: "", code: 0, signal: null, killed: false, + noOutputTimedOut: false, termination: "exit" as const, }; - runCommandWithTimeoutMock.mockResolvedValue(commandResult); + const runCommandWithTimeoutMock = vi + .spyOn(execModule, "runCommandWithTimeout") + .mockResolvedValue(commandResult); const runtime = createPluginRuntime(); await expect( @@ -41,7 +39,9 @@ describe("plugin runtime command execution", () => { }); it("forwards runtime.system.runCommandWithTimeout errors", async () => { - runCommandWithTimeoutMock.mockRejectedValue(new Error("boom")); + const runCommandWithTimeoutMock = vi + .spyOn(execModule, "runCommandWithTimeout") + .mockRejectedValue(new Error("boom")); const runtime = createPluginRuntime(); await expect( runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 }), @@ -63,6 +63,12 @@ describe("plugin runtime command execution", () => { expect(runtime.mediaUnderstanding.transcribeAudioFile).toBe(runtime.stt.transcribeAudioFile); }); + it("exposes runtime.webSearch helpers", () => { + const runtime = createPluginRuntime(); + expect(typeof runtime.webSearch.listProviders).toBe("function"); + expect(typeof runtime.webSearch.search).toBe("function"); + }); + it("exposes runtime.system.requestHeartbeatNow", () => { const runtime = createPluginRuntime(); expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 48899303e2f..cd76a21916b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -11,6 +11,7 @@ import { transcribeAudioFile, } from "../../media-understanding/runtime.js"; import { listSpeechVoices, textToSpeech, textToSpeechTelephony } from "../../tts/tts.js"; +import { listWebSearchProviders, runWebSearch } from "../../web-search/runtime.js"; import { createRuntimeAgent } from "./runtime-agent.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; @@ -147,6 +148,10 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): describeVideoFile, transcribeAudioFile, }, + webSearch: { + listProviders: listWebSearchProviders, + search: runWebSearch, + }, stt: { transcribeAudioFile }, tools: createRuntimeTools(), channel: createRuntimeChannel(), diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 822f0026b49..528c488d987 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -57,6 +57,10 @@ export type PluginRuntimeCore = { describeVideoFile: typeof import("../../media-understanding/runtime.js").describeVideoFile; transcribeAudioFile: typeof import("../../media-understanding/runtime.js").transcribeAudioFile; }; + webSearch: { + listProviders: typeof import("../../web-search/runtime.js").listWebSearchProviders; + search: typeof import("../../web-search/runtime.js").runWebSearch; + }; stt: { transcribeAudioFile: typeof import("../../media-understanding/transcribe-audio.js").transcribeAudioFile; }; diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 52e326ddc04..9d2fd18e030 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { resolvePluginWebSearchProviders } from "./web-search-providers.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; +import { + resolvePluginWebSearchProviders, + resolveRuntimeWebSearchProviders, +} from "./web-search-providers.js"; describe("resolvePluginWebSearchProviders", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + it("returns bundled providers in auto-detect order", () => { const providers = resolvePluginWebSearchProviders({}); @@ -72,4 +81,36 @@ describe("resolvePluginWebSearchProviders", () => { expect(providers).toEqual([]); }); + + it("prefers the active plugin registry for runtime resolution", () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "custom-search", + pluginName: "Custom Search", + provider: { + id: "custom", + label: "Custom Search", + hint: "Custom runtime provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/signup", + autoDetectOrder: 1, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: "custom", + parameters: {}, + execute: async () => ({}), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + + const providers = resolveRuntimeWebSearchProviders({}); + + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "custom-search:custom", + ]); + }); }); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 97b6d9ee022..8aba087f1fc 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -12,6 +12,7 @@ import { } from "./bundled-compat.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; +import { getActivePluginRegistry } from "./runtime.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ @@ -127,25 +128,47 @@ export function resolvePluginWebSearchProviders(params: { }); const normalizedPlugins = normalizePluginsConfig(config?.plugins); - return BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( - ({ pluginId }) => - resolveEffectiveEnableState({ - id: pluginId, - origin: "bundled", - config: normalizedPlugins, - rootConfig: config, - }).enabled, - ) - .map((entry) => ({ + return sortWebSearchProviders( + BUNDLED_WEB_SEARCH_PROVIDER_REGISTRY.filter( + ({ pluginId }) => + resolveEffectiveEnableState({ + id: pluginId, + origin: "bundled", + config: normalizedPlugins, + rootConfig: config, + }).enabled, + ).map((entry) => ({ ...entry.provider, pluginId: entry.pluginId, - })) - .toSorted((a, b) => { - const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - if (aOrder !== bOrder) { - return aOrder - bOrder; - } - return a.id.localeCompare(b.id); - }); + })), + ); +} + +function sortWebSearchProviders( + providers: PluginWebSearchProviderEntry[], +): PluginWebSearchProviderEntry[] { + return providers.toSorted((a, b) => { + const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + return a.id.localeCompare(b.id); + }); +} + +export function resolveRuntimeWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderEntry[] { + const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; + if (runtimeProviders.length > 0) { + return sortWebSearchProviders( + runtimeProviders.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), + ); + } + return resolvePluginWebSearchProviders(params); } diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts new file mode 100644 index 00000000000..68446d33a95 --- /dev/null +++ b/src/web-search/runtime.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { runWebSearch } from "./runtime.js"; + +describe("web search runtime", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("executes searches through the active plugin registry", async () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "custom-search", + pluginName: "Custom Search", + provider: { + id: "custom", + label: "Custom Search", + hint: "Custom runtime provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/signup", + autoDetectOrder: 1, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: "custom", + parameters: {}, + execute: async (args) => ({ ...args, ok: true }), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + + await expect( + runWebSearch({ + config: {}, + args: { query: "hello" }, + }), + ).resolves.toEqual({ + provider: "custom", + result: { query: "hello", ok: true }, + }); + }); +}); diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts new file mode 100644 index 00000000000..cf11dfcb667 --- /dev/null +++ b/src/web-search/runtime.ts @@ -0,0 +1,194 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeResolvedSecretInputString } from "../config/types.secrets.js"; +import { logVerbose } from "../globals.js"; +import type { + PluginWebSearchProviderEntry, + WebSearchProviderToolDefinition, +} from "../plugins/types.js"; +import { + resolvePluginWebSearchProviders, + resolveRuntimeWebSearchProviders, +} from "../plugins/web-search-providers.js"; +import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; + +type WebSearchConfig = NonNullable["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +export type ResolveWebSearchDefinitionParams = { + config?: OpenClawConfig; + sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providerId?: string; + preferRuntimeProviders?: boolean; +}; + +export type RunWebSearchParams = ResolveWebSearchDefinitionParams & { + args: Record; +}; + +function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + return search as WebSearchConfig; +} + +export function resolveWebSearchEnabled(params: { + search?: WebSearchConfig; + sandboxed?: boolean; +}): boolean { + if (typeof params.search?.enabled === "boolean") { + return params.search.enabled; + } + if (params.sandboxed) { + return true; + } + return true; +} + +function readProviderEnvValue(envVars: string[]): string | undefined { + for (const envVar of envVars) { + const value = normalizeSecretInput(process.env[envVar]); + if (value) { + return value; + } + } + return undefined; +} + +function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean { + const providers = resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + return false; + } + const rawValue = provider.getCredentialValue(search as Record | undefined); + const fromConfig = normalizeSecretInput( + normalizeResolvedSecretInputString({ + value: rawValue, + path: + providerId === "brave" + ? "tools.web.search.apiKey" + : `tools.web.search.${providerId}.apiKey`, + }), + ); + return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); +} + +export function listWebSearchProviders(params?: { + config?: OpenClawConfig; +}): PluginWebSearchProviderEntry[] { + return resolveRuntimeWebSearchProviders({ + config: params?.config, + bundledAllowlistCompat: true, + }); +} + +export function resolveWebSearchProviderId(params: { + search?: WebSearchConfig; + providers?: PluginWebSearchProviderEntry[]; +}): string { + const providers = + params.providers ?? + resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); + const raw = + params.search && "provider" in params.search && typeof params.search.provider === "string" + ? params.search.provider.trim().toLowerCase() + : ""; + + if (raw) { + const explicit = providers.find((provider) => provider.id === raw); + if (explicit) { + return explicit.id; + } + } + + if (!raw) { + for (const provider of providers) { + if (!hasProviderCredential(provider.id, params.search)) { + continue; + } + logVerbose( + `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, + ); + return provider.id; + } + } + + return providers[0]?.id ?? "brave"; +} + +export function resolveWebSearchDefinition( + options?: ResolveWebSearchDefinitionParams, +): { provider: PluginWebSearchProviderEntry; definition: WebSearchProviderToolDefinition } | null { + const search = resolveSearchConfig(options?.config); + if (!resolveWebSearchEnabled({ search, sandboxed: options?.sandboxed })) { + return null; + } + + const providers = ( + options?.preferRuntimeProviders + ? resolveRuntimeWebSearchProviders({ + config: options?.config, + bundledAllowlistCompat: true, + }) + : resolvePluginWebSearchProviders({ + config: options?.config, + bundledAllowlistCompat: true, + }) + ).filter(Boolean); + if (providers.length === 0) { + return null; + } + + const providerId = + options?.providerId ?? + options?.runtimeWebSearch?.selectedProvider ?? + options?.runtimeWebSearch?.providerConfigured ?? + resolveWebSearchProviderId({ search, providers }); + const provider = + providers.find((entry) => entry.id === providerId) ?? + providers.find((entry) => entry.id === resolveWebSearchProviderId({ search, providers })) ?? + providers[0]; + if (!provider) { + return null; + } + + const definition = provider.createTool({ + config: options?.config, + searchConfig: search as Record | undefined, + runtimeMetadata: options?.runtimeWebSearch, + }); + if (!definition) { + return null; + } + + return { provider, definition }; +} + +export async function runWebSearch( + params: RunWebSearchParams, +): Promise<{ provider: string; result: Record }> { + const resolved = resolveWebSearchDefinition({ ...params, preferRuntimeProviders: true }); + if (!resolved) { + throw new Error("web_search is disabled or no provider is available."); + } + return { + provider: resolved.provider.id, + result: await resolved.definition.execute(params.args), + }; +} + +export const __testing = { + resolveSearchConfig, + resolveWebSearchProviderId, +};