/** * Unified Web Search Tool * * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic * providers with provider-specific parameters exposed conditionally. * */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback, } from "@f5-sales-demo/pi-agent-core"; import { StringEnum } from "@f5-sales-demo/pi-ai"; import { prompt } from "@f5-sales-demo/pi-utils"; import { Type } from "@sinclair/typebox"; import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme } from "../../modes/theme/theme"; import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" }; import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" }; import type { ToolSession } from "../../tools"; import { formatAge } from "../../tools/render-utils"; import { parseWebSearchError } from "./errors"; import { normalizeUserLocation, validateWebSearchParams, type WebSearchParams } from "./params"; import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider"; import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render"; import type { SearchProviderId, SearchResponse } from "./types"; import { SearchProviderError } from "./types"; /** Web search tool parameters schema */ export const webSearchSchema = Type.Object({ query: Type.String({ description: "Search query (non-empty, whitespace-only is rejected)" }), recency: Type.Optional( StringEnum(["day", "week", "month", "year"], { description: "Recency filter. Exhaustive enum — one of: day, week, month, year.", }), ), limit: Type.Optional( Type.Number({ description: "Post-processing cap on the number of sources surfaced in the tool result. Positive integer. Controls output verbosity; see num_search_results for backend fetch count.", }), ), max_tokens: Type.Optional(Type.Number({ description: "Maximum output tokens. Positive integer." })), temperature: Type.Optional(Type.Number({ description: "Sampling temperature between 0 and 2." })), num_search_results: Type.Optional( Type.Number({ description: "Number of sources the backend should fetch. Positive integer. Controls provider fetch count; see limit for output-side trimming.", }), ), allowed_domains: Type.Optional( Type.Array(Type.String(), { description: "Only return results from these domains. Entries must be non-empty strings.", }), ), blocked_domains: Type.Optional( Type.Array(Type.String(), { description: "Exclude results from these domains. Entries must be non-empty strings.", }), ), max_uses: Type.Optional( Type.Number({ description: "Maximum number of web searches per request. Positive integer." }), ), user_location: Type.Optional( Type.Object( { type: Type.Literal("approximate"), city: Type.Optional(Type.String()), region: Type.Optional(Type.String()), country: Type.Optional( Type.String({ minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country code (e.g. US, JP, GB)", }), ), timezone: Type.Optional(Type.String()), }, { description: "Approximate user location for localized results" }, ), ), }); export type SearchToolParams = { query: string; recency?: "day" | "week" | "month" | "year"; limit?: number; /** Maximum output tokens. Defaults to 4096. */ max_tokens?: number; /** Sampling temperature (0–1). Lower = more focused/factual. Defaults to 0.2. */ temperature?: number; /** Number of search results to retrieve. Defaults to 10. */ num_search_results?: number; allowed_domains?: string[]; blocked_domains?: string[]; max_uses?: number; user_location?: { type: "approximate"; city?: string; region?: string; country?: string; timezone?: string; }; }; export interface SearchQueryParams extends SearchToolParams { provider?: SearchProviderId | "auto"; } function formatProviderList(providers: SearchProvider[]): string { return providers.map(provider => provider.label).join(", "); } function formatProviderError(error: unknown, provider: SearchProvider): string { if (error instanceof SearchProviderError) { if (error.provider === "anthropic" && error.status === 404) { return "Anthropic web search returned 404 (model or endpoint not found)."; } if (error.status === 401 || error.status === 403) { if (error.provider === "zai") { return error.message; } return `${getSearchProvider(error.provider).label} authorization failed (${error.status}). Check API key or base URL.`; } return parseWebSearchError(error).userMessage; } if (error instanceof Error) return parseWebSearchError(error).userMessage; return `Unknown error from ${provider.label}`; } /** Truncate text for tool output */ function truncateText(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return `${text.slice(0, Math.max(0, maxLen - 1))}…`; } function formatCount(label: string, count: number): string { return `${count} ${label}${count === 1 ? "" : "s"}`; } /** Format response for LLM consumption */ function formatForLLM(response: SearchResponse): string { const parts: string[] = []; if (response.answer) { parts.push(response.answer); if (response.sources.length > 0) { parts.push("\n## Sources"); parts.push(formatCount("source", response.sources.length)); } } for (const [i, src] of response.sources.entries()) { const age = formatAge(src.ageSeconds) || src.publishedDate; const agePart = age ? ` (${age})` : ""; parts.push(`[${i + 1}] ${src.title}${agePart}\n ${src.url}`); if (src.snippet) { parts.push(` ${truncateText(src.snippet, 240)}`); } } if (response.citations && response.citations.length > 0) { parts.push("\n## Citations"); parts.push(formatCount("citation", response.citations.length)); for (const [i, citation] of response.citations.entries()) { const title = citation.title || citation.url; parts.push(`[${i + 1}] ${title}\n ${citation.url}`); if (citation.citedText) { parts.push(` ${truncateText(citation.citedText, 240)}`); } } } if (response.relatedQuestions && response.relatedQuestions.length > 0) { parts.push("\n## Related"); parts.push(formatCount("question", response.relatedQuestions.length)); for (const q of response.relatedQuestions) { parts.push(`- ${q}`); } } if (response.searchQueries && response.searchQueries.length > 0) { parts.push(`Search queries: ${response.searchQueries.length}`); for (const query of response.searchQueries.slice(0, 3)) { parts.push(`- ${truncateText(query, 120)}`); } } return parts.join("\n"); } /** Execute web search */ async function executeSearch( _toolCallId: string, params: SearchQueryParams, ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> { const validation = validateWebSearchParams(params as WebSearchParams); if (!validation.valid) { const message = `web_search invalid parameter: ${validation.error}`; return { content: [{ type: "text" as const, text: `Error: ${message}` }], details: { response: { provider: "none", sources: [] }, error: message }, }; } const normalizedLocation = normalizeUserLocation(params.user_location); const normalizedParams: SearchQueryParams = normalizedLocation ? { ...params, user_location: normalizedLocation } : params; params = normalizedParams; const hasAnthropicOnlyParams = params.allowed_domains?.length || params.blocked_domains?.length || params.max_uses || params.user_location; const effectiveProvider = hasAnthropicOnlyParams && (!params.provider || params.provider === "auto") ? "anthropic" : params.provider; const providers = effectiveProvider && effectiveProvider !== "auto" ? (await getSearchProvider(effectiveProvider).isAvailable()) ? [getSearchProvider(effectiveProvider)] : await resolveProviderChain("auto") : await resolveProviderChain(); if (providers.length === 0) { const message = "No web search provider configured."; return { content: [{ type: "text" as const, text: `Error: ${message}` }], details: { response: { provider: "none", sources: [] }, error: message }, }; } let lastError: unknown; let lastProvider = providers[0]; for (const provider of providers) { lastProvider = provider; try { const searchStart = performance.now(); const response = await provider.search({ query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL limit: params.limit, recency: params.recency, systemPrompt: webSearchSystemPrompt, maxOutputTokens: params.max_tokens, numSearchResults: params.num_search_results, temperature: params.temperature, allowedDomains: params.allowed_domains, blockedDomains: params.blocked_domains, maxUses: params.max_uses, userLocation: params.user_location, }); response.durationMs = performance.now() - searchStart; const text = formatForLLM(response); return { content: [{ type: "text" as const, text }], details: { response }, }; } catch (error) { lastError = error; } } const baseMessage = formatProviderError(lastError, lastProvider); const message = providers.length > 1 ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}` : baseMessage; return { content: [{ type: "text" as const, text: `Error: ${message}` }], details: { response: { provider: lastProvider.id, sources: [] }, error: message }, }; } /** * Execute a web search query for CLI/testing workflows. */ export async function runSearchQuery( params: SearchQueryParams, ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> { return executeSearch("cli-web-search", params); } /** * Web search tool implementation. * * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, and Synthetic providers with automatic fallback. * Session is accepted for interface consistency but not used. */ export class SearchTool implements AgentTool { readonly name = "web_search"; readonly label = "Web Search"; readonly description: string; readonly parameters = webSearchSchema; readonly strict = true; constructor(_session: ToolSession) { this.description = prompt.render(webSearchDescription); } async execute( _toolCallId: string, params: SearchToolParams, _signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, ): Promise> { return executeSearch(_toolCallId, params); } } /** Web search tool as CustomTool (for TUI rendering support) */ export const webSearchCustomTool: CustomTool = { name: "web_search", label: "Web Search", description: prompt.render(webSearchDescription), parameters: webSearchSchema, async execute( toolCallId: string, params: SearchToolParams, _onUpdate, _ctx: CustomToolContext, _signal?: AbortSignal, ) { return executeSearch(toolCallId, params); }, renderCall(args: SearchToolParams, options: RenderResultOptions, theme: Theme) { return renderSearchCall(args, options, theme); }, renderResult(result, options: RenderResultOptions, theme: Theme) { return renderSearchResult(result, options, theme); }, mergeCallAndResult: true as const, } as CustomTool & { mergeCallAndResult: true }; export function getSearchTools(): CustomTool[] { return [webSearchCustomTool]; } export { getSearchProvider, setPreferredSearchProvider } from "./provider"; export type { SearchProviderId as SearchProvider, SearchResponse } from "./types"; export { isSearchProviderPreference } from "./types";