diff --git a/.env.example b/.env.example index 412b0a6..d4e4e5b 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,23 @@ FEISHU_TOOLS_CALENDAR=true # 定时任务数据库路径 (默认 ./data/cron.db) # CRON_DB_PATH=./data/cron.db +# === Web 搜索配置 (Tavily) === +# 替代内置 WebSearch — 当通过 ANTHROPIC_BASE_URL 走代理/网关时,内置 WebSearch(Anthropic 服务端工具)会失效, +# 配置 TAVILY_API_KEY 后会自动启用客户端 web_search MCP 工具。 +# Tavily API Key (https://app.tavily.com 获取,形如 tvly-xxx) +# 未配置 key 时 web_search 一律不启用(即使 WEBSEARCH_ENABLED=true 也无效) +# TAVILY_API_KEY=tvly-xxxxxxxx +# 显式开关 (仅在已配 key 时生效:留空=自动启用;设为 false 可强制关闭) +# WEBSEARCH_ENABLED=true +# 默认返回结果数 (1-20,默认 5) +# WEBSEARCH_MAX_RESULTS=5 +# 默认搜索深度 basic (1 credit) | advanced (2 credits,默认 basic) +# WEBSEARCH_DEPTH=basic +# 单次请求超时毫秒数 (默认 15000) +# WEBSEARCH_TIMEOUT_MS=15000 +# Tavily API base URL (一般无需修改) +# TAVILY_BASE_URL=https://api.tavily.com + # === 记忆系统配置 === # 记忆系统总开关 (默认关闭) # MEMORY_ENABLED=false diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index a8bd15b..1d74e3d 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -59,4 +59,69 @@ describe('config', () => { expect(config.claude.defaultWorkDir).toBe(dirname(process.cwd())); }); }); + + describe('parsePositiveInt', () => { + it('parses valid positive integers', async () => { + const { parsePositiveInt } = await loadConfig(); + expect(parsePositiveInt('15000', 999)).toBe(15000); + expect(parsePositiveInt('1', 999)).toBe(1); + }); + + it('falls back when undefined or empty', async () => { + const { parsePositiveInt } = await loadConfig(); + expect(parsePositiveInt(undefined, 15000)).toBe(15000); + expect(parsePositiveInt('', 15000)).toBe(15000); + }); + + it('falls back on non-numeric values (guards setTimeout(fn, NaN) firing instantly)', async () => { + const { parsePositiveInt } = await loadConfig(); + expect(parsePositiveInt('abc', 15000)).toBe(15000); + expect(parsePositiveInt('none', 15000)).toBe(15000); + }); + + it('falls back on zero and negative values', async () => { + const { parsePositiveInt } = await loadConfig(); + expect(parsePositiveInt('0', 15000)).toBe(15000); + expect(parsePositiveInt('-5', 15000)).toBe(15000); + }); + }); + + describe('websearch config', () => { + it('auto-enables when TAVILY_API_KEY is present', async () => { + vi.stubEnv('TAVILY_API_KEY', 'tvly-abc'); + delete process.env.WEBSEARCH_ENABLED; + const { config } = await loadConfig(); + expect(config.websearch.enabled).toBe(true); + expect(config.websearch.apiKey).toBe('tvly-abc'); + }); + + it('stays disabled when no key and no explicit enable', async () => { + vi.stubEnv('TAVILY_API_KEY', ''); + delete process.env.WEBSEARCH_ENABLED; + const { config } = await loadConfig(); + expect(config.websearch.enabled).toBe(false); + }); + + it('WEBSEARCH_ENABLED=false overrides key presence', async () => { + vi.stubEnv('TAVILY_API_KEY', 'tvly-abc'); + vi.stubEnv('WEBSEARCH_ENABLED', 'false'); + const { config } = await loadConfig(); + expect(config.websearch.enabled).toBe(false); + }); + + it('stays disabled without a key even when WEBSEARCH_ENABLED=true', async () => { + vi.stubEnv('TAVILY_API_KEY', ''); + vi.stubEnv('WEBSEARCH_ENABLED', 'true'); + const { config } = await loadConfig(); + expect(config.websearch.enabled).toBe(false); + }); + + it('falls back to safe timeout/maxResults on non-numeric env', async () => { + vi.stubEnv('WEBSEARCH_TIMEOUT_MS', 'abc'); + vi.stubEnv('WEBSEARCH_MAX_RESULTS', 'xyz'); + const { config } = await loadConfig(); + expect(config.websearch.timeoutMs).toBe(15000); + expect(config.websearch.maxResults).toBe(5); + }); + }); }); diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 9209ba5..1305e9d 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -17,6 +17,7 @@ vi.mock('../../config.js', () => ({ workspace: { baseDir: '/tmp/workspaces' }, feishu: { tools: { enabled: false, doc: true, wiki: true, drive: true, bitable: true } }, cron: { enabled: false }, + websearch: { enabled: false, apiKey: '', baseUrl: 'https://api.tavily.com', maxResults: 5, searchDepth: 'basic', timeoutMs: 15000 }, }, })); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index a958654..9ed3429 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -12,6 +12,7 @@ import { createMemorySearchMcpServer } from '../memory/tools/memory-search.js'; import { getMemoryStore, getHybridSearch, isMemoryEnabled } from '../memory/init.js'; import { createCronMcpServer } from '../cron/tool.js'; import { getCronScheduler } from '../cron/init.js'; +import { createWebSearchMcpServer } from '../websearch/tool.js'; import { feishuClientContext } from '../feishu/client.js'; import { isAutoWorkspacePath, isServiceOwnRepo, isInsideSourceRepo } from '../workspace/isolation.js'; import { detectRuntime } from '../utils/runtime.js'; @@ -724,6 +725,11 @@ export class ClaudeExecutor { } } + // Web search MCP tool (Tavily) — 替代网关代理下失效的内置 WebSearch + if (config.websearch.enabled && config.websearch.apiKey) { + mcpServers['web-search'] = createWebSearchMcpServer(); + } + // 合并调用方传入的额外 MCP servers(如 discussion-tools) if (input.additionalMcpServers) { Object.assign(mcpServers, input.additionalMcpServers); @@ -932,6 +938,11 @@ export class ClaudeExecutor { logger.info({ toolName, readOnly }, 'canUseTool allowed — cron tool in read-only mode'); return { behavior: 'allow' as const, updatedInput: inputObj }; } + // web-search: 联网搜索,只读操作(不触碰代码仓库),readonly 下允许 + if (toolName.startsWith('mcp__web-search__')) { + logger.info({ toolName, readOnly }, 'canUseTool allowed — web search tool in read-only mode'); + return { behavior: 'allow' as const, updatedInput: inputObj }; + } // 所有其他 MCP 工具(含 workspace-manager、未来新增):deny logger.info({ toolName, readOnly }, 'canUseTool denied — read-only mode (MCP tool)'); return { behavior: 'deny' as const, message: '当前用户处于只读模式,无法使用此工具。' }; diff --git a/src/config.ts b/src/config.ts index b5c6f25..d5febe2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,15 @@ dotenv.config(); import { dirname } from 'node:path'; import type { GroupConfig } from './agent/types.js'; +/** + * parseInt 的正整数守卫:非数字或 <=0 的环境变量值回退到默认值。 + * 防止形如 setTimeout(fn, NaN) 被强转为 0 立即触发的隐患。 + */ +export function parsePositiveInt(raw: string | undefined, fallback: number): number { + const n = parseInt(raw ?? '', 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + function parseGroupConfigs(raw?: string): Record { if (!raw?.trim()) return {}; try { @@ -187,6 +196,27 @@ export const config = { strictRepositoryFiltering: process.env.MEMORY_STRICT_REPO_FILTERING === 'true', }, + // Web 搜索配置 (Tavily) — 替代网关代理下失效的内置 WebSearch + websearch: { + /** + * 是否启用 web_search 工具。 + * 前提:必须配置 TAVILY_API_KEY,否则一律不启用(即使 WEBSEARCH_ENABLED=true)。 + * 配了 key 后默认启用,WEBSEARCH_ENABLED 仅作为已配 key 情况下的显式开/关覆盖。 + */ + enabled: Boolean(process.env.TAVILY_API_KEY) && + (process.env.WEBSEARCH_ENABLED ? process.env.WEBSEARCH_ENABLED === 'true' : true), + /** Tavily API Key (tvly-xxx) */ + apiKey: process.env.TAVILY_API_KEY || '', + /** Tavily API base URL */ + baseUrl: process.env.TAVILY_BASE_URL || 'https://api.tavily.com', + /** 默认返回结果数 (1-20) */ + maxResults: parsePositiveInt(process.env.WEBSEARCH_MAX_RESULTS, 5), + /** 默认搜索深度: basic (1 credit) | advanced (2 credits) */ + searchDepth: (process.env.WEBSEARCH_DEPTH || 'basic') as 'basic' | 'advanced', + /** 单次请求超时毫秒数 */ + timeoutMs: parsePositiveInt(process.env.WEBSEARCH_TIMEOUT_MS, 15000), + }, + // 定时任务配置 cron: { /** 定时任务调度器开关 */ diff --git a/src/websearch/__tests__/tool.test.ts b/src/websearch/__tests__/tool.test.ts new file mode 100644 index 0000000..a0ee883 --- /dev/null +++ b/src/websearch/__tests__/tool.test.ts @@ -0,0 +1,218 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ============================================================ +// Mocks +// ============================================================ + +// Mutable config so individual tests can flip apiKey / depth etc. +// vi.hoisted so the value exists when the hoisted vi.mock factory runs. +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + websearch: { + enabled: true, + apiKey: 'tvly-test-key', + baseUrl: 'https://api.tavily.com', + maxResults: 5, + searchDepth: 'basic', + timeoutMs: 15000, + }, + }, +})); +vi.mock('../../config.js', () => ({ config: mockConfig })); + +vi.mock('../../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// Capture the handler registered via tool() +let capturedHandler: (args: Record) => Promise; +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + tool: (_name: string, _desc: string, _schema: unknown, handler: unknown) => { + capturedHandler = handler as typeof capturedHandler; + return { name: _name, handler }; + }, + createSdkMcpServer: (cfg: unknown) => ({ type: 'mock-server', cfg }), +})); + +import { webSearchTool, createWebSearchMcpServer, formatTavilyResponse } from '../tool.js'; + +// ============================================================ +// formatTavilyResponse (pure) +// ============================================================ + +describe('formatTavilyResponse', () => { + it('formats answer + results', () => { + const text = formatTavilyResponse( + { + answer: 'Messi is a footballer.', + results: [ + { title: 'Lionel Messi', url: 'https://en.wikipedia.org/wiki/Lionel_Messi', content: 'Argentine footballer.', score: 0.99 }, + { title: 'Messi stats', url: 'https://example.com/messi', content: 'Career stats.' }, + ], + }, + 'who is messi', + ); + expect(text).toContain('**摘要**: Messi is a footballer.'); + expect(text).toContain('找到 2 条结果'); + expect(text).toContain('1. Lionel Messi'); + expect(text).toContain('https://en.wikipedia.org/wiki/Lionel_Messi'); + expect(text).toContain('Argentine footballer.'); + expect(text).toContain('2. Messi stats'); + }); + + it('handles empty results', () => { + const text = formatTavilyResponse({ results: [] }, 'nonexistent zzz'); + expect(text).toContain('未找到 "nonexistent zzz" 的相关结果'); + }); + + it('handles missing answer and missing fields gracefully', () => { + const text = formatTavilyResponse({ results: [{ url: 'https://x.com' }] }, 'q'); + expect(text).not.toContain('**摘要**'); + expect(text).toContain('1. (无标题)'); + expect(text).toContain('https://x.com'); + }); +}); + +// ============================================================ +// web_search handler +// ============================================================ + +describe('web_search tool handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + // reset config defaults each test + mockConfig.websearch.apiKey = 'tvly-test-key'; + mockConfig.websearch.baseUrl = 'https://api.tavily.com'; + mockConfig.websearch.maxResults = 5; + mockConfig.websearch.searchDepth = 'basic'; + mockConfig.websearch.timeoutMs = 15000; + global.fetch = vi.fn(); + webSearchTool(); // registers tool() → captures handler + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns error when apiKey is missing', async () => { + mockConfig.websearch.apiKey = ''; + const result = await capturedHandler({ query: 'hello' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('TAVILY_API_KEY'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('calls Tavily with correct endpoint, auth header and body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ results: [{ title: 'T', url: 'https://u', content: 'c' }] }), + }); + + await capturedHandler({ query: 'typescript news', max_results: 3, topic: 'news', time_range: 'week' }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, opts] = global.fetch.mock.calls[0]; + expect(url).toBe('https://api.tavily.com/search'); + expect(opts.method).toBe('POST'); + expect(opts.headers.Authorization).toBe('Bearer tvly-test-key'); + expect(opts.headers['Content-Type']).toBe('application/json'); + const body = JSON.parse(opts.body); + expect(body.query).toBe('typescript news'); + expect(body.max_results).toBe(3); + expect(body.topic).toBe('news'); + expect(body.time_range).toBe('week'); + expect(body.search_depth).toBe('basic'); + expect(body.include_answer).toBe(true); + }); + + it('returns formatted results on success', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + answer: 'An answer.', + results: [{ title: 'Result One', url: 'https://one.com', content: 'snippet' }], + }), + }); + + const result = await capturedHandler({ query: 'q' }); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('**摘要**: An answer.'); + expect(result.content[0].text).toContain('Result One'); + expect(result.content[0].text).toContain('https://one.com'); + }); + + it('clamps max_results to [1, 20]', async () => { + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ results: [] }) }); + + await capturedHandler({ query: 'q', max_results: 99 }); + expect(JSON.parse(global.fetch.mock.calls[0][1].body).max_results).toBe(20); + + global.fetch.mockClear(); + await capturedHandler({ query: 'q', max_results: 0 }); + expect(JSON.parse(global.fetch.mock.calls[0][1].body).max_results).toBe(1); + }); + + it('falls back to config defaults for depth and max_results', async () => { + mockConfig.websearch.maxResults = 8; + mockConfig.websearch.searchDepth = 'advanced'; + global.fetch.mockResolvedValue({ ok: true, json: async () => ({ results: [] }) }); + + await capturedHandler({ query: 'q' }); + const body = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(body.max_results).toBe(8); + expect(body.search_depth).toBe('advanced'); + }); + + it('maps HTTP 401 to a key error', async () => { + global.fetch.mockResolvedValue({ ok: false, status: 401, text: async () => 'unauthorized' }); + const result = await capturedHandler({ query: 'q' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('401'); + expect(result.content[0].text).toContain('TAVILY_API_KEY'); + }); + + it('maps HTTP 429 to a rate-limit error', async () => { + global.fetch.mockResolvedValue({ ok: false, status: 429, text: async () => '' }); + const result = await capturedHandler({ query: 'q' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('限流'); + }); + + it('maps other HTTP errors with status + body snippet', async () => { + global.fetch.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }); + const result = await capturedHandler({ query: 'q' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('HTTP 500'); + expect(result.content[0].text).toContain('boom'); + }); + + it('handles network errors', async () => { + global.fetch.mockRejectedValue(new Error('ECONNREFUSED')); + const result = await capturedHandler({ query: 'q' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('搜索请求失败'); + expect(result.content[0].text).toContain('ECONNREFUSED'); + }); + + it('reports timeout on AbortError', async () => { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + global.fetch.mockRejectedValue(abortErr); + const result = await capturedHandler({ query: 'q' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('超时'); + }); +}); + +// ============================================================ +// createWebSearchMcpServer +// ============================================================ + +describe('createWebSearchMcpServer', () => { + it('builds an SDK MCP server named web-search', () => { + const server = createWebSearchMcpServer() as any; + expect(server.cfg.name).toBe('web-search'); + expect(server.cfg.tools).toHaveLength(1); + }); +}); diff --git a/src/websearch/tool.ts b/src/websearch/tool.ts new file mode 100644 index 0000000..17e6548 --- /dev/null +++ b/src/websearch/tool.ts @@ -0,0 +1,190 @@ +// ============================================================ +// Web Search — MCP Tool: web_search (Tavily) +// +// 通过 Tavily Search API 提供联网搜索能力。 +// +// 为什么需要它:Claude Code 内置的 WebSearch 是 Anthropic 服务端工具, +// 当本服务通过 ANTHROPIC_BASE_URL 走代理/网关(如 litellm)时, +// 网关并不实现该服务端工具,导致内置 WebSearch 失效。 +// MCP 工具在本进程内(客户端)执行 HTTP 请求,不依赖上游网关,因此可用。 +// ============================================================ + +import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { config } from '../config.js'; +import { logger } from '../utils/logger.js'; + +/** Tavily /search 接口返回的单条结果(仅取用到的字段) */ +interface TavilyResult { + title?: string; + url?: string; + content?: string; + score?: number; +} + +/** Tavily /search 接口返回体(仅取用到的字段) */ +export interface TavilyResponse { + query?: string; + answer?: string; + results?: TavilyResult[]; + response_time?: number; +} + +/** + * 把 Tavily 返回体格式化为适合在飞书聊天里阅读的纯文本。 + * 纯函数,便于单元测试。 + */ +export function formatTavilyResponse(data: TavilyResponse, query: string): string { + const lines: string[] = []; + + if (data.answer?.trim()) { + lines.push(`**摘要**: ${data.answer.trim()}`, ''); + } + + const results = data.results ?? []; + if (results.length === 0) { + lines.push(`未找到 "${query}" 的相关结果。`); + return lines.join('\n'); + } + + lines.push(`找到 ${results.length} 条结果:`, ''); + results.forEach((r, i) => { + lines.push(`${i + 1}. ${r.title?.trim() || '(无标题)'}`); + if (r.url) lines.push(` ${r.url}`); + if (r.content?.trim()) lines.push(` ${r.content.trim()}`); + lines.push(''); + }); + + return lines.join('\n').trimEnd(); +} + +/** 把 HTTP 状态码映射为可读的错误提示 */ +function describeHttpError(status: number, body: string): string { + const snippet = body.slice(0, 200); + switch (status) { + case 401: + return 'Tavily API key 无效或缺失 (401)。请检查 TAVILY_API_KEY 配置。'; + case 429: + return 'Tavily 请求过于频繁,已被限流 (429)。请稍后重试。'; + case 432: + case 433: + return `Tavily 套餐额度已用尽 (${status})。`; + default: + return `Tavily 搜索失败 (HTTP ${status})${snippet ? `: ${snippet}` : ''}`; + } +} + +/** + * web_search MCP 工具定义。 + * 返回 SDK tool(),handler 内通过 fetch 调用 Tavily /search。 + */ +export function webSearchTool() { + return tool( + 'web_search', + [ + '联网搜索 — 通过 Tavily 搜索引擎获取最新的网络信息。', + '当需要查询实时信息、新闻、文档、未知概念,或验证训练数据之外的事实时使用。', + '返回相关网页的标题、URL 和摘要片段,并可附带一段综合性答案摘要。', + '', + '注意:这是本服务对内置 WebSearch 的替代实现(内置 WebSearch 在当前网关下不可用)。', + ].join('\n'), + { + query: z.string().describe('搜索关键词或自然语言问题'), + max_results: z.number().optional().describe('返回结果数 (1-20,默认 5)'), + topic: z.enum(['general', 'news', 'finance']).optional() + .describe('搜索主题:general(默认) / news(新闻) / finance(财经)'), + search_depth: z.enum(['basic', 'advanced']).optional() + .describe('搜索深度:basic(默认,1 credit) / advanced(更全面,2 credits)'), + time_range: z.enum(['day', 'week', 'month', 'year']).optional() + .describe('时间范围限制(可选),如 news 主题查最近一周用 week'), + include_answer: z.boolean().optional() + .describe('是否返回综合答案摘要(默认 true)'), + }, + async (args) => { + const apiKey = config.websearch.apiKey; + if (!apiKey) { + return { + content: [{ type: 'text' as const, text: 'web_search 未配置:缺少 TAVILY_API_KEY 环境变量。' }], + isError: true, + }; + } + + const maxResults = Math.min(Math.max(args.max_results ?? config.websearch.maxResults, 1), 20); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.websearch.timeoutMs); + + try { + const resp = await fetch(`${config.websearch.baseUrl}/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + query: args.query, + search_depth: args.search_depth ?? config.websearch.searchDepth, + topic: args.topic ?? 'general', + max_results: maxResults, + include_answer: args.include_answer ?? true, + ...(args.time_range ? { time_range: args.time_range } : {}), + }), + signal: controller.signal, + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + const detail = describeHttpError(resp.status, body); + // 完整 body 仅写服务端日志(便于排障),返回给聊天的 detail 已被 describeHttpError 截断 + logger.warn({ status: resp.status, query: args.query, body: body.slice(0, 500) }, 'web_search Tavily API error'); + return { + content: [{ type: 'text' as const, text: detail }], + isError: true, + }; + } + + const data = (await resp.json()) as TavilyResponse; + logger.info( + { query: args.query, resultCount: data.results?.length ?? 0, hasAnswer: !!data.answer }, + 'web_search invoked', + ); + + return { + content: [{ type: 'text' as const, text: formatTavilyResponse(data, args.query) }], + }; + } catch (err) { + const isAbort = err instanceof Error && err.name === 'AbortError'; + const msg = isAbort + ? `搜索超时(超过 ${config.websearch.timeoutMs}ms)。请稍后重试或简化查询。` + : `搜索请求失败: ${err instanceof Error ? err.message : String(err)}`; + logger.error({ err: msg, query: args.query, isAbort }, 'web_search failed'); + return { + content: [{ type: 'text' as const, text: msg }], + isError: true, + }; + } finally { + clearTimeout(timer); + } + }, + { + annotations: { + readOnlyHint: true, + destructiveHint: false, + // 查询开放网络,结果不可预先枚举 + openWorldHint: true, + }, + }, + ); +} + +/** + * 创建 web-search MCP 服务器。 + * 每次 query 创建独立实例(与其它 MCP server 一致),工具读取全局 config。 + */ +export function createWebSearchMcpServer() { + return createSdkMcpServer({ + name: 'web-search', + version: '1.0.0', + tools: [webSearchTool()], + }); +}