mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge branch 'main' into fix-kimi2-token-limits
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
58
packages/core/src/tools/web-search/base-provider.ts
Normal file
58
packages/core/src/tools/web-search/base-provider.ts
Normal file
@@ -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<WebSearchResult>;
|
||||
|
||||
/**
|
||||
* 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<WebSearchResult> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
312
packages/core/src/tools/web-search/index.test.ts
Normal file
312
packages/core/src/tools/web-search/index.test.ts
Normal file
@@ -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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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<typeof vi.fn>
|
||||
).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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
336
packages/core/src/tools/web-search/index.ts
Normal file
336
packages/core/src/tools/web-search/index.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, WebSearchProvider> {
|
||||
const providers = new Map<string, WebSearchProvider>();
|
||||
|
||||
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<string, WebSearchProvider>,
|
||||
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<WebSearchToolResult> {
|
||||
// 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<WebSearchToolParams, WebSearchToolResult> {
|
||||
return new WebSearchToolInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types for external use
|
||||
export type {
|
||||
WebSearchToolParams,
|
||||
WebSearchToolResult,
|
||||
WebSearchConfig,
|
||||
WebSearchProviderConfig,
|
||||
} from './types.js';
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<QwenCredentials | null> {
|
||||
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<WebSearchResult> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<WebSearchResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<WebSearchResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
156
packages/core/src/tools/web-search/types.ts
Normal file
156
packages/core/src/tools/web-search/types.ts
Normal file
@@ -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<WebSearchResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
42
packages/core/src/tools/web-search/utils.ts
Normal file
42
packages/core/src/tools/web-search/utils.ts
Normal file
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user