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
-57
View File
@@ -1,57 +0,0 @@
const EXA_TOOL_ALIASES: Record<string, string> = {
web_search_exa: "exa_web_search",
web_fetch_exa: "exa_web_fetch",
web_search_advanced_exa: "exa_web_search_advanced",
};
export type ExaToolDefinition = {
name: string;
label: string;
[key: string]: unknown;
};
export function mapExaToolName(name: string): string {
return EXA_TOOL_ALIASES[name] ?? name;
}
export function prefixExaTool<T extends ExaToolDefinition>(definition: T): T {
const name = mapExaToolName(definition.name);
if (name === definition.name) return definition;
const displayName = name
.slice("exa_".length)
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return {
...definition,
name,
label: `Exa ${displayName}`,
} as T;
}
type ToolApi = {
registerTool: (tool: unknown) => unknown;
setActiveTools: (names: string[]) => unknown;
unregisterTool?: (name: string) => boolean;
};
export function createExaToolApiOverride<T extends object>(pi: T): T {
const api = pi as T & ToolApi;
return new Proxy(pi, {
get(target, property) {
if (property === "registerTool") {
return (definition: ExaToolDefinition) => api.registerTool(prefixExaTool(definition));
}
if (property === "setActiveTools") {
return (names: string[]) => api.setActiveTools([...new Set(names.map(mapExaToolName))]);
}
if (property === "unregisterTool") {
return api.unregisterTool ? (name: string) => api.unregisterTool?.(mapExaToolName(name)) : undefined;
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as T;
}
-26
View File
@@ -1,26 +0,0 @@
export const EXA_TOOLS = ["web_search_exa", "web_fetch_exa", "web_search_advanced_exa"];
const EXA_MCP_URL = `https://mcp.exa.ai/mcp?tools=${EXA_TOOLS.join(",")}`;
export function createMcpConfig(env: Record<string, string | undefined>) {
const hasExaApiKey = Boolean(env.EXA_API_KEY?.trim());
return {
settings: {
disableProxyTool: true,
scriptMode: false,
},
mcpServers: {
exa: {
url: EXA_MCP_URL,
...(hasExaApiKey ? { headers: { "x-api-key": "$env:EXA_API_KEY" } } : {}),
protocolVersion: "auto" as const,
lifecycle: "eager" as const,
directTools: EXA_TOOLS,
includeTools: EXA_TOOLS,
toolPrefix: "none" as const,
approveTools: false,
exposeResources: false,
},
},
};
}
-12
View File
@@ -1,12 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createMcpAdapter } from "pi-mcp-adapter";
import { createExaToolApiOverride } from "./exa-tool-prefix.ts";
import { createMcpConfig } from "./mcp-config.ts";
import { loadSearchConfigEnv } from "./search-config.ts";
/** Connect Pi to Exa's hosted MCP through the shared adapter instance. CodeGraph is a native tool (see codegraph.ts), not an MCP server. */
export default function mcpExtension(pi: ExtensionAPI): void {
const searchEnvironment = loadSearchConfigEnv();
const mcpAdapter = createMcpAdapter({ config: createMcpConfig(searchEnvironment) });
mcpAdapter(createExaToolApiOverride(pi));
}
-21
View File
@@ -1,21 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import tavilyExtension from "../node_modules/@tavily/pi-extension/index.ts";
import { prefixTavilyTool, type TavilyToolDefinition } from "./tavily-tool-prefix.ts";
/** Load Tavily while making the provider explicit in every registered tool name. */
export default function tavilyOverrideExtension(pi: ExtensionAPI): void {
const prefixedPi = new Proxy(pi, {
get(target, property) {
if (property === "registerTool") {
return (definition: TavilyToolDefinition) =>
(target.registerTool as (tool: unknown) => void)(prefixTavilyTool(definition));
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
tavilyExtension(prefixedPi);
}
-25
View File
@@ -1,25 +0,0 @@
const TAVILY_TOOL_PREFIX = "tavily_";
export type TavilyToolDefinition = {
name: string;
label: string;
description: string;
promptSnippet?: string;
promptGuidelines?: string[];
[key: string]: unknown;
};
function rewriteToolReferences(text: string): string {
return text.replace(/\bweb_search\b/g, "tavily_web_search").replace(/\bweb_fetch\b/g, "tavily_web_fetch");
}
export function prefixTavilyTool<T extends TavilyToolDefinition>(definition: T): T {
return {
...definition,
name: `${TAVILY_TOOL_PREFIX}${definition.name}`,
label: definition.label.startsWith("Tavily ") ? definition.label : `Tavily ${definition.label}`,
description: rewriteToolReferences(definition.description),
promptSnippet: definition.promptSnippet ? rewriteToolReferences(definition.promptSnippet) : undefined,
promptGuidelines: definition.promptGuidelines?.map(rewriteToolReferences),
} as T;
}
+2 -20
View File
@@ -62,27 +62,9 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string {
);
}
const tavilyTools = ["tavily_web_search", "tavily_web_fetch"];
const exaTools = ["exa_web_search", "exa_web_fetch", "exa_web_search_advanced"];
const keenableTools = ["keenable_search", "keenable_fetch"];
if (hasAnyTool(selectedTools, [...tavilyTools, ...exaTools, ...keenableTools])) {
if (hasAnyTool(selectedTools, ["web_search", "web_fetch"])) {
rules.push(
"- Choose one online search provider based on the task instead of querying Tavily, Exa, and Keenable by default; cross-check with another provider only when source quality or uncertainty warrants it.",
);
}
if (hasAnyTool(selectedTools, tavilyTools)) {
rules.push(
"- Use Tavily tools for broad discovery when keywords are uncertain, for news/recent/trending topics, or when many candidate sources are useful. Tavily results can mix official pages, blogs, forums, and secondary sources, so keep max_results modest, avoid raw content unless needed, and fetch only selected pages when depth matters.",
);
}
if (hasAnyTool(selectedTools, exaTools)) {
rules.push(
"- Use Exa tools when source precision matters: official or API documentation, release/version information, academic papers, research material, and high-quality fact confirmation. Use exa_web_fetch to continue reading selected pages; for exa_web_search_advanced, explicitly bound the result count and returned text length to prevent oversized responses.",
);
}
if (hasAnyTool(selectedTools, keenableTools)) {
rules.push(
"- Use Keenable tools for Chinese-language pages, policies and government notices, site-restricted search, and publication-date filtering. Prefer its compact search results and fetch only selected pages to keep returned content controlled; KEENABLE_API_KEY is optional but raises rate limits.",
"- Use web_search for current, real-world, or citable web information; provider selection is handled internally. Keep queries focused and use site/published_* filters only when they materially narrow results. Then read selected pages with web_fetch instead of repeating similar searches.",
);
}