diff --git a/services/kiloclaw/controller/src/config-writer.test.ts b/services/kiloclaw/controller/src/config-writer.test.ts index fe03506b1..824c8afa9 100644 --- a/services/kiloclaw/controller/src/config-writer.test.ts +++ b/services/kiloclaw/controller/src/config-writer.test.ts @@ -153,6 +153,127 @@ describe('generateBaseConfig', () => { expect(config.tools.web.search.provider).toBe('brave'); }); + it('auto-assigns kilo-exa when provider is missing and mode is unset', () => { + const existing = JSON.stringify({ tools: { web: { search: {} } } }); + const { deps } = fakeDeps(existing); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBe('kilo-exa'); + expect(config.tools.web.search.enabled).toBe(true); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(true); + }); + + it('auto-assigns kilo-exa even when web search was explicitly disabled but provider is missing', () => { + const existing = JSON.stringify({ + tools: { + web: { + search: { + enabled: false, + }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBe('kilo-exa'); + expect(config.tools.web.search.enabled).toBe(true); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(true); + }); + + it('preserves explicit kilo-exa provider when mode is unset', () => { + const existing = JSON.stringify({ + tools: { + web: { + search: { + provider: 'kilo-exa', + }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBe('kilo-exa'); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(true); + }); + + it('selects kilo-exa provider when KILO_EXA_SEARCH_MODE=kilo-proxy', () => { + const { deps } = fakeDeps(); + const env = { ...minimalEnv(), KILO_EXA_SEARCH_MODE: 'kilo-proxy' }; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBe('kilo-exa'); + expect(config.tools.web.search.enabled).toBe(true); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(true); + }); + + it('switches to brave when Exa is disabled and BRAVE_API_KEY is configured', () => { + const existing = JSON.stringify({ + tools: { + web: { + search: { + provider: 'kilo-exa', + }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const env = { + ...minimalEnv(), + KILO_EXA_SEARCH_MODE: 'disabled', + BRAVE_API_KEY: 'BSA' + 'A'.repeat(20), + }; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBe('brave'); + expect(config.tools.web.search.enabled).toBe(true); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(false); + }); + + it('preserves explicit non-Exa provider when Exa is disabled and BRAVE_API_KEY is configured', () => { + const existing = JSON.stringify({ + tools: { + web: { + search: { + provider: 'perplexity', + }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const env = { + ...minimalEnv(), + KILO_EXA_SEARCH_MODE: 'disabled', + BRAVE_API_KEY: 'BSA' + 'A'.repeat(20), + }; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBe('perplexity'); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(false); + }); + + it('clears kilo-exa provider when Exa is disabled and Brave is not configured', () => { + const existing = JSON.stringify({ + tools: { + web: { + search: { + provider: 'kilo-exa', + }, + }, + }, + }); + const { deps } = fakeDeps(existing); + const env = { + ...minimalEnv(), + KILO_EXA_SEARCH_MODE: 'disabled', + }; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.tools.web.search.provider).toBeUndefined(); + expect(config.plugins.entries['kiloclaw-customizer'].config.webSearch.enabled).toBe(false); + }); + it('defaults tool profile to full when not previously set', () => { const { deps } = fakeDeps(); const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps); @@ -984,7 +1105,7 @@ describe('writeBaseConfig', () => { expect(config.tools.profile).toBe('full'); }); - it('does not steer web search provider on config restore path', () => { + it('auto-assigns Exa web search provider on restore path when provider is missing', () => { const { deps, written } = fakeDeps(); const env = minimalEnv(); delete env.KILOCLAW_FRESH_INSTALL; @@ -992,7 +1113,8 @@ describe('writeBaseConfig', () => { writeBaseConfig(env, '/tmp/openclaw.json', deps); const config = JSON.parse(written[0].data); - expect(config.tools?.web?.search?.provider).toBeUndefined(); + expect(config.tools?.web?.search?.provider).toBe('kilo-exa'); + expect(config.tools?.web?.search?.enabled).toBe(true); }); it('throws if KILOCODE_API_KEY is missing', () => { diff --git a/services/kiloclaw/controller/src/config-writer.ts b/services/kiloclaw/controller/src/config-writer.ts index 1d4223707..d6a57022b 100644 --- a/services/kiloclaw/controller/src/config-writer.ts +++ b/services/kiloclaw/controller/src/config-writer.ts @@ -55,6 +55,29 @@ const ONBOARD_FLAGS = [ '--skip-health', ] as const; +const KILOCLAW_CUSTOMIZER_PLUGIN_ID = 'kiloclaw-customizer'; +const KILOCLAW_CUSTOMIZER_PLUGIN_PATH = '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer'; +const KILO_EXA_PROVIDER_ID = 'kilo-exa'; + +type KiloExaSearchMode = 'kilo-proxy' | 'disabled'; + +type KiloExaSearchModeState = KiloExaSearchMode | 'unset'; + +function resolveKiloExaSearchMode(value: string | undefined): KiloExaSearchModeState { + const normalized = value?.trim().toLowerCase(); + if (normalized === 'kilo-proxy') { + return 'kilo-proxy'; + } + if (normalized === 'disabled') { + return 'disabled'; + } + if (normalized === undefined || normalized === '') { + return 'unset'; + } + console.warn(`Unknown KILO_EXA_SEARCH_MODE value "${value}"; treating as "disabled"`); + return 'disabled'; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any type ConfigObject = Record; @@ -272,14 +295,56 @@ export function generateBaseConfig( config.plugins.load.paths = Array.isArray(config.plugins.load.paths) ? config.plugins.load.paths : []; - const customizerPluginPath = '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer'; - if (!(config.plugins.load.paths as string[]).includes(customizerPluginPath)) { - (config.plugins.load.paths as string[]).push(customizerPluginPath); + if (!(config.plugins.load.paths as string[]).includes(KILOCLAW_CUSTOMIZER_PLUGIN_PATH)) { + (config.plugins.load.paths as string[]).push(KILOCLAW_CUSTOMIZER_PLUGIN_PATH); } config.plugins.entries = config.plugins.entries ?? {}; - const customizerEntry = 'kiloclaw-customizer'; - config.plugins.entries[customizerEntry] = config.plugins.entries[customizerEntry] ?? {}; - config.plugins.entries[customizerEntry].enabled = true; + config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID] = + config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID] ?? {}; + config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID].enabled = true; + + const customizerPluginConfig = config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID].config ?? {}; + const customizerWebSearchConfig = customizerPluginConfig.webSearch ?? {}; + const searchProvider = config.tools?.web?.search?.provider; + const hasExplicitSearchProvider = + typeof searchProvider === 'string' && searchProvider.trim().length > 0; + + const kiloExaSearchMode = resolveKiloExaSearchMode(env.KILO_EXA_SEARCH_MODE); + const shouldForceExa = kiloExaSearchMode === 'kilo-proxy'; + const shouldAutoAssignExa = kiloExaSearchMode === 'unset' && !hasExplicitSearchProvider; + if (shouldForceExa || shouldAutoAssignExa) { + customizerWebSearchConfig.enabled = true; + config.tools = config.tools ?? {}; + config.tools.web = config.tools.web ?? {}; + config.tools.web.search = config.tools.web.search ?? {}; + config.tools.web.search.enabled = true; + config.tools.web.search.provider = KILO_EXA_PROVIDER_ID; + if (shouldAutoAssignExa) { + console.log('[config-writer] Auto-assigned web search provider to kilo-exa (mode=unset)'); + } + } else if (kiloExaSearchMode === 'disabled') { + customizerWebSearchConfig.enabled = false; + + const braveConfigured = Boolean(env.BRAVE_API_KEY?.trim()); + if ( + braveConfigured && + (!hasExplicitSearchProvider || config.tools?.web?.search?.provider === KILO_EXA_PROVIDER_ID) + ) { + config.tools = config.tools ?? {}; + config.tools.web = config.tools.web ?? {}; + config.tools.web.search = config.tools.web.search ?? {}; + config.tools.web.search.enabled = true; + config.tools.web.search.provider = 'brave'; + } else if (config.tools?.web?.search?.provider === KILO_EXA_PROVIDER_ID) { + delete config.tools.web.search.provider; + } + } else if (hasExplicitSearchProvider) { + customizerWebSearchConfig.enabled = + config.tools?.web?.search?.provider === KILO_EXA_PROVIDER_ID; + } + + customizerPluginConfig.webSearch = customizerWebSearchConfig; + config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID].config = customizerPluginConfig; // Telegram if (env.TELEGRAM_BOT_TOKEN) { diff --git a/services/kiloclaw/plugins/kiloclaw-customizer/openclaw.plugin.json b/services/kiloclaw/plugins/kiloclaw-customizer/openclaw.plugin.json index 9ba06146b..e6da5e539 100644 --- a/services/kiloclaw/plugins/kiloclaw-customizer/openclaw.plugin.json +++ b/services/kiloclaw/plugins/kiloclaw-customizer/openclaw.plugin.json @@ -8,6 +8,16 @@ "configSchema": { "type": "object", "additionalProperties": false, - "properties": {} + "properties": { + "webSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } } } diff --git a/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.test.ts b/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.test.ts index a82a73550..4eafdc60a 100644 --- a/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.test.ts @@ -38,6 +38,36 @@ describe('kilo-exa web search provider', () => { }); }); + it('does not create a tool when plugin webSearch.enabled is false', () => { + const provider = createKiloExaWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + 'kiloclaw-customizer': { + config: { + webSearch: { + enabled: false, + }, + }, + }, + }, + }, + }, + searchConfig: {}, + }); + + expect(tool).toBeNull(); + }); + + it('applySelectionConfig enables plugin web search flag', () => { + const provider = createKiloExaWebSearchProvider(); + const config = provider.applySelectionConfig?.({}) ?? {}; + + expect(config.plugins?.entries?.['kiloclaw-customizer']?.enabled).toBe(true); + expect(config.plugins?.entries?.['kiloclaw-customizer']?.config?.webSearch?.enabled).toBe(true); + }); + it('normalizes uppercase freshness values', async () => { webSearchSdkStub.setPostHandler(async () => ({ results: [] })); const tool = getTool(); diff --git a/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.ts b/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.ts index 784d26f4e..396ea4e0d 100644 --- a/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.ts +++ b/services/kiloclaw/plugins/kiloclaw-customizer/src/kilo-exa-web-search-provider.ts @@ -21,6 +21,7 @@ import { } from 'openclaw/plugin-sdk/provider-web-search'; const KILO_EXA_PROVIDER_ID = 'kilo-exa'; +const KILOCLAW_CUSTOMIZER_PLUGIN_ID = 'kiloclaw-customizer'; const DEFAULT_KILO_API_ORIGIN = 'https://api.kilo.ai'; const EXA_SEARCH_TYPES = ['auto', 'neural', 'fast', 'deep', 'deep-reasoning', 'instant'] as const; const EXA_FRESHNESS_VALUES = ['day', 'week', 'month', 'year'] as const; @@ -66,6 +67,53 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function resolveCustomizerWebSearchConfig( + config: + | { + plugins?: { + entries?: Record; + }; + } + | undefined +): Record | undefined { + const pluginEntry = config?.plugins?.entries?.[KILOCLAW_CUSTOMIZER_PLUGIN_ID]; + if (!isRecord(pluginEntry)) { + return undefined; + } + const pluginConfig = isRecord(pluginEntry.config) ? pluginEntry.config : undefined; + return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined; +} + +function setCustomizerWebSearchEnabled( + config: { + plugins?: { + entries?: Record; + }; + }, + enabled: boolean +): void { + const existingEntries = config.plugins?.entries; + const entries = isRecord(existingEntries) ? existingEntries : {}; + const existingEntry = entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID]; + const pluginEntry = isRecord(existingEntry) ? existingEntry : {}; + const existingPluginConfig = pluginEntry.config; + const pluginConfig = isRecord(existingPluginConfig) ? existingPluginConfig : {}; + const existingWebSearch = pluginConfig.webSearch; + const webSearch = isRecord(existingWebSearch) ? existingWebSearch : {}; + + webSearch.enabled = enabled; + pluginConfig.webSearch = webSearch; + pluginEntry.config = pluginConfig; + + config.plugins = { + ...(config.plugins ?? {}), + entries: { + ...entries, + [KILOCLAW_CUSTOMIZER_PLUGIN_ID]: pluginEntry, + }, + }; +} + function parsePositiveInteger(value: unknown): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; } @@ -658,11 +706,20 @@ export function createKiloExaWebSearchProvider(): WebSearchProviderPlugin { getScopedCredentialValue(searchConfig, KILO_EXA_PROVIDER_ID), setCredentialValue: (searchConfigTarget, value) => setScopedCredentialValue(searchConfigTarget, KILO_EXA_PROVIDER_ID, value), - applySelectionConfig: config => enablePluginInConfig(config, 'kiloclaw-customizer').config, - createTool: ctx => - createKiloExaToolDefinition( + applySelectionConfig: config => { + const next = enablePluginInConfig(config, KILOCLAW_CUSTOMIZER_PLUGIN_ID).config; + setCustomizerWebSearchEnabled(next, true); + return next; + }, + createTool: ctx => { + const pluginWebSearchConfig = resolveCustomizerWebSearchConfig(ctx.config); + if (pluginWebSearchConfig?.enabled === false) { + return null; + } + return createKiloExaToolDefinition( isSearchConfigRecord(ctx.searchConfig) ? ctx.searchConfig : undefined - ), + ); + }, }; }