From 2b37c4f38c826c13af0bc5a30c2e690fe90c1188 Mon Sep 17 00:00:00 2001 From: fuleinist <1163738+fuleinist@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:13:16 +1000 Subject: [PATCH 1/2] fix(search): wire search_engines parameter through to engine dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search_engines parameter was declared in the MCP schema and CLI help but never consumed — passing it had no effect on which engines ran. - Add engineFilter to OrchestratorInput - Filter engine entries by name (case-insensitive) in runV1Search - Pass SearchInput.search_engines as engineFilter in core-provider - Unknown filter names fall back to full roster (graceful degradation) - 5 new tests: filter, case-insensitive, empty, undefined, no-match fallback Closes #303 --- src/search/core/core-provider.ts | 2 + src/search/core/orchestrator.ts | 17 +++- tests/unit/search/v1/orchestrator.test.ts | 97 +++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/search/core/core-provider.ts b/src/search/core/core-provider.ts index a966047e2..fc60eaf51 100644 --- a/src/search/core/core-provider.ts +++ b/src/search/core/core-provider.ts @@ -395,6 +395,7 @@ export class CoreSearchProvider implements SearchProvider { country: input.country, timeRange: input.time_range, exactMatch: input.exact_match, + engineFilter: input.search_engines, }), ), ); @@ -452,6 +453,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..7d408319b 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 { @@ -365,9 +369,20 @@ 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) { + const allowlist = input.engineFilter.map((n) => n.toLowerCase()); + const filtered = entries.filter((e) => allowlist.includes(e.engine.name.toLowerCase())); + if (filtered.length > 0) { + entries = filtered; + } + } + const options: SearchEngineOptions = { maxResults: input.maxResults ?? DEFAULT_MAX_RESULTS, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, 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(); + }); +}); From 9d4476f21dbdce115cc77512af228a05d774340e Mon Sep 17 00:00:00 2001 From: fuleinist <1163738+fuleinist@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:11:00 +1000 Subject: [PATCH 2/2] fix(search): apply engineFilter to recovery wave, backfill, and cache key coderabbitai findings from PR#414: - Include search_engines in buildSearchCacheKey fingerprint so cached unfiltered results cannot satisfy filtered requests - Apply engineFilter allowlist to probeEntries (recovery wave) and getGeneralEngines (starvation backfill) so a degraded or thin search does not dispatch unselected engines - Extract applyEngineAllowlist helper to avoid duplicating the case-insensitive allowlist logic across three call sites --- src/cache/store.ts | 4 ++++ src/search/core/core-provider.ts | 1 + src/search/core/orchestrator.ts | 22 +++++++++++++++------- 3 files changed, 20 insertions(+), 7 deletions(-) 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 fc60eaf51..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[] = []; diff --git a/src/search/core/orchestrator.ts b/src/search/core/orchestrator.ts index 7d408319b..ac1c76a58 100644 --- a/src/search/core/orchestrator.ts +++ b/src/search/core/orchestrator.ts @@ -307,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 = {}, @@ -376,11 +382,7 @@ export async function runV1Search( // 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) { - const allowlist = input.engineFilter.map((n) => n.toLowerCase()); - const filtered = entries.filter((e) => allowlist.includes(e.engine.name.toLowerCase())); - if (filtered.length > 0) { - entries = filtered; - } + entries = applyEngineAllowlist(entries, input.engineFilter); } const options: SearchEngineOptions = { @@ -638,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) && @@ -699,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,