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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
});
}
Expand All @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -338,11 +362,13 @@ function executeSearch(
signal: AbortSignal | undefined,
filters: SearchFilters | undefined,
): Promise<SearchResult[]> {
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,
);
}
```

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SearchFilterMode, "post-filter">;
}

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.
1 change: 1 addition & 0 deletions src/providers/duckduckgo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const EXEC_TIMEOUT_MS = 15_000;
export class DuckDuckGoProvider implements SearchProvider {
readonly name = "duckduckgo";
readonly label = "DuckDuckGo";
readonly filterSupport = { domains: "post-filter", dates: "unsupported" } as const;

private readonly execFile: ExecFileFn;
private readonly ddgsOptions: DDGSOptions;
Expand Down
1 change: 1 addition & 0 deletions src/providers/exa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/providers/firecrawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 10 additions & 1 deletion src/providers/http-adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down
Loading