From e40c1cb20d8f77577704ab558a2c89b861ff452f Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 9 Jun 2026 14:22:38 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Tavily=20web?= =?UTF-8?q?=5Fsearch=20MCP=20=E5=B7=A5=E5=85=B7=E6=9B=BF=E4=BB=A3=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E7=9A=84=E5=86=85=E7=BD=AE=20WebSearch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内置 WebSearch 是 Anthropic 服务端工具,当服务通过 ANTHROPIC_BASE_URL 走代理/网关(如 litellm)时网关不实现该工具导致其失效。新增的 web_search 作为客户端 MCP 工具在本进程内通过 Tavily API 执行 HTTP 请求,不依赖上游网关。 - src/websearch/tool.ts: web_search 工具 + createWebSearchMcpServer 工厂 + 纯函数 formatTavilyResponse - src/config.ts: config.websearch(配了 TAVILY_API_KEY 即自动启用) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.ts | 18 ++++ src/websearch/tool.ts | 189 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/websearch/tool.ts diff --git a/src/config.ts b/src/config.ts index bff18fc..37e4b46 100644 --- a/src/config.ts +++ b/src/config.ts @@ -174,6 +174,24 @@ export const config = { maxInjectTokens: parseInt(process.env.MEMORY_MAX_INJECT_TOKENS || '4000', 10), }, + // Web 搜索配置 (Tavily) — 替代网关代理下失效的内置 WebSearch + websearch: { + /** 是否启用 web_search 工具。未显式设置 WEBSEARCH_ENABLED 时,配了 TAVILY_API_KEY 即自动启用 */ + enabled: process.env.WEBSEARCH_ENABLED + ? process.env.WEBSEARCH_ENABLED === 'true' + : Boolean(process.env.TAVILY_API_KEY), + /** 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: parseInt(process.env.WEBSEARCH_MAX_RESULTS || '5', 10), + /** 默认搜索深度: basic (1 credit) | advanced (2 credits) */ + searchDepth: (process.env.WEBSEARCH_DEPTH || 'basic') as 'basic' | 'advanced', + /** 单次请求超时毫秒数 */ + timeoutMs: parseInt(process.env.WEBSEARCH_TIMEOUT_MS || '15000', 10), + }, + // 定时任务配置 cron: { /** 定时任务调度器开关 */ diff --git a/src/websearch/tool.ts b/src/websearch/tool.ts new file mode 100644 index 0000000..0be68fc --- /dev/null +++ b/src/websearch/tool.ts @@ -0,0 +1,189 @@ +// ============================================================ +// 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); + logger.warn({ status: resp.status, query: args.query }, '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()], + }); +} From 3ce85522e284605d6a74cc3b82d08bf9fc34dd77 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 9 Jun 2026 14:22:50 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E5=9C=A8=20executor=20=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=20web-search=20MCP=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.websearch.enabled 且配置了 apiKey 时注册 web-search MCP server; canUseTool 在 readOnly 模式下放行 mcp__web-search__ 工具(联网搜索为只读操作)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/claude/executor.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/claude/executor.ts b/src/claude/executor.ts index b544fcf..fc9b1a2 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'; @@ -694,6 +695,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); @@ -898,6 +904,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: '当前用户处于只读模式,无法使用此工具。' }; From 2cf2e0ffdf52853d503f5445621242b8a44683bc Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 9 Jun 2026 14:22:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test:=20web=5Fsearch=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=20+=20executor=20config=20?= =?UTF-8?q?mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖成功/无结果/缺 key/HTTP 401·429·500/网络错误/超时/max_results 边界/ config 默认值回退/格式化纯函数;executor.test.ts 补充 websearch config mock。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/claude/__tests__/executor.test.ts | 1 + src/websearch/__tests__/tool.test.ts | 218 ++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/websearch/__tests__/tool.test.ts diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 277e2f2..647848b 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/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); + }); +}); From aef2077e3a21fdcdfcc0a6fe5821779355158357 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 9 Jun 2026 14:22:50 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20.env.example=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20Tavily=20web=20=E6=90=9C=E7=B4=A2=E9=85=8D=E7=BD=AE=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.env.example b/.env.example index 97f7086..f32651d 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,22 @@ 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) +# TAVILY_API_KEY=tvly-xxxxxxxx +# 显式开关 (留空时:配了 TAVILY_API_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 From 3afc7608d22053ccf187a1381a88b5a8df875cfc Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 9 Jun 2026 14:30:53 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=AE=88=E5=8D=AB=20websearch=20?= =?UTF-8?q?=E6=95=B0=E5=80=BC=E9=85=8D=E7=BD=AE=20+=20=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BD=93=E6=94=B9=E5=86=99=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 发现的两处低危问题: - 非数字的 WEBSEARCH_TIMEOUT_MS/WEBSEARCH_MAX_RESULTS 会得到 NaN, setTimeout(fn, NaN) 被强转为 0 立即触发,导致每次搜索秒超时。 新增 parsePositiveInt 守卫,非正整数回退默认值。 - Tavily 非 2xx 错误的完整 body 改为只写服务端日志(截断 500 字), 返回聊天的提示仍由 describeHttpError 截断到 200 字。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/config.test.ts | 58 ++++++++++++++++++++++++++++++++++++ src/config.ts | 13 ++++++-- src/websearch/tool.ts | 3 +- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index a8bd15b..90e1178 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -59,4 +59,62 @@ 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('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/config.ts b/src/config.ts index 37e4b46..74eafab 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 { @@ -185,11 +194,11 @@ export const config = { /** Tavily API base URL */ baseUrl: process.env.TAVILY_BASE_URL || 'https://api.tavily.com', /** 默认返回结果数 (1-20) */ - maxResults: parseInt(process.env.WEBSEARCH_MAX_RESULTS || '5', 10), + maxResults: parsePositiveInt(process.env.WEBSEARCH_MAX_RESULTS, 5), /** 默认搜索深度: basic (1 credit) | advanced (2 credits) */ searchDepth: (process.env.WEBSEARCH_DEPTH || 'basic') as 'basic' | 'advanced', /** 单次请求超时毫秒数 */ - timeoutMs: parseInt(process.env.WEBSEARCH_TIMEOUT_MS || '15000', 10), + timeoutMs: parsePositiveInt(process.env.WEBSEARCH_TIMEOUT_MS, 15000), }, // 定时任务配置 diff --git a/src/websearch/tool.ts b/src/websearch/tool.ts index 0be68fc..17e6548 100644 --- a/src/websearch/tool.ts +++ b/src/websearch/tool.ts @@ -135,7 +135,8 @@ export function webSearchTool() { if (!resp.ok) { const body = await resp.text().catch(() => ''); const detail = describeHttpError(resp.status, body); - logger.warn({ status: resp.status, query: args.query }, 'web_search Tavily API error'); + // 完整 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, From 75c28edfe8370b11d543d65e9f84454f311b3a78 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Tue, 9 Jun 2026 14:33:53 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E6=B2=A1=E9=85=8D=20TAVILY=5FAPI=5F?= =?UTF-8?q?KEY=20=E6=97=B6=E4=B8=80=E5=BE=8B=E4=B8=8D=E5=90=AF=E7=94=A8=20?= =?UTF-8?q?web=5Fsearch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enabled 改为以 key 为前提:未配 TAVILY_API_KEY 时即使 WEBSEARCH_ENABLED=true 也保持禁用。配了 key 后默认启用,WEBSEARCH_ENABLED 仅作为已配 key 情况下的 显式开/关覆盖。补充对应回归测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 ++- src/__tests__/config.test.ts | 7 +++++++ src/config.ts | 11 +++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index f32651d..a256d3b 100644 --- a/.env.example +++ b/.env.example @@ -88,8 +88,9 @@ FEISHU_TOOLS_CALENDAR=true # 替代内置 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 -# 显式开关 (留空时:配了 TAVILY_API_KEY 即自动启用;设为 false 可强制关闭) +# 显式开关 (仅在已配 key 时生效:留空=自动启用;设为 false 可强制关闭) # WEBSEARCH_ENABLED=true # 默认返回结果数 (1-20,默认 5) # WEBSEARCH_MAX_RESULTS=5 diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 90e1178..1d74e3d 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -109,6 +109,13 @@ describe('config', () => { 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'); diff --git a/src/config.ts b/src/config.ts index 74eafab..454fffc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -185,10 +185,13 @@ export const config = { // Web 搜索配置 (Tavily) — 替代网关代理下失效的内置 WebSearch websearch: { - /** 是否启用 web_search 工具。未显式设置 WEBSEARCH_ENABLED 时,配了 TAVILY_API_KEY 即自动启用 */ - enabled: process.env.WEBSEARCH_ENABLED - ? process.env.WEBSEARCH_ENABLED === 'true' - : Boolean(process.env.TAVILY_API_KEY), + /** + * 是否启用 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 */