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 7f78cf70..94845339 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -1,43 +1,186 @@ # 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) - 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 ### 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) +- **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) -``` -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. + +## 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) + +``` +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 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 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 + +- **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:** 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?** + +- **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?** + +- 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/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/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2017b714..bd016d76 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -42,6 +42,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 = { @@ -116,6 +117,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; @@ -323,7 +327,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', @@ -749,10 +766,11 @@ 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, + settings.security?.auth?.selectedType, + ), summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b503df68..bd87163a 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1072,17 +1072,36 @@ 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, }, }, }, + 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/cli/src/config/webSearch.ts b/packages/cli/src/config/webSearch.ts new file mode 100644 index 00000000..260220ac --- /dev/null +++ b/packages/cli/src/config/webSearch.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Qwen + * 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'; + +/** + * 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 + * @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 { + 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) { + // 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); + } + } + + // 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); + } + } + + // Step 3: If no providers available, return undefined + if (providers.length === 0) { + return undefined; + } + + // 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, + 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 f1baa935..754551b4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -57,7 +57,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 @@ -262,7 +262,14 @@ export interface ConfigParameters { cliVersion?: string; 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; @@ -351,7 +358,14 @@ 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'; + apiKey?: string; + searchEngineId?: string; + }>; + default: string; + }; private readonly chatCompression: ChatCompressionSettings | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; @@ -457,7 +471,7 @@ export class Config { this.skipLoopDetection = params.skipLoopDetection ?? false; // Web search - this.tavilyApiKey = params.tavilyApiKey; + this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; @@ -912,8 +926,8 @@ export class Config { } // Web search provider configuration - getTavilyApiKey(): string | undefined { - return this.tavilyApiKey; + getWebSearchConfig() { + return this.webSearch; } getIdeMode(): boolean { @@ -1152,8 +1166,10 @@ 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 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/index.ts b/packages/core/src/index.ts index d990bab6..dd675380 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -98,7 +98,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.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/base-provider.ts b/packages/core/src/tools/web-search/base-provider.ts new file mode 100644 index 00000000..a9bdc5b0 --- /dev/null +++ b/packages/core/src/tools/web-search/base-provider.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WebSearchProvider, WebSearchResult } from './types.js'; + +/** + * Base implementation for web search providers. + * Provides common functionality for error handling. + */ +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) { + if ( + error instanceof Error && + error.message.startsWith(`[${this.name}]`) + ) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`[${this.name}] Search failed: ${message}`); + } + } +} 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..d851ceae --- /dev/null +++ b/packages/core/src/tools/web-search/index.test.ts @@ -0,0 +1,312 @@ +/** + * @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'); + }); + + 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 new file mode 100644 index 00000000..f9962b52 --- /dev/null +++ b/packages/core/src/tools/web-search/index.ts @@ -0,0 +1,336 @@ +/** + * @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 { 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 } 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, + DashScopeProviderConfig, +} from './types.js'; + +class WebSearchToolInvocation extends BaseToolInvocation< + WebSearchToolParams, + WebSearchToolResult +> { + constructor( + private readonly config: Config, + params: WebSearchToolParams, + ) { + super(params); + } + + override getDescription(): string { + 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})`; + } + + 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; + } + + /** + * 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': { + // 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'); + } + } + + /** + * 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. + * Throws error if provider not found. + */ + private selectProvider( + providers: Map, + requestedProvider?: string, + defaultProvider?: string, + ): WebSearchProvider { + // Use requested provider if specified + if (requestedProvider) { + const provider = providers.get(requestedProvider); + if (!provider) { + const available = Array.from(providers.keys()).join(', '); + throw new Error( + `The specified provider "${requestedProvider}" is not available. Available: ${available}`, + ); + } + return provider; + } + + // Use default provider if specified and available + if (defaultProvider && providers.has(defaultProvider)) { + return providers.get(defaultProvider)!; + } + + // Fallback to first available provider + const firstProvider = providers.values().next().value; + if (!firstProvider) { + throw new Error('No web search providers are available.'); + } + return firstProvider; + } + + /** + * 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 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 }; + } + + async execute(signal: AbortSignal): Promise { + // 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 + 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 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 ${provider.name}):\n\n${content}`, + returnDisplay: `Search results for "${this.params.query}".`, + sources, + }; + } catch (error: unknown) { + const errorMessage = `Error during web search: ${getErrorMessage(error)}`; + console.error(errorMessage, error); + return { + llmContent: errorMessage, + returnDisplay: 'Error performing web search.', + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + } +} + +/** + * 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', + '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', + 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"). 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'], + }, + ); + } + + /** + * 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/providers/dashscope-provider.ts b/packages/core/src/tools/web-search/providers/dashscope-provider.ts new file mode 100644 index 00000000..fce2b49d --- /dev/null +++ b/packages/core/src/tools/web-search/providers/dashscope-provider.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2025 Qwen + * 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; + 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; +} + +// 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. + */ +export class DashScopeProvider extends BaseWebSearchProvider { + readonly name = 'DashScope'; + + constructor(private readonly config: DashScopeProviderConfig) { + super(); + } + + isAvailable(): boolean { + // 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'; + } + + /** + * 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 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()) { + accessToken = credentials.access_token; + } + } + if (!accessToken) { + accessToken = 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 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', + ); + } + + const requestBody = { + uq: query, + page: 1, + rows: this.config.maxResults || 10, + }; + + 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(() => ''); + throw new Error( + `API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + ); + } + + const data = (await response.json()) as DashScopeSearchResponse; + + if (data.status !== 0) { + throw new Error(`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 { + 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 new file mode 100644 index 00000000..0293bfdc --- /dev/null +++ b/packages/core/src/tools/web-search/providers/google-provider.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { + WebSearchResult, + WebSearchResultItem, + GoogleProviderConfig, +} from '../types.js'; + +interface GoogleSearchItem { + title: string; + link: string; + snippet?: string; + displayLink?: string; + formattedUrl?: string; +} + +interface GoogleSearchResponse { + items?: GoogleSearchItem[]; + searchInformation?: { + totalResults?: string; + searchTime?: number; + }; +} + +/** + * Web search provider using Google Custom Search API. + */ +export class GoogleProvider extends BaseWebSearchProvider { + readonly name = 'Google'; + + constructor(private readonly config: GoogleProviderConfig) { + 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( + `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 { + 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 new file mode 100644 index 00000000..b6284050 --- /dev/null +++ b/packages/core/src/tools/web-search/providers/tavily-provider.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseWebSearchProvider } from '../base-provider.js'; +import type { + WebSearchResult, + WebSearchResultItem, + TavilyProviderConfig, +} from '../types.js'; + +interface TavilyResultItem { + title: string; + url: string; + content?: string; + score?: number; + published_date?: string; +} + +interface TavilySearchResponse { + query: string; + answer?: string; + results: TavilyResultItem[]; +} + +/** + * Web search provider using Tavily API. + */ +export class TavilyProvider extends BaseWebSearchProvider { + readonly name = 'Tavily'; + + constructor(private readonly config: TavilyProviderConfig) { + 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( + `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 { + query, + answer: data.answer?.trim(), + results, + }; + } +} 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..12368df6 --- /dev/null +++ b/packages/core/src/tools/web-search/types.ts @@ -0,0 +1,156 @@ +/** + * @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. + */ + provider: WebSearchProviderConfig[]; + + /** + * The default provider to use. + */ + default: string; +} + +/** + * Base configuration for Tavily provider. + */ +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; + /** + * 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; +} + +/** + * 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..4f4f24db --- /dev/null +++ b/packages/core/src/tools/web-search/utils.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility functions for web search formatting and processing. + */ + +/** + * 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; + const sourceList = sources + .map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`) + .join('\n'); + return `${content}\n\nSources:\n${sourceList}`; +} + +/** + * 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'); +}