@aalis/plugin-agent 0.15.2 → 0.16.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 CHANGED
@@ -10,14 +10,14 @@ import { memory as memoryService } from '@aalis/api-memory';
10
10
  import { messageArchive as messageArchiveService } from '@aalis/api-message-archive';
11
11
  import { persona as personaService } from '@aalis/api-persona';
12
12
  import { getPlatformSelfIdentity, platform as platformService } from '@aalis/api-platform';
13
- import { sessionManager as sessionManagerService } from '@aalis/api-session-manager';
14
- import { createStorageGateway, storage as storageService } from '@aalis/api-storage';
13
+ import { rootSessionOf, SESSION_LAYERS, sessionManager as sessionManagerService, } from '@aalis/api-session-manager';
14
+ import { storage as storageService } from '@aalis/api-storage';
15
15
  import { tools as toolsService, } from '@aalis/api-tools';
16
16
  import { config as configCap, definePlugin, events, lifecycle, logger as loggerCap, optional, pluginsService, provide, services, } from '@aalis/core';
17
17
  import { defineConfig, parseConfig } from '@aalis/schema-config';
18
18
  import { CONTROL_KINDS, getMessageName, getSenderLabel, WellKnownKinds, WellKnownMetadataKeys, } from '@aalis/schema-message';
19
19
  import { normalizeAssistantContent, stripLeakedSpecialTokens, truncateChars } from '@aalis/util-text-normalize';
20
- import { buildFocusGuidance, describeLLMFailure, estimateMsgTokens, estimateTextTokens, estimateTokens, formatTimeLabel, formatToolArgsForLog, INPUT_CONVENTIONS, isSameMessage, toPersonaOptions, } from './helpers.js';
20
+ import { buildFocusGuidance, describeLLMFailure, estimateHistoryTokens, estimateMsgTokens, estimateTextTokens, estimateTokens, formatTimeLabel, formatToolArgsForLog, INPUT_CONVENTIONS, isSameMessage, parseToolArguments, toPersonaOptions, } from './helpers.js';
21
21
  import { assemblePromptContributions, VOLATILE_INJECTOR } from './prompt-assembly.js';
22
22
  // ===== 能力声明 =====
23
23
  //
@@ -32,7 +32,6 @@ const uses = {
32
32
  contributions,
33
33
  lifecycle,
34
34
  provide,
35
- services,
36
35
  commands: optional(commandsService),
37
36
  tools: optional(toolsService),
38
37
  llm: optional(llmService),
@@ -45,6 +44,8 @@ const uses = {
45
44
  storage: optional(storageService),
46
45
  gateway: optional(gatewayService),
47
46
  plugins: optional(pluginsService),
47
+ /** 会话没设模型时比对本插件该生效的 llm 偏好与实际用的模型 */
48
+ services,
48
49
  };
49
50
  /**
50
51
  * 默认 Agent 实现 —— 对话编排器
@@ -61,16 +62,19 @@ const uses = {
61
62
  */
62
63
  /** 统一读取 LLM 单次最大输出 token;缺省回退 4096(与各 adapter 默认一致)。 */
63
64
  const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
65
+ /** 会话设置「最大工具迭代」没设时一轮里工具调用循环的最大次数 */
66
+ const MAX_TOOL_ITERATIONS = 30;
67
+ // 宿主通知的公开链接须在最终正文保留;同时识别普通文本和 Markdown 链接的边界。
68
+ const NOTICE_URL = /https?:\/\/[^\s<>()[\]"'`*“”‘’,。!?;、]+/gu;
64
69
  function getModelMaxOutput(llm) {
65
70
  return llm.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
66
71
  }
67
72
  class DefaultAgent {
73
+ instanceId;
68
74
  caps;
69
75
  logger;
70
76
  systemPrompt;
71
77
  memoryTokenBudget;
72
- historyLimit;
73
- maxToolIterations;
74
78
  /** 单个 agent:prompt 贡献 build 的等待上限(毫秒;0=不设限)——见 prompt-assembly 的护栏说明 */
75
79
  promptBuildTimeoutMs;
76
80
  /** 单条工具结果占上下文窗口的最大比例 (0~1),超出则截断 */
@@ -78,12 +82,17 @@ class DefaultAgent {
78
82
  /** 内存裁剪触发比例 (0~1):估算输入 token 占 contextLength 的比例上限,超过则触发本次调用的内存裁剪 */
79
83
  trimThresholdRatio;
80
84
  /**
81
- * 活跃 AbortController 表
85
+ * 在飞回合表:lane → 这一轮的 AbortController 与 turnId
82
86
  *
83
- * key = `${sessionId}::${source}` — 同一 session 不同来源(user / scheduler)
87
+ * lane = `${sessionId}::${source}` — 同一 session 不同来源(user / scheduler)
84
88
  * 独立管理,互不打断;同来源新消息会中止旧的生成。
85
89
  */
86
90
  activeControllers = new Map();
91
+ /**
92
+ * 延续某位发言者身份的宿主通知回合(lane → 会话与 hostNotice.callerUserId)。那位发言者在同一会话发来的真人消息
93
+ * 中止这些回合;别人的消息与不延续任何人身份的通知(白纸等)不受影响,宿主通知也从不打断别的回合。
94
+ */
95
+ noticeCallers = new Map();
87
96
  /**
88
97
  * 在飞回合 Promise。关停时 abort 之后等它们以 AbortError(或正常完成)收尾,
89
98
  * 而不是等 LLM 跑完——收尾里的钩子 / 出站事件还要用 memory。
@@ -100,15 +109,18 @@ class DefaultAgent {
100
109
  * 与 token:usage 事件共用同一份 breakdown 数据,零额外计算。
101
110
  */
102
111
  tokenLogState = new Map();
112
+ /** 已告警过的(llm 服务的偏好,实际用的模型)组合 */
113
+ preferenceFallbacks = new Set();
103
114
  /** 已注册的预处理器(name → { dispose }) */
104
115
  preprocessors = new Map();
105
- constructor(caps, cfg) {
116
+ constructor(caps, cfg,
117
+ /** 本插件实例 id:读它的本插件偏好 */
118
+ instanceId) {
119
+ this.instanceId = instanceId;
106
120
  this.caps = caps;
107
- this.logger = caps.logger.child('agent');
121
+ this.logger = caps.logger.use.child('agent');
108
122
  this.systemPrompt = cfg.systemPrompt;
109
123
  this.memoryTokenBudget = cfg.memoryTokenBudget;
110
- this.historyLimit = cfg.historyLimit;
111
- this.maxToolIterations = cfg.maxToolIterations;
112
124
  this.promptBuildTimeoutMs = cfg.promptBuildTimeoutMs;
113
125
  this.toolResultMaxRatio = cfg.toolResultMaxRatio;
114
126
  this.trimThresholdRatio = cfg.trimThresholdRatio;
@@ -117,38 +129,43 @@ class DefaultAgent {
117
129
  /**
118
130
  * 根据优先级链解析当前会话该用哪个 LLMModel entry。
119
131
  *
120
- * 全部交给 session-manager.resolveConfig。优先级(从高到低):
121
- * 1. 会话 config
122
- * 2. 父会话 sessionDefaults
123
- * 3. 平台 profile
124
- * 4. ServicePreference(由 agent 启动时锁定的全局默认 entry,未传 ref 时生效)
132
+ * 会话设置的 llm 交给 session-manager.resolveConfig 解析(按层表叠加,全局默认层即 llm 服务的偏好);
133
+ * 全局默认之上哪一层都没设时不给值,由 resolveLLMModel 落到本插件此刻选中的那一个(本插件偏好 > 全局偏好 > 提供者优先级 >
134
+ * 登记先后)。这时该生效的偏好(设了本插件偏好就是它,否则是全局偏好)指向的模型不在(模型删了、提供者在重载或激活失败),
135
+ * 服务落到别的模型上:照常用它,每个(偏好,实际)组合告警一次。
125
136
  *
126
- * 返回 LLMModelEntry(含 instance / contextId / capabilities),entry 已绑定具体 model。
137
+ * 返回 llm 的提供者条目(id 与这一家的业务句柄 use),条目固定那一个模型。
127
138
  */
128
139
  async resolveLLM(platform, sessionId) {
129
- let ref;
130
- // session-manager 一步到位:会话 > 父 sessionDefaults > platform profile
131
- const sm = this.caps.sessionManager.current;
132
- if (sm && sessionId) {
133
- const resolved = sm.resolveConfig(sessionId, platform);
134
- if (resolved.llm?.provider && resolved.llm?.model)
135
- ref = resolved.llm;
136
- }
140
+ const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
141
+ const ref = sm && sessionId ? sm.resolveConfig(sessionId, platform).llm : undefined;
137
142
  // 解析为具体 LLMModel entry(要求至少 chat 能力;ref 为空时走 ServicePreference / 优先级)
138
- return resolveLLMModel(this.caps.llm, ref, ['chat']);
143
+ const entry = resolveLLMModel(this.caps.llm, ref, ['chat']);
144
+ if (ref || !entry)
145
+ return entry;
146
+ const own = this.caps.services.use.preferred(llmService, { for: this.instanceId });
147
+ const preferred = own ?? this.caps.services.use.preferred(llmService);
148
+ if (preferred !== undefined && entry.id !== preferred) {
149
+ const combo = `${preferred}\n${entry.id}`;
150
+ if (!this.preferenceFallbacks.has(combo)) {
151
+ this.preferenceFallbacks.add(combo);
152
+ this.logger.warn(`llm 服务的${own === undefined ? '' : '本插件'}偏好 ${preferred} 没有生效(指向的模型不在、提供者在重载或激活失败),实际用的是 ${entry.id}`);
153
+ }
154
+ }
155
+ return entry;
139
156
  }
140
157
  /** 解析失败时说清**为什么**(取数在此,措辞在 helpers 的 describeLLMFailure,纯函数便于单测)。 */
141
158
  explainLLMFailure(platform, sessionId) {
142
- const available = listLLMModels(this.caps.llm, { caps: ['chat'] }).map(e => e.contextId);
143
- const sm = this.caps.sessionManager.current;
159
+ const available = listLLMModels(this.caps.llm, { caps: ['chat'] }).map(e => e.id);
160
+ const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
144
161
  const wanted = sm && sessionId ? sm.resolveConfig(sessionId, platform).llm : undefined;
145
162
  return describeLLMFailure(available, wanted, this.erroredLLMPlugins());
146
163
  }
147
164
  /** 激活失败且声明提供 llm 的插件实例名——「一个模型都没有」时,病因通常就在它们身上(多为缺 apiKey)。 */
148
165
  erroredLLMPlugins() {
149
- const pm = this.caps.plugins.current;
150
- if (!pm)
166
+ if (!this.caps.plugins.present)
151
167
  return [];
168
+ const pm = this.caps.plugins.use;
152
169
  // 只取实例名:这串会拼进**发到聊天**(含群聊)的文案里,error 原文可能带内网地址与端口等部署细节,
153
170
  // 原因留在日志、WebUI 与 /doctor
154
171
  return pm
@@ -160,16 +177,20 @@ class DefaultAgent {
160
177
  return `${sessionId}::${source ?? 'user'}`;
161
178
  }
162
179
  /**
163
- * 中止指定会话的当前生成(所有 lane)
180
+ * 中止指定会话的在飞回合:不带 turnId 时停所有 lane,带 turnId 时只停那一轮。返回是否停下了回合。
164
181
  */
165
- abort(sessionId) {
166
- for (const [key, controller] of this.activeControllers) {
167
- if (key.startsWith(`${sessionId}::`)) {
168
- controller.abort();
169
- this.activeControllers.delete(key);
170
- }
182
+ abort(sessionId, turnId) {
183
+ let aborted = false;
184
+ for (const [key, turn] of this.activeControllers) {
185
+ if (!key.startsWith(`${sessionId}::`) || (turnId !== undefined && turn.turnId !== turnId))
186
+ continue;
187
+ turn.controller.abort();
188
+ this.activeControllers.delete(key);
189
+ aborted = true;
171
190
  }
172
- this.logger.info(`生成已中止: session=${sessionId}`);
191
+ if (aborted)
192
+ this.logger.info(`生成已中止: session=${sessionId}${turnId ? ` turn=${turnId}` : ''}`);
193
+ return aborted;
173
194
  }
174
195
  /**
175
196
  * 中止全部在飞回合(拆卸路径用)。
@@ -181,8 +202,8 @@ class DefaultAgent {
181
202
  */
182
203
  abortAll() {
183
204
  let n = 0;
184
- for (const [key, controller] of this.activeControllers) {
185
- controller.abort();
205
+ for (const [key, turn] of this.activeControllers) {
206
+ turn.controller.abort();
186
207
  this.activeControllers.delete(key);
187
208
  n++;
188
209
  }
@@ -213,7 +234,7 @@ class DefaultAgent {
213
234
  registerPreprocessor(name, handler) {
214
235
  // 同名替换
215
236
  this.preprocessors.get(name)?.dispose();
216
- const dispose = this.caps.hooks.middleware('agent:input:before', async (data, next) => {
237
+ const dispose = this.caps.hooks.use.middleware('agent:input:before', async (data, next) => {
217
238
  await handler(data.message, next);
218
239
  });
219
240
  // 退订按条目引用比对:同名重注册后,旧退订闭包只摘自己的中间件,不动新登记的账目
@@ -243,9 +264,9 @@ class DefaultAgent {
243
264
  * (平台属于独立子系统,由 api-platform 的 helper 负责)。
244
265
  */
245
266
  getPluginGroups() {
246
- const pm = this.caps.plugins.current;
247
- if (!pm)
267
+ if (!this.caps.plugins.present)
248
268
  return [];
269
+ const pm = this.caps.plugins.use;
249
270
  // 子系统归属:Agent 域的服务(不含 platform——平台是独立子系统)
250
271
  const targetServices = new Set(['llm', 'memory', 'persona', 'message-archive']);
251
272
  const grouped = [];
@@ -261,8 +282,12 @@ class DefaultAgent {
261
282
  *
262
283
  * 同时构建本轮调用的 segments 时间线(按 chunk 到达顺序记录 text / reasoning_text,
263
284
  * 相邻同类合并)——用于上层将多轮调用 + 工具执行交错拼接成统一时间线。
285
+ *
286
+ * 工具调用的生成进度按调用的序号各配一个 callId 随进度推出,界面在参数生成期间就画出它的工具卡。generating 按序号
287
+ * 记下 callId 与工具名(provider 按序号组装 toolCalls,序号即调用在 toolCalls 里的位置);没报过进度的调用没有。
264
288
  */
265
- async consumeStream(llm, request, sessionId, platform) {
289
+ async consumeStream(llm, request, turnId, sessionId, platform, emitProgress = true) {
290
+ const generating = new Map();
266
291
  let content = '';
267
292
  let reasoningContent = '';
268
293
  let toolCalls;
@@ -277,7 +302,7 @@ class DefaultAgent {
277
302
  segments.push({ type: kind, content: delta });
278
303
  }
279
304
  };
280
- for await (const chunk of llm.chatStream(request)) {
305
+ for await (const chunk of llm.chatStream({ ...request, sessionId, purpose: this.purposeOf(sessionId) })) {
281
306
  // 检查中止信号
282
307
  if (request.signal?.aborted) {
283
308
  throw new DOMException('Generation aborted', 'AbortError');
@@ -285,26 +310,34 @@ class DefaultAgent {
285
310
  if (chunk.contentDelta) {
286
311
  content += chunk.contentDelta;
287
312
  appendDelta('text', chunk.contentDelta);
288
- await this.caps.events.emit('outbound:stream', {
289
- sessionId,
290
- platform,
291
- contentDelta: chunk.contentDelta,
292
- });
313
+ if (emitProgress)
314
+ await this.caps.events.use.emit('outbound:stream', {
315
+ sessionId,
316
+ turnId,
317
+ platform,
318
+ contentDelta: chunk.contentDelta,
319
+ });
293
320
  }
294
321
  if (chunk.reasoningDelta) {
295
322
  reasoningContent += chunk.reasoningDelta;
296
323
  appendDelta('reasoning_text', chunk.reasoningDelta);
297
- await this.caps.events.emit('outbound:stream', {
298
- sessionId,
299
- platform,
300
- reasoningDelta: chunk.reasoningDelta,
301
- });
324
+ if (emitProgress)
325
+ await this.caps.events.use.emit('outbound:stream', {
326
+ sessionId,
327
+ turnId,
328
+ platform,
329
+ reasoningDelta: chunk.reasoningDelta,
330
+ });
302
331
  }
303
- if (chunk.toolCallProgress) {
304
- await this.caps.events.emit('outbound:stream', {
332
+ if (emitProgress && chunk.toolCallProgress) {
333
+ const { index, name } = chunk.toolCallProgress;
334
+ const callId = generating.get(index)?.callId ?? crypto.randomUUID();
335
+ generating.set(index, { callId, name });
336
+ await this.caps.events.use.emit('outbound:stream', {
305
337
  sessionId,
338
+ turnId,
306
339
  platform,
307
- toolCallProgress: chunk.toolCallProgress,
340
+ toolCallProgress: { ...chunk.toolCallProgress, callId },
308
341
  });
309
342
  }
310
343
  if (chunk.done) {
@@ -319,7 +352,7 @@ class DefaultAgent {
319
352
  // 兜底剥离 LLM 端漏出的特殊 token 残渣(DSML 等),同时修复 GFM 表格
320
353
  const { sanitized, hadLeak } = stripLeakedSpecialTokens(content);
321
354
  if (hadLeak) {
322
- this.caps.logger.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
355
+ this.caps.logger.use.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
323
356
  }
324
357
  finalContent = normalizeAssistantContent(content);
325
358
  }
@@ -329,8 +362,16 @@ class DefaultAgent {
329
362
  toolCalls,
330
363
  usage,
331
364
  segments,
365
+ generating,
332
366
  };
333
367
  }
368
+ /** 记账的用途:子会话里的回合是子任务,其余是对话 */
369
+ purposeOf(sessionId) {
370
+ return (this.caps.sessionManager.present ? this.caps.sessionManager.use.getSession(sessionId)?.kind : undefined) ===
371
+ 'task'
372
+ ? '子任务'
373
+ : '对话';
374
+ }
334
375
  /**
335
376
  * Debug 模式下格式化输出 LLM 响应详情
336
377
  */
@@ -439,18 +480,34 @@ class DefaultAgent {
439
480
  // 仅中止同一 lane(同 session + 同 source)的旧生成;不同来源互不打断
440
481
  const prev = this.activeControllers.get(lane);
441
482
  if (prev)
442
- prev.abort();
483
+ prev.controller.abort();
484
+ // 例外:真人消息打断延续其本人身份的宿主通知回合,本人说话就停得下她自己开的那一轮。
485
+ if (incoming.source === undefined && incoming.userId !== undefined) {
486
+ for (const notice of this.noticeCallers.values()) {
487
+ if (notice.sessionId === incoming.sessionId && notice.callerUserId === incoming.userId) {
488
+ notice.controller.abort();
489
+ }
490
+ }
491
+ }
443
492
  const controller = new AbortController();
444
- this.activeControllers.set(lane, controller);
493
+ // 回合 id:这一轮的入站钩子、流式片段、工具调用、用量与回合结束都带它,停止键也能按它只停这一轮
494
+ const turnId = crypto.randomUUID();
495
+ this.activeControllers.set(lane, { controller, turnId });
496
+ const callerUserId = incoming.hostNotice?.callerUserId;
497
+ if (callerUserId !== undefined) {
498
+ this.noticeCallers.set(lane, { sessionId: incoming.sessionId, callerUserId, controller });
499
+ }
445
500
  const turn = (async () => {
446
501
  try {
447
- await this._handleMessageInner(incoming, controller.signal, lane);
502
+ await this._handleMessageInner(incoming, controller.signal, lane, turnId);
448
503
  }
449
504
  finally {
450
505
  // 仅清理自己创建的 controller(避免清掉后续新请求的)
451
- if (this.activeControllers.get(lane) === controller) {
506
+ if (this.activeControllers.get(lane)?.controller === controller) {
452
507
  this.activeControllers.delete(lane);
453
508
  }
509
+ if (this.noticeCallers.get(lane)?.controller === controller)
510
+ this.noticeCallers.delete(lane);
454
511
  }
455
512
  })();
456
513
  this.inflightTurns.add(turn);
@@ -461,33 +518,47 @@ class DefaultAgent {
461
518
  this.inflightTurns.delete(turn);
462
519
  }
463
520
  }
464
- async _handleMessageInner(incoming, signal, lane) {
521
+ async _handleMessageInner(incoming, signal, lane, turnId) {
465
522
  // Hook: agent:input:before — 插件可以修改或拦截消息
466
523
  // 中间件不调用 next() 即可中断整个流程(包括 LLM 调用)
467
524
  const msgHookData = {
468
525
  message: incoming,
526
+ turnId,
469
527
  metadata: {},
470
528
  };
471
529
  let handled = false;
472
- await this.caps.hooks.run('agent:input:before', msgHookData, async () => {
530
+ await this.caps.hooks.use.run('agent:input:before', msgHookData, async () => {
473
531
  handled = true;
474
532
  // ===== defaultAction: 全部消息处理逻辑在此 =====
475
533
  // 中间件不调用 next() → 此处永远不执行 → 消息被拦截
476
534
  incoming = msgHookData.message;
535
+ const reportOnly = incoming.hostNotice?.reportOnly === true;
536
+ const noticeLinks = reportOnly ? (incoming.content.match(NOTICE_URL) ?? []) : [];
477
537
  const archivedIncoming = await this.archiveIncomingMessageInOrder(lane, incoming);
478
538
  const resolved = await this.resolveLLM(incoming.platform, incoming.sessionId);
479
539
  if (!resolved) {
480
540
  const why = this.explainLLMFailure(incoming.platform, incoming.sessionId);
481
541
  this.logger.warn(`LLM 解析失败,无法处理消息:${why}`);
482
- await this.dispatchOutbound({
483
- content: `[系统] ${why}`,
542
+ if (!reportOnly) {
543
+ await this.dispatchOutbound({
544
+ content: `[系统] ${why}`,
545
+ sessionId: incoming.sessionId,
546
+ platform: incoming.platform,
547
+ source: 'system',
548
+ });
549
+ }
550
+ await this.caps.hooks.use.run('agent:turn:after', {
551
+ message: incoming,
552
+ reply: '',
553
+ outcome: signal.aborted ? 'aborted' : 'error',
554
+ ...(signal.aborted ? {} : { error: why }),
484
555
  sessionId: incoming.sessionId,
485
- platform: incoming.platform,
486
- source: 'system',
556
+ turnId,
557
+ metadata: msgHookData.metadata,
487
558
  });
488
559
  return;
489
560
  }
490
- const llm = resolved.instance;
561
+ const llm = resolved.use;
491
562
  // 从 LLM model entry 读取参数。contextLength / maxOutputTokens 均为 per-model 属性,
492
563
  // service-granularity 后不再需要 router.getContextLengthFor() 反查,也不再会出现
493
564
  // 默认 provider 全局窗口与会话实际 model 不一致的偏差(Bug F 结构性修复)。
@@ -499,35 +570,42 @@ class DefaultAgent {
499
570
  const turnPersistedTimestamps = [];
500
571
  try {
501
572
  // 统一解析 session 配置(一次解析,多处复用)
502
- const sessionMgr = this.caps.sessionManager.current;
573
+ const sessionMgr = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
503
574
  const resolved = sessionMgr && incoming.sessionId
504
575
  ? sessionMgr.resolveConfig(incoming.sessionId, incoming.platform)
505
576
  : undefined;
506
- // 会话级 maxToolIterations 覆盖(WebUI 会话页可编辑):正整数才生效,其余视为未设置、回落全局配置
507
- const sessionMaxIter = resolved?.maxToolIterations;
508
- const maxToolIterations = typeof sessionMaxIter === 'number' && Number.isInteger(sessionMaxIter) && sessionMaxIter >= 1
509
- ? sessionMaxIter
510
- : this.maxToolIterations;
577
+ // 会话设置的 maxToolIterations 按登记的字段解析过(正整数),没设置时用默认值
578
+ const maxToolIterations = resolved?.maxToolIterations ?? MAX_TOOL_ITERATIONS;
511
579
  // 构建 persona 会话选项(从 resolved config 中提取,传给 persona 服务)
512
580
  const personaOpts = toPersonaOptions(resolved);
513
- // 会话级 thinking 覆盖(/session.set -t on|off):未设置则不带 think 字段,
514
- // 由各 provider 按自己的全局配置决定(ollama thinking / deepseek thinkingMode)。
515
- const thinkOverride = resolved?.think;
516
- const messages = await this.buildMessages(incoming, personaOpts, archivedIncoming);
581
+ // 会话的深度思考档位:当前模型有这一档才带上;没设置、模型没有这一档或不能调时不带,由模型服务按它的默认档
582
+ const level = resolved?.think;
583
+ const thinking = llm.thinking?.levels.some(({ id }) => id === level) ? { think: level } : {};
584
+ const built = await this.buildMessages(incoming, resolved?.historyLimit, personaOpts, archivedIncoming);
585
+ const messages = built.messages;
586
+ /** 这次调用的消息列表里来自会话历史的条数:读进的,加本回合追加的工具调用与结果 */
587
+ let historyMessages = built.historyMessages;
517
588
  // 通过 resolved config 获取工具分组
518
589
  let enabledGroups;
519
590
  if (resolved?.enabledToolGroups && resolved.enabledToolGroups.length > 0) {
520
591
  enabledGroups = resolved.enabledToolGroups;
521
592
  }
522
593
  this.logger.debug(`工具分组: platform=${incoming.platform}, enabledGroups=${enabledGroups ? JSON.stringify(enabledGroups) : '(无)'}`);
523
- const tools = this.caps.tools.current?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ?? [];
594
+ const tools = reportOnly
595
+ ? []
596
+ : this.caps.tools.present
597
+ ? this.caps.tools.use.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined)
598
+ : [];
524
599
  const toolCtx = {
525
600
  sessionId: incoming.sessionId,
526
- // platform/userId 保持**会话/物理**语义(定时任务归属、平台档继承、记忆平台域、
527
- // confirm 选路都靠它们);授权身份单独走 actor——authority 守卫按 actor 查等级,
528
- // scheduler/delegate/subtask 等触发的 AI 因此以创建者等级执行而非匿名。
529
- // 不可再用 actor 覆盖 platform:跨平台委派会把上述四类下游全路由到发起者平台。
530
- userId: incoming.userId,
601
+ // platform/userId 保持**会话/物理**语义(定时任务归属、平台档继承、记忆平台域都靠它们);
602
+ // 授权身份单独走 actor——authority 守卫按 actor 查等级,
603
+ // scheduler/workflow/subtask 等触发的 AI 因此以创建者等级执行而非匿名。
604
+ // 不可再用 actor 覆盖 platform:actor 来自另一平台时(如跨平台的定时任务、workflow)
605
+ // 会把上述下游全路由到发起者平台。
606
+ // 延续某次工具调用的宿主通知不带发言者,userId 取那次调用的(hostNotice.callerUserId),工具眼里是起它的人在调。
607
+ // 只用在这里,归档与提示词钩子仍按消息本身的 userId(没有)。
608
+ userId: incoming.userId ?? incoming.hostNotice?.callerUserId,
531
609
  platform: incoming.platform,
532
610
  actor: incoming.actor,
533
611
  // 恒为数组:平台档没配分组时给 [](列举面本就等价于「只给无分组工具」),
@@ -535,8 +613,17 @@ class DefaultAgent {
535
613
  enabledGroups: enabledGroups ?? [],
536
614
  // 本循环会把 ToolExecutionResult.images 挂到 tool 消息交给主模型(出口由 prepareLLMMessages 编码)
537
615
  acceptsImages: true,
538
- // 回合中止信号:等确认期间被 latest-wins / 手动 abort 掐掉的工具不再执行
616
+ // 回合中止信号:等 owner 审批期间被 latest-wins / 手动 abort 掐掉的工具不再执行
539
617
  signal,
618
+ // 本回合的入站来源:只有这里填写,工具据此正向区分真人消息(source 缺省)与内部注入;
619
+ // 延续某次调用的宿主通知另带那次调用的发言者,执行守卫据此认出 owner 链的延续;
620
+ // 入站消息的平台消息 id 供转到后台的调用结束时,通知的回复引用它
621
+ inbound: {
622
+ source: incoming.source,
623
+ callerUserId: incoming.hostNotice?.callerUserId,
624
+ messageId: incoming.messageId,
625
+ },
626
+ turnId,
540
627
  };
541
628
  // 保存原始完整工具列表,后续迭代均以此为基础(避免被 hooks 修改后丢失)
542
629
  const originalTools = [...tools];
@@ -548,27 +635,70 @@ class DefaultAgent {
548
635
  userId: incoming.userId,
549
636
  platform: incoming.platform,
550
637
  triggerType: incoming.triggerType,
638
+ source: incoming.source,
551
639
  };
552
640
  // 组装先于链:贡献块(档案/技能/记忆/即时提示)先物化,拦截者审完整成品
553
641
  await assemblePromptContributions(this.caps, llmBeforeData, {
554
642
  buildTimeoutMs: this.promptBuildTimeoutMs,
555
643
  signal,
556
644
  });
557
- await this.caps.hooks.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
558
- // 裁剪消息以确保不超过上下文窗口
559
- llmBeforeData.messages = this.trimMessages(llmBeforeData.messages, tokenBudget);
560
- // 推送 token 使用量统计
561
- this.emitTokenUsage(incoming.sessionId, incoming.platform, llmBeforeData.messages, llmBeforeData.tools, contextLength, maxTokens, tokenBudget);
645
+ await this.caps.hooks.use.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
646
+ if (reportOnly) {
647
+ llmBeforeData.tools = [];
648
+ // 这是结果汇报,不续跑历史工具链。保留人设与对话,去掉会诱发继续查任务的工具协议。
649
+ llmBeforeData.messages = llmBeforeData.messages.filter(message => message.role !== 'tool' &&
650
+ !message.toolCalls?.length &&
651
+ message.metadata?.injector !== WellKnownKinds.HostNotice);
652
+ // 放在钩子之后、消息末尾,确保裁剪仍保留本次结果及转述要求。
653
+ llmBeforeData.messages.push({
654
+ role: 'system',
655
+ content: `[宿主通知]\n${incoming.content}${incoming.hostNotice?.untrusted ? `\n\n${incoming.hostNotice.untrusted}` : ''}\n\n` +
656
+ '本轮只转述以上已确认的结果。沿用当前人设和回复格式,向当前会话自然地说明结果,保留通知中的链接;' +
657
+ '不要调用工具、重新查询状态或继续制作,不把编号、内部流程当成回复正文,也不要编造未提供的作品细节。请给出非空回复。',
658
+ metadata: { injector: WellKnownKinds.HostNotice },
659
+ });
660
+ }
661
+ /** 本回合到目前为止经过了硬裁剪:首次调用的裁剪结果写回底稿,之后各次调用都在它上面追加 */
662
+ let turnTrimmed = false;
663
+ /** 首次调用的硬裁剪从底稿里去掉的历史与工具结果:之后各次调用按底稿估算没裁剪的量时补回 */
664
+ let trimmedFromBase = 0;
665
+ /** 硬裁剪这次要发出的列表,删了消息时记一行日志;untrimmedHistory 按没经过硬裁剪的列表估算 */
666
+ const trimForCall = (list) => {
667
+ const sent = this.trimMessages(list, tokenBudget);
668
+ if (sent.messages.length < list.length) {
669
+ this.logger.info(`上下文硬裁剪: session=${incoming.sessionId}, ${list.length} → ${sent.messages.length} 条消息`);
670
+ }
671
+ turnTrimmed ||= sent.trimmed;
672
+ return { messages: sent.messages, untrimmedHistory: estimateHistoryTokens(list) + trimmedFromBase };
673
+ };
674
+ // 裁剪消息以确保不超过上下文窗口;裁剪结果写回,作为之后各次调用的底稿
675
+ const firstSent = trimForCall(llmBeforeData.messages);
676
+ trimmedFromBase = firstSent.untrimmedHistory - estimateHistoryTokens(firstSent.messages);
677
+ llmBeforeData.messages = firstSent.messages;
678
+ // 每次模型调用之后发一次用量:拆分按这次发出的消息与工具定义估算,used 取模型服务返回的输入 token
679
+ const reportUsage = (sent, sentTools, response) => this.emitTokenUsage({
680
+ sessionId: incoming.sessionId,
681
+ platform: incoming.platform,
682
+ turnId,
683
+ contextWindow: contextLength,
684
+ maxTokens,
685
+ tokenBudget,
686
+ messages: sent.messages,
687
+ tools: sentTools,
688
+ trimmed: turnTrimmed,
689
+ untrimmedHistory: sent.untrimmedHistory,
690
+ historyMessages,
691
+ promptTokens: response.usage?.promptTokens,
692
+ });
562
693
  this.logger.debug(`LLM 请求: ${llmBeforeData.messages.length} 条消息, ` +
563
694
  `${llmBeforeData.tools.length} 个工具, ` +
564
695
  `maxTokens=${maxTokens}`);
565
696
  const t0 = Date.now();
566
- // 本轮初始时间与累加用量,用于最终 assistant 消息的 modelInfo 元数据。
567
- const turnStartTs = t0;
697
+ // 本轮累加的用量,用于最终 assistant 消息的 modelInfo 元数据。
568
698
  // 注:缓存命中量(usage.cachedPromptTokens)不进这里——本对象只喂
569
699
  // assistant 消息的 modelInfo,而其字段是逐个挑的(见下方 turnModelInfo),
570
700
  // 加进来也不会流到下游。命中率的观测出口是 LLM 响应日志那一行。
571
- const turnUsageAcc = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
701
+ const turnUsageAcc = { promptTokens: 0, completionTokens: 0 };
572
702
  const accUsage = (u) => {
573
703
  if (!u)
574
704
  return;
@@ -580,16 +710,18 @@ class DefaultAgent {
580
710
  // completionTokens 语义 = "本回合真正新生成的输出"(含中间 reasoning / tool-call 生成
581
711
  // 与最终回复文本),每次调用都是不重复的新输出,故逐次累加。
582
712
  turnUsageAcc.completionTokens += u.completionTokens ?? 0;
583
- turnUsageAcc.totalTokens = turnUsageAcc.promptTokens + turnUsageAcc.completionTokens;
584
713
  };
585
714
  const firstResult = await this.consumeStream(llm, {
586
715
  messages: llmBeforeData.messages,
587
716
  tools: llmBeforeData.tools.length > 0 ? llmBeforeData.tools : undefined,
588
717
  maxTokens,
589
718
  signal,
590
- ...(thinkOverride !== undefined ? { think: thinkOverride } : {}),
591
- }, incoming.sessionId, incoming.platform);
719
+ ...thinking,
720
+ }, turnId, incoming.sessionId, incoming.platform, !reportOnly);
721
+ await reportUsage(firstSent, llmBeforeData.tools, firstResult);
592
722
  accUsage(firstResult.usage);
723
+ /** 这一次模型回复里各工具调用在生成进度里配好的 callId 与工具名,按调用的位置 */
724
+ let generating = firstResult.generating;
593
725
  // 维护本轮(一次完整对话回合)的统一时间线 segments:
594
726
  // 多次 LLM 调用 + 工具执行的输出按到达顺序拼接,保留模型原本的"思考/回答/工具/思考/回答"交错。
595
727
  const turnSegments = [...firstResult.segments];
@@ -597,57 +729,71 @@ class DefaultAgent {
597
729
  this.debugLogResponse(response, Date.now() - t0);
598
730
  // Hook: agent:llm:after — 插件可以处理 LLM 返回结果
599
731
  const llmAfterData = { response, messages: llmBeforeData.messages };
600
- await this.caps.hooks.run('agent:llm:after', llmAfterData);
732
+ await this.caps.hooks.use.run('agent:llm:after', llmAfterData);
601
733
  response = llmAfterData.response;
602
- // 收集所有思考内容
603
- const allReasoning = [];
604
- if (response.reasoningContent) {
605
- allReasoning.push(response.reasoningContent);
606
- }
607
734
  // 收集工具调用摘要
608
735
  const toolCallSummaries = [];
609
- const assistantMetadata = this.buildAssistantMetadata(incoming);
736
+ const assistantMetadata = this.buildAssistantMetadata(incoming, turnId);
610
737
  // 工具调用循环
611
738
  let iterations = 0;
612
- while (response.toolCalls && response.toolCalls.length > 0 && iterations < maxToolIterations) {
739
+ while (!reportOnly && response.toolCalls && response.toolCalls.length > 0 && iterations < maxToolIterations) {
613
740
  if (signal.aborted)
614
741
  throw new DOMException('Generation aborted', 'AbortError');
615
742
  iterations++;
616
743
  this.logger.debug(`工具调用迭代 ${iterations}: ${response.toolCalls.map(tc => tc.function.name).join(', ')}`);
744
+ // 先解析各次调用的参数:解析不了的,写进本回合消息与存档的那次调用的 arguments 换成 {}(见 parseToolArguments)
745
+ const parsedCalls = response.toolCalls.map(parseToolArguments);
746
+ const toolCalls = parsedCalls.map(parsed => parsed.toolCall);
617
747
  // 将 assistant 消息 (含 toolCalls) 加入历史
618
748
  llmBeforeData.messages.push({
619
749
  role: 'assistant',
620
750
  content: response.content,
621
- toolCalls: response.toolCalls,
751
+ toolCalls,
622
752
  reasoningContent: response.reasoningContent,
623
753
  metadata: assistantMetadata,
624
754
  });
625
755
  const assistantToolMessage = {
626
756
  role: 'assistant',
627
757
  content: response.content,
628
- toolCalls: response.toolCalls,
758
+ toolCalls,
629
759
  reasoningContent: response.reasoningContent,
630
760
  metadata: assistantMetadata,
631
761
  };
632
762
  const toolMessages = [];
633
763
  // 并行执行所有工具调用(互不依赖的工具无需串行等待)
634
764
  const toolResultMaxChars = Math.floor(contextLength * this.toolResultMaxRatio * 3.5);
635
- const parallelResults = await Promise.all(response.toolCalls.map(async (toolCall) => {
636
- // 无参工具的 arguments 常是空串(流式路径没有 argument delta 时拼出来就是 ''),不算坏 JSON
637
- const rawArgs = toolCall.function.arguments?.trim();
638
- let args;
639
- try {
640
- args = rawArgs ? JSON.parse(rawArgs) : {};
641
- }
642
- catch (parseErr) {
643
- // 参数不是合法 JSON:跳过本次执行,把错误当工具结果回给模型重新生成 arguments。
644
- // 旧行为 args = {} 会让"无必填参数"的工具真的空参跑一遍(副作用已发生,模型却不知情)。
645
- const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
646
- this.logger.warn(`工具参数解析失败: ${toolCall.function.name} (${reason}) 原始参数=${rawArgs?.slice(0, 200)}`);
765
+ const parallelResults = await Promise.all(parsedCalls.map(async (parsed, position) => {
766
+ const { toolCall } = parsed;
767
+ // 这次调用的 id:开始与结束事件、工具上下文都带它。同一位置生成参数时报过同一个工具的进度,用那时配的
768
+ // (会话里已有它的工具卡);没报过的、或钩子改过调用列表而对不上的现在生成,免得接管画着别的工具的卡。
769
+ // 自己生成而不用模型给的 toolCall.id——模型给的 id 不保证唯一(有的 provider 用毫秒时间戳加序号拼,并发的
770
+ // 两轮会撞上),而 callId 要在全局配对开始与结束。模型的 id 只留在发回模型的 tool 消息里配对调用与结果。
771
+ const generated = generating.get(position);
772
+ const callId = generated?.name === toolCall.function.name ? generated.callId : crypto.randomUUID();
773
+ // 会话里的工具卡跟着这一对事件走:没执行就失败的调用(参数不合法、执行前的钩子出错)同样发一对,
774
+ // 结束带回给模型的错误,否则那张卡一直停在生成中
775
+ const announce = (phase, toolName, toolArgs, result) => this.caps.events.use.emit('tool:execute', {
776
+ sessionId: incoming.sessionId,
777
+ turnId,
778
+ callId,
779
+ platform: incoming.platform,
780
+ toolName,
781
+ args: toolArgs,
782
+ phase,
783
+ result,
784
+ });
785
+ if ('error' in parsed) {
786
+ // 参数不是合法 JSON 对象:跳过本次执行,把错误当工具结果回给模型重新生成 arguments。
787
+ // 不能按空参数执行:「无必填参数」的工具会真的空参跑一遍(副作用已发生,模型却不知情)。
788
+ this.logger.warn(`工具参数解析失败: ${toolCall.function.name}: ${parsed.error}`);
647
789
  const failedAt = Date.now();
790
+ const result = JSON.stringify({ error: parsed.error });
791
+ await announce('start', toolCall.function.name, {});
792
+ await announce('end', toolCall.function.name, {}, result);
648
793
  return {
649
794
  toolCall,
650
- result: JSON.stringify({ error: `工具参数不是合法 JSON:${reason};请重新生成 arguments` }),
795
+ callId,
796
+ result,
651
797
  resultImages: undefined,
652
798
  toolName: toolCall.function.name,
653
799
  toolArgs: {},
@@ -655,35 +801,37 @@ class DefaultAgent {
655
801
  endTime: failedAt,
656
802
  };
657
803
  }
804
+ const callCtx = { ...toolCtx, callId };
658
805
  // Hook: agent:tool:before — 插件可以拦截或修改工具调用
659
- const toolBeforeData = { name: toolCall.function.name, args, toolCallContext: toolCtx };
806
+ const toolBeforeData = { name: toolCall.function.name, args: parsed.args, toolCallContext: callCtx };
660
807
  // 单个工具的链路异常(钩子 / 守卫 / 结果处理抛错)只算该工具失败,转成它自己的错误结果:
661
808
  // 整批照常落定、整组落库,兄弟工具已发生的副作用不会因连坐而从历史里消失。
662
809
  // executeDone 区分失败发生在执行前还是执行后——后者须明说「已执行」,免得模型重做有副作用的操作。
663
810
  let executeDone = false;
664
811
  let toolT0;
665
812
  try {
666
- await this.caps.hooks.run('agent:tool:before', toolBeforeData);
813
+ await this.caps.hooks.use.run('agent:tool:before', toolBeforeData);
667
814
  // 通知平台:工具开始执行
668
- await this.caps.events.emit('tool:execute', {
669
- sessionId: incoming.sessionId,
670
- platform: incoming.platform,
671
- toolName: toolBeforeData.name,
672
- args: toolBeforeData.args,
673
- phase: 'start',
674
- });
675
- // 紧随 start 赋值:catch 据此判断 start 已发出、要补发配对的 end
815
+ await announce('start', toolBeforeData.name, toolBeforeData.args);
816
+ // 紧随 start 赋值:catch 据此判断 start 已发出
676
817
  toolT0 = Date.now();
677
818
  this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${formatToolArgsForLog(toolBeforeData.args)}`);
678
- const executed = await (this.caps.tools.current?.execute(toolBeforeData.name, toolBeforeData.args, toolCtx) ?? Promise.resolve({ content: JSON.stringify({ error: 'tools 服务不可用' }) }));
819
+ const executed = await (this.caps.tools.present
820
+ ? this.caps.tools.use.execute(toolBeforeData.name, toolBeforeData.args, callCtx)
821
+ : Promise.resolve({ content: JSON.stringify({ error: 'tools 服务不可用' }) }));
679
822
  executeDone = true;
680
823
  let result = executed.content;
681
824
  // 工具交给主模型看的图:只随本回合的 tool 消息走(出口由 prepareLLMMessages 编码),
682
825
  // 不进钩子/事件/时间线(那些面都是文本),也不落库(见 saveToolCallGroup)。
683
826
  const resultImages = executed.images;
684
- // Hook: agent:tool:after — 插件可以处理工具执行结果
685
- const toolAfterData = { name: toolBeforeData.name, result, toolCallContext: toolCtx };
686
- await this.caps.hooks.run('agent:tool:after', toolAfterData);
827
+ // Hook: agent:tool:after — 插件可以处理工具执行结果,写进 metadata 的随结果落库
828
+ const toolAfterData = {
829
+ name: toolBeforeData.name,
830
+ result,
831
+ toolCallContext: callCtx,
832
+ metadata: {},
833
+ };
834
+ await this.caps.hooks.use.run('agent:tool:after', toolAfterData);
687
835
  result = toolAfterData.result;
688
836
  // 工具结果截断:按上下文窗口比例限制单条工具结果长度
689
837
  if (result.length > toolResultMaxChars) {
@@ -693,18 +841,13 @@ class DefaultAgent {
693
841
  const toolEndTime = Date.now();
694
842
  this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
695
843
  // 通知平台:工具执行完成
696
- await this.caps.events.emit('tool:execute', {
697
- sessionId: incoming.sessionId,
698
- platform: incoming.platform,
699
- toolName: toolBeforeData.name,
700
- args: toolBeforeData.args,
701
- phase: 'end',
702
- result,
703
- });
844
+ await announce('end', toolBeforeData.name, toolBeforeData.args, result);
704
845
  return {
705
846
  toolCall,
847
+ callId,
706
848
  result,
707
849
  resultImages,
850
+ resultMetadata: toolAfterData.metadata,
708
851
  toolName: toolBeforeData.name,
709
852
  toolArgs: toolBeforeData.args,
710
853
  startTime: toolT0,
@@ -719,19 +862,13 @@ class DefaultAgent {
719
862
  const result = JSON.stringify({
720
863
  error: executeDone ? `工具已执行,但结果处理失败:${reason}` : `工具调用失败:${reason}`,
721
864
  });
722
- // start 已发出就补发配对的 end(emit 恒 resolve):否则 WebUI 流式缓冲里这一段一直显示执行中
723
- if (toolT0 !== undefined) {
724
- await this.caps.events.emit('tool:execute', {
725
- sessionId: incoming.sessionId,
726
- platform: incoming.platform,
727
- toolName: toolBeforeData.name,
728
- args: toolBeforeData.args,
729
- phase: 'end',
730
- result,
731
- });
732
- }
865
+ // 执行前就失败的补发开始,再发配对的结束(emit 恒 resolve)
866
+ if (toolT0 === undefined)
867
+ await announce('start', toolBeforeData.name, toolBeforeData.args);
868
+ await announce('end', toolBeforeData.name, toolBeforeData.args, result);
733
869
  return {
734
870
  toolCall,
871
+ callId,
735
872
  result,
736
873
  resultImages: undefined,
737
874
  toolName: toolBeforeData.name,
@@ -751,14 +888,20 @@ class DefaultAgent {
751
888
  }
752
889
  }
753
890
  // 按原始 toolCalls 顺序将结果推入消息列表 + 时间线
754
- for (const { toolCall, result, resultImages, toolName, toolArgs, startTime, endTime } of parallelResults) {
891
+ for (const { toolCall, callId, result, resultImages, resultMetadata, toolName, toolArgs, startTime, endTime, } of parallelResults) {
755
892
  const resultPreview = result.length > 200 ? `${result.slice(0, 200)}...` : result;
756
893
  toolCallSummaries.push(`[${toolCall.function.name}] ${resultPreview}`);
894
+ // callId 随工具结果与时间线落库:没回复就收尾的一轮只落工具调用组,历史里的工具卡靠它对回这次调用
757
895
  const toolMessage = {
758
896
  role: 'tool',
759
897
  content: result,
760
898
  toolCallId: toolCall.id,
761
899
  ...(resultImages && resultImages.length > 0 ? { images: resultImages } : {}),
900
+ metadata: {
901
+ ...resultMetadata,
902
+ [WellKnownMetadataKeys.TurnId]: turnId,
903
+ [WellKnownMetadataKeys.CallId]: callId,
904
+ },
762
905
  };
763
906
  llmBeforeData.messages.push(toolMessage);
764
907
  toolMessages.push(toolMessage);
@@ -770,8 +913,10 @@ class DefaultAgent {
770
913
  result,
771
914
  startTime,
772
915
  endTime,
916
+ callId,
773
917
  });
774
918
  }
919
+ historyMessages += 1 + toolMessages.length;
775
920
  await this.saveToolCallGroup(incoming.sessionId, assistantToolMessage, toolMessages, turnPersistedTimestamps);
776
921
  // 继续请求 LLM (再次经过 hooks),使用原始完整工具列表而非被上一轮 hooks 修改过的列表
777
922
  const nextLlmData = {
@@ -781,35 +926,34 @@ class DefaultAgent {
781
926
  userId: incoming.userId,
782
927
  platform: incoming.platform,
783
928
  triggerType: incoming.triggerType,
929
+ source: incoming.source,
784
930
  };
785
931
  // 已物化的贡献按全局键跳过;本轮工具调用新注册的贡献(如新激活技能)增量落位
786
932
  await assemblePromptContributions(this.caps, nextLlmData, {
787
933
  buildTimeoutMs: this.promptBuildTimeoutMs,
788
934
  signal,
789
935
  });
790
- await this.caps.hooks.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
936
+ await this.caps.hooks.use.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
791
937
  // 裁剪消息以确保不超过上下文窗口
792
- nextLlmData.messages = this.trimMessages(nextLlmData.messages, tokenBudget);
793
- // 推送 token 使用量统计
794
- this.emitTokenUsage(incoming.sessionId, incoming.platform, nextLlmData.messages, nextLlmData.tools, contextLength, maxTokens, tokenBudget);
938
+ const nextSent = trimForCall(nextLlmData.messages);
939
+ nextLlmData.messages = nextSent.messages;
795
940
  const tN = Date.now();
796
941
  const nextResult = await this.consumeStream(llm, {
797
942
  messages: nextLlmData.messages,
798
943
  tools: nextLlmData.tools.length > 0 ? nextLlmData.tools : undefined,
799
944
  maxTokens,
800
945
  signal,
801
- ...(thinkOverride !== undefined ? { think: thinkOverride } : {}),
802
- }, incoming.sessionId, incoming.platform);
946
+ ...thinking,
947
+ }, turnId, incoming.sessionId, incoming.platform);
948
+ await reportUsage(nextSent, nextLlmData.tools, nextResult);
803
949
  turnSegments.push(...nextResult.segments);
804
950
  response = nextResult;
951
+ generating = nextResult.generating;
805
952
  accUsage(response.usage);
806
953
  this.debugLogResponse(response, Date.now() - tN, iterations);
807
954
  const nextLlmAfterData = { response, messages: nextLlmData.messages };
808
- await this.caps.hooks.run('agent:llm:after', nextLlmAfterData);
955
+ await this.caps.hooks.use.run('agent:llm:after', nextLlmAfterData);
809
956
  response = nextLlmAfterData.response;
810
- if (response.reasoningContent) {
811
- allReasoning.push(response.reasoningContent);
812
- }
813
957
  }
814
958
  // 检测是否因工具调用次数达到上限而退出循环
815
959
  const toolLimitReached = iterations >= maxToolIterations && response.toolCalls != null && response.toolCalls.length > 0;
@@ -829,7 +973,24 @@ class DefaultAgent {
829
973
  triggerType: incoming.triggerType,
830
974
  attempt: 0,
831
975
  };
832
- await this.caps.hooks.run('agent:reply:before', responseData);
976
+ const validateReply = async () => {
977
+ const attemptedTools = reportOnly && !!response.toolCalls?.length;
978
+ if (attemptedTools)
979
+ responseData.content = '';
980
+ await this.caps.hooks.use.run('agent:reply:before', responseData);
981
+ // 误调用工具或静默先给模型一次纠正机会,复用格式重试预算,不能无限尝试或执行工具。
982
+ const replyLinks = reportOnly ? (responseData.content.match(NOTICE_URL) ?? []) : [];
983
+ const missingLink = noticeLinks.some(link => !replyLinks.some(candidate => candidate === link || candidate.replace(/[.!;:]+$/u, '') === link));
984
+ if (reportOnly && (attemptedTools || !responseData.content.trim() || missingLink)) {
985
+ responseData.content = '';
986
+ responseData.maxRetries = Math.max(1, responseData.maxRetries ?? 0);
987
+ responseData.retryRequested = true;
988
+ responseData.retryFeedback =
989
+ `${responseData.retryFeedback ?? ''}\n本轮只需转述已给出的宿主结果,不要调用工具或查询状态。` +
990
+ '请沿用当前人设和规定的回复格式,给出包含结果与已有链接的非空回复。';
991
+ }
992
+ };
993
+ await validateReply();
833
994
  // 重试循环:当 hook(如 persona 的 outputFormat 解析)报告 retryRequested 时,
834
995
  // 把失败的 assistant 输出 + 系统反馈追加到消息列表,重新请求 LLM;最多按 maxRetries 次。
835
996
  // maxRetries 由 hook 端写入(plugin-persona 从 outputFormat.retries 读取,默认 1)。
@@ -845,20 +1006,19 @@ class DefaultAgent {
845
1006
  role: 'system',
846
1007
  content: responseData.retryFeedback ?? '上一次回复未能通过格式校验,请严格按照系统提示中规定的格式重新输出。',
847
1008
  });
848
- const retryTrimmed = this.trimMessages(llmBeforeData.messages, tokenBudget);
1009
+ const retrySent = trimForCall(llmBeforeData.messages);
849
1010
  const retryResult = await this.consumeStream(llm, {
850
- messages: retryTrimmed,
1011
+ messages: retrySent.messages,
851
1012
  tools: undefined,
852
1013
  maxTokens,
853
1014
  signal,
854
- ...(thinkOverride !== undefined ? { think: thinkOverride } : {}),
855
- }, incoming.sessionId, incoming.platform);
1015
+ ...thinking,
1016
+ }, turnId, incoming.sessionId, incoming.platform, !reportOnly);
1017
+ await reportUsage(retrySent, [], retryResult);
856
1018
  turnSegments.push(...retryResult.segments);
857
1019
  response = retryResult;
858
1020
  accUsage(response.usage);
859
1021
  rawLlmContent = response.content ?? '';
860
- if (response.reasoningContent)
861
- allReasoning.push(response.reasoningContent);
862
1022
  // 用新输出再次跑 hook;hook 端根据 attempt 决定继续重试或走兜底(静默丢弃)
863
1023
  responseData.content = rawLlmContent;
864
1024
  responseData.archiveContent = undefined;
@@ -866,7 +1026,7 @@ class DefaultAgent {
866
1026
  responseData.retryRequested = false;
867
1027
  responseData.retryFeedback = undefined;
868
1028
  responseData.attempt = attempt;
869
- await this.caps.hooks.run('agent:reply:before', responseData);
1029
+ await validateReply();
870
1030
  }
871
1031
  // 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
872
1032
  // 强制把 content 置空,避免原始未校验内容被外发。
@@ -891,8 +1051,9 @@ class DefaultAgent {
891
1051
  if (signal.aborted)
892
1052
  throw new DOMException('Generation aborted', 'AbortError');
893
1053
  // 发出流结束标记
894
- await this.caps.events.emit('outbound:stream', {
1054
+ await this.caps.events.use.emit('outbound:stream', {
895
1055
  sessionId: incoming.sessionId,
1056
+ turnId,
896
1057
  platform: incoming.platform,
897
1058
  done: true,
898
1059
  toolLimitReached,
@@ -902,20 +1063,18 @@ class DefaultAgent {
902
1063
  this.logger.debug(`空回复,跳过发送 (session=${incoming.sessionId})`);
903
1064
  }
904
1065
  else {
905
- const combinedReasoning = allReasoning.length > 0 ? allReasoning.join('\n\n---\n\n') : undefined;
1066
+ const visibleSegments = !reportOnly && turnSegments.length > 0 ? turnSegments : undefined;
906
1067
  // 在 assistant 最终持久化时记录 modelInfo:前端从消息历史可还原 "这条回复
907
- // 由哪个 model 生成、用了多少 token、耗时多久",方便用户验证模型切换是否生效。
1068
+ // 由哪个 model 生成、用了多少 token",方便用户验证模型切换是否生效。
908
1069
  const turnModelInfo = {
909
1070
  provider: llm.providerId,
910
1071
  model: llm.id,
911
1072
  promptTokens: turnUsageAcc.promptTokens || undefined,
912
1073
  completionTokens: turnUsageAcc.completionTokens || undefined,
913
- totalTokens: turnUsageAcc.totalTokens || undefined,
914
- elapsedMs: Date.now() - turnStartTs,
915
1074
  };
916
1075
  const finalAssistantMetadata = {
917
1076
  ...(assistantMetadata ?? {}),
918
- modelInfo: turnModelInfo,
1077
+ [WellKnownMetadataKeys.ModelInfo]: turnModelInfo,
919
1078
  };
920
1079
  // 落库的是信封(如 outputFormat 的整串 JSON)时,另带解码后的可见正文,供检索、摘要等按语义读历史的一方用
921
1080
  if (visibleContent !== archiveContent) {
@@ -926,29 +1085,33 @@ class DefaultAgent {
926
1085
  await this.saveToMemory(incoming.sessionId, {
927
1086
  role: 'assistant',
928
1087
  content: archiveContent,
929
- reasoningContent: response.reasoningContent,
1088
+ reasoningContent: reportOnly ? undefined : response.reasoningContent,
930
1089
  timestamp: Date.now(),
931
1090
  metadata: finalAssistantMetadata,
932
- segments: turnSegments.length > 0 ? turnSegments : undefined,
1091
+ segments: visibleSegments,
933
1092
  });
934
- // 发送给流式客户端时使用合并版本(统一时间线 segments 同时给出,前端按到达顺序渲染)
1093
+ // 外发给平台:只带正文与这条回复的模型和 token,思考与时间线只在上面落库的消息上
935
1094
  await this.dispatchOutbound({
936
1095
  content: replyContent,
937
1096
  sessionId: incoming.sessionId,
938
1097
  platform: incoming.platform,
939
- reasoningContent: combinedReasoning,
1098
+ ...(incoming.hostNotice
1099
+ ? { hostNotice: { kind: incoming.hostNotice.kind, id: incoming.hostNotice.id } }
1100
+ : {}),
1101
+ ...(incoming.hostNotice?.replyTo ? { replyTo: incoming.hostNotice.replyTo } : {}),
940
1102
  source: 'agent',
941
- segments: turnSegments.length > 0 ? turnSegments : undefined,
1103
+ turnId,
942
1104
  modelInfo: turnModelInfo,
943
1105
  });
944
1106
  }
945
1107
  // Hook: agent:turn:after — 插件可以在完整消息周期结束后做后处理
946
1108
  const turnOutcome = replyContent.trim().length === 0 ? 'silent' : 'replied';
947
- await this.caps.hooks.run('agent:turn:after', {
1109
+ await this.caps.hooks.use.run('agent:turn:after', {
948
1110
  message: incoming,
949
1111
  reply: replyContent,
950
1112
  outcome: turnOutcome,
951
1113
  sessionId: incoming.sessionId,
1114
+ turnId,
952
1115
  metadata: msgHookData.metadata,
953
1116
  });
954
1117
  }
@@ -970,84 +1133,94 @@ class DefaultAgent {
970
1133
  (turnPersistedTimestamps.length > 0
971
1134
  ? `(保留本轮已完成的 ${turnPersistedTimestamps.length} 条工具调用记录,便于下一轮 agent 感知)`
972
1135
  : ''));
973
- await this.caps.events.emit('outbound:stream', {
1136
+ await this.caps.events.use.emit('outbound:stream', {
974
1137
  sessionId: incoming.sessionId,
1138
+ turnId,
975
1139
  platform: incoming.platform,
976
1140
  done: true,
977
1141
  });
978
1142
  // 中止同样是回合终态:发 agent:turn:after(outcome=aborted) 让生命周期订阅方收尾——
979
- // session-manager 把会话状态从 active 收口为 completed(否则永远停在"进行中"),
980
- // checkpoint 关闭当前回合(否则中止后回合不关闭、长期泄漏)。
1143
+ // 编排的 agent 节点在等这一轮的结果,不发它就要等到超时才失败。
981
1144
  // 文档与 agent-api 早已声明 outcome 含 aborted,此处兑现契约。
982
- await this.caps.hooks.run('agent:turn:after', {
1145
+ await this.caps.hooks.use.run('agent:turn:after', {
983
1146
  message: incoming,
984
1147
  reply: '',
985
1148
  outcome: 'aborted',
986
1149
  sessionId: incoming.sessionId,
1150
+ turnId,
987
1151
  metadata: msgHookData.metadata,
988
1152
  });
989
1153
  return;
990
1154
  }
991
1155
  const message = err instanceof Error ? err.message : String(err);
992
1156
  this.logger.error(`处理消息失败: ${message}`);
993
- // 异常同样要结束流:不发 done,CLI 的流式块不收尾、WebUI 的工具进度与上限标记不复位,
1157
+ // 异常同样要结束流:不发 done,CLI 的流式块不收尾、会话里流式输出的条目不结束,
994
1158
  // 下一轮 delta 还会抹掉这条 [错误] 与用户的新输入。顺序与正常收尾一致——先 done 再发消息。
995
- await this.caps.events.emit('outbound:stream', {
1159
+ await this.caps.events.use.emit('outbound:stream', {
996
1160
  sessionId: incoming.sessionId,
1161
+ turnId,
997
1162
  platform: incoming.platform,
998
1163
  done: true,
999
1164
  });
1000
- await this.dispatchOutbound({
1001
- content: `[错误] ${message}`,
1002
- sessionId: incoming.sessionId,
1003
- platform: incoming.platform,
1004
- source: 'system',
1005
- });
1006
- // 异常也是回合终态:同样发 turn:after(outcome=error) 让 checkpoint 关闭回合、
1007
- // session-manager 收口状态。dispatchOutbound 已发系统错误消息,状态可被 outbound:message
1008
- // 与本钩子双路径幂等收口。
1009
- await this.caps.hooks.run('agent:turn:after', {
1165
+ if (!reportOnly) {
1166
+ await this.dispatchOutbound({
1167
+ content: `[错误] ${message}`,
1168
+ sessionId: incoming.sessionId,
1169
+ platform: incoming.platform,
1170
+ source: 'system',
1171
+ });
1172
+ }
1173
+ // 异常也是回合终态:同样发 turn:after(outcome=error),等这一轮结果的订阅方才收得了尾。
1174
+ await this.caps.hooks.use.run('agent:turn:after', {
1010
1175
  message: incoming,
1011
1176
  reply: '',
1012
1177
  outcome: 'error',
1178
+ error: message,
1013
1179
  sessionId: incoming.sessionId,
1180
+ turnId,
1014
1181
  metadata: msgHookData.metadata,
1015
1182
  });
1016
1183
  }
1017
1184
  });
1018
1185
  // 消息被拦截(如流控缓冲),通知前端结束 loading
1019
1186
  if (!handled) {
1020
- await this.caps.events.emit('outbound:stream', {
1187
+ await this.caps.events.use.emit('outbound:stream', {
1021
1188
  sessionId: incoming.sessionId,
1189
+ turnId,
1022
1190
  platform: incoming.platform,
1023
1191
  done: true,
1024
1192
  });
1025
1193
  }
1026
1194
  }
1027
1195
  /**
1028
- * 构建发送给 LLM 的消息列表
1196
+ * 构建发送给 LLM 的消息列表。历史按会话的条数线读:historyLimit 为 0 或没设置时读全部活跃历史。
1197
+ * historyMessages 是从记忆读进的条数(含已归档的这一轮入站消息),与之后的清洗、裁剪无关
1029
1198
  */
1030
- async buildMessages(incoming, personaOpts, archivedIncoming) {
1199
+ async buildMessages(incoming, historyLimit, personaOpts, archivedIncoming) {
1031
1200
  const messages = [];
1201
+ let historyMessages = 0;
1032
1202
  // 1. 系统提示
1033
1203
  const systemPrompt = this.buildSystemPrompt(personaOpts);
1034
1204
  messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
1035
1205
  // 2. 历史消息
1036
- const memory = this.caps.memory.current;
1206
+ const memory = this.caps.memory.present ? this.caps.memory.use : undefined;
1037
1207
  if (memory) {
1038
1208
  try {
1039
- const history = this.sanitizeToolCallHistory(await memory.getHistory(incoming.sessionId, this.historyLimit), incoming.sessionId);
1209
+ const stored = await memory.getHistory(incoming.sessionId, historyLimit || Infinity);
1210
+ historyMessages = stored.length;
1211
+ const history = this.sanitizeToolCallHistory(stored, incoming.sessionId);
1040
1212
  const now = Date.now();
1041
1213
  for (const m of history) {
1042
1214
  if (archivedIncoming && this.isSameMessage(m, archivedIncoming))
1043
1215
  continue;
1044
1216
  if (CONTROL_KINDS.includes(m.kind ?? ''))
1045
1217
  continue;
1046
- // 为用户消息注入时间标注,帮助 LLM 理解时间先后
1218
+ // 为用户消息注入时间标注,帮助 LLM 理解时间先后。标注加在副本上:记忆服务可能直接交出存着的消息对象
1047
1219
  if (m.role === 'user' && m.timestamp && m.content) {
1048
1220
  const timeLabel = formatTimeLabel(m.timestamp, now);
1049
1221
  if (timeLabel && !m.content.startsWith(`(${timeLabel})`)) {
1050
- m.content = `(${timeLabel}) ${m.content}`;
1222
+ messages.push({ ...m, content: `(${timeLabel}) ${m.content}` });
1223
+ continue;
1051
1224
  }
1052
1225
  }
1053
1226
  messages.push(m);
@@ -1067,34 +1240,33 @@ class DefaultAgent {
1067
1240
  const archivedBody = archivedIncoming?.content;
1068
1241
  const fallbackBody = senderLabel ? `[${senderLabel}]: ${incoming.content}` : incoming.content;
1069
1242
  const currentContent = `(${nowLabel}) ${archivedBody ?? fallbackBody}`;
1070
- // 3a. proactive 委派分支:跨会话由「同一 agent 在另一会话的实例」派发过来的任务
1071
- //
1072
- // 这条消息不是用户请求,必须作为 system 指令呈现给 LLM,否则 B 会把它当成
1073
- // 真实用户在指挥(典型 BUG:源会话 agent 决定派发,目标会话 agent 看到 user
1074
- // 角色的消息,回复时使用「您」「您好」等措辞,把 agent 当成了用户)。
1243
+ // 宿主通知分支:宿主撰写的事件通知不是任何人的发言,与 proactive 一样以 system 呈现、
1244
+ // 不推当前 user 消息与易变块。不可信段只在当轮接在宿主正文之后(归档只留正文),
1245
+ // 之后的回合从历史里只看得到宿主正文。
1246
+ if (incoming.hostNotice) {
1247
+ const { untrusted } = incoming.hostNotice;
1248
+ messages.push({
1249
+ role: 'system',
1250
+ content: `[宿主通知]\n${incoming.content}${untrusted ? `\n\n${untrusted}` : ''}`,
1251
+ metadata: { injector: WellKnownKinds.HostNotice },
1252
+ });
1253
+ return { messages, historyMessages };
1254
+ }
1255
+ // 3a. proactive 分支:系统代发给本会话的任务指令(如 workflow 的 agent 节点)
1075
1256
  //
1076
- // 同时附上源会话 ID,并明确告诉 B:如需了解源会话上下文,按需调用
1077
- // session_get_history(sessionId="<源>") —— 把"是否需要上下文"的决策权
1078
- // 交给 B 的 LLM,避免无差别拼接源历史造成 token 浪费。
1257
+ // 这条消息不是用户请求,必须作为 system 指令呈现给 LLM,否则 agent 会把它当成
1258
+ // 真实用户在指挥(典型 BUG:agent 看到 user 角色的任务指令,回复时使用「您」
1259
+ // 「您好」等措辞,把派发方当成了用户)。
1079
1260
  if (incoming.triggerType === 'proactive') {
1080
- const sourceMatch = incoming.source?.match(/^proactive:from:(.+)$/);
1081
- const sourceSessionId = sourceMatch?.[1];
1082
- const sourceLine = sourceSessionId ? `源会话 ID: ${sourceSessionId}\n` : '';
1083
- // 不在 hint 里写死 limit,让 LLM 按 plugin-tool-session 的 defaultLimit / 自身判断决定
1084
- const hintLine = sourceSessionId
1085
- ? `如需了解源会话上下文(例如「按之前讨论的方案」之类的引用),调用 \`session_get_history(sessionId="${sourceSessionId}")\` 自行查阅(可按需附加 limit)。\n`
1086
- : '';
1087
1261
  messages.push({
1088
1262
  role: 'system',
1089
1263
  content: `[跨会话委派 — 非用户消息]\n` +
1090
- sourceLine +
1091
1264
  `任务: ${incoming.content}\n\n` +
1092
1265
  `说明: 这是你(作为同一 agent 在另一会话的实例)派发给本会话的任务指令,` +
1093
- `不是用户请求。处理时不要使用「您」「请问」等面向用户的措辞,按指令直接执行并简明回报结果。\n` +
1094
- hintLine,
1095
- metadata: { injector: WellKnownKinds.CrossSessionDelegation, sourceSessionId },
1266
+ `不是用户请求。处理时不要使用「您」「请问」等面向用户的措辞,按指令直接执行并简明回报结果。\n`,
1267
+ metadata: { injector: WellKnownKinds.CrossSessionDelegation },
1096
1268
  });
1097
- return messages;
1269
+ return { messages, historyMessages };
1098
1270
  }
1099
1271
  // 3b. 普通用户消息分支
1100
1272
  // 检测预处理附件内容——引导 LLM 综合分析而非逐项转述
@@ -1117,7 +1289,7 @@ class DefaultAgent {
1117
1289
  // 多模态:把 attachments 中的 image 项传递给 LLM(视觉模型多模态字段)
1118
1290
  const imageAtts = incoming.attachments?.filter(a => a.kind === 'image') ?? [];
1119
1291
  if (imageAtts.length > 0) {
1120
- if (this.caps.media.current !== undefined) {
1292
+ if (this.caps.media.present) {
1121
1293
  // media 在场:原样透传。出口形态由 media 的 agent:llm:before 中间件规范化
1122
1294
  //(describe 剥离 / passthrough 物化),此处不做任何转换——它的描述缓存键与
1123
1295
  // 动图提示都按 attachment.data 原串命中,改形态会打散。
@@ -1147,7 +1319,7 @@ class DefaultAgent {
1147
1319
  // 秒级时间戳一变,人设 + 记忆 + 历史近 7 万 token 的前缀全部作废——实测每个
1148
1320
  // 新回合命中率恒为 0,只有同回合内的工具迭代(prompt 不重建)才命中。
1149
1321
  // 放到这里语义也更顺:它们本就是「此刻的事实」,紧挨当前消息说更自然。
1150
- const persona = this.caps.persona.current;
1322
+ const persona = this.caps.persona.present ? this.caps.persona.use : undefined;
1151
1323
  const volatileCtx = persona?.getVolatilePrompt?.(personaOpts);
1152
1324
  if (volatileCtx) {
1153
1325
  messages.push({ role: 'system', content: volatileCtx, metadata: { injector: VOLATILE_INJECTOR } });
@@ -1158,7 +1330,7 @@ class DefaultAgent {
1158
1330
  if (focusGuidance)
1159
1331
  messages.push(focusGuidance);
1160
1332
  messages.push(userMessage);
1161
- return messages;
1333
+ return { messages, historyMessages };
1162
1334
  }
1163
1335
  /**
1164
1336
  * 裁剪预算 = 上下文长度 × trimThresholdRatio − 最大输出 token − 512 安全余量(下限 1024)。
@@ -1172,7 +1344,7 @@ class DefaultAgent {
1172
1344
  * 构建系统提示词
1173
1345
  */
1174
1346
  buildSystemPrompt(personaOpts) {
1175
- const persona = this.caps.persona.current;
1347
+ const persona = this.caps.persona.present ? this.caps.persona.use : undefined;
1176
1348
  const base = persona
1177
1349
  ? this.systemPrompt
1178
1350
  ? `${persona.getSystemPrompt(personaOpts)}\n\n${this.systemPrompt}`
@@ -1187,11 +1359,10 @@ class DefaultAgent {
1187
1359
  return estimateTokens(messages);
1188
1360
  }
1189
1361
  /**
1190
- * 推送 token 使用量统计事件
1191
- *
1192
- * 包含各维度的 token 使用分解,供前端展示和自动压缩判断。
1362
+ * 发 token:usage:各维度的拆分按消息与工具定义估算,used 取模型服务返回的输入 token,没有时取估算。
1363
+ * 等全部监听者处理完才返回:摘要插件在回合结束时按这一轮最后一条用量判定,不能让它晚于回合结束
1193
1364
  */
1194
- emitTokenUsage(sessionId, platform, messages, tools, contextLength, maxTokens, tokenBudget) {
1365
+ async emitTokenUsage({ sessionId, platform, turnId, contextWindow, maxTokens, tokenBudget, messages, tools, trimmed, untrimmedHistory, historyMessages, promptTokens, }) {
1195
1366
  // 按来源分类统计各消息的 token 占用
1196
1367
  let historyTokens = 0;
1197
1368
  let toolResultTokens = 0;
@@ -1250,11 +1421,11 @@ class DefaultAgent {
1250
1421
  personaTokens += t;
1251
1422
  }
1252
1423
  }
1253
- else {
1424
+ else if (rawSource) {
1254
1425
  // 其余注入者(memory-history/user-profile/user-relation/自定义插件等):
1255
- // 计入 systemOther 汇总之余按标签点名,预算争抢时能看出是谁在吃。
1426
+ // 计入 systemOther 汇总之余按全局键点名,预算争抢时能看出是谁在吃;界面按键里的实例前缀合并到插件
1256
1427
  systemOtherTokens += t;
1257
- injectorTokens[source] = (injectorTokens[source] ?? 0) + t;
1428
+ injectorTokens[rawSource] = (injectorTokens[rawSource] ?? 0) + t;
1258
1429
  }
1259
1430
  }
1260
1431
  else if (msg.role === 'tool') {
@@ -1273,17 +1444,23 @@ class DefaultAgent {
1273
1444
  platformTokens +
1274
1445
  subtaskTokens +
1275
1446
  systemOtherTokens;
1276
- const totalUsed = systemTokens + historyTokens + toolResultTokens + toolDefsTokens;
1277
- const usageRatio = contextLength > 0 ? totalUsed / contextLength : 0;
1278
- this.caps.events
1279
- .emit('token:usage', {
1447
+ // 服务商回的数没人核过:不是正的有限数(缺省、0、NaN)时按估算,一次调用的输入不会是 0 token
1448
+ const used = promptTokens !== undefined && promptTokens > 0 && Number.isFinite(promptTokens)
1449
+ ? promptTokens
1450
+ : systemTokens + historyTokens + toolResultTokens + toolDefsTokens;
1451
+ const usageRatio = contextWindow > 0 ? used / contextWindow : 0;
1452
+ await this.caps.events.use.emit('token:usage', {
1280
1453
  sessionId,
1454
+ turnId,
1281
1455
  platform,
1282
- contextWindow: contextLength,
1456
+ contextWindow,
1283
1457
  maxTokens,
1284
1458
  tokenBudget,
1285
- used: totalUsed,
1459
+ used,
1286
1460
  usageRatio,
1461
+ trimmed,
1462
+ untrimmedHistory,
1463
+ historyMessages,
1287
1464
  breakdown: {
1288
1465
  system: systemTokens,
1289
1466
  persona: personaTokens,
@@ -1299,8 +1476,19 @@ class DefaultAgent {
1299
1476
  toolDefs: toolDefsTokens,
1300
1477
  reservedForReply: maxTokens,
1301
1478
  },
1302
- })
1303
- .catch(() => { });
1479
+ parts: [
1480
+ { label: '历史消息', tokens: historyTokens },
1481
+ { label: '工具结果', tokens: toolResultTokens },
1482
+ { label: '工具定义', tokens: toolDefsTokens },
1483
+ { label: '人设', tokens: personaTokens },
1484
+ { label: '对话摘要', tokens: memorySummaryTokens },
1485
+ { label: '向量记忆', tokens: memoryVectorTokens },
1486
+ { label: '技能', tokens: skillsTokens },
1487
+ { label: '平台提示', tokens: platformTokens },
1488
+ { label: '子任务', tokens: subtaskTokens },
1489
+ { label: '其余系统提示', tokens: systemOtherTokens, injectors: injectorTokens },
1490
+ ],
1491
+ });
1304
1492
  // 节流日志:与 WebUI 看到的同一份数据,让 CLI / 文件日志使用者也能看见预算消耗。
1305
1493
  // 跨过 0.5 / 0.7 / 0.85 阈值必输出,否则每 10 轮一次。
1306
1494
  const bucket = usageRatio >= 0.85 ? 3 : usageRatio >= 0.7 ? 2 : usageRatio >= 0.5 ? 1 : 0;
@@ -1309,7 +1497,7 @@ class DefaultAgent {
1309
1497
  const crossedBucket = bucket !== st.lastRatioBucket;
1310
1498
  if (crossedBucket || st.count % 10 === 1) {
1311
1499
  const tag = bucket >= 3 ? 'CRITICAL' : bucket >= 2 ? 'WARN' : bucket >= 1 ? 'INFO' : 'OK';
1312
- this.logger.info(`[token-usage:${tag}] ${sessionId} ${totalUsed}/${contextLength} (${(usageRatio * 100).toFixed(1)}%) ` +
1500
+ this.logger.info(`[token-usage:${tag}] ${sessionId} ${used}/${contextWindow} (${(usageRatio * 100).toFixed(1)}%) ` +
1313
1501
  `sys=${systemTokens}(persona=${personaTokens} mem=${memorySummaryTokens + memoryVectorTokens} ` +
1314
1502
  `skills=${skillsTokens} subtask=${subtaskTokens} other=${systemOtherTokens}) ` +
1315
1503
  `hist=${historyTokens} tools=${toolResultTokens}+${toolDefsTokens}def reserve=${maxTokens}`);
@@ -1330,9 +1518,8 @@ class DefaultAgent {
1330
1518
  const rel = t.match(/^data\/((?:images|videos|audios|files)\/.+)$/);
1331
1519
  if (!rel)
1332
1520
  return t;
1333
- // 经网关按 URI 路由:storage 按根逐条提供,胜者只是其中一个根(多根时通常是 workspace),
1334
- // 读不了 data:/。缺 data 根(含 storage 整体缺席)时网关抛错,落到下面的 catch
1335
- const storage = createStorageGateway(this.caps.storage);
1521
+ // storage 按 URI 的根名路由;缺 data 根(含 storage 整体缺席)时抛错,落到下面的 catch
1522
+ const storage = this.caps.storage.use;
1336
1523
  try {
1337
1524
  const raw = (await storage.readFile(`data:/${rel[1]}`));
1338
1525
  // mime 优先取附件自带的 mimeType;缺省按 basename 扩展名推断(适配器落盘只产
@@ -1349,7 +1536,8 @@ class DefaultAgent {
1349
1536
  }
1350
1537
  }
1351
1538
  /**
1352
- * 裁剪消息列表,使总 token 数不超过预算
1539
+ * 裁剪消息列表,使总 token 数不超过预算;trimmed 报告入口估算超过了预算、进入了硬裁剪(不一定真裁掉了内容)。
1540
+ * 不记日志:token:request 的快照同样走这里,删了消息的日志由真实回合的调用点记
1353
1541
  *
1354
1542
  * 渐进式压缩策略(保证 agent 在长工具链中无限运行):
1355
1543
  * 1. 首条 system(主提示词)和末条消息永不删除
@@ -1363,7 +1551,7 @@ class DefaultAgent {
1363
1551
  const result = messages.map(m => ({ ...m }));
1364
1552
  let estimated = this.estimateTokens(result);
1365
1553
  if (estimated <= budget)
1366
- return result;
1554
+ return { messages: result, trimmed: false };
1367
1555
  /** 重新扫描 system 消息索引(首条和末条之间) */
1368
1556
  const findSystemIndices = () => {
1369
1557
  const indices = [];
@@ -1391,7 +1579,7 @@ class DefaultAgent {
1391
1579
  }
1392
1580
  }
1393
1581
  if (estimated <= budget)
1394
- return result;
1582
+ return { messages: result, trimmed: true };
1395
1583
  // === Phase 2: 截断过长的 tool 输出 ===
1396
1584
  for (let i = 1; i < result.length - 1; i++) {
1397
1585
  if (estimated <= budget)
@@ -1403,7 +1591,7 @@ class DefaultAgent {
1403
1591
  }
1404
1592
  }
1405
1593
  if (estimated <= budget)
1406
- return result;
1594
+ return { messages: result, trimmed: true };
1407
1595
  // === Phase 2.5: 缩减 assistant 消息的 reasoningContent ===
1408
1596
  // 深度思考模型的推理内容可能非常长(数万 token),优先缩减旧迭代的推理,
1409
1597
  // 仍超预算时缩减最新一条(保留头尾摘要以保持上下文连贯性)
@@ -1429,7 +1617,7 @@ class DefaultAgent {
1429
1617
  }
1430
1618
  }
1431
1619
  if (estimated <= budget)
1432
- return result;
1620
+ return { messages: result, trimmed: true };
1433
1621
  // === Phase 3: 将旧的 assistant+tool 组压缩为摘要 ===
1434
1622
  // 识别所有工具调用组 (assistant(toolCalls) + 紧跟的 tool 消息)
1435
1623
  {
@@ -1476,12 +1664,8 @@ class DefaultAgent {
1476
1664
  }
1477
1665
  }
1478
1666
  }
1479
- if (estimated <= budget) {
1480
- if (result.length < messages.length) {
1481
- this.logger.info(`上下文压缩: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
1482
- }
1483
- return result;
1484
- }
1667
+ if (estimated <= budget)
1668
+ return { messages: result, trimmed: true };
1485
1669
  // === Phase 4: 删除最旧的非 system 消息(保护最后一组工具调用 + 最新用户消息) ===
1486
1670
  {
1487
1671
  // 识别最后一组工具调用的索引范围,确保不被删除
@@ -1540,10 +1724,8 @@ class DefaultAgent {
1540
1724
  adjustAfterSplice(i);
1541
1725
  }
1542
1726
  }
1543
- if (estimated <= budget) {
1544
- this.logger.info(`上下文截断: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
1545
- return result;
1546
- }
1727
+ if (estimated <= budget)
1728
+ return { messages: result, trimmed: true };
1547
1729
  // === Phase 5: 极端情况 — 删除 hook 注入的 system 消息 ===
1548
1730
  // 从最靠前的块开始删。turn-context 迁移后头部只剩技能正文/会话摘要、
1549
1731
  // 档案/记忆类材料都在尾部——正序扫描意味着极端裁剪时**先牺牲头部稳定块**、
@@ -1577,15 +1759,11 @@ class DefaultAgent {
1577
1759
  }
1578
1760
  }
1579
1761
  result.splice(insertIdx, 0, hint);
1580
- estimated += estimateMsgTokens(hint);
1581
1762
  }
1582
- if (result.length < messages.length) {
1583
- this.logger.info(`上下文截断: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
1584
- }
1585
- return result;
1763
+ return { messages: result, trimmed: true };
1586
1764
  }
1587
1765
  getArchiveForWrite() {
1588
- const archive = this.caps.messageArchive.current;
1766
+ const archive = this.caps.messageArchive.present ? this.caps.messageArchive.use : undefined;
1589
1767
  if (!archive && !this.archiveUnavailableWarned) {
1590
1768
  this.archiveUnavailableWarned = true;
1591
1769
  this.logger.warn('message-archive 服务不可用,缺席期间的新消息不会写入记忆,对话继续。请加载 @aalis/plugin-message-archive 及其 memory 依赖。');
@@ -1604,22 +1782,24 @@ class DefaultAgent {
1604
1782
  }
1605
1783
  }
1606
1784
  }
1607
- buildAssistantMetadata(incoming) {
1785
+ /** 这一轮落库的 assistant 消息的元数据;带 turnId,会话的客户端据此把存档归回它所在的回合 */
1786
+ buildAssistantMetadata(incoming, turnId) {
1608
1787
  const identity = getPlatformSelfIdentity(this.caps.platform, incoming.platform, incoming.sessionId);
1609
1788
  const metadata = {
1610
- platform: incoming.platform,
1789
+ [WellKnownMetadataKeys.Platform]: incoming.platform,
1611
1790
  senderType: 'assistant',
1791
+ [WellKnownMetadataKeys.TurnId]: turnId,
1612
1792
  };
1613
1793
  if (identity?.selfId)
1614
- metadata.userId = identity.selfId;
1794
+ metadata[WellKnownMetadataKeys.UserId] = identity.selfId;
1615
1795
  if (identity?.nickname)
1616
- metadata.nickname = identity.nickname;
1796
+ metadata[WellKnownMetadataKeys.Nickname] = identity.nickname;
1617
1797
  if (incoming.groupId)
1618
- metadata.groupId = incoming.groupId;
1798
+ metadata[WellKnownMetadataKeys.GroupId] = incoming.groupId;
1619
1799
  if (incoming.groupName)
1620
- metadata.groupName = incoming.groupName;
1800
+ metadata[WellKnownMetadataKeys.GroupName] = incoming.groupName;
1621
1801
  if (incoming.sessionType)
1622
- metadata.sessionType = incoming.sessionType;
1802
+ metadata[WellKnownMetadataKeys.SessionType] = incoming.sessionType;
1623
1803
  return Object.keys(metadata).length > 0 ? metadata : undefined;
1624
1804
  }
1625
1805
  async saveToolCallGroup(sessionId, assistantMessage, toolMessages, persistedTimestamps) {
@@ -1719,22 +1899,17 @@ class DefaultAgent {
1719
1899
  * 生产部署务必确保 plugin-gateway 已加载,否则中间件链会被跳过。
1720
1900
  */
1721
1901
  async dispatchOutbound(message) {
1722
- const gateway = this.caps.gateway.current;
1902
+ const gateway = this.caps.gateway.present ? this.caps.gateway.use : undefined;
1723
1903
  if (gateway) {
1724
1904
  await gateway.dispatchOutbound(message);
1725
1905
  return;
1726
1906
  }
1727
1907
  this.logger.warn('Gateway 服务不可用,回退至 outbound:message 事件(中间件链被跳过)');
1728
- await this.caps.events.emit('outbound:message', message);
1908
+ await this.caps.events.use.emit('outbound:message', message);
1729
1909
  }
1730
1910
  }
1731
1911
  // ----- 插件导出 -----
1732
1912
  const configSchema = defineConfig({
1733
- defaultLLM: {
1734
- type: 'llm-ref',
1735
- label: '默认对话模型',
1736
- description: '全局默认 LLM。apply() 时经 services.prefer 锁定 llm 服务的偏好提供者。会话 / 平台 profile 未覆盖时生效。',
1737
- },
1738
1913
  systemPrompt: {
1739
1914
  type: 'textarea',
1740
1915
  label: '行为准则提示词',
@@ -1745,20 +1920,9 @@ const configSchema = defineConfig({
1745
1920
  type: 'number',
1746
1921
  label: '长期记忆预留 Token',
1747
1922
  default: 4096,
1923
+ secret: false,
1748
1924
  description: '为注入的 system 消息预留的 token 额度:超出时先按比例缩减,极端情况下(最后阶段)仍会删除',
1749
1925
  },
1750
- historyLimit: {
1751
- type: 'number',
1752
- label: '历史消息条数',
1753
- default: 50,
1754
- description: '从记忆中加载的最近对话历史条数',
1755
- },
1756
- maxToolIterations: {
1757
- type: 'number',
1758
- label: '最大工具迭代',
1759
- default: 30,
1760
- description: '工具调用循环的最大迭代次数',
1761
- },
1762
1926
  promptBuildTimeoutMs: {
1763
1927
  type: 'number',
1764
1928
  label: '提示词贡献构建超时 (ms)',
@@ -1775,66 +1939,151 @@ const configSchema = defineConfig({
1775
1939
  type: 'number',
1776
1940
  label: '裁剪触发比例',
1777
1941
  default: 1.0,
1778
- description: '裁剪预算 = 上下文长度 × 该比例 − 最大输出 token − 512 安全余量(下限 1024)。本次调用估算输入 token 超过该预算才会对消息列表做内存裁剪(不影响 DB)。默认 1.0 表示用满扣除输出预留后的可用窗口;调低可提前裁剪。压缩触发请在“@aalis/plugin-memory-summary”中配置。',
1942
+ description: '裁剪预算 = 上下文长度 × 该比例 − 最大输出 token − 512 安全余量(下限 1024)。本次调用估算输入 token 超过该预算才会对消息列表做内存裁剪(不影响 DB)。默认 1.0 表示用满扣除输出预留后的可用窗口;调低可提前裁剪。压缩触发在会话设置里调:自动压缩线(摘要插件登记)与历史条数上限(本插件登记)。',
1779
1943
  },
1780
1944
  });
1945
+ /**
1946
+ * 本插件登记的会话设置项:模型(输入框旁的快捷切换)、深度思考、工具迭代上限与历史条数上限,各种会话都适用。模型的全局
1947
+ * 默认层就是 llm 服务的偏好(会话管理按字段类型承担),界面写服务实际用的那一个
1948
+ */
1949
+ const SESSION_SETTINGS = [
1950
+ {
1951
+ key: 'llm',
1952
+ field: {
1953
+ type: 'llm-ref',
1954
+ label: '模型',
1955
+ description: '没设置时用默认模型:给本插件设了 llm 的服务偏好时用它,否则是全局默认层的「模型」,那里也没设时用第一个可用的对话模型',
1956
+ },
1957
+ appliesTo: ['owner', 'room', 'task'],
1958
+ prominence: 'composer',
1959
+ // 会话选的是一个具体的模型:服务与模型都要给
1960
+ validate: value => {
1961
+ const ref = value;
1962
+ return typeof ref?.provider === 'string' &&
1963
+ ref.provider !== '' &&
1964
+ typeof ref.model === 'string' &&
1965
+ ref.model !== ''
1966
+ ? undefined
1967
+ : '要是 { provider, model },两项都是非空字符串';
1968
+ },
1969
+ },
1970
+ {
1971
+ key: 'think',
1972
+ field: {
1973
+ type: 'select',
1974
+ label: '深度思考',
1975
+ dynamicOptions: 'llm-thinking',
1976
+ description: '可选的档位随所用的模型;没设置或当前模型没有这一档时按模型的默认档',
1977
+ },
1978
+ // 可选项是「模型」这一项生效的那个模型声明的档位
1979
+ dependsOn: 'llm',
1980
+ appliesTo: ['owner', 'room', 'task'],
1981
+ prominence: 'common',
1982
+ order: 20,
1983
+ validate: value => (typeof value === 'string' && value !== '' ? undefined : '要是档位 id(非空字符串)'),
1984
+ },
1985
+ {
1986
+ key: 'maxToolIterations',
1987
+ field: {
1988
+ type: 'number',
1989
+ label: '最大工具迭代',
1990
+ integer: true,
1991
+ min: 1,
1992
+ default: MAX_TOOL_ITERATIONS,
1993
+ description: '一轮里工具调用循环的最大次数',
1994
+ },
1995
+ appliesTo: ['owner', 'room', 'task'],
1996
+ prominence: 'advanced',
1997
+ },
1998
+ {
1999
+ key: 'historyLimit',
2000
+ field: {
2001
+ type: 'number',
2002
+ label: '历史条数上限',
2003
+ integer: true,
2004
+ min: 0,
2005
+ default: 0,
2006
+ description: '一轮最多读进这么多条会话历史;装了摘要插件时,会话的消息到这个条数就压缩。0 表示不按条数:读全部活跃历史,只按自动压缩线压缩',
2007
+ },
2008
+ appliesTo: ['owner', 'room', 'task'],
2009
+ prominence: 'advanced',
2010
+ // 子会话按自己的继承链实时解析,不冻结父会话当时的值
2011
+ liveOnly: true,
2012
+ },
2013
+ ];
1781
2014
  function run(caps) {
1782
- const cfg = parseConfig(configSchema, caps.config, caps.logger);
1783
- const agentImpl = new DefaultAgent(caps, cfg);
1784
- caps.provide(agentService, agentImpl);
2015
+ const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
2016
+ for (const setting of SESSION_SETTINGS)
2017
+ caps.sessionManager.use.registerSetting(setting);
2018
+ const agentImpl = new DefaultAgent(caps, cfg, caps.lifecycle.use.id);
2019
+ caps.provide.use(agentService, agentImpl);
1785
2020
  // 收尾段中止在飞回合并等 AbortError 落定。
1786
2021
  // 第一方栈由 core 关停编排(optional 环成员先全部 drain)与 agent:turn:after 收口;
1787
2022
  // session-manager 的 onDrain 兜的是未装 agent 或钩子未挂上的路径。
1788
2023
  // 超时沿用 core 对 onDrain 的 disposeTimeoutMs,不另加配置键。
1789
- caps.lifecycle.onDrain(() => agentImpl.abortInflightAndSettle(), '中止在飞回合');
2024
+ caps.lifecycle.use.onDrain(() => agentImpl.abortInflightAndSettle(), '中止在飞回合');
1790
2025
  // 清理段再 abort 一次:drain 超时或未走到收尾时的兜底,表空则空操作。
1791
- caps.lifecycle.onDispose(() => agentImpl.abortAll(), '中止在飞回合(兜底)');
2026
+ caps.lifecycle.use.onDispose(() => agentImpl.abortAll(), '中止在飞回合(兜底)');
1792
2027
  // 会话删除:丢弃该会话的 token 日志节流状态(与 todo-list / file-reader 走同一条会话清理路径)
1793
- caps.events.on('session:deleted', sessionId => agentImpl.dropTokenLogState(sessionId));
2028
+ caps.events.use.on('session:deleted', sessionId => agentImpl.dropTokenLogState(sessionId));
1794
2029
  const agent = agentImpl;
1795
- // 全局默认 LLM:通过 ServicePreference 锁定 llm 服务的首选 entry。
1796
- // 偏好的持久化在宿主配置文档(servicePreferences 字段,WebUI 切换时写入);这里只是开机时按 agent
1797
- // 自己的 cfg.defaultLLM 覆写一次,便于纯文件配置流(无 webui 干预)也能生效。
1798
- const defaultLLM = cfg.defaultLLM;
1799
- if (defaultLLM?.provider && defaultLLM?.model) {
1800
- caps.services.prefer(llmService, `${defaultLLM.provider}/${defaultLLM.model}`);
2030
+ // ===== 会话级配置指令 =====
2031
+ // /session 查(模型+人设+名+来源) · /session.set 改 · /session.reset 复位 · /model 列可用模型
2032
+ // 与"会话"相关的模型改写统一收敛到 /session.*;/model 只保留全局的"列可用模型"发现。
2033
+ /** 会话设置存在会话管理里:它没在运行时三条会话指令都回这一句 */
2034
+ const NO_SESSION_MANAGER = '会话管理没有在运行,会话设置要靠它:在 WebUI 插件区启用「会话管理」,' +
2035
+ '或把它从配置文件的 disabledPlugins 里删掉;启用了还起不来就用 /doctor 看原因';
2036
+ /**
2037
+ * 继承值的来源:层表里这一层的称呼,平台档、平台类型档与场所另写这个会话取的是哪一份(「平台档 onebot」
2038
+ * 「平台类型档 onebot · group」「场所『<显示名>』」);哪一层都没设时为「默认」
2039
+ */
2040
+ function sourceName(smSvc, sessionId, inheritance, layer) {
2041
+ if (layer === undefined)
2042
+ return '默认';
2043
+ const label = SESSION_LAYERS.find(({ name }) => name === layer).label;
2044
+ if (layer === 'platform')
2045
+ return `${label} ${inheritance.platform}`;
2046
+ if (layer === 'type')
2047
+ return `${label} ${inheritance.platform} · ${inheritance.type}`;
2048
+ if (layer !== 'place')
2049
+ return label;
2050
+ const { key } = smSvc.placeOf(sessionId);
2051
+ return `${label}『${smSvc.getPlace(key)?.name ?? key}』`;
1801
2052
  }
1802
- function resolutionLines(sessionId, platform, label, pick) {
1803
- const smSvc = caps.sessionManager.current;
1804
- if (!smSvc)
1805
- return [`${label}: (session-manager 不可用)`];
1806
- const session = smSvc.getSession(sessionId);
1807
- const own = pick(session?.config);
1808
- const parent = session?.parentId ? smSvc.getSession(session.parentId) : undefined;
1809
- const parentDefaults = pick(parent?.config?.sessionDefaults);
1810
- const profile = pick(smSvc.getPlatformProfiles()?.[platform || 'webui']);
1811
- const resolved = pick(smSvc.resolveConfig(sessionId, platform));
1812
- let source = '默认';
1813
- if (own)
1814
- source = '会话覆盖';
1815
- else if (parentDefaults)
1816
- source = '父会话 sessionDefaults';
1817
- else if (profile)
1818
- source = `平台 profile (${platform})`;
1819
- const lines = [`${label}: ${resolved ?? '(默认)'} [来源: ${source}]`];
1820
- const chain = [];
1821
- if (own)
1822
- chain.push(`会话: ${own}`);
1823
- if (parentDefaults)
1824
- chain.push(`父 sessionDefaults: ${parentDefaults}`);
1825
- if (profile)
1826
- chain.push(`平台 profile: ${profile}`);
1827
- if (chain.length > 0) {
1828
- lines.push(' 解析链(高优先级在前):');
1829
- for (const c of chain)
1830
- lines.push(` - ${c}`);
1831
- }
1832
- return lines;
2053
+ function resolutionLines(smSvc, sessionId, platform, label, key, pick) {
2054
+ const inheritance = smSvc.resolveInheritance(sessionId, platform);
2055
+ const inherited = pick(inheritance.values);
2056
+ const from = sourceName(smSvc, sessionId, inheritance, inheritance.sources[key]);
2057
+ const own = pick(smSvc.getOwnConfig(sessionId));
2058
+ if (own == null)
2059
+ return [`${label}: ${inherited ?? '(默认)'} [来源: ${from}]`];
2060
+ return [`${label}: ${own} [来源: 会话覆盖]`, ` 被覆盖的继承值: ${inherited ?? '(默认)'} [来源: ${from}]`];
1833
2061
  }
1834
2062
  const fmtModel = (c) => (c?.llm ? `${c.llm.provider}/${c.llm.model}` : undefined);
1835
2063
  const fmtPersona = (c) => c?.persona;
1836
- // null 与 undefined 同义(BSON 持久化读回 null,契约见 session-manager stripUndefined)
1837
- const fmtThink = (c) => (c?.think == null ? undefined : c.think ? 'on' : 'off');
2064
+ const fmtThink = (c) => c?.think;
2065
+ /**
2066
+ * /session 模型那一行之后:这一轮实际会用的模型与显示的生效值不同时写出来(哪一层都没设、偏好也没有时,或偏好指向的
2067
+ * 模型不在时,服务落到别的模型上)
2068
+ */
2069
+ async function modelNote(smSvc, sessionId, platform) {
2070
+ const actual = (await agent.resolveLLM(platform, sessionId))?.id;
2071
+ const shown = fmtModel(smSvc.getOwnConfig(sessionId)) ?? fmtModel(smSvc.resolveInheritance(sessionId, platform).values);
2072
+ return actual && actual !== shown ? ` 这一轮会用: ${actual}` : '';
2073
+ }
2074
+ /** 模型的档位写成一句:id(名字),由浅到深 */
2075
+ const levelList = (thinking) => thinking.levels.map(({ id, label }) => `${id}(${label})`).join('、');
2076
+ /** /session 深度思考那一行之后:这一轮会用的模型的档位与默认档,生效的档位它没有时写明按默认 */
2077
+ async function thinkingNote(sessionId, platform) {
2078
+ const thinking = (await agent.resolveLLM(platform, sessionId))?.use.thinking;
2079
+ if (!thinking)
2080
+ return ' 当前模型不能调';
2081
+ const level = caps.sessionManager.present
2082
+ ? caps.sessionManager.use.resolveConfig(sessionId, platform).think
2083
+ : undefined;
2084
+ const missing = level !== undefined && !thinking.levels.some(({ id }) => id === level);
2085
+ return ` 当前模型的档位:${levelList(thinking)},默认 ${thinking.default}${missing ? ';当前模型没有这一档,按默认' : ''}`;
2086
+ }
1838
2087
  // 分页渲染 helper:限制每页 PAGE_SIZE 条 + 关键词过滤 + 翻页提示(/model 与 /persona 共用)。
1839
2088
  const PAGE_SIZE = 20;
1840
2089
  function renderPaged(all, keyword, page, label, cmd) {
@@ -1852,54 +2101,56 @@ function run(caps) {
1852
2101
  }
1853
2102
  return lines.join('\n');
1854
2103
  }
1855
- caps.commands
1856
- .command('model [keyword:string]', '列出/搜索可用对话模型(分页,-p 翻页;如 /model grok)')
2104
+ caps.commands.use
2105
+ .command('model [keyword:string]', '列出/搜索可用对话模型。分页显示,-p 翻页;如 /model grok')
1857
2106
  .option('page', '-p <n:number>', { description: '页码(默认 1)' })
1858
2107
  .action(async (argv, keyword) => {
1859
2108
  const seen = new Set();
1860
2109
  const ids = [];
1861
2110
  for (const e of listLLMModels(caps.llm, { caps: ['chat'] })) {
1862
- if (seen.has(e.contextId))
2111
+ if (seen.has(e.id))
1863
2112
  continue;
1864
- seen.add(e.contextId);
1865
- ids.push(e.contextId);
2113
+ seen.add(e.id);
2114
+ ids.push(e.id);
1866
2115
  }
1867
2116
  const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
1868
2117
  return renderPaged(ids, String(keyword ?? ''), page, '可用模型', '/model');
1869
2118
  });
1870
- caps.commands
2119
+ caps.commands.use
1871
2120
  .command('persona [keyword:string]', '列出/搜索可用人设(分页,-p 翻页)')
1872
2121
  .option('page', '-p <n:number>', { description: '页码(默认 1)' })
1873
2122
  .action(async (argv, keyword) => {
1874
- const persona = caps.persona.current;
2123
+ const persona = caps.persona.present ? caps.persona.use : undefined;
1875
2124
  const names = persona?.listModels ? await persona.listModels() : [];
1876
2125
  const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
1877
2126
  return renderPaged(names, String(keyword ?? ''), page, '可用人设', '/persona');
1878
2127
  });
1879
- // ---- 会话级「模型 + 人设 + thinking + 名称」配置(onebot 等平台对话直接改当前对话生效) ----
1880
- caps.commands
1881
- .command('session', '查看当前对话生效的模型 / 人设 / thinking / 名称及来源与解析链', { risk: 'sensitive' })
2128
+ // ---- 会话级「模型 + 人设 + 深度思考 + 名称」配置(onebot 等平台对话直接改当前对话生效) ----
2129
+ caps.commands.use
2130
+ .command('session', '查看当前对话生效的设定及来源。设定指模型、人设、深度思考与名称', { risk: 'sensitive' })
1882
2131
  .action(async (argv) => {
1883
- const smSvc = caps.sessionManager.current;
1884
- if (!smSvc)
1885
- return 'session-manager 服务不可用';
2132
+ if (!caps.sessionManager.present)
2133
+ return NO_SESSION_MANAGER;
2134
+ const smSvc = caps.sessionManager.use;
1886
2135
  const sid = argv.session.sessionId;
1887
2136
  const session = smSvc.getSession(sid);
1888
2137
  return [
1889
2138
  `会话: ${session?.name ?? sid}`,
1890
- ...resolutionLines(sid, argv.session.platform, '模型', fmtModel),
1891
- ...resolutionLines(sid, argv.session.platform, '人设', fmtPersona),
1892
- ...resolutionLines(sid, argv.session.platform, 'thinking', fmtThink),
2139
+ ...resolutionLines(smSvc, sid, argv.session.platform, '模型', 'llm', fmtModel),
2140
+ await modelNote(smSvc, sid, argv.session.platform),
2141
+ ...resolutionLines(smSvc, sid, argv.session.platform, '人设', 'persona', fmtPersona),
2142
+ ...resolutionLines(smSvc, sid, argv.session.platform, '深度思考', 'think', fmtThink),
2143
+ await thinkingNote(sid, argv.session.platform),
1893
2144
  session ? '' : '(本对话尚无独立配置记录,全部继承默认)',
1894
2145
  '────',
1895
- '改配置: /session.set -m <模型> -p <人设> -t <on|off> [-n <名>] (/model 看可用模型/人设名错时也会列出)',
1896
- '复位: /session.reset (-m 仅模型 / -p 仅人设 / -t 仅 thinking)',
2146
+ '改配置: /session.set -m <模型> -p <人设> -t <档位> [-n <名>] (/model 看可用模型/人设名错时也会列出)',
2147
+ '复位: /session.reset (-m 仅模型 / -p 仅人设 / -t 仅深度思考)',
1897
2148
  ]
1898
2149
  .filter(Boolean)
1899
2150
  .join('\n');
1900
2151
  });
1901
- caps.commands
1902
- .command('session.set', '设定当前对话的模型 / 人设 / thinking / 显示名(会话级覆盖,持久化,重启不丢)', {
2152
+ caps.commands.use
2153
+ .command('session.set', '设定当前对话的模型、人设、深度思考或显示名。会话级覆盖,持久化,重启不丢', {
1903
2154
  risk: 'sensitive',
1904
2155
  examples: [
1905
2156
  '/session.set -m @aalis/plugin-llm-openai:main/gpt-4o -p catgirl',
@@ -1910,42 +2161,50 @@ function run(caps) {
1910
2161
  })
1911
2162
  .option('model', '-m <ref:string>', { description: '模型 provider/model(LLM entry 的 contextId;/model 可列出)' })
1912
2163
  .option('persona', '-p <name:string>', { description: '人设卡名(不含后缀)' })
1913
- .option('think', '-t <mode:string>', {
1914
- description: 'thinking 开关:on|off(会话级覆盖;/session.reset 后回落全局配置)',
2164
+ .option('think', '-t <level:string>', {
2165
+ description: '深度思考的档位 id,按当前模型的档位(/session 列出;会话级覆盖,/session.reset 后回落默认)',
1915
2166
  })
1916
2167
  .option('name', '-n <label:string>', { description: '会话显示名(可选)' })
1917
2168
  .action(async (argv) => {
1918
- const smSvc = caps.sessionManager.current;
1919
- if (!smSvc)
1920
- return 'session-manager 服务不可用';
2169
+ if (!caps.sessionManager.present)
2170
+ return NO_SESSION_MANAGER;
2171
+ const smSvc = caps.sessionManager.use;
1921
2172
  const modelRef = typeof argv.options.model === 'string' ? argv.options.model.trim() : '';
1922
2173
  const personaName = typeof argv.options.persona === 'string' ? argv.options.persona.trim() : '';
1923
- const thinkRaw = typeof argv.options.think === 'string' ? argv.options.think.trim().toLowerCase() : '';
2174
+ const level = typeof argv.options.think === 'string' ? argv.options.think.trim() : '';
1924
2175
  const displayName = typeof argv.options.name === 'string' ? argv.options.name.trim() : '';
1925
- if (!modelRef && !personaName && !thinkRaw && !displayName) {
1926
- return '用法: /session.set -m <provider/model> -p <人设名> -t <on|off> [-n <显示名>](至少给一项)';
1927
- }
1928
- if (thinkRaw && thinkRaw !== 'on' && thinkRaw !== 'off') {
1929
- return `thinking 取值只能是 on 或 off(收到 "${thinkRaw}")。例:/session.set -t off`;
2176
+ if (!modelRef && !personaName && !level && !displayName) {
2177
+ return '至少给一项。敲 /session.set -h 看用法';
1930
2178
  }
1931
2179
  const config = {};
1932
- if (thinkRaw)
1933
- config.think = thinkRaw === 'on';
1934
2180
  // 模型:校验存在再落,避免错名静默回退默认
2181
+ let chosen;
1935
2182
  if (modelRef) {
1936
2183
  const lastSlash = modelRef.lastIndexOf('/');
1937
2184
  if (lastSlash <= 0 || lastSlash === modelRef.length - 1) {
1938
2185
  return '模型格式错误。请用 `provider/model`,例如 `@aalis/plugin-llm-openai:main/gpt-4o`';
1939
2186
  }
1940
2187
  const chatModels = listLLMModels(caps.llm, { caps: ['chat'] });
1941
- if (!chatModels.some(e => e.contextId === modelRef)) {
2188
+ chosen = chatModels.find(e => e.id === modelRef);
2189
+ if (!chosen) {
1942
2190
  return `未知模型 "${modelRef}"。用 /model <关键词> 搜索(如 /model grok),共 ${chatModels.length} 个可用。`;
1943
2191
  }
1944
2192
  config.llm = { provider: modelRef.slice(0, lastSlash), model: modelRef.slice(lastSlash + 1) };
1945
2193
  }
1946
- // 人设:listModels 可选,缺失则不校验(persona 插件缺席时不误拒)
2194
+ // 深度思考:按这一轮会用的模型(同时改了模型时按新模型)的档位校验
2195
+ if (level) {
2196
+ const thinking = (chosen ?? (await agent.resolveLLM(argv.session.platform, argv.session.sessionId)))?.use
2197
+ .thinking;
2198
+ if (!thinking)
2199
+ return '当前模型不能调深度思考';
2200
+ if (!thinking.levels.some(({ id }) => id === level)) {
2201
+ return `当前模型没有档位「${level}」,可选:${levelList(thinking)}`;
2202
+ }
2203
+ config.think = level;
2204
+ }
2205
+ // 人设:人设插件不在时这一项没有登记,session-manager 拒绝写入
1947
2206
  if (personaName) {
1948
- const persona = caps.persona.current;
2207
+ const persona = caps.persona.present ? caps.persona.use : undefined;
1949
2208
  if (persona?.listModels) {
1950
2209
  const names = await persona.listModels();
1951
2210
  if (!names.includes(personaName)) {
@@ -1954,35 +2213,31 @@ function run(caps) {
1954
2213
  }
1955
2214
  config.persona = personaName;
1956
2215
  }
1957
- await smSvc.ensureSession(argv.session.sessionId, {
1958
- config,
1959
- createdBy: 'user',
1960
- ...(displayName ? { name: displayName } : {}),
1961
- });
2216
+ await smSvc.ensureSession(argv.session.sessionId, { config, ...(displayName ? { name: displayName } : {}) });
1962
2217
  const lines = ['本对话已切换 →'];
1963
2218
  if (config.llm)
1964
2219
  lines.push(`· 模型: ${config.llm.provider}/${config.llm.model}`);
1965
2220
  if (config.persona)
1966
2221
  lines.push(`· 人设: ${config.persona}`);
1967
- if (config.think !== undefined)
1968
- lines.push(`· thinking: ${config.think ? 'on' : 'off'}`);
2222
+ if (config.think)
2223
+ lines.push(`· 深度思考: ${config.think}`);
1969
2224
  if (displayName)
1970
2225
  lines.push(`· 名称: ${displayName}`);
1971
2226
  lines.push('后续消息持续生效(已持久化,重启不丢)。/session.reset 可复位。');
1972
2227
  return lines.join('\n');
1973
2228
  });
1974
- caps.commands
1975
- .command('session.reset', '复位当前对话的会话级覆盖(默认清模型+人设+thinking;-m/-p/-t 单独清对应项)', {
2229
+ caps.commands.use
2230
+ .command('session.reset', '复位当前对话的会话级覆盖。默认清模型、人设与深度思考,-m/-p/-t 单独清对应项', {
1976
2231
  risk: 'sensitive',
1977
2232
  examples: ['/session.reset', '/session.reset -m', '/session.reset -p', '/session.reset -t'],
1978
2233
  })
1979
2234
  .option('model', '-m', { description: '仅复位模型覆盖' })
1980
2235
  .option('persona', '-p', { description: '仅复位人设覆盖' })
1981
- .option('think', '-t', { description: '仅复位 thinking 覆盖' })
2236
+ .option('think', '-t', { description: '仅复位深度思考覆盖' })
1982
2237
  .action(async (argv) => {
1983
- const smSvc = caps.sessionManager.current;
1984
- if (!smSvc)
1985
- return 'session-manager 服务不可用';
2238
+ if (!caps.sessionManager.present)
2239
+ return NO_SESSION_MANAGER;
2240
+ const smSvc = caps.sessionManager.use;
1986
2241
  const mFlag = argv.options.model === true;
1987
2242
  const pFlag = argv.options.persona === true;
1988
2243
  const tFlag = argv.options.think === true;
@@ -1996,53 +2251,55 @@ function run(caps) {
1996
2251
  const willClear = (clearModel && session?.config?.llm) ||
1997
2252
  (clearPersona && session?.config?.persona) ||
1998
2253
  (clearThink && session?.config?.think != null);
1999
- if (session?.config && willClear) {
2000
- // 写 undefined → resolveConfig 的 stripUndefined 使其回落默认级联
2001
- const patch = { ...session.config };
2254
+ if (willClear) {
2255
+ // null 删掉这一项,回到继承链
2256
+ const patch = {};
2002
2257
  if (clearModel)
2003
- patch.llm = undefined;
2258
+ patch.llm = null;
2004
2259
  if (clearPersona)
2005
- patch.persona = undefined;
2260
+ patch.persona = null;
2006
2261
  if (clearThink)
2007
- patch.think = undefined;
2008
- await smSvc.ensureSession(sid, { config: patch });
2262
+ patch.think = null;
2263
+ await smSvc.updateSession(sid, { config: patch });
2009
2264
  }
2010
2265
  const eff = smSvc.resolveConfig(sid, argv.session.platform);
2011
- const thinkStr = eff.think == null ? '(默认)' : eff.think ? 'on' : 'off';
2012
- return `已复位 → 模型: ${eff.llm ? `${eff.llm.provider}/${eff.llm.model}` : '(默认)'},人设: ${eff.persona ?? '(默认)'},thinking: ${thinkStr}`;
2266
+ return `已复位 → 模型: ${eff.llm ? `${eff.llm.provider}/${eff.llm.model}` : '(默认)'},人设: ${eff.persona ?? '(默认)'},深度思考: ${eff.think ?? '(默认)'}`;
2013
2267
  });
2014
2268
  // 监听 token:request 事件 — 客户端刷新/重连时主动请求 token 用量
2015
- caps.events.on('token:request', async (data) => {
2269
+ caps.events.use.on('token:request', async (data) => {
2016
2270
  if (!data?.sessionId)
2017
2271
  return;
2018
- // 唯一发射方是 WebUI;平台缺省按 'webui' 兜底,否则模型/会话配置解析会绕过平台 profile 层
2019
- const platform = data.platform ?? 'webui';
2272
+ // platform 是调用方的入口平台(owner 面会话的快照按它选档);会话服务对房间不填、其余填连接的平台。
2273
+ // 缺省时 IM 房间按会话记下的出生平台(子会话取根会话的)、其余按 'webui':它还是提示词贡献方的「当前平台」
2274
+ //(按平台取跨会话料),不能缺省成空
2275
+ const sm = caps.sessionManager.present ? caps.sessionManager.use : undefined;
2276
+ const origin = sm && rootSessionOf(sm.getSession(data.sessionId), id => sm.getSession(id))?.originPlatform;
2277
+ const platform = data.platform ?? origin ?? 'webui';
2020
2278
  try {
2021
2279
  const resolved = await agent.resolveLLM(platform, data.sessionId);
2022
2280
  if (!resolved)
2023
2281
  return;
2024
- const llm = resolved.instance;
2282
+ const llm = resolved.use;
2025
2283
  const contextLength = llm.contextLength;
2026
2284
  const maxTokens = getModelMaxOutput(llm);
2027
2285
  const tokenBudget = agent.computeTokenBudget(contextLength, maxTokens);
2028
2286
  // 与真实回合同一份会话解析:人设覆盖、结构化输出开关、额外提示都影响系统提示长度
2029
- const sm = caps.sessionManager.current;
2030
2287
  const sessionResolved = sm ? sm.resolveConfig(data.sessionId, platform) : undefined;
2031
2288
  // 获取历史消息并构建基础消息列表
2032
- const memory = caps.memory.current;
2289
+ const memory = caps.memory.present ? caps.memory.use : undefined;
2033
2290
  const messages = [];
2034
2291
  // 系统提示
2035
2292
  const systemPrompt = agent.buildSystemPrompt(toPersonaOptions(sessionResolved));
2036
2293
  messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
2037
- // 历史消息
2038
- if (memory) {
2039
- const history = await memory.getHistory(data.sessionId, agent.historyLimit);
2040
- messages.push(...history.filter(m => !CONTROL_KINDS.includes(m.kind ?? '')));
2041
- }
2294
+ // 历史消息:与真实回合同样按会话的条数线读
2295
+ const history = memory ? await memory.getHistory(data.sessionId, sessionResolved?.historyLimit || Infinity) : [];
2296
+ messages.push(...history.filter(m => !CONTROL_KINDS.includes(m.kind ?? '')));
2042
2297
  // 与真实回合同序:先组装 agent:prompt 贡献(摘要/向量记忆/档案等物化为 system 块),
2043
2298
  // 再跑 agent:llm:before(工具搜索层过滤等拦截职责),使快照贴近实际送入 LLM 的形态
2044
2299
  const enabledGroups = sessionResolved?.enabledToolGroups?.length ? sessionResolved.enabledToolGroups : undefined;
2045
- const tools = caps.tools.current?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ?? [];
2300
+ const tools = caps.tools.present
2301
+ ? caps.tools.use.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined)
2302
+ : [];
2046
2303
  const llmBeforeData = {
2047
2304
  messages,
2048
2305
  tools,
@@ -2052,11 +2309,24 @@ function run(caps) {
2052
2309
  dryRun: true, // 纯统计路径:昂贵注入者(向量检索/档案加载)据此跳过副作用
2053
2310
  };
2054
2311
  await assemblePromptContributions(caps, llmBeforeData, { buildTimeoutMs: agent.promptBuildTimeoutMs });
2055
- await caps.hooks.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
2056
- agent.emitTokenUsage(data.sessionId, platform, llmBeforeData.messages, llmBeforeData.tools, contextLength, maxTokens, tokenBudget);
2312
+ await caps.hooks.use.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
2313
+ // 与真实回合同样过硬裁剪,拆分按裁剪之后的消息算
2314
+ const sent = agent.trimMessages(llmBeforeData.messages, tokenBudget);
2315
+ await agent.emitTokenUsage({
2316
+ sessionId: data.sessionId,
2317
+ platform,
2318
+ contextWindow: contextLength,
2319
+ maxTokens,
2320
+ tokenBudget,
2321
+ messages: sent.messages,
2322
+ tools: llmBeforeData.tools,
2323
+ trimmed: sent.trimmed,
2324
+ untrimmedHistory: estimateHistoryTokens(llmBeforeData.messages),
2325
+ historyMessages: history.length,
2326
+ });
2057
2327
  }
2058
2328
  catch (err) {
2059
- caps.logger.debug('token:request 处理失败:', err);
2329
+ caps.logger.use.debug('token:request 处理失败:', err);
2060
2330
  }
2061
2331
  });
2062
2332
  }