openclaw/src/commands/onboard-search.ts
Lakshya Agarwal b36e456b09
feat: add Tavily as a bundled web search plugin with search and extract tools (#49200)
Merged via squash.

Prepared head SHA: ece9226e886004f1e0536dd5de3ddc2946fc118c
Co-authored-by: lakshyaag-tavily <266572148+lakshyaag-tavily@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-20 01:06:26 -04:00

296 lines
9.2 KiB
TypeScript

import type { OpenClawConfig } from "../config/config.js";
import {
DEFAULT_SECRET_PROVIDER_ALIAS,
type SecretInput,
type SecretRef,
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./onboard-types.js";
export type SearchProvider = NonNullable<
NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>["provider"]
>;
type SearchConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>;
type MutableSearchConfig = SearchConfig & Record<string, unknown>;
type SearchProviderEntry = {
value: SearchProvider;
label: string;
hint: string;
envKeys: string[];
placeholder: string;
signupUrl: string;
credentialPath: string;
applySelectionConfig?: PluginWebSearchProviderEntry["applySelectionConfig"];
};
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
}).map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.hint,
envKeys: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
credentialPath: provider.credentialPath,
applySelectionConfig: provider.applySelectionConfig,
}));
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
}
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
const search = config.tools?.web?.search;
const entry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
return (
entry?.getConfiguredCredentialValue?.(config) ??
entry?.getCredentialValue(search as Record<string, unknown> | undefined)
);
}
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
export function resolveExistingKey(
config: OpenClawConfig,
provider: SearchProvider,
): string | undefined {
return normalizeSecretInputString(rawKeyValue(config, provider));
}
/** Returns true if a key is configured (plaintext string or SecretRef). */
export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider): boolean {
return hasConfiguredSecretInput(rawKeyValue(config, provider));
}
/** Build an env-backed SecretRef for a search provider. */
function buildSearchEnvRef(provider: SearchProvider): SecretRef {
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0];
if (!envVar) {
throw new Error(
`No env var mapping for search provider "${provider}" at ${entry?.credentialPath ?? "unknown path"} in secret-input-mode=ref.`,
);
}
return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id: envVar };
}
/** Resolve a plaintext key into the appropriate SecretInput based on mode. */
function resolveSearchSecretInput(
provider: SearchProvider,
key: string,
secretInputMode?: SecretInputMode,
): SecretInput {
const useSecretRefMode = secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
return buildSearchEnvRef(provider);
}
return key;
}
export function applySearchKey(
config: OpenClawConfig,
provider: SearchProvider,
key: SecretInput,
): OpenClawConfig {
const providerEntry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true };
if (providerEntry && !providerEntry.setConfiguredCredentialValue) {
providerEntry.setCredentialValue(search, key);
}
const nextBase: OpenClawConfig = {
...config,
tools: {
...config.tools,
web: { ...config.tools?.web, search },
},
};
const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
providerEntry?.setConfiguredCredentialValue?.(next, key);
return next;
}
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
const providerEntry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const search: MutableSearchConfig = {
...config.tools?.web?.search,
provider,
enabled: true,
};
const nextBase: OpenClawConfig = {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search,
},
},
};
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
}
function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig {
if (original.tools?.web?.search?.enabled !== false) {
return result;
}
return {
...result,
tools: {
...result.tools,
web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } },
},
};
}
export type SetupSearchOptions = {
quickstartDefaults?: boolean;
secretInputMode?: SecretInputMode;
};
export async function setupSearch(
config: OpenClawConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
opts?: SetupSearchOptions,
): Promise<OpenClawConfig> {
await prompter.note(
[
"Web search lets your agent look things up online.",
"Choose a provider and paste your API key.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
const existingProvider = config.tools?.web?.search?.provider;
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
const hint = configured ? `${entry.hint} · configured` : entry.hint;
return { value: entry.value, label: entry.label, hint };
});
const defaultProvider: SearchProvider = (() => {
if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) {
return existingProvider;
}
const detected = SEARCH_PROVIDER_OPTIONS.find(
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
);
if (detected) {
return detected.value;
}
return SEARCH_PROVIDER_OPTIONS[0].value;
})();
const choice = await prompter.select({
message: "Search provider",
options: [
...options,
{
value: "__skip__" as const,
label: "Skip for now",
hint: "Configure later with openclaw configure --section web",
},
],
initialValue: defaultProvider,
});
if (choice === "__skip__") {
return config;
}
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
const existingKey = resolveExistingKey(config, choice);
const keyConfigured = hasExistingKey(config, choice);
const envAvailable = hasKeyInEnv(entry);
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, choice, existingKey)
: applyProviderOnly(config, choice);
return preserveDisabledState(config, result);
}
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
return preserveDisabledState(config, applyProviderOnly(config, choice));
}
const ref = buildSearchEnvRef(choice);
await prompter.note(
[
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
`Env var: ${ref.id}${envAvailable ? " (detected)" : ""}.`,
...(envAvailable ? [] : [`Set ${ref.id} in the Gateway environment.`]),
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
return applySearchKey(config, choice, ref);
}
const keyInput = await prompter.text({
message: keyConfigured
? `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (leave blank to use env var)`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
});
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
return applySearchKey(config, choice, secretInput);
}
if (existingKey) {
return preserveDisabledState(config, applySearchKey(config, choice, existingKey));
}
if (keyConfigured || envAvailable) {
return preserveDisabledState(config, applyProviderOnly(config, choice));
}
await prompter.note(
[
"No API key stored — web_search won't work until a key is available.",
`Get your key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
const search: SearchConfig = {
...config.tools?.web?.search,
provider: choice,
};
return {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search,
},
},
};
}