Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
1 change: 1 addition & 0 deletions src/claude/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
}));

Expand Down
11 changes: 11 additions & 0 deletions src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: '当前用户处于只读模式,无法使用此工具。' };
Expand Down
30 changes: 30 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, GroupConfig> {
if (!raw?.trim()) return {};
try {
Expand Down Expand Up @@ -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: {
/** 定时任务调度器开关 */
Expand Down
218 changes: 218 additions & 0 deletions src/websearch/__tests__/tool.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<unknown>;
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);
});
});
Loading
Loading