diff --git a/src/resources/extensions/search-the-web/index.ts b/src/resources/extensions/search-the-web/index.ts index 7527904f2..a3c71b661 100644 --- a/src/resources/extensions/search-the-web/index.ts +++ b/src/resources/extensions/search-the-web/index.ts @@ -1,5 +1,5 @@ /** - * Web Search Extension v3 + * Web Search Extension v4 * * Provides three tools for grounding the agent in real-world web content: * @@ -15,6 +15,15 @@ * Returns pre-extracted, relevance-scored page content. * Best when you need content, not just links. * + * v4: Native Anthropic web search + * - When using an Anthropic provider, injects the native `web_search_20250305` + * server-side tool via `before_provider_request`. This eliminates the need for + * a BRAVE_API_KEY when using Anthropic models — search is billed through the + * existing Anthropic API key ($0.01/search). + * - Custom Brave-based tools (search-the-web, search_and_read) are disabled when + * Anthropic + no BRAVE_API_KEY to avoid confusing the LLM with broken tools. + * - fetch_page (Jina) remains available — it works without a key at lower rate limits. + * * v3 improvements over v2: * - search_and_read: New tool — Brave LLM Context API (search + read in one call) * - Structured error taxonomy: auth_error, rate_limited, network_error, etc. @@ -30,7 +39,8 @@ * - Cache timer cleanup: purge timers use unref() to not block process exit * * Environment variables: - * BRAVE_API_KEY — Required for search. Get one at brave.com/search/api + * BRAVE_API_KEY — Optional with Anthropic models (built-in search available). + * Required for non-Anthropic providers. Get one at brave.com/search/api * JINA_API_KEY — Optional. Higher rate limits for page extraction. */ @@ -39,36 +49,17 @@ import { registerSearchTool } from "./tool-search"; import { registerFetchPageTool } from "./tool-fetch-page"; import { registerLLMContextTool } from "./tool-llm-context"; import { registerSearchProviderCommand } from "./command-search-provider.ts"; +import { registerNativeSearchHooks } from "./native-search"; export default function (pi: ExtensionAPI) { - // Register all tools registerSearchTool(pi); registerFetchPageTool(pi); registerLLMContextTool(pi); + // Register slash commands registerSearchProviderCommand(pi); - // Startup diagnostics - pi.on("session_start", async (_event, ctx) => { - const hasBrave = !!process.env.BRAVE_API_KEY; - const hasTavily = !!process.env.TAVILY_API_KEY; - const hasJina = !!process.env.JINA_API_KEY; - const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY; - - if (!hasBrave && !hasTavily) { - ctx.ui.notify( - "Web search: Set BRAVE_API_KEY or TAVILY_API_KEY for web search capability", - "warning" - ); - } - - const parts: string[] = ["Web search v3"]; - if (hasTavily) parts.push("Tavily ✓"); - if (hasBrave) parts.push("Search ✓"); - if (hasAnswers) parts.push("Answers ✓"); - if (hasJina) parts.push("Jina ✓"); - - ctx.ui.notify(parts.join(" · "), "info"); - }); + // Register native Anthropic web search hooks + registerNativeSearchHooks(pi); } diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts new file mode 100644 index 000000000..73edba843 --- /dev/null +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -0,0 +1,98 @@ +/** + * Native Anthropic web search hook logic. + * + * Extracted from index.ts so it can be unit-tested without importing + * the heavy tool-registration modules. + */ + +/** Tool names for the Brave-backed custom search tools */ +export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"]; + +/** Minimal interface matching the subset of ExtensionAPI we use */ +export interface NativeSearchPI { + on(event: string, handler: (...args: any[]) => any): void; + getActiveTools(): string[]; + setActiveTools(tools: string[]): void; +} + +/** + * Register model_select, before_provider_request, and session_start hooks + * for native Anthropic web search injection. + * + * Returns the isAnthropicProvider getter for testing. + */ +export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: () => boolean } { + let isAnthropicProvider = false; + + // Track provider changes via model selection + pi.on("model_select", async (event: any, _ctx: any) => { + const wasAnthropic = isAnthropicProvider; + isAnthropicProvider = event.model.provider === "anthropic"; + + const hasBrave = !!process.env.BRAVE_API_KEY; + + // When Anthropic + no Brave key: disable custom search tools (they'd fail) + if (isAnthropicProvider && !hasBrave) { + const active = pi.getActiveTools(); + pi.setActiveTools( + active.filter((t: string) => !BRAVE_TOOL_NAMES.includes(t)) + ); + } else if (!isAnthropicProvider && wasAnthropic && !hasBrave) { + // Switching away from Anthropic without Brave — re-enable so the user + // sees the "missing key" error rather than tools silently vanishing + const active = pi.getActiveTools(); + pi.setActiveTools([...active, ...BRAVE_TOOL_NAMES]); + } + }); + + // Inject native web search into Anthropic API requests + pi.on("before_provider_request", (event: any) => { + const payload = event.payload as Record; + if (!payload) return; + + // Detect Anthropic by model name prefix (works even before model_select fires) + const model = payload.model as string | undefined; + if (!model || !model.startsWith("claude")) return; + + // Keep provider tracking in sync + isAnthropicProvider = true; + + if (!Array.isArray(payload.tools)) payload.tools = []; + + // Don't double-inject if already present + const tools = payload.tools as Array>; + if (tools.some((t) => t.type === "web_search_20250305")) return; + + tools.push({ + type: "web_search_20250305", + name: "web_search", + }); + + return payload; + }); + + // Startup diagnostics + pi.on("session_start", async (_event: any, ctx: any) => { + const hasBrave = !!process.env.BRAVE_API_KEY; + const hasJina = !!process.env.JINA_API_KEY; + const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY; + + const parts: string[] = ["Web search v4 loaded"]; + + if (isAnthropicProvider) parts.push("Native search ✓"); + if (hasBrave) parts.push("Brave ✓"); + if (hasAnswers) parts.push("Answers ✓"); + if (hasJina) parts.push("Jina ✓"); + + if (!isAnthropicProvider && !hasBrave) { + ctx.ui.notify( + "Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search", + "warning" + ); + } + + ctx.ui.notify(parts.join(" · "), "info"); + }); + + return { getIsAnthropic: () => isAnthropicProvider }; +}