diff --git a/src/cache/store.ts b/src/cache/store.ts index de3706ff6..d8ca29c6b 100644 --- a/src/cache/store.ts +++ b/src/cache/store.ts @@ -353,6 +353,7 @@ export interface SearchCacheFilters { exact_match?: boolean | null; search_depth?: string | null; reranker?: string | null; + search_engines?: string[] | null; } function normaliseDomainList(list?: string[] | null): string[] | null { @@ -400,6 +401,9 @@ export function buildSearchCacheKey( exact_match: filters!.exact_match ?? null, search_depth: filters!.search_depth ?? null, reranker: filters!.reranker ?? null, + search_engines: filters!.search_engines && filters!.search_engines.length > 0 + ? [...filters!.search_engines].sort() + : null, }; return `${query}${JSON.stringify(fingerprint)}`; } diff --git a/src/search/core/core-provider.ts b/src/search/core/core-provider.ts index a966047e2..d8355ea61 100644 --- a/src/search/core/core-provider.ts +++ b/src/search/core/core-provider.ts @@ -203,6 +203,7 @@ export class CoreSearchProvider implements SearchProvider { exact_match: input.exact_match, search_depth: depth, reranker: getConfig().reranker, + search_engines: input.search_engines, }); let items: SearchResultItem[] = []; @@ -395,6 +396,7 @@ export class CoreSearchProvider implements SearchProvider { country: input.country, timeRange: input.time_range, exactMatch: input.exact_match, + engineFilter: input.search_engines, }), ), ); @@ -452,6 +454,7 @@ export class CoreSearchProvider implements SearchProvider { country: input.country, timeRange: input.time_range, exactMatch: input.exact_match, + engineFilter: input.search_engines, }); // RRF-merge the retry results on top of the initial dispatch so // we keep ranking signal from both passes. diff --git a/src/search/core/orchestrator.ts b/src/search/core/orchestrator.ts index b31feb15b..ac1c76a58 100644 --- a/src/search/core/orchestrator.ts +++ b/src/search/core/orchestrator.ts @@ -211,6 +211,10 @@ export interface OrchestratorInput { * result whose title+snippet does not contain the unquoted query as a * case-insensitive substring is dropped post-rerank. */ exactMatch?: boolean; + /** Caller-supplied engine allowlist. When non-empty, only engines whose + * name matches an entry (case-insensitive) are dispatched. Wired from + * SearchInput.search_engines via the MCP schema and CLI --search-engines. */ + engineFilter?: string[]; } export interface OrchestratorOutput { @@ -303,6 +307,12 @@ interface RunV1SearchOptions { _isFallback?: boolean; } +function applyEngineAllowlist(entries: EngineEntry[], allowlist: string[]): EngineEntry[] { + const lowered = allowlist.map((n) => n.toLowerCase()); + const filtered = entries.filter((e) => lowered.includes(e.engine.name.toLowerCase())); + return filtered.length > 0 ? filtered : entries; +} + export async function runV1Search( input: OrchestratorInput, opts: RunV1SearchOptions = {}, @@ -365,9 +375,16 @@ export async function runV1Search( // Probe-only engines are held back from the primary wave: they are a // per-call latency/failure tax on the happy path but still an independent // signal the degraded-recovery wave can pull in when the pool collapses. - const entries = allEntries.filter((e) => e.probeOnly !== true); + let entries = allEntries.filter((e) => e.probeOnly !== true); const probeEntries = allEntries.filter((e) => e.probeOnly === true); + // Apply caller-supplied engine allowlist (SearchInput.search_engines). + // Case-insensitive match against engine name. Unknown names are silently + // ignored — if no entries match, fall back to the full roster. + if (input.engineFilter && input.engineFilter.length > 0) { + entries = applyEngineAllowlist(entries, input.engineFilter); + } + const options: SearchEngineOptions = { maxResults: input.maxResults ?? DEFAULT_MAX_RESULTS, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, @@ -623,10 +640,13 @@ export async function runV1Search( const skippedPrimary = outcomes .filter((o) => o.skipped) .map((o) => o.engine); - const recoveryEntries = [ + let recoveryEntries = [ ...probeEntries, ...entries.filter((e) => skippedPrimary.includes(e.engine.name)), ]; + if (input.engineFilter && input.engineFilter.length > 0) { + recoveryEntries = applyEngineAllowlist(recoveryEntries, input.engineFilter); + } if ( outcomes.length > 0 && primaryHealthy < poolHealthFloor(outcomes.length) && @@ -684,7 +704,10 @@ export async function runV1Search( vertical !== 'images' && !opts._isFallback ) { - const generalEntries = getGeneralEngines(); + let generalEntries = getGeneralEngines(); + if (input.engineFilter && input.engineFilter.length > 0) { + generalEntries = applyEngineAllowlist(generalEntries, input.engineFilter); + } if (generalEntries.length > 0) { log.info('vertical starved below floor, backfilling from general', { from: vertical, diff --git a/tests/unit/search/v1/orchestrator.test.ts b/tests/unit/search/v1/orchestrator.test.ts index 0596e6d7a..df2f6501a 100644 --- a/tests/unit/search/v1/orchestrator.test.ts +++ b/tests/unit/search/v1/orchestrator.test.ts @@ -1352,3 +1352,100 @@ describe('runV1Search — recency boost', () => { ]); }); }); + +describe('runV1Search — engineFilter (search_engines parameter)', () => { + it('filters engines by name when engineFilter is provided', async () => { + const { entry: bing, spy: bingSpy } = makeEntry({ + name: 'bing', + results: [makeResult('bing', 'https://bing.test/x')], + }); + const { entry: ddg, spy: ddgSpy } = makeEntry({ + name: 'duckduckgo', + results: [makeResult('duckduckgo', 'https://ddg.test/y')], + }); + verticalState.general = [bing, ddg]; + + const out = await runV1Search({ + query: 'test query', + engineFilter: ['duckduckgo'], + }); + expect(ddgSpy).toHaveBeenCalledOnce(); + expect(bingSpy).not.toHaveBeenCalled(); + expect(out.enginesUsed).toEqual(['duckduckgo']); + }); + + it('matches engine names case-insensitively', async () => { + const { entry: bing, spy: bingSpy } = makeEntry({ + name: 'bing', + results: [makeResult('bing', 'https://bing.test/x')], + }); + const { entry: ddg, spy: ddgSpy } = makeEntry({ + name: 'duckduckgo', + results: [makeResult('duckduckgo', 'https://ddg.test/y')], + }); + verticalState.general = [bing, ddg]; + + const out = await runV1Search({ + query: 'test query', + engineFilter: ['DuckDuckGo'], + }); + expect(ddgSpy).toHaveBeenCalledOnce(); + expect(bingSpy).not.toHaveBeenCalled(); + expect(out.enginesUsed).toEqual(['duckduckgo']); + }); + + it('falls back to full roster when filter matches nothing', async () => { + const { entry: bing, spy: bingSpy } = makeEntry({ + name: 'bing', + results: [makeResult('bing', 'https://bing.test/x')], + }); + const { entry: ddg, spy: ddgSpy } = makeEntry({ + name: 'duckduckgo', + results: [makeResult('duckduckgo', 'https://ddg.test/y')], + }); + verticalState.general = [bing, ddg]; + + await runV1Search({ + query: 'test query', + engineFilter: ['nonexistent-engine'], + }); + // Both engines dispatched when filter matches nothing + expect(bingSpy).toHaveBeenCalledOnce(); + expect(ddgSpy).toHaveBeenCalledOnce(); + }); + + it('does not filter when engineFilter is empty', async () => { + const { entry: bing, spy: bingSpy } = makeEntry({ + name: 'bing', + results: [makeResult('bing', 'https://bing.test/x')], + }); + const { entry: ddg, spy: ddgSpy } = makeEntry({ + name: 'duckduckgo', + results: [makeResult('duckduckgo', 'https://ddg.test/y')], + }); + verticalState.general = [bing, ddg]; + + await runV1Search({ + query: 'test query', + engineFilter: [], + }); + expect(bingSpy).toHaveBeenCalledOnce(); + expect(ddgSpy).toHaveBeenCalledOnce(); + }); + + it('does not filter when engineFilter is undefined', async () => { + const { entry: bing, spy: bingSpy } = makeEntry({ + name: 'bing', + results: [makeResult('bing', 'https://bing.test/x')], + }); + const { entry: ddg, spy: ddgSpy } = makeEntry({ + name: 'duckduckgo', + results: [makeResult('duckduckgo', 'https://ddg.test/y')], + }); + verticalState.general = [bing, ddg]; + + await runV1Search({ query: 'test query' }); + expect(bingSpy).toHaveBeenCalledOnce(); + expect(ddgSpy).toHaveBeenCalledOnce(); + }); +});