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