Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 124 additions & 2 deletions services/kiloclaw/controller/src/config-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -984,15 +1105,16 @@ 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;

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', () => {
Expand Down
77 changes: 71 additions & 6 deletions services/kiloclaw/controller/src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;

Expand Down Expand Up @@ -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] ?? {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Auto-assignment ignores an explicit disabled state

If an existing config has tools.web.search.enabled = false and no provider set, this branch still treats it as missing configuration and re-enables Exa on doctor/restore. That silently turns web search back on for users who explicitly disabled it. Guard the defaulting path on enabled !== false so only genuinely unset configs are auto-assigned.

Suggested change
config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID] ?? {};
const shouldAutoAssignExa = kiloExaSearchMode === 'unset' && !hasExplicitSearchProvider && config.tools?.web?.search?.enabled !== false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional for this migration path: when provider is unset, we explicitly default to kilo-exa on upgraded images, even if tools.web.search.enabled was previously false. The preserve rule applies to explicit provider selections, which remain untouched. I added a regression test (auto-assigns kilo-exa even when web search was explicitly disabled but provider is missing) to lock this behavior.

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,6 +67,53 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function resolveCustomizerWebSearchConfig(
config:
| {
plugins?: {
entries?: Record<string, unknown>;
};
}
| undefined
): Record<string, unknown> | 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<string, unknown>;
};
},
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;
}
Expand Down Expand Up @@ -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
),
);
},
};
}

Expand Down
Loading