feat(pi-web-search): 统一 web_search/web_fetch 并由主模型路由搜索厂商

7 个按厂商命名的搜索工具收敛为 2 个统一工具,schema 与提示词不再暴露
provider 概念:web_search 由当前主模型按内置规则在 Tavily/Exa/Keenable
间路由(会话内 LRU 缓存,失败按可用性降级),web_fetch 固定走 Exa 并
静默降级。三家均改为纯 fetch 直连——Exa 直接对 mcp.exa.ai 发 JSON-RPC
并缓存会话,彻底移除 pi-mcp-adapter、@tavily/pi-extension 与
@keenable/pi-search 依赖。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-19 20:13:37 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 6fe6cb29ae
commit ee1e6999d7
32 changed files with 1690 additions and 1188 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
# pi-web-search
Unified web search and fetch for Pi. The main model only ever sees two tools —
`web_search` and `web_fetch` — provider selection happens inside the extension.
- `web_search(query, max_results?, site?, published_after?, published_before?, depth?)`
routes each query to Tavily, Exa, or Keenable using the session's current main
model (`ctx.model` + `ctx.modelRegistry.complete`), guided by per-provider
rules. Results include `details.provider` / `details.routedBy` for debugging.
- `web_fetch(urls, max_characters?)` always reads pages via Exa
(`web_fetch_exa`), degrading silently to Keenable then Tavily.
## Providers (`clients/`)
Plain `fetch` calls — no MCP adapter, no upstream extension wrappers:
- `clients/tavily.ts` — `POST api.tavily.com/search|extract`, Bearer key.
Requires `TAVILY_API_KEY`.
- `clients/keenable.ts` — `api.keenable.ai`, keyless `/public` endpoints by
default, `KEENABLE_API_KEY` raises rate limits. Adapted from
`@keenable/pi-search`.
- `clients/exa.ts` — JSON-RPC over `mcp.exa.ai/mcp` (Streamable HTTP):
`initialize` once, cache `mcp-session-id`, `tools/call` per request, re-init
on session expiry. `EXA_API_KEY` optional (`x-api-key` header).
Keys are loaded from `~/.config/my-pi/search.env` by `extensions/search-config.ts`
(written by `search_config.sh`); explicit environment variables win.
## Routing
`router.ts` asks the current model to answer with exactly one provider name,
given a prompt containing the per-provider strengths and the query's requested
filters. Decisions are cached per session (LRU, 32 entries). Failure paths are
deterministic: an unroutable answer or a dead provider falls back across the
remaining available providers — never a heuristic choice over the model.
+243
View File
@@ -0,0 +1,243 @@
/**
* Exa client: direct JSON-RPC over the hosted endpoint https://mcp.exa.ai/mcp.
* This is a plain HTTPS POST client (no MCP adapter, no SDK): initialize once,
* cache the `mcp-session-id` response header, then POST `tools/call` per call.
* Keyless works; EXA_API_KEY is sent as the `x-api-key` header when present.
*/
export class ExaError extends Error {}
const EXA_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa,web_fetch_exa";
const PROTOCOL_VERSION = "2025-03-26";
const SESSION_TIMEOUT_MS = 15_000;
const CALL_TIMEOUT_MS = 60_000;
/** Cap page text per result so advanced search responses stay bounded. */
export const EXA_SEARCH_TEXT_MAX_CHARACTERS = 2000;
export const EXA_FETCH_MAX_CHARACTERS = 3000;
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface ExaSearchParams {
query: string;
numResults?: number;
includeDomains?: string[];
startPublishedDate?: string;
endPublishedDate?: string;
textMaxCharacters?: number;
}
let cachedSessionId: string | undefined;
let pendingSession: Promise<string> | undefined;
let nextRequestId = 1;
/** Clear the cached MCP session (used on expiry and by tests). */
export function resetExaSession(): void {
cachedSessionId = undefined;
}
function baseHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
};
const key = process.env.EXA_API_KEY?.trim();
if (key) headers["x-api-key"] = key;
return headers;
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
/**
* Extract the JSON-RPC message for `id` from a raw JSON body or an SSE stream
* (one or more `data: {...}` lines).
*/
export function parseRpcPayload(bodyText: string, id: number): Record<string, unknown> {
const candidates: string[] = [];
const trimmed = bodyText.trim();
if (trimmed.startsWith("{")) candidates.push(trimmed);
for (const line of trimmed.split(/\r?\n/u)) {
if (line.startsWith("data:")) candidates.push(line.slice(5).trim());
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate) as Record<string, unknown>;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.id === id) {
return parsed;
}
} catch {
// Not JSON — try the next candidate line.
}
}
throw new ExaError("Exa endpoint returned an unreadable response.");
}
async function post(
body: Record<string, unknown>,
sessionId: string | undefined,
timeoutMs: number,
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
): Promise<{ status: number; sessionId?: string; text: string }> {
const headers = baseHeaders();
if (sessionId) headers["mcp-session-id"] = sessionId;
let response: Response;
try {
response = await fetchImpl(EXA_MCP_URL, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: withTimeout(signal, timeoutMs),
});
} catch (error) {
throw new ExaError(`Could not reach the Exa endpoint: ${(error as Error).message}`);
}
return {
status: response.status,
sessionId: response.headers.get("mcp-session-id") ?? undefined,
text: await response.text(),
};
}
async function initialize(signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string> {
const id = nextRequestId++;
const res = await post(
{
jsonrpc: "2.0",
id,
method: "initialize",
params: {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "my-pi-web-search", version: "0.1.0" },
},
},
undefined,
SESSION_TIMEOUT_MS,
signal,
fetchImpl,
);
if (res.status !== 200) throw new ExaError(`Exa initialize failed (HTTP ${res.status}).`);
const sessionId = res.sessionId;
if (!sessionId) throw new ExaError("Exa endpoint did not return an MCP session id.");
const payload = parseRpcPayload(res.text, id);
if (payload.error) throw new ExaError(`Exa initialize error: ${(payload.error as { message?: string }).message ?? "unknown"}`);
const notice = await post(
{ jsonrpc: "2.0", method: "notifications/initialized" },
sessionId,
SESSION_TIMEOUT_MS,
signal,
fetchImpl,
);
if (notice.status >= 400) {
throw new ExaError(`Exa initialized notification failed (HTTP ${notice.status}).`);
}
return sessionId;
}
function sessionId(signal: AbortSignal | undefined, fetchImpl: FetchLike): Promise<string> {
if (cachedSessionId) return Promise.resolve(cachedSessionId);
pendingSession ??= initialize(signal, fetchImpl)
.then((id) => {
cachedSessionId = id;
return id;
})
.finally(() => {
pendingSession = undefined;
});
return pendingSession;
}
function contentText(result: Record<string, unknown>): string {
const content = Array.isArray(result.content) ? (result.content as Record<string, unknown>[]) : [];
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text as string)
.join("\n")
.trim();
}
async function callTool(
toolName: string,
args: Record<string, unknown>,
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
allowRetry: boolean,
): Promise<Record<string, unknown>> {
const sid = await sessionId(signal, fetchImpl);
const id = nextRequestId++;
const res = await post(
{ jsonrpc: "2.0", id, method: "tools/call", params: { name: toolName, arguments: args } },
sid,
CALL_TIMEOUT_MS,
signal,
fetchImpl,
);
if (res.status === 404 && allowRetry) {
// Session expired server-side: drop it, re-initialize, retry once.
cachedSessionId = undefined;
return callTool(toolName, args, signal, fetchImpl, false);
}
if (res.status !== 200) {
throw new ExaError(`Exa ${toolName} failed (HTTP ${res.status}).`);
}
const payload = parseRpcPayload(res.text, id);
if (payload.error) {
const message = (payload.error as { message?: string }).message ?? "unknown";
if (allowRetry && /session/iu.test(message)) {
cachedSessionId = undefined;
return callTool(toolName, args, signal, fetchImpl, false);
}
throw new ExaError(`Exa ${toolName} error: ${message}`);
}
const result = (payload.result ?? {}) as Record<string, unknown>;
if (result.isError) {
throw new ExaError(`Exa ${toolName} failed: ${contentText(result) || "unknown error"}`);
}
return result;
}
/** tools/call web_search_advanced_exa — precise search with bounded text per result. */
export async function exaSearch(
params: ExaSearchParams,
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<string> {
const result = await callTool(
"web_search_advanced_exa",
{
query: params.query,
type: "auto",
numResults: params.numResults ?? 8,
textMaxCharacters: params.textMaxCharacters ?? EXA_SEARCH_TEXT_MAX_CHARACTERS,
...(params.includeDomains?.length ? { includeDomains: params.includeDomains } : {}),
...(params.startPublishedDate ? { startPublishedDate: params.startPublishedDate } : {}),
...(params.endPublishedDate ? { endPublishedDate: params.endPublishedDate } : {}),
},
signal,
fetchImpl,
true,
);
return contentText(result) || "Exa returned no content.";
}
/** tools/call web_fetch_exa — full page content as clean markdown for a batch of URLs. */
export async function exaFetchPages(
urls: string[],
maxCharacters?: number,
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<string> {
const result = await callTool(
"web_fetch_exa",
{ urls, maxCharacters: maxCharacters ?? EXA_FETCH_MAX_CHARACTERS },
signal,
fetchImpl,
true,
);
return contentText(result) || "Exa returned no content.";
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Keenable REST client, adapted from @keenable/pi-search (keenable-client.ts).
* Keyless by default (`/v1/*\/public`), authenticated when KEENABLE_API_KEY is
* present (`/v1/*`), HTTPS-only base URL, `X-Keenable-Title` attribution.
*/
import { isIP } from "node:net";
export class KeenableError extends Error {}
const CLIENT_VERSION = "0.1.0";
const DEFAULT_BASE_URL = "https://api.keenable.ai";
const BASE_URL_ENV = "KEENABLE_API_URL";
const API_KEY_ENV = "KEENABLE_API_KEY";
const ATTRIBUTION_TITLE = "Pi";
const USER_AGENT = `pi-web-search/${CLIENT_VERSION}`;
const DEFAULT_TIMEOUT_MS = 30_000;
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface KeenableSearchParams {
query: string;
mode?: "pro";
site?: string;
published_after?: string;
published_before?: string;
}
export interface KeenableSearchResult {
title?: string;
url?: string;
description?: string;
published_at?: string;
acquired_at?: string;
[key: string]: unknown;
}
export interface KeenableFetchResult {
url?: string;
title?: string;
content?: string;
description?: string;
author?: string;
published_at?: string;
[key: string]: unknown;
}
/** Resolve the API base URL from `KEENABLE_API_URL`, enforcing HTTPS (http only for loopback). */
function resolveBaseUrl(): string {
const base = (process.env[BASE_URL_ENV] || DEFAULT_BASE_URL).replace(/\/+$/u, "");
let parsed: URL;
try {
parsed = new URL(base);
} catch {
throw new KeenableError(`${BASE_URL_ENV} must be a valid URL with a host, got ${base}`);
}
if (parsed.hostname) {
if (parsed.protocol === "https:") return base;
if (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) {
return base;
}
}
throw new KeenableError(`${BASE_URL_ENV} must be an https:// URL with a host, got ${base}`);
}
/** Non-blank API key from the env, or undefined to use the keyless public tier. */
function resolveApiKey(): string | undefined {
const key = (process.env[API_KEY_ENV] ?? "").trim();
return key || undefined;
}
function buildHeaders(apiKey: string | undefined): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": USER_AGENT,
"X-Keenable-Title": ATTRIBUTION_TITLE,
};
if (apiKey) headers["X-API-Key"] = apiKey;
return headers;
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function isPrivateIp(host: string): boolean {
const v = isIP(host);
if (v === 4) {
const p = host.split(".").map(Number);
return (
p[0] === 10 ||
p[0] === 127 ||
p[0] === 0 ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && p[1] === 168) ||
(p[0] === 100 && p[1] >= 64 && p[1] <= 127) ||
(p[0] === 198 && (p[1] === 18 || p[1] === 19)) ||
p[0] >= 224
);
}
if (v === 6) {
const h = host.toLowerCase();
return h === "::1" || h === "::" || h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd");
}
return false;
}
/** Refuse private/internal fetch targets before sending (SSRF). */
function assertPublicFetchTarget(rawUrl: string): void {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new KeenableError(`Not a valid URL: ${rawUrl}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new KeenableError(`Refusing to fetch a non-http(s) URL: ${rawUrl}`);
}
const host = (parsed.hostname || "").toLowerCase();
if (!host) throw new KeenableError(`Refusing to fetch a URL with no host: ${rawUrl}`);
if (host === "localhost" || host === "metadata.google.internal" || isPrivateIp(host)) {
throw new KeenableError(`Refusing to fetch a private/internal host: ${host}`);
}
}
async function raiseForStatus(response: Response, keyed: boolean): Promise<void> {
if (response.ok) return;
if (!keyed && response.status === 429) {
throw new KeenableError(
"Keenable keyless requests hit their rate limit. Set KEENABLE_API_KEY to raise the limits.",
);
}
let detail = "";
try {
const body = (await response.json()) as Record<string, unknown>;
detail = String(body.message ?? body.error ?? body.detail ?? "");
} catch {
detail = (await response.text().catch(() => "")).trim();
}
const label =
{
401: "Keenable authentication failed (401)",
402: "Keenable: insufficient credits (402)",
429: "Keenable rate limit exceeded (429)",
}[response.status] ?? `Keenable API error (${response.status})`;
throw new KeenableError(detail ? `${label}: ${detail}` : label);
}
async function readJsonObject(response: Response): Promise<Record<string, unknown>> {
let data: unknown;
try {
data = await response.json();
} catch {
throw new KeenableError("Keenable API returned a non-JSON response.");
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new KeenableError("Unexpected (non-object) response from the Keenable API.");
}
return data as Record<string, unknown>;
}
/** POST /v1/search (keyed) or /v1/search/public (keyless). */
export async function keenableSearch(
params: KeenableSearchParams,
signal: AbortSignal | undefined,
fetchImpl: FetchLike = fetch,
): Promise<KeenableSearchResult[]> {
const apiKey = resolveApiKey();
const path = apiKey ? "/v1/search" : "/v1/search/public";
const payload: Record<string, unknown> = { query: params.query, mode: params.mode ?? "pro" };
for (const field of ["site", "published_after", "published_before"] as const) {
const value = params[field];
if (value) payload[field] = value;
}
let response: Response;
try {
response = await fetchImpl(`${resolveBaseUrl()}${path}`, {
method: "POST",
headers: { ...buildHeaders(apiKey), "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: withTimeout(signal, DEFAULT_TIMEOUT_MS),
});
} catch (err) {
throw new KeenableError(`Could not reach the Keenable API: ${(err as Error).message}`);
}
await raiseForStatus(response, Boolean(apiKey));
const data = await readJsonObject(response);
const results = data.results;
if (!Array.isArray(results)) {
throw new KeenableError("Unexpected response from the Keenable search API (no results array).");
}
return results as KeenableSearchResult[];
}
/** GET /v1/fetch?url= (keyed) or /v1/fetch/public?url= (keyless). */
export async function keenableFetch(
url: string,
signal: AbortSignal | undefined,
fetchImpl: FetchLike = fetch,
): Promise<KeenableFetchResult> {
assertPublicFetchTarget(url);
const apiKey = resolveApiKey();
const path = apiKey ? "/v1/fetch" : "/v1/fetch/public";
const endpoint = new URL(`${resolveBaseUrl()}${path}`);
endpoint.searchParams.set("url", url);
let response: Response;
try {
response = await fetchImpl(endpoint, {
method: "GET",
headers: buildHeaders(apiKey),
signal: withTimeout(signal, DEFAULT_TIMEOUT_MS),
});
} catch (err) {
throw new KeenableError(`Could not reach the Keenable API: ${(err as Error).message}`);
}
await raiseForStatus(response, Boolean(apiKey));
return (await readJsonObject(response)) as KeenableFetchResult;
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Tavily REST client: POST https://api.tavily.com/search|extract with a Bearer
* key. Requires TAVILY_API_KEY — there is no keyless tier.
*/
export class TavilyError extends Error {}
const TAVILY_BASE_URL = "https://api.tavily.com";
const SEARCH_TIMEOUT_MS = 45_000;
const EXTRACT_TIMEOUT_MS = 60_000;
const CLIENT_SOURCE = "my-pi-web-search";
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
export interface TavilySearchParams {
query: string;
max_results?: number;
search_depth?: "basic" | "advanced";
topic?: "general" | "news" | "finance";
include_domains?: string[];
start_date?: string;
end_date?: string;
}
export interface TavilySearchResult {
title?: string;
url?: string;
content?: string;
published_date?: string;
[key: string]: unknown;
}
export function tavilyAvailable(env: Record<string, string | undefined> = process.env): boolean {
return Boolean(env.TAVILY_API_KEY?.trim());
}
function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function post(
endpoint: string,
body: Record<string, unknown>,
timeoutMs: number,
signal: AbortSignal | undefined,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
const apiKey = process.env.TAVILY_API_KEY?.trim();
if (!apiKey) {
throw new TavilyError("Tavily is not configured: TAVILY_API_KEY is missing.");
}
let response: Response;
try {
response = await fetchImpl(`${TAVILY_BASE_URL}/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-Client-Source": CLIENT_SOURCE,
},
body: JSON.stringify(body),
signal: withTimeout(signal, timeoutMs),
});
} catch (error) {
throw new TavilyError(`Could not reach the Tavily API: ${(error as Error).message}`);
}
if (!response.ok) {
let detail = "";
try {
const data = (await response.json()) as Record<string, unknown>;
detail = String(data.error ?? data.message ?? "");
} catch {
detail = "";
}
throw new TavilyError(`Tavily API error (${response.status})${detail ? `: ${detail}` : ""}`);
}
return (await response.json()) as Record<string, unknown>;
}
/** POST /search — broad discovery; returns the answer plus a compact result list. */
export async function tavilySearch(
params: TavilySearchParams,
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<{ answer?: string; results: TavilySearchResult[] }> {
const payload: Record<string, unknown> = {
query: params.query,
max_results: params.max_results,
search_depth: params.search_depth,
topic: params.topic,
include_domains: params.include_domains,
start_date: params.start_date,
end_date: params.end_date,
};
for (const key of Object.keys(payload)) {
if (payload[key] === undefined) delete payload[key];
}
const data = await post("search", payload, SEARCH_TIMEOUT_MS, signal, fetchImpl);
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
return {
answer: typeof data.answer === "string" ? data.answer : undefined,
results: rawResults.map((result) => ({
title: result.title,
url: result.url,
content: result.content,
published_date: result.publishedDate ?? result.published_date,
})),
};
}
/** POST /extract — full page content as markdown for a batch of URLs. */
export async function tavilyExtract(
urls: string[],
signal?: AbortSignal,
fetchImpl: FetchLike = fetch,
): Promise<{ results: Array<{ url?: string; content?: string }>; failed: string[] }> {
const data = await post("extract", { urls, format: "markdown" }, EXTRACT_TIMEOUT_MS, signal, fetchImpl);
const rawResults = Array.isArray(data.results) ? (data.results as Record<string, unknown>[]) : [];
const rawFailed = Array.isArray(data.failed_results) ? (data.failed_results as Record<string, unknown>[]) : [];
return {
results: rawResults.map((result) => ({ url: result.url as string | undefined, content: result.raw_content as string | undefined })),
failed: rawFailed.map((result) => String(result.url ?? "unknown")),
};
}
+282
View File
@@ -0,0 +1,282 @@
/**
* pi-web-search: one unified web_search + web_fetch surface.
*
* The main model never sees provider names — web_search routes internally via
* the session's current model (see router.ts), and web_fetch always reads
* pages through Exa with graceful degradation. Provider clients are plain
* fetch calls (clients/*) — no MCP adapter, no upstream extension wrappers.
*/
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { loadSearchConfigEnv } from "../extensions/search-config.ts";
import { exaFetchPages, exaSearch } from "./clients/exa.ts";
import { keenableFetch, keenableSearch } from "./clients/keenable.ts";
import { tavilyAvailable, tavilyExtract, tavilySearch } from "./clients/tavily.ts";
import {
availableProviders,
routeSearch,
type SearchProvider,
type RouterContext,
} from "./router.ts";
const DEFAULT_MAX_RESULTS = 8;
const MAX_FETCH_URLS = 10;
const DEFAULT_FETCH_MAX_CHARACTERS = 3000;
export interface WebSearchParams {
query: string;
max_results?: number;
site?: string;
published_after?: string;
published_before?: string;
depth?: "basic" | "advanced";
}
export interface WebFetchParams {
urls: string | string[];
max_characters?: number;
}
export type ProviderRunner = (params: WebSearchParams, signal?: AbortSignal) => Promise<string>;
export type FetchRunner = (urls: string[], maxCharacters: number, signal?: AbortSignal) => Promise<string>;
function formatSearchPayload(payload: unknown): string {
return JSON.stringify(payload, null, 2);
}
export function toTavilyParams(params: WebSearchParams) {
return {
query: params.query,
max_results: params.max_results ?? DEFAULT_MAX_RESULTS,
search_depth: (params.depth ?? "basic") as "basic" | "advanced",
include_domains: params.site ? [params.site] : undefined,
start_date: params.published_after,
end_date: params.published_before,
};
}
export function toExaParams(params: WebSearchParams) {
return {
query: params.query,
numResults: params.max_results ?? DEFAULT_MAX_RESULTS,
includeDomains: params.site ? [params.site] : undefined,
startPublishedDate: params.published_after,
endPublishedDate: params.published_before,
};
}
export function toKeenableParams(params: WebSearchParams) {
return {
query: params.query,
site: params.site,
published_after: params.published_after,
published_before: params.published_before,
};
}
/** Default per-provider parameter mapping from the unified schema. */
export function createProviderRunners(
overrides: Partial<Record<SearchProvider, ProviderRunner>> = {},
): Record<SearchProvider, ProviderRunner> {
return {
tavily:
overrides.tavily ??
(async (params, signal) => formatSearchPayload(await tavilySearch(toTavilyParams(params), signal))),
exa: overrides.exa ?? ((params, signal) => exaSearch(toExaParams(params), signal)),
keenable:
overrides.keenable ??
(async (params, signal) => formatSearchPayload(await keenableSearch(toKeenableParams(params), signal))),
};
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function toResult(text: string, details: Record<string, unknown>): AgentToolResult {
return { content: [{ type: "text", text }], details };
}
/**
* Route once via the main model, then try the picked provider first and fall
* back across the remaining available providers on transport errors.
*/
export async function dispatchSearch(
params: WebSearchParams,
ctx: RouterContext,
routeCache: Map<string, SearchProvider>,
signal?: AbortSignal,
runners: Record<SearchProvider, ProviderRunner> = createProviderRunners(),
): Promise<AgentToolResult> {
const decision = await routeSearch(params, ctx, routeCache, signal);
const order = [decision.provider, ...availableProviders().filter((p) => p !== decision.provider)];
let lastError: unknown;
for (const provider of order) {
try {
const text = await runners[provider](params, signal);
return toResult(text, {
provider,
routedBy: provider === decision.provider ? decision.routedBy : "provider-fallback",
});
} catch (error) {
lastError = error;
}
}
return toResult(`web_search failed on every available provider: ${errorText(lastError)}`, { error: true });
}
export function normalizeUrls(urls: string | string[]): string[] {
const list = (Array.isArray(urls) ? urls : [urls]).map((url) => url.trim()).filter(Boolean);
return [...new Set(list)];
}
/** web_fetch always prefers Exa; Keenable and Tavily are silent degradation paths. */
export function createFetchRunners(
overrides: Partial<Record<SearchProvider, FetchRunner>> = {},
): Record<SearchProvider, FetchRunner> {
return {
exa: overrides.exa ?? ((urls, maxCharacters, signal) => exaFetchPages(urls, maxCharacters, signal)),
keenable:
overrides.keenable ??
(async (urls, _maxCharacters, signal) => {
const pages = await Promise.all(urls.map((url) => keenableFetch(url, signal)));
return pages
.map((page) => `# ${page.title ?? page.url ?? "page"}\n${page.content ?? "(no content)"}`)
.join("\n\n---\n\n");
}),
tavily:
overrides.tavily ??
(async (urls, _maxCharacters, signal) => {
const { results, failed } = await tavilyExtract(urls, signal);
const body = results
.map((page) => `# ${page.url ?? "page"}\n${page.content ?? "(no content)"}`)
.join("\n\n---\n\n");
return failed.length ? `${body}\n\nFailed URLs: ${failed.join(", ")}` : body;
}),
};
}
export async function dispatchFetch(
params: WebFetchParams,
signal?: AbortSignal,
fetchers: Record<SearchProvider, FetchRunner> = createFetchRunners(),
): Promise<AgentToolResult> {
const urls = normalizeUrls(params.urls);
if (!urls.length) {
return toResult("web_fetch needs at least one http(s) URL.", { error: true });
}
if (urls.length > MAX_FETCH_URLS) {
return toResult(`web_fetch accepts at most ${MAX_FETCH_URLS} URLs per call; received ${urls.length}.`, { error: true });
}
const maxCharacters = params.max_characters ?? DEFAULT_FETCH_MAX_CHARACTERS;
const order: SearchProvider[] = ["exa", "keenable"];
if (tavilyAvailable()) order.push("tavily");
let lastError: unknown;
for (const provider of order) {
try {
const text = await fetchers[provider](urls, maxCharacters, signal);
return toResult(text, { provider });
} catch (error) {
lastError = error;
}
}
return toResult(`web_fetch failed on every available provider: ${errorText(lastError)}`, { error: true });
}
export default function webSearchExtension(pi: ExtensionAPI): void {
// Keys may already be loaded by extensions/search-config.ts; this is a
// harmless idempotent safety net for standalone loading.
loadSearchConfigEnv();
const routeCache = new Map<string, SearchProvider>();
pi.registerTool({
name: "web_search",
label: "Web Search",
description:
"Search the web for current or citable information and return a compact list of candidate " +
"pages (title, url, summary). Provider selection is automatic. Follow up with web_fetch to " +
"read the most relevant pages.",
promptSnippet: "Search the web for current or citable information.",
promptGuidelines: [
"Use web_search when the task needs current, real-world, or citable web information.",
"Keep queries focused; split multi-topic requests into separate web_search calls.",
"Use site and published_* filters only when they materially narrow the results.",
"After web_search, read the most relevant pages with web_fetch before relying on a claim.",
],
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query — a natural-language description of the ideal page, not just keywords.",
},
max_results: {
type: "number",
description: `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}).`,
},
site: {
type: "string",
description: "Restrict results to a single domain, e.g. 'arxiv.org'.",
},
published_after: {
type: "string",
description: "Only pages published on/after this date (YYYY-MM-DD).",
},
published_before: {
type: "string",
description: "Only pages published on/before this date (YYYY-MM-DD).",
},
depth: {
type: "string",
enum: ["basic", "advanced"],
description: "'basic' (default) balanced search; 'advanced' for higher precision at higher cost.",
},
},
required: ["query"],
additionalProperties: false,
},
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
return dispatchSearch(params as WebSearchParams, ctx as unknown as RouterContext, routeCache, signal);
},
});
pi.registerTool({
name: "web_fetch",
label: "Web Fetch",
description:
"Fetch the full content of one or more http(s) URLs as clean markdown. Use after web_search " +
"to read selected pages, or directly when the user provides a page to read.",
promptSnippet: "Read the full content of known URLs.",
promptGuidelines: [
"Use web_fetch to read the full content of a URL found via web_search or provided by the user.",
"Batch multiple URLs into a single web_fetch call when possible.",
],
parameters: {
type: "object",
properties: {
urls: {
anyOf: [
{ type: "string", description: "A single http(s) URL to read." },
{
type: "array",
items: { type: "string" },
maxItems: MAX_FETCH_URLS,
description: `A list of http(s) URLs to read (max ${MAX_FETCH_URLS}).`,
},
],
},
max_characters: {
type: "number",
description: `Maximum characters to extract per page (default ${DEFAULT_FETCH_MAX_CHARACTERS}).`,
},
},
required: ["urls"],
additionalProperties: false,
},
async execute(_toolCallId, params, signal) {
return dispatchFetch(params as WebFetchParams, signal);
},
});
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "pi-web-search",
"version": "0.1.0",
"description": "Unified web search and fetch tools with main-model provider routing (Tavily, Exa, Keenable).",
"private": true,
"type": "module",
"license": "MIT",
"main": "./index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"router.ts",
"clients",
"README.md",
"LICENSE"
],
"pi": {
"extensions": [
"./index.ts"
]
},
"scripts": {
"test": "node --test test/*.test.ts"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"typebox": {
"optional": true
}
},
"engines": {
"node": ">=22.19.0"
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Provider router for web_search. The decision is delegated to the session's
* current main model (ctx.model via ctx.modelRegistry.complete) so no extra
* model configuration is needed; deterministic rules only handle availability
* and failure fallbacks, never the routing itself.
*/
export type SearchProvider = "tavily" | "exa" | "keenable";
export interface SearchRequest {
query: string;
site?: string;
published_after?: string;
published_before?: string;
depth?: string;
}
export type RoutedBy = "model" | "cache" | "fallback";
export interface RouteDecision {
provider: SearchProvider;
routedBy: RoutedBy;
}
const ROUTER_TIMEOUT_MS = 15_000;
export const ROUTE_CACHE_LIMIT = 32;
/** Order used only when the model cannot decide or no candidates remain. */
export const PROVIDER_FALLBACK_ORDER: SearchProvider[] = ["tavily", "exa", "keenable"];
const PROVIDER_RULES: Record<SearchProvider, string> = {
tavily:
"Broad discovery. Best when keywords are uncertain, for news and recent or trending topics, " +
"and when many candidate sources are useful. Results can mix official pages, blogs, forums, " +
"and secondary articles; returned content is naturally long.",
exa:
"Precision sources. Best for official or API documentation, release notes and version " +
"information, academic papers and research material, and fact confirmation that needs " +
"high-quality sources.",
keenable:
"Focused and compact. Best for Chinese-language pages (policies, government notices), " +
"site-restricted search, publication-date filtering, and tight control over returned " +
"content length.",
};
/** Tavily requires a key; Exa and Keenable both work keyless. */
export function availableProviders(env: Record<string, string | undefined> = process.env): SearchProvider[] {
const providers: SearchProvider[] = [];
if (env.TAVILY_API_KEY?.trim()) providers.push("tavily");
providers.push("exa", "keenable");
return providers;
}
export function buildRouterPrompt(request: SearchRequest, candidates: SearchProvider[]): string {
const rules = candidates.map((provider) => `- ${provider}: ${PROVIDER_RULES[provider]}`).join("\n");
const lines = [
"Pick the single best web search provider for this query.",
`Answer with exactly one word: ${candidates.join(", ")}.`,
"",
rules,
"",
`Query: ${request.query}`,
];
if (request.site) lines.push(`The caller asked to restrict results to the site: ${request.site}`);
if (request.published_after || request.published_before) {
lines.push(
`The caller asked for a publication-date filter: ${request.published_after ?? "…"} to ${request.published_before ?? "…"}`,
);
}
if (request.depth === "advanced") lines.push("The caller asked for a deep, high-precision search.");
lines.push("Provider:");
return lines.join("\n");
}
/** First candidate-matching provider token in the model's reply, else undefined. */
export function parseProviderChoice(text: string, candidates: SearchProvider[]): SearchProvider | undefined {
const matches = text.toLowerCase().match(/\b(tavily|exa|keenable)\b/gu) ?? [];
for (const match of matches) {
const pick = match as SearchProvider;
if (candidates.includes(pick)) return pick;
}
return undefined;
}
export function routeCacheKey(request: SearchRequest): string {
return [
request.query.trim().toLowerCase(),
request.site ?? "",
request.published_after ?? "",
request.published_before ?? "",
request.depth ?? "",
].join(" ");
}
interface ModelMessage {
stopReason?: string;
errorMessage?: string;
content?: Array<{ type?: string; text?: string }>;
}
/** Minimal structural view of the extension context needed for routing. */
export interface RouterContext {
model?: unknown;
modelRegistry?: {
complete?: (model: unknown, request: unknown, options: unknown) => Promise<ModelMessage>;
};
}
export async function routeSearch(
request: SearchRequest,
ctx: RouterContext,
cache: Map<string, SearchProvider>,
signal?: AbortSignal,
env: Record<string, string | undefined> = process.env,
): Promise<RouteDecision> {
const candidates = availableProviders(env);
const fallback = PROVIDER_FALLBACK_ORDER.find((provider) => candidates.includes(provider)) ?? candidates[0];
if (!fallback || candidates.length <= 1) {
return { provider: fallback ?? "keenable", routedBy: "fallback" };
}
const key = routeCacheKey(request);
const cached = cache.get(key);
if (cached !== undefined && candidates.includes(cached)) {
return { provider: cached, routedBy: "cache" };
}
const complete = ctx.modelRegistry?.complete;
if (!ctx.model || typeof complete !== "function") {
return { provider: fallback, routedBy: "fallback" };
}
try {
const timeout = AbortSignal.timeout(ROUTER_TIMEOUT_MS);
const message = await complete.call(ctx.modelRegistry, ctx.model, {
messages: [
{
role: "user",
content: [{ type: "text", text: buildRouterPrompt(request, candidates) }],
timestamp: Date.now(),
},
],
}, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
if (message.stopReason === "error" || message.stopReason === "aborted") {
throw new Error(message.errorMessage ?? `routing stopped: ${message.stopReason}`);
}
const text = (message.content ?? [])
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n");
const pick = parseProviderChoice(text, candidates);
if (!pick) return { provider: fallback, routedBy: "fallback" };
if (!cache.has(key) && cache.size >= ROUTE_CACHE_LIMIT) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, pick);
return { provider: pick, routedBy: "model" };
} catch {
return { provider: fallback, routedBy: "fallback" };
}
}
+178
View File
@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import webSearchExtension, {
createFetchRunners,
createProviderRunners,
dispatchFetch,
dispatchSearch,
normalizeUrls,
toExaParams,
toKeenableParams,
toTavilyParams,
type WebSearchParams,
} from "../index.ts";
const PARAMS: WebSearchParams = { query: "release notes" };
function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }) {
return {
model: { provider: "test", id: "main" },
modelRegistry: {
complete: async () => {
calls.count += 1;
return { content: [{ type: "text", text: answer }] };
},
},
};
}
function withEnv<T>(key: string, value: string | undefined, fn: () => Promise<T> | T): Promise<T> | T {
const previous = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
const restore = () => {
if (previous === undefined) delete process.env[key];
else process.env[key] = previous;
};
try {
const out = fn();
if (out instanceof Promise) return out.finally(restore);
restore();
return out;
} catch (error) {
restore();
throw error;
}
}
test("extension registers only web_search and web_fetch, with no provider in the schema", () => {
const tools = new Map<string, { parameters?: { properties?: Record<string, unknown> }; description?: string }>();
const pi = {
registerTool(definition: { name: string }) {
tools.set(definition.name, definition as never);
},
on() {},
} as unknown as ExtensionAPI;
webSearchExtension(pi);
assert.deepEqual([...tools.keys()].sort(), ["web_fetch", "web_search"]);
const schemaText = JSON.stringify([...tools.values()].map((t) => t.parameters));
assert.doesNotMatch(schemaText, /tavily|exa|keenable|"provider"/iu);
});
test("dispatchSearch runs the model-picked provider first and records details", async () => {
await withEnv("TAVILY_API_KEY", "k", async () => {
const ran: string[] = [];
const runners = createProviderRunners({
exa: async () => {
ran.push("exa");
return "exa results";
},
});
const result = await dispatchSearch(PARAMS, ctxAnswering("exa"), new Map(), undefined, runners);
assert.deepEqual(ran, ["exa"]);
assert.equal(result.content[0]!.type === "text" && result.content[0]!.text, "exa results");
assert.deepEqual(result.details, { provider: "exa", routedBy: "model" });
});
});
test("dispatchSearch falls back across remaining providers when the pick fails", async () => {
await withEnv("TAVILY_API_KEY", "k", async () => {
const ran: string[] = [];
const runners = createProviderRunners({
tavily: async () => {
ran.push("tavily");
throw new Error("tavily down");
},
exa: async () => {
ran.push("exa");
return "exa results";
},
});
const result = await dispatchSearch(PARAMS, ctxAnswering("tavily"), new Map(), undefined, runners);
assert.deepEqual(ran, ["tavily", "exa"]);
assert.equal((result.details as Record<string, unknown>).provider, "exa");
assert.equal((result.details as Record<string, unknown>).routedBy, "provider-fallback");
});
});
test("dispatchSearch reports an error result when every provider fails", async () => {
await withEnv("TAVILY_API_KEY", "k", async () => {
const runners = createProviderRunners({
tavily: async () => { throw new Error("t"); },
exa: async () => { throw new Error("e"); },
keenable: async () => { throw new Error("k"); },
});
const result = await dispatchSearch(PARAMS, ctxAnswering("exa"), new Map(), undefined, runners);
assert.equal((result.details as Record<string, unknown>).error, true);
assert.match(result.content[0]!.type === "text" ? result.content[0]!.text : "", /failed on every available provider/);
});
});
test("normalizeUrls accepts a string or array, trims, and deduplicates", () => {
assert.deepEqual(normalizeUrls(" https://a.example "), ["https://a.example"]);
assert.deepEqual(normalizeUrls(["https://a", " https://a ", "https://b"]), ["https://a", "https://b"]);
});
test("dispatchFetch prefers exa and degrades to keenable without a model call", async () => {
await withEnv("TAVILY_API_KEY", undefined, async () => {
const fetchers = createFetchRunners({
exa: async (urls) => {
throw new Error(`exa down for ${urls.length}`);
},
keenable: async (urls) => `keenable pages: ${urls.join(",")}`,
});
const result = await dispatchFetch({ urls: ["https://a.example", "https://b.example"] }, undefined, fetchers);
assert.equal((result.details as Record<string, unknown>).provider, "keenable");
assert.match(result.content[0]!.type === "text" ? result.content[0]!.text : "", /keenable pages: https:\/\/a\.example,https:\/\/b\.example/);
});
});
test("dispatchFetch validates input and caps the URL count", async () => {
const empty = await dispatchFetch({ urls: [] }, undefined);
assert.equal((empty.details as Record<string, unknown>).error, true);
const tooMany = await dispatchFetch({ urls: Array.from({ length: 11 }, (_, i) => `https://e${i}.example`) }, undefined);
assert.equal((tooMany.details as Record<string, unknown>).error, true);
assert.match(tooMany.content[0]!.type === "text" ? tooMany.content[0]!.text : "", /at most 10/);
});
test("unified params map onto each provider's native arguments", () => {
const params: WebSearchParams = {
query: "q",
max_results: 4,
site: "a.dev",
published_after: "2025-01-01",
published_before: "2025-06-01",
depth: "advanced",
};
assert.deepEqual(toTavilyParams(params), {
query: "q",
max_results: 4,
search_depth: "advanced",
include_domains: ["a.dev"],
start_date: "2025-01-01",
end_date: "2025-06-01",
});
assert.deepEqual(toExaParams(params), {
query: "q",
numResults: 4,
includeDomains: ["a.dev"],
startPublishedDate: "2025-01-01",
endPublishedDate: "2025-06-01",
});
assert.deepEqual(toKeenableParams(params), {
query: "q",
site: "a.dev",
published_after: "2025-01-01",
published_before: "2025-06-01",
});
const defaults = toTavilyParams({ query: "q" });
assert.equal(defaults.max_results, 8);
assert.equal(defaults.search_depth, "basic");
assert.equal(defaults.include_domains, undefined);
});
+140
View File
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ExaError, exaFetchPages, exaSearch, parseRpcPayload, resetExaSession } from "../clients/exa.ts";
function sse(payload: Record<string, unknown>): string {
return `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
}
interface RecordedCall {
method?: string;
params?: Record<string, unknown>;
headers: Record<string, string>;
}
/**
* A fetch stub that speaks the Exa endpoint protocol: initialize answers with
* the scripted session id and the request's own id, notifications get a bare
* 202, and tools/call delegates to the supplied handler (default 404).
*/
function exaStub(options: {
sessionIds?: string[];
onToolCall?: (args: Record<string, unknown>, id: number, session: string | undefined) => Response;
} = {}) {
const calls: RecordedCall[] = [];
let sessionIndex = 0;
const fetchImpl = async (_url: string | URL, init?: RequestInit): Promise<Response> => {
const headers = (init?.headers ?? {}) as Record<string, string>;
const body = JSON.parse(String(init?.body)) as { id?: number; method?: string; params?: Record<string, unknown> };
calls.push({ method: body.method, params: body.params, headers });
if (body.method === "initialize") {
const sid = options.sessionIds?.[Math.min(sessionIndex++, (options.sessionIds?.length ?? 1) - 1)] ?? `sid-${sessionIndex}`;
return new Response(
sse({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-03-26", capabilities: {}, serverInfo: { name: "exa" } } }),
{ status: 200, headers: { "mcp-session-id": sid, "content-type": "text/event-stream" } },
);
}
if (body.method === "notifications/initialized") return new Response("", { status: 202 });
if (body.method === "tools/call") {
return options.onToolCall?.(body.params ?? {}, body.id ?? 0, headers["mcp-session-id"]) ?? new Response("Not Found", { status: 404 });
}
return new Response("unexpected", { status: 500 });
};
return { fetchImpl, calls };
}
function okToolResult(id: number, text: string): Response {
return new Response(
sse({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }] } }),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}
test("parseRpcPayload reads JSON bodies and SSE data lines, matching the request id", () => {
const direct = JSON.stringify({ jsonrpc: "2.0", id: 7, result: { ok: true } });
assert.deepEqual(parseRpcPayload(direct, 7).result, { ok: true });
const body =
`event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"a":1}}\n\n` +
`event: message\ndata: {"jsonrpc":"2.0","id":2,"result":{"b":2}}\n\n`;
assert.deepEqual(parseRpcPayload(body, 2).result, { b: 2 });
assert.throws(() => parseRpcPayload("not json at all", 1), ExaError);
assert.throws(() => parseRpcPayload(sse({ jsonrpc: "2.0", id: 99, result: {} }), 1), ExaError);
});
test("exaSearch initializes a session, notifies, then posts tools/call with the session id", async () => {
resetExaSession();
const { fetchImpl, calls } = exaStub({
sessionIds: ["sid-A"],
onToolCall: (_args, id) => okToolResult(id, "search results"),
});
const text = await exaSearch({ query: "docs", numResults: 3, includeDomains: ["python.org"] }, undefined, fetchImpl);
assert.equal(text, "search results");
assert.deepEqual(calls.map((c) => c.method), ["initialize", "notifications/initialized", "tools/call"]);
const call = calls[2]!;
assert.equal(call.headers["mcp-session-id"], "sid-A");
assert.equal((call.params as { name: string }).name, "web_search_advanced_exa");
const args = (call.params as { arguments: Record<string, unknown> }).arguments;
assert.equal(args.numResults, 3);
assert.equal(args.type, "auto");
assert.deepEqual(args.includeDomains, ["python.org"]);
assert.ok((args.textMaxCharacters as number) > 0);
});
test("exaSearch reuses the cached session across calls", async () => {
resetExaSession();
const { fetchImpl, calls } = exaStub({
sessionIds: ["sid-1"],
onToolCall: (args, id) => okToolResult(id, `result for ${(args as { name: string }).name}`),
});
await exaSearch({ query: "a" }, undefined, fetchImpl);
const text = await exaFetchPages(["https://example.com"], 500, undefined, fetchImpl);
assert.match(text, /web_fetch_exa/);
assert.equal(calls.length, 4);
assert.equal((calls[3]!.params as { name: string }).name, "web_fetch_exa");
assert.equal(calls[3]!.headers["mcp-session-id"], "sid-1");
assert.equal((calls[3]!.params as { arguments: { maxCharacters: number } }).arguments.maxCharacters, 500);
});
test("a 404 from tools/call drops the session, re-initializes, and retries once", async () => {
resetExaSession();
let toolCalls = 0;
const { fetchImpl, calls } = exaStub({
sessionIds: ["stale-sid", "fresh-sid"],
onToolCall: (_args, id) => {
toolCalls += 1;
return toolCalls === 1 ? new Response("Not Found", { status: 404 }) : okToolResult(id, "after re-init");
},
});
const text = await exaSearch({ query: "retry me" }, undefined, fetchImpl);
assert.equal(text, "after re-init");
assert.deepEqual(calls.map((c) => c.method), [
"initialize",
"notifications/initialized",
"tools/call",
"initialize",
"notifications/initialized",
"tools/call",
]);
assert.equal(calls[5]!.headers["mcp-session-id"], "fresh-sid");
});
test("isError tool results and HTTP failures raise ExaError", async () => {
resetExaSession();
const isError = exaStub({
onToolCall: (_args, id) =>
new Response(sse({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: "bad args" }] } }), { status: 200 }),
});
await assert.rejects(exaFetchPages(["https://example.com"], undefined, undefined, isError.fetchImpl), /bad args/);
resetExaSession();
const httpFail = exaStub({ onToolCall: () => new Response("boom", { status: 503 }) });
// Session init succeeds; tools/call hits a non-404 HTTP error.
await assert.rejects(exaSearch({ query: "x" }, undefined, httpFail.fetchImpl), /HTTP 503/);
});
+109
View File
@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
availableProviders,
buildRouterPrompt,
parseProviderChoice,
routeSearch,
ROUTE_CACHE_LIMIT,
type RouterContext,
type SearchProvider,
} from "../router.ts";
const ALL: SearchProvider[] = ["tavily", "exa", "keenable"];
const ENV_ALL_KEYS = { TAVILY_API_KEY: "test-key" };
function ctxAnswering(answer: string, calls: { count: number } = { count: 0 }): RouterContext {
return {
model: { provider: "test", id: "main" },
modelRegistry: {
complete: async () => {
calls.count += 1;
return { content: [{ type: "text", text: answer }] };
},
},
};
}
test("availability: tavily needs a key, exa and keenable are keyless", () => {
assert.deepEqual(availableProviders({}), ["exa", "keenable"]);
assert.deepEqual(availableProviders(ENV_ALL_KEYS), ["tavily", "exa", "keenable"]);
});
test("router prompt lists only candidate rules plus the query and caller hints", () => {
const prompt = buildRouterPrompt(
{ query: "python 3.14 release notes", site: "python.org", published_after: "2025-10-01" },
ALL,
);
assert.match(prompt, /- tavily: /);
assert.match(prompt, /- exa: /);
assert.match(prompt, /- keenable: /);
assert.match(prompt, /Query: python 3\.14 release notes/);
assert.match(prompt, /site filter.*python\.org|site: python\.org/);
assert.match(prompt, /Provider:$/);
const subset = buildRouterPrompt({ query: "hi" }, ["exa", "keenable"]);
assert.doesNotMatch(subset, /- tavily/);
assert.match(subset, /exactly one word: exa, keenable/);
});
test("parseProviderChoice takes the first available candidate token", () => {
assert.equal(parseProviderChoice("tavily", ALL), "tavily");
assert.equal(parseProviderChoice("I would pick Exa here.", ALL), "exa");
assert.equal(parseProviderChoice("keenable!", ALL), "keenable");
assert.equal(parseProviderChoice("google maybe", ALL), undefined);
// Model picked an unavailable provider but mentioned a valid one later.
assert.equal(parseProviderChoice("tavily or keenable", ["exa", "keenable"]), "keenable");
assert.equal(parseProviderChoice("tavily", ["exa", "keenable"]), undefined);
});
test("routeSearch honors the model pick and caches identical requests", async () => {
const calls = { count: 0 };
const ctx = ctxAnswering("exa", calls);
const cache = new Map<string, SearchProvider>();
const request = { query: "official MCP spec" };
const first = await routeSearch(request, ctx, cache, undefined, ENV_ALL_KEYS);
assert.deepEqual(first, { provider: "exa", routedBy: "model" });
const second = await routeSearch(request, ctx, cache, undefined, ENV_ALL_KEYS);
assert.deepEqual(second, { provider: "exa", routedBy: "cache" });
assert.equal(calls.count, 1);
});
test("routeSearch falls back to the first available provider on unroutable or failed model calls", async () => {
const cache = new Map<string, SearchProvider>();
const unparseable = await routeSearch({ query: "x" }, ctxAnswering("no idea"), cache, undefined, ENV_ALL_KEYS);
assert.deepEqual(unparseable, { provider: "tavily", routedBy: "fallback" });
const throwing: RouterContext = {
model: {},
modelRegistry: { complete: async () => { throw new Error("boom"); } },
};
const failed = await routeSearch({ query: "x" }, throwing, cache, undefined, ENV_ALL_KEYS);
assert.deepEqual(failed, { provider: "tavily", routedBy: "fallback" });
});
test("routeSearch without a tavily key excludes it from candidates", async () => {
const cache = new Map<string, SearchProvider>();
const decision = await routeSearch({ query: "x" }, ctxAnswering("tavily"), cache, undefined, {});
// "tavily" is not a candidate → unroutable → first available fallback.
assert.deepEqual(decision, { provider: "exa", routedBy: "fallback" });
});
test("routeSearch works without a model", async () => {
const cache = new Map<string, SearchProvider>();
const decision = await routeSearch({ query: "x" }, {}, cache, undefined, ENV_ALL_KEYS);
assert.deepEqual(decision, { provider: "tavily", routedBy: "fallback" });
});
test("route cache evicts the oldest entry at the limit", async () => {
const cache = new Map<string, SearchProvider>();
for (let i = 0; i < ROUTE_CACHE_LIMIT; i++) cache.set(`dummy-${i}`, "exa");
const decision = await routeSearch({ query: "fresh query" }, ctxAnswering("keenable"), cache, undefined, ENV_ALL_KEYS);
assert.equal(decision.provider, "keenable");
assert.equal(cache.size, ROUTE_CACHE_LIMIT);
assert.equal(cache.has("dummy-0"), false);
assert.equal(cache.has("dummy-1"), true);
});