feat: migrate core search providers to bundled plugins

This commit is contained in:
Tak Hoffman
2026-03-12 20:19:48 -05:00
parent 04769d7fe2
commit 80206bf20a
27 changed files with 438 additions and 79 deletions

View File

@@ -0,0 +1,12 @@
import { createBundledBuiltinSearchProvider, type OpenClawPluginApi } from "openclaw/plugin-sdk";
const plugin = {
id: "search-brave",
name: "Brave Search",
description: "Bundled Brave web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledBuiltinSearchProvider("brave"));
},
};
export default plugin;

View File

@@ -0,0 +1,4 @@
{
"id": "search-brave",
"provides": ["providers.search.brave"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/search-brave",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Brave search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,12 @@
import { createBundledBuiltinSearchProvider, type OpenClawPluginApi } from "openclaw/plugin-sdk";
const plugin = {
id: "search-gemini",
name: "Gemini Search",
description: "Bundled Gemini web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledBuiltinSearchProvider("gemini"));
},
};
export default plugin;

View File

@@ -0,0 +1,4 @@
{
"id": "search-gemini",
"provides": ["providers.search.gemini"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/search-gemini",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Gemini search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,12 @@
import { createBundledBuiltinSearchProvider, type OpenClawPluginApi } from "openclaw/plugin-sdk";
const plugin = {
id: "search-grok",
name: "Grok Search",
description: "Bundled xAI Grok web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledBuiltinSearchProvider("grok"));
},
};
export default plugin;

View File

@@ -0,0 +1,4 @@
{
"id": "search-grok",
"provides": ["providers.search.grok"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/search-grok",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Grok search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,12 @@
import { createBundledBuiltinSearchProvider, type OpenClawPluginApi } from "openclaw/plugin-sdk";
const plugin = {
id: "search-kimi",
name: "Kimi Search",
description: "Bundled Kimi web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledBuiltinSearchProvider("kimi"));
},
};
export default plugin;

View File

@@ -0,0 +1,4 @@
{
"id": "search-kimi",
"provides": ["providers.search.kimi"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/search-kimi",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Kimi search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,12 @@
import { createBundledBuiltinSearchProvider, type OpenClawPluginApi } from "openclaw/plugin-sdk";
const plugin = {
id: "search-perplexity",
name: "Perplexity Search",
description: "Bundled Perplexity web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledBuiltinSearchProvider("perplexity"));
},
};
export default plugin;

View File

@@ -0,0 +1,4 @@
{
"id": "search-perplexity",
"provides": ["providers.search.perplexity"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/search-perplexity",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw bundled Perplexity search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -8,6 +8,18 @@ export const BUILTIN_WEB_SEARCH_PROVIDER_IDS = [
export type BuiltinWebSearchProviderId = (typeof BUILTIN_WEB_SEARCH_PROVIDER_IDS)[number];
export const MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS = BUILTIN_WEB_SEARCH_PROVIDER_IDS;
export type MigratedBundledWebSearchProviderId =
(typeof MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS)[number];
export const bundledCoreWebSearchPluginId = (providerId: BuiltinWebSearchProviderId): string =>
`search-${providerId}`;
export const MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS = MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS.map(
bundledCoreWebSearchPluginId,
);
export type BuiltinWebSearchProviderEntry = {
value: BuiltinWebSearchProviderId;
label: string;

View File

@@ -2461,10 +2461,56 @@ function getBuiltinSearchProviders(search?: WebSearchConfig): SearchProviderPlug
];
}
export function createBundledBuiltinSearchProvider(
providerId: BuiltinWebSearchProviderId,
): SearchProviderPlugin {
const providers = getBuiltinSearchProviders();
switch (providerId) {
case "brave":
return {
...providers[0],
builtinProviderId: "brave",
};
case "gemini":
return {
...providers[1],
builtinProviderId: "gemini",
};
case "grok":
return {
...providers[2],
builtinProviderId: "grok",
};
case "kimi":
return {
...providers[3],
builtinProviderId: "kimi",
};
case "perplexity":
return {
...providers[4],
builtinProviderId: "perplexity",
};
}
}
function getPluginSearchProviders(): SearchProviderPlugin[] {
return getActivePluginRegistry()?.searchProviders.map((entry) => entry.provider) ?? [];
}
function resolveBuiltinSchemaProviderId(
provider: SearchProviderPlugin,
): BuiltinWebSearchProviderId | undefined {
if (provider.builtinProviderId && isBuiltinSearchProviderId(provider.builtinProviderId)) {
return provider.builtinProviderId;
}
if (!provider.pluginId) {
const candidate = normalizeSearchProviderId(provider.id);
return isBuiltinSearchProviderId(candidate) ? candidate : undefined;
}
return undefined;
}
function resolveConfiguredSearchProviderId(params: {
config?: OpenClawConfig;
search?: WebSearchConfig;
@@ -2582,8 +2628,8 @@ function createSearchProviderSchema(params: {
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}) {
const providerId = normalizeSearchProviderId(params.provider.id);
if (!params.provider.pluginId && isBuiltinSearchProviderId(providerId)) {
const providerId = resolveBuiltinSchemaProviderId(params.provider);
if (providerId) {
const perplexityTransport =
params.runtimeWebSearch?.selectedProvider === "perplexity"
? params.runtimeWebSearch.perplexityTransport
@@ -2719,36 +2765,35 @@ export function createWebSearchTool(options?: {
});
}
const providerId = normalizeSearchProviderId(provider.id);
const builtinProviderId = resolveBuiltinSchemaProviderId(provider);
logVerbose(formatWebSearchExecutionLog(provider));
const result =
!provider.pluginId && isBuiltinSearchProviderId(providerId)
? await executeBuiltinSearchProvider({
provider: providerId,
request,
context: {
config: options?.config ?? {},
timeoutSeconds: resolveTimeoutSeconds(
search?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS,
),
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
pluginConfig: resolveSearchProviderPluginConfig(options?.config, provider),
},
})
: await executePluginSearchProvider({
provider,
request,
context: {
config: options?.config ?? {},
timeoutSeconds: resolveTimeoutSeconds(
search?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS,
),
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
pluginConfig: resolveSearchProviderPluginConfig(options?.config, provider),
},
});
const result = builtinProviderId
? await executeBuiltinSearchProvider({
provider: builtinProviderId,
request,
context: {
config: options?.config ?? {},
timeoutSeconds: resolveTimeoutSeconds(
search?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS,
),
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
pluginConfig: resolveSearchProviderPluginConfig(options?.config, provider),
},
})
: await executePluginSearchProvider({
provider,
request,
context: {
config: options?.config ?? {},
timeoutSeconds: resolveTimeoutSeconds(
search?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS,
),
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
pluginConfig: resolveSearchProviderPluginConfig(options?.config, provider),
},
});
return jsonResult(result);
},
};

View File

@@ -210,6 +210,75 @@ describe("web tools defaults", () => {
});
describe("web_search plugin providers", () => {
it.each(["brave", "perplexity", "grok", "gemini", "kimi"] as const)(
"resolves configured built-in provider %s through bundled plugin registrations when available",
async (providerId) => {
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: `search-${providerId}`,
source: `/plugins/search-${providerId}`,
provider: {
id: providerId,
name: `${providerId} bundled provider`,
pluginId: `search-${providerId}`,
builtinProviderId: providerId,
isAvailable: () => true,
search: async () => ({ content: "unused" }),
},
});
setActivePluginRegistry(registry);
const mockFetch = installMockFetch(createProviderSuccessPayload(providerId));
const provider = webSearchTesting.resolveRegisteredSearchProvider({
config: {
tools: {
web: {
search:
providerId === "perplexity"
? { provider: providerId, perplexity: { apiKey: "pplx-config-test" } }
: providerId === "grok"
? { provider: providerId, grok: { apiKey: "xai-config-test" } }
: providerId === "gemini"
? { provider: providerId, gemini: { apiKey: "gemini-config-test" } }
: providerId === "kimi"
? { provider: providerId, kimi: { apiKey: "moonshot-config-test" } }
: { provider: providerId, apiKey: "brave-config-test" },
},
},
},
});
expect(provider.pluginId).toBe(`search-${providerId}`);
const tool = createWebSearchTool({
config: {
tools: {
web: {
search:
providerId === "perplexity"
? { provider: providerId, perplexity: { apiKey: "pplx-config-test" } }
: providerId === "grok"
? { provider: providerId, grok: { apiKey: "xai-config-test" } }
: providerId === "gemini"
? { provider: providerId, gemini: { apiKey: "gemini-config-test" } }
: providerId === "kimi"
? { provider: providerId, kimi: { apiKey: "moonshot-config-test" } }
: { provider: providerId, apiKey: "brave-config-test" },
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.(`call-bundled-${providerId}`, {
query: `bundled ${providerId}`,
});
expect(mockFetch).toHaveBeenCalled();
expect((result?.details as { provider?: string } | undefined)?.provider).toBe(providerId);
},
);
it("prefers an explicitly configured plugin provider over a built-in provider with the same id", async () => {
const searchMock = vi.fn(async () => ({
results: [

View File

@@ -335,7 +335,7 @@ describe("runConfigureWizard", () => {
}),
}),
);
expect(mocks.writeConfigFile.mock.calls[0]?.[0]?.tools?.web?.search?.provider).toBeUndefined();
expect(mocks.writeConfigFile.mock.calls[0]?.[0]?.tools?.web?.search?.provider).toBe("brave");
});
it("re-prompts invalid plugin config values during configure", async () => {

View File

@@ -242,6 +242,64 @@ describe("setupSearch", () => {
);
});
it.each([
["brave", "Brave Search"],
["gemini", "Gemini (Google Search)"],
["grok", "Grok (xAI)"],
["kimi", "Kimi (Moonshot)"],
["perplexity", "Perplexity Search"],
] as const)(
"does not duplicate built-in provider %s when a bundled search plugin registers the same provider id",
async (providerId, providerLabel) => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: `search-${providerId}`,
provider: {
id: providerId,
name: providerLabel,
description: `Bundled ${providerLabel} provider`,
pluginId: `search-${providerId}`,
builtinProviderId: providerId,
isAvailable: () => true,
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: `search-${providerId}`,
name: providerLabel,
description: `Bundled ${providerLabel} provider`,
origin: "bundled",
source: `/tmp/bundled/search-${providerId}`,
configJsonSchema: undefined,
configUiHints: undefined,
},
],
typedHooks: [],
});
loadPluginManifestRegistry.mockReturnValue({
plugins: [],
diagnostics: [],
});
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "__skip__" });
await setupSearch(cfg, runtime, prompter);
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0]?.message === "Choose active web search provider",
);
const matchingOptions =
providerSelectCall?.[0]?.options?.filter(
(option: { value?: string }) => option.value === providerId,
) ?? [];
expect(matchingOptions).toHaveLength(1);
expect(matchingOptions[0]?.hint).toContain("Bundled plugin");
},
);
it("uses the updated configure-or-install action label", async () => {
vi.stubEnv("BRAVE_API_KEY", "BSA-test-key");
loadOpenClawPlugins.mockReturnValue({

View File

@@ -1,5 +1,6 @@
import {
BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS,
MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS,
type BuiltinWebSearchProviderEntry,
type BuiltinWebSearchProviderId,
isBuiltinWebSearchProviderId,
@@ -49,6 +50,10 @@ const SEARCH_PROVIDER_SKIP_SENTINEL = "__skip__" as const;
const SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL = "__switch_active__" as const;
const SEARCH_PROVIDER_CONFIGURE_SENTINEL = "__configure_provider__" as const;
const MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET = new Set<string>(
MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS,
);
type PluginSearchProviderEntry = {
kind: "plugin";
value: string;
@@ -517,7 +522,9 @@ export async function resolveSearchProviderPickerEntries(
config: OpenClawConfig,
workspaceDir?: string,
): Promise<SearchProviderPickerEntry[]> {
const builtins: SearchProviderPickerEntry[] = SEARCH_PROVIDER_OPTIONS.map((entry) => ({
const builtins: SearchProviderPickerEntry[] = SEARCH_PROVIDER_OPTIONS.filter(
(entry) => !MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET.has(entry.value),
).map((entry) => ({
...entry,
kind: "builtin",
configured: hasExistingKey(config, entry.value) || hasKeyInEnv(entry),
@@ -568,6 +575,15 @@ export async function resolveSearchProviderPickerEntries(
configUiHints: pluginRecord.configUiHints,
};
})
.filter((entry) => {
if (!entry) {
return false;
}
return !(
entry.origin === "bundled" &&
!MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_ID_SET.has(entry.value)
);
})
.filter(Boolean) as PluginSearchProviderEntry[];
pluginEntries = resolvedPluginEntries.toSorted((left, right) =>
left.label.localeCompare(right.label),

View File

@@ -139,6 +139,7 @@ describe("plugin-sdk exports", () => {
"formatInboundFromLabel",
"resolveRuntimeGroupPolicy",
"emptyPluginConfigSchema",
"createBundledBuiltinSearchProvider",
"normalizePluginHttpPath",
"registerPluginHttpRoute",
"buildBaseAccountStatusSnapshot",

View File

@@ -125,6 +125,7 @@ export type {
export { normalizePluginHttpPath } from "../plugins/http-path.js";
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { createBundledBuiltinSearchProvider } from "../agents/tools/web-search.js";
export type { OpenClawConfig } from "../config/config.js";
/** @deprecated Use OpenClawConfig instead */
export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js";

View File

@@ -114,6 +114,32 @@ describe("resolveEffectiveEnableState", () => {
});
expect(state).toEqual({ enabled: false, reason: "disabled in config" });
});
it("enables bundled search provider plugins by default", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
const state = resolveEffectiveEnableState({
id: "search-brave",
origin: "bundled",
config: normalized,
rootConfig: {},
});
expect(state).toEqual({ enabled: true });
});
it("enables other migrated bundled search provider plugins by default", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
const state = resolveEffectiveEnableState({
id: "search-gemini",
origin: "bundled",
config: normalized,
rootConfig: {},
});
expect(state).toEqual({ enabled: true });
});
});
describe("resolveEnableState", () => {

View File

@@ -1,3 +1,4 @@
import { MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "../agents/tools/web-search-provider-catalog.js";
import { normalizeChatChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginRecord } from "./registry.js";
@@ -28,6 +29,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"ollama",
"phone-control",
"sglang",
...MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS,
"talk-voice",
"vllm",
]);

View File

@@ -148,21 +148,28 @@ function getHooksForNameWithoutPluginIds<K extends PluginHookName>(params: {
);
}
type SearchProviderAliasDescriptor<
TGenericName extends
| "before_provider_configure"
| "after_provider_configure"
| "after_provider_activate",
TLegacyName extends
| "before_search_provider_configure"
| "after_search_provider_configure"
| "after_search_provider_activate",
TGenericEvent,
TLegacyEvent,
> = {
genericHookName: TGenericName;
legacyHookName: TLegacyName;
toLegacyEvent: (event: TGenericEvent) => TLegacyEvent;
type SearchBeforeProviderAliasDescriptor = {
genericHookName: "before_provider_configure";
legacyHookName: "before_search_provider_configure";
toLegacyEvent: (
event: PluginHookBeforeProviderConfigureEvent,
) => PluginHookBeforeSearchProviderConfigureEvent;
};
type SearchAfterProviderConfigureAliasDescriptor = {
genericHookName: "after_provider_configure";
legacyHookName: "after_search_provider_configure";
toLegacyEvent: (
event: PluginHookAfterProviderConfigureEvent,
) => PluginHookAfterSearchProviderConfigureEvent;
};
type SearchAfterProviderActivateAliasDescriptor = {
genericHookName: "after_provider_activate";
legacyHookName: "after_search_provider_activate";
toLegacyEvent: (
event: PluginHookAfterProviderActivateEvent,
) => PluginHookAfterSearchProviderActivateEvent;
};
const SEARCH_PROVIDER_ALIAS_HOOKS = {
@@ -180,12 +187,7 @@ const SEARCH_PROVIDER_ALIAS_HOOKS = {
activeProviderId: event.activeProviderId,
configured: event.configured,
}),
} satisfies SearchProviderAliasDescriptor<
"before_provider_configure",
"before_search_provider_configure",
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeSearchProviderConfigureEvent
>,
} satisfies SearchBeforeProviderAliasDescriptor,
afterConfigure: {
genericHookName: "after_provider_configure",
legacyHookName: "after_search_provider_configure",
@@ -200,12 +202,7 @@ const SEARCH_PROVIDER_ALIAS_HOOKS = {
activeProviderId: event.activeProviderId,
configured: event.configured,
}),
} satisfies SearchProviderAliasDescriptor<
"after_provider_configure",
"after_search_provider_configure",
PluginHookAfterProviderConfigureEvent,
PluginHookAfterSearchProviderConfigureEvent
>,
} satisfies SearchAfterProviderConfigureAliasDescriptor,
afterActivate: {
genericHookName: "after_provider_activate",
legacyHookName: "after_search_provider_activate",
@@ -219,12 +216,7 @@ const SEARCH_PROVIDER_ALIAS_HOOKS = {
previousProviderId: event.previousProviderId,
intent: event.intent,
}),
} satisfies SearchProviderAliasDescriptor<
"after_provider_activate",
"after_search_provider_activate",
PluginHookAfterProviderActivateEvent,
PluginHookAfterSearchProviderActivateEvent
>,
} satisfies SearchAfterProviderActivateAliasDescriptor,
} as const;
/**
@@ -409,16 +401,12 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return result;
}
function getSearchProviderLegacyHooks<
TGenericName extends
| "before_provider_configure"
| "after_provider_configure"
| "after_provider_activate",
TLegacyName extends
| "before_search_provider_configure"
| "after_search_provider_configure"
| "after_search_provider_activate",
>(descriptor: SearchProviderAliasDescriptor<TGenericName, TLegacyName, unknown, unknown>) {
function getSearchProviderLegacyHooks(
descriptor:
| SearchBeforeProviderAliasDescriptor
| SearchAfterProviderConfigureAliasDescriptor
| SearchAfterProviderActivateAliasDescriptor,
) {
const genericHooks = getHooksForName(registry, descriptor.genericHookName);
const genericPluginIds = new Set(genericHooks.map((hook) => hook.pluginId));
return getHooksForNameWithoutPluginIds({
@@ -512,7 +500,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
PluginHookBeforeSearchProviderConfigureResult
>(
aliasDescriptor.legacyHookName,
legacyHooks,
legacyHooks as PluginHookRegistration<"before_search_provider_configure">[],
aliasDescriptor.toLegacyEvent(event),
ctx,
mergeBeforeSearchProviderConfigure,
@@ -538,7 +526,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
await runVoidHookRegistrations(
aliasDescriptor.legacyHookName,
legacyHooks,
legacyHooks as PluginHookRegistration<"after_search_provider_configure">[],
aliasDescriptor.toLegacyEvent(event),
ctx,
);
@@ -557,7 +545,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor);
await runVoidHookRegistrations(
aliasDescriptor.legacyHookName,
legacyHooks,
legacyHooks as PluginHookRegistration<"after_search_provider_activate">[],
aliasDescriptor.toLegacyEvent(event),
ctx,
);

View File

@@ -299,6 +299,7 @@ export type SearchProviderPlugin = {
name: string;
description?: string;
pluginId?: string;
builtinProviderId?: string;
docsUrl?: string;
configFieldOrder?: string[];
isAvailable?: (config?: OpenClawConfig) => boolean;