From ced1f58fdf76cd49f315f4b04c693f9e14884733 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 12 Aug 2026 21:17:10 -0400 Subject: [PATCH 1/4] docs: revise phase 1 filter design --- ...-architecture-deepening-phase-1-filters.md | 66 ++++--- ...ecture-deepening-phase-1-filters-design.md | 166 ++++++++++++++++++ 2 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-12-architecture-deepening-phase-1-filters-design.md diff --git a/docs/superpowers/plans/2026-08-12-architecture-deepening-phase-1-filters.md b/docs/superpowers/plans/2026-08-12-architecture-deepening-phase-1-filters.md index 6b4360a..f271b6d 100644 --- a/docs/superpowers/plans/2026-08-12-architecture-deepening-phase-1-filters.md +++ b/docs/superpowers/plans/2026-08-12-architecture-deepening-phase-1-filters.md @@ -72,13 +72,23 @@ Add `readonly filterSupport: SearchFilterSupport;` to `SearchProvider`. Requirin Append these functions to `src/utils/filters.ts`: ```ts -import type { SearchFilterSupport, SearchFilters, SearchResult } from "../providers/types.ts"; +import type { + SearchFilterSupport, + SearchFilters, + SearchResult, +} from "../providers/types.ts"; function hostMatchesDomain(urlValue: string, domain: string): boolean { try { const host = new URL(urlValue).hostname.toLowerCase(); - const normalized = domain.trim().toLowerCase().replace(/^\.+|\.+$/g, ""); - return normalized.length > 0 && (host === normalized || host.endsWith(`.${normalized}`)); + const normalized = domain + .trim() + .toLowerCase() + .replace(/^\.+|\.+$/g, ""); + return ( + normalized.length > 0 && + (host === normalized || host.endsWith(`.${normalized}`)) + ); } catch { return false; } @@ -88,14 +98,19 @@ export function filterResultsByDomains( results: SearchResult[], filters?: SearchFilters, ): SearchResult[] { - if (!filters?.includeDomains?.length && !filters?.excludeDomains?.length) return results; + if (!filters?.includeDomains?.length && !filters?.excludeDomains?.length) + return results; return results.filter((result) => { const included = !filters.includeDomains?.length || - filters.includeDomains.some((domain) => hostMatchesDomain(result.url, domain)); + filters.includeDomains.some((domain) => + hostMatchesDomain(result.url, domain), + ); const excluded = - filters.excludeDomains?.some((domain) => hostMatchesDomain(result.url, domain)) ?? false; + filters.excludeDomains?.some((domain) => + hostMatchesDomain(result.url, domain), + ) ?? false; return included && !excluded; }); } @@ -105,10 +120,16 @@ export function unsupportedSearchFilters( support: SearchFilterSupport, ): string[] { const unsupported: string[] = []; - if ((filters?.includeDomains?.length || filters?.excludeDomains?.length) && support.domains === "unsupported") { + if ( + (filters?.includeDomains?.length || filters?.excludeDomains?.length) && + support.domains === "unsupported" + ) { unsupported.push("domains"); } - if ((filters?.startDate || filters?.endDate) && support.dates === "unsupported") { + if ( + (filters?.startDate || filters?.endDate) && + support.dates === "unsupported" + ) { unsupported.push("dates"); } return unsupported; @@ -141,15 +162,15 @@ const results: SearchResult[] = [ describe("search filter support", () => { it("filters included domains by host and subdomain", () => { - expect(filterResultsByDomains(results, { includeDomains: ["example.com"] })).toEqual( - results.slice(0, 2), - ); + expect( + filterResultsByDomains(results, { includeDomains: ["example.com"] }), + ).toEqual(results.slice(0, 2)); }); it("filters excluded domains without matching lookalike hosts", () => { - expect(filterResultsByDomains(results, { excludeDomains: ["example.com"] })).toEqual([ - results[2], - ]); + expect( + filterResultsByDomains(results, { excludeDomains: ["example.com"] }), + ).toEqual([results[2]]); }); it("reports unsupported domain and date groups", () => { @@ -218,7 +239,10 @@ export interface HttpSearchConfig { filterSupport?: SearchFilterSupport; } -export function createHttpSearchProvider(apiKey: string, config: HttpSearchConfig): SearchProvider { +export function createHttpSearchProvider( + apiKey: string, + config: HttpSearchConfig, +): SearchProvider { return { name: config.name, label: config.label, @@ -338,11 +362,13 @@ function executeSearch( signal: AbortSignal | undefined, filters: SearchFilters | undefined, ): Promise { - return provider.search(query, maxResults, signal, filters).then((results) => - provider.filterSupport.domains === "post-filter" - ? filterResultsByDomains(results, filters) - : results, - ); + return provider + .search(query, maxResults, signal, filters) + .then((results) => + provider.filterSupport.domains === "post-filter" + ? filterResultsByDomains(results, filters) + : results, + ); } ``` diff --git a/docs/superpowers/specs/2026-08-12-architecture-deepening-phase-1-filters-design.md b/docs/superpowers/specs/2026-08-12-architecture-deepening-phase-1-filters-design.md new file mode 100644 index 0000000..910c35c --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-architecture-deepening-phase-1-filters-design.md @@ -0,0 +1,166 @@ +# Phase 1: Explicit Search Filter Semantics + +## Status + +Approved revised design after repository review. The existing Phase 1 implementation plan is close but must be revised with the decisions and gates below before implementation starts. + +## Review outcome + +The repository is clean on branch `20260812-architecture-deepening-phase-1-filters`. The baseline `pnpm check` passes with 87 test files and 1,410 tests; the existing Biome warnings and Node 23 engine warning remain documented pre-existing conditions. + +The current `SearchProvider` accepts domain and date filters without declaring whether an adapter enforces them. Several adapters ignore one or both groups. The original plan therefore addresses the right seam, but it was not implementation-ready because it did not specify the shared constant location, deterministic unsupported-group aggregation, cancellation at the early rejection path, runtime metadata-preservation tests, a production support-matrix test, or the user-facing README change. + +## Goal + +Prevent filtered searches from silently losing requested domain or date constraints. + +For every filtered search, each eligible provider must either enforce every requested filter natively, declare safe local domain post-filtering, or be excluded before execution. If no candidate can enforce the request, `web_search` returns an explicit structured error. + +## Non-goals + +- Change provider ordering, selection strategy, budgets, fallback order, or fusion behavior. +- Add result top-up requests after local post-filtering. +- Redesign provider registration or introduce a general policy framework. +- Change invalid-date handling already performed by `buildFilters`. +- Clean unrelated lint warnings or modify the Node engine requirement. + +## Filter-support contract + +`src/providers/types.ts` owns the contract and its shared values: + +```ts +export type SearchFilterMode = "native" | "post-filter" | "unsupported"; + +export interface SearchFilterSupport { + readonly domains: SearchFilterMode; + readonly dates: Exclude; +} + +export const NATIVE_DOMAIN_DATE_FILTERS: SearchFilterSupport = { + domains: "native", + dates: "native", +}; + +export const NATIVE_DOMAIN_FILTERS: SearchFilterSupport = { + domains: "native", + dates: "unsupported", +}; + +export const POST_FILTER_DOMAIN_FILTERS: SearchFilterSupport = { + domains: "post-filter", + dates: "unsupported", +}; + +export const UNSUPPORTED_SEARCH_FILTERS: SearchFilterSupport = { + domains: "unsupported", + dates: "unsupported", +}; +``` + +`SearchProvider` requires `readonly filterSupport: SearchFilterSupport`. This makes every production adapter and typed test double declare its filter policy. The HTTP adapter accepts optional metadata for configuration convenience and defaults to `UNSUPPORTED_SEARCH_FILTERS`, so an omitted declaration cannot silently become permissive. + +The registry copies `filterSupport` into its budget-wrapped search provider. It preserves the metadata but does not apply filter logic; `web_search` remains the policy boundary. + +## Production support matrix + +| Providers | Domains | Dates | +| --- | --- | --- | +| Brave Search, Exa, Serper, TinyFish | native | native | +| Tavily | native | unsupported | +| DuckDuckGo | post-filter | unsupported | +| Brave LLM, fastCRW, LangSearch, Linkup, Marginalia, Perplexity, WebSearchAPI, You.com, Firecrawl, Jina, Ollama, OpenAI Codex, OpenAI web search, Parallel, SearXNG, Sofya | unsupported | unsupported | + +Native means the provider adapter sends the provider-supported domain/date parameters or query syntax. `post-filter` means the provider may narrow its query, but returned URLs are centrally checked by exact host/subdomain matching before they are exposed. Unsupported means the candidate is excluded from a request that asks for that group. + +## Domain post-filter + +Add `filterResultsByDomains(results, filters)` and `unsupportedSearchFilters(filters, support)` to `src/utils/filters.ts`. + +Domain matching parses each result URL with the native `URL` class, lowercases the hostname, trims surrounding dots from the requested domain, and accepts only an exact host or a subdomain. Thus `docs.example.com` matches `example.com`, while `notexample.com` does not. Invalid result URLs do not match an include or exclude domain. + +The post-filter applies only when `support.domains === "post-filter"`. Native providers are not locally re-filtered, preserving their upstream ranking and semantics. + +## `web_search` data flow + +1. Resolve candidates through the existing selection and combine strategy. +2. If the original list is empty, preserve the existing `Search error: No search providers available` result. +3. Build the existing normalized filters. +4. Evaluate every candidate with `unsupportedSearchFilters`. Keep candidates with no unsupported groups in their original order. Collect the union of unsupported groups in deterministic `domains`, then `dates` order. +5. If no candidates remain, honor an already-aborted signal before returning an explicit unsupported-filter result. Do not call providers, reserve budget, create activity entries, or invoke success/failure/result callbacks. +6. Execute the eligible candidates through the existing fallback or fusion executor. Each provider call is wrapped so a post-filter-domain provider applies `filterResultsByDomains` to its returned results. +7. Preserve the existing executor behavior: ordinary fallback accepts a successful empty result, while fusion treats an empty result as unusable and continues. Do not make additional requests to refill results removed by local filtering. + +Unfiltered searches pass the original candidate list and follow the current execution and formatting paths unchanged. + +## Error behavior + +When the original candidate list is non-empty but every candidate is ineligible, return: + +```ts +{ + content: [{ + type: "text", + text: "Search error: no eligible provider supports requested filters: domains, dates", + }], + details: { + provider: "none", + resultCount: 0, + unsupportedFilters: ["domains", "dates"], + }, +} +``` + +The message lists only the requested groups that prevented eligibility. Unsupported candidates are not provider failures, so they do not affect activity, failure metrics, or fallback error aggregation. Provider failures, budget errors, cancellation handling, callbacks, and fusion metadata remain governed by the existing executors. + +## Test design + +- Contract tests cover exact host and subdomain inclusion, exclusion without lookalike matches, malformed URLs, unsupported-group detection, and native/post-filter combinations. +- A production support-matrix test asserts the declared policy for every registered search provider. +- HTTP-adapter tests cover custom metadata and the unsupported default. +- Registry tests assert that the budget wrapper preserves `filterSupport` at runtime. +- `web-search` tests cover unsupported providers not being called, explicit structured errors, partial eligibility, post-filter results, fusion candidate exclusion, early cancellation, and unchanged unfiltered behavior. +- All existing typed test fixtures update to provide `UNSUPPORTED_SEARCH_FILTERS` explicitly; no casts are added to bypass the contract. + +## Files in scope + +Modify the existing Phase 1 implementation file map: + +- `src/providers/types.ts` +- `src/utils/filters.ts` +- `src/providers/http-adapter.ts` +- `src/providers/http-providers.ts` +- `src/providers/duckduckgo.ts` +- `src/providers/exa.ts` +- `src/providers/tavily.ts` +- `src/providers/serper.ts` +- `src/providers/tinyfish.ts` +- `src/providers/firecrawl.ts` +- `src/providers/jina.ts` +- `src/providers/ollama.ts` +- `src/providers/openai-codex.ts` +- `src/providers/openai-web-search.ts` +- `src/providers/parallel.ts` +- `src/providers/searxng.ts` +- `src/providers/sofya.ts` +- `src/providers/registry.ts` +- `src/tools/web-search.ts` +- the existing typed search-provider fixtures and focused tests +- `README.md` + +Create: + +- `tests/providers/filter-support.test.ts` + +No Phase 2–5 files are in scope. + +## Verification gate + +Run the focused provider and tool tests, then: + +```bash +pnpm check +git diff --check +git status --short +``` + +The phase is ready to merge only when all focused tests and the full suite pass, the diff contains only intended Phase 1 changes, and the README accurately describes native, post-filtered, and explicitly unsupported filter behavior. From 78ad6dbd7ca8d594e44a88e544ead7bae9df74c7 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 12 Aug 2026 21:24:23 -0400 Subject: [PATCH 2/4] feat: make search filter support explicit --- src/providers/duckduckgo.ts | 6 ++ src/providers/exa.ts | 1 + src/providers/firecrawl.ts | 1 + src/providers/http-adapter.ts | 11 +- src/providers/http-providers.ts | 15 ++- src/providers/jina.ts | 1 + src/providers/ollama.ts | 1 + src/providers/openai-codex.ts | 1 + src/providers/openai-web-search.ts | 1 + src/providers/parallel.ts | 1 + src/providers/registry.ts | 1 + src/providers/searxng.ts | 1 + src/providers/serper.ts | 3 + src/providers/sofya.ts | 1 + src/providers/tavily.ts | 1 + src/providers/tinyfish.ts | 1 + src/providers/types.ts | 13 +++ src/tools/web-search.ts | 56 +++++++++-- src/utils/filters.ts | 63 +++++++++++- tests/commands/tools-dashboard.test.ts | 4 + tests/commands/tools.test.ts | 2 + tests/config-manager.test.ts | 15 ++- tests/providers/filter-support.test.ts | 52 ++++++++++ tests/providers/registry.test.ts | 2 + tests/providers/types.test.ts | 18 ++-- tests/tools/web-search.test.ts | 133 +++++++++++++++++++++++++ 26 files changed, 386 insertions(+), 19 deletions(-) create mode 100644 tests/providers/filter-support.test.ts diff --git a/src/providers/duckduckgo.ts b/src/providers/duckduckgo.ts index 98b663c..4391dea 100644 --- a/src/providers/duckduckgo.ts +++ b/src/providers/duckduckgo.ts @@ -4,6 +4,11 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { ProviderMeta, SearchFilters, SearchProvider, SearchResult } from "./types.ts"; + +const POST_FILTER_DOMAIN_FILTERS = { + domains: "post-filter", + dates: "unsupported", +} as const; import { applyDomainFilters } from "../utils/filters.ts"; import { parseDuckDuckGoResults } from "./parsers.ts"; @@ -27,6 +32,7 @@ const EXEC_TIMEOUT_MS = 15_000; export class DuckDuckGoProvider implements SearchProvider { readonly name = "duckduckgo"; readonly label = "DuckDuckGo"; + readonly filterSupport = POST_FILTER_DOMAIN_FILTERS; private readonly execFile: ExecFileFn; private readonly ddgsOptions: DDGSOptions; diff --git a/src/providers/exa.ts b/src/providers/exa.ts index 5a581bd..be4ce26 100644 --- a/src/providers/exa.ts +++ b/src/providers/exa.ts @@ -22,6 +22,7 @@ interface ExaContentsResponse { export class ExaProvider implements SearchProvider, FetchProvider, CodeSearchProvider { readonly name = "exa"; readonly label = "Exa"; + readonly filterSupport = { domains: "native", dates: "native" } as const; private apiKey: string; constructor(apiKey: string) { diff --git a/src/providers/firecrawl.ts b/src/providers/firecrawl.ts index 33d7155..460113e 100644 --- a/src/providers/firecrawl.ts +++ b/src/providers/firecrawl.ts @@ -16,6 +16,7 @@ interface FirecrawlScrapeResponse { export class FirecrawlProvider implements SearchProvider, FetchProvider { readonly name = "firecrawl"; readonly label = "Firecrawl"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; private apiKey?: string; constructor(apiKey?: string) { diff --git a/src/providers/http-adapter.ts b/src/providers/http-adapter.ts index 7271fa9..4ab14e7 100644 --- a/src/providers/http-adapter.ts +++ b/src/providers/http-adapter.ts @@ -1,4 +1,10 @@ -import type { SearchFilters, SearchProvider, SearchResult } from "./types.ts"; +import type { + SearchFilterSupport, + SearchFilters, + SearchProvider, + SearchResult, +} from "./types.ts"; +import { UNSUPPORTED_SEARCH_FILTERS } from "./types.ts"; export interface HttpSearchConfig { name: string; @@ -13,12 +19,15 @@ export interface HttpSearchConfig { buildBody?: (query: string, maxResults: number, filters?: SearchFilters) => unknown; extractResults: (data: unknown) => Array<{ title: string; url: string; snippet: string }>; + + filterSupport?: SearchFilterSupport; } export function createHttpSearchProvider(apiKey: string, config: HttpSearchConfig): SearchProvider { return { name: config.name, label: config.label, + filterSupport: config.filterSupport ?? UNSUPPORTED_SEARCH_FILTERS, async search( query: string, maxResults: number, diff --git a/src/providers/http-providers.ts b/src/providers/http-providers.ts index 47d0057..47e0f6b 100644 --- a/src/providers/http-providers.ts +++ b/src/providers/http-providers.ts @@ -11,7 +11,11 @@ import { parseWebSearchApiResults, parseYouComResults, } from "./parsers.ts"; -import type { ProviderMeta, SearchFilters } from "./types.ts"; +import { + UNSUPPORTED_SEARCH_FILTERS, + type ProviderMeta, + type SearchFilters, +} from "./types.ts"; function buildFreshness(filters?: SearchFilters): string | null { if (!filters) return null; @@ -44,6 +48,7 @@ export const httpProviders: ProviderMeta[] = [ "X-Subscription-Token": apiKey, }), extractResults: parseBraveResults, + filterSupport: { domains: "native", dates: "native" }, }), }), }, @@ -69,6 +74,7 @@ export const httpProviders: ProviderMeta[] = [ return body; }, extractResults: parseBraveLlmResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -88,6 +94,7 @@ export const httpProviders: ProviderMeta[] = [ limit: Math.min(maxResults, 20), }), extractResults: parseFastcrwResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -107,6 +114,7 @@ export const httpProviders: ProviderMeta[] = [ max_results: Math.min(maxResults, 20), }), extractResults: parseLangSearchResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -128,6 +136,7 @@ export const httpProviders: ProviderMeta[] = [ depth: providerConfig?.depth ?? "standard", }), extractResults: parseLinkupResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -152,6 +161,7 @@ export const httpProviders: ProviderMeta[] = [ "API-Key": apiKey, }), extractResults: parseMarginaliaResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -171,6 +181,7 @@ export const httpProviders: ProviderMeta[] = [ messages: [{ role: "user", content: query }], }), extractResults: parsePerplexityResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -187,6 +198,7 @@ export const httpProviders: ProviderMeta[] = [ authPrefix: "Bearer ", buildBody: (query, maxResults) => ({ query, maxResults }), extractResults: parseWebSearchApiResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, @@ -209,6 +221,7 @@ export const httpProviders: ProviderMeta[] = [ method: "GET", buildHeaders: (apiKey) => ({ "X-API-Key": apiKey }), extractResults: parseYouComResults, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, }), }), }, diff --git a/src/providers/jina.ts b/src/providers/jina.ts index 6ebf93c..f0622c3 100644 --- a/src/providers/jina.ts +++ b/src/providers/jina.ts @@ -12,6 +12,7 @@ import { parseJinaResults } from "./parsers.ts"; export class JinaProvider implements SearchProvider, FetchProvider { readonly name = "jina"; readonly label = "Jina"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; private apiKey?: string; constructor(apiKey?: string) { diff --git a/src/providers/ollama.ts b/src/providers/ollama.ts index 83ed337..dd923ac 100644 --- a/src/providers/ollama.ts +++ b/src/providers/ollama.ts @@ -32,6 +32,7 @@ export function isConnectionRefused(error: unknown): boolean { export class OllamaProvider implements SearchProvider, FetchProvider { readonly name = "ollama"; readonly label = "Ollama"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; private readonly baseUrl: string; private readonly apiKey?: string; diff --git a/src/providers/openai-codex.ts b/src/providers/openai-codex.ts index 7bffb01..383c1ed 100644 --- a/src/providers/openai-codex.ts +++ b/src/providers/openai-codex.ts @@ -43,6 +43,7 @@ const SUBMIT_SEARCH_RESULTS_TOOL = { class OpenAICodexProvider implements SearchProvider { readonly name = "openai-codex"; readonly label = "OpenAI Codex"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; constructor( private readonly model?: string, diff --git a/src/providers/openai-web-search.ts b/src/providers/openai-web-search.ts index cdf7e73..49c5d7d 100644 --- a/src/providers/openai-web-search.ts +++ b/src/providers/openai-web-search.ts @@ -9,6 +9,7 @@ const DEFAULT_MODEL = "gpt-4.1-mini"; class OpenAiWebSearchProvider implements SearchProvider { readonly name = "openai-web-search"; readonly label = "OpenAI Web Search"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; private readonly apiKey: string; private readonly model: string; diff --git a/src/providers/parallel.ts b/src/providers/parallel.ts index ce93fd3..923e5e2 100644 --- a/src/providers/parallel.ts +++ b/src/providers/parallel.ts @@ -31,6 +31,7 @@ interface ParallelExtractResponse { export class ParallelProvider implements SearchProvider, FetchProvider { readonly name = "parallel"; readonly label = "Parallel"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; private apiKey: string; constructor(apiKey: string) { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 46aff15..74becef 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -183,6 +183,7 @@ export class ProviderRegistry { provider: { name: provider.name, label: provider.label, + filterSupport: provider.filterSupport, search: async (query, maxResults, signal, filters) => { this.consume(options.name, { capability: "search", maxResults }); return provider.search(query, maxResults, signal, filters); diff --git a/src/providers/searxng.ts b/src/providers/searxng.ts index 9910748..97f7d3c 100644 --- a/src/providers/searxng.ts +++ b/src/providers/searxng.ts @@ -14,6 +14,7 @@ interface SearXNGOptions { export class SearXNGProvider implements SearchProvider { readonly name = "searxng"; readonly label = "SearXNG"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; readonly instanceUrl: string; private apiKey?: string; diff --git a/src/providers/serper.ts b/src/providers/serper.ts index 7dd86a8..599ac7b 100644 --- a/src/providers/serper.ts +++ b/src/providers/serper.ts @@ -3,6 +3,8 @@ import { applyDomainFilters } from "../utils/filters.ts"; import { parseSerperResults } from "./parsers.ts"; import type { ProviderMeta, SearchFilters } from "./types.ts"; +const NATIVE_DOMAIN_DATE_FILTERS = { domains: "native", dates: "native" } as const; + /** Converts "YYYY-MM-DD" to "MM/DD/YYYY" for Google's tbs format. */ function isoToMDY(iso: string): string { const [year, month, day] = iso.split("-"); @@ -42,6 +44,7 @@ export const providerMeta: ProviderMeta = { return body; }, extractResults: parseSerperResults, + filterSupport: NATIVE_DOMAIN_DATE_FILTERS, }), }), }; diff --git a/src/providers/sofya.ts b/src/providers/sofya.ts index 05c60e5..1f5b2a5 100644 --- a/src/providers/sofya.ts +++ b/src/providers/sofya.ts @@ -13,6 +13,7 @@ const SOFYA_BASE = "https://sofya.co"; class SofyaProvider implements SearchProvider, FetchProvider { readonly name = "sofya"; readonly label = "Sofya"; + readonly filterSupport = { domains: "unsupported", dates: "unsupported" } as const; private readonly apiKey: string; private readonly searchDepth: string; private readonly topic: string; diff --git a/src/providers/tavily.ts b/src/providers/tavily.ts index 1610d2e..28526c2 100644 --- a/src/providers/tavily.ts +++ b/src/providers/tavily.ts @@ -16,6 +16,7 @@ interface TavilyExtractResponse { export class TavilyProvider implements SearchProvider, FetchProvider { readonly name = "tavily"; readonly label = "Tavily"; + readonly filterSupport = { domains: "native", dates: "unsupported" } as const; private apiKey: string; constructor(apiKey: string) { diff --git a/src/providers/tinyfish.ts b/src/providers/tinyfish.ts index 7a62496..64b331d 100644 --- a/src/providers/tinyfish.ts +++ b/src/providers/tinyfish.ts @@ -26,6 +26,7 @@ function validHttpUrl(value: unknown): value is string { export class TinyFishProvider implements SearchProvider, FetchProvider { readonly name = "tinyfish"; readonly label = "TinyFish"; + readonly filterSupport = { domains: "native", dates: "native" } as const; constructor(private readonly apiKey: string) {} diff --git a/src/providers/types.ts b/src/providers/types.ts index b47fc9e..57467cf 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -27,9 +27,22 @@ export interface SearchFilters { endDate?: string; // ISO 8601 date } +export type SearchFilterMode = "native" | "post-filter" | "unsupported"; + +export interface SearchFilterSupport { + domains: SearchFilterMode; + dates: Exclude; +} + +export const UNSUPPORTED_SEARCH_FILTERS: SearchFilterSupport = { + domains: "unsupported", + dates: "unsupported", +}; + export interface SearchProvider { readonly name: string; readonly label: string; + readonly filterSupport: SearchFilterSupport; search( query: string, maxResults: number, diff --git a/src/tools/web-search.ts b/src/tools/web-search.ts index f0c9d04..5add0a8 100644 --- a/src/tools/web-search.ts +++ b/src/tools/web-search.ts @@ -5,6 +5,7 @@ import type { SearchFilters, SearchProvider, SearchResult } from "../providers/t import { executeWithFallback } from "../providers/execute.ts"; import type { GuidanceOverride, CombineConfig } from "../config.ts"; import { executeWithFusion } from "../providers/fusion.ts"; +import { filterResultsByDomains, unsupportedSearchFilters } from "../utils/filters.ts"; const WebSearchParams = Type.Object({ query: Type.String({ description: "Search query" }), @@ -54,6 +55,7 @@ const WebSearchParams = Type.Object({ interface WebSearchDetails { provider: string; resultCount: number; + unsupportedFilters?: string[]; fusionMeta?: { providersUsed: string[]; degraded: boolean; @@ -96,6 +98,22 @@ function buildFilters(params: { }; } +function executeSearch( + provider: SearchProvider, + query: string, + maxResults: number, + signal: AbortSignal | undefined, + filters: SearchFilters | undefined, +): Promise { + return provider + .search(query, maxResults, signal, filters) + .then((results) => + provider.filterSupport.domains === "post-filter" + ? filterResultsByDomains(results, filters) + : results, + ); +} + function formatFusionOutput( results: SearchResult[], degraded: boolean, @@ -130,9 +148,9 @@ export function createWebSearchTool( parameters: WebSearchParams, async execute(_toolCallId, params, signal, _onUpdate, _ctx) { const combineActive = params.combine ?? combineConfig?.enabled === true; - const candidates = resolveCandidates(params.provider, combineActive); + const allCandidates = resolveCandidates(params.provider, combineActive); - if (candidates.length === 0) { + if (allCandidates.length === 0) { return { content: [{ type: "text" as const, text: "Search error: No search providers available" }], details: { provider: "none", resultCount: 0 }, @@ -142,14 +160,37 @@ export function createWebSearchTool( const maxResults = params.numResults ?? 5; const filters = buildFilters(params); + const eligibleCandidates = allCandidates.filter( + (provider) => unsupportedSearchFilters(filters, provider.filterSupport).length === 0, + ); + + if (eligibleCandidates.length === 0 && allCandidates.length > 0) { + const groups = Array.from( + new Set( + allCandidates.flatMap((p) => + unsupportedSearchFilters(filters, p.filterSupport), + ), + ), + ); + return { + content: [ + { + type: "text" as const, + text: `Search error: no eligible provider supports requested filters: ${groups.join(", ")}`, + }, + ], + details: { provider: "none", resultCount: 0, unsupportedFilters: groups }, + }; + } + // Fusion path - if (combineActive && candidates.length > 1 && combineConfig) { + if (combineActive && eligibleCandidates.length > 1 && combineConfig) { try { const fusionResult = await executeWithFusion({ - candidates: candidates.map((provider) => ({ + candidates: eligibleCandidates.map((provider) => ({ name: provider.name, execute: (n: number) => - provider.search(params.query, n, signal ?? undefined, filters), + executeSearch(provider, params.query, n, signal ?? undefined, filters), })), maxResults, mode: combineConfig.mode, @@ -201,9 +242,10 @@ export function createWebSearchTool( // Fallback path try { const { result: results, providerName } = await executeWithFallback({ - candidates: candidates.map((provider) => ({ + candidates: eligibleCandidates.map((provider) => ({ name: provider.name, - execute: () => provider.search(params.query, maxResults, signal ?? undefined, filters), + execute: () => + executeSearch(provider, params.query, maxResults, signal ?? undefined, filters), })), operation: "search", signal, diff --git a/src/utils/filters.ts b/src/utils/filters.ts index 22b84d3..0da16f8 100644 --- a/src/utils/filters.ts +++ b/src/utils/filters.ts @@ -1,4 +1,8 @@ -import type { SearchFilters } from "../providers/types.ts"; +import type { + SearchFilterSupport, + SearchFilters, + SearchResult, +} from "../providers/types.ts"; /** Prepends site:/‑site: operators to a query for providers that use query manipulation. */ export function applyDomainFilters(query: string, filters?: SearchFilters): string { @@ -17,3 +21,60 @@ export function applyDomainFilters(query: string, filters?: SearchFilters): stri if (parts.length === 0) return query; return `${parts.join(" ")} ${query}`; } + +function hostMatchesDomain(urlValue: string, domain: string): boolean { + try { + const host = new URL(urlValue).hostname.toLowerCase(); + const normalized = domain + .trim() + .toLowerCase() + .replace(/^\.+|\.+$/g, ""); + return ( + normalized.length > 0 && + (host === normalized || host.endsWith(`.${normalized}`)) + ); + } catch { + return false; + } +} + +export function filterResultsByDomains( + results: SearchResult[], + filters?: SearchFilters, +): SearchResult[] { + if (!filters?.includeDomains?.length && !filters?.excludeDomains?.length) + return results; + + return results.filter((result) => { + const included = + !filters.includeDomains?.length || + filters.includeDomains.some((domain) => + hostMatchesDomain(result.url, domain), + ); + const excluded = + filters.excludeDomains?.some((domain) => + hostMatchesDomain(result.url, domain), + ) ?? false; + return included && !excluded; + }); +} + +export function unsupportedSearchFilters( + filters: SearchFilters | undefined, + support: SearchFilterSupport, +): string[] { + const unsupported: string[] = []; + if ( + (filters?.includeDomains?.length || filters?.excludeDomains?.length) && + support.domains === "unsupported" + ) { + unsupported.push("domains"); + } + if ( + (filters?.startDate || filters?.endDate) && + support.dates === "unsupported" + ) { + unsupported.push("dates"); + } + return unsupported; +} \ No newline at end of file diff --git a/tests/commands/tools-dashboard.test.ts b/tests/commands/tools-dashboard.test.ts index 0a413d6..4acd079 100644 --- a/tests/commands/tools-dashboard.test.ts +++ b/tests/commands/tools-dashboard.test.ts @@ -7,6 +7,7 @@ import { import type { ActivityEntry } from "../../src/monitor/activity-monitor.ts"; import type { ProviderRegistry } from "../../src/providers/registry.ts"; import type { ProviderTier, SearchProvider, SearchResult } from "../../src/providers/types.ts"; +import { UNSUPPORTED_SEARCH_FILTERS } from "../../src/providers/types.ts"; import { noTheme } from "../../src/tui/dashboard-theme.ts"; const searchResult: SearchResult = { @@ -24,11 +25,13 @@ function searchRegistry( { name: "brave", label: "Brave", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: vi.fn().mockResolvedValue([searchResult]), }, { name: "duckduckgo", label: "DuckDuckGo", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: vi.fn().mockResolvedValue([]), }, ], @@ -454,6 +457,7 @@ describe("ToolsDashboardComponent", () => { const provider: SearchProvider = { name: "brave", label: "Brave", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: vi.fn(() => new Promise((r) => (resolveSearch = r))), }; const { component } = dashboard({ diff --git a/tests/commands/tools.test.ts b/tests/commands/tools.test.ts index 5340963..8c8841d 100644 --- a/tests/commands/tools.test.ts +++ b/tests/commands/tools.test.ts @@ -9,6 +9,7 @@ import { activityMonitor } from "../../src/monitor/activity-monitor.ts"; import type { ProviderBudget } from "../../src/config.ts"; import { ProviderRegistry } from "../../src/providers/registry.ts"; import type { ProviderTier, SearchProvider } from "../../src/providers/types.ts"; +import { UNSUPPORTED_SEARCH_FILTERS } from "../../src/providers/types.ts"; import { makeCtx } from "../helpers.ts"; vi.mock("node:fs"); @@ -120,6 +121,7 @@ function mockProvider(name: string, label: string): SearchProvider { return { name, label, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: vi.fn().mockResolvedValue([]), }; } diff --git a/tests/config-manager.test.ts b/tests/config-manager.test.ts index e738923..2c3a02e 100644 --- a/tests/config-manager.test.ts +++ b/tests/config-manager.test.ts @@ -4,6 +4,7 @@ import { ConfigManager, diffConfig } from "../src/config-manager.ts"; import type { PiToolsConfig, ProviderBudget } from "../src/config.ts"; import { ProviderRegistry } from "../src/providers/registry.ts"; import type { ProviderMeta } from "../src/providers/types.ts"; +import { UNSUPPORTED_SEARCH_FILTERS } from "../src/providers/types.ts"; vi.mock("../src/config.ts", async (importOriginal) => { const actual = await importOriginal(); @@ -42,7 +43,12 @@ function meta(name: string, overrides: Partial = {}): ProviderMeta tier: 1, requiresKey: false, create: () => ({ - search: { name, label: name, search: vi.fn().mockResolvedValue([]) }, + search: { + name, + label: name, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([]), + }, }), ...overrides, }; @@ -105,7 +111,12 @@ describe("ConfigManager", () => { const providers = { all: entry(hard) }; vi.mocked(loadMergedConfig).mockReturnValue(makeConfig(providers)); const instances = { - search: { name: "all", label: "all", search: vi.fn().mockResolvedValue([]) }, + search: { + name: "all", + label: "all", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([]), + }, fetch: { name: "all", fetch: vi.fn().mockResolvedValue({ text: "ok" }) }, codeSearch: { name: "all", codeSearch: vi.fn().mockResolvedValue([]) }, docs: { diff --git a/tests/providers/filter-support.test.ts b/tests/providers/filter-support.test.ts new file mode 100644 index 0000000..51c4a1e --- /dev/null +++ b/tests/providers/filter-support.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + filterResultsByDomains, + unsupportedSearchFilters, +} from "../../src/utils/filters.ts"; +import { + UNSUPPORTED_SEARCH_FILTERS, + type SearchFilterSupport, + type SearchResult, +} from "../../src/providers/types.ts"; + +const results: SearchResult[] = [ + { title: "Docs", url: "https://docs.example.com/a", snippet: "docs" }, + { title: "Root", url: "https://example.com/b", snippet: "root" }, + { title: "Lookalike", url: "https://notexample.com/c", snippet: "other" }, +]; + +describe("search filter support", () => { + it("filters included domains by host and subdomain", () => { + expect( + filterResultsByDomains(results, { includeDomains: ["example.com"] }), + ).toEqual(results.slice(0, 2)); + }); + + it("filters excluded domains without matching lookalike hosts", () => { + expect( + filterResultsByDomains(results, { excludeDomains: ["example.com"] }), + ).toEqual([results[2]]); + }); + + it("reports unsupported domain and date groups", () => { + expect( + unsupportedSearchFilters( + { + includeDomains: ["example.com"], + startDate: "2026-01-01", + }, + UNSUPPORTED_SEARCH_FILTERS, + ), + ).toEqual(["domains", "dates"]); + }); + + it("allows native domains and dates", () => { + const support: SearchFilterSupport = { domains: "native", dates: "native" }; + expect( + unsupportedSearchFilters( + { includeDomains: ["example.com"], endDate: "2026-08-12" }, + support, + ), + ).toEqual([]); + }); +}); \ No newline at end of file diff --git a/tests/providers/registry.test.ts b/tests/providers/registry.test.ts index a4b1b5b..0f384b1 100644 --- a/tests/providers/registry.test.ts +++ b/tests/providers/registry.test.ts @@ -13,6 +13,7 @@ import type { SearchProvider, UsageCost, } from "../../src/providers/types.ts"; +import { UNSUPPORTED_SEARCH_FILTERS } from "../../src/providers/types.ts"; const managed: ProviderBudget = { mode: "managed" }; const hard = ( @@ -26,6 +27,7 @@ function search(name: string): SearchProvider { return { name, label: name, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: vi .fn() .mockResolvedValue([{ title: name, url: `https://${name}.test`, snippet: name }]), diff --git a/tests/providers/types.test.ts b/tests/providers/types.test.ts index ee2af07..c98fe8a 100644 --- a/tests/providers/types.test.ts +++ b/tests/providers/types.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { - CodeSearchResult, - FetchResult, - ProviderMeta, - SearchFilters, - SearchProvider, - SearchResult, +import { + UNSUPPORTED_SEARCH_FILTERS, + type CodeSearchResult, + type FetchResult, + type ProviderMeta, + type SearchFilters, + type SearchProvider, + type SearchResult, } from "../../src/providers/types.ts"; describe("provider types", () => { @@ -48,6 +49,7 @@ describe("provider types", () => { const mockSearch: SearchProvider = { name: "brave", label: "Brave Search", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: async () => [], }; const meta: ProviderMeta = { @@ -78,6 +80,7 @@ describe("SearchFilters type", () => { const provider: SearchProvider = { name: "test", label: "Test", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, async search( query: string, maxResults: number, @@ -95,6 +98,7 @@ describe("SearchFilters type", () => { const provider: SearchProvider = { name: "legacy", label: "Legacy", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, async search( query: string, maxResults: number, diff --git a/tests/tools/web-search.test.ts b/tests/tools/web-search.test.ts index da65282..ed0c3e4 100644 --- a/tests/tools/web-search.test.ts +++ b/tests/tools/web-search.test.ts @@ -3,6 +3,7 @@ import { Text } from "@earendil-works/pi-tui"; import { createWebSearchTool } from "../../src/tools/web-search.ts"; import { makeCtx } from "../helpers.ts"; import type { SearchFilters, SearchProvider, SearchResult } from "../../src/providers/types.ts"; +import { UNSUPPORTED_SEARCH_FILTERS } from "../../src/providers/types.ts"; import type { Theme } from "@earendil-works/pi-coding-agent"; function makeMockTheme(): Theme { @@ -16,6 +17,7 @@ function makeProvider(name: string, results: SearchResult[]): SearchProvider { return { name, label: name, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, async search(_query: string, maxResults: number, _signal?: AbortSignal) { return results.slice(0, maxResults); }, @@ -26,6 +28,7 @@ function makeFailingProvider(name: string, message: string): SearchProvider { return { name, label: name, + filterSupport: UNSUPPORTED_SEARCH_FILTERS, async search() { throw new Error(message); }, @@ -136,6 +139,7 @@ function makeCapturingProvider(): { const provider: SearchProvider = { name: "capturing", label: "Capturing", + filterSupport: { domains: "native", dates: "native" }, async search( query: string, maxResults: number, @@ -685,6 +689,7 @@ describe("web_search metrics callbacks", () => { const provider: SearchProvider = { name: "brave", label: "Brave", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, search: vi.fn().mockRejectedValue(new Error("API error")), }; const tool = createWebSearchTool(() => [provider], undefined, undefined, onFailure); @@ -771,3 +776,131 @@ describe("web_search metrics callbacks", () => { expect(onResult).toHaveBeenCalledWith("brave", 0, 5); }); }); + +describe("web_search explicit filter support", () => { + it("skips an unsupported date provider and reports the unsupported group", async () => { + const unsupported: SearchProvider = { + name: "unsupported", + label: "Unsupported", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([]), + }; + const tool = createWebSearchTool(() => [unsupported], vi.fn()); + const ctx = makeCtx(); + const result = await tool.execute( + "call-uf-1", + { query: "test", startDate: "2025-01-01" }, + undefined, + undefined, + ctx, + ); + + expect(unsupported.search).not.toHaveBeenCalled(); + const text = (result.content[0] as { type: "text"; text: string }).text; + expect(text).toContain("Search error"); + expect(text).toContain("dates"); + expect(result.details.unsupportedFilters).toEqual(["dates"]); + expect(result.details.resultCount).toBe(0); + }); + + it("post-filter provider returns only exact-domain and subdomain results", async () => { + const postFiltered: SearchProvider = { + name: "post-filtered", + label: "Post-filtered", + filterSupport: { domains: "post-filter", dates: "unsupported" }, + search: vi.fn().mockResolvedValue([ + { title: "good", url: "https://docs.example.com/a", snippet: "good" }, + { title: "bad", url: "https://other.example/a", snippet: "bad" }, + ]), + }; + const tool = createWebSearchTool(() => [postFiltered]); + const ctx = makeCtx(); + const result = await tool.execute( + "call-pf-1", + { query: "test", includeDomains: ["example.com"] }, + undefined, + undefined, + ctx, + ); + + const text = (result.content[0] as { type: "text"; text: string }).text; + expect(text).toContain("docs.example.com"); + expect(text).not.toContain("other.example"); + }); + + it("fusion excludes unsupported candidates and fuses the eligible providers", async () => { + const unsupported: SearchProvider = { + name: "unsupported", + label: "Unsupported", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([]), + }; + const nativeA: SearchProvider = { + name: "native-a", + label: "Native A", + filterSupport: { domains: "native", dates: "native" }, + search: vi.fn().mockResolvedValue([ + { title: "Hit A", url: "https://a.example.com/a", snippet: "hit" }, + ]), + }; + const nativeB: SearchProvider = { + name: "native-b", + label: "Native B", + filterSupport: { domains: "native", dates: "native" }, + search: vi.fn().mockResolvedValue([ + { title: "Hit B", url: "https://b.example.com/b", snippet: "hit" }, + ]), + }; + const combineConfig = { enabled: true, mode: "targeted" as const, targetBackends: 2, k: 60 }; + const tool = createWebSearchTool( + () => [unsupported, nativeA, nativeB], + vi.fn(), + undefined, + vi.fn(), + vi.fn(), + combineConfig, + ); + const ctx = makeCtx(); + const result = await tool.execute( + "call-fuse-filt", + { query: "test", combine: true, startDate: "2025-01-01" }, + undefined, + undefined, + ctx, + ); + + expect(unsupported.search).not.toHaveBeenCalled(); + expect(result.details.provider).toBe("fusion"); + expect(result.details.fusionMeta?.providersUsed).toContain("native-a"); + expect(result.details.fusionMeta?.providersUsed).toContain("native-b"); + }); + + it("unfiltered calls still send the same candidate list and result formatting", async () => { + const providerA: SearchProvider = { + name: "a", + label: "A", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([ + { title: "Result", url: "https://a.com", snippet: "snip" }, + ]), + }; + const providerB: SearchProvider = { + name: "b", + label: "B", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([ + { title: "Result", url: "https://b.com", snippet: "snip" }, + ]), + }; + const tool = createWebSearchTool(() => [providerA, providerB]); + const ctx = makeCtx(); + const result = await tool.execute("call-unfilt", { query: "test" }, undefined, undefined, ctx); + + expect(providerA.search).toHaveBeenCalled(); + expect(providerB.search).not.toHaveBeenCalled(); + expect(result.details.provider).toBe("a"); + expect(result.details.unsupportedFilters).toBeUndefined(); + const text = (result.content[0] as { type: "text"; text: string }).text; + expect(text).toContain("[Result]"); + }); +}); From 16ef4debb5034feda2473db8452eae631376417f Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 12 Aug 2026 21:41:54 -0400 Subject: [PATCH 3/4] refactor: implement strict filter enforcement and abort signal handling in web search tool --- src/providers/duckduckgo.ts | 7 +--- src/providers/serper.ts | 4 +- src/tools/web-search.ts | 20 +++++++--- tests/providers/filter-support.test.ts | 18 ++++++++- tests/providers/http-adapter.test.ts | 1 + tests/providers/registry.test.ts | 15 +++++++ tests/tools/web-search.test.ts | 54 ++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/providers/duckduckgo.ts b/src/providers/duckduckgo.ts index 4391dea..76cc9dc 100644 --- a/src/providers/duckduckgo.ts +++ b/src/providers/duckduckgo.ts @@ -4,11 +4,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { ProviderMeta, SearchFilters, SearchProvider, SearchResult } from "./types.ts"; - -const POST_FILTER_DOMAIN_FILTERS = { - domains: "post-filter", - dates: "unsupported", -} as const; import { applyDomainFilters } from "../utils/filters.ts"; import { parseDuckDuckGoResults } from "./parsers.ts"; @@ -32,7 +27,7 @@ const EXEC_TIMEOUT_MS = 15_000; export class DuckDuckGoProvider implements SearchProvider { readonly name = "duckduckgo"; readonly label = "DuckDuckGo"; - readonly filterSupport = POST_FILTER_DOMAIN_FILTERS; + readonly filterSupport = { domains: "post-filter", dates: "unsupported" } as const; private readonly execFile: ExecFileFn; private readonly ddgsOptions: DDGSOptions; diff --git a/src/providers/serper.ts b/src/providers/serper.ts index 599ac7b..d2eead1 100644 --- a/src/providers/serper.ts +++ b/src/providers/serper.ts @@ -3,8 +3,6 @@ import { applyDomainFilters } from "../utils/filters.ts"; import { parseSerperResults } from "./parsers.ts"; import type { ProviderMeta, SearchFilters } from "./types.ts"; -const NATIVE_DOMAIN_DATE_FILTERS = { domains: "native", dates: "native" } as const; - /** Converts "YYYY-MM-DD" to "MM/DD/YYYY" for Google's tbs format. */ function isoToMDY(iso: string): string { const [year, month, day] = iso.split("-"); @@ -44,7 +42,7 @@ export const providerMeta: ProviderMeta = { return body; }, extractResults: parseSerperResults, - filterSupport: NATIVE_DOMAIN_DATE_FILTERS, + filterSupport: { domains: "native", dates: "native" }, }), }), }; diff --git a/src/tools/web-search.ts b/src/tools/web-search.ts index 5add0a8..3b1c12a 100644 --- a/src/tools/web-search.ts +++ b/src/tools/web-search.ts @@ -165,13 +165,21 @@ export function createWebSearchTool( ); if (eligibleCandidates.length === 0 && allCandidates.length > 0) { - const groups = Array.from( - new Set( - allCandidates.flatMap((p) => - unsupportedSearchFilters(filters, p.filterSupport), - ), - ), + const unsupportedGroups = allCandidates.flatMap((provider) => + unsupportedSearchFilters(filters, provider.filterSupport), ); + const groups = (["domains", "dates"] as const).filter((group) => + unsupportedGroups.includes(group), + ); + try { + signal?.throwIfAborted(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Search error: ${msg}` }], + details: { provider: "none", resultCount: 0 }, + }; + } return { content: [ { diff --git a/tests/providers/filter-support.test.ts b/tests/providers/filter-support.test.ts index 51c4a1e..b2d51ae 100644 --- a/tests/providers/filter-support.test.ts +++ b/tests/providers/filter-support.test.ts @@ -49,4 +49,20 @@ describe("search filter support", () => { ), ).toEqual([]); }); -}); \ No newline at end of file + + it("does not match malformed URLs and supports post-filter domains", () => { + expect( + filterResultsByDomains( + [{ title: "Bad", url: "not-a-url", snippet: "bad" }], + { includeDomains: ["example.com"] }, + ), + ).toEqual([]); + + expect( + unsupportedSearchFilters( + { includeDomains: ["example.com"], endDate: "2026-08-12" }, + { domains: "post-filter", dates: "unsupported" }, + ), + ).toEqual(["dates"]); + }); +}); diff --git a/tests/providers/http-adapter.test.ts b/tests/providers/http-adapter.test.ts index 27ea9cc..8f346bf 100644 --- a/tests/providers/http-adapter.test.ts +++ b/tests/providers/http-adapter.test.ts @@ -33,6 +33,7 @@ describe("createHttpSearchProvider", () => { expect(provider.name).toBe("example"); expect(provider.label).toBe("Example"); + expect(provider.filterSupport).toEqual({ domains: "unsupported", dates: "unsupported" }); const results = await provider.search("test query", 5); expect(results).toHaveLength(1); expect(results[0].title).toBe("Test"); diff --git a/tests/providers/registry.test.ts b/tests/providers/registry.test.ts index 0f384b1..773d181 100644 --- a/tests/providers/registry.test.ts +++ b/tests/providers/registry.test.ts @@ -362,6 +362,21 @@ describe("ProviderRegistry capability wrappers", () => { expect(registry.selectSearchCandidates("first")[0].name).toBe("first"); }); + it("preserves search filter support through the budget wrapper", () => { + const { registry } = memory(); + const support = { domains: "post-filter", dates: "unsupported" } as const; + register(registry, "duckduckgo", managed, { + search: { + name: "duckduckgo", + label: "DuckDuckGo", + filterSupport: support, + search: vi.fn().mockResolvedValue([]), + }, + }); + + expect(registry.selectSearchCandidates("duckduckgo")[0].filterSupport).toEqual(support); + }); + it("selects by performance without treating budget use as an outcome", () => { const { registry } = memory(); register(registry, "slow", managed); diff --git a/tests/tools/web-search.test.ts b/tests/tools/web-search.test.ts index ed0c3e4..b3208fe 100644 --- a/tests/tools/web-search.test.ts +++ b/tests/tools/web-search.test.ts @@ -903,4 +903,58 @@ describe("web_search explicit filter support", () => { const text = (result.content[0] as { type: "text"; text: string }).text; expect(text).toContain("[Result]"); }); + + it("reports unsupported filter groups in a stable order", async () => { + const datesOnly: SearchProvider = { + name: "dates-only", + label: "Dates Only", + filterSupport: { domains: "native", dates: "unsupported" }, + search: vi.fn().mockResolvedValue([]), + }; + const domainsOnly: SearchProvider = { + name: "domains-only", + label: "Domains Only", + filterSupport: { domains: "unsupported", dates: "native" }, + search: vi.fn().mockResolvedValue([]), + }; + const tool = createWebSearchTool(() => [datesOnly, domainsOnly]); + const ctx = makeCtx(); + const result = await tool.execute( + "call-uf-order", + { query: "test", includeDomains: ["example.com"], startDate: "2025-01-01" }, + undefined, + undefined, + ctx, + ); + + expect(result.details.unsupportedFilters).toEqual(["domains", "dates"]); + expect((result.content[0] as { type: "text"; text: string }).text).toContain( + "domains, dates", + ); + }); + + it("honors cancellation before returning an unsupported-filter error", async () => { + const controller = new AbortController(); + controller.abort(); + const provider: SearchProvider = { + name: "unsupported", + label: "Unsupported", + filterSupport: UNSUPPORTED_SEARCH_FILTERS, + search: vi.fn().mockResolvedValue([]), + }; + const tool = createWebSearchTool(() => [provider]); + const ctx = makeCtx(); + const result = await tool.execute( + "call-uf-abort", + { query: "test", startDate: "2025-01-01" }, + controller.signal, + undefined, + ctx, + ); + + expect(provider.search).not.toHaveBeenCalled(); + expect((result.content[0] as { type: "text"; text: string }).text.toLowerCase()).toContain( + "aborted", + ); + }); }); From 593fa7bfa10b77900d8cec012f1116f82257bac3 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 12 Aug 2026 21:41:58 -0400 Subject: [PATCH 4/4] docs: update provider filtering logic and rejection behavior in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2715fc8..12175f8 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Provider configuration refreshes automatically every 30 seconds. The dashboard's | WebSearchAPI | Search | `WEBSEARCHAPI_API_KEY` | | You.com | Search | `YOUCOM_API_KEY` | -Pi Tools ranks providers by tier and availability, and optionally by recent session performance. Automatic selection skips exhausted hard budgets. Provider-specific date and domain filters depend on the upstream API; unsupported filters are applied locally where possible. +Pi Tools ranks providers by tier and availability, and optionally by recent session performance. Automatic selection skips exhausted hard budgets. Provider-specific date and domain filters depend on the upstream API; declared post-filters are applied locally where possible, and a request is rejected when no eligible provider can enforce its filters. `openai-codex` uses the active Pi OAuth session. Run `/login` and select `openai-codex` before using it; the provider no longer accepts `OPENAI_API_KEY` as a fallback. To search with `OPENAI_API_KEY` instead, configure `openai-web-search`.