From f9f6eb52dd3244edf15e85131a2d2556c0092f15 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Fri, 24 Oct 2025 17:16:14 +0800 Subject: [PATCH 01/14] feat: add multi websearch provider --- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 19 ++ packages/core/src/config/config.ts | 52 +++- packages/core/src/index.ts | 2 +- .../src/tools/web-search/base-provider.ts | 101 ++++++++ packages/core/src/tools/web-search/index.ts | 235 ++++++++++++++++++ .../src/tools/web-search/provider-factory.ts | 110 ++++++++ .../providers/dashscope-provider.ts | 138 ++++++++++ .../web-search/providers/google-provider.ts | 96 +++++++ .../web-search/providers/tavily-provider.ts | 86 +++++++ packages/core/src/tools/web-search/types.ts | 119 +++++++++ 11 files changed, 955 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tools/web-search/base-provider.ts create mode 100644 packages/core/src/tools/web-search/index.ts create mode 100644 packages/core/src/tools/web-search/provider-factory.ts create mode 100644 packages/core/src/tools/web-search/providers/dashscope-provider.ts create mode 100644 packages/core/src/tools/web-search/providers/google-provider.ts create mode 100644 packages/core/src/tools/web-search/providers/tavily-provider.ts create mode 100644 packages/core/src/tools/web-search/types.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7296ff43..9655a1ec 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -771,6 +771,7 @@ export async function loadCliConfig( output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, + webSearch: settings.webSearch, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 34ebe4b0..4265cc5c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1055,6 +1055,25 @@ const SETTINGS_SCHEMA = { }, }, + webSearch: { + type: 'object', + label: 'Web Search', + category: 'Advanced', + requiresRestart: true, + default: undefined as + | { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + } + | undefined, + description: 'Configuration for web search providers.', + showInDialog: false, + }, + experimental: { type: 'object', label: 'Experimental', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4bc2b902..75ec3766 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -56,7 +56,7 @@ import { TaskTool } from '../tools/task.js'; import { TodoWriteTool } from '../tools/todoWrite.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { WebFetchTool } from '../tools/web-fetch.js'; -import { WebSearchTool } from '../tools/web-search.js'; +import { WebSearchTool } from '../tools/web-search/index.js'; import { WriteFileTool } from '../tools/write-file.js'; // Other modules @@ -261,6 +261,14 @@ export interface ConfigParameters { loadMemoryFromIncludeDirectories?: boolean; // Web search providers tavilyApiKey?: string; + webSearch?: { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + }; chatCompression?: ChatCompressionSettings; interactive?: boolean; trustedFolder?: boolean; @@ -349,6 +357,14 @@ export class Config { private readonly experimentalZedIntegration: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly tavilyApiKey?: string; + private readonly webSearch?: { + provider: Array<{ + type: 'tavily' | 'google' | 'dashscope'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + }; private readonly chatCompression: ChatCompressionSettings | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; @@ -454,6 +470,7 @@ export class Config { // Web search this.tavilyApiKey = params.tavilyApiKey; + this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; @@ -891,6 +908,31 @@ 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, + }; + } + getIdeMode(): boolean { return this.ideMode; } @@ -1118,8 +1160,12 @@ export class Config { registerCoreTool(TodoWriteTool, this); registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); - // Conditionally register web search tool only if Tavily API key is set - if (this.getTavilyApiKey()) { + // Conditionally register web search tool if any web search provider is configured + // or if using qwen-oauth authentication + if ( + this.getWebSearchConfig() || + this.getAuthType() === AuthType.QWEN_OAUTH + ) { registerCoreTool(WebSearchTool, this); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38787dc1..8a538bfe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,7 +97,7 @@ export * from './tools/write-file.js'; export * from './tools/web-fetch.js'; export * from './tools/memoryTool.js'; export * from './tools/shell.js'; -export * from './tools/web-search.js'; +export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; diff --git a/packages/core/src/tools/web-search/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts new file mode 100644 index 00000000..63e8aafc --- /dev/null +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + WebSearchProvider, + WebSearchResult, + WebSearchResultItem, +} from './types.js'; +import { getErrorMessage } from '../../utils/errors.js'; + +/** + * Base implementation for web search providers. + */ +export abstract class BaseWebSearchProvider implements WebSearchProvider { + abstract readonly name: string; + + /** + * Check if the provider is available (has required configuration). + */ + abstract isAvailable(): boolean; + + /** + * Perform the actual search implementation. + * @param query The search query + * @param signal Abort signal for cancellation + * @returns Promise resolving to search results + */ + protected abstract performSearch( + query: string, + signal: AbortSignal, + ): Promise; + + /** + * Execute a web search with error handling. + * @param query The search query + * @param signal Abort signal for cancellation + * @returns Promise resolving to search results + */ + async search(query: string, signal: AbortSignal): Promise { + if (!this.isAvailable()) { + throw new Error( + `${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}`); + } + } + + /** + * Format search results into a consistent format. + * @param results Raw results from the provider + * @param query The original search query + * @returns Formatted search results + */ + protected formatResults( + results: WebSearchResultItem[], + query: string, + answer?: string, + ): WebSearchResult { + return { + query, + answer, + 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/index.ts b/packages/core/src/tools/web-search/index.ts new file mode 100644 index 00000000..3aba841f --- /dev/null +++ b/packages/core/src/tools/web-search/index.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolCallConfirmationDetails, + type ToolInfoConfirmationDetails, + ToolConfirmationOutcome, +} from '../tools.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 type { + WebSearchToolParams, + WebSearchToolResult, + WebSearchProvider, + WebSearchResultItem, +} from './types.js'; + +class WebSearchToolInvocation extends BaseToolInvocation< + WebSearchToolParams, + WebSearchToolResult +> { + constructor( + private readonly config: Config, + params: WebSearchToolParams, + ) { + super(params); + } + + override getDescription(): string { + // Try to determine which provider will be used + const webSearchConfig = this.config.getWebSearchConfig(); + const provider = + this.params.provider || webSearchConfig?.default || 'tavily'; + return ` (Searching the web via ${provider})`; + } + + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { + return false; + } + + const confirmationDetails: ToolInfoConfirmationDetails = { + type: 'info', + title: 'Confirm Web Search', + prompt: `Search the web for: "${this.params.query}"`, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); + } + }, + }; + return confirmationDetails; + } + + async execute(signal: AbortSignal): Promise { + const webSearchConfig = this.config.getWebSearchConfig(); + if (!webSearchConfig) { + return { + llmContent: + 'Web search is disabled because no web search configuration is available. Please configure web search providers in your settings.json.', + returnDisplay: + 'Web search disabled. Configure web search providers to enable search.', + }; + } + + const providers = WebSearchProviderFactory.createProviders( + webSearchConfig.provider, + ); + + // 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 (!selectedProvider) { + return { + llmContent: + 'Web search is disabled because no web search providers are available. Please check your configuration.', + returnDisplay: 'Web search disabled. No available providers.', + }; + } + + try { + const searchResult = await selectedProvider.search( + this.params.query, + 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')}`; + } + + if (!content.trim()) { + return { + llmContent: `No search results or information found for query: "${this.params.query}" (searched via ${selectedProvider.name})`, + returnDisplay: `No information found for "${this.params.query}".`, + }; + } + + return { + llmContent: `Web search results for "${this.params.query}" (via ${selectedProvider.name}):\n\n${content}`, + returnDisplay: `Search results for "${this.params.query}".`, + sources, + }; + } catch (error: unknown) { + const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage( + error, + )}`; + console.error(errorMessage, error); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error performing web search.`, + }; + } + } +} + +/** + * A tool to perform web searches using configurable providers. + */ +export class WebSearchTool extends BaseDeclarativeTool< + WebSearchToolParams, + WebSearchToolResult +> { + static readonly Name: string = 'web_search'; + + constructor(private readonly config: Config) { + super( + WebSearchTool.Name, + 'WebSearch', + 'Performs a web search using configurable providers and returns a concise answer with sources.', + Kind.Search, + { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query to find information on the web.', + }, + provider: { + type: 'string', + description: + 'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). If not specified, the default provider will be used.', + }, + }, + required: ['query'], + }, + ); + } + + /** + * Validates the parameters for the WebSearchTool. + * @param params The parameters to validate + * @returns An error message string if validation fails, null if valid + */ + protected override validateToolParamValues( + params: WebSearchToolParams, + ): string | null { + if (!params.query || params.query.trim() === '') { + return "The 'query' parameter cannot be empty."; + } + + // Validate provider parameter if provided + if (params.provider !== undefined && params.provider.trim() === '') { + return "The 'provider' parameter cannot be empty if specified."; + } + + return null; + } + + protected createInvocation( + params: WebSearchToolParams, + ): ToolInvocation { + return new WebSearchToolInvocation(this.config, params); + } +} + +// Re-export types for external use +export type { + WebSearchToolParams, + WebSearchToolResult, + WebSearchConfig, + WebSearchProviderConfig, +} from './types.js'; diff --git a/packages/core/src/tools/web-search/provider-factory.ts b/packages/core/src/tools/web-search/provider-factory.ts new file mode 100644 index 00000000..0be0d953 --- /dev/null +++ b/packages/core/src/tools/web-search/provider-factory.ts @@ -0,0 +1,110 @@ +/** + * @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 new file mode 100644 index 00000000..cca0a09f --- /dev/null +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { WebSearchResult, WebSearchResultItem } from '../types.js'; + +interface DashScopeSearchItem { + _id: string; + snippet: string; + title: string; + url: string; + timestamp: number; + timestamp_format: string; + hostname: string; + hostlogo?: string; + web_main_body?: string; + _score?: number; +} + +interface DashScopeSearchResponse { + headers: Record; + rid: string; + status: number; + message: string | null; + data: { + total: number; + totalDistinct: number; + docs: DashScopeSearchItem[]; + keywords?: string[]; + qpInfos?: Array<{ + query: string; + cleanQuery: string; + sensitive: boolean; + spellchecked: string; + spellcheck: boolean; + tokenized: string[]; + stopWords: string[]; + synonymWords: string[]; + recognitions: unknown[]; + rewrite: string; + operator: string; + }>; + aggs?: unknown; + extras?: Record; + }; + debug?: unknown; + 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) { + super(); + } + + isAvailable(): boolean { + return !!(this.config.apiKey && this.config.uid && this.config.appId); + } + + protected async performSearch( + query: string, + signal: AbortSignal, + ): Promise { + const requestBody = { + rid: '', + uid: this.config.uid, + scene: this.config.scene || 'dolphin_search_inner_turbo', + uq: query, + fields: [], + page: 1, + rows: this.config.maxResults || 10, + customConfigInfo: {}, + headers: { + __d_head_qto: this.config.timeout || 8000, + __d_head_app: this.config.appId, + }, + }; + + const response = await fetch( + 'https://dashscope.aliyuncs.com/api/v1/indices/plugin/web_search', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.config.apiKey, + }, + body: JSON.stringify(requestBody), + signal, + }, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `DashScope 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'}`, + ); + } + + const results: WebSearchResultItem[] = (data.data?.docs || []).map( + (item) => ({ + title: item.title, + url: item.url, + content: item.snippet, + score: item._score, + publishedDate: item.timestamp_format, + }), + ); + + return this.formatResults(results, query, undefined); + } +} diff --git a/packages/core/src/tools/web-search/providers/google-provider.ts b/packages/core/src/tools/web-search/providers/google-provider.ts new file mode 100644 index 00000000..c166f015 --- /dev/null +++ b/packages/core/src/tools/web-search/providers/google-provider.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { WebSearchResult, WebSearchResultItem } from '../types.js'; + +interface GoogleSearchItem { + title: string; + link: string; + snippet?: string; + displayLink?: string; + formattedUrl?: string; +} + +interface GoogleSearchResponse { + items?: GoogleSearchItem[]; + searchInformation?: { + totalResults?: string; + searchTime?: number; + }; +} + +/** + * 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) { + super(); + } + + isAvailable(): boolean { + return !!(this.config.apiKey && this.config.searchEngineId); + } + + protected async performSearch( + query: string, + signal: AbortSignal, + ): Promise { + const params = new URLSearchParams({ + key: this.config.apiKey, + cx: this.config.searchEngineId, + q: query, + num: String(this.config.maxResults || 10), + safe: this.config.safeSearch || 'medium', + }); + + if (this.config.language) { + params.append('lr', `lang_${this.config.language}`); + } + + if (this.config.country) { + params.append('cr', `country${this.config.country}`); + } + + const url = `https://www.googleapis.com/customsearch/v1?${params.toString()}`; + + const response = await fetch(url, { + method: 'GET', + signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `Google Search API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + ); + } + + const data = (await response.json()) as GoogleSearchResponse; + + const results: WebSearchResultItem[] = (data.items || []).map((item) => ({ + title: item.title, + url: item.link, + content: item.snippet, + })); + + return this.formatResults(results, query, undefined); + } +} diff --git a/packages/core/src/tools/web-search/providers/tavily-provider.ts b/packages/core/src/tools/web-search/providers/tavily-provider.ts new file mode 100644 index 00000000..9afaee7d --- /dev/null +++ b/packages/core/src/tools/web-search/providers/tavily-provider.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { WebSearchResult, WebSearchResultItem } from '../types.js'; + +interface TavilyResultItem { + title: string; + url: string; + content?: string; + score?: number; + published_date?: string; +} + +interface TavilySearchResponse { + query: string; + answer?: string; + 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) { + super(); + } + + isAvailable(): boolean { + return !!this.config.apiKey; + } + + protected async performSearch( + query: string, + signal: AbortSignal, + ): Promise { + const response = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key: this.config.apiKey, + query, + search_depth: this.config.searchDepth || 'advanced', + max_results: this.config.maxResults || 5, + include_answer: this.config.includeAnswer !== false, + }), + signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + ); + } + + const data = (await response.json()) as TavilySearchResponse; + + const results: WebSearchResultItem[] = (data.results || []).map((r) => ({ + title: r.title, + url: r.url, + content: r.content, + score: r.score, + publishedDate: r.published_date, + })); + + return this.formatResults(results, query, data.answer?.trim()); + } +} diff --git a/packages/core/src/tools/web-search/types.ts b/packages/core/src/tools/web-search/types.ts new file mode 100644 index 00000000..12da8116 --- /dev/null +++ b/packages/core/src/tools/web-search/types.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ToolResult } from '../tools.js'; + +/** + * Common interface for all web search providers. + */ +export interface WebSearchProvider { + /** + * The name of the provider. + */ + readonly name: string; + + /** + * Whether the provider is available (has required configuration). + */ + isAvailable(): boolean; + + /** + * Perform a web search with the given query. + * @param query The search query + * @param signal Abort signal for cancellation + * @returns Promise resolving to search results + */ + search(query: string, signal: AbortSignal): Promise; +} + +/** + * Result item from a web search. + */ +export interface WebSearchResultItem { + title: string; + url: string; + content?: string; + score?: number; + publishedDate?: string; +} + +/** + * Result from a web search operation. + */ +export interface WebSearchResult { + /** + * The search query that was executed. + */ + query: string; + + /** + * A concise answer if available from the provider. + */ + answer?: string; + + /** + * List of search result items. + */ + results: WebSearchResultItem[]; + + /** + * Provider-specific metadata. + */ + metadata?: Record; +} + +/** + * Extended tool result that includes sources for web search. + */ +export interface WebSearchToolResult extends ToolResult { + sources?: Array<{ title: string; url: string }>; +} + +/** + * Parameters for the WebSearchTool. + */ +export interface WebSearchToolParams { + /** + * The search query. + */ + query: string; + + /** + * Optional provider to use for the search. + * If not specified, the default provider will be used. + */ + provider?: string; +} + +/** + * Configuration for web search providers. + */ +export interface WebSearchConfig { + /** + * List of available providers with their configurations. + */ + providers: WebSearchProviderConfig[]; + + /** + * The default provider to use. + */ + default: string; +} + +/** + * Base configuration for a web search provider. + */ +export interface WebSearchProviderConfig { + /** + * The type of provider. + */ + type: 'tavily' | 'google' | 'dashscope'; + + /** + * Provider-specific configuration. + */ + config?: Record; +} From b1ece177b72c0cf73e42709a23518076b60e959c Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 11:01:48 +0800 Subject: [PATCH 02/14] feat: Optimize the code --- packages/core/src/config/config.ts | 25 +-- .../src/tools/web-search/base-provider.ts | 42 +--- packages/core/src/tools/web-search/errors.ts | 19 ++ packages/core/src/tools/web-search/index.ts | 181 +++++++++++++----- .../src/tools/web-search/provider-factory.ts | 110 ----------- .../providers/dashscope-provider.ts | 40 ++-- .../web-search/providers/google-provider.ts | 30 ++- .../web-search/providers/tavily-provider.ts | 24 +-- packages/core/src/tools/web-search/types.ts | 55 ++++-- packages/core/src/tools/web-search/utils.ts | 52 +++++ 10 files changed, 296 insertions(+), 282 deletions(-) create mode 100644 packages/core/src/tools/web-search/errors.ts delete mode 100644 packages/core/src/tools/web-search/provider-factory.ts create mode 100644 packages/core/src/tools/web-search/utils.ts 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'); +} From 79b4821499e8562a9d921dc4a0a629bb32b69436 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 11:24:38 +0800 Subject: [PATCH 03/14] feat: Optimize the code --- .../src/tools/web-search/base-provider.ts | 41 +++++-------------- packages/core/src/tools/web-search/errors.ts | 19 --------- .../providers/dashscope-provider.ts | 14 +++---- .../web-search/providers/google-provider.ts | 9 ++-- .../web-search/providers/tavily-provider.ts | 10 +++-- packages/core/src/tools/web-search/utils.ts | 18 ++------ 6 files changed, 31 insertions(+), 80 deletions(-) delete mode 100644 packages/core/src/tools/web-search/errors.ts diff --git a/packages/core/src/tools/web-search/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts index 8bae63ae..a9bdc5b0 100644 --- a/packages/core/src/tools/web-search/base-provider.ts +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -4,16 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - WebSearchProvider, - WebSearchResult, - WebSearchResultItem, -} from './types.js'; -import { WebSearchError } from './errors.js'; +import type { WebSearchProvider, WebSearchResult } from './types.js'; /** * Base implementation for web search providers. - * Provides common functionality for error handling and result formatting. + * Provides common functionality for error handling. */ export abstract class BaseWebSearchProvider implements WebSearchProvider { abstract readonly name: string; @@ -42,38 +37,22 @@ export abstract class BaseWebSearchProvider implements WebSearchProvider { */ async search(query: string, signal: AbortSignal): Promise { if (!this.isAvailable()) { - throw new WebSearchError( - this.name, - 'Provider is not available. Please check your configuration.', + throw new Error( + `[${this.name}] Provider is not available. Please check your configuration.`, ); } try { return await this.performSearch(query, signal); } catch (error: unknown) { - if (error instanceof WebSearchError) { + if ( + error instanceof Error && + error.message.startsWith(`[${this.name}]`) + ) { throw error; } - throw new WebSearchError(this.name, 'Search failed', error); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[${this.name}] Search failed: ${message}`); } } - - /** - * 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( - results: WebSearchResultItem[], - query: string, - answer?: string, - ): WebSearchResult { - return { - query, - answer, - results, - }; - } } diff --git a/packages/core/src/tools/web-search/errors.ts b/packages/core/src/tools/web-search/errors.ts deleted file mode 100644 index 4e795f85..00000000 --- a/packages/core/src/tools/web-search/errors.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @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/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts index df4177d8..20491ab7 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,6 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import { WebSearchError } from '../errors.js'; import type { WebSearchResult, WebSearchResultItem, @@ -104,8 +103,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new WebSearchError( - this.name, + throw new Error( `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } @@ -113,10 +111,7 @@ export class DashScopeProvider extends BaseWebSearchProvider { const data = (await response.json()) as DashScopeSearchResponse; if (data.status !== 0) { - throw new WebSearchError( - this.name, - `API error: ${data.message || 'Unknown error'}`, - ); + throw new Error(`API error: ${data.message || 'Unknown error'}`); } const results: WebSearchResultItem[] = (data.data?.docs || []).map( @@ -129,6 +124,9 @@ export class DashScopeProvider extends BaseWebSearchProvider { }), ); - return this.formatResults(results, query, undefined); + return { + query, + results, + }; } } 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 8dddb5a6..0293bfdc 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,6 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import { WebSearchError } from '../errors.js'; import type { WebSearchResult, WebSearchResultItem, @@ -71,8 +70,7 @@ export class GoogleProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new WebSearchError( - this.name, + throw new Error( `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } @@ -85,6 +83,9 @@ export class GoogleProvider extends BaseWebSearchProvider { content: item.snippet, })); - return this.formatResults(results, query, undefined); + return { + query, + results, + }; } } 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 78745858..b6284050 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,6 @@ */ import { BaseWebSearchProvider } from '../base-provider.js'; -import { WebSearchError } from '../errors.js'; import type { WebSearchResult, WebSearchResultItem, @@ -61,8 +60,7 @@ export class TavilyProvider extends BaseWebSearchProvider { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new WebSearchError( - this.name, + throw new Error( `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, ); } @@ -77,6 +75,10 @@ export class TavilyProvider extends BaseWebSearchProvider { publishedDate: r.published_date, })); - return this.formatResults(results, query, data.answer?.trim()); + return { + query, + answer: data.answer?.trim(), + results, + }; } } diff --git a/packages/core/src/tools/web-search/utils.ts b/packages/core/src/tools/web-search/utils.ts index b2634a56..4f4f24db 100644 --- a/packages/core/src/tools/web-search/utils.ts +++ b/packages/core/src/tools/web-search/utils.ts @@ -8,19 +8,6 @@ * 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 @@ -32,7 +19,10 @@ export function buildContentWithSources( sources: Array<{ title: string; url: string }>, ): string { if (!sources.length) return content; - return `${content}\n\nSources:\n${formatSources(sources)}`; + const sourceList = sources + .map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`) + .join('\n'); + return `${content}\n\nSources:\n${sourceList}`; } /** From 741eaf91c2d37c9fffa45624c8f7d25fb3f7bb7a Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 17:05:47 +0800 Subject: [PATCH 04/14] feat: add web_search docs --- docs/tools/web-search.md | 168 ++++++++++++--- packages/cli/src/config/config.ts | 25 ++- packages/cli/src/config/settingsSchema.ts | 4 +- packages/cli/src/config/webSearch.ts | 91 ++++++++ packages/cli/src/gemini.test.tsx | 3 + packages/core/src/config/config.ts | 7 - packages/core/src/tools/web-search.test.ts | 166 --------------- packages/core/src/tools/web-search.ts | 218 -------------------- packages/core/src/tools/web-search/index.ts | 85 +++----- 9 files changed, 293 insertions(+), 474 deletions(-) create mode 100644 packages/cli/src/config/webSearch.ts delete mode 100644 packages/core/src/tools/web-search.test.ts delete mode 100644 packages/core/src/tools/web-search.ts diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index 7f78cf70..60617d71 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -1,43 +1,165 @@ # Web Search Tool (`web_search`) -This document describes the `web_search` tool. +This document describes the `web_search` tool for performing web searches using multiple providers. ## Description -Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible. +Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available. + +### Supported Providers + +1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication +2. **Tavily** - High-quality search API with built-in answer generation +3. **Google Custom Search** - Google's Custom Search JSON API ### Arguments -`web_search` takes one argument: +`web_search` takes two arguments: -- `query` (string, required): The search query. +- `query` (string, required): The search query +- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google") + - If not specified, uses the default provider from configuration -## How to use `web_search` +## Configuration -`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods: +### Method 1: Settings File (Recommended) -1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json` -2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file -3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI +Add to your `settings.json`: -If the key is not configured, the tool will be disabled and skipped. - -Usage: - -``` -web_search(query="Your query goes here.") +```json +{ + "webSearch": { + "provider": [ + { "type": "dashscope" }, + { "type": "tavily", "apiKey": "tvly-xxxxx" }, + { + "type": "google", + "apiKey": "your-google-api-key", + "searchEngineId": "your-search-engine-id" + } + ], + "default": "dashscope" + } +} ``` -## `web_search` examples +**Notes:** -Get information on a topic: +- DashScope doesn't require an API key (official, free service) +- Only configure the providers you want to use +- Set `default` to specify which provider to use by default -``` -web_search(query="latest advancements in AI-powered code generation") +### Method 2: Environment Variables + +Set environment variables in your shell or `.env` file: + +```bash +# Tavily +export TAVILY_API_KEY="tvly-xxxxx" + +# Google +export GOOGLE_API_KEY="your-api-key" +export GOOGLE_SEARCH_ENGINE_ID="your-engine-id" ``` -## Important notes +### Method 3: Command Line Arguments -- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links. -- **Citations:** Source links are appended as a numbered list. -- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered. +Pass API keys when running Qwen Code: + +```bash +# Tavily +qwen --tavily-api-key tvly-xxxxx + +# Google +qwen --google-api-key your-key --google-search-engine-id your-id + +# Specify default provider +qwen --web-search-default tavily +``` + +### Backward Compatibility (Deprecated) + +⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated: + +```json +{ + "advanced": { + "tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated + } +} +``` + +**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration. + +## Usage Examples + +### Basic search (using default provider) + +``` +web_search(query="latest advancements in AI") +``` + +### Search with specific provider + +``` +web_search(query="latest advancements in AI", provider="tavily") +``` + +### Real-world examples + +``` +web_search(query="weather in San Francisco today") +web_search(query="latest Node.js LTS version", provider="google") +web_search(query="best practices for React 19", provider="dashscope") +``` + +## Provider Details + +### DashScope (Official) + +- **Cost:** Free +- **Authentication:** Automatically available with Qwen OAuth +- **Configuration:** No API key required +- **Best for:** General queries, always available + +### Tavily + +- **Cost:** Requires API key (paid service with free tier) +- **Sign up:** https://tavily.com +- **Features:** High-quality results with AI-generated answers +- **Best for:** Research, comprehensive answers with citations + +### Google Custom Search + +- **Cost:** Free tier available (100 queries/day) +- **Setup:** + 1. Enable Custom Search API in Google Cloud Console + 2. Create a Custom Search Engine at https://programmablesearchengine.google.com +- **Features:** Google's search quality +- **Best for:** Specific, factual queries + +## Important Notes + +- **Response format:** Returns a concise answer with numbered source citations +- **Citations:** Source links are appended as a numbered list: [1], [2], etc. +- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter +- **DashScope availability:** Always available when authenticated with Qwen OAuth, no configuration needed + +## Troubleshooting + +**Tool not available?** + +- Check if at least one provider is configured +- For DashScope: Ensure you're authenticated with Qwen OAuth +- For Tavily/Google: Verify your API keys are correct + +**Provider-specific errors?** + +- Use the `provider` parameter to try a different search provider +- Check your API quotas and rate limits +- Verify API keys are properly set in configuration + +**Need help?** + +- Check your configuration: Run `qwen` and use the settings dialog +- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9655a1ec..61a856d6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -43,6 +43,7 @@ import { mcpCommand } from '../commands/mcp.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { buildWebSearchConfig } from './webSearch.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -117,6 +118,9 @@ export interface CliArgs { proxy: string | undefined; includeDirectories: string[] | undefined; tavilyApiKey: string | undefined; + googleApiKey: string | undefined; + googleSearchEngineId: string | undefined; + webSearchDefault: string | undefined; screenReader: boolean | undefined; vlmSwitchMode: string | undefined; useSmartEdit: boolean | undefined; @@ -325,7 +329,20 @@ export async function parseArguments(settings: Settings): Promise { }) .option('tavily-api-key', { type: 'string', - description: 'Tavily API key for web search functionality', + description: 'Tavily API key for web search', + }) + .option('google-api-key', { + type: 'string', + description: 'Google Custom Search API key', + }) + .option('google-search-engine-id', { + type: 'string', + description: 'Google Custom Search Engine ID', + }) + .option('web-search-default', { + type: 'string', + description: + 'Default web search provider (dashscope, tavily, google)', }) .option('screen-reader', { type: 'boolean', @@ -747,10 +764,7 @@ export async function loadCliConfig( : argv.openaiLogging) ?? false, }, cliVersion: await getCliVersion(), - tavilyApiKey: - argv.tavilyApiKey || - settings.advanced?.tavilyApiKey || - process.env['TAVILY_API_KEY'], + webSearch: buildWebSearchConfig(argv, settings), summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, @@ -771,7 +785,6 @@ export async function loadCliConfig( output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, - webSearch: settings.webSearch, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4265cc5c..509f7666 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1044,12 +1044,12 @@ const SETTINGS_SCHEMA = { }, tavilyApiKey: { type: 'string', - label: 'Tavily API Key', + label: 'Tavily API Key (Deprecated)', category: 'Advanced', requiresRestart: false, default: undefined as string | undefined, description: - 'The API key for the Tavily API. Required to enable the web_search tool functionality.', + '⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.', showInDialog: false, }, }, diff --git a/packages/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts new file mode 100644 index 00000000..a558de17 --- /dev/null +++ b/packages/cli/src/config/webSearch.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core'; +import type { Settings } from './settings.js'; + +/** + * CLI arguments related to web search configuration + */ +export interface WebSearchCliArgs { + tavilyApiKey?: string; + googleApiKey?: string; + googleSearchEngineId?: string; + webSearchDefault?: string; +} + +/** + * Web search configuration structure + */ +export interface WebSearchConfig { + provider: WebSearchProviderConfig[]; + default: string; +} + +/** + * Build webSearch configuration from multiple sources with priority: + * 1. settings.json (new format) - highest priority + * 2. Command line args + environment variables + * 3. Legacy tavilyApiKey (backward compatibility) + * + * @param argv - Command line arguments + * @param settings - User settings from settings.json + * @returns WebSearch configuration or undefined if no providers available + */ +export function buildWebSearchConfig( + argv: WebSearchCliArgs, + settings: Settings, +): WebSearchConfig | undefined { + // Priority 1: Use settings.json webSearch config if present + if (settings.webSearch) { + return settings.webSearch; + } + + // Priority 2: Build from command line args and environment variables + const providers: WebSearchProviderConfig[] = []; + + // DashScope is always available (official, free) + providers.push({ type: 'dashscope' } as WebSearchProviderConfig); + + // Tavily from args/env/legacy settings + const tavilyKey = + argv.tavilyApiKey || + settings.advanced?.tavilyApiKey || + process.env['TAVILY_API_KEY']; + if (tavilyKey) { + providers.push({ + type: 'tavily', + apiKey: tavilyKey, + } as WebSearchProviderConfig); + } + + // Google from args/env + const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; + const googleEngineId = + argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; + if (googleKey && googleEngineId) { + providers.push({ + type: 'google', + apiKey: googleKey, + searchEngineId: googleEngineId, + } as WebSearchProviderConfig); + } + + // If no providers configured, return undefined + if (providers.length === 0) { + return undefined; + } + + // Determine default provider + // Priority: CLI arg > has Tavily key > DashScope (fallback) + const defaultProvider = + argv.webSearchDefault || (tavilyKey ? 'tavily' : 'dashscope'); + + return { + provider: providers, + default: defaultProvider, + }; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 231c34a7..76d2c772 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -330,6 +330,9 @@ describe('gemini.tsx main function kitty protocol', () => { proxy: undefined, includeDirectories: undefined, tavilyApiKey: undefined, + googleApiKey: undefined, + googleSearchEngineId: undefined, + webSearchDefault: undefined, screenReader: undefined, vlmSwitchMode: undefined, useSmartEdit: undefined, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 74dbef22..c3b4dbc1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -260,7 +260,6 @@ export interface ConfigParameters { cliVersion?: string; loadMemoryFromIncludeDirectories?: boolean; // Web search providers - tavilyApiKey?: string; webSearch?: { provider: Array<{ type: 'tavily' | 'google' | 'dashscope'; @@ -356,7 +355,6 @@ export class Config { private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false; - private readonly tavilyApiKey?: string; private readonly webSearch?: { provider: Array<{ type: 'tavily' | 'google' | 'dashscope'; @@ -469,7 +467,6 @@ export class Config { this.skipLoopDetection = params.skipLoopDetection ?? false; // Web search - this.tavilyApiKey = params.tavilyApiKey; this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; @@ -904,10 +901,6 @@ export class Config { } // Web search provider configuration - getTavilyApiKey(): string | undefined { - return this.tavilyApiKey; - } - getWebSearchConfig() { return this.webSearch; } diff --git a/packages/core/src/tools/web-search.test.ts b/packages/core/src/tools/web-search.test.ts deleted file mode 100644 index 5b0a8f26..00000000 --- a/packages/core/src/tools/web-search.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { WebSearchTool, type WebSearchToolParams } from './web-search.js'; -import type { Config } from '../config/config.js'; -import { GeminiClient } from '../core/client.js'; - -// Mock GeminiClient and Config constructor -vi.mock('../core/client.js'); -vi.mock('../config/config.js'); - -// Mock global fetch -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe('WebSearchTool', () => { - const abortSignal = new AbortController().signal; - let mockGeminiClient: GeminiClient; - let tool: WebSearchTool; - - beforeEach(() => { - vi.clearAllMocks(); - const mockConfigInstance = { - getGeminiClient: () => mockGeminiClient, - getProxy: () => undefined, - getTavilyApiKey: () => 'test-api-key', // Add the missing method - } as unknown as Config; - mockGeminiClient = new GeminiClient(mockConfigInstance); - tool = new WebSearchTool(mockConfigInstance); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('build', () => { - it('should return an invocation for a valid query', () => { - const params: WebSearchToolParams = { query: 'test query' }; - const invocation = tool.build(params); - expect(invocation).toBeDefined(); - expect(invocation.params).toEqual(params); - }); - - it('should throw an error for an empty query', () => { - const params: WebSearchToolParams = { query: '' }; - expect(() => tool.build(params)).toThrow( - "The 'query' parameter cannot be empty.", - ); - }); - - it('should throw an error for a query with only whitespace', () => { - const params: WebSearchToolParams = { query: ' ' }; - expect(() => tool.build(params)).toThrow( - "The 'query' parameter cannot be empty.", - ); - }); - }); - - describe('getDescription', () => { - it('should return a description of the search', () => { - const params: WebSearchToolParams = { query: 'test query' }; - const invocation = tool.build(params); - expect(invocation.getDescription()).toBe( - 'Searching the web for: "test query"', - ); - }); - }); - - describe('execute', () => { - it('should return search results for a successful query', async () => { - const params: WebSearchToolParams = { query: 'successful query' }; - - // Mock the fetch response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - answer: 'Here are your results.', - results: [], - }), - } as Response); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - expect(result.llmContent).toBe( - 'Web search results for "successful query":\n\nHere are your results.', - ); - expect(result.returnDisplay).toBe( - 'Search results for "successful query" returned.', - ); - expect(result.sources).toEqual([]); - }); - - it('should handle no search results found', async () => { - const params: WebSearchToolParams = { query: 'no results query' }; - - // Mock the fetch response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - answer: '', - results: [], - }), - } as Response); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - expect(result.llmContent).toBe( - 'No search results or information found for query: "no results query"', - ); - expect(result.returnDisplay).toBe('No information found.'); - }); - - it('should handle API errors gracefully', async () => { - const params: WebSearchToolParams = { query: 'error query' }; - - // Mock the fetch to reject - mockFetch.mockRejectedValueOnce(new Error('API Failure')); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - expect(result.llmContent).toContain('Error:'); - expect(result.llmContent).toContain('API Failure'); - expect(result.returnDisplay).toBe('Error performing web search.'); - }); - - it('should correctly format results with sources', async () => { - const params: WebSearchToolParams = { query: 'grounding query' }; - - // Mock the fetch response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - answer: 'This is a test response.', - results: [ - { title: 'Example Site', url: 'https://example.com' }, - { title: 'Google', url: 'https://google.com' }, - ], - }), - } as Response); - - const invocation = tool.build(params); - const result = await invocation.execute(abortSignal); - - const expectedLlmContent = `Web search results for "grounding query": - -This is a test response. - -Sources: -[1] Example Site (https://example.com) -[2] Google (https://google.com)`; - - expect(result.llmContent).toBe(expectedLlmContent); - expect(result.returnDisplay).toBe( - 'Search results for "grounding query" returned.', - ); - expect(result.sources).toHaveLength(2); - }); - }); -}); diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts deleted file mode 100644 index e99997a8..00000000 --- a/packages/core/src/tools/web-search.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type ToolInvocation, - type ToolResult, - type ToolCallConfirmationDetails, - type ToolInfoConfirmationDetails, - ToolConfirmationOutcome, -} from './tools.js'; - -import type { Config } from '../config/config.js'; -import { ApprovalMode } from '../config/config.js'; -import { getErrorMessage } from '../utils/errors.js'; - -interface TavilyResultItem { - title: string; - url: string; - content?: string; - score?: number; - published_date?: string; -} - -interface TavilySearchResponse { - query: string; - answer?: string; - results: TavilyResultItem[]; -} - -/** - * Parameters for the WebSearchTool. - */ -export interface WebSearchToolParams { - /** - * The search query. - */ - query: string; -} - -/** - * Extends ToolResult to include sources for web search. - */ -export interface WebSearchToolResult extends ToolResult { - sources?: Array<{ title: string; url: string }>; -} - -class WebSearchToolInvocation extends BaseToolInvocation< - WebSearchToolParams, - WebSearchToolResult -> { - constructor( - private readonly config: Config, - params: WebSearchToolParams, - ) { - super(params); - } - - override getDescription(): string { - return `Searching the web for: "${this.params.query}"`; - } - - override async shouldConfirmExecute( - _abortSignal: AbortSignal, - ): Promise { - if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { - return false; - } - - const confirmationDetails: ToolInfoConfirmationDetails = { - type: 'info', - title: 'Confirm Web Search', - prompt: `Search the web for: "${this.params.query}"`, - onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); - } - }, - }; - return confirmationDetails; - } - - async execute(signal: AbortSignal): Promise { - const apiKey = this.config.getTavilyApiKey(); - if (!apiKey) { - return { - llmContent: - 'Web search is disabled because TAVILY_API_KEY is not configured. Please set it in your settings.json, .env file, or via --tavily-api-key command line argument to enable web search.', - returnDisplay: - 'Web search disabled. Configure TAVILY_API_KEY to enable Tavily search.', - }; - } - - try { - const response = await fetch('https://api.tavily.com/search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - api_key: apiKey, - query: this.params.query, - search_depth: 'advanced', - max_results: 5, - include_answer: true, - }), - signal, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error( - `Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, - ); - } - - const data = (await response.json()) as TavilySearchResponse; - - const sources = (data.results || []).map((r) => ({ - title: r.title, - url: r.url, - })); - - const sourceListFormatted = sources.map( - (s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`, - ); - - let content = data.answer?.trim() || ''; - if (!content) { - // Fallback: build a concise summary from top results - content = sources - .slice(0, 3) - .map((s, i) => `${i + 1}. ${s.title} - ${s.url}`) - .join('\n'); - } - - if (sourceListFormatted.length > 0) { - content += `\n\nSources:\n${sourceListFormatted.join('\n')}`; - } - - if (!content.trim()) { - return { - llmContent: `No search results or information found for query: "${this.params.query}"`, - returnDisplay: 'No information found.', - }; - } - - return { - llmContent: `Web search results for "${this.params.query}":\n\n${content}`, - returnDisplay: `Search results for "${this.params.query}" returned.`, - sources, - }; - } catch (error: unknown) { - const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage( - error, - )}`; - console.error(errorMessage, error); - return { - llmContent: `Error: ${errorMessage}`, - returnDisplay: `Error performing web search.`, - }; - } - } -} - -/** - * A tool to perform web searches using Tavily Search via the Tavily API. - */ -export class WebSearchTool extends BaseDeclarativeTool< - WebSearchToolParams, - WebSearchToolResult -> { - static readonly Name: string = 'web_search'; - - constructor(private readonly config: Config) { - super( - WebSearchTool.Name, - 'WebSearch', - 'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.', - Kind.Search, - { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to find information on the web.', - }, - }, - required: ['query'], - }, - ); - } - - /** - * Validates the parameters for the WebSearchTool. - * @param params The parameters to validate - * @returns An error message string if validation fails, null if valid - */ - protected override validateToolParamValues( - params: WebSearchToolParams, - ): string | null { - if (!params.query || params.query.trim() === '') { - return "The 'query' parameter cannot be empty."; - } - return null; - } - - protected createInvocation( - params: WebSearchToolParams, - ): ToolInvocation { - return new WebSearchToolInvocation(this.config, params); - } -} diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 06e3cc5f..52b62266 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -108,34 +108,36 @@ class WebSearchToolInvocation extends BaseToolInvocation< /** * Select the appropriate provider based on configuration and parameters. + * Throws error if provider not found. */ private selectProvider( providers: Map, requestedProvider?: string, defaultProvider?: string, - ): { provider: WebSearchProvider | null; error?: string } { + ): WebSearchProvider { // 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}`, - }; + const available = Array.from(providers.keys()).join(', '); + throw new Error( + `The specified provider "${requestedProvider}" is not available. Available: ${available}`, + ); } - return { provider }; + return provider; } // Use default provider if specified and available if (defaultProvider && providers.has(defaultProvider)) { - const provider = providers.get(defaultProvider)!; - return { provider }; + return providers.get(defaultProvider)!; } // Fallback to first available provider const firstProvider = providers.values().next().value; - return { provider: firstProvider || null }; + if (!firstProvider) { + throw new Error('No web search providers are available.'); + } + return firstProvider; } /** @@ -166,76 +168,55 @@ class WebSearchToolInvocation extends BaseToolInvocation< } async execute(signal: AbortSignal): Promise { + // Guard: Check configuration exists const webSearchConfig = this.config.getWebSearchConfig(); if (!webSearchConfig) { + const message = + 'Web search is disabled. Please configure web search providers in settings.json.'; return { - llmContent: - 'Web search is disabled because no web search configuration is available. Please configure web search providers in your settings.json.', + llmContent: message, returnDisplay: - 'Web search disabled. Configure web search providers to enable search.', - }; - } - - const providers = this.createProviders(webSearchConfig.provider); - - const { provider: selectedProvider, error } = this.selectProvider( - providers, - this.params.provider, - webSearchConfig.default, - ); - - if (error) { - return { - llmContent: error, - returnDisplay: `Provider "${this.params.provider}" not available.`, + 'Web search disabled. Configure providers to enable search.', 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: errorMsg, - returnDisplay: 'Web search disabled. No available providers.', - error: { - message: errorMsg, + message, type: ToolErrorType.EXECUTION_FAILED, }, }; } try { - const searchResult = await selectedProvider.search( - this.params.query, - signal, + // Create and select provider + const providers = this.createProviders(webSearchConfig.provider); + const provider = this.selectProvider( + providers, + this.params.provider, + webSearchConfig.default, ); + // Perform search + const searchResult = await provider.search(this.params.query, signal); const { content, sources } = this.formatSearchResults(searchResult); + // Guard: Check if we got results if (!content.trim()) { return { - llmContent: `No search results or information found for query: "${this.params.query}" (searched via ${selectedProvider.name})`, + llmContent: `No search results found for query: "${this.params.query}" (via ${provider.name})`, returnDisplay: `No information found for "${this.params.query}".`, }; } + // Success result return { - llmContent: `Web search results for "${this.params.query}" (via ${selectedProvider.name}):\n\n${content}`, + llmContent: `Web search results for "${this.params.query}" (via ${provider.name}):\n\n${content}`, returnDisplay: `Search results for "${this.params.query}".`, sources, }; } catch (error: unknown) { - const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage( - error, - )}`; + const errorMessage = `Error during web search: ${getErrorMessage(error)}`; console.error(errorMessage, error); return { - llmContent: `Error: ${errorMessage}`, - returnDisplay: `Error performing web search.`, + llmContent: errorMessage, + returnDisplay: 'Error performing web search.', error: { message: errorMessage, type: ToolErrorType.EXECUTION_FAILED, From 799d2bf0db3511f159106e93e73f7d3bff8fe853 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 27 Oct 2025 19:59:13 +0800 Subject: [PATCH 05/14] feat: add oauth credit token --- .../providers/dashscope-provider.ts | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) 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 20491ab7..ddf44bb6 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -4,12 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { promises as fs } from 'node:fs'; +import * as os from 'os'; +import * as path from 'path'; import { BaseWebSearchProvider } from '../base-provider.js'; import type { WebSearchResult, WebSearchResultItem, DashScopeProviderConfig, } from '../types.js'; +import type { QwenCredentials } from '../../../qwen/qwenOAuth2.js'; interface DashScopeSearchItem { _id: string; @@ -54,6 +58,30 @@ interface DashScopeSearchResponse { success: boolean; } +// File System Configuration +const QWEN_DIR = '.qwen'; +const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json'; + +/** + * Get the path to the cached OAuth credentials file. + */ +function getQwenCachedCredentialPath(): string { + return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); +} + +/** + * Load cached Qwen OAuth credentials from disk. + */ +async function loadQwenCredentials(): Promise { + try { + const keyFile = getQwenCachedCredentialPath(); + const creds = await fs.readFile(keyFile, 'utf-8'); + return JSON.parse(creds) as QwenCredentials; + } catch { + return null; + } +} + /** * Web search provider using Alibaba Cloud DashScope API. */ @@ -69,32 +97,49 @@ export class DashScopeProvider extends BaseWebSearchProvider { // return !!(this.config.apiKey && this.config.uid && this.config.appId); } + /** + * Get the access token for authentication. + * Tries OAuth credentials first, falls back to apiKey if OAuth is not available. + */ + private async getAccessToken(): Promise { + // Try to load OAuth credentials first + const credentials = await loadQwenCredentials(); + if (credentials?.access_token) { + // Check if token is not expired + if (credentials.expiry_date && credentials.expiry_date > Date.now()) { + return credentials.access_token; + } + } + + // Fallback to apiKey from config if OAuth is not available + return this.config.apiKey || null; + } + protected async performSearch( query: string, signal: AbortSignal, ): Promise { + // Get access token from OAuth credentials or fallback to apiKey + const accessToken = await this.getAccessToken(); + if (!accessToken) { + throw new Error( + 'No access token available. Please authenticate using OAuth', + ); + } + const requestBody = { - rid: '', - uid: this.config.uid!, - scene: this.config.scene || 'dolphin_search_inner_turbo', uq: query, - fields: [], page: 1, rows: this.config.maxResults || 10, - customConfigInfo: {}, - headers: { - __d_head_qto: this.config.timeout || 8000, - __d_head_app: this.config.appId!, - }, }; const response = await fetch( - 'https://dashscope.aliyuncs.com/api/v1/indices/plugin/web_search', + 'https://pre-portal.qwen.ai/api/v1/indices/plugin/web_search', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: this.config.apiKey!, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(requestBody), signal, From 4781736f99a274f948a03c04032206f58467e8bb Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 16:15:42 +0800 Subject: [PATCH 06/14] Improve web search fallback with snippet and web_fetch hint --- packages/core/src/tools/web-search/index.ts | 47 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 52b62266..3f8796ab 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -18,7 +18,7 @@ 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 { buildContentWithSources, buildSummary } from './utils.js'; +import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; import { GoogleProvider } from './providers/google-provider.js'; import { DashScopeProvider } from './providers/dashscope-provider.js'; @@ -156,13 +156,46 @@ class WebSearchToolInvocation extends BaseToolInvocation< })); 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); + if (!content) { + // Fallback: Build an informative summary with title + snippet + source link + // This provides enough context for the LLM while keeping token usage efficient + content = searchResult.results + .slice(0, 5) // Top 5 results + .map((r, i) => { + const parts = [`${i + 1}. **${r.title}**`]; + + // Include snippet/content if available + if (r.content?.trim()) { + parts.push(` ${r.content.trim()}`); + } + + // Always include the source URL + parts.push(` Source: ${r.url}`); + + // Optionally include relevance score if available + if (r.score !== undefined) { + parts.push(` Relevance: ${(r.score * 100).toFixed(0)}%`); + } + + // Optionally include publish date if available + if (r.publishedDate) { + parts.push(` Published: ${r.publishedDate}`); + } + + return parts.join('\n'); + }) + .join('\n\n'); + + // Add a note about using web_fetch for detailed content + if (content) { + content += + '\n\n*Note: For detailed content from any source above, use the web_fetch tool with the URL.*'; + } + } else { + // When answer is available, append sources section + content = buildContentWithSources(content, sources); + } return { content, sources }; } From 9a41db612a0a741a50952d4174767cb03a1370d1 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 16:18:41 +0800 Subject: [PATCH 07/14] Add unit tests for web search core logic --- .../core/src/tools/web-search/index.test.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 packages/core/src/tools/web-search/index.test.ts diff --git a/packages/core/src/tools/web-search/index.test.ts b/packages/core/src/tools/web-search/index.test.ts new file mode 100644 index 00000000..288aa5ea --- /dev/null +++ b/packages/core/src/tools/web-search/index.test.ts @@ -0,0 +1,276 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebSearchTool } from './index.js'; +import type { Config } from '../../config/config.js'; +import type { WebSearchConfig } from './types.js'; +import { ApprovalMode } from '../../config/config.js'; + +describe('WebSearchTool', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.resetAllMocks(); + mockConfig = { + getApprovalMode: vi.fn(() => ApprovalMode.AUTO_EDIT), + setApprovalMode: vi.fn(), + getWebSearchConfig: vi.fn(), + } as unknown as Config; + }); + + describe('formatSearchResults', () => { + it('should use answer when available and append sources', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'tavily', + apiKey: 'test-key', + }, + ], + default: 'tavily', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return search results with answer + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'test query', + answer: 'This is a concise answer from the search provider.', + results: [ + { + title: 'Result 1', + url: 'https://example.com/1', + content: 'Content 1', + }, + { + title: 'Result 2', + url: 'https://example.com/2', + content: 'Content 2', + }, + ], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain( + 'This is a concise answer from the search provider.', + ); + expect(result.llmContent).toContain('Sources:'); + expect(result.llmContent).toContain( + '[1] Result 1 (https://example.com/1)', + ); + expect(result.llmContent).toContain( + '[2] Result 2 (https://example.com/2)', + ); + }); + + it('should build informative summary when answer is not available', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'google', + apiKey: 'test-key', + searchEngineId: 'test-engine', + }, + ], + default: 'google', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return search results without answer + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { + title: 'Google Result 1', + link: 'https://example.com/1', + snippet: 'This is a helpful snippet from the first result.', + }, + { + title: 'Google Result 2', + link: 'https://example.com/2', + snippet: 'This is a helpful snippet from the second result.', + }, + ], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + // Should contain formatted results with title, snippet, and source + expect(result.llmContent).toContain('1. **Google Result 1**'); + expect(result.llmContent).toContain( + 'This is a helpful snippet from the first result.', + ); + expect(result.llmContent).toContain('Source: https://example.com/1'); + expect(result.llmContent).toContain('2. **Google Result 2**'); + expect(result.llmContent).toContain( + 'This is a helpful snippet from the second result.', + ); + expect(result.llmContent).toContain('Source: https://example.com/2'); + + // Should include web_fetch hint + expect(result.llmContent).toContain('web_fetch tool'); + }); + + it('should include optional fields when available', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'tavily', + apiKey: 'test-key', + }, + ], + default: 'tavily', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return results with score and publishedDate + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'test query', + results: [ + { + title: 'Result with metadata', + url: 'https://example.com', + content: 'Content with metadata', + score: 0.95, + published_date: '2024-01-15', + }, + ], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + // Should include relevance score + expect(result.llmContent).toContain('Relevance: 95%'); + // Should include published date + expect(result.llmContent).toContain('Published: 2024-01-15'); + }); + + it('should handle empty results gracefully', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'google', + apiKey: 'test-key', + searchEngineId: 'test-engine', + }, + ], + default: 'google', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return empty results + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + items: [], + }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('No search results found'); + }); + + it('should limit to top 5 results in fallback mode', async () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'google', + apiKey: 'test-key', + searchEngineId: 'test-engine', + }, + ], + default: 'google', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + // Mock fetch to return 10 results + const items = Array.from({ length: 10 }, (_, i) => ({ + title: `Result ${i + 1}`, + link: `https://example.com/${i + 1}`, + snippet: `Snippet ${i + 1}`, + })); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ items }), + }); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + // Should only contain first 5 results + expect(result.llmContent).toContain('1. **Result 1**'); + expect(result.llmContent).toContain('5. **Result 5**'); + expect(result.llmContent).not.toContain('6. **Result 6**'); + expect(result.llmContent).not.toContain('10. **Result 10**'); + }); + }); + + describe('validation', () => { + it('should throw validation error when query is empty', () => { + const tool = new WebSearchTool(mockConfig); + expect(() => tool.build({ query: '' })).toThrow( + "The 'query' parameter cannot be empty", + ); + }); + + it('should throw validation error when provider is empty string', () => { + const tool = new WebSearchTool(mockConfig); + expect(() => tool.build({ query: 'test', provider: '' })).toThrow( + "The 'provider' parameter cannot be empty", + ); + }); + }); + + describe('configuration', () => { + it('should return error when web search is not configured', async () => { + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(null); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error?.message).toContain('Web search is disabled'); + expect(result.llmContent).toContain('Web search is disabled'); + }); + }); +}); From 864bf03fee85be1045bffe7fcd4ef464091dd99c Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 19:06:46 +0800 Subject: [PATCH 08/14] docs: add DashScope quota limits to web search documentation - Add quota information (200 requests/minute, 2000 requests/day) to DashScope provider description - Update provider details section with quota limits --- docs/tools/web-search.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index 60617d71..b7085ea2 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -8,7 +8,7 @@ Use `web_search` to perform a web search and get information from the internet. ### Supported Providers -1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication +1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication (200 requests/minute, 2000 requests/day) 2. **Tavily** - High-quality search API with built-in answer generation 3. **Google Custom Search** - Google's Custom Search JSON API @@ -120,6 +120,7 @@ web_search(query="best practices for React 19", provider="dashscope") - **Cost:** Free - **Authentication:** Automatically available with Qwen OAuth - **Configuration:** No API key required +- **Quota:** 200 requests/minute, 2000 requests/day - **Best for:** General queries, always available ### Tavily From a40479d40a3f3bdc1e2c7b53b3efd7b6895b7ff1 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 30 Oct 2025 20:21:30 +0800 Subject: [PATCH 09/14] feat: adjust the description of the web search tool --- packages/core/src/tools/web-search/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 3f8796ab..a25002e1 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -272,7 +272,7 @@ export class WebSearchTool extends BaseDeclarativeTool< super( WebSearchTool.Name, 'WebSearch', - 'Performs a web search using configurable providers and returns a concise answer with sources.', + 'Allows searching the web and using results to inform responses. Provides up-to-date information for current events and recent data beyond the training data cutoff. Returns search results formatted with concise answers and source links. Use this tool when accessing information that may be outdated or beyond the knowledge cutoff.', Kind.Search, { type: 'object', From 40d82a2b254e831124ffa6a9f3f6974d33c7fc16 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Fri, 31 Oct 2025 10:19:44 +0800 Subject: [PATCH 10/14] feat: add docs for web search tool --- docs/tools/web-search.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index b7085ea2..a2c7f6fa 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -91,6 +91,20 @@ qwen --web-search-default tavily **Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration. +## Disabling Web Search + +If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`: + +```json +{ + "tools": { + "exclude": ["web_search"] + } +} +``` + +**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured. + ## Usage Examples ### Basic search (using default provider) From d1507e73fe26bb45c71bd4ab6e2c661f249793c9 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 4 Nov 2025 16:59:30 +0800 Subject: [PATCH 11/14] feat(web-search): use resource_url from credentials for DashScope endpoint --- .../providers/dashscope-provider.ts | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) 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 ddf44bb6..4222d747 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -98,29 +98,53 @@ export class DashScopeProvider extends BaseWebSearchProvider { } /** - * Get the access token for authentication. + * Get the access token and API endpoint for authentication and web search. * Tries OAuth credentials first, falls back to apiKey if OAuth is not available. + * Returns both token and endpoint to avoid loading credentials multiple times. */ - private async getAccessToken(): Promise { - // Try to load OAuth credentials first + private async getAuthConfig(): Promise<{ + accessToken: string | null; + apiEndpoint: string; + }> { + // Load credentials once const credentials = await loadQwenCredentials(); + + // Get access token: try OAuth credentials first, fallback to apiKey + let accessToken: string | null = null; if (credentials?.access_token) { // Check if token is not expired if (credentials.expiry_date && credentials.expiry_date > Date.now()) { - return credentials.access_token; + accessToken = credentials.access_token; } } + if (!accessToken) { + accessToken = this.config.apiKey || null; + } - // Fallback to apiKey from config if OAuth is not available - return this.config.apiKey || null; + // Get API endpoint: use resource_url from credentials + if (!credentials?.resource_url) { + throw new Error( + 'No resource_url found in credentials. Please authenticate using OAuth', + ); + } + + // Normalize the URL: add protocol if missing + const baseUrl = credentials.resource_url.startsWith('http') + ? credentials.resource_url + : `https://${credentials.resource_url}`; + // Remove trailing slash if present + const normalizedBaseUrl = baseUrl.replace(/\/$/, ''); + const apiEndpoint = `${normalizedBaseUrl}/api/v1/indices/plugin/web_search`; + + return { accessToken, apiEndpoint }; } protected async performSearch( query: string, signal: AbortSignal, ): Promise { - // Get access token from OAuth credentials or fallback to apiKey - const accessToken = await this.getAccessToken(); + // Get access token and API endpoint (loads credentials once) + const { accessToken, apiEndpoint } = await this.getAuthConfig(); if (!accessToken) { throw new Error( 'No access token available. Please authenticate using OAuth', @@ -133,18 +157,15 @@ export class DashScopeProvider extends BaseWebSearchProvider { rows: this.config.maxResults || 10, }; - const response = await fetch( - 'https://pre-portal.qwen.ai/api/v1/indices/plugin/web_search', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify(requestBody), - signal, + const response = await fetch(apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, }, - ); + body: JSON.stringify(requestBody), + signal, + }); if (!response.ok) { const text = await response.text().catch(() => ''); From 6357a5c87e83ce932f552328e005b71f54bdcc96 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 4 Nov 2025 19:59:19 +0800 Subject: [PATCH 12/14] feat(web-search): enable DashScope provider only for Qwen OAuth auth type --- packages/core/src/tools/web-search/index.ts | 26 +++++++++++++++---- .../providers/dashscope-provider.ts | 5 ++-- packages/core/src/tools/web-search/types.ts | 6 +++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index a25002e1..5a8e3b3b 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -17,6 +17,7 @@ import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; +import { AuthType } from '../../core/contentGenerator.js'; import { getErrorMessage } from '../../utils/errors.js'; import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; @@ -28,6 +29,7 @@ import type { WebSearchProvider, WebSearchResultItem, WebSearchProviderConfig, + DashScopeProviderConfig, } from './types.js'; class WebSearchToolInvocation extends BaseToolInvocation< @@ -43,8 +45,15 @@ class WebSearchToolInvocation extends BaseToolInvocation< override getDescription(): string { const webSearchConfig = this.config.getWebSearchConfig(); - const provider = - this.params.provider || webSearchConfig?.default || 'tavily'; + const authType = this.config.getAuthType(); + let defaultProvider = webSearchConfig?.default; + + // If auth type is QWEN_OAUTH, prefer dashscope as default + if (authType === AuthType.QWEN_OAUTH && !defaultProvider) { + defaultProvider = 'dashscope'; + } + + const provider = this.params.provider || defaultProvider; return ` (Searching the web via ${provider})`; } @@ -77,8 +86,15 @@ class WebSearchToolInvocation extends BaseToolInvocation< return new TavilyProvider(config); case 'google': return new GoogleProvider(config); - case 'dashscope': - return new DashScopeProvider(config); + case 'dashscope': { + // Pass auth type to DashScope provider for availability check + const authType = this.config.getAuthType(); + const dashscopeConfig: DashScopeProviderConfig = { + ...config, + authType: authType as string | undefined, + }; + return new DashScopeProvider(dashscopeConfig); + } default: throw new Error('Unknown provider type'); } @@ -284,7 +300,7 @@ export class WebSearchTool extends BaseDeclarativeTool< provider: { type: 'string', description: - 'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). If not specified, the default provider will be used.', + 'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). IMPORTANT: Only specify this parameter if you explicitly know which provider to use. Otherwise, omit this parameter entirely and let the system automatically select the appropriate provider based on availability and configuration. The system will choose the best available provider automatically.', }, }, required: ['query'], 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 4222d747..fce2b49d 100644 --- a/packages/core/src/tools/web-search/providers/dashscope-provider.ts +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -93,8 +93,9 @@ export class DashScopeProvider extends BaseWebSearchProvider { } isAvailable(): boolean { - return true; - // return !!(this.config.apiKey && this.config.uid && this.config.appId); + // DashScope provider is only available when auth type is QWEN_OAUTH + // This ensures it's only used when OAuth credentials are available + return this.config.authType === 'qwen-oauth'; } /** diff --git a/packages/core/src/tools/web-search/types.ts b/packages/core/src/tools/web-search/types.ts index 1983e166..12368df6 100644 --- a/packages/core/src/tools/web-search/types.ts +++ b/packages/core/src/tools/web-search/types.ts @@ -138,6 +138,12 @@ export interface DashScopeProviderConfig { maxResults?: number; scene?: string; timeout?: number; + /** + * Optional auth type to determine provider availability. + * If set to 'qwen-oauth', the provider will be available. + * If set to other values or undefined, the provider will check auth type dynamically. + */ + authType?: string; } /** From 2967bec11c7837c4115a88f8e41cc44b91302503 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 11:23:27 +0800 Subject: [PATCH 13/14] feat: update code --- docs/cli/configuration-v1.md | 7 +- docs/cli/configuration.md | 7 +- docs/tools/web-search.md | 24 +++-- packages/cli/src/config/config.ts | 6 +- packages/cli/src/config/webSearch.ts | 100 +++++++++++++------- packages/core/src/config/config.ts | 10 +- packages/core/src/tools/web-search/index.ts | 31 +----- 7 files changed, 102 insertions(+), 83 deletions(-) diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md index 0a16eb2e..0926d49e 100644 --- a/docs/cli/configuration-v1.md +++ b/docs/cli/configuration-v1.md @@ -309,7 +309,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c ``` - **`tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. + - **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality. + - **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. - **Default:** `undefined` (web search disabled) - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` - **`chatCompression`** (object): @@ -465,8 +466,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - This is useful for development and testing. - **`TAVILY_API_KEY`**: - Your API key for the Tavily web search service. - - Required to enable the `web_search` tool functionality. - - If not configured, the web search tool will be disabled and skipped. + - Used to enable the `web_search` tool functionality. + - **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search. - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` ## Command-Line Arguments diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 4d593096..b152a701 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -305,7 +305,8 @@ Settings are organized into categories. All settings should be placed within the - **Default:** `undefined` - **`advanced.tavilyApiKey`** (string): - - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. + - **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality. + - **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format. - **Default:** `undefined` #### `mcpServers` @@ -474,8 +475,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi - Set to a string to customize the title of the CLI. - **`TAVILY_API_KEY`**: - Your API key for the Tavily web search service. - - Required to enable the `web_search` tool functionality. - - If not configured, the web search tool will be disabled and skipped. + - Used to enable the `web_search` tool functionality. + - **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search. - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` ## Command-Line Arguments diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index a2c7f6fa..94845339 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -8,7 +8,7 @@ Use `web_search` to perform a web search and get information from the internet. ### Supported Providers -1. **DashScope** (Official, Free) - Default provider, always available when using Qwen OAuth authentication (200 requests/minute, 2000 requests/day) +1. **DashScope** (Official, Free) - Automatically available for Qwen OAuth users (200 requests/minute, 2000 requests/day) 2. **Tavily** - High-quality search API with built-in answer generation 3. **Google Custom Search** - Google's Custom Search JSON API @@ -46,8 +46,9 @@ Add to your `settings.json`: **Notes:** - DashScope doesn't require an API key (official, free service) -- Only configure the providers you want to use -- Set `default` to specify which provider to use by default +- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured +- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope +- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope) ### Method 2: Environment Variables @@ -132,10 +133,11 @@ web_search(query="best practices for React 19", provider="dashscope") ### DashScope (Official) - **Cost:** Free -- **Authentication:** Automatically available with Qwen OAuth -- **Configuration:** No API key required +- **Authentication:** Automatically available when using Qwen OAuth authentication +- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users - **Quota:** 200 requests/minute, 2000 requests/day -- **Best for:** General queries, always available +- **Best for:** General queries, always available as fallback for Qwen OAuth users +- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly ### Tavily @@ -158,14 +160,18 @@ web_search(query="best practices for React 19", provider="dashscope") - **Response format:** Returns a concise answer with numbered source citations - **Citations:** Source links are appended as a numbered list: [1], [2], etc. - **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter -- **DashScope availability:** Always available when authenticated with Qwen OAuth, no configuration needed +- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed +- **Default provider selection:** The system automatically selects a default provider based on availability: + 1. Your explicit `default` configuration (highest priority) + 2. CLI argument `--web-search-default` + 3. First available provider by priority: Tavily > Google > DashScope ## Troubleshooting **Tool not available?** -- Check if at least one provider is configured -- For DashScope: Ensure you're authenticated with Qwen OAuth +- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed +- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured - For Tavily/Google: Verify your API keys are correct **Provider-specific errors?** diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7c20ee83..bd016d76 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -766,7 +766,11 @@ export async function loadCliConfig( : argv.openaiLogging) ?? false, }, cliVersion: await getCliVersion(), - webSearch: buildWebSearchConfig(argv, settings), + webSearch: buildWebSearchConfig( + argv, + settings, + settings.security?.auth?.selectedType, + ), summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, diff --git a/packages/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts index a558de17..260220ac 100644 --- a/packages/cli/src/config/webSearch.ts +++ b/packages/cli/src/config/webSearch.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { AuthType } from '@qwen-code/qwen-code-core'; import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; @@ -33,56 +34,85 @@ export interface WebSearchConfig { * * @param argv - Command line arguments * @param settings - User settings from settings.json + * @param authType - Authentication type (e.g., 'qwen-oauth') * @returns WebSearch configuration or undefined if no providers available */ export function buildWebSearchConfig( argv: WebSearchCliArgs, settings: Settings, + authType?: string, ): WebSearchConfig | undefined { - // Priority 1: Use settings.json webSearch config if present + const isQwenOAuth = authType === AuthType.QWEN_OAUTH; + + // Step 1: Collect providers from settings or command line/env + let providers: WebSearchProviderConfig[] = []; + let userDefault: string | undefined; + if (settings.webSearch) { - return settings.webSearch; + // Use providers from settings.json + providers = [...settings.webSearch.provider]; + userDefault = settings.webSearch.default; + } else { + // Build providers from command line args and environment variables + const tavilyKey = + argv.tavilyApiKey || + settings.advanced?.tavilyApiKey || + process.env['TAVILY_API_KEY']; + if (tavilyKey) { + providers.push({ + type: 'tavily', + apiKey: tavilyKey, + } as WebSearchProviderConfig); + } + + const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; + const googleEngineId = + argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; + if (googleKey && googleEngineId) { + providers.push({ + type: 'google', + apiKey: googleKey, + searchEngineId: googleEngineId, + } as WebSearchProviderConfig); + } } - // Priority 2: Build from command line args and environment variables - const providers: WebSearchProviderConfig[] = []; - - // DashScope is always available (official, free) - providers.push({ type: 'dashscope' } as WebSearchProviderConfig); - - // Tavily from args/env/legacy settings - const tavilyKey = - argv.tavilyApiKey || - settings.advanced?.tavilyApiKey || - process.env['TAVILY_API_KEY']; - if (tavilyKey) { - providers.push({ - type: 'tavily', - apiKey: tavilyKey, - } as WebSearchProviderConfig); + // Step 2: Ensure dashscope is available for qwen-oauth users + if (isQwenOAuth) { + const hasDashscope = providers.some((p) => p.type === 'dashscope'); + if (!hasDashscope) { + providers.push({ type: 'dashscope' } as WebSearchProviderConfig); + } } - // Google from args/env - const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY']; - const googleEngineId = - argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID']; - if (googleKey && googleEngineId) { - providers.push({ - type: 'google', - apiKey: googleKey, - searchEngineId: googleEngineId, - } as WebSearchProviderConfig); - } - - // If no providers configured, return undefined + // Step 3: If no providers available, return undefined if (providers.length === 0) { return undefined; } - // Determine default provider - // Priority: CLI arg > has Tavily key > DashScope (fallback) - const defaultProvider = - argv.webSearchDefault || (tavilyKey ? 'tavily' : 'dashscope'); + // Step 4: Determine default provider + // Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope) + const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [ + 'tavily', + 'google', + 'dashscope', + ]; + + // Determine default provider based on availability + let defaultProvider = userDefault || argv.webSearchDefault; + if (!defaultProvider) { + // Find first available provider by priority order + for (const providerType of providerPriority) { + if (providers.some((p) => p.type === providerType)) { + defaultProvider = providerType; + break; + } + } + // Fallback to first available provider if none found in priority list + if (!defaultProvider) { + defaultProvider = providers[0]?.type || 'dashscope'; + } + } return { provider: providers, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6955c698..754551b4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1166,12 +1166,10 @@ export class Config { registerCoreTool(TodoWriteTool, this); registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); - // Conditionally register web search tool if any web search provider is configured - // or if using qwen-oauth authentication - if ( - this.getWebSearchConfig() || - this.getAuthType() === AuthType.QWEN_OAUTH - ) { + // Conditionally register web search tool if web search provider is configured + // buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so + // if tool is registered, config must exist + if (this.getWebSearchConfig()) { registerCoreTool(WebSearchTool, this); } diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 5a8e3b3b..92855116 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -17,7 +17,6 @@ import { ToolErrorType } from '../tool-error.js'; import type { Config } from '../../config/config.js'; import { ApprovalMode } from '../../config/config.js'; -import { AuthType } from '../../core/contentGenerator.js'; import { getErrorMessage } from '../../utils/errors.js'; import { buildContentWithSources } from './utils.js'; import { TavilyProvider } from './providers/tavily-provider.js'; @@ -44,16 +43,9 @@ class WebSearchToolInvocation extends BaseToolInvocation< } override getDescription(): string { - const webSearchConfig = this.config.getWebSearchConfig(); - const authType = this.config.getAuthType(); - let defaultProvider = webSearchConfig?.default; - - // If auth type is QWEN_OAUTH, prefer dashscope as default - if (authType === AuthType.QWEN_OAUTH && !defaultProvider) { - defaultProvider = 'dashscope'; - } - - const provider = this.params.provider || defaultProvider; + // If tool is registered, config must exist with a default provider + const webSearchConfig = this.config.getWebSearchConfig()!; + const provider = this.params.provider || webSearchConfig.default; return ` (Searching the web via ${provider})`; } @@ -217,21 +209,8 @@ class WebSearchToolInvocation extends BaseToolInvocation< } async execute(signal: AbortSignal): Promise { - // Guard: Check configuration exists - const webSearchConfig = this.config.getWebSearchConfig(); - if (!webSearchConfig) { - const message = - 'Web search is disabled. Please configure web search providers in settings.json.'; - return { - llmContent: message, - returnDisplay: - 'Web search disabled. Configure providers to enable search.', - error: { - message, - type: ToolErrorType.EXECUTION_FAILED, - }, - }; - } + // If tool is registered, config must exist with providers and default + const webSearchConfig = this.config.getWebSearchConfig()!; try { // Create and select provider From 7ff07fd88c655060be037d9c02daa3f551195712 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 5 Nov 2025 11:37:56 +0800 Subject: [PATCH 14/14] fix(web-search): handle unconfigured state and improve tests --- integration-tests/web_search.test.ts | 47 +++++++++++++++++-- .../core/src/tools/web-search/index.test.ts | 36 ++++++++++++++ packages/core/src/tools/web-search/index.ts | 21 +++++++-- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/integration-tests/web_search.test.ts b/integration-tests/web_search.test.ts index 11cd78b6..680a1ffd 100644 --- a/integration-tests/web_search.test.ts +++ b/integration-tests/web_search.test.ts @@ -9,14 +9,53 @@ import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; describe('web_search', () => { it('should be able to search the web', async () => { - // Skip if Tavily key is not configured - if (!process.env['TAVILY_API_KEY']) { - console.warn('Skipping web search test: TAVILY_API_KEY not set'); + // Check if any web search provider is available + const hasTavilyKey = !!process.env['TAVILY_API_KEY']; + const hasGoogleKey = + !!process.env['GOOGLE_API_KEY'] && + !!process.env['GOOGLE_SEARCH_ENGINE_ID']; + + // Skip if no provider is configured + // Note: DashScope provider is automatically available for Qwen OAuth users, + // but we can't easily detect that in tests without actual OAuth credentials + if (!hasTavilyKey && !hasGoogleKey) { + console.warn( + 'Skipping web search test: No web search provider configured. ' + + 'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.', + ); return; } const rig = new TestRig(); - await rig.setup('should be able to search the web'); + // Configure web search in settings if provider keys are available + const webSearchSettings: Record = {}; + const providers: Array<{ + type: string; + apiKey?: string; + searchEngineId?: string; + }> = []; + + if (hasTavilyKey) { + providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] }); + } + if (hasGoogleKey) { + providers.push({ + type: 'google', + apiKey: process.env['GOOGLE_API_KEY'], + searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'], + }); + } + + if (providers.length > 0) { + webSearchSettings.webSearch = { + provider: providers, + default: providers[0]?.type, + }; + } + + await rig.setup('should be able to search the web', { + settings: webSearchSettings, + }); let result; try { diff --git a/packages/core/src/tools/web-search/index.test.ts b/packages/core/src/tools/web-search/index.test.ts index 288aa5ea..d851ceae 100644 --- a/packages/core/src/tools/web-search/index.test.ts +++ b/packages/core/src/tools/web-search/index.test.ts @@ -272,5 +272,41 @@ describe('WebSearchTool', () => { expect(result.error?.message).toContain('Web search is disabled'); expect(result.llmContent).toContain('Web search is disabled'); }); + + it('should return descriptive message in getDescription when web search is not configured', () => { + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(null); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const description = invocation.getDescription(); + + expect(description).toBe( + ' (Web search is disabled - configure a provider in settings.json)', + ); + }); + + it('should return provider name in getDescription when web search is configured', () => { + const webSearchConfig: WebSearchConfig = { + provider: [ + { + type: 'tavily', + apiKey: 'test-key', + }, + ], + default: 'tavily', + }; + + ( + mockConfig.getWebSearchConfig as ReturnType + ).mockReturnValue(webSearchConfig); + + const tool = new WebSearchTool(mockConfig); + const invocation = tool.build({ query: 'test query' }); + const description = invocation.getDescription(); + + expect(description).toBe(' (Searching the web via tavily)'); + }); }); }); diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 92855116..f9962b52 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -43,8 +43,10 @@ class WebSearchToolInvocation extends BaseToolInvocation< } override getDescription(): string { - // If tool is registered, config must exist with a default provider - const webSearchConfig = this.config.getWebSearchConfig()!; + const webSearchConfig = this.config.getWebSearchConfig(); + if (!webSearchConfig) { + return ' (Web search is disabled - configure a provider in settings.json)'; + } const provider = this.params.provider || webSearchConfig.default; return ` (Searching the web via ${provider})`; } @@ -209,8 +211,19 @@ class WebSearchToolInvocation extends BaseToolInvocation< } async execute(signal: AbortSignal): Promise { - // If tool is registered, config must exist with providers and default - const webSearchConfig = this.config.getWebSearchConfig()!; + // Check if web search is configured + const webSearchConfig = this.config.getWebSearchConfig(); + if (!webSearchConfig) { + return { + llmContent: + 'Web search is disabled. Please configure a web search provider in your settings.', + returnDisplay: 'Web search is disabled.', + error: { + message: 'Web search is disabled', + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } try { // Create and select provider