mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: add web_search docs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ToolCallConfirmationDetails | false> {
|
||||
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<WebSearchToolResult> {
|
||||
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<WebSearchToolParams, WebSearchToolResult> {
|
||||
return new WebSearchToolInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
@@ -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<string, WebSearchProvider>,
|
||||
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<WebSearchToolResult> {
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user