feat: 添加 web_search 工具和权限管控

- 新增 web_search 工具,使用 Tavily SDK 进行网络搜索
- 支持搜索深度(basic/advanced)和主题(general/news/finance)配置
- 新增 WebPermissionChecker 权限检查器
- 搜索操作默认需要用户确认,支持会话级权限记忆
- 配置文件支持 tavilyApiKey 存储
This commit is contained in:
2025-12-11 01:32:32 +08:00
parent 924fd7b9c6
commit 3c922fe16c
12 changed files with 744 additions and 1 deletions
+1
View File
@@ -1,3 +1,4 @@
export type { PermissionChecker, BasePermissionConfig } from './base.js';
export { BashPermissionChecker } from './bash.js';
export { FilePermissionChecker } from './file.js';
export { WebPermissionChecker } from './web.js';
+159
View File
@@ -0,0 +1,159 @@
import type {
WebPermissionConfig,
WebPermissionContext,
PermissionCheckResult,
PermissionDecision,
PermissionContext,
} from '../types.js';
import type { PermissionChecker } from './base.js';
// 默认 Web 权限配置
const DEFAULT_CONFIG: WebPermissionConfig = {
default: 'ask', // 默认需要确认
allowAdvancedSearch: true,
allowedTopics: [], // 空数组表示允许所有主题
};
/**
* Web 搜索权限检查器
* 控制网络搜索操作的权限
*/
export class WebPermissionChecker implements PermissionChecker {
readonly name = 'web';
private config: WebPermissionConfig;
private askCallback?: (ctx: PermissionContext) => Promise<PermissionDecision>;
private sessionPermissions = new Map<string, 'allow' | 'deny'>();
constructor() {
this.config = { ...DEFAULT_CONFIG };
}
/**
* 设置权限询问回调
*/
setAskCallback(callback: (ctx: PermissionContext) => Promise<PermissionDecision>): void {
this.askCallback = callback;
}
/**
* 检查 Web 搜索权限
*/
async checkWebPermission(ctx: WebPermissionContext): Promise<PermissionCheckResult> {
const { query, searchDepth, topic } = ctx;
// 1. 检查深度搜索权限
if (searchDepth === 'advanced' && !this.config.allowAdvancedSearch) {
return {
allowed: false,
action: 'deny',
reason: '不允许深度搜索',
};
}
// 2. 检查主题限制
if (this.config.allowedTopics.length > 0 && topic) {
if (!this.config.allowedTopics.includes(topic)) {
return {
allowed: false,
action: 'deny',
reason: `不允许搜索主题: ${topic}`,
};
}
}
// 3. 检查会话级别的临时权限
const sessionKey = `web_search`;
const sessionPerm = this.sessionPermissions.get(sessionKey);
if (sessionPerm === 'allow') {
return {
allowed: true,
action: 'allow',
reason: '本次会话已允许网络搜索',
};
}
if (sessionPerm === 'deny') {
return {
allowed: false,
action: 'deny',
reason: '本次会话已拒绝网络搜索',
};
}
// 4. 根据默认策略处理
const action = this.config.default;
if (action === 'allow') {
return {
allowed: true,
action: 'allow',
reason: '默认允许网络搜索',
};
}
if (action === 'deny') {
return {
allowed: false,
action: 'deny',
reason: '默认拒绝网络搜索',
};
}
// action === 'ask'
if (!this.askCallback) {
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
reason: `搜索: ${query}`,
};
}
// 调用回调询问用户
const decision = await this.askCallback({
command: `web_search: ${query}`,
workdir: process.cwd(),
});
if (decision.remember) {
this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny');
}
return {
allowed: decision.allow,
action: decision.allow ? 'allow' : 'deny',
reason: decision.allow ? '用户允许' : '用户拒绝',
};
}
/**
* 实现 PermissionChecker 接口的 check 方法
* 从通用 PermissionContext 中提取 Web 搜索信息
*/
async check(ctx: PermissionContext): Promise<PermissionCheckResult> {
// 从 command 中提取搜索查询
const query = ctx.command.replace(/^web_search:\s*/, '');
return this.checkWebPermission({ query });
}
/**
* 清除会话权限
*/
clearSessionPermissions(): void {
this.sessionPermissions.clear();
}
/**
* 获取当前配置
*/
getConfig(): WebPermissionConfig {
return { ...this.config };
}
/**
* 更新配置
*/
setConfig(config: Partial<WebPermissionConfig>): void {
this.config = { ...this.config, ...config };
}
}
+19
View File
@@ -3,10 +3,12 @@ import type {
PermissionCheckResult,
PermissionDecision,
FilePermissionContext,
WebPermissionContext,
} from './types.js';
import type { PermissionChecker } from './checkers/base.js';
import { BashPermissionChecker } from './checkers/bash.js';
import { FilePermissionChecker } from './checkers/file.js';
import { WebPermissionChecker } from './checkers/web.js';
/**
* 权限管理器
@@ -20,6 +22,7 @@ export class PermissionManager {
// 注册默认的检查器
this.registerChecker(new BashPermissionChecker(projectRoot));
this.registerChecker(new FilePermissionChecker(projectRoot));
this.registerChecker(new WebPermissionChecker());
}
/**
@@ -100,6 +103,22 @@ export class PermissionManager {
return fileChecker.checkFilePermission(ctx);
}
/**
* 检查 Web 搜索权限(便捷方法)
*/
async checkWebPermission(ctx: WebPermissionContext): Promise<PermissionCheckResult> {
const webChecker = this.getChecker<WebPermissionChecker>('web');
if (!webChecker) {
return {
allowed: false,
action: 'ask',
needsConfirmation: true,
reason: 'Web 权限检查器未注册',
};
}
return webChecker.checkWebPermission(ctx);
}
/**
* 清除所有检查器的会话权限
*/
+18
View File
@@ -88,3 +88,21 @@ export interface PermissionDecision {
allow: boolean;
remember?: boolean; // 是否记住这个决定
}
// Web 搜索权限请求上下文
export interface WebPermissionContext {
query: string; // 搜索查询
searchDepth?: 'basic' | 'advanced'; // 搜索深度
topic?: 'general' | 'news' | 'finance'; // 搜索主题
maxResults?: number; // 最大结果数
}
// Web 权限配置
export interface WebPermissionConfig {
// 默认策略
default: PermissionAction;
// 是否允许深度搜索
allowAdvancedSearch: boolean;
// 搜索主题限制(空数组表示允许所有)
allowedTopics: ('general' | 'news' | 'finance')[];
}
+21
View File
@@ -0,0 +1,21 @@
搜索网络获取最新信息。使用 Tavily API 进行智能搜索,返回相关网页内容和 AI 摘要。
这是进行网络搜索的首选工具,不要使用 curl 或 bash 命令来搜索网络。
适用场景:
- 查询最新新闻、事件、游戏更新
- 搜索技术文档、API 参考
- 获取实时数据(股价、天气等)
- 查找开源项目、库的信息
- 了解最新的技术趋势
参数说明:
- query: 搜索关键词(必填)
- max_results: 返回结果数量,1-20,默认 5
- search_depth: "basic" 快速搜索 / "advanced" 深度搜索
- topic: "general" 通用 / "news" 新闻 / "finance" 财经
- include_answer: 是否包含 AI 摘要,默认 true
返回内容:
- AI 生成的摘要答案
- 相关网页列表(标题、链接、内容摘要)
+6
View File
@@ -23,6 +23,9 @@ import {
deleteFileTool,
} from './filesystem/index.js';
// Web 工具
import { webSearchTool } from './web/index.js';
// 所有工具列表(用于注册)
const allToolsWithMetadata: ToolWithMetadata[] = [
// 核心工具 (deferLoading: false)
@@ -43,6 +46,9 @@ const allToolsWithMetadata: ToolWithMetadata[] = [
moveFileTool,
copyFileTool,
deleteFileTool,
// Web 工具 (deferLoading: true)
webSearchTool,
];
// 注册所有工具到 registry
+1 -1
View File
@@ -62,7 +62,7 @@ export const toolSearchTool: ToolWithMetadata = {
return {
success: true,
output: `找到 ${results.length} 个相关工具:\n\n${toolList}\n\n这些工具现在可以使用了。请选择合适的工具来完成任务`,
output: `找到 ${results.length} 个相关工具:\n\n${toolList}\n\n重要:这些工具现在已经可以直接调用了。请立即使用合适的工具(如 web_search)来完成任务,不要使用 bash 命令代替`,
};
},
};
+1
View File
@@ -0,0 +1 @@
export { webSearchTool } from './web_search.js';
+138
View File
@@ -0,0 +1,138 @@
import { tavily } from '@tavily/core';
import type { ToolResult } from '../../types/index.js';
import type { ToolWithMetadata } from '../types.js';
import { loadDescription } from '../load_description.js';
import { getConfig } from '../../utils/config.js';
import { getPermissionManager } from '../../permission/index.js';
export const webSearchTool: ToolWithMetadata = {
name: 'web_search',
description: loadDescription('web_search'),
metadata: {
name: 'web_search',
category: 'web',
description: '搜索网络获取最新信息',
keywords: ['search', 'web', 'internet', 'google', 'query', '搜索', '网络', '查询', '互联网'],
deferLoading: false, // 核心工具,始终可用
},
parameters: {
query: {
type: 'string',
description: '搜索查询关键词',
required: true,
},
max_results: {
type: 'number',
description: '返回结果数量(默认 5,最大 20)',
required: false,
},
search_depth: {
type: 'string',
description: '搜索深度: "basic" 快速搜索,"advanced" 深度搜索(默认 basic',
required: false,
},
topic: {
type: 'string',
description: '搜索主题: "general" 通用,"news" 新闻,"finance" 财经(默认 general',
required: false,
},
include_answer: {
type: 'boolean',
description: '是否包含 AI 生成的摘要答案(默认 true)',
required: false,
},
},
execute: async (params: Record<string, unknown>): Promise<ToolResult> => {
const query = params.query as string;
const maxResults = Math.min((params.max_results as number) || 5, 20);
const searchDepth = (params.search_depth as 'basic' | 'advanced') || 'basic';
const topic = (params.topic as 'general' | 'news' | 'finance') || 'general';
const includeAnswer = params.include_answer !== false;
// 权限检查
const permissionManager = getPermissionManager();
const permResult = await permissionManager.checkWebPermission({
query,
searchDepth,
topic,
maxResults,
});
if (!permResult.allowed) {
// 如果需要用户确认但没有设置回调,返回等待确认的状态
if (permResult.needsConfirmation) {
return {
success: false,
output: '',
error: `需要用户确认网络搜索: "${query}"\n原因: ${permResult.reason || '需要权限确认'}`,
};
}
return {
success: false,
output: '',
error: `网络搜索权限被拒绝: ${permResult.reason || '搜索不被允许'}`,
};
}
// 获取 Tavily API Key
const config = getConfig();
const apiKey = process.env.TAVILY_API_KEY || config.tavilyApiKey;
if (!apiKey) {
return {
success: false,
output: '',
error: '未配置 Tavily API Key。请设置环境变量 TAVILY_API_KEY 或在配置文件中添加 tavilyApiKey。',
};
}
try {
// 使用 Tavily SDK
const client = tavily({ apiKey });
const response = await client.search(query, {
searchDepth,
topic,
maxResults,
includeAnswer,
});
// 格式化输出
let output = `## 搜索结果: "${query}"\n\n`;
// 如果有 AI 摘要答案
if (response.answer) {
output += `### 摘要\n${response.answer}\n\n`;
}
// 搜索结果列表
if (response.results && response.results.length > 0) {
output += `### 相关链接 (${response.results.length} 条)\n\n`;
for (let i = 0; i < response.results.length; i++) {
const result = response.results[i];
output += `**${i + 1}. ${result.title}**\n`;
output += `链接: ${result.url}\n`;
// 截断过长的内容
const content = result.content.length > 300
? result.content.substring(0, 300) + '...'
: result.content;
output += `${content}\n\n`;
}
} else {
output += '未找到相关结果。\n';
}
return {
success: true,
output,
};
} catch (error) {
return {
success: false,
output: '',
error: `搜索失败: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
};
+14
View File
@@ -12,6 +12,7 @@ interface StoredConfig {
deepseekApiKey?: string;
model?: string;
maxTokens?: number;
tavilyApiKey?: string;
}
// 默认模型配置
@@ -40,6 +41,19 @@ const DEFAULT_SYSTEM_PROMPT = `你是一个运行在终端中的 AI 编程助手
当前工作目录: ${process.cwd()}
操作系统: ${process.platform}`;
// 获取原始配置(包含所有字段)
export function getConfig(): StoredConfig {
if (fs.existsSync(CONFIG_FILE)) {
try {
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(content);
} catch {
return {};
}
}
return {};
}
// 加载配置
export function loadConfig(): AgentConfig {
// 从环境变量获取