pigpig-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.skills/code-review/SKILL.md +42 -0
  2. package/dist/agent/loop-detection.js +135 -0
  3. package/dist/agent/loop.js +123 -0
  4. package/dist/agent/retry.js +39 -0
  5. package/dist/agents/registry.js +59 -0
  6. package/dist/agents/spawn.js +124 -0
  7. package/dist/agents/types.js +5 -0
  8. package/dist/commands/agent.js +33 -0
  9. package/dist/commands/context.js +27 -0
  10. package/dist/commands/cron.js +38 -0
  11. package/dist/commands/debug.js +44 -0
  12. package/dist/commands/dream.js +36 -0
  13. package/dist/commands/index.js +10 -0
  14. package/dist/commands/memory.js +40 -0
  15. package/dist/commands/plugin.js +73 -0
  16. package/dist/commands/rag.js +25 -0
  17. package/dist/commands/security.js +44 -0
  18. package/dist/commands/skill.js +84 -0
  19. package/dist/config/init.js +69 -0
  20. package/dist/config/loader.js +52 -0
  21. package/dist/config/schema.js +55 -0
  22. package/dist/context/compressor.js +165 -0
  23. package/dist/context/defense.js +201 -0
  24. package/dist/context/prompt-builder.js +56 -0
  25. package/dist/context/prompt-pipes.js +14 -0
  26. package/dist/context/tool-result-output.js +25 -0
  27. package/dist/context/view.js +185 -0
  28. package/dist/cron/parser.js +27 -0
  29. package/dist/cron/service.js +211 -0
  30. package/dist/cron/store.js +53 -0
  31. package/dist/cron/types.js +1 -0
  32. package/dist/index.js +9 -0
  33. package/dist/main.js +270 -0
  34. package/dist/memory/store.js +175 -0
  35. package/dist/memory/validator.js +62 -0
  36. package/dist/mock-model.js +534 -0
  37. package/dist/plugins/manager.js +97 -0
  38. package/dist/plugins/supabase-plugin.js +111 -0
  39. package/dist/plugins/types.js +1 -0
  40. package/dist/rag/chunker.js +54 -0
  41. package/dist/rag/embedder.js +53 -0
  42. package/dist/rag/search.js +123 -0
  43. package/dist/rag/sqlite-store.js +195 -0
  44. package/dist/rag/store.js +29 -0
  45. package/dist/security/bash-classifier.js +39 -0
  46. package/dist/security/hook.js +53 -0
  47. package/dist/security/roles.js +16 -0
  48. package/dist/session/store.js +60 -0
  49. package/dist/skills/loader.js +100 -0
  50. package/dist/tools/cron-tools.js +94 -0
  51. package/dist/tools/file-tools.js +115 -0
  52. package/dist/tools/index.js +17 -0
  53. package/dist/tools/mcp-client.js +100 -0
  54. package/dist/tools/memory-tools.js +81 -0
  55. package/dist/tools/rag-tools.js +54 -0
  56. package/dist/tools/registry.js +270 -0
  57. package/dist/tools/search-tools.js +116 -0
  58. package/dist/tools/shell-tools.js +39 -0
  59. package/dist/tools/spawn-tools.js +35 -0
  60. package/dist/tools/tool-search.js +17 -0
  61. package/dist/tools/web-search.js +150 -0
  62. package/dist/usage/tracker.js +83 -0
  63. package/package.json +55 -0
  64. package/readme.md +131 -0
@@ -0,0 +1,270 @@
1
+ import { jsonSchema } from 'ai';
2
+ import { canUseTool } from '../security/roles.js';
3
+ import { classifyBashCommand } from '../security/bash-classifier.js';
4
+ const DEFAULT_MAX_RESULT_CHARS = 3000; // 工具执行允许的最大输出字符数
5
+ export class ToolRegistry {
6
+ tools = new Map(); // 工具列表
7
+ mcpClients = []; // 存放正在连接的 MCP 服务器
8
+ // 用三个状态变量来构成一把读写锁
9
+ exclusiveLock = false; // 当前是否有独占锁的持有者
10
+ concurrentCount = 0; // 当前共享锁的持有数
11
+ waitQueue = []; // 等待队列,用来阻塞等待中的 resolve 函数
12
+ // 已发现的工具列表
13
+ discoveredTools = new Set(); // 已搜到的工具列表
14
+ // 当前角色
15
+ currentRole = 'owner';
16
+ hookPipeline;
17
+ register(...tools) {
18
+ for (const tool of tools) {
19
+ this.tools.set(tool.name, tool);
20
+ }
21
+ }
22
+ // 切换权限角色
23
+ setRole(role) {
24
+ this.currentRole = role;
25
+ }
26
+ // 获取当前角色
27
+ getRole() {
28
+ return this.currentRole;
29
+ }
30
+ // hook 管线
31
+ setHookPipeline(pipeline) {
32
+ this.hookPipeline = pipeline;
33
+ }
34
+ unregister(name) {
35
+ this.discoveredTools.delete(name);
36
+ this.tools.delete(name);
37
+ }
38
+ async registerMCPServer(serverName, client) {
39
+ await client.connect(); // 连接到 MCP 服务器
40
+ this.mcpClients.push(client); // 存储 MCP 服务器连接
41
+ const tools = await client.listTools(); // 获取 MCP 服务器中的工具列表
42
+ const registered = [];
43
+ for (const tool of tools) {
44
+ const prefixedName = `mcp__${serverName}__${tool.name}`;
45
+ if (this.tools.has(prefixedName))
46
+ continue;
47
+ const toolClient = client;
48
+ const originalName = tool.name;
49
+ this.register({
50
+ name: prefixedName,
51
+ description: `[MCP:${serverName}] ${tool.description}`,
52
+ parameters: tool.inputSchema,
53
+ isConcurrencySafe: true,
54
+ isReadOnly: true,
55
+ maxResultChars: 3000,
56
+ shouldDefer: true,
57
+ searchHint: `${serverName} ${tool.name} ${tool.description}`,
58
+ execute: async (input) => {
59
+ return toolClient.callTool(originalName, input);
60
+ },
61
+ });
62
+ registered.push(prefixedName);
63
+ }
64
+ return registered;
65
+ }
66
+ async closeAllMCP() {
67
+ for (const client of this.mcpClients) {
68
+ await client.close();
69
+ }
70
+ this.mcpClients = [];
71
+ }
72
+ get(name) {
73
+ return this.tools.get(name);
74
+ }
75
+ getAll() {
76
+ return [...this.tools.values()];
77
+ }
78
+ // 获取共享锁
79
+ async acquireConcurrent() {
80
+ while (this.exclusiveLock) {
81
+ await new Promise(resolve => this.waitQueue.push(resolve));
82
+ }
83
+ this.concurrentCount++;
84
+ }
85
+ // 释放共享锁
86
+ releaseConcurrent() {
87
+ this.concurrentCount--;
88
+ if (this.concurrentCount === 0)
89
+ this.drainQueue(); // 释放共享锁后,检查是否有等待中的 resolve 函数,如果有则执行
90
+ }
91
+ // 获取独占锁
92
+ async acquireExclusive() {
93
+ while (this.exclusiveLock || this.concurrentCount > 0) {
94
+ await new Promise(resolve => this.waitQueue.push(resolve));
95
+ }
96
+ this.exclusiveLock = true;
97
+ }
98
+ // 释放独占锁
99
+ releaseExclusive() {
100
+ this.exclusiveLock = false;
101
+ this.drainQueue(); // 释放独占锁后,检查是否有等待中的 resolve 函数,如果有则执行
102
+ }
103
+ // 锁释放的时候,把等待队列中的 resolve 全部唤醒,让它们重新去抢锁
104
+ drainQueue() {
105
+ const waiting = this.waitQueue.splice(0);
106
+ for (const resolve of waiting) {
107
+ resolve();
108
+ }
109
+ }
110
+ toAISDKFormat() {
111
+ const result = {};
112
+ const activeTools = this.getActiveTools(); // 获取所有核心的工具
113
+ for (const tool of activeTools) {
114
+ const maxChars = tool.maxResultChars;
115
+ const executeFn = tool.execute;
116
+ const isSafe = tool.isConcurrencySafe === true;
117
+ const registry = this;
118
+ const toolName = tool.name;
119
+ const hookPipeline = registry.hookPipeline;
120
+ result[tool.name] = {
121
+ description: tool.description,
122
+ inputSchema: jsonSchema(tool.parameters),
123
+ execute: async (input) => {
124
+ // Bash 风险检测
125
+ if (toolName === 'bash' && input?.command) {
126
+ const risk = classifyBashCommand(input.command);
127
+ if (risk.level === 'dangerous') {
128
+ return `[拒绝执行] 检测到危险操作: ${risk.reason}\n命令:${input.command}`;
129
+ }
130
+ if (risk.level === 'moderate') {
131
+ console.log(`[安全警告] 操作: ${risk.reason}\n命令:${input.command}`);
132
+ }
133
+ }
134
+ // pre hook
135
+ if (hookPipeline) {
136
+ const preResult = await hookPipeline.runPre(toolName, input);
137
+ if (preResult.action === 'block') {
138
+ return `[Hook 拦截] ${preResult.reason} || '操作被阻止'`;
139
+ }
140
+ if (preResult.action === 'modify' && preResult.modifiedInput !== undefined) {
141
+ input = preResult.modifiedInput;
142
+ }
143
+ }
144
+ // 在真正执行前,先按 isConcurrencySafe 来获取锁
145
+ if (isSafe) {
146
+ await registry.acquireConcurrent();
147
+ console.log(`[并发] ${tool.name} 获取共享锁`);
148
+ }
149
+ else {
150
+ await registry.acquireExclusive();
151
+ console.log(`[串行] ${tool.name} 获得独占锁,等待其他工具执行`);
152
+ }
153
+ try {
154
+ const raw = await executeFn(input);
155
+ const text = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2);
156
+ let output = truncateResult(text, maxChars);
157
+ // Post Hook
158
+ if (hookPipeline) {
159
+ const postResult = await hookPipeline.runPost(toolName, input, output);
160
+ if (postResult.modifiedOutput !== undefined) {
161
+ output = String(postResult.modifiedOutput);
162
+ }
163
+ }
164
+ return output;
165
+ }
166
+ finally {
167
+ // 无论是否成功,都释放锁
168
+ if (isSafe) {
169
+ registry.releaseConcurrent(); // 释放共享锁
170
+ }
171
+ else {
172
+ registry.releaseExclusive(); // 释放独占锁
173
+ }
174
+ }
175
+ },
176
+ };
177
+ }
178
+ return result;
179
+ }
180
+ toAISDKFormatUnlocked(excludeTools) {
181
+ const result = {};
182
+ const activeTools = this.getActiveTools()
183
+ .filter(t => !excludeTools || !excludeTools.has(t.name));
184
+ for (const tool of activeTools) {
185
+ const maxChars = tool.maxResultChars;
186
+ const executeFn = tool.execute;
187
+ result[tool.name] = {
188
+ description: tool.description,
189
+ inputSchema: jsonSchema(tool.parameters),
190
+ execute: async (input) => {
191
+ const raw = await executeFn(input);
192
+ const text = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2);
193
+ return truncateResult(text, maxChars);
194
+ },
195
+ };
196
+ }
197
+ return result;
198
+ }
199
+ // 搜索工具
200
+ searchTools(query) {
201
+ const q = query.trim(); // "mcp__github__list_issues, mcp__github__get_issue"
202
+ const results = [];
203
+ // 去 Map 对象中搜索哪个值(对象) 拥有 searchHint 字段,且 searchHint 字段包含 q 字段
204
+ const names = q.includes(',') ? q.split(',').map(n => n.trim()).filter(Boolean) : [q];
205
+ for (const name of names) {
206
+ const tool = this.tools.get(name); // 去 Map 对象中搜索 name 对应的工具对象
207
+ if (tool && tool.name !== 'tool_search') {
208
+ results.push(tool);
209
+ // 记录被搜到的延迟工具
210
+ this.discoveredTools.add(tool.name);
211
+ }
212
+ }
213
+ return results;
214
+ }
215
+ // 可以被添加进Prompt中的工具
216
+ getActiveTools() {
217
+ return this.getAll().filter(tool => {
218
+ if (tool.shouldDefer && !this.discoveredTools.has(tool.name)) {
219
+ return false;
220
+ }
221
+ if (!canUseTool(this.currentRole, tool.name)) {
222
+ return false;
223
+ }
224
+ return true;
225
+ });
226
+ }
227
+ // 生成延迟工具的名字列表
228
+ getDeferredToolSummary() {
229
+ const deferred = this.getAll().filter(tool => {
230
+ return tool.shouldDefer && !this.discoveredTools.has(tool.name);
231
+ });
232
+ if (deferred.length === 0)
233
+ return '';
234
+ const lines = deferred.map(t => {
235
+ const hint = t.searchHint ? ` — ${t.searchHint}` : '';
236
+ return ` - ${t.name}${hint}`;
237
+ });
238
+ return `\n以下工具可用,但需要先通过 tool_search 搜索获取完整定义:\n${lines.join('\n')}`;
239
+ }
240
+ // 估算 token
241
+ countTokenEstimate() {
242
+ let active = 0;
243
+ let deferred = 0;
244
+ for (const tool of this.getAll()) {
245
+ const schemaSize = JSON.stringify({
246
+ name: tool.name,
247
+ description: tool.description,
248
+ parameters: tool.parameters
249
+ }).length;
250
+ const tokens = Math.ceil(schemaSize / 4);
251
+ if (tool.shouldDefer && !this.discoveredTools.has(tool.name)) {
252
+ deferred += tokens;
253
+ }
254
+ else {
255
+ active += tokens;
256
+ }
257
+ }
258
+ return { active, deferred, total: active + deferred };
259
+ }
260
+ }
261
+ export function truncateResult(text, maxChars = DEFAULT_MAX_RESULT_CHARS) {
262
+ if (text.length <= maxChars)
263
+ return text;
264
+ const headSize = Math.floor(maxChars / 0.6); // 内容头部
265
+ const tailSize = maxChars - headSize; // 内容尾部
266
+ const head = text.slice(0, headSize);
267
+ const tail = text.slice(-tailSize);
268
+ const dropped = text.length - headSize - tailSize; // 被截断的字符数
269
+ return `${head}\n\n...[省略${dropped}个字符]\n\n${tail}`;
270
+ }
@@ -0,0 +1,116 @@
1
+ import fg from 'fast-glob';
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
4
+ // 全局搜索
5
+ export const globTool = {
6
+ name: 'glob',
7
+ description: '按模式搜索文件。支持 * 和 ** 通配符,如 "src/**/*.ts" 匹配 src 下所有 TypeScript 文件',
8
+ parameters: {
9
+ type: 'object',
10
+ properties: {
11
+ pattern: { type: 'string', description: '搜索模式,如 "**/*.ts"、"src/*.json"' },
12
+ path: { type: 'string', description: '搜索起始目录,默认当前目录' },
13
+ },
14
+ required: ['pattern'],
15
+ additionalProperties: false,
16
+ },
17
+ isConcurrencySafe: true,
18
+ isReadOnly: true,
19
+ execute: async ({ pattern, path = '.' }) => {
20
+ // ... 递归遍历目录,匹配模式 ...
21
+ // 自动跳过 node_modules 和 .git
22
+ // 结果上限 100 条,防止大项目撑爆
23
+ const results = await fg(pattern, {
24
+ cwd: resolve(path),
25
+ ignore: ['node_modules/**', '.git/**'],
26
+ dot: false,
27
+ onlyFiles: true,
28
+ followSymbolicLinks: false,
29
+ });
30
+ if (results.length === 0)
31
+ return `没有找到匹配 "${pattern}" 的文件`;
32
+ return results.sort().join('\n');
33
+ },
34
+ };
35
+ // Grep 搜索 精准匹配
36
+ export const grepTool = {
37
+ name: 'grep',
38
+ description: '在文件中搜索匹配指定模式的内容。返回匹配的行号和内容',
39
+ parameters: {
40
+ type: 'object',
41
+ properties: {
42
+ pattern: { type: 'string', description: '搜索模式(正则表达式)' },
43
+ path: { type: 'string', description: '搜索路径(文件或目录),默认当前目录' },
44
+ },
45
+ required: ['pattern'],
46
+ additionalProperties: false,
47
+ },
48
+ isConcurrencySafe: true,
49
+ isReadOnly: true,
50
+ maxResultChars: 3000,
51
+ execute: async ({ pattern, path = '.' }) => {
52
+ const baseDir = resolve(path);
53
+ const regex = new RegExp(pattern, 'i');
54
+ const matches = [];
55
+ const SKIP = new Set(['node_modules', '.git', 'dist']);
56
+ const BIN_EXT = new Set(['.png', '.jpg', '.gif', '.woff', '.woff2', '.ico', '.lock']);
57
+ function searchFile(filePath) {
58
+ if (matches.length >= 50)
59
+ return;
60
+ const ext = filePath.slice(filePath.lastIndexOf('.'));
61
+ if (BIN_EXT.has(ext))
62
+ return;
63
+ let content;
64
+ try {
65
+ content = readFileSync(filePath, 'utf-8');
66
+ }
67
+ catch {
68
+ return;
69
+ }
70
+ const lines = content.split('\n');
71
+ const rel = relative(baseDir, filePath);
72
+ for (let i = 0; i < lines.length; i++) {
73
+ if (regex.test(lines[i])) {
74
+ matches.push(`${rel}:${i + 1}: ${lines[i].trimEnd()}`);
75
+ if (matches.length >= 50)
76
+ return;
77
+ }
78
+ }
79
+ }
80
+ function walk(dir) {
81
+ if (matches.length >= 50)
82
+ return;
83
+ let entries;
84
+ try {
85
+ entries = readdirSync(dir);
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ for (const name of entries) {
91
+ if (SKIP.has(name))
92
+ continue;
93
+ const full = join(dir, name);
94
+ try {
95
+ const stat = statSync(full);
96
+ if (stat.isDirectory())
97
+ walk(full);
98
+ else
99
+ searchFile(full);
100
+ }
101
+ catch { /* skip */ }
102
+ }
103
+ }
104
+ const stat = statSync(baseDir);
105
+ if (stat.isFile()) {
106
+ searchFile(baseDir);
107
+ }
108
+ else {
109
+ walk(baseDir);
110
+ }
111
+ if (matches.length === 0)
112
+ return `没有找到匹配 "${pattern}" 的内容`;
113
+ const suffix = matches.length >= 50 ? '\n... (结果已截断,共 50+ 条匹配)' : '';
114
+ return matches.join('\n') + suffix;
115
+ },
116
+ };
@@ -0,0 +1,39 @@
1
+ import { execSync } from 'node:child_process';
2
+ // 执行 shell 命令
3
+ export const bashTool = {
4
+ name: 'bash',
5
+ description: '执行 shell 命令并返回输出。适合运行脚本、检查环境、执行构建等操作',
6
+ parameters: {
7
+ type: 'object',
8
+ properties: {
9
+ command: { type: 'string', description: '要执行的 shell 命令' },
10
+ },
11
+ required: ['command'],
12
+ additionalProperties: false,
13
+ },
14
+ isConcurrencySafe: false,
15
+ isReadOnly: false,
16
+ maxResultChars: 3000,
17
+ execute: async ({ command }) => {
18
+ try {
19
+ execSync('echo test', { stdio: 'ignore' });
20
+ }
21
+ catch {
22
+ return `[bash 不可用] 当前 Sandbox 不支持 shell 命令。本地终端运行 pigpig-agent 可使用 bash 工具。`;
23
+ }
24
+ try {
25
+ const output = execSync(command, {
26
+ encoding: 'utf-8',
27
+ timeout: 10000,
28
+ maxBuffer: 1024 * 1024,
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+ return output || '(命令执行成功,无输出)';
32
+ }
33
+ catch (err) {
34
+ const stderr = err.stderr || '';
35
+ const stdout = err.stdout || '';
36
+ return `命令执行失败 (exit ${err.status || 1}):\n${stderr || stdout || err.message}`;
37
+ }
38
+ },
39
+ };
@@ -0,0 +1,35 @@
1
+ import { spawnAgent, spawnParallel } from '../agents/spawn.js';
2
+ export function createSpawnTool(agentRegistry, getSpawnCtx) {
3
+ return {
4
+ name: 'spawn_agent',
5
+ description: '派一个子 Agent 去执行任务。子 Agent 有独立的上下文,完成后返回结果摘要。支持同时派多个子 Agent 并行执行。',
6
+ parameters: {
7
+ type: 'object',
8
+ properties: {
9
+ task: {
10
+ type: 'string',
11
+ description: '单个任务描述(与 tasks 二选一)',
12
+ },
13
+ tasks: {
14
+ type: 'array',
15
+ items: { type: 'string' },
16
+ description: '多个任务描述,并行执行(与 task 二选一)',
17
+ },
18
+ },
19
+ },
20
+ isConcurrencySafe: false,
21
+ isReadOnly: true,
22
+ execute: async (input) => {
23
+ const ctx = getSpawnCtx();
24
+ if (input.tasks && input.tasks.length > 0) {
25
+ const requests = input.tasks.map(t => ({ task: t }));
26
+ const results = await spawnParallel(requests, ctx);
27
+ return results.map((r, i) => `## 子 Agent ${i + 1}: ${r.task.slice(0, 40)}\n\n${r.result}`).join('\n\n---\n\n');
28
+ }
29
+ if (input.task) {
30
+ return spawnAgent({ task: input.task }, ctx);
31
+ }
32
+ return '需要提供 task 或 tasks 参数';
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,17 @@
1
+ export function createToolSearchTool(registry) {
2
+ return {
3
+ name: 'tool_search',
4
+ description: '获取延迟工具的完整定义,传入工具名(从系统提示的延迟工具列表中获取),返回该工具的完整参数 Schema',
5
+ parameters: { type: 'object', properties: { query: { type: 'string', description: '工具名,如"mcp__github__list_issues"。支持逗号分隔多个工具名' } }, required: ['query'] },
6
+ isConcurrencySafe: true,
7
+ isReadOnly: true,
8
+ execute: async ({ query }) => {
9
+ const results = registry.searchTools(query); // 搜出来哪些工具的 searchHint 包含 query 字段
10
+ return results.map(t => ({
11
+ name: t.name,
12
+ description: t.description,
13
+ parameters: t.parameters,
14
+ }));
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,150 @@
1
+ import TurndownService from 'turndown';
2
+ // Tavily 搜索引擎
3
+ export const tavilySearchTool = {
4
+ name: 'web_search',
5
+ description: '搜索互联网获取最新信息。返回相关网页的标题、链接和内容摘要',
6
+ parameters: {
7
+ type: 'object',
8
+ properties: {
9
+ query: {
10
+ type: 'string',
11
+ description: '搜索关键词',
12
+ },
13
+ max_results: {
14
+ type: 'number',
15
+ description: '返回的最大结果数量,默认5个',
16
+ },
17
+ },
18
+ required: ['query'],
19
+ },
20
+ isConcurrencySafe: true,
21
+ isReadOnly: true,
22
+ maxResultChars: 3000,
23
+ execute: async ({ query, max_results = 5 }) => {
24
+ const apiKey = process.env.TAVILY_API_KEY;
25
+ if (!apiKey)
26
+ return `[web_search] 未配置 Tavily API Key, 请在 .env 中配置`;
27
+ const res = await fetch('https://api.tavily.com/search ', {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Authorization': `Bearer ${apiKey}`,
31
+ 'Content-Type': 'application/json',
32
+ },
33
+ body: JSON.stringify({
34
+ query,
35
+ search_depth: 'advanced',
36
+ max_results,
37
+ include_answer: true,
38
+ }),
39
+ });
40
+ if (!res.ok)
41
+ return `[web_search] Tavily 请求失败, 状态码: ${res.status}`;
42
+ const data = await res.json();
43
+ const lines = [];
44
+ if (data.answer) {
45
+ lines.push(`## AI 摘要\n ${data.answer}`);
46
+ }
47
+ for (const r of data.results || []) {
48
+ lines.push(`### ${r.title}`);
49
+ lines.push(r.url);
50
+ lines.push(r.content || '');
51
+ lines.push('');
52
+ }
53
+ return lines.join('\n') || '没有找到相关结果';
54
+ },
55
+ };
56
+ // Serper 搜索引擎
57
+ export const serperSearchTool = {
58
+ name: 'web_search',
59
+ description: '搜索互联网获取最新信息。返回 Google 搜索结果的标题、链接和摘要',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ query: { type: 'string', description: '搜索关键词' },
64
+ max_results: { type: 'number', description: '返回结果数量,默认 5' },
65
+ },
66
+ required: ['query'],
67
+ },
68
+ isConcurrencySafe: true,
69
+ isReadOnly: true,
70
+ maxResultChars: 3000,
71
+ execute: async ({ query, max_results = 5 }) => {
72
+ const apiKey = process.env.SERPER_API_KEY;
73
+ if (!apiKey)
74
+ return '[web_search] 未配置 SERPER_API_KEY,请在 .env 中设置';
75
+ const res = await fetch('https://google.serper.dev/search', {
76
+ method: 'POST',
77
+ headers: {
78
+ 'X-API-KEY': apiKey,
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ body: JSON.stringify({ q: query, num: max_results }),
82
+ });
83
+ if (!res.ok)
84
+ return `[web_search] 请求失败: HTTP ${res.status}`;
85
+ const data = await res.json();
86
+ const lines = [];
87
+ // Knowledge Graph(如果有)
88
+ if (data.knowledgeGraph) {
89
+ const kg = data.knowledgeGraph;
90
+ lines.push(`## ${kg.title}`);
91
+ if (kg.description)
92
+ lines.push(kg.description);
93
+ lines.push('');
94
+ }
95
+ // Organic Results
96
+ for (const r of (data.organic || []).slice(0, max_results)) {
97
+ lines.push(`### ${r.title}`);
98
+ lines.push(r.link);
99
+ lines.push(r.snippet || '');
100
+ lines.push('');
101
+ }
102
+ return lines.join('\n') || '没有找到相关结果';
103
+ },
104
+ };
105
+ // 为 Serper 搜索引擎添加 web_fetch
106
+ export const webFetchTool = {
107
+ name: 'web_fetch',
108
+ description: '抓取指定 URL 的网页内容,转换为 Markdown 格式',
109
+ parameters: {
110
+ type: 'object',
111
+ properties: {
112
+ url: { type: 'string', description: '完整 URL' },
113
+ },
114
+ required: ['url'],
115
+ },
116
+ isConcurrencySafe: true,
117
+ isReadOnly: true,
118
+ maxResultChars: 3000,
119
+ execute: async ({ url }) => {
120
+ try {
121
+ const res = await fetch(url, {
122
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SuperAgent/1.0)' },
123
+ signal: AbortSignal.timeout(15000),
124
+ });
125
+ if (!res.ok)
126
+ return `抓取失败: HTTP ${res.status}`;
127
+ const html = await res.text();
128
+ return htmlToMarkdown(html);
129
+ }
130
+ catch (err) {
131
+ return `抓取失败: ${err.message}`;
132
+ }
133
+ },
134
+ };
135
+ // HTML 转换为 Markdown
136
+ const turndownService = new TurndownService({
137
+ headingStyle: 'atx',
138
+ codeBlockStyle: 'fenced',
139
+ });
140
+ turndownService.remove(['style', 'script', 'header', 'nav', 'footer', 'iframe']);
141
+ function htmlToMarkdown(html) {
142
+ return turndownService.turndown(html);
143
+ }
144
+ export function pickSearchTool() {
145
+ if (process.env.TAVILY_API_KEY)
146
+ return tavilySearchTool;
147
+ if (process.env.SERPER_API_KEY)
148
+ return serperSearchTool;
149
+ return tavilySearchTool;
150
+ }