@zhin.js/ai 1.0.0 → 1.0.2

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 (94) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +131 -497
  3. package/lib/agent.d.ts +54 -6
  4. package/lib/agent.d.ts.map +1 -1
  5. package/lib/agent.js +468 -116
  6. package/lib/agent.js.map +1 -1
  7. package/lib/compaction.d.ts +132 -0
  8. package/lib/compaction.d.ts.map +1 -0
  9. package/lib/compaction.js +370 -0
  10. package/lib/compaction.js.map +1 -0
  11. package/lib/context-manager.d.ts.map +1 -1
  12. package/lib/context-manager.js +10 -3
  13. package/lib/context-manager.js.map +1 -1
  14. package/lib/conversation-memory.d.ts +192 -0
  15. package/lib/conversation-memory.d.ts.map +1 -0
  16. package/lib/conversation-memory.js +619 -0
  17. package/lib/conversation-memory.js.map +1 -0
  18. package/lib/index.d.ts +25 -163
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +24 -1122
  21. package/lib/index.js.map +1 -1
  22. package/lib/output.d.ts +93 -0
  23. package/lib/output.d.ts.map +1 -0
  24. package/lib/output.js +176 -0
  25. package/lib/output.js.map +1 -0
  26. package/lib/providers/anthropic.d.ts +7 -0
  27. package/lib/providers/anthropic.d.ts.map +1 -1
  28. package/lib/providers/anthropic.js +5 -0
  29. package/lib/providers/anthropic.js.map +1 -1
  30. package/lib/providers/ollama.d.ts +10 -0
  31. package/lib/providers/ollama.d.ts.map +1 -1
  32. package/lib/providers/ollama.js +19 -4
  33. package/lib/providers/ollama.js.map +1 -1
  34. package/lib/providers/openai.d.ts +7 -0
  35. package/lib/providers/openai.d.ts.map +1 -1
  36. package/lib/providers/openai.js +11 -0
  37. package/lib/providers/openai.js.map +1 -1
  38. package/lib/rate-limiter.d.ts +38 -0
  39. package/lib/rate-limiter.d.ts.map +1 -0
  40. package/lib/rate-limiter.js +86 -0
  41. package/lib/rate-limiter.js.map +1 -0
  42. package/lib/session.d.ts +7 -0
  43. package/lib/session.d.ts.map +1 -1
  44. package/lib/session.js +47 -18
  45. package/lib/session.js.map +1 -1
  46. package/lib/storage.d.ts +68 -0
  47. package/lib/storage.d.ts.map +1 -0
  48. package/lib/storage.js +105 -0
  49. package/lib/storage.js.map +1 -0
  50. package/lib/tone-detector.d.ts +19 -0
  51. package/lib/tone-detector.d.ts.map +1 -0
  52. package/lib/tone-detector.js +72 -0
  53. package/lib/tone-detector.js.map +1 -0
  54. package/lib/types.d.ts +84 -8
  55. package/lib/types.d.ts.map +1 -1
  56. package/package.json +13 -42
  57. package/src/agent.ts +518 -135
  58. package/src/compaction.ts +529 -0
  59. package/src/context-manager.ts +10 -9
  60. package/src/conversation-memory.ts +816 -0
  61. package/src/index.ts +121 -1406
  62. package/src/output.ts +261 -0
  63. package/src/providers/anthropic.ts +4 -0
  64. package/src/providers/ollama.ts +23 -4
  65. package/src/providers/openai.ts +8 -1
  66. package/src/rate-limiter.ts +129 -0
  67. package/src/session.ts +47 -18
  68. package/src/storage.ts +135 -0
  69. package/src/tone-detector.ts +89 -0
  70. package/src/types.ts +95 -6
  71. package/tests/agent.test.ts +123 -70
  72. package/tests/compaction.test.ts +310 -0
  73. package/tests/context-manager.test.ts +73 -47
  74. package/tests/conversation-memory.test.ts +128 -0
  75. package/tests/output.test.ts +128 -0
  76. package/tests/providers.test.ts +574 -0
  77. package/tests/rate-limiter.test.ts +108 -0
  78. package/tests/session.test.ts +139 -48
  79. package/tests/setup.ts +82 -240
  80. package/tests/storage.test.ts +224 -0
  81. package/tests/tone-detector.test.ts +80 -0
  82. package/tsconfig.json +4 -5
  83. package/vitest.setup.ts +1 -0
  84. package/TOOLS.md +0 -294
  85. package/lib/tools.d.ts +0 -45
  86. package/lib/tools.d.ts.map +0 -1
  87. package/lib/tools.js +0 -194
  88. package/lib/tools.js.map +0 -1
  89. package/src/tools.ts +0 -205
  90. package/tests/ai-trigger.test.ts +0 -369
  91. package/tests/integration.test.ts +0 -596
  92. package/tests/providers.integration.test.ts +0 -227
  93. package/tests/tool.test.ts +0 -800
  94. package/tests/tools-builtin.test.ts +0 -346
package/src/agent.ts CHANGED
@@ -1,22 +1,54 @@
1
1
  /**
2
- * @zhin.js/ai - Agent System
2
+ * @zhin.js/agent - Agent System
3
3
  * AI Agent 实现,支持工具调用和多轮对话
4
4
  */
5
5
 
6
- import { Logger } from '@zhin.js/core';
6
+ import { Logger } from '@zhin.js/logger';
7
7
  import type {
8
8
  AIProvider,
9
9
  AgentConfig,
10
10
  AgentTool,
11
11
  AgentResult,
12
12
  ChatMessage,
13
- ToolDefinition,
13
+ ToolDefinition as ChatToolDefinition,
14
14
  ToolCall,
15
+ ToolFilterOptions,
15
16
  Usage,
16
17
  } from './types.js';
17
18
 
18
19
  const logger = new Logger(null, 'Agent');
19
20
 
21
+ /** 工具执行默认超时时间 (ms) */
22
+ const DEFAULT_TOOL_TIMEOUT = 30_000;
23
+
24
+ /** 中英文混合分词:按标点/空格切分,保留 ≥2 字符的 token */
25
+ const TOKENIZE_RE = /[\s,.:;!?,。:;!?、()()【】\[\]"'"'「」『』]+/;
26
+ function tokenize(text: string): string[] {
27
+ return text.split(TOKENIZE_RE).filter(w => w.length >= 2);
28
+ }
29
+
30
+ /**
31
+ * 根据工具名和参数生成简短标题(用于日志、TOOLS.md 等)
32
+ */
33
+ export function formatToolTitle(name: string, args?: Record<string, any>): string {
34
+ if (!args || Object.keys(args).length === 0) return name;
35
+ const a = args;
36
+ switch (name) {
37
+ case 'bash': return a.command != null ? `bash: ${String(a.command).slice(0, 60)}` : name;
38
+ case 'read_file': return a.file_path != null ? `read_file: ${a.file_path}` : name;
39
+ case 'write_file': return a.file_path != null ? `write_file: ${a.file_path}` : name;
40
+ case 'edit_file': return a.file_path != null ? `edit_file: ${a.file_path}` : name;
41
+ case 'list_dir': return a.path != null ? `list_dir: ${a.path}` : name;
42
+ case 'web_search': return a.query != null ? `web_search: ${String(a.query).slice(0, 40)}` : name;
43
+ case 'web_fetch': return a.url != null ? `web_fetch: ${String(a.url).slice(0, 50)}` : name;
44
+ default: {
45
+ const first = Object.values(a)[0];
46
+ if (first != null) return `${name}: ${String(first).slice(0, 50)}`;
47
+ return name;
48
+ }
49
+ }
50
+ }
51
+
20
52
  /**
21
53
  * Agent 执行状态
22
54
  */
@@ -121,9 +153,9 @@ export class Agent {
121
153
  }
122
154
 
123
155
  /**
124
- * 获取工具定义
156
+ * 获取工具定义(缓存在第一次调用后保持不变)
125
157
  */
126
- private getToolDefinitions(): ToolDefinition[] {
158
+ private getToolDefinitions(): ChatToolDefinition[] {
127
159
  return Array.from(this.tools.values()).map(tool => ({
128
160
  type: 'function',
129
161
  function: {
@@ -135,32 +167,255 @@ export class Agent {
135
167
  }
136
168
 
137
169
  /**
138
- * 执行工具调用
170
+ * 生成工具调用的去重 key(规范化参数以避免 "" vs "{}" 等差异)
171
+ */
172
+ private static toolCallKey(name: string, args: string): string {
173
+ let normalized: string;
174
+ try {
175
+ const parsed = JSON.parse(args || '{}');
176
+ normalized = JSON.stringify(parsed, Object.keys(parsed).sort());
177
+ } catch {
178
+ normalized = args || '';
179
+ }
180
+ return `${name}::${normalized}`;
181
+ }
182
+
183
+ /**
184
+ * 安全解析 JSON,失败则返回原始字符串
185
+ */
186
+ private static safeParse(str: string): any {
187
+ try {
188
+ return JSON.parse(str);
189
+ } catch {
190
+ return str;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 程序化工具过滤 —— TF-IDF 加权的相关性评分
196
+ *
197
+ * 评分层级(基础权重 × IDF 倍率):
198
+ * 1. keywords 精确匹配: base 1.0 × idf —— 工具声明的触发关键词
199
+ * 2. tags 匹配: base 0.5 × idf —— 工具分类标签
200
+ * 3. 工具名 token 匹配: base 0.3 × idf —— 工具名按 `.` `_` `-` 拆词
201
+ * 4. description 关键词: base 0.15 × idf —— 描述中的词/短语
202
+ *
203
+ * IDF = log(N / df),N 为工具总数,df 为包含该词的工具数。
204
+ * 高频词(出现在大部分工具中)的 IDF 接近 0,权重被压低;
205
+ * 稀有词(仅少数工具有)的 IDF 较高,权重被放大。
206
+ *
207
+ * @param message 用户消息原文
208
+ * @param tools 候选工具列表
209
+ * @param options 过滤选项
210
+ * @returns 按相关性降序排列的工具子集
211
+ */
212
+ static filterTools(
213
+ message: string,
214
+ tools: AgentTool[],
215
+ options?: ToolFilterOptions,
216
+ ): AgentTool[] {
217
+ if (tools.length === 0) return [];
218
+
219
+ const maxTools = options?.maxTools ?? 10;
220
+ const minScore = options?.minScore ?? 0.1;
221
+ const callerPerm = options?.callerPermissionLevel ?? Infinity;
222
+ const N = tools.length;
223
+
224
+ const msgLower = message.toLowerCase();
225
+ const msgTokens = tokenize(msgLower);
226
+
227
+ // ── 构建 IDF 索引 ──
228
+ const df = new Map<string, number>();
229
+ const toolTermSets: Map<AgentTool, Set<string>> = new Map();
230
+
231
+ for (const tool of tools) {
232
+ const terms = new Set<string>();
233
+ if (tool.keywords) for (const kw of tool.keywords) { if (kw) terms.add(kw.toLowerCase()); }
234
+ if (tool.tags) for (const tag of tool.tags) { if (tag && tag.length > 1) terms.add(tag.toLowerCase()); }
235
+ for (const nt of tool.name.toLowerCase().split(/[._\-]+/)) { if (nt.length > 1) terms.add(nt); }
236
+ for (const w of tokenize(tool.description.toLowerCase())) { terms.add(w); }
237
+ toolTermSets.set(tool, terms);
238
+ for (const t of terms) {
239
+ df.set(t, (df.get(t) || 0) + 1);
240
+ }
241
+ }
242
+
243
+ const idf = (term: string): number => {
244
+ const docFreq = df.get(term);
245
+ if (!docFreq) return 1.0;
246
+ return Math.max(0.1, Math.log(N / docFreq));
247
+ };
248
+
249
+ // ── 评分 ──
250
+ const scored: { tool: AgentTool; score: number }[] = [];
251
+
252
+ for (const tool of tools) {
253
+ if (tool.permissionLevel != null && tool.permissionLevel > callerPerm) {
254
+ continue;
255
+ }
256
+
257
+ let score = 0;
258
+
259
+ // 1. keywords(最高基础权重)
260
+ if (tool.keywords?.length) {
261
+ for (const kw of tool.keywords) {
262
+ if (kw && msgLower.includes(kw.toLowerCase())) {
263
+ score += 1.0 * idf(kw.toLowerCase());
264
+ }
265
+ }
266
+ }
267
+
268
+ // 2. tags
269
+ if (tool.tags?.length) {
270
+ for (const tag of tool.tags) {
271
+ if (tag && tag.length > 1 && msgLower.includes(tag.toLowerCase())) {
272
+ score += 0.5 * idf(tag.toLowerCase());
273
+ }
274
+ }
275
+ }
276
+
277
+ // 3. 工具名 token
278
+ const nameTokens = tool.name.toLowerCase().split(/[._\-]+/);
279
+ for (const nt of nameTokens) {
280
+ if (nt.length > 1 && msgLower.includes(nt)) {
281
+ score += 0.3 * idf(nt);
282
+ }
283
+ }
284
+
285
+ // 4. 描述双向匹配
286
+ const descLower = tool.description.toLowerCase();
287
+ const descTokens = tokenize(descLower);
288
+ for (const dw of descTokens) {
289
+ if (msgLower.includes(dw)) {
290
+ score += 0.15 * idf(dw);
291
+ }
292
+ }
293
+ for (const mw of msgTokens) {
294
+ if (descLower.includes(mw)) {
295
+ score += 0.2 * idf(mw);
296
+ }
297
+ }
298
+
299
+ if (score >= minScore) {
300
+ scored.push({ tool, score });
301
+ }
302
+ }
303
+
304
+ scored.sort((a, b) => b.score - a.score);
305
+ return scored.slice(0, maxTools).map(s => s.tool);
306
+ }
307
+
308
+ /**
309
+ * 执行单个工具调用(带超时保护)
139
310
  */
140
311
  private async executeToolCall(toolCall: ToolCall): Promise<string> {
141
312
  const tool = this.tools.get(toolCall.function.name);
142
313
  if (!tool) {
143
- return JSON.stringify({ error: `Unknown tool: ${toolCall.function.name}` });
314
+ return JSON.stringify({
315
+ error: `Unknown tool: ${toolCall.function.name}`,
316
+ hint: '该工具不存在,请尝试使用其他可用工具,或直接回答用户。',
317
+ });
318
+ }
319
+
320
+ let args: Record<string, unknown>;
321
+ try {
322
+ args = JSON.parse(toolCall.function.arguments);
323
+ } catch {
324
+ return JSON.stringify({
325
+ error: 'Invalid tool arguments JSON',
326
+ tool: toolCall.function.name,
327
+ hint: '请检查工具参数格式后重试。',
328
+ });
144
329
  }
145
330
 
331
+ logger.debug({ tool: toolCall.function.name, params: args }, 'Executing tool');
332
+ this.emit('tool_call', tool.name, args);
333
+
146
334
  try {
147
- const args = JSON.parse(toolCall.function.arguments);
148
- this.emit('tool_call', tool.name, args);
149
-
150
- const result = await tool.execute(args);
335
+ // 带超时的工具执行
336
+ const result = await Promise.race([
337
+ tool.execute(args),
338
+ new Promise<never>((_, reject) =>
339
+ setTimeout(() => reject(new Error(`工具 ${tool.name} 执行超时`)), DEFAULT_TOOL_TIMEOUT),
340
+ ),
341
+ ]);
342
+
151
343
  this.emit('tool_result', tool.name, result);
152
-
153
344
  return typeof result === 'string' ? result : JSON.stringify(result);
154
345
  } catch (error) {
155
346
  const errorMsg = error instanceof Error ? error.message : String(error);
156
- return JSON.stringify({ error: errorMsg });
347
+ logger.warn(`工具 ${toolCall.function.name} 执行失败: ${errorMsg}`);
348
+ logger.error({ tool: toolCall.function.name, params: args, err: error }, 'Tool execution failed');
349
+ // 向 AI 提供结构化的错误信息和恢复提示
350
+ return JSON.stringify({
351
+ error: errorMsg,
352
+ tool: toolCall.function.name,
353
+ hint: '该工具执行失败。请尝试使用不同的参数重试,或换一个工具来完成任务。如果所有工具都无法使用,请直接用文字回答用户。',
354
+ });
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 并行执行多个工具调用(跳过重复的)
360
+ * @returns 新执行的工具调用结果列表;如果全部重复则返回空数组
361
+ */
362
+ private async executeToolCalls(
363
+ toolCalls: ToolCall[],
364
+ seenKeys: Set<string>,
365
+ state: AgentState,
366
+ ): Promise<{ toolCall: ToolCall; result: string; args: Record<string, any> }[]> {
367
+ // 分离:新调用 vs 重复调用
368
+ const fresh: ToolCall[] = [];
369
+ for (const tc of toolCalls) {
370
+ const key = Agent.toolCallKey(tc.function.name, tc.function.arguments);
371
+ if (seenKeys.has(key)) {
372
+ logger.debug(`跳过重复工具调用: ${tc.function.name}`);
373
+ } else {
374
+ fresh.push(tc);
375
+ }
157
376
  }
377
+
378
+ if (fresh.length === 0) return [];
379
+
380
+ // 并行执行所有新工具调用
381
+ const tasks = fresh.map(async (tc) => {
382
+ const result = await this.executeToolCall(tc);
383
+ const args = Agent.safeParse(tc.function.arguments);
384
+ const parsedResult = Agent.safeParse(result);
385
+
386
+ // 记录到状态
387
+ const key = Agent.toolCallKey(tc.function.name, tc.function.arguments);
388
+ seenKeys.add(key);
389
+ state.toolCalls.push({
390
+ tool: tc.function.name,
391
+ args: typeof args === 'object' ? args : { raw: args },
392
+ result: parsedResult,
393
+ });
394
+
395
+ return { toolCall: tc, result, args };
396
+ });
397
+
398
+ return Promise.all(tasks);
399
+ }
400
+
401
+ /**
402
+ * 累加 token 用量
403
+ */
404
+ private static addUsage(target: Usage, source?: Usage): void {
405
+ if (!source) return;
406
+ target.prompt_tokens += source.prompt_tokens;
407
+ target.completion_tokens += source.completion_tokens;
408
+ target.total_tokens += source.total_tokens;
158
409
  }
159
410
 
160
411
  /**
161
412
  * 运行 Agent
413
+ *
414
+ * @param userMessage 用户消息
415
+ * @param context 对话上下文
416
+ * @param filterOptions 工具过滤选项 —— 启用后在 AI 调用之前程序化筛选工具,省去额外的 AI 意图分析往返
162
417
  */
163
- async run(userMessage: string, context?: ChatMessage[]): Promise<AgentResult> {
418
+ async run(userMessage: string, context?: ChatMessage[], filterOptions?: ToolFilterOptions): Promise<AgentResult> {
164
419
  const state: AgentState = {
165
420
  messages: [
166
421
  { role: 'system', content: this.config.systemPrompt },
@@ -172,94 +427,112 @@ export class Agent {
172
427
  iterations: 0,
173
428
  };
174
429
 
175
- const toolDefinitions = this.getToolDefinitions();
430
+ // 程序化工具预过滤:只把相关工具传给 AI,减少 token 消耗和误选
431
+ let toolDefinitions: ChatToolDefinition[];
432
+ if (filterOptions) {
433
+ const allTools = Array.from(this.tools.values());
434
+ const filtered = Agent.filterTools(userMessage, allTools, filterOptions);
435
+ logger.info(`工具预过滤: ${allTools.length} -> ${filtered.length}`);
436
+ toolDefinitions = filtered.map(tool => ({
437
+ type: 'function' as const,
438
+ function: {
439
+ name: tool.name,
440
+ description: tool.description,
441
+ parameters: tool.parameters,
442
+ },
443
+ }));
444
+ } else {
445
+ toolDefinitions = this.getToolDefinitions();
446
+ }
447
+ const hasTools = toolDefinitions.length > 0;
448
+ // O(1) 去重集合
449
+ const seenToolKeys = new Set<string>();
450
+ // 连续全重复计数器
451
+ let consecutiveDuplicateRounds = 0;
176
452
 
177
453
  while (state.iterations < this.config.maxIterations) {
178
454
  state.iterations++;
179
455
 
456
+ // 强制文本回答的条件:
457
+ // 1. 检测到连续重复工具调用
458
+ // 2. 最后一轮迭代且已有工具结果 —— 保证 Agent 始终输出文本,不再需要额外的 summary 往返
459
+ const isLastIteration = state.iterations >= this.config.maxIterations;
460
+ const forceAnswer = consecutiveDuplicateRounds > 0 ||
461
+ (isLastIteration && state.toolCalls.length > 0);
462
+
180
463
  try {
464
+ // 工具调用轮次禁用思考(qwen3 等模型),大幅减少无效 token 生成
465
+ const isToolCallRound = hasTools && !forceAnswer;
181
466
  const response = await this.provider.chat({
182
467
  model: this.config.model,
183
468
  messages: state.messages,
184
- tools: toolDefinitions.length > 0 ? toolDefinitions : undefined,
185
- tool_choice: toolDefinitions.length > 0 ? 'auto' : undefined,
469
+ tools: isToolCallRound ? toolDefinitions : undefined,
470
+ tool_choice: isToolCallRound ? 'auto' : undefined,
186
471
  temperature: this.config.temperature,
472
+ think: isToolCallRound ? false : undefined,
187
473
  });
188
474
 
189
- // 更新用量
190
- if (response.usage) {
191
- state.usage.prompt_tokens += response.usage.prompt_tokens;
192
- state.usage.completion_tokens += response.usage.completion_tokens;
193
- state.usage.total_tokens += response.usage.total_tokens;
194
- }
195
-
475
+ Agent.addUsage(state.usage, response.usage);
476
+ logger.info(`token 用量: ${state.usage.prompt_tokens} -> ${state.usage.completion_tokens} -> ${state.usage.total_tokens}`);
477
+ logger.info(`response: `,response);
196
478
  const choice = response.choices[0];
197
479
  if (!choice) break;
198
480
 
199
- // 如果包含工具调用,不立即将模型原始内容暴露给上层
481
+ // ── 分支 1: 模型想调用工具 ──
200
482
  if (choice.message.tool_calls?.length) {
483
+ const callSummary = choice.message.tool_calls.map(
484
+ (tc: any) => `${tc.function.name}(${tc.function.arguments})`
485
+ ).join(', ');
486
+ logger.info(`[第${state.iterations}轮] 工具调用: ${callSummary}`);
201
487
  this.emit('thinking', '正在执行工具调用...');
202
488
 
203
- // assistant 消息加入会话上下文,但避免直接展示纯 JSON 的工具调用原始内容
204
- let assistantContent = '';
205
- if (typeof choice.message.content === 'string' && choice.message.content) {
206
- const rawContent = choice.message.content;
207
- const trimmed = rawContent.trim();
208
- // 如果内容整体看起来是 JSON(常见于模型将工具调用以 JSON 形式返回),则不暴露给上层
209
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
210
- try {
211
- JSON.parse(trimmed);
212
- // 解析成功,说明是纯 JSON;保持 assistantContent 为空字符串
213
- } catch {
214
- // 解析失败,当作普通文本保留
215
- assistantContent = rawContent;
216
- }
217
- } else {
218
- // 非纯 JSON 内容,直接保留
219
- assistantContent = rawContent;
220
- }
221
- }
222
-
489
+ // 当存在 tool_calls 时,content 通常是模型的内部思考或原始 JSON
490
+ // 不需要暴露给最终用户,但需要保留在消息历史中以维持对话完整性
223
491
  state.messages.push({
224
- role: 'assistant',
225
- content: assistantContent,
492
+ role: 'tool_call',
493
+ content: typeof choice.message.content === 'string' ? choice.message.content : '',
226
494
  tool_calls: choice.message.tool_calls,
227
495
  });
228
496
 
229
- // 检测重复工具调用
230
- let hasDuplicateCall = false;
231
- for (const toolCall of choice.message.tool_calls) {
232
- const toolName = toolCall.function.name;
233
- const toolArgs = toolCall.function.arguments;
234
-
235
- // 检查是否重复调用相同工具(相同名称和参数)
236
- const isDuplicate = state.toolCalls.some(
237
- tc => tc.tool === toolName && JSON.stringify(tc.args) === toolArgs
238
- );
239
-
240
- if (isDuplicate) {
241
- logger.debug(`重复工具调用: ${toolName},跳过`);
242
- hasDuplicateCall = true;
243
- continue;
244
- }
245
-
246
- const result = await this.executeToolCall(toolCall);
247
-
248
- // 尝试解析 JSON,如果失败则使用原始字符串
249
- let parsedResult: any;
250
- try {
251
- parsedResult = JSON.parse(result);
252
- } catch {
253
- parsedResult = result;
497
+ // 并行执行所有新工具调用,自动跳过重复
498
+ const results = await this.executeToolCalls(
499
+ choice.message.tool_calls,
500
+ seenToolKeys,
501
+ state,
502
+ );
503
+
504
+ if (results.length === 0) {
505
+ consecutiveDuplicateRounds++;
506
+ logger.warn(`[第${state.iterations}轮] 检测到重复工具调用,已跳过执行,强制下轮文本回答`);
507
+
508
+ for (const tc of choice.message.tool_calls) {
509
+ const key = Agent.toolCallKey(tc.function.name, tc.function.arguments);
510
+ const previous = state.toolCalls.find(
511
+ stc => Agent.toolCallKey(stc.tool, JSON.stringify(stc.args)) === key ||
512
+ Agent.toolCallKey(stc.tool, tc.function.arguments) === key,
513
+ );
514
+ state.messages.push({
515
+ role: 'tool',
516
+ content: previous ? JSON.stringify(previous.result) : '结果已获取',
517
+ tool_call_id: tc.id,
518
+ });
254
519
  }
255
-
256
- state.toolCalls.push({
257
- tool: toolName,
258
- args: JSON.parse(toolArgs),
259
- result: parsedResult,
520
+
521
+ state.messages.push({
522
+ role: 'system',
523
+ content: '你已经获取了所需的全部信息,请直接用自然语言回答用户,不要再调用工具。',
260
524
  });
261
525
 
262
- // 添加工具结果消息(供模型下一步使用)
526
+ continue;
527
+ }
528
+
529
+ // 有新的工具调用被执行
530
+ consecutiveDuplicateRounds = 0;
531
+
532
+ // 将工具结果加入消息历史
533
+ for (const { toolCall, result } of results) {
534
+ const resultPreview = result.length > 200 ? result.slice(0, 200) + '...' : result;
535
+ logger.info(`[第${state.iterations}轮] 工具结果 ${toolCall.function.name}: ${resultPreview}`);
263
536
  state.messages.push({
264
537
  role: 'tool',
265
538
  content: result,
@@ -267,35 +540,23 @@ export class Agent {
267
540
  });
268
541
  }
269
542
 
270
- // 如果所有调用都是重复的,强制模型生成最终回答
271
- if (hasDuplicateCall && choice.message.tool_calls.every(tc =>
272
- state.toolCalls.some(stc => stc.tool === tc.function.name && JSON.stringify(stc.args) === tc.function.arguments)
273
- )) {
274
- // 添加提示让模型总结结果
543
+ // 如果工具返回的是最终结果(非查询中间步骤),引导模型直接回复
544
+ const allSucceeded = results.every(r => !r.result.startsWith('{'));
545
+ if (allSucceeded && results.length > 0) {
275
546
  state.messages.push({
276
- role: 'user',
277
- content: '请根据已获取的工具结果,用中文总结回答我的问题。',
547
+ role: 'system',
548
+ content: '工具已返回结果。如果信息足够回答用户问题,请直接用自然语言回答,不要重复调用相同工具。',
278
549
  });
279
550
  }
280
551
 
281
- // 继续循环,让模型处理工具结果并生成最终回答
282
552
  continue;
283
553
  }
284
554
 
285
- // 没有工具调用,返回结果
286
- let content = typeof choice.message.content === 'string'
555
+ // ── 分支 2: 模型返回文本回答 ──
556
+ const content = typeof choice.message.content === 'string'
287
557
  ? choice.message.content
288
558
  : '';
289
559
 
290
- // 如果内容为空但有工具调用结果,生成基于工具结果的回复
291
- if (!content.trim() && state.toolCalls.length > 0) {
292
- const lastToolCall = state.toolCalls[state.toolCalls.length - 1];
293
- const resultStr = typeof lastToolCall.result === 'string'
294
- ? lastToolCall.result
295
- : JSON.stringify(lastToolCall.result, null, 2);
296
- content = `根据查询结果:\n\n${resultStr}`;
297
- }
298
-
299
560
  const result: AgentResult = {
300
561
  content,
301
562
  toolCalls: state.toolCalls,
@@ -309,13 +570,52 @@ export class Agent {
309
570
  } catch (error) {
310
571
  const err = error instanceof Error ? error : new Error(String(error));
311
572
  this.emit('error', err);
312
- throw err;
573
+
574
+ // ── 错误恢复策略 ──
575
+ // 如果已经有工具结果,注入恢复消息让 AI 基于已有数据回答
576
+ if (state.toolCalls.length > 0) {
577
+ logger.warn(`第 ${state.iterations} 轮 LLM 调用失败,尝试基于已有数据恢复: ${err.message}`);
578
+ const toolSummary = state.toolCalls.map(tc => {
579
+ const r = typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result);
580
+ return `【${tc.tool}】${r}`;
581
+ }).join('\n');
582
+ const fallbackResult: AgentResult = {
583
+ content: `以下是已获取的工具结果:\n${toolSummary}`,
584
+ toolCalls: state.toolCalls,
585
+ usage: state.usage,
586
+ iterations: state.iterations,
587
+ };
588
+ this.emit('complete', fallbackResult);
589
+ return fallbackResult;
590
+ }
591
+
592
+ // 没有任何工具结果,提供友好的错误消息
593
+ const fallbackResult: AgentResult = {
594
+ content: `抱歉,处理过程中遇到了问题:${err.message}。请稍后重试或换个方式提问。`,
595
+ toolCalls: [],
596
+ usage: state.usage,
597
+ iterations: state.iterations,
598
+ };
599
+ this.emit('complete', fallbackResult);
600
+ return fallbackResult;
313
601
  }
314
602
  }
315
603
 
316
- // 达到最大迭代次数
604
+ // 达到最大迭代次数,基于已有工具结果生成兜底回复
605
+ let fallbackContent: string;
606
+ if (state.toolCalls.length > 0) {
607
+ // 尝试从工具结果中构建有意义的回复
608
+ const toolSummary = state.toolCalls.map(tc => {
609
+ const r = typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result);
610
+ return `【${tc.tool}】${r}`;
611
+ }).join('\n');
612
+ fallbackContent = `处理完成,以下是获取到的信息:\n${toolSummary}`;
613
+ } else {
614
+ fallbackContent = '达到最大处理轮次,任务可能未完全完成。请尝试简化问题后重试。';
615
+ }
616
+
317
617
  const result: AgentResult = {
318
- content: '达到最大迭代次数,任务可能未完成。',
618
+ content: fallbackContent,
319
619
  toolCalls: state.toolCalls,
320
620
  usage: state.usage,
321
621
  iterations: state.iterations,
@@ -327,8 +627,12 @@ export class Agent {
327
627
 
328
628
  /**
329
629
  * 流式运行 Agent
630
+ *
631
+ * @param userMessage 用户消息
632
+ * @param context 对话上下文
633
+ * @param filterOptions 工具过滤选项 —— 启用后在 AI 调用之前程序化筛选工具
330
634
  */
331
- async *runStream(userMessage: string, context?: ChatMessage[]): AsyncIterable<{
635
+ async *runStream(userMessage: string, context?: ChatMessage[], filterOptions?: ToolFilterOptions): AsyncIterable<{
332
636
  type: 'content' | 'tool_call' | 'tool_result' | 'done';
333
637
  data: any;
334
638
  }> {
@@ -338,10 +642,29 @@ export class Agent {
338
642
  { role: 'user', content: userMessage },
339
643
  ];
340
644
 
341
- const toolDefinitions = this.getToolDefinitions();
645
+ // 程序化工具预过滤
646
+ let toolDefinitions: ChatToolDefinition[];
647
+ if (filterOptions) {
648
+ const allTools = Array.from(this.tools.values());
649
+ const filtered = Agent.filterTools(userMessage, allTools, filterOptions);
650
+ logger.debug(`流式工具预过滤: ${allTools.length} -> ${filtered.length}`);
651
+ toolDefinitions = filtered.map(tool => ({
652
+ type: 'function' as const,
653
+ function: {
654
+ name: tool.name,
655
+ description: tool.description,
656
+ parameters: tool.parameters,
657
+ },
658
+ }));
659
+ } else {
660
+ toolDefinitions = this.getToolDefinitions();
661
+ }
662
+ const hasTools = toolDefinitions.length > 0;
342
663
  let iterations = 0;
343
664
  const toolCallHistory: { tool: string; args: any; result: any }[] = [];
344
665
  const usage: Usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
666
+ const seenToolKeys = new Set<string>();
667
+ let consecutiveDuplicateRounds = 0;
345
668
 
346
669
  while (iterations < this.config.maxIterations) {
347
670
  iterations++;
@@ -349,28 +672,33 @@ export class Agent {
349
672
  let content = '';
350
673
  const pendingToolCalls: ToolCall[] = [];
351
674
  let finishReason: string | null = null;
675
+ const isLastIteration = iterations >= this.config.maxIterations;
676
+ const forceAnswer = consecutiveDuplicateRounds > 0 ||
677
+ (isLastIteration && toolCallHistory.length > 0);
352
678
 
353
679
  // 流式获取响应
354
680
  for await (const chunk of this.provider.chatStream({
355
681
  model: this.config.model,
356
682
  messages,
357
- tools: toolDefinitions.length > 0 ? toolDefinitions : undefined,
358
- tool_choice: toolDefinitions.length > 0 ? 'auto' : undefined,
683
+ tools: hasTools && !forceAnswer ? toolDefinitions : undefined,
684
+ tool_choice: hasTools && !forceAnswer ? 'auto' : undefined,
359
685
  temperature: this.config.temperature,
360
686
  })) {
361
687
  const choice = chunk.choices[0];
362
688
  if (!choice) continue;
363
689
 
364
- // 处理内容
690
+ // 处理内容片段
365
691
  if (choice.delta.content) {
366
692
  content += choice.delta.content;
367
- yield { type: 'content', data: choice.delta.content };
693
+ // 仅在非工具调用阶段输出内容给消费者
694
+ if (pendingToolCalls.length === 0) {
695
+ yield { type: 'content', data: choice.delta.content };
696
+ }
368
697
  }
369
698
 
370
- // 处理工具调用
699
+ // 合并工具调用片段
371
700
  if (choice.delta.tool_calls) {
372
701
  for (const tc of choice.delta.tool_calls) {
373
- // 合并工具调用片段
374
702
  let existing = pendingToolCalls.find(p => p.id === tc.id);
375
703
  if (!existing && tc.id) {
376
704
  existing = {
@@ -387,42 +715,82 @@ export class Agent {
387
715
  }
388
716
  }
389
717
 
390
- if (choice.finish_reason) {
391
- finishReason = choice.finish_reason;
392
- }
718
+ if (choice.finish_reason) finishReason = choice.finish_reason;
393
719
 
394
- if (chunk.usage) {
395
- usage.prompt_tokens += chunk.usage.prompt_tokens;
396
- usage.completion_tokens += chunk.usage.completion_tokens;
397
- usage.total_tokens += chunk.usage.total_tokens;
398
- }
720
+ Agent.addUsage(usage, chunk.usage);
399
721
  }
400
722
 
401
- // 添加 assistant 消息。若存在 pendingToolCalls,则不要在流式期间把模型原始包含工具调用的内容直接发送给消费者。
402
- const assistantMessage: ChatMessage = {
403
- role: 'assistant',
723
+ // assistant 消息加入上下文
724
+ messages.push({
725
+ role: 'tool_call',
404
726
  content,
405
727
  tool_calls: pendingToolCalls.length > 0 ? pendingToolCalls : undefined,
406
- };
407
- messages.push(assistantMessage);
728
+ });
408
729
 
409
- // 处理工具调用(流式模式)
730
+ // 处理工具调用
410
731
  if (pendingToolCalls.length > 0 && finishReason === 'tool_calls') {
411
- for (const toolCall of pendingToolCalls) {
412
- // 通知上层开始执行工具(上层可用于显示“正在执行工具”而非工具原始内容)
413
- yield { type: 'tool_call', data: { name: toolCall.function.name, args: toolCall.function.arguments } };
732
+ // 分离新调用和重复调用
733
+ const freshCalls: ToolCall[] = [];
734
+ const duplicateCalls: ToolCall[] = [];
735
+
736
+ for (const tc of pendingToolCalls) {
737
+ const key = Agent.toolCallKey(tc.function.name, tc.function.arguments);
738
+ if (seenToolKeys.has(key)) {
739
+ duplicateCalls.push(tc);
740
+ } else {
741
+ freshCalls.push(tc);
742
+ }
743
+ }
414
744
 
415
- const result = await this.executeToolCall(toolCall);
745
+ if (freshCalls.length === 0) {
746
+ // 全部重复
747
+ consecutiveDuplicateRounds++;
416
748
 
417
- toolCallHistory.push({
418
- tool: toolCall.function.name,
419
- args: JSON.parse(toolCall.function.arguments),
420
- result: JSON.parse(result),
421
- });
749
+ // 补上 tool 消息保持协议完整
750
+ for (const tc of pendingToolCalls) {
751
+ const key = Agent.toolCallKey(tc.function.name, tc.function.arguments);
752
+ const previous = toolCallHistory.find(
753
+ h => Agent.toolCallKey(h.tool, JSON.stringify(h.args)) === key ||
754
+ Agent.toolCallKey(h.tool, tc.function.arguments) === key,
755
+ );
756
+ messages.push({
757
+ role: 'tool_result',
758
+ content: previous ? JSON.stringify(previous.result) : '结果已获取',
759
+ tool_call_id: tc.id,
760
+ });
761
+ }
762
+ continue;
763
+ }
422
764
 
423
- // 返回工具结果(上层可选择把工具执行过程隐藏,仅在最终完成时展示润色结果)
424
- yield { type: 'tool_result', data: { name: toolCall.function.name, result } };
765
+ consecutiveDuplicateRounds = 0;
766
+
767
+ // 先通知上层所有工具调用开始
768
+ for (const tc of freshCalls) {
769
+ yield { type: 'tool_call', data: { name: tc.function.name, args: tc.function.arguments } };
770
+ }
771
+
772
+ // 并行执行所有新工具调用
773
+ const results = await Promise.all(
774
+ freshCalls.map(async (toolCall) => {
775
+ const result = await this.executeToolCall(toolCall);
776
+ const args = Agent.safeParse(toolCall.function.arguments);
777
+ const parsedResult = Agent.safeParse(result);
778
+
779
+ const key = Agent.toolCallKey(toolCall.function.name, toolCall.function.arguments);
780
+ seenToolKeys.add(key);
781
+ toolCallHistory.push({
782
+ tool: toolCall.function.name,
783
+ args: typeof args === 'object' ? args : { raw: args },
784
+ result: parsedResult,
785
+ });
786
+
787
+ return { toolCall, result };
788
+ }),
789
+ );
425
790
 
791
+ // yield 工具结果并加入消息历史
792
+ for (const { toolCall, result } of results) {
793
+ yield { type: 'tool_result', data: { name: toolCall.function.name, result } };
426
794
  messages.push({
427
795
  role: 'tool',
428
796
  content: result,
@@ -430,7 +798,20 @@ export class Agent {
430
798
  });
431
799
  }
432
800
 
433
- // 继续循环,让模型以工具结果为上下文生成最终回答
801
+ // 为重复的调用也补上 tool 消息
802
+ for (const tc of duplicateCalls) {
803
+ const key = Agent.toolCallKey(tc.function.name, tc.function.arguments);
804
+ const previous = toolCallHistory.find(
805
+ h => Agent.toolCallKey(h.tool, JSON.stringify(h.args)) === key ||
806
+ Agent.toolCallKey(h.tool, tc.function.arguments) === key,
807
+ );
808
+ messages.push({
809
+ role: 'tool',
810
+ content: previous ? JSON.stringify(previous.result) : '结果已获取',
811
+ tool_call_id: tc.id,
812
+ });
813
+ }
814
+
434
815
  continue;
435
816
  }
436
817
 
@@ -451,7 +832,9 @@ export class Agent {
451
832
  yield {
452
833
  type: 'done',
453
834
  data: {
454
- content: '达到最大迭代次数',
835
+ content: toolCallHistory.length > 0
836
+ ? `处理完成,共执行了 ${toolCallHistory.length} 个工具调用。`
837
+ : '达到最大迭代次数',
455
838
  toolCalls: toolCallHistory,
456
839
  usage,
457
840
  iterations,