@aalis/plugin-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1609 @@
1
+ import { useCommandService } from '@aalis/plugin-commands-api';
2
+ import { resolveLLMModel } from '@aalis/plugin-llm-api';
3
+ import { CONTROL_KINDS, getMessageName, getSenderLabel, WellKnownKinds } from '@aalis/plugin-message-api';
4
+ import { getPlatformSelfIdentity } from '@aalis/plugin-platform-api';
5
+ import '@aalis/plugin-commands-api';
6
+ import { normalizeAssistantContent, stripLeakedSpecialTokens } from '@aalis/util-text-normalize';
7
+ import { buildFocusGuidance, estimateMsgTokens, estimateTextTokens, estimateTokens, formatTimeLabel, INPUT_CONVENTIONS, isSameMessage, } from './helpers.js';
8
+ /**
9
+ * 默认 Agent 实现 —— 对话编排器
10
+ *
11
+ * 负责:
12
+ * 1. 组装系统提示 (persona + base)
13
+ * 2. 加载历史消息 (memory)
14
+ * 3. 收集可用工具 (tools registry)
15
+ * 4. 调用 LLM 服务
16
+ * 5. 执行工具调用循环
17
+ * 6. 发出 outbound:message 事件
18
+ *
19
+ * 外部插件可以注册高优先级的 AgentService 来完全替换此默认实现。
20
+ */
21
+ /** 统一读取 LLM 单次最大输出 token;缺省回退 4096(与各 adapter 默认一致)。 */
22
+ const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
23
+ function getModelMaxOutput(llm) {
24
+ return llm.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
25
+ }
26
+ class DefaultAgent {
27
+ ctx;
28
+ logger;
29
+ systemPrompt;
30
+ memoryTokenBudget;
31
+ historyLimit;
32
+ maxToolIterations;
33
+ /** 单条工具结果占上下文窗口的最大比例 (0~1),超出则截断 */
34
+ toolResultMaxRatio;
35
+ /** 内存裁剪触发比例 (0~1):估算输入 token 占 contextLength 的比例上限,超过则触发本次调用的内存裁剪 */
36
+ trimThresholdRatio;
37
+ /**
38
+ * 活跃 AbortController 表
39
+ *
40
+ * key = `${sessionId}::${source}` — 同一 session 不同来源(user / scheduler)
41
+ * 独立管理,互不打断;同来源新消息会中止旧的生成。
42
+ */
43
+ activeControllers = new Map();
44
+ /** 同一 lane 的入站消息归档串行化,避免连续消息读取历史时漏掉前一条输入。 */
45
+ archiveQueues = new Map();
46
+ /**
47
+ * 节流日志状态:记录每个 session 上次 token:usage 日志的"轮次"与 ratio 桶。
48
+ * - 跨过 0.5/0.7/0.85 三个阈值必输出
49
+ * - 否则每 10 轮(计数器)输出一次
50
+ * 与 token:usage 事件共用同一份 breakdown 数据,零额外计算。
51
+ */
52
+ tokenLogState = new Map();
53
+ /** 已注册的预处理器(name → { priority, dispose }) */
54
+ preprocessors = new Map();
55
+ constructor(ctx, config) {
56
+ this.ctx = ctx;
57
+ this.logger = ctx.logger.child('agent');
58
+ this.systemPrompt = config.systemPrompt || '';
59
+ this.memoryTokenBudget = config.memoryTokenBudget ?? 4096;
60
+ this.historyLimit = config.historyLimit ?? 50;
61
+ this.maxToolIterations = config.maxToolIterations ?? 30;
62
+ this.toolResultMaxRatio = config.toolResultMaxRatio ?? 0.15;
63
+ this.trimThresholdRatio = config.trimThresholdRatio ?? 1.0;
64
+ this.logger.info('默认对话代理已初始化');
65
+ }
66
+ /**
67
+ * 根据优先级链解析当前会话该用哪个 LLMModel entry。
68
+ *
69
+ * 全部交给 session-manager.resolveConfig。优先级(从高到低):
70
+ * 1. 会话 config
71
+ * 2. 父会话 sessionDefaults
72
+ * 3. 平台 profile
73
+ * 4. ServicePreference(由 agent 启动时锁定的全局默认 entry,未传 ref 时生效)
74
+ *
75
+ * 返回 LLMModelEntry(含 instance / contextId / capabilities),entry 已绑定具体 model。
76
+ */
77
+ async resolveLLM(platform, sessionId) {
78
+ let ref;
79
+ // session-manager 一步到位:会话 > 父 sessionDefaults > platform profile
80
+ const sm = this.ctx.getService('session-manager');
81
+ if (sm && sessionId) {
82
+ const resolved = sm.resolveConfig(sessionId, platform);
83
+ if (resolved.llm?.provider && resolved.llm?.model)
84
+ ref = resolved.llm;
85
+ }
86
+ // 解析为具体 LLMModel entry(要求至少 chat 能力;ref 为空时走 ServicePreference / 优先级)
87
+ return resolveLLMModel(this.ctx, ref, ['chat']);
88
+ }
89
+ /** 生成 lane key:同 session + 同 source 共用一个 lane */ laneKey(sessionId, source) {
90
+ return `${sessionId}::${source ?? 'user'}`;
91
+ }
92
+ /**
93
+ * 中止指定会话的当前生成(所有 lane)
94
+ */
95
+ abort(sessionId) {
96
+ for (const [key, controller] of this.activeControllers) {
97
+ if (key.startsWith(`${sessionId}::`)) {
98
+ controller.abort();
99
+ this.activeControllers.delete(key);
100
+ }
101
+ }
102
+ this.logger.info(`生成已中止: session=${sessionId}`);
103
+ }
104
+ /**
105
+ * 注册消息预处理器
106
+ *
107
+ /**
108
+ * 注册输入预处理器(如图片识别、文件读取、用户画像等)。
109
+ * 多个预处理器按注册顺序串行执行(Koa-style 洋葱模型)。
110
+ *
111
+ * 底层通过 agent:input:before 中间件实现。
112
+ * 同名注册会自动替换旧的预处理器。
113
+ */
114
+ registerPreprocessor(name, handler) {
115
+ // 同名替换
116
+ const existing = this.preprocessors.get(name);
117
+ if (existing)
118
+ existing.dispose();
119
+ const dispose = this.ctx.middleware('agent:input:before', async (data, next) => {
120
+ await handler(data.message, next);
121
+ });
122
+ const cleanup = () => {
123
+ dispose();
124
+ this.preprocessors.delete(name);
125
+ this.logger.info(`预处理器已注销: ${name}`);
126
+ };
127
+ this.preprocessors.set(name, { dispose: cleanup });
128
+ this.logger.info(`预处理器已注册: ${name}`);
129
+ return cleanup;
130
+ }
131
+ /**
132
+ /**
133
+ * 获取当前所有已注册预处理器的元信息(按注册顺序返回)
134
+ */
135
+ getPreprocessors() {
136
+ return [...this.preprocessors.keys()].map(name => ({ name }));
137
+ }
138
+ /**
139
+ * 获取 Agent 子系统的插件分组
140
+ *
141
+ * 仅纳入 Agent 直接依赖的能力提供者;不包含 `platform`
142
+ * (平台属于独立子系统,由 plugin-platform-api 的 helper 负责)。
143
+ */
144
+ getPluginGroups() {
145
+ const pm = this.ctx.getService('plugins');
146
+ if (!pm)
147
+ return [];
148
+ // 子系统归属:Agent 域的服务(不含 platform——平台是独立子系统)
149
+ const targetServices = new Set(['llm', 'memory', 'persona', 'message-archive']);
150
+ const grouped = [];
151
+ for (const p of pm.getStatus()) {
152
+ if (p.provides?.some(s => targetServices.has(s))) {
153
+ grouped.push(p.instanceId);
154
+ }
155
+ }
156
+ return [{ label: 'Agent', plugins: grouped }];
157
+ }
158
+ /**
159
+ * 消费流式 LLM 调用,累积完整响应,同时向前端推送增量事件。
160
+ *
161
+ * 同时构建本轮调用的 segments 时间线(按 chunk 到达顺序记录 text / reasoning_text,
162
+ * 相邻同类合并)——用于上层将多轮调用 + 工具执行交错拼接成统一时间线。
163
+ */
164
+ async consumeStream(llm, request, sessionId, platform, signal) {
165
+ let content = '';
166
+ let reasoningContent = '';
167
+ let toolCalls;
168
+ let usage;
169
+ const segments = [];
170
+ const appendDelta = (kind, delta) => {
171
+ const last = segments[segments.length - 1];
172
+ if (last && last.type === kind) {
173
+ segments[segments.length - 1] = { type: kind, content: last.content + delta };
174
+ }
175
+ else {
176
+ segments.push({ type: kind, content: delta });
177
+ }
178
+ };
179
+ for await (const chunk of llm.chatStream(request)) {
180
+ // 检查中止信号
181
+ if (signal?.aborted) {
182
+ throw new DOMException('Generation aborted', 'AbortError');
183
+ }
184
+ if (chunk.contentDelta) {
185
+ content += chunk.contentDelta;
186
+ appendDelta('text', chunk.contentDelta);
187
+ await this.ctx.emit('outbound:stream', {
188
+ sessionId,
189
+ platform,
190
+ contentDelta: chunk.contentDelta,
191
+ });
192
+ }
193
+ if (chunk.reasoningDelta) {
194
+ reasoningContent += chunk.reasoningDelta;
195
+ appendDelta('reasoning_text', chunk.reasoningDelta);
196
+ await this.ctx.emit('outbound:stream', {
197
+ sessionId,
198
+ platform,
199
+ reasoningDelta: chunk.reasoningDelta,
200
+ });
201
+ }
202
+ if (chunk.toolCallProgress) {
203
+ await this.ctx.emit('outbound:stream', {
204
+ sessionId,
205
+ platform,
206
+ toolCallProgress: chunk.toolCallProgress,
207
+ });
208
+ }
209
+ if (chunk.done) {
210
+ toolCalls = chunk.toolCalls;
211
+ }
212
+ if (chunk.usage) {
213
+ usage = chunk.usage;
214
+ }
215
+ }
216
+ let finalContent = content;
217
+ if (content) {
218
+ // 兜底剥离 LLM 端漏出的特殊 token 残渣(DSML 等),同时修复 GFM 表格
219
+ const { sanitized, hadLeak } = stripLeakedSpecialTokens(content);
220
+ if (hadLeak) {
221
+ this.ctx.logger.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
222
+ }
223
+ finalContent = normalizeAssistantContent(content);
224
+ }
225
+ return {
226
+ content: finalContent,
227
+ reasoningContent: reasoningContent || undefined,
228
+ toolCalls,
229
+ usage,
230
+ segments,
231
+ };
232
+ }
233
+ /**
234
+ * Debug 模式下格式化输出 LLM 响应详情
235
+ */
236
+ debugLogResponse(response, elapsedMs, iteration) {
237
+ const tag = iteration != null ? `LLM 响应 (工具迭代 #${iteration})` : 'LLM 响应';
238
+ const sep = '━'.repeat(52);
239
+ const lines = ['', sep, ` ${tag} (${elapsedMs}ms)`, sep];
240
+ // 尝试解析结构化输出(用于 debug 日志美化展示,不影响行为)
241
+ let parsedFormat = null;
242
+ if (response.content) {
243
+ const raw = response.content.trim();
244
+ const jsonStr = raw.startsWith('{') ? raw : raw.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/, '');
245
+ try {
246
+ const obj = JSON.parse(jsonStr);
247
+ if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
248
+ parsedFormat = obj;
249
+ }
250
+ }
251
+ catch {
252
+ /* 非 JSON,正常文本 */
253
+ }
254
+ }
255
+ if (parsedFormat) {
256
+ // 结构化输出:先展示原始 JSON,再逐字段展示
257
+ lines.push(' 原始 JSON:');
258
+ try {
259
+ const compact = JSON.stringify(parsedFormat);
260
+ if (compact.length <= 200) {
261
+ lines.push(` ${compact}`);
262
+ }
263
+ else {
264
+ for (const pLine of JSON.stringify(parsedFormat, null, 2).split('\n')) {
265
+ lines.push(` ${pLine}`);
266
+ }
267
+ }
268
+ }
269
+ catch {
270
+ /* ignore */
271
+ }
272
+ lines.push('');
273
+ lines.push(' 结构化输出:');
274
+ for (const [key, value] of Object.entries(parsedFormat)) {
275
+ const valStr = typeof value === 'string' ? value : JSON.stringify(value);
276
+ if (valStr.includes('\n')) {
277
+ lines.push(` ${key}:`);
278
+ for (const vLine of valStr.split('\n')) {
279
+ lines.push(` ${vLine}`);
280
+ }
281
+ }
282
+ else {
283
+ lines.push(` ${key}: ${valStr}`);
284
+ }
285
+ }
286
+ }
287
+ else if (response.content) {
288
+ // 普通文本
289
+ lines.push(' 内容:');
290
+ for (const line of response.content.split('\n')) {
291
+ lines.push(` ${line}`);
292
+ }
293
+ }
294
+ else {
295
+ lines.push(' 内容: (空)');
296
+ }
297
+ // 推理
298
+ if (response.reasoningContent) {
299
+ lines.push('');
300
+ lines.push(' 推理:');
301
+ for (const line of response.reasoningContent.split('\n')) {
302
+ lines.push(` ${line}`);
303
+ }
304
+ }
305
+ // 工具调用
306
+ if (response.toolCalls?.length) {
307
+ lines.push('');
308
+ lines.push(' 工具调用:');
309
+ for (const tc of response.toolCalls) {
310
+ lines.push(` -> ${tc.function.name}`);
311
+ // 格式化 JSON 参数
312
+ try {
313
+ const pretty = JSON.stringify(JSON.parse(tc.function.arguments), null, 2);
314
+ for (const pLine of pretty.split('\n')) {
315
+ lines.push(` ${pLine}`);
316
+ }
317
+ }
318
+ catch {
319
+ lines.push(` ${tc.function.arguments}`);
320
+ }
321
+ }
322
+ }
323
+ // Token 用量
324
+ if (response.usage) {
325
+ const u = response.usage;
326
+ lines.push('');
327
+ lines.push(` Token: 输入 ${u.promptTokens} / 输出 ${u.completionTokens} / 总计 ${u.totalTokens}`);
328
+ }
329
+ lines.push(sep);
330
+ this.logger.debug(lines.join('\n'));
331
+ }
332
+ async handleMessage(incoming) {
333
+ const lane = this.laneKey(incoming.sessionId, incoming.source);
334
+ // 仅中止同一 lane(同 session + 同 source)的旧生成;不同来源互不打断
335
+ const prev = this.activeControllers.get(lane);
336
+ if (prev)
337
+ prev.abort();
338
+ const controller = new AbortController();
339
+ this.activeControllers.set(lane, controller);
340
+ try {
341
+ await this._handleMessageInner(incoming, controller.signal, lane);
342
+ }
343
+ finally {
344
+ // 仅清理自己创建的 controller(避免清掉后续新请求的)
345
+ if (this.activeControllers.get(lane) === controller) {
346
+ this.activeControllers.delete(lane);
347
+ }
348
+ }
349
+ }
350
+ async _handleMessageInner(incoming, signal, lane) {
351
+ // Hook: agent:input:before — 插件可以修改或拦截消息
352
+ // 中间件不调用 next() 即可中断整个流程(包括 LLM 调用)
353
+ const msgHookData = {
354
+ message: incoming,
355
+ metadata: {},
356
+ };
357
+ let handled = false;
358
+ await this.ctx.hooks.run('agent:input:before', msgHookData, async () => {
359
+ handled = true;
360
+ // ===== defaultAction: 全部消息处理逻辑在此 =====
361
+ // 中间件不调用 next() → 此处永远不执行 → 消息被拦截
362
+ incoming = msgHookData.message;
363
+ const archivedIncoming = await this.archiveIncomingMessageInOrder(lane, incoming);
364
+ const resolved = await this.resolveLLM(incoming.platform, incoming.sessionId);
365
+ if (!resolved) {
366
+ this.logger.warn('LLM 服务不可用,无法处理消息');
367
+ await this.dispatchOutbound({
368
+ content: '[系统] LLM 服务不可用,请检查配置。',
369
+ sessionId: incoming.sessionId,
370
+ platform: incoming.platform,
371
+ source: 'system',
372
+ });
373
+ return;
374
+ }
375
+ const llm = resolved.instance;
376
+ // 从 LLM model entry 读取参数。contextLength / maxOutputTokens 均为 per-model 属性,
377
+ // service-granularity 后不再需要 router.getContextLengthFor() 反查,也不再会出现
378
+ // 默认 provider 全局窗口与会话实际 model 不一致的偏差(Bug F 结构性修复)。
379
+ const maxTokens = getModelMaxOutput(llm);
380
+ const maxToolIterations = this.maxToolIterations;
381
+ const contextLength = llm.contextLength;
382
+ // 预留 token 预算 = 上下文长度 × trimThresholdRatio - 最大输出 token - 安全余量
383
+ // trimThresholdRatio < 1 可提前触发裁剪,默认 1.0 = 占满物理上限才裁剪
384
+ const tokenBudget = Math.max(1024, Math.floor(contextLength * this.trimThresholdRatio) - maxTokens - 512);
385
+ // Bug B 防回溯:本回合中通过工具循环写入到 memory 的 (assistant+toolCalls + tool 结果) 消息时间戳。
386
+ // 用户中途点「停止生成」时,这些条目要从历史中删除——否则下一条消息发出后,agent 会再次
387
+ // 看到上一轮被中断的工具调用 + 半截 assistant 文本,导致「假回退」。
388
+ const turnPersistedTimestamps = [];
389
+ try {
390
+ // 统一解析 session 配置(一次解析,多处复用)
391
+ const sessionMgr = this.ctx.getService('session-manager');
392
+ const resolved = sessionMgr && incoming.sessionId
393
+ ? sessionMgr.resolveConfig(incoming.sessionId, incoming.platform)
394
+ : undefined;
395
+ // 构建 persona 会话选项(从 resolved config 中提取,传给 persona 服务)
396
+ const personaOpts = resolved
397
+ ? {
398
+ persona: resolved.persona,
399
+ disableOutputFormat: resolved.disableOutputFormat,
400
+ clientSideJsonRendering: resolved.clientSideJsonRendering,
401
+ }
402
+ : undefined;
403
+ const messages = await this.buildMessages(incoming, personaOpts, archivedIncoming);
404
+ // 通过 resolved config 获取工具分组
405
+ let enabledGroups;
406
+ if (resolved?.enabledToolGroups && resolved.enabledToolGroups.length > 0) {
407
+ enabledGroups = resolved.enabledToolGroups;
408
+ }
409
+ this.logger.debug(`工具分组: platform=${incoming.platform}, enabledGroups=${enabledGroups ? JSON.stringify(enabledGroups) : '(无)'}`);
410
+ const tools = this.ctx
411
+ .getService('tools')
412
+ ?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ?? [];
413
+ const toolCtx = {
414
+ sessionId: incoming.sessionId,
415
+ // 优先用 actor(系统触发器注入的代理身份),其次 fallback 到消息原始 userId/platform。
416
+ // 这样 scheduler/idle/proactive 等触发的 AI 走的是创建者的 authority,而非匿名 defaultAuthority。
417
+ userId: incoming.actor?.userId ?? incoming.userId,
418
+ platform: incoming.actor?.platform ?? incoming.platform,
419
+ enabledGroups,
420
+ };
421
+ // 保存原始完整工具列表,后续迭代均以此为基础(避免被 hooks 修改后丢失)
422
+ const originalTools = [...tools];
423
+ // Hook: agent:llm:before — 插件可以修改消息或工具列表
424
+ const llmBeforeData = {
425
+ messages,
426
+ tools,
427
+ sessionId: incoming.sessionId,
428
+ userId: incoming.userId,
429
+ platform: incoming.platform,
430
+ triggerType: incoming.triggerType,
431
+ };
432
+ await this.ctx.hooks.run('agent:llm:before', llmBeforeData);
433
+ // 裁剪消息以确保不超过上下文窗口
434
+ llmBeforeData.messages = this.trimMessages(llmBeforeData.messages, tokenBudget);
435
+ // 推送 token 使用量统计
436
+ this.emitTokenUsage(incoming.sessionId, incoming.platform, llmBeforeData.messages, llmBeforeData.tools, contextLength, maxTokens, tokenBudget);
437
+ this.logger.debug(`LLM 请求: ${llmBeforeData.messages.length} 条消息, ` +
438
+ `${llmBeforeData.tools.length} 个工具, ` +
439
+ `maxTokens=${maxTokens}`);
440
+ const t0 = Date.now();
441
+ // 本轮初始时间与累加用量,用于最终 assistant 消息的 modelInfo 元数据。
442
+ const turnStartTs = t0;
443
+ const turnUsageAcc = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
444
+ const accUsage = (u) => {
445
+ if (!u)
446
+ return;
447
+ // promptTokens 语义 = "生成这条回复时的上下文大小"。工具迭代循环里每轮都把同一份
448
+ // (不断增长的)上下文重新发给模型,若累加会把重复发送的上下文重复计入,导致单条
449
+ // 回复虚高到几十万 token。故取最后一次调用的 prompt(覆盖而非累加)= 回复定稿时的真实上下文。
450
+ if (u.promptTokens != null)
451
+ turnUsageAcc.promptTokens = u.promptTokens;
452
+ // completionTokens 语义 = "本回合真正新生成的输出"(含中间 reasoning / tool-call 生成
453
+ // 与最终回复文本),每次调用都是不重复的新输出,故逐次累加。
454
+ turnUsageAcc.completionTokens += u.completionTokens ?? 0;
455
+ turnUsageAcc.totalTokens = turnUsageAcc.promptTokens + turnUsageAcc.completionTokens;
456
+ };
457
+ const firstResult = await this.consumeStream(llm, {
458
+ messages: llmBeforeData.messages,
459
+ tools: llmBeforeData.tools.length > 0 ? llmBeforeData.tools : undefined,
460
+ maxTokens,
461
+ signal,
462
+ }, incoming.sessionId, incoming.platform, signal);
463
+ accUsage(firstResult.usage);
464
+ // 维护本轮(一次完整对话回合)的统一时间线 segments:
465
+ // 多次 LLM 调用 + 工具执行的输出按到达顺序拼接,保留模型原本的"思考/回答/工具/思考/回答"交错。
466
+ const turnSegments = [...firstResult.segments];
467
+ let response = firstResult;
468
+ this.debugLogResponse(response, Date.now() - t0);
469
+ // Hook: agent:llm:after — 插件可以处理 LLM 返回结果
470
+ const llmAfterData = { response, messages: llmBeforeData.messages };
471
+ await this.ctx.hooks.run('agent:llm:after', llmAfterData);
472
+ response = llmAfterData.response;
473
+ // 收集所有思考内容
474
+ const allReasoning = [];
475
+ if (response.reasoningContent) {
476
+ allReasoning.push(response.reasoningContent);
477
+ }
478
+ // 收集工具调用摘要
479
+ const toolCallSummaries = [];
480
+ const assistantMetadata = this.buildAssistantMetadata(incoming);
481
+ // 工具调用循环
482
+ let iterations = 0;
483
+ while (response.toolCalls && response.toolCalls.length > 0 && iterations < maxToolIterations) {
484
+ if (signal.aborted)
485
+ throw new DOMException('Generation aborted', 'AbortError');
486
+ iterations++;
487
+ this.logger.debug(`工具调用迭代 ${iterations}: ${response.toolCalls.map(tc => tc.function.name).join(', ')}`);
488
+ // 将 assistant 消息 (含 toolCalls) 加入历史
489
+ llmBeforeData.messages.push({
490
+ role: 'assistant',
491
+ content: response.content,
492
+ toolCalls: response.toolCalls,
493
+ reasoningContent: response.reasoningContent,
494
+ metadata: assistantMetadata,
495
+ });
496
+ const assistantToolMessage = {
497
+ role: 'assistant',
498
+ content: response.content,
499
+ toolCalls: response.toolCalls,
500
+ reasoningContent: response.reasoningContent,
501
+ metadata: assistantMetadata,
502
+ };
503
+ const toolMessages = [];
504
+ // 并行执行所有工具调用(互不依赖的工具无需串行等待)
505
+ const toolResultMaxChars = Math.floor(contextLength * this.toolResultMaxRatio * 3.5);
506
+ const parallelResults = await Promise.all(response.toolCalls.map(async (toolCall) => {
507
+ let args;
508
+ try {
509
+ args = JSON.parse(toolCall.function.arguments);
510
+ }
511
+ catch {
512
+ args = {};
513
+ }
514
+ // Hook: agent:tool:before — 插件可以拦截或修改工具调用
515
+ const toolBeforeData = { name: toolCall.function.name, args, toolCallContext: toolCtx };
516
+ await this.ctx.hooks.run('agent:tool:before', toolBeforeData);
517
+ // 通知平台:工具开始执行
518
+ await this.ctx.emit('tool:execute', {
519
+ sessionId: incoming.sessionId,
520
+ platform: incoming.platform,
521
+ toolName: toolBeforeData.name,
522
+ args: toolBeforeData.args,
523
+ phase: 'start',
524
+ });
525
+ this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${JSON.stringify(toolBeforeData.args)}`);
526
+ const toolT0 = Date.now();
527
+ let result = await (this.ctx
528
+ .getService('tools')
529
+ ?.execute(toolBeforeData.name, toolBeforeData.args, toolCtx) ??
530
+ Promise.resolve(JSON.stringify({ error: 'tools 服务不可用' })));
531
+ // Hook: agent:tool:after — 插件可以处理工具执行结果
532
+ const toolAfterData = { name: toolBeforeData.name, result, toolCallContext: toolCtx };
533
+ await this.ctx.hooks.run('agent:tool:after', toolAfterData);
534
+ result = toolAfterData.result;
535
+ // 工具结果截断:按上下文窗口比例限制单条工具结果长度
536
+ if (result.length > toolResultMaxChars) {
537
+ this.logger.info(`工具结果过长 (${result.length} 字符),截断至 ${toolResultMaxChars} 字符: ${toolBeforeData.name}`);
538
+ result = `${result.slice(0, toolResultMaxChars)}\n... [工具输出已截断,原始长度 ${result.length} 字符]`;
539
+ }
540
+ const toolEndTime = Date.now();
541
+ this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
542
+ // 通知平台:工具执行完成
543
+ await this.ctx.emit('tool:execute', {
544
+ sessionId: incoming.sessionId,
545
+ platform: incoming.platform,
546
+ toolName: toolBeforeData.name,
547
+ args: toolBeforeData.args,
548
+ phase: 'end',
549
+ result,
550
+ });
551
+ return {
552
+ toolCall,
553
+ result,
554
+ toolName: toolBeforeData.name,
555
+ toolArgs: toolBeforeData.args,
556
+ startTime: toolT0,
557
+ endTime: toolEndTime,
558
+ };
559
+ }));
560
+ // 按原始 toolCalls 顺序将结果推入消息列表 + 时间线
561
+ for (const { toolCall, result, toolName, toolArgs, startTime, endTime } of parallelResults) {
562
+ const resultPreview = result.length > 200 ? `${result.slice(0, 200)}...` : result;
563
+ toolCallSummaries.push(`[${toolCall.function.name}] ${resultPreview}`);
564
+ const toolMessage = {
565
+ role: 'tool',
566
+ content: result,
567
+ toolCallId: toolCall.id,
568
+ };
569
+ llmBeforeData.messages.push(toolMessage);
570
+ toolMessages.push(toolMessage);
571
+ // 工具调用追加到本回合的统一时间线
572
+ turnSegments.push({
573
+ type: 'tool_call',
574
+ name: toolName,
575
+ args: toolArgs,
576
+ result,
577
+ startTime,
578
+ endTime,
579
+ });
580
+ }
581
+ await this.saveToolCallGroup(incoming.sessionId, assistantToolMessage, toolMessages, turnPersistedTimestamps);
582
+ // 继续请求 LLM (再次经过 hooks),使用原始完整工具列表而非被上一轮 hooks 修改过的列表
583
+ const nextLlmData = {
584
+ messages: llmBeforeData.messages,
585
+ tools: [...originalTools],
586
+ sessionId: incoming.sessionId,
587
+ userId: incoming.userId,
588
+ platform: incoming.platform,
589
+ triggerType: incoming.triggerType,
590
+ };
591
+ await this.ctx.hooks.run('agent:llm:before', nextLlmData);
592
+ // 裁剪消息以确保不超过上下文窗口
593
+ nextLlmData.messages = this.trimMessages(nextLlmData.messages, tokenBudget);
594
+ // 推送 token 使用量统计
595
+ this.emitTokenUsage(incoming.sessionId, incoming.platform, nextLlmData.messages, nextLlmData.tools, contextLength, maxTokens, tokenBudget);
596
+ const tN = Date.now();
597
+ const nextResult = await this.consumeStream(llm, {
598
+ messages: nextLlmData.messages,
599
+ tools: nextLlmData.tools.length > 0 ? nextLlmData.tools : undefined,
600
+ maxTokens,
601
+ signal,
602
+ }, incoming.sessionId, incoming.platform, signal);
603
+ turnSegments.push(...nextResult.segments);
604
+ response = nextResult;
605
+ accUsage(response.usage);
606
+ this.debugLogResponse(response, Date.now() - tN, iterations);
607
+ const nextLlmAfterData = { response, messages: nextLlmData.messages };
608
+ await this.ctx.hooks.run('agent:llm:after', nextLlmAfterData);
609
+ response = nextLlmAfterData.response;
610
+ if (response.reasoningContent) {
611
+ allReasoning.push(response.reasoningContent);
612
+ }
613
+ }
614
+ // 检测是否因工具调用次数达到上限而退出循环
615
+ const toolLimitReached = iterations >= maxToolIterations && response.toolCalls != null && response.toolCalls.length > 0;
616
+ if (toolLimitReached) {
617
+ this.logger.warn(`工具调用达到上限 (${maxToolIterations}),session=${incoming.sessionId}`);
618
+ }
619
+ // 保留原始 LLM 输出,用于存入 memory(避免纯文本历史污染 few-shot 示例)
620
+ let rawLlmContent = response.content ?? '';
621
+ let replyContent = rawLlmContent;
622
+ const responseData = {
623
+ content: replyContent,
624
+ sessionId: incoming.sessionId,
625
+ platform: incoming.platform,
626
+ userId: incoming.userId,
627
+ triggerType: incoming.triggerType,
628
+ attempt: 0,
629
+ };
630
+ await this.ctx.hooks.run('agent:reply:before', responseData);
631
+ // 重试循环:当 hook(如 persona 的 outputFormat 解析)报告 retryRequested 时,
632
+ // 把失败的 assistant 输出 + 系统反馈追加到消息列表,重新请求 LLM;最多按 maxRetries 次。
633
+ // maxRetries 由 hook 端写入(plugin-persona 从 outputFormat.retries 读取,默认 1)。
634
+ const maxRetries = Math.max(0, responseData.maxRetries ?? 0);
635
+ let attempt = 0;
636
+ while (responseData.retryRequested && attempt < maxRetries && rawLlmContent.length > 0) {
637
+ attempt++;
638
+ this.logger.debug(`agent:reply:before 请求重试 (attempt=${attempt}/${maxRetries}, session=${incoming.sessionId}): ${responseData.retryFeedback ?? '(无反馈)'}`);
639
+ llmBeforeData.messages.push({ role: 'assistant', content: rawLlmContent });
640
+ llmBeforeData.messages.push({
641
+ role: 'system',
642
+ content: responseData.retryFeedback ?? '上一次回复未能通过格式校验,请严格按照系统提示中规定的格式重新输出。',
643
+ });
644
+ const retryTrimmed = this.trimMessages(llmBeforeData.messages, tokenBudget);
645
+ const retryResult = await this.consumeStream(llm, {
646
+ messages: retryTrimmed,
647
+ tools: undefined,
648
+ maxTokens,
649
+ signal,
650
+ }, incoming.sessionId, incoming.platform, signal);
651
+ turnSegments.push(...retryResult.segments);
652
+ response = retryResult;
653
+ accUsage(response.usage);
654
+ rawLlmContent = response.content ?? '';
655
+ if (response.reasoningContent)
656
+ allReasoning.push(response.reasoningContent);
657
+ // 用新输出再次跑 hook;hook 端根据 attempt 决定继续重试或走兜底(静默丢弃)
658
+ responseData.content = rawLlmContent;
659
+ responseData.archiveContent = undefined;
660
+ responseData.retryRequested = false;
661
+ responseData.retryFeedback = undefined;
662
+ responseData.attempt = attempt;
663
+ await this.ctx.hooks.run('agent:reply:before', responseData);
664
+ }
665
+ // 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
666
+ // 强制把 content 置空,避免原始未校验内容被外发。
667
+ if (responseData.retryRequested) {
668
+ this.logger.warn(`agent:reply:before 重试用尽但 retryRequested 仍为 true,强制丢弃回复内容 (session=${incoming.sessionId})`);
669
+ responseData.content = '';
670
+ responseData.retryRequested = false;
671
+ }
672
+ replyContent = responseData.content;
673
+ const archiveContent = responseData.archiveContent ?? rawLlmContent;
674
+ // 重复检测:如果回复与最近一条 assistant 消息完全相同,视为模型"卡壳",静默跳过
675
+ const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
676
+ if (replyContent && lastAssistant?.content && replyContent === lastAssistant.content) {
677
+ this.logger.warn(`检测到重复回复,跳过发送 (session=${incoming.sessionId})`);
678
+ replyContent = '';
679
+ }
680
+ // 发出流结束标记
681
+ await this.ctx.emit('outbound:stream', {
682
+ sessionId: incoming.sessionId,
683
+ platform: incoming.platform,
684
+ done: true,
685
+ toolLimitReached,
686
+ });
687
+ // 空回复(outputFormat 中 reply 字段为空字符串或仅空白)时静默,不发送消息
688
+ if (replyContent.trim().length === 0) {
689
+ this.logger.debug(`空回复,跳过发送 (session=${incoming.sessionId})`);
690
+ }
691
+ else {
692
+ const combinedReasoning = allReasoning.length > 0 ? allReasoning.join('\n\n---\n\n') : undefined;
693
+ // 在 assistant 最终持久化时记录 modelInfo:前端从消息历史可还原 "这条回复
694
+ // 由哪个 model 生成、用了多少 token、耗时多久",方便用户验证模型切换是否生效。
695
+ const turnModelInfo = {
696
+ provider: llm.providerId,
697
+ model: llm.id,
698
+ promptTokens: turnUsageAcc.promptTokens || undefined,
699
+ completionTokens: turnUsageAcc.completionTokens || undefined,
700
+ totalTokens: turnUsageAcc.totalTokens || undefined,
701
+ elapsedMs: Date.now() - turnStartTs,
702
+ };
703
+ const finalAssistantMetadata = {
704
+ ...(assistantMetadata ?? {}),
705
+ modelInfo: turnModelInfo,
706
+ };
707
+ // 保存最终 assistant 回复:优先存 persona 修复/规范化后的 JSON,保持格式完整,
708
+ // 避免坏 JSON 或解码后纯文本污染历史 few-shot 示例导致模型不再遵守 outputFormat
709
+ await this.saveToMemory(incoming.sessionId, {
710
+ role: 'assistant',
711
+ content: archiveContent,
712
+ reasoningContent: response.reasoningContent,
713
+ timestamp: Date.now(),
714
+ metadata: finalAssistantMetadata,
715
+ segments: turnSegments.length > 0 ? turnSegments : undefined,
716
+ });
717
+ // 发送给流式客户端时使用合并版本(统一时间线 segments 同时给出,前端按到达顺序渲染)
718
+ await this.dispatchOutbound({
719
+ content: replyContent,
720
+ sessionId: incoming.sessionId,
721
+ platform: incoming.platform,
722
+ reasoningContent: combinedReasoning,
723
+ source: 'agent',
724
+ segments: turnSegments.length > 0 ? turnSegments : undefined,
725
+ modelInfo: turnModelInfo,
726
+ });
727
+ }
728
+ // Hook: agent:turn:after — 插件可以在完整消息周期结束后做后处理
729
+ const turnOutcome = replyContent.trim().length === 0 ? 'silent' : 'replied';
730
+ await this.ctx.hooks.run('agent:turn:after', {
731
+ message: incoming,
732
+ reply: replyContent,
733
+ outcome: turnOutcome,
734
+ sessionId: incoming.sessionId,
735
+ metadata: msgHookData.metadata,
736
+ });
737
+ }
738
+ catch (err) {
739
+ // 中止错误 — 静默退出,前端通过 outbound:stream done 清理 buffer。
740
+ //
741
+ // 历史教训:早期版本会用 turnPersistedTimestamps 把"本轮已持久化的中间消息"全部删掉,
742
+ // 注释里写的是防"半截 assistant 内容 / 假回退"。但实际审计 saveToolCallGroup(见下方)发现:
743
+ // 1. saveToolCallGroup 只在并行工具全部执行完毕后整组写入(assistant tool_call + 所有
744
+ // tool result 一次性 push),catch 路径根本进不来;turnPersistedTimestamps 里只
745
+ // 可能是"已完成、副作用已发生"的工具调用对。
746
+ // 2. 删除这些 = 让 agent 忘记自己刚刚做过的有副作用的事(戳一戳/发送消息/调度任务/…),
747
+ // 下一轮 LLM 看不到自己的行为,会重复调用,外部观察就是"agent 一直以为戳不了"。
748
+ // 3. 真正的 orphan 风险(assistant tool_calls 缺 tool result)由 sanitizeToolCallHistory
749
+ // 在装载历史时兜底过滤,不需要 abort 路径主动删除。
750
+ // 所以这里不再回滚,让已完成的工具调用记录留在 memory,agent 下一轮能正确感知。
751
+ if (err instanceof DOMException && err.name === 'AbortError') {
752
+ this.logger.info(`生成已中止: session=${incoming.sessionId}` +
753
+ (turnPersistedTimestamps.length > 0
754
+ ? `(保留本轮已完成的 ${turnPersistedTimestamps.length} 条工具调用记录,便于下一轮 agent 感知)`
755
+ : ''));
756
+ await this.ctx.emit('outbound:stream', {
757
+ sessionId: incoming.sessionId,
758
+ platform: incoming.platform,
759
+ done: true,
760
+ });
761
+ // 中止同样是回合终态:发 agent:turn:after(outcome=aborted) 让生命周期订阅方收尾——
762
+ // session-manager 把会话状态从 active 收口为 completed(否则永远停在"进行中"),
763
+ // checkpoint 关闭当前回合(否则中止后回合不关闭、长期泄漏)。
764
+ // 文档与 agent-api 早已声明 outcome 含 aborted,此处兑现契约。
765
+ await this.ctx.hooks.run('agent:turn:after', {
766
+ message: incoming,
767
+ reply: '',
768
+ outcome: 'aborted',
769
+ sessionId: incoming.sessionId,
770
+ metadata: msgHookData.metadata,
771
+ });
772
+ return;
773
+ }
774
+ const message = err instanceof Error ? err.message : String(err);
775
+ this.logger.error(`处理消息失败: ${message}`);
776
+ await this.dispatchOutbound({
777
+ content: `[错误] ${message}`,
778
+ sessionId: incoming.sessionId,
779
+ platform: incoming.platform,
780
+ source: 'system',
781
+ });
782
+ // 异常也是回合终态:同样发 turn:after(outcome=error) 让 checkpoint 关闭回合、
783
+ // session-manager 收口状态。dispatchOutbound 已发系统错误消息,状态可被 outbound:message
784
+ // 与本钩子双路径幂等收口。
785
+ await this.ctx.hooks.run('agent:turn:after', {
786
+ message: incoming,
787
+ reply: '',
788
+ outcome: 'error',
789
+ sessionId: incoming.sessionId,
790
+ metadata: msgHookData.metadata,
791
+ });
792
+ }
793
+ });
794
+ // 消息被拦截(如流控缓冲),通知前端结束 loading
795
+ if (!handled) {
796
+ await this.ctx.emit('outbound:stream', {
797
+ sessionId: incoming.sessionId,
798
+ platform: incoming.platform,
799
+ done: true,
800
+ });
801
+ }
802
+ }
803
+ /**
804
+ * 构建发送给 LLM 的消息列表
805
+ */
806
+ async buildMessages(incoming, personaOpts, archivedIncoming) {
807
+ const messages = [];
808
+ // 1. 系统提示
809
+ const systemPrompt = this.buildSystemPrompt(personaOpts);
810
+ messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
811
+ // 2. 历史消息
812
+ const memory = this.ctx.getService('memory');
813
+ if (memory) {
814
+ try {
815
+ const history = this.sanitizeToolCallHistory(await memory.getHistory(incoming.sessionId, this.historyLimit), incoming.sessionId);
816
+ const now = Date.now();
817
+ for (const m of history) {
818
+ if (archivedIncoming && this.isSameMessage(m, archivedIncoming))
819
+ continue;
820
+ if (CONTROL_KINDS.includes(m.kind ?? ''))
821
+ continue;
822
+ // 为用户消息注入时间标注,帮助 LLM 理解时间先后
823
+ if (m.role === 'user' && m.timestamp && m.content) {
824
+ const timeLabel = formatTimeLabel(m.timestamp, now);
825
+ if (timeLabel && !m.content.startsWith(`(${timeLabel})`)) {
826
+ m.content = `(${timeLabel}) ${m.content}`;
827
+ }
828
+ }
829
+ messages.push(m);
830
+ }
831
+ }
832
+ catch (err) {
833
+ this.logger.warn('获取历史消息失败:', err);
834
+ }
835
+ }
836
+ // 3. 当前消息
837
+ //
838
+ // 内容主体由 message-archive 烘焙:sender 前缀 / 引用回复 / 图片描述 / 附件描述
839
+ // 都已经写进 archivedIncoming.content。这里只在外层加一个时间标注,
840
+ // 保证「LLM 看到的当前消息」与「历史消息」「向量库存档」三者一致。
841
+ const senderLabel = getSenderLabel(incoming.nickname, incoming.userId);
842
+ const nowLabel = formatTimeLabel(Date.now(), Date.now());
843
+ const archivedBody = archivedIncoming?.content;
844
+ const fallbackBody = senderLabel ? `[${senderLabel}]: ${incoming.content}` : incoming.content;
845
+ const currentContent = `(${nowLabel}) ${archivedBody ?? fallbackBody}`;
846
+ // 3a. proactive 委派分支:跨会话由「同一 agent 在另一会话的实例」派发过来的任务
847
+ //
848
+ // 这条消息不是用户请求,必须作为 system 指令呈现给 LLM,否则 B 会把它当成
849
+ // 真实用户在指挥(典型 BUG:源会话 agent 决定派发,目标会话 agent 看到 user
850
+ // 角色的消息,回复时使用「您」「您好」等措辞,把 agent 当成了用户)。
851
+ //
852
+ // 同时附上源会话 ID,并明确告诉 B:如需了解源会话上下文,按需调用
853
+ // session_get_history(sessionId="<源>") —— 把"是否需要上下文"的决策权
854
+ // 交给 B 的 LLM,避免无差别拼接源历史造成 token 浪费。
855
+ if (incoming.triggerType === 'proactive') {
856
+ const sourceMatch = incoming.source?.match(/^proactive:from:(.+)$/);
857
+ const sourceSessionId = sourceMatch?.[1];
858
+ const sourceLine = sourceSessionId ? `源会话 ID: ${sourceSessionId}\n` : '';
859
+ // 不在 hint 里写死 limit,让 LLM 按 plugin-tool-session 的 defaultLimit / 自身判断决定
860
+ const hintLine = sourceSessionId
861
+ ? `如需了解源会话上下文(例如「按之前讨论的方案」之类的引用),调用 \`session_get_history(sessionId="${sourceSessionId}")\` 自行查阅(可按需附加 limit)。\n`
862
+ : '';
863
+ messages.push({
864
+ role: 'system',
865
+ content: `[跨会话委派 — 非用户消息]\n` +
866
+ sourceLine +
867
+ `任务: ${incoming.content}\n\n` +
868
+ `说明: 这是你(作为同一 agent 在另一会话的实例)派发给本会话的任务指令,` +
869
+ `不是用户请求。处理时不要使用「您」「请问」等面向用户的措辞,按指令直接执行并简明回报结果。\n` +
870
+ hintLine,
871
+ metadata: { injector: WellKnownKinds.CrossSessionDelegation, sourceSessionId },
872
+ });
873
+ return messages;
874
+ }
875
+ // 3b. 普通用户消息分支
876
+ // 检测预处理附件内容——引导 LLM 综合分析而非逐项转述
877
+ const hasPreprocessed = /\[图片\d*[::]|\[文件[::]|--- 文件内容 ---/.test(currentContent);
878
+ if (hasPreprocessed) {
879
+ messages.push({
880
+ role: 'system',
881
+ content: '用户消息中包含系统预处理的附件描述(图片识别结果和/或文件内容提取)。' +
882
+ '请将这些信息作为参考上下文,结合用户的文字,给出一个自然、连贯的统一回复。' +
883
+ '不要将分析结果逐项列出或分成单独的字段,直接在回复中融合所有信息。',
884
+ metadata: { injector: 'system-other' },
885
+ });
886
+ }
887
+ const userMessage = {
888
+ role: 'user',
889
+ content: currentContent,
890
+ name: getMessageName(incoming.userId),
891
+ timestamp: Date.now(),
892
+ };
893
+ // 多模态:把 attachments 中的 image 项传递给 LLM(视觉模型多模态字段)
894
+ const imageAtts = incoming.attachments?.filter(a => a.kind === 'image') ?? [];
895
+ if (imageAtts.length > 0) {
896
+ userMessage.images = imageAtts.map(a => a.data);
897
+ }
898
+ // 群聊焦点指引:仅 sessionType=group + triggerType ∈ {direct, immediate} 时注入,
899
+ // 紧贴在当前 user 消息前,告诉 LLM "下一条就是焦点"。详细动机见 helpers.ts。
900
+ const focusGuidance = buildFocusGuidance(incoming);
901
+ if (focusGuidance)
902
+ messages.push(focusGuidance);
903
+ messages.push(userMessage);
904
+ return messages;
905
+ }
906
+ /**
907
+ * 构建系统提示词
908
+ */
909
+ buildSystemPrompt(personaOpts) {
910
+ const persona = this.ctx.getService('persona');
911
+ const base = persona
912
+ ? this.systemPrompt
913
+ ? `${persona.getSystemPrompt(personaOpts)}\n\n${this.systemPrompt}`
914
+ : persona.getSystemPrompt(personaOpts)
915
+ : this.systemPrompt;
916
+ return base ? `${base}\n\n${INPUT_CONVENTIONS}` : INPUT_CONVENTIONS;
917
+ }
918
+ /**
919
+ * 粗略估算消息列表的总 token 数
920
+ */
921
+ estimateTokens(messages) {
922
+ return estimateTokens(messages);
923
+ }
924
+ /**
925
+ * 推送 token 使用量统计事件
926
+ *
927
+ * 包含各维度的 token 使用分解,供前端展示和自动压缩判断。
928
+ */
929
+ emitTokenUsage(sessionId, platform, messages, tools, contextLength, maxTokens, tokenBudget) {
930
+ // 按来源分类统计各消息的 token 占用
931
+ let historyTokens = 0;
932
+ let toolResultTokens = 0;
933
+ let personaTokens = 0;
934
+ let memorySummaryTokens = 0;
935
+ let memoryVectorTokens = 0;
936
+ let skillsTokens = 0;
937
+ let platformTokens = 0;
938
+ let subtaskTokens = 0;
939
+ let systemOtherTokens = 0;
940
+ for (const msg of messages) {
941
+ const t = estimateMsgTokens(msg);
942
+ if (msg.role === 'system') {
943
+ const source = msg.metadata?.injector;
944
+ const contributions = msg.metadata?._tokenContributions;
945
+ if (source === 'memory-summary') {
946
+ memorySummaryTokens += t;
947
+ }
948
+ else if (source === 'memory-vector') {
949
+ memoryVectorTokens += t;
950
+ }
951
+ else if (source === 'platform') {
952
+ platformTokens += t;
953
+ }
954
+ else if (source === 'system-other') {
955
+ systemOtherTokens += t;
956
+ }
957
+ else if (source === 'persona' || !source) {
958
+ // persona 消息可能被 skills/subtask/toolPriority 追加了内容
959
+ if (contributions) {
960
+ let contributionTokens = 0;
961
+ for (const [key, charCount] of Object.entries(contributions)) {
962
+ const ct = estimateTextTokens('x'.repeat(charCount));
963
+ if (key === 'skills')
964
+ skillsTokens += ct;
965
+ else if (key === 'subtask')
966
+ subtaskTokens += ct;
967
+ else
968
+ systemOtherTokens += ct;
969
+ contributionTokens += ct;
970
+ }
971
+ personaTokens += Math.max(0, t - contributionTokens);
972
+ }
973
+ else {
974
+ personaTokens += t;
975
+ }
976
+ }
977
+ else {
978
+ systemOtherTokens += t;
979
+ }
980
+ }
981
+ else if (msg.role === 'tool') {
982
+ toolResultTokens += t;
983
+ }
984
+ else {
985
+ historyTokens += t;
986
+ }
987
+ }
988
+ // 工具定义的 token 估算
989
+ const toolDefsTokens = tools.length > 0 ? estimateTextTokens(JSON.stringify(tools)) : 0;
990
+ const systemTokens = personaTokens +
991
+ memorySummaryTokens +
992
+ memoryVectorTokens +
993
+ skillsTokens +
994
+ platformTokens +
995
+ subtaskTokens +
996
+ systemOtherTokens;
997
+ const totalUsed = systemTokens + historyTokens + toolResultTokens + toolDefsTokens;
998
+ const usageRatio = contextLength > 0 ? totalUsed / contextLength : 0;
999
+ this.ctx
1000
+ .emit('token:usage', {
1001
+ sessionId,
1002
+ platform,
1003
+ contextWindow: contextLength,
1004
+ maxTokens,
1005
+ tokenBudget,
1006
+ used: totalUsed,
1007
+ usageRatio,
1008
+ breakdown: {
1009
+ system: systemTokens,
1010
+ persona: personaTokens,
1011
+ memorySummary: memorySummaryTokens,
1012
+ memoryVector: memoryVectorTokens,
1013
+ skills: skillsTokens,
1014
+ platform: platformTokens,
1015
+ subtask: subtaskTokens,
1016
+ systemOther: systemOtherTokens,
1017
+ history: historyTokens,
1018
+ toolResults: toolResultTokens,
1019
+ toolDefs: toolDefsTokens,
1020
+ reservedForReply: maxTokens,
1021
+ },
1022
+ })
1023
+ .catch(() => { });
1024
+ // 节流日志:与 WebUI 看到的同一份数据,让 CLI / 文件日志使用者也能看见预算消耗。
1025
+ // 跨过 0.5 / 0.7 / 0.85 阈值必输出,否则每 10 轮一次。
1026
+ const bucket = usageRatio >= 0.85 ? 3 : usageRatio >= 0.7 ? 2 : usageRatio >= 0.5 ? 1 : 0;
1027
+ const st = this.tokenLogState.get(sessionId) ?? { count: 0, lastRatioBucket: -1 };
1028
+ st.count++;
1029
+ const crossedBucket = bucket !== st.lastRatioBucket;
1030
+ if (crossedBucket || st.count % 10 === 1) {
1031
+ const tag = bucket >= 3 ? 'CRITICAL' : bucket >= 2 ? 'WARN' : bucket >= 1 ? 'INFO' : 'OK';
1032
+ this.logger.info(`[token-usage:${tag}] ${sessionId} ${totalUsed}/${contextLength} (${(usageRatio * 100).toFixed(1)}%) ` +
1033
+ `sys=${systemTokens}(persona=${personaTokens} mem=${memorySummaryTokens + memoryVectorTokens} ` +
1034
+ `skills=${skillsTokens} subtask=${subtaskTokens} other=${systemOtherTokens}) ` +
1035
+ `hist=${historyTokens} tools=${toolResultTokens}+${toolDefsTokens}def reserve=${maxTokens}`);
1036
+ }
1037
+ st.lastRatioBucket = bucket;
1038
+ this.tokenLogState.set(sessionId, st);
1039
+ }
1040
+ /**
1041
+ * 裁剪消息列表,使总 token 数不超过预算
1042
+ *
1043
+ * 渐进式压缩策略(保证 agent 在长工具链中无限运行):
1044
+ * 1. 首条 system(主提示词)和末条消息永不删除
1045
+ * 2. 缩减超出预留额度的 system 消息(长期记忆)
1046
+ * 3. 截断过长的 tool 输出内容(保留头部关键信息)
1047
+ * 4. 将最旧的 assistant+tool 组压缩为紧凑摘要(保留决策上下文)
1048
+ * 5. 删除最旧的非 system 消息
1049
+ * 6. 最后手段:删除 hook 注入的 system 消息
1050
+ */
1051
+ trimMessages(messages, budget) {
1052
+ const result = messages.map(m => ({ ...m }));
1053
+ let estimated = this.estimateTokens(result);
1054
+ if (estimated <= budget)
1055
+ return result;
1056
+ /** 重新扫描 system 消息索引(首条和末条之间) */
1057
+ const findSystemIndices = () => {
1058
+ const indices = [];
1059
+ for (let i = 1; i < result.length - 1; i++) {
1060
+ if (result[i].role === 'system')
1061
+ indices.push(i);
1062
+ }
1063
+ return indices;
1064
+ };
1065
+ // === Phase 1: 缩减超出预留额度的 system 消息 ===
1066
+ {
1067
+ const sysIdx = findSystemIndices();
1068
+ const sysTokens = sysIdx.reduce((s, i) => s + estimateMsgTokens(result[i]), 0);
1069
+ if (sysTokens > this.memoryTokenBudget && sysIdx.length > 0) {
1070
+ const ratio = this.memoryTokenBudget / sysTokens;
1071
+ for (const idx of sysIdx) {
1072
+ const msg = result[idx];
1073
+ if (msg.content && msg.content.length > 200) {
1074
+ const oldTokens = estimateMsgTokens(msg);
1075
+ const targetLen = Math.max(200, Math.floor(msg.content.length * ratio));
1076
+ msg.content = `${msg.content.slice(0, targetLen)}\n... [记忆内容已缩减]`;
1077
+ estimated -= oldTokens - estimateMsgTokens(msg);
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+ if (estimated <= budget)
1083
+ return result;
1084
+ // === Phase 2: 截断过长的 tool 输出 ===
1085
+ for (let i = 1; i < result.length - 1; i++) {
1086
+ if (estimated <= budget)
1087
+ break;
1088
+ if (result[i].role === 'tool' && result[i].content && result[i].content.length > 1500) {
1089
+ const oldTokens = estimateMsgTokens(result[i]);
1090
+ result[i].content = `${result[i].content.slice(0, 500)}\n... [工具输出已截断]`;
1091
+ estimated -= oldTokens - estimateMsgTokens(result[i]);
1092
+ }
1093
+ }
1094
+ if (estimated <= budget)
1095
+ return result;
1096
+ // === Phase 2.5: 缩减 assistant 消息的 reasoningContent ===
1097
+ // 深度思考模型的推理内容可能非常长(数万 token),优先缩减旧迭代的推理,
1098
+ // 仍超预算时缩减最新一条(保留头尾摘要以保持上下文连贯性)
1099
+ {
1100
+ // 收集带 reasoningContent 的 assistant 消息索引(从旧到新)
1101
+ const rcIndices = [];
1102
+ for (let i = 1; i < result.length; i++) {
1103
+ if (result[i].role === 'assistant' && result[i].reasoningContent && result[i].reasoningContent.length > 200) {
1104
+ rcIndices.push(i);
1105
+ }
1106
+ }
1107
+ // 从最旧开始,先截断非最后一条的推理,仍不够时截断最后一条
1108
+ // 注意:DeepSeek 思考模式要求历史中凡有 reasoning_content 的消息必须原样带回,
1109
+ // 不能将字段设为 undefined,否则 API 返回 400。因此只截断,不删除。
1110
+ for (let k = 0; k < rcIndices.length && estimated > budget; k++) {
1111
+ const idx = rcIndices[k];
1112
+ const msg = result[idx];
1113
+ const oldTokens = estimateMsgTokens(msg);
1114
+ // 所有条目统一保留头部 200 字符(最新条保留稍多以保持上下文连贯性)
1115
+ const keepLen = k < rcIndices.length - 1 ? 200 : 400;
1116
+ msg.reasoningContent = `${msg.reasoningContent.slice(0, keepLen)}\n... [推理内容已缩减]`;
1117
+ estimated -= oldTokens - estimateMsgTokens(msg);
1118
+ }
1119
+ }
1120
+ if (estimated <= budget)
1121
+ return result;
1122
+ // === Phase 3: 将旧的 assistant+tool 组压缩为摘要 ===
1123
+ // 识别所有工具调用组 (assistant(toolCalls) + 紧跟的 tool 消息)
1124
+ {
1125
+ const groups = [];
1126
+ for (let j = 1; j < result.length; j++) {
1127
+ if (result[j].role === 'assistant' && result[j].toolCalls?.length) {
1128
+ let end = j + 1;
1129
+ while (end < result.length && result[end].role === 'tool')
1130
+ end++;
1131
+ groups.push({ start: j, end });
1132
+ j = end - 1;
1133
+ }
1134
+ }
1135
+ // 从最旧开始压缩,保护最后一组(当前工具链需要完整结果)
1136
+ let offset = 0;
1137
+ for (let g = 0; g < groups.length - 1 && estimated > budget; g++) {
1138
+ const start = groups[g].start - offset;
1139
+ const end = groups[g].end - offset;
1140
+ const groupLen = end - start;
1141
+ // 计算当前组的 token
1142
+ let groupTokens = 0;
1143
+ const toolPreviews = [];
1144
+ for (let j = start; j < end; j++) {
1145
+ groupTokens += estimateMsgTokens(result[j]);
1146
+ if (result[j].role === 'tool') {
1147
+ const c = result[j].content ?? '';
1148
+ toolPreviews.push(c.length > 100 ? `${c.slice(0, 100)}...` : c);
1149
+ }
1150
+ }
1151
+ // 构建紧凑摘要
1152
+ const aMsg = result[start];
1153
+ const names = aMsg.toolCalls.map(tc => tc.function.name);
1154
+ const parts = names.map((n, idx) => `${n} → ${toolPreviews[idx] ?? '(无结果)'}`);
1155
+ let text = `[历史工具调用] ${parts.join(' | ')}`;
1156
+ if (aMsg.content)
1157
+ text = `${aMsg.content}\n${text}`;
1158
+ const summaryMsg = { role: 'assistant', content: text };
1159
+ const summaryTokens = estimateMsgTokens(summaryMsg);
1160
+ // 仅在确实能节省 token 时压缩
1161
+ if (summaryTokens < groupTokens) {
1162
+ result.splice(start, groupLen, summaryMsg);
1163
+ estimated -= groupTokens - summaryTokens;
1164
+ offset += groupLen - 1;
1165
+ }
1166
+ }
1167
+ }
1168
+ if (estimated <= budget) {
1169
+ if (result.length < messages.length) {
1170
+ this.logger.info(`上下文压缩: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
1171
+ }
1172
+ return result;
1173
+ }
1174
+ // === Phase 4: 删除最旧的非 system 消息(保护最后一组工具调用 + 最新用户消息) ===
1175
+ {
1176
+ // 识别最后一组工具调用的索引范围,确保不被删除
1177
+ const lastGroupIndices = new Set();
1178
+ for (let j = result.length - 1; j >= 1; j--) {
1179
+ if (result[j].role === 'assistant' && result[j].toolCalls?.length) {
1180
+ lastGroupIndices.add(j);
1181
+ let k = j + 1;
1182
+ while (k < result.length && result[k].role === 'tool') {
1183
+ lastGroupIndices.add(k);
1184
+ k++;
1185
+ }
1186
+ break;
1187
+ }
1188
+ }
1189
+ // 保护最新的 user 消息(用户发起任务的请求,删掉会导致模型丢失任务上下文)
1190
+ let lastUserIdx = -1;
1191
+ for (let j = result.length - 1; j >= 1; j--) {
1192
+ if (result[j].role === 'user') {
1193
+ lastUserIdx = j;
1194
+ break;
1195
+ }
1196
+ }
1197
+ const adjustAfterSplice = (splicedAt) => {
1198
+ const updated = new Set();
1199
+ for (const idx of lastGroupIndices)
1200
+ updated.add(idx > splicedAt ? idx - 1 : idx);
1201
+ lastGroupIndices.clear();
1202
+ for (const idx of updated)
1203
+ lastGroupIndices.add(idx);
1204
+ if (lastUserIdx > splicedAt)
1205
+ lastUserIdx--;
1206
+ };
1207
+ let i = 1;
1208
+ while (estimated > budget && i < result.length - 1) {
1209
+ if (result[i].role === 'system' || lastGroupIndices.has(i) || i === lastUserIdx) {
1210
+ i++;
1211
+ continue;
1212
+ }
1213
+ // assistant(含toolCalls) + 紧跟的 tool 消息成组删除
1214
+ if (result[i].role === 'assistant' && result[i].toolCalls?.length) {
1215
+ estimated -= estimateMsgTokens(result[i]);
1216
+ result.splice(i, 1);
1217
+ adjustAfterSplice(i);
1218
+ while (i < result.length - 1 && result[i].role === 'tool') {
1219
+ if (lastGroupIndices.has(i))
1220
+ break;
1221
+ estimated -= estimateMsgTokens(result[i]);
1222
+ result.splice(i, 1);
1223
+ adjustAfterSplice(i);
1224
+ }
1225
+ continue;
1226
+ }
1227
+ estimated -= estimateMsgTokens(result[i]);
1228
+ result.splice(i, 1);
1229
+ adjustAfterSplice(i);
1230
+ }
1231
+ }
1232
+ if (estimated <= budget) {
1233
+ this.logger.info(`上下文截断: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
1234
+ return result;
1235
+ }
1236
+ // === Phase 5: 极端情况 — 删除 hook 注入的 system 消息 ===
1237
+ {
1238
+ const sysIdx = findSystemIndices();
1239
+ for (let j = sysIdx.length - 1; j >= 0 && estimated > budget; j--) {
1240
+ const idx = sysIdx[j];
1241
+ estimated -= estimateMsgTokens(result[idx]);
1242
+ result.splice(idx, 1);
1243
+ }
1244
+ }
1245
+ // 大幅裁剪后注入继续执行提示,防止模型因上下文缺失而中止任务
1246
+ if (messages.length - result.length >= 6) {
1247
+ const hint = {
1248
+ role: 'system',
1249
+ content: '[系统提示] 由于上下文长度限制,部分历史消息已被压缩或移除。请基于当前可见的上下文和最新用户请求继续完成任务,不要因为看不到之前的细节而停止工作。如果你之前有正在执行的多步骤任务或计划,请查看对话摘要和 todo-list 工具确认当前进度,然后继续未完成的步骤。',
1250
+ metadata: { injector: 'system-other' },
1251
+ };
1252
+ // 插入到最后一条 user 消息之后(如果有),否则插到末尾前
1253
+ let insertIdx = result.length - 1;
1254
+ for (let j = result.length - 1; j >= 1; j--) {
1255
+ if (result[j].role === 'user') {
1256
+ insertIdx = j + 1;
1257
+ break;
1258
+ }
1259
+ }
1260
+ result.splice(insertIdx, 0, hint);
1261
+ estimated += estimateMsgTokens(hint);
1262
+ }
1263
+ if (result.length < messages.length) {
1264
+ this.logger.info(`上下文截断: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
1265
+ }
1266
+ return result;
1267
+ }
1268
+ /**
1269
+ * 保存消息到记忆服务
1270
+ */
1271
+ async saveToMemory(sessionId, message) {
1272
+ const archive = this.ctx.getService('message-archive');
1273
+ if (archive) {
1274
+ try {
1275
+ await archive.saveMessage(sessionId, message);
1276
+ }
1277
+ catch (err) {
1278
+ this.logger.warn('保存消息到记忆失败:', err);
1279
+ }
1280
+ }
1281
+ }
1282
+ buildAssistantMetadata(incoming) {
1283
+ const identity = getPlatformSelfIdentity(this.ctx, incoming.platform, incoming.sessionId);
1284
+ const metadata = {
1285
+ platform: incoming.platform,
1286
+ senderType: 'assistant',
1287
+ };
1288
+ if (identity?.selfId)
1289
+ metadata.userId = identity.selfId;
1290
+ if (identity?.nickname)
1291
+ metadata.nickname = identity.nickname;
1292
+ if (incoming.groupId)
1293
+ metadata.groupId = incoming.groupId;
1294
+ if (incoming.groupName)
1295
+ metadata.groupName = incoming.groupName;
1296
+ if (incoming.sessionType)
1297
+ metadata.sessionType = incoming.sessionType;
1298
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
1299
+ }
1300
+ async saveToolCallGroup(sessionId, assistantMessage, toolMessages, persistedTimestamps) {
1301
+ const timestamp = Date.now();
1302
+ await this.saveToMemory(sessionId, { ...assistantMessage, timestamp });
1303
+ persistedTimestamps?.push(timestamp);
1304
+ for (let i = 0; i < toolMessages.length; i++) {
1305
+ const ts = timestamp + i + 1;
1306
+ await this.saveToMemory(sessionId, { ...toolMessages[i], timestamp: ts });
1307
+ persistedTimestamps?.push(ts);
1308
+ }
1309
+ }
1310
+ sanitizeToolCallHistory(history, sessionId) {
1311
+ const result = [];
1312
+ let dropped = 0;
1313
+ for (let i = 0; i < history.length; i++) {
1314
+ const message = history[i];
1315
+ if (message.role === 'tool') {
1316
+ dropped++;
1317
+ continue;
1318
+ }
1319
+ if (message.role === 'assistant' && message.toolCalls?.length) {
1320
+ const expectedIds = new Set(message.toolCalls.map(tc => tc.id));
1321
+ const seenIds = new Set();
1322
+ const tools = [];
1323
+ let j = i + 1;
1324
+ while (j < history.length && history[j].role === 'tool') {
1325
+ const toolMessage = history[j];
1326
+ const id = toolMessage.toolCallId;
1327
+ if (!id || !expectedIds.has(id) || seenIds.has(id))
1328
+ break;
1329
+ tools.push(toolMessage);
1330
+ seenIds.add(id);
1331
+ j++;
1332
+ }
1333
+ if (seenIds.size === expectedIds.size) {
1334
+ result.push(message, ...tools);
1335
+ }
1336
+ else {
1337
+ dropped += 1 + tools.length;
1338
+ }
1339
+ i = j - 1;
1340
+ continue;
1341
+ }
1342
+ result.push(message);
1343
+ }
1344
+ if (dropped > 0) {
1345
+ this.logger.warn(`历史消息中发现不完整工具调用组,已跳过 ${dropped} 条 (session=${sessionId})`);
1346
+ }
1347
+ return result;
1348
+ }
1349
+ isSameMessage(a, b) {
1350
+ return isSameMessage(a, b);
1351
+ }
1352
+ async archiveIncomingMessageInOrder(lane, incoming) {
1353
+ const previous = this.archiveQueues.get(lane) ?? Promise.resolve();
1354
+ const current = previous.catch(() => undefined).then(() => this.archiveIncomingMessage(incoming));
1355
+ const tail = current.then(() => undefined, () => undefined);
1356
+ this.archiveQueues.set(lane, tail);
1357
+ try {
1358
+ return await current;
1359
+ }
1360
+ finally {
1361
+ if (this.archiveQueues.get(lane) === tail) {
1362
+ this.archiveQueues.delete(lane);
1363
+ }
1364
+ }
1365
+ }
1366
+ async archiveIncomingMessage(incoming) {
1367
+ // 跳过非真实用户输入:闲聊主动触发是系统提示,不应作为 user 消息写入历史
1368
+ if (incoming.source === 'idle-trigger')
1369
+ return undefined;
1370
+ const archive = this.ctx.getService('message-archive');
1371
+ if (!archive)
1372
+ return undefined;
1373
+ try {
1374
+ const result = await archive.archiveIncoming(incoming);
1375
+ return result.message;
1376
+ }
1377
+ catch (err) {
1378
+ this.logger.warn('归档用户消息失败:', err);
1379
+ return undefined;
1380
+ }
1381
+ }
1382
+ /**
1383
+ * 派发出站消息:优先经过 gateway 中间件链;gateway 缺失时回退到事件总线。
1384
+ *
1385
+ * ⚠️ 仅用于"无 gateway"的最小应用 / 测试场景。完整应用应当加载 plugin-gateway,
1386
+ * 此时该 fallback 永远不命中——出站消息总是经过 outbound:dispatch 钩子链
1387
+ * (审计 / 脱敏 / 限速 / authority 等中间件)。
1388
+ *
1389
+ * 由于 plugin-agent-default 未在 inject.required 中声明 'gateway',
1390
+ * 即使 gateway 未加载本插件仍会激活,因此保留该 fallback 以便:
1391
+ * - 集成测试不必启动 gateway
1392
+ * - 嵌入式 / 单 agent 部署场景
1393
+ * 生产部署务必确保 plugin-gateway 已加载,否则中间件链会被跳过。
1394
+ */
1395
+ async dispatchOutbound(message) {
1396
+ const gateway = this.ctx.getService('gateway');
1397
+ if (gateway) {
1398
+ await gateway.dispatchOutbound(message);
1399
+ return;
1400
+ }
1401
+ this.logger.warn('Gateway 服务不可用,回退至 ctx.emit(outbound:message)(中间件链被跳过)');
1402
+ await this.ctx.emit('outbound:message', message);
1403
+ }
1404
+ }
1405
+ // ----- 插件导出 -----
1406
+ export const name = '@aalis/plugin-agent';
1407
+ export const displayName = '默认 Agent';
1408
+ export const subsystem = 'agent';
1409
+ export const provides = ['agent'];
1410
+ export const inject = {
1411
+ optional: ['llm', 'memory', 'persona', 'message-archive', 'platform'],
1412
+ };
1413
+ export const configSchema = {
1414
+ defaultLLM: {
1415
+ type: 'llm-ref',
1416
+ label: '默认对话模型',
1417
+ description: '全局默认 LLM。apply() 时调用 ctx.preferService("llm", `provider/model`) 锁定 ServiceContainer 偏好。会话 / 平台 profile 未覆盖时生效。',
1418
+ },
1419
+ systemPrompt: {
1420
+ type: 'textarea',
1421
+ label: '行为准则提示词',
1422
+ description: '定义 Agent 的行为准则。当人设插件存在时,身份描述由人设提供,此处仅作为行为指令追加。',
1423
+ },
1424
+ memoryTokenBudget: {
1425
+ type: 'number',
1426
+ label: '长期记忆预留 Token',
1427
+ default: 4096,
1428
+ description: '为长期记忆注入的 system 消息预留的 token 额度,截断时不会删除这些消息',
1429
+ },
1430
+ historyLimit: {
1431
+ type: 'number',
1432
+ label: '历史消息条数',
1433
+ default: 50,
1434
+ description: '从记忆中加载的最近对话历史条数',
1435
+ },
1436
+ maxToolIterations: {
1437
+ type: 'number',
1438
+ label: '最大工具迭代',
1439
+ default: 30,
1440
+ description: '工具调用循环的最大迭代次数',
1441
+ },
1442
+ toolResultMaxRatio: {
1443
+ type: 'number',
1444
+ label: '工具结果最大比例',
1445
+ default: 0.15,
1446
+ description: '单条工具结果占上下文窗口的最大比例 (0~1),超出则截断。例如 0.15 表示 15%',
1447
+ },
1448
+ trimThresholdRatio: {
1449
+ type: 'number',
1450
+ label: '裁剪触发比例',
1451
+ default: 1.0,
1452
+ description: '估算输入 token 占上下文长度的比例上限 (0~1)。本次调用超过该比例才会对消息列表做内存裁剪(不影响 DB)。默认 1.0 表示占满物理上限才裁剪;如需提前护航可调低。压缩触发请在“@aalis/plugin-memory-summary”中配置。',
1453
+ },
1454
+ };
1455
+ export const defaultConfig = {
1456
+ systemPrompt: '',
1457
+ memoryTokenBudget: 4096,
1458
+ historyLimit: 50,
1459
+ maxToolIterations: 30,
1460
+ toolResultMaxRatio: 0.15,
1461
+ trimThresholdRatio: 1.0,
1462
+ };
1463
+ export function apply(ctx, config) {
1464
+ const agentImpl = new DefaultAgent(ctx, config);
1465
+ ctx.provide('agent', agentImpl);
1466
+ const agent = agentImpl;
1467
+ // 全局默认 LLM:通过 ServicePreference 锁定 ctx.getService<'llm'>() 的首选 entry。
1468
+ // 偏好持久化由 core 配置层负责(servicePreferences 字段);这里只是开机时按 agent
1469
+ // 自己的 cfg.defaultLLM 覆写一次,便于纯文件配置流(无 webui 干预)也能生效。
1470
+ const defaultLLM = config.defaultLLM;
1471
+ if (defaultLLM?.provider && defaultLLM?.model) {
1472
+ ctx.preferService('llm', `${defaultLLM.provider}/${defaultLLM.model}`);
1473
+ }
1474
+ // ===== /model 指令组(split 为 dot-path 子命令)=====
1475
+ // info / status / reset / set <name>
1476
+ async function modelInfo(sessionId, platform, listAvailable) {
1477
+ const smSvc = ctx.getService('session-manager');
1478
+ const sessionLLM = smSvc?.getSession(sessionId)?.config?.llm;
1479
+ const parent = smSvc?.getSession(sessionId)?.parentId
1480
+ ? smSvc?.getSession(smSvc.getSession(sessionId).parentId)
1481
+ : undefined;
1482
+ const parentDefaultsLLM = parent?.config?.sessionDefaults?.llm;
1483
+ const profileLLM = smSvc?.getPlatformProfiles()?.[platform || 'webui']?.llm;
1484
+ const resolvedLLM = smSvc ? smSvc.resolveConfig(sessionId, platform).llm : undefined;
1485
+ const fmt = (r) => (r ? `${r.provider}/${r.model}` : undefined);
1486
+ let source = '(无 / 走 ServicePreference)';
1487
+ if (sessionLLM)
1488
+ source = '会话覆盖';
1489
+ else if (parentDefaultsLLM)
1490
+ source = '父会话 sessionDefaults';
1491
+ else if (profileLLM)
1492
+ source = `平台 profile (${platform})`;
1493
+ const lines = [`**当前模型**: ${fmt(resolvedLLM) || '(默认)'}`, `**来源**: ${source}`];
1494
+ const chain = [];
1495
+ if (sessionLLM)
1496
+ chain.push(`会话: ${fmt(sessionLLM)}`);
1497
+ if (parentDefaultsLLM)
1498
+ chain.push(`父 sessionDefaults: ${fmt(parentDefaultsLLM)}`);
1499
+ if (profileLLM)
1500
+ chain.push(`平台 profile: ${fmt(profileLLM)}`);
1501
+ if (chain.length > 0) {
1502
+ lines.push('', '**解析链**(高优先级在前):');
1503
+ for (const c of chain)
1504
+ lines.push(`- ${c}`);
1505
+ }
1506
+ if (sessionLLM)
1507
+ lines.push('', '_使用 `/model reset` 清除会话覆盖_');
1508
+ if (listAvailable) {
1509
+ // 直接枚举所有 chat-capable LLMModel entry
1510
+ const entries = ctx.getAllServices('llm', ['chat']);
1511
+ const seen = new Set();
1512
+ const items = [];
1513
+ for (const e of entries) {
1514
+ const display = e.label ? `${e.contextId} _(${e.label})_` : e.contextId;
1515
+ if (seen.has(e.contextId))
1516
+ continue;
1517
+ seen.add(e.contextId);
1518
+ items.push(display);
1519
+ }
1520
+ if (items.length > 0) {
1521
+ lines.push('', '**可用模型**(contextId 形式 `provider/model`):');
1522
+ for (const m of items)
1523
+ lines.push(`- ${m}`);
1524
+ }
1525
+ }
1526
+ return lines.join('\n');
1527
+ }
1528
+ useCommandService(ctx)
1529
+ .command('model', '查看当前会话的对话模型与解析链;并列出可用模型')
1530
+ .action(async (argv) => modelInfo(argv.session.sessionId, argv.session.platform, true));
1531
+ useCommandService(ctx)
1532
+ .command('model.info', '查看当前会话的对话模型与解析链')
1533
+ .action(async (argv) => modelInfo(argv.session.sessionId, argv.session.platform, false));
1534
+ useCommandService(ctx)
1535
+ .command('model.status', '查看当前会话的对话模型与解析链')
1536
+ .action(async (argv) => modelInfo(argv.session.sessionId, argv.session.platform, false));
1537
+ useCommandService(ctx)
1538
+ .command('model.reset', '清除当前会话的模型覆盖')
1539
+ .action(async (argv) => {
1540
+ const smSvc = ctx.getService('session-manager');
1541
+ if (!smSvc)
1542
+ return 'session-manager 服务不可用';
1543
+ const session = smSvc.getSession(argv.session.sessionId);
1544
+ if (session?.config?.llm) {
1545
+ const { llm: _, ...rest } = session.config;
1546
+ await smSvc.updateSession(argv.session.sessionId, { config: { ...rest, llm: undefined } });
1547
+ }
1548
+ const fallback = smSvc.resolveConfig(argv.session.sessionId, argv.session.platform).llm;
1549
+ return `已清除会话模型覆盖,回退到: ${fallback ? `${fallback.provider}/${fallback.model}` : '(默认)'}`;
1550
+ });
1551
+ useCommandService(ctx)
1552
+ .command('model.set <ref:string>', '设置会话级模型覆盖;ref 形如 `provider/model`(即 LLM entry 的 contextId)')
1553
+ .action(async (argv, ref) => {
1554
+ const refStr = String(ref || '').trim();
1555
+ if (!refStr)
1556
+ return '用法: /model set <provider/model>,例如 /model set @aalis/plugin-openai:main/gpt-4o';
1557
+ // 用最后一个 '/' 切分,因为 provider id 内含有 '/'(如 `@aalis/plugin-openai:main`)。
1558
+ const lastSlash = refStr.lastIndexOf('/');
1559
+ if (lastSlash <= 0 || lastSlash === refStr.length - 1) {
1560
+ return '格式错误。请使用 `provider/model`,例如 `@aalis/plugin-openai:main/gpt-4o`';
1561
+ }
1562
+ const provider = refStr.slice(0, lastSlash);
1563
+ const model = refStr.slice(lastSlash + 1);
1564
+ const smSvc = ctx.getService('session-manager');
1565
+ if (!smSvc)
1566
+ return 'session-manager 服务不可用';
1567
+ await smSvc.updateSession(argv.session.sessionId, { config: { llm: { provider, model } } });
1568
+ return `当前会话模型已切换为: ${provider}/${model}(已持久化)`;
1569
+ });
1570
+ // 监听 token:request 事件 — 客户端刷新/重连时主动请求 token 用量
1571
+ ctx.on('token:request', async (...args) => {
1572
+ const data = args[0];
1573
+ if (!data?.sessionId)
1574
+ return;
1575
+ try {
1576
+ const resolved = await agent.resolveLLM(data.platform, data.sessionId);
1577
+ if (!resolved)
1578
+ return;
1579
+ const llm = resolved.instance;
1580
+ const contextLength = llm.contextLength;
1581
+ const maxTokens = getModelMaxOutput(llm);
1582
+ const tokenBudget = Math.max(1024, contextLength - maxTokens - 512);
1583
+ // 获取历史消息并构建基础消息列表
1584
+ const memory = ctx.getService('memory');
1585
+ const messages = [];
1586
+ // 系统提示
1587
+ const systemPrompt = agent.buildSystemPrompt();
1588
+ messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
1589
+ // 历史消息
1590
+ if (memory) {
1591
+ const history = await memory.getHistory(data.sessionId, agent.historyLimit);
1592
+ messages.push(...history.filter(m => !CONTROL_KINDS.includes(m.kind ?? '')));
1593
+ }
1594
+ // 运行 agent:llm:before 中间件以获取注入的 system 消息(摘要、向量记忆等)+ 工具搜索层过滤
1595
+ const sm = ctx.getService('session-manager');
1596
+ const sessionResolved = sm ? sm.resolveConfig(data.sessionId, data.platform) : undefined;
1597
+ const enabledGroups = sessionResolved?.enabledToolGroups?.length ? sessionResolved.enabledToolGroups : undefined;
1598
+ const tools = ctx.getService('tools')?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ??
1599
+ [];
1600
+ const llmBeforeData = { messages, tools, sessionId: data.sessionId, userId: '', platform: data.platform ?? '' };
1601
+ await ctx.hooks.run('agent:llm:before', llmBeforeData);
1602
+ agent.emitTokenUsage(data.sessionId, data.platform ?? '', llmBeforeData.messages, llmBeforeData.tools, contextLength, maxTokens, tokenBudget);
1603
+ }
1604
+ catch (err) {
1605
+ ctx.logger.debug('token:request 处理失败:', err);
1606
+ }
1607
+ });
1608
+ }
1609
+ //# sourceMappingURL=index.js.map