mirror of https://github.com/openclaw/openclaw.git
Preserve plugin web search config flows
This commit is contained in:
parent
d7f5a6d308
commit
3396e21d79
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue