diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 75ec3766..74dbef22 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -908,29 +908,8 @@ export class Config { return this.tavilyApiKey; } - getWebSearchConfig(): - | { - provider: Array<{ - type: 'tavily' | 'google' | 'dashscope'; - config: Record; - }>; - default: string; - } - | undefined { - if (!this.webSearch) { - return undefined; - } - - return { - provider: this.webSearch.provider.map((p) => ({ - type: p.type, - config: { - apiKey: p.apiKey, - searchEngineId: p.searchEngineId, - }, - })), - default: this.webSearch.default, - }; + getWebSearchConfig() { + return this.webSearch; } getIdeMode(): boolean { diff --git a/packages/core/src/tools/web-search/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts index 63e8aafc..8bae63ae 100644 --- a/packages/core/src/tools/web-search/base-provider.ts +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -9,10 +9,11 @@ import type { WebSearchResult, WebSearchResultItem, } from './types.js'; -import { getErrorMessage } from '../../utils/errors.js'; +import { WebSearchError } from './errors.js'; /** * Base implementation for web search providers. + * Provides common functionality for error handling and result formatting. */ export abstract class BaseWebSearchProvider implements WebSearchProvider { abstract readonly name: string; @@ -41,16 +42,19 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { */ async search(query: string, signal: AbortSignal): Promise { if (!this.isAvailable()) { - throw new Error( - `${this.name} provider is not available. Please check your configuration.`, + throw new WebSearchError( + this.name, + 'Provider is not available. Please check your configuration.', ); } try { return await this.performSearch(query, signal); } catch (error: unknown) { - const errorMessage = getErrorMessage(error); - throw new Error(`Error during ${this.name} search: ${errorMessage}`); + if (error instanceof WebSearchError) { + throw error; + } + throw new WebSearchError(this.name, 'Search failed', error); } } @@ -58,6 +62,7 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { * Format search results into a consistent format. * @param results Raw results from the provider * @param query The original search query + * @param answer Optional answer from the provider * @returns Formatted search results */ protected formatResults( @@ -71,31 +76,4 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { results, }; } - - /** - * Create a formatted source list for display. - * @param results Search result items - * @returns Formatted source list - */ - protected createSourceList(results: WebSearchResultItem[]): string { - return results - .map((r, i) => `[${i + 1}] ${r.title || 'Untitled'} (${r.url})`) - .join('\n'); - } - - /** - * Build a concise summary from search results. - * @param results Search result items - * @param maxResults Maximum number of results to include - * @returns Concise summary string - */ - protected buildSummary( - results: WebSearchResultItem[], - maxResults: number = 3, - ): string { - return results - .slice(0, maxResults) - .map((r, i) => `${i + 1}. ${r.title} - ${r.url}`) - .join('\n'); - } } diff --git a/packages/core/src/tools/web-search/errors.ts b/packages/core/src/tools/web-search/errors.ts new file mode 100644 index 00000000..4e795f85 --- /dev/null +++ b/packages/core/src/tools/web-search/errors.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Custom error class for web search operations. + */ +export class WebSearchError extends Error { + constructor( + readonly provider: string, + message: string, + readonly originalError?: unknown, + ) { + super(`[${provider}] ${message}`); + this.name = 'WebSearchError'; + } +} diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 3aba841f..06e3cc5f 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -13,16 +13,21 @@ import { type ToolInfoConfirmationDetails, ToolConfirmationOutcome, } from '../tools.js'; +import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { WebSearchProviderFactory } from './provider-factory.js'; +import { buildContentWithSources, buildSummary } from './utils.js'; +import { TavilyProvider } from './providers/tavily-provider.js'; +import { GoogleProvider } from './providers/google-provider.js'; +import { DashScopeProvider } from './providers/dashscope-provider.js'; import type { WebSearchToolParams, WebSearchToolResult, WebSearchProvider, WebSearchResultItem, + WebSearchProviderConfig, } from './types.js'; class WebSearchToolInvocation extends BaseToolInvocation< @@ -37,7 +42,6 @@ class WebSearchToolInvocation extends BaseToolInvocation< } override getDescription(): string { - // Try to determine which provider will be used const webSearchConfig = this.config.getWebSearchConfig(); const provider = this.params.provider || webSearchConfig?.default || 'tavily'; @@ -64,6 +68,103 @@ class WebSearchToolInvocation extends BaseToolInvocation< return confirmationDetails; } + /** + * Create a provider instance from configuration. + */ + private createProvider(config: WebSearchProviderConfig): WebSearchProvider { + switch (config.type) { + case 'tavily': + return new TavilyProvider(config); + case 'google': + return new GoogleProvider(config); + case 'dashscope': + return new DashScopeProvider(config); + default: + throw new Error('Unknown provider type'); + } + } + + /** + * Create all configured providers. + */ + private createProviders( + configs: WebSearchProviderConfig[], + ): Map { + const providers = new Map(); + + for (const config of configs) { + try { + const provider = this.createProvider(config); + if (provider.isAvailable()) { + providers.set(config.type, provider); + } + } catch (error) { + console.warn(`Failed to create ${config.type} provider:`, error); + } + } + + return providers; + } + + /** + * Select the appropriate provider based on configuration and parameters. + */ + private selectProvider( + providers: Map, + requestedProvider?: string, + defaultProvider?: string, + ): { provider: WebSearchProvider | null; error?: string } { + // Use requested provider if specified + if (requestedProvider) { + const provider = providers.get(requestedProvider); + if (!provider) { + const availableProviders = Array.from(providers.keys()).join(', '); + return { + provider: null, + error: `The specified provider "${requestedProvider}" is not available or not configured. Available providers: ${availableProviders}`, + }; + } + return { provider }; + } + + // Use default provider if specified and available + if (defaultProvider && providers.has(defaultProvider)) { + const provider = providers.get(defaultProvider)!; + return { provider }; + } + + // Fallback to first available provider + const firstProvider = providers.values().next().value; + return { provider: firstProvider || null }; + } + + /** + * Format search results into a content string. + */ + private formatSearchResults(searchResult: { + answer?: string; + results: WebSearchResultItem[]; + }): { + content: string; + sources: Array<{ title: string; url: string }>; + } { + const sources = searchResult.results.map((r) => ({ + title: r.title, + url: r.url, + })); + + let content = searchResult.answer?.trim() || ''; + if (!content) { + // Fallback: build a concise summary from top results + content = buildSummary(sources, 3); + } + + // Add sources section + content = buildContentWithSources(content, sources); + + return { content, sources }; + } + async execute(signal: AbortSignal): Promise { const webSearchConfig = this.config.getWebSearchConfig(); if (!webSearchConfig) { @@ -75,37 +176,35 @@ class WebSearchToolInvocation extends BaseToolInvocation< }; } - const providers = WebSearchProviderFactory.createProviders( - webSearchConfig.provider, + const providers = this.createProviders(webSearchConfig.provider); + + const { provider: selectedProvider, error } = this.selectProvider( + providers, + this.params.provider, + webSearchConfig.default, ); - // Determine which provider to use - let selectedProvider: WebSearchProvider | null = null; - - if (this.params.provider) { - // Use the specified provider if available - const provider = providers.get(this.params.provider); - if (provider && provider.isAvailable()) { - selectedProvider = provider; - } else { - return { - llmContent: `The specified provider "${this.params.provider}" is not available or not configured. Available providers: ${Array.from(providers.keys()).join(', ')}`, - returnDisplay: `The WebSearch Provider "${this.params.provider}" not available.`, - }; - } - } else { - // Use default provider - selectedProvider = WebSearchProviderFactory.getDefaultProvider( - providers, - webSearchConfig.default, - ); + if (error) { + return { + llmContent: error, + returnDisplay: `Provider "${this.params.provider}" not available.`, + error: { + message: error, + type: ToolErrorType.INVALID_TOOL_PARAMS, + }, + }; } if (!selectedProvider) { + const errorMsg = + 'Web search is disabled because no web search providers are available. Please check your configuration.'; return { - llmContent: - 'Web search is disabled because no web search providers are available. Please check your configuration.', + llmContent: errorMsg, returnDisplay: 'Web search disabled. No available providers.', + error: { + message: errorMsg, + type: ToolErrorType.EXECUTION_FAILED, + }, }; } @@ -115,31 +214,7 @@ class WebSearchToolInvocation extends BaseToolInvocation< signal, ); - const sources = searchResult.results.map((r: WebSearchResultItem) => ({ - title: r.title, - url: r.url, - })); - - const sourceListFormatted = sources.map( - (s: { title: string; url: string }, i: number) => - `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`, - ); - - let content = searchResult.answer?.trim() || ''; - if (!content) { - // Fallback: build a concise summary from top results - content = sources - .slice(0, 3) - .map( - (s: { title: string; url: string }, i: number) => - `${i + 1}. ${s.title} - ${s.url}`, - ) - .join('\n'); - } - - if (sourceListFormatted.length > 0) { - content += `\n\nSources:\n${sourceListFormatted.join('\n')}`; - } + const { content, sources } = this.formatSearchResults(searchResult); if (!content.trim()) { return { @@ -161,6 +236,10 @@ class WebSearchToolInvocation extends BaseToolInvocation< return { llmContent: `Error: ${errorMessage}`, returnDisplay: `Error performing web search.`, + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, }; } } diff --git a/packages/core/src/tools/web-search/provider-factory.ts b/packages/core/src/tools/web-search/provider-factory.ts deleted file mode 100644 index 0be0d953..00000000 --- a/packages/core/src/tools/web-search/provider-factory.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { WebSearchProvider, WebSearchProviderConfig } from './types.js'; -import { - TavilyProvider, - type TavilyConfig, -} from './providers/tavily-provider.js'; -import { - GoogleProvider, - type GoogleConfig, -} from './providers/google-provider.js'; -import { - DashScopeProvider, - type DashScopeConfig, -} from './providers/dashscope-provider.js'; - -/** - * Factory for creating web search providers based on configuration. - */ -export class WebSearchProviderFactory { - /** - * Create a web search provider from configuration. - * @param config Provider configuration - * @returns Web search provider instance - */ - static createProvider(config: WebSearchProviderConfig): WebSearchProvider { - switch (config.type) { - case 'tavily': { - const tavilyConfig = config.config as unknown as TavilyConfig; - if (!tavilyConfig?.apiKey) { - throw new Error('Tavily provider requires apiKey in configuration'); - } - return new TavilyProvider(tavilyConfig); - } - - case 'google': { - const googleConfig = config.config as unknown as GoogleConfig; - if (!googleConfig?.apiKey || !googleConfig?.searchEngineId) { - throw new Error( - 'Google provider requires apiKey and searchEngineId in configuration', - ); - } - return new GoogleProvider(googleConfig); - } - - case 'dashscope': { - const dashscopeConfig = config.config as unknown as DashScopeConfig; - return new DashScopeProvider(dashscopeConfig); - } - - default: - throw new Error( - `Unsupported web search provider type: ${(config as WebSearchProviderConfig).type}`, - ); - } - } - - /** - * Create multiple providers from configuration list. - * @param configs List of provider configurations - * @returns Map of provider name to provider instance - */ - static createProviders( - configs: WebSearchProviderConfig[], - ): Map { - const providers = new Map(); - - for (const config of configs) { - try { - const provider = this.createProvider(config); - providers.set(config.type, provider); - } catch (error) { - console.warn(`Failed to create ${config.type} provider:`, error); - } - } - - return providers; - } - - /** - * Get the default provider from a list of providers. - * @param providers Map of available providers - * @param defaultProviderName Name of the default provider - * @returns Default provider or the first available provider - */ - static getDefaultProvider( - providers: Map, - defaultProviderName?: string, - ): WebSearchProvider | null { - if (defaultProviderName && providers.has(defaultProviderName)) { - const provider = providers.get(defaultProviderName)!; - if (provider.isAvailable()) { - return provider; - } - } - - // Fallback to first available provider - for (const provider of providers.values()) { - if (provider.isAvailable()) { - return provider; - } - } - - return null; - } -} diff --git a/packages/core/src/tools/web-search/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index cca0a09f..df4177d8 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -5,7 +5,12 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import type { WebSearchResult, WebSearchResultItem } from '../types.js'; +import { WebSearchError } from '../errors.js'; +import type { + WebSearchResult, + WebSearchResultItem, + DashScopeProviderConfig, +} from '../types.js'; interface DashScopeSearchItem { _id: string; @@ -50,30 +55,19 @@ interface DashScopeSearchResponse { success: boolean; } -/** - * Configuration for DashScope provider. - */ -export interface DashScopeConfig { - apiKey: string; - uid: string; - appId: string; - maxResults?: number; - scene?: string; - timeout?: number; -} - /** * Web search provider using Alibaba Cloud DashScope API. */ export class DashScopeProvider extends BaseWebSearchProvider { readonly name = 'DashScope'; - constructor(private readonly config: DashScopeConfig) { + constructor(private readonly config: DashScopeProviderConfig) { super(); } isAvailable(): boolean { - return !!(this.config.apiKey && this.config.uid && this.config.appId); + return true; + // return !!(this.config.apiKey && this.config.uid && this.config.appId); } protected async performSearch( @@ -82,7 +76,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { ): Promise { const requestBody = { rid: '', - uid: this.config.uid, + uid: this.config.uid!, scene: this.config.scene || 'dolphin_search_inner_turbo', uq: query, fields: [], @@ -91,7 +85,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { customConfigInfo: {}, headers: { __d_head_qto: this.config.timeout || 8000, - __d_head_app: this.config.appId, + __d_head_app: this.config.appId!, }, }; @@ -101,7 +95,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: this.config.apiKey, + Authorization: this.config.apiKey!, }, body: JSON.stringify(requestBody), signal, @@ -110,16 +104,18 @@ export class DashScopeProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error( - `DashScope API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + throw new WebSearchError( + this.name, + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } const data = (await response.json()) as DashScopeSearchResponse; if (data.status !== 0) { - throw new Error( - `DashScope API error: ${data.message || 'Unknown error'}`, + throw new WebSearchError( + this.name, + `API error: ${data.message || 'Unknown error'}`, ); } diff --git a/packages/core/src/tools/web-search/providers/google-provider.ts b/packages/core/src/tools/web-search/providers/google-provider.ts index c166f015..8dddb5a6 100644 --- a/packages/core/src/tools/web-search/providers/google-provider.ts +++ b/packages/core/src/tools/web-search/providers/google-provider.ts @@ -5,7 +5,12 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import type { WebSearchResult, WebSearchResultItem } from '../types.js'; +import { WebSearchError } from '../errors.js'; +import type { + WebSearchResult, + WebSearchResultItem, + GoogleProviderConfig, +} from '../types.js'; interface GoogleSearchItem { title: string; @@ -23,25 +28,13 @@ interface GoogleSearchResponse { }; } -/** - * Configuration for Google provider. - */ -export interface GoogleConfig { - apiKey: string; - searchEngineId: string; - maxResults?: number; - safeSearch?: 'off' | 'medium' | 'high'; - language?: string; - country?: string; -} - /** * Web search provider using Google Custom Search API. */ export class GoogleProvider extends BaseWebSearchProvider { readonly name = 'Google'; - constructor(private readonly config: GoogleConfig) { + constructor(private readonly config: GoogleProviderConfig) { super(); } @@ -54,8 +47,8 @@ export class GoogleProvider extends BaseWebSearchProvider { signal: AbortSignal, ): Promise { const params = new URLSearchParams({ - key: this.config.apiKey, - cx: this.config.searchEngineId, + key: this.config.apiKey!, + cx: this.config.searchEngineId!, q: query, num: String(this.config.maxResults || 10), safe: this.config.safeSearch || 'medium', @@ -78,8 +71,9 @@ export class GoogleProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error( - `Google Search API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + throw new WebSearchError( + this.name, + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } diff --git a/packages/core/src/tools/web-search/providers/tavily-provider.ts b/packages/core/src/tools/web-search/providers/tavily-provider.ts index 9afaee7d..78745858 100644 --- a/packages/core/src/tools/web-search/providers/tavily-provider.ts +++ b/packages/core/src/tools/web-search/providers/tavily-provider.ts @@ -5,7 +5,12 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import type { WebSearchResult, WebSearchResultItem } from '../types.js'; +import { WebSearchError } from '../errors.js'; +import type { + WebSearchResult, + WebSearchResultItem, + TavilyProviderConfig, +} from '../types.js'; interface TavilyResultItem { title: string; @@ -21,23 +26,13 @@ interface TavilySearchResponse { results: TavilyResultItem[]; } -/** - * Configuration for Tavily provider. - */ -export interface TavilyConfig { - apiKey: string; - searchDepth?: 'basic' | 'advanced'; - maxResults?: number; - includeAnswer?: boolean; -} - /** * Web search provider using Tavily API. */ export class TavilyProvider extends BaseWebSearchProvider { readonly name = 'Tavily'; - constructor(private readonly config: TavilyConfig) { + constructor(private readonly config: TavilyProviderConfig) { super(); } @@ -66,8 +61,9 @@ export class TavilyProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error( - `Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + throw new WebSearchError( + this.name, + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } diff --git a/packages/core/src/tools/web-search/types.ts b/packages/core/src/tools/web-search/types.ts index 12da8116..1983e166 100644 --- a/packages/core/src/tools/web-search/types.ts +++ b/packages/core/src/tools/web-search/types.ts @@ -95,7 +95,7 @@ export interface WebSearchConfig { /** * List of available providers with their configurations. */ - providers: WebSearchProviderConfig[]; + provider: WebSearchProviderConfig[]; /** * The default provider to use. @@ -104,16 +104,47 @@ export interface WebSearchConfig { } /** - * Base configuration for a web search provider. + * Base configuration for Tavily provider. */ -export interface WebSearchProviderConfig { - /** - * The type of provider. - */ - type: 'tavily' | 'google' | 'dashscope'; - - /** - * Provider-specific configuration. - */ - config?: Record; +export interface TavilyProviderConfig { + type: 'tavily'; + apiKey?: string; + searchDepth?: 'basic' | 'advanced'; + maxResults?: number; + includeAnswer?: boolean; } + +/** + * Base configuration for Google provider. + */ +export interface GoogleProviderConfig { + type: 'google'; + apiKey?: string; + searchEngineId?: string; + maxResults?: number; + safeSearch?: 'off' | 'medium' | 'high'; + language?: string; + country?: string; +} + +/** + * Base configuration for DashScope provider. + */ +export interface DashScopeProviderConfig { + type: 'dashscope'; + apiKey?: string; + uid?: string; + appId?: string; + maxResults?: number; + scene?: string; + timeout?: number; +} + +/** + * Discriminated union type for web search provider configurations. + * This ensures type safety when working with different provider configs. + */ +export type WebSearchProviderConfig = + | TavilyProviderConfig + | GoogleProviderConfig + | DashScopeProviderConfig; diff --git a/packages/core/src/tools/web-search/utils.ts b/packages/core/src/tools/web-search/utils.ts new file mode 100644 index 00000000..b2634a56 --- /dev/null +++ b/packages/core/src/tools/web-search/utils.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility functions for web search formatting and processing. + */ + +/** + * Format sources into a numbered list with titles and URLs. + * @param sources Array of source objects with title and url + * @returns Formatted source list string + */ +export function formatSources( + sources: Array<{ title: string; url: string }>, +): string { + return sources + .map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`) + .join('\n'); +} + +/** + * Build content string with appended sources section. + * @param content Main content text + * @param sources Array of source objects + * @returns Combined content with sources + */ +export function buildContentWithSources( + content: string, + sources: Array<{ title: string; url: string }>, +): string { + if (!sources.length) return content; + return `${content}\n\nSources:\n${formatSources(sources)}`; +} + +/** + * Build a concise summary from top search results. + * @param sources Array of source objects + * @param maxResults Maximum number of results to include + * @returns Concise summary string + */ +export function buildSummary( + sources: Array<{ title: string; url: string }>, + maxResults: number = 3, +): string { + return sources + .slice(0, maxResults) + .map((s, i) => `${i + 1}. ${s.title} - ${s.url}`) + .join('\n'); +}