@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.
- package/CHANGELOG.md +13 -0
- package/README.md +131 -497
- package/lib/agent.d.ts +54 -6
- package/lib/agent.d.ts.map +1 -1
- package/lib/agent.js +468 -116
- package/lib/agent.js.map +1 -1
- package/lib/compaction.d.ts +132 -0
- package/lib/compaction.d.ts.map +1 -0
- package/lib/compaction.js +370 -0
- package/lib/compaction.js.map +1 -0
- package/lib/context-manager.d.ts.map +1 -1
- package/lib/context-manager.js +10 -3
- package/lib/context-manager.js.map +1 -1
- package/lib/conversation-memory.d.ts +192 -0
- package/lib/conversation-memory.d.ts.map +1 -0
- package/lib/conversation-memory.js +619 -0
- package/lib/conversation-memory.js.map +1 -0
- package/lib/index.d.ts +25 -163
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +24 -1122
- package/lib/index.js.map +1 -1
- package/lib/output.d.ts +93 -0
- package/lib/output.d.ts.map +1 -0
- package/lib/output.js +176 -0
- package/lib/output.js.map +1 -0
- package/lib/providers/anthropic.d.ts +7 -0
- package/lib/providers/anthropic.d.ts.map +1 -1
- package/lib/providers/anthropic.js +5 -0
- package/lib/providers/anthropic.js.map +1 -1
- package/lib/providers/ollama.d.ts +10 -0
- package/lib/providers/ollama.d.ts.map +1 -1
- package/lib/providers/ollama.js +19 -4
- package/lib/providers/ollama.js.map +1 -1
- package/lib/providers/openai.d.ts +7 -0
- package/lib/providers/openai.d.ts.map +1 -1
- package/lib/providers/openai.js +11 -0
- package/lib/providers/openai.js.map +1 -1
- package/lib/rate-limiter.d.ts +38 -0
- package/lib/rate-limiter.d.ts.map +1 -0
- package/lib/rate-limiter.js +86 -0
- package/lib/rate-limiter.js.map +1 -0
- package/lib/session.d.ts +7 -0
- package/lib/session.d.ts.map +1 -1
- package/lib/session.js +47 -18
- package/lib/session.js.map +1 -1
- package/lib/storage.d.ts +68 -0
- package/lib/storage.d.ts.map +1 -0
- package/lib/storage.js +105 -0
- package/lib/storage.js.map +1 -0
- package/lib/tone-detector.d.ts +19 -0
- package/lib/tone-detector.d.ts.map +1 -0
- package/lib/tone-detector.js +72 -0
- package/lib/tone-detector.js.map +1 -0
- package/lib/types.d.ts +84 -8
- package/lib/types.d.ts.map +1 -1
- package/package.json +13 -42
- package/src/agent.ts +518 -135
- package/src/compaction.ts +529 -0
- package/src/context-manager.ts +10 -9
- package/src/conversation-memory.ts +816 -0
- package/src/index.ts +121 -1406
- package/src/output.ts +261 -0
- package/src/providers/anthropic.ts +4 -0
- package/src/providers/ollama.ts +23 -4
- package/src/providers/openai.ts +8 -1
- package/src/rate-limiter.ts +129 -0
- package/src/session.ts +47 -18
- package/src/storage.ts +135 -0
- package/src/tone-detector.ts +89 -0
- package/src/types.ts +95 -6
- package/tests/agent.test.ts +123 -70
- package/tests/compaction.test.ts +310 -0
- package/tests/context-manager.test.ts +73 -47
- package/tests/conversation-memory.test.ts +128 -0
- package/tests/output.test.ts +128 -0
- package/tests/providers.test.ts +574 -0
- package/tests/rate-limiter.test.ts +108 -0
- package/tests/session.test.ts +139 -48
- package/tests/setup.ts +82 -240
- package/tests/storage.test.ts +224 -0
- package/tests/tone-detector.test.ts +80 -0
- package/tsconfig.json +4 -5
- package/vitest.setup.ts +1 -0
- package/TOOLS.md +0 -294
- package/lib/tools.d.ts +0 -45
- package/lib/tools.d.ts.map +0 -1
- package/lib/tools.js +0 -194
- package/lib/tools.js.map +0 -1
- package/src/tools.ts +0 -205
- package/tests/ai-trigger.test.ts +0 -369
- package/tests/integration.test.ts +0 -596
- package/tests/providers.integration.test.ts +0 -227
- package/tests/tool.test.ts +0 -800
- package/tests/tools-builtin.test.ts +0 -346
package/src/agent.ts
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zhin.js/
|
|
2
|
+
* @zhin.js/agent - Agent System
|
|
3
3
|
* AI Agent 实现,支持工具调用和多轮对话
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Logger } from '@zhin.js/
|
|
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():
|
|
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({
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
185
|
-
tool_choice:
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
//
|
|
204
|
-
|
|
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: '
|
|
225
|
-
content:
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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.
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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: '
|
|
277
|
-
content: '
|
|
547
|
+
role: 'system',
|
|
548
|
+
content: '工具已返回结果。如果信息足够回答用户问题,请直接用自然语言回答,不要重复调用相同工具。',
|
|
278
549
|
});
|
|
279
550
|
}
|
|
280
551
|
|
|
281
|
-
// 继续循环,让模型处理工具结果并生成最终回答
|
|
282
552
|
continue;
|
|
283
553
|
}
|
|
284
554
|
|
|
285
|
-
//
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
358
|
-
tool_choice:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
402
|
-
|
|
403
|
-
role: '
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
745
|
+
if (freshCalls.length === 0) {
|
|
746
|
+
// 全部重复
|
|
747
|
+
consecutiveDuplicateRounds++;
|
|
416
748
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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,
|