Preserve plugin web search config flows

This commit is contained in:
Tak Hoffman 2026-03-10 10:52:08 -05:00
parent d7f5a6d308
commit 3396e21d79
8 changed files with 232 additions and 47 deletions

View File

@ -113,6 +113,7 @@ export function createOpenClawTools(
const webSearchTool = createWebSearchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
});
const webFetchTool = createWebFetchTool({
config: options?.config,

View File

@ -11,6 +11,7 @@ import type {
SearchProviderRequest,
SearchProviderSuccessResult,
} from "../../plugins/types.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js";
import { wrapWebContent } from "../../security/external-content.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import type { AnyAgentTool } from "./common.js";
@ -2476,6 +2477,7 @@ function getPluginSearchProviders(): SearchProviderPlugin[] {
function resolvePreferredBuiltinSearchProvider(params: {
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): BuiltinSearchProviderId {
const configuredProviderId = normalizeSearchProviderId(
typeof params.search?.provider === "string" ? params.search.provider : undefined,
@ -2484,12 +2486,27 @@ function resolvePreferredBuiltinSearchProvider(params: {
return configuredProviderId;
}
if (
params.runtimeWebSearch?.providerConfigured &&
params.runtimeWebSearch.providerConfigured === configuredProviderId
) {
return params.runtimeWebSearch.providerConfigured;
}
if (
params.runtimeWebSearch?.selectedProvider &&
params.runtimeWebSearch.providerSource !== "none"
) {
return params.runtimeWebSearch.selectedProvider;
}
return resolveBuiltinSearchProvider(params.search);
}
function resolveRegisteredSearchProvider(params: {
search?: WebSearchConfig;
config?: OpenClawConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): SearchProviderPlugin {
const configuredProviderId = normalizeSearchProviderId(
typeof params.search?.provider === "string" ? params.search.provider : undefined,
@ -2511,7 +2528,16 @@ function resolveRegisteredSearchProvider(params: {
}
} else {
for (const provider of pluginProviders.values()) {
if (provider.isAvailable?.(params.config)) {
let isAvailable = false;
try {
isAvailable = provider.isAvailable?.(params.config) ?? false;
} catch (error) {
logVerbose(
`web_search: plugin provider "${provider.id}" auto-detect failed during isAvailable: ${error instanceof Error ? error.message : String(error)}`,
);
continue;
}
if (isAvailable) {
logVerbose(
`web_search: no provider configured, auto-detected plugin provider "${provider.id}"`,
);
@ -2531,6 +2557,7 @@ function resolveRegisteredSearchProvider(params: {
builtinProviders.get(
resolvePreferredBuiltinSearchProvider({
search: params.search,
runtimeWebSearch: params.runtimeWebSearch,
}),
) ?? builtinProviders.get(DEFAULT_PROVIDER)!
);
@ -2539,11 +2566,14 @@ function resolveRegisteredSearchProvider(params: {
function createSearchProviderSchema(params: {
provider: SearchProviderPlugin;
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}) {
const providerId = normalizeSearchProviderId(params.provider.id);
if (!params.provider.pluginId && isBuiltinSearchProviderId(providerId)) {
const perplexityConfig = resolvePerplexityConfig(params.search);
const perplexityTransport = resolvePerplexitySchemaTransportHint(perplexityConfig);
const perplexityTransport =
params.runtimeWebSearch?.selectedProvider === "perplexity"
? params.runtimeWebSearch.perplexityTransport
: resolvePerplexitySchemaTransportHint(resolvePerplexityConfig(params.search));
return createWebSearchSchema({
provider: providerId,
perplexityTransport: providerId === "perplexity" ? perplexityTransport : undefined,
@ -2605,6 +2635,7 @@ function resolveSearchProviderPluginConfig(
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): AnyAgentTool | null {
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
@ -2614,10 +2645,12 @@ export function createWebSearchTool(options?: {
const provider = resolveRegisteredSearchProvider({
search,
config: options?.config,
runtimeWebSearch: options?.runtimeWebSearch,
});
const parameters = createSearchProviderSchema({
provider,
search,
runtimeWebSearch: options?.runtimeWebSearch,
});
const description =
provider.description ??

View File

@ -158,4 +158,58 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("preserves an existing plugin web search provider when keeping the current provider", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
tools: {
web: {
search: {
provider: "searxng",
enabled: true,
},
},
},
},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm
.mockResolvedValueOnce(true) // enable web_search
.mockResolvedValueOnce(true); // enable web_fetch
mocks.clackSelect.mockResolvedValue("__keep_current__");
mocks.clackText.mockResolvedValue("");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.clackText).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
provider: "searxng",
enabled: true,
}),
}),
}),
}),
);
});
});

View File

@ -183,6 +183,13 @@ async function promptWebToolsConfig(
return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry);
};
const existingPluginProvider =
typeof existingSearch?.provider === "string" &&
existingSearch.provider.trim() &&
!SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingSearch.provider)
? existingSearch.provider
: undefined;
const existingProvider: string = (() => {
const stored = existingSearch?.provider;
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
@ -227,53 +234,69 @@ async function promptWebToolsConfig(
};
});
type ProviderChoice = SP | "__keep_current__";
const providerChoice = guardCancel(
await select({
await select<ProviderChoice>({
message: "Choose web search provider",
options: providerOptions,
initialValue: existingProvider,
options: [
...(existingPluginProvider
? [
{
value: "__keep_current__" as const,
label: `Keep current provider (${existingPluginProvider})`,
hint: "Leave the current plugin-managed web_search provider unchanged",
},
]
: []),
...providerOptions,
],
initialValue: existingPluginProvider ? "__keep_current__" : existingProvider,
}),
runtime,
);
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
nextSearch = { ...applied.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };
if (providerChoice === "__keep_current__") {
nextSearch = { ...nextSearch, provider: existingPluginProvider };
} else {
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
nextSearch = { ...applied.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };
} else {
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
}
}
}

View File

@ -80,6 +80,34 @@ describe("setupSearch", () => {
expect(result).toBe(cfg);
});
it("preserves an existing plugin provider when user keeps current provider", async () => {
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "searxng",
enabled: true,
},
},
},
};
const { prompter } = createPrompter({ selectValue: "__keep_current__" });
const result = await setupSearch(cfg, runtime, prompter);
expect(result).toBe(cfg);
expect(prompter.text).not.toHaveBeenCalled();
expect(prompter.select).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: "__keep_current__",
options: expect.arrayContaining([
expect.objectContaining({
value: "__keep_current__",
label: "Keep current provider (searxng)",
}),
]),
}),
);
});
it("sets provider and key for perplexity", async () => {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({

View File

@ -64,6 +64,10 @@ export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [
},
] as const;
function isBuiltinSearchProvider(value: string): value is SearchProvider {
return SEARCH_PROVIDER_OPTIONS.some((entry) => entry.value === value);
}
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
}
@ -205,6 +209,12 @@ export async function setupSearch(
);
const existingProvider = config.tools?.web?.search?.provider;
const existingPluginProvider =
typeof existingProvider === "string" &&
existingProvider.trim() &&
!isBuiltinSearchProvider(existingProvider)
? existingProvider
: undefined;
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
@ -225,10 +235,19 @@ export async function setupSearch(
return SEARCH_PROVIDER_OPTIONS[0].value;
})();
type PickerValue = SearchProvider | "__skip__";
type PickerValue = SearchProvider | "__skip__" | "__keep_current__";
const choice = await prompter.select<PickerValue>({
message: "Search provider",
options: [
...(existingPluginProvider
? [
{
value: "__keep_current__" as const,
label: `Keep current provider (${existingPluginProvider})`,
hint: "Leave the current plugin-managed web_search provider unchanged",
},
]
: []),
...options,
{
value: "__skip__" as const,
@ -236,10 +255,10 @@ export async function setupSearch(
hint: "Configure later with openclaw configure --section web",
},
],
initialValue: defaultProvider as PickerValue,
initialValue: (existingPluginProvider ? "__keep_current__" : defaultProvider) as PickerValue,
});
if (choice === "__skip__") {
if (choice === "__skip__" || choice === "__keep_current__") {
return config;
}

View File

@ -67,6 +67,28 @@ describe("web search provider config", () => {
expect(res.ok).toBe(true);
});
it("surfaces plugin loading failures during plugin-aware validation", () => {
loadOpenClawPlugins.mockImplementation(() => {
throw new Error("plugin import failed");
});
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "searxng",
}),
);
expect(res.ok).toBe(false);
expect(
res.issues.some(
(issue) =>
issue.path === "tools.web.search.provider" &&
issue.message.includes("plugin loading failed") &&
issue.message.includes("plugin import failed"),
),
).toBe(true);
});
it("rejects invalid custom plugin provider ids", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({

View File

@ -422,8 +422,13 @@ function validateConfigObjectWithPluginsBase(
if (registered) {
return;
}
} catch {
// Fall through and surface the unknown provider issue below.
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
issues.push({
path: "tools.web.search.provider",
message: `could not validate web search provider "${provider}" because plugin loading failed: ${detail}`,
});
return;
}
if (provider.trim()) {