@aalis/plugin-agent 0.16.0 → 0.17.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
@@ -1,24 +1,30 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { agent as agentService, } from '@aalis/api-agent';
3
+ import { authority as authorityService, originTurnOf } from '@aalis/api-authority';
3
4
  import { commands as commandsService } from '@aalis/api-commands';
4
5
  import { contributions } from '@aalis/api-contributions';
5
6
  import { gateway as gatewayService } from '@aalis/api-gateway';
6
7
  import { hooks } from '@aalis/api-hooks';
7
- import { listLLMModels, llm as llmService, resolveLLMModel } from '@aalis/api-llm';
8
+ import { ownHostConfig } from '@aalis/api-host-config';
9
+ import { estimateMsgTokens, estimateTextTokens, LLMCapabilities, listLLMModels, llm as llmService, MODEL_REF_TEXT, resolveLLMModel, } from '@aalis/api-llm';
8
10
  import { media as mediaService } from '@aalis/api-media';
9
11
  import { memory as memoryService } from '@aalis/api-memory';
10
- import { messageArchive as messageArchiveService } from '@aalis/api-message-archive';
12
+ import { messageArchive as messageArchiveService, speakerOf } from '@aalis/api-message-archive';
11
13
  import { persona as personaService } from '@aalis/api-persona';
12
14
  import { getPlatformSelfIdentity, platform as platformService } from '@aalis/api-platform';
13
- import { rootSessionOf, SESSION_LAYERS, sessionManager as sessionManagerService, } from '@aalis/api-session-manager';
14
- import { storage as storageService } from '@aalis/api-storage';
15
+ import { originChatOf, rootSessionOf, sessionManager as sessionManagerService } from '@aalis/api-session-manager';
16
+ import { sessionFields as sessionFieldsService } from '@aalis/api-session-settings';
17
+ import { isStorageUri, storage as storageService } from '@aalis/api-storage';
15
18
  import { tools as toolsService, } from '@aalis/api-tools';
16
19
  import { config as configCap, definePlugin, events, lifecycle, logger as loggerCap, optional, pluginsService, provide, services, } from '@aalis/core';
17
20
  import { defineConfig, parseConfig } from '@aalis/schema-config';
18
- import { CONTROL_KINDS, getMessageName, getSenderLabel, WellKnownKinds, WellKnownMetadataKeys, } from '@aalis/schema-message';
19
- import { normalizeAssistantContent, stripLeakedSpecialTokens, truncateChars } from '@aalis/util-text-normalize';
20
- import { buildFocusGuidance, describeLLMFailure, estimateHistoryTokens, estimateMsgTokens, estimateTextTokens, estimateTokens, formatTimeLabel, formatToolArgsForLog, INPUT_CONVENTIONS, isSameMessage, parseToolArguments, toPersonaOptions, } from './helpers.js';
21
+ import { textLines, textOf, toSource } from '@aalis/schema-i18n';
22
+ import { arrivalTag, buildIncomingContent, CONTROL_KINDS, DIRECTIVE_KINDS, getMessageName, getSenderLabel, groupToolCalls, WellKnownKinds, WellKnownMetadataKeys, wasAddressed, } from '@aalis/schema-message';
23
+ import { stripLeakedSpecialTokens, truncateChars } from '@aalis/util-text-normalize';
24
+ import { buildFocusGuidance, describeLLMFailure, estimateHistoryTokens, estimateTokens, formatTimeLabel, formatToolArgsForLog, hasAttachmentDescriptions, historyForModel, INPUT_CONVENTIONS, isSameMessage, parseToolArguments, queuedNote, readHistory, stoppedNote, } from './helpers.js';
21
25
  import { assemblePromptContributions, VOLATILE_INJECTOR } from './prompt-assembly.js';
26
+ import { SessionQueues } from './session-queue.js';
27
+ import { t } from './texts.js';
22
28
  // ===== 能力声明 =====
23
29
  //
24
30
  // 对话编排要碰的东西全在这里:没写进 uses 的,插件碰不到。全部 optional——少一样能力
@@ -39,6 +45,12 @@ const uses = {
39
45
  persona: optional(personaService),
40
46
  messageArchive: optional(messageArchiveService),
41
47
  sessionManager: optional(sessionManagerService),
48
+ /** 判谁是 owner:参与者里合一 owner 的各个身份、存档不在时当轮正文的发送者前缀(与存档同一规则,speakerOf)、/stop */
49
+ authority: optional(authorityService),
50
+ /** 按会话读本插件的四项(模型、深度思考、最大工具迭代、历史条数上限);缺席时只取插件配置里的值 */
51
+ sessionFields: optional(sessionFieldsService),
52
+ /** 按会话字段的全局层:宿主配置文档里本实例的那一段 */
53
+ document: optional(ownHostConfig),
42
54
  platform: optional(platformService),
43
55
  media: optional(mediaService),
44
56
  storage: optional(storageService),
@@ -62,13 +74,13 @@ const uses = {
62
74
  */
63
75
  /** 统一读取 LLM 单次最大输出 token;缺省回退 4096(与各 adapter 默认一致)。 */
64
76
  const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
65
- /** 会话设置「最大工具迭代」没设时一轮里工具调用循环的最大次数 */
66
- const MAX_TOOL_ITERATIONS = 30;
67
77
  // 宿主通知的公开链接须在最终正文保留;同时识别普通文本和 Markdown 链接的边界。
68
78
  const NOTICE_URL = /https?:\/\/[^\s<>()[\]"'`*“”‘’,。!?;、]+/gu;
69
79
  function getModelMaxOutput(llm) {
70
80
  return llm.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
71
81
  }
82
+ /** 条目固定的那个模型写成模型引用 */
83
+ const refOf = (entry) => ({ provider: entry.use.providerId, model: entry.use.id });
72
84
  class DefaultAgent {
73
85
  instanceId;
74
86
  caps;
@@ -79,27 +91,10 @@ class DefaultAgent {
79
91
  promptBuildTimeoutMs;
80
92
  /** 单条工具结果占上下文窗口的最大比例 (0~1),超出则截断 */
81
93
  toolResultMaxRatio;
82
- /** 内存裁剪触发比例 (0~1):估算输入 token 占 contextLength 的比例上限,超过则触发本次调用的内存裁剪 */
94
+ /** 裁剪触发比例 (0~1):裁剪预算按上下文长度乘它再扣输出预留,见 computeTokenBudget */
83
95
  trimThresholdRatio;
84
- /**
85
- * 在飞回合表:lane → 这一轮的 AbortController 与 turnId
86
- *
87
- * lane = `${sessionId}::${source}` — 同一 session 不同来源(user / scheduler)
88
- * 独立管理,互不打断;同来源新消息会中止旧的生成。
89
- */
90
- activeControllers = new Map();
91
- /**
92
- * 延续某位发言者身份的宿主通知回合(lane → 会话与 hostNotice.callerUserId)。那位发言者在同一会话发来的真人消息
93
- * 中止这些回合;别人的消息与不延续任何人身份的通知(白纸等)不受影响,宿主通知也从不打断别的回合。
94
- */
95
- noticeCallers = new Map();
96
- /**
97
- * 在飞回合 Promise。关停时 abort 之后等它们以 AbortError(或正常完成)收尾,
98
- * 而不是等 LLM 跑完——收尾里的钩子 / 出站事件还要用 memory。
99
- */
100
- inflightTurns = new Set();
101
- /** 同一 lane 的入站消息归档串行化,避免连续消息读取历史时漏掉前一条输入。 */
102
- archiveQueues = new Map();
96
+ /** 各会话的通道:在跑的一轮、待插入与排队的输入(见 session-queue.ts)。停机由 run() 直接经它停,清消息历史经 clearing */
97
+ queues;
103
98
  /** 每次激活只在实际写入缺少归档时告警一次,不因插件加载顺序误报。 */
104
99
  archiveUnavailableWarned = false;
105
100
  /**
@@ -113,11 +108,24 @@ class DefaultAgent {
113
108
  preferenceFallbacks = new Set();
114
109
  /** 已注册的预处理器(name → { dispose }) */
115
110
  preprocessors = new Map();
111
+ /** 本插件的按会话字段:每一轮按会话现读;模型没设时的 actual 是服务此刻为本插件挑的那一个 */
112
+ fields;
113
+ /** 判一个身份是不是 owner:工具调用上下文里合一 owner 的各个身份、/stop 都用它;权限服务不在时没有人是 owner */
114
+ isOwner = (platform, userId) => this.caps.authority.present && this.caps.authority.use.isOwner(platform, userId);
116
115
  constructor(caps, cfg,
117
116
  /** 本插件实例 id:读它的本插件偏好 */
118
117
  instanceId) {
119
118
  this.instanceId = instanceId;
120
119
  this.caps = caps;
120
+ this.queues = new SessionQueues((sessionId, waiting) => {
121
+ void caps.events.use.emit('agent:queue', { sessionId, waiting });
122
+ });
123
+ this.fields = caps.sessionFields.use.fields(configSchema, caps, {
124
+ llm: () => {
125
+ const entry = this.resolveLLM(undefined);
126
+ return entry && { value: refOf(entry) };
127
+ },
128
+ });
121
129
  this.logger = caps.logger.use.child('agent');
122
130
  this.systemPrompt = cfg.systemPrompt;
123
131
  this.memoryTokenBudget = cfg.memoryTokenBudget;
@@ -127,18 +135,15 @@ class DefaultAgent {
127
135
  this.logger.info('默认对话代理已初始化');
128
136
  }
129
137
  /**
130
- * 根据优先级链解析当前会话该用哪个 LLMModel entry。
138
+ * 解析会话该用哪个 LLMModel entry:ref 是这个会话的「模型」(按会话字段 llm 的生效值)。
131
139
  *
132
- * 会话设置的 llm 交给 session-manager.resolveConfig 解析(按层表叠加,全局默认层即 llm 服务的偏好);
133
- * 全局默认之上哪一层都没设时不给值,由 resolveLLMModel 落到本插件此刻选中的那一个(本插件偏好 > 全局偏好 > 提供者优先级 >
134
- * 登记先后)。这时该生效的偏好(设了本插件偏好就是它,否则是全局偏好)指向的模型不在(模型删了、提供者在重载或激活失败),
135
- * 服务落到别的模型上:照常用它,每个(偏好,实际)组合告警一次。
140
+ * 「模型」的全局层是本插件的 llm 偏好,哪一层都没设时不给值,由 resolveLLMModel 落到本插件此刻选中的那一个(本插件偏好 >
141
+ * 全局偏好 > 提供者优先级 > 登记先后)。这时该生效的偏好(设了本插件偏好就是它,否则是全局偏好)指向的模型不在(模型删了、
142
+ * 提供者在重载或激活失败),服务落到别的模型上:照常用它,每个(偏好,实际)组合告警一次。
136
143
  *
137
144
  * 返回 llm 的提供者条目(id 与这一家的业务句柄 use),条目固定那一个模型。
138
145
  */
139
- async resolveLLM(platform, sessionId) {
140
- const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
141
- const ref = sm && sessionId ? sm.resolveConfig(sessionId, platform).llm : undefined;
146
+ resolveLLM(ref) {
142
147
  // 解析为具体 LLMModel entry(要求至少 chat 能力;ref 为空时走 ServicePreference / 优先级)
143
148
  const entry = resolveLLMModel(this.caps.llm, ref, ['chat']);
144
149
  if (ref || !entry)
@@ -155,12 +160,14 @@ class DefaultAgent {
155
160
  return entry;
156
161
  }
157
162
  /** 解析失败时说清**为什么**(取数在此,措辞在 helpers 的 describeLLMFailure,纯函数便于单测)。 */
158
- explainLLMFailure(platform, sessionId) {
163
+ explainLLMFailure(wanted) {
159
164
  const available = listLLMModels(this.caps.llm, { caps: ['chat'] }).map(e => e.id);
160
- const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
161
- const wanted = sm && sessionId ? sm.resolveConfig(sessionId, platform).llm : undefined;
162
165
  return describeLLMFailure(available, wanted, this.erroredLLMPlugins());
163
166
  }
167
+ /** 把这个会话本会话层的「模型」设成 model:经读取器写本插件的字段,会话设置不在时抛错 */
168
+ async assignModel(sessionId, model) {
169
+ await this.fields.set(sessionId, 'llm', model);
170
+ }
164
171
  /** 激活失败且声明提供 llm 的插件实例名——「一个模型都没有」时,病因通常就在它们身上(多为缺 apiKey)。 */
165
172
  erroredLLMPlugins() {
166
173
  if (!this.caps.plugins.present)
@@ -173,55 +180,33 @@ class DefaultAgent {
173
180
  .filter(p => p.state === 'error' && p.provides?.includes('llm'))
174
181
  .map(p => p.instanceId);
175
182
  }
176
- /** 生成 lane key:同 session + 同 source 共用一个 lane */ laneKey(sessionId, source) {
177
- return `${sessionId}::${source ?? 'user'}`;
178
- }
179
183
  /**
180
- * 中止指定会话的在飞回合:不带 turnId 时停所有 lane,带 turnId 时只停那一轮。返回是否停下了回合。
184
+ * 停下这个会话在跑的一轮;queued 为真时连同排队与待插入的一起放掉,放掉的在那一轮落定之后归档(不带回合 id);by 是停下它的
185
+ * 人,写进停下标记。禁言、全部停止、停止键与 /stop 经这里
181
186
  */
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;
190
- }
191
- if (aborted)
192
- this.logger.info(`生成已中止: session=${sessionId}${turnId ? ` turn=${turnId}` : ''}`);
193
- return aborted;
187
+ abort(sessionId, options = {}) {
188
+ return this.queues.stop(sessionId, { ...options, archive: true });
194
189
  }
195
190
  /**
196
- * 中止全部在飞回合(拆卸路径用)。
197
- *
198
- * activeControllers 是实例私有的,bounce 后新实例看不见旧实例的在飞回合。
199
- * abort 走已有的 AbortError 收尾(outbound:stream done + turn:after outcome='aborted'),
200
- * 与 WebUI「停止生成」同语义。关停在 onDrain 里先调本方法再等回合 Promise;
201
- * onDispose 再调一次作兜底(已中止的 controller 不在表里,是空操作)。
191
+ * 清消息历史:先按住这些会话,再停下在跑的一轮、放掉排着与待插入的(不归档),等它落定再跑 clear。被停的一轮落定之前还会写
192
+ * 工具调用组,先停后清,清空的历史里才不留它的写入。按住期间要交给通道的输入放开再交
202
193
  */
203
- abortAll() {
204
- let n = 0;
205
- for (const [key, turn] of this.activeControllers) {
206
- turn.controller.abort();
207
- this.activeControllers.delete(key);
208
- n++;
194
+ async clearing(sessionIds, clear, reason = '清消息历史') {
195
+ const release = this.queues.hold(sessionIds);
196
+ try {
197
+ await this.queues.stopAll(sessionIds ?? this.queues.sessions(), { archive: false, reason });
198
+ await clear();
199
+ }
200
+ finally {
201
+ release();
209
202
  }
210
- if (n > 0)
211
- this.logger.info(`拆卸:已中止 ${n} 个在飞回合`);
212
203
  }
213
204
  /**
214
- * 关停收尾:中止在飞回合并等待其 AbortError(或正常完成)收尾落定。
215
- * 超时由 core 对 onDrain 回调的 disposeTimeoutMs 护栏负责,不另加配置键。
205
+ * 会话删除(session:deleted):停掉这个会话在跑的一轮、放掉排着的(不归档)并等它收尾(在 clearing 之内时它已经停过):没有
206
+ * 上限,不响应中止的工具会让它一直等到工具返回。再丢弃它的 token 日志节流状态
216
207
  */
217
- async abortInflightAndSettle() {
218
- this.abortAll();
219
- if (this.inflightTurns.size === 0)
220
- return;
221
- await Promise.allSettled([...this.inflightTurns]);
222
- }
223
- /** 会话删除时丢弃其节流日志状态(订阅见 run() 的 session:deleted)。 */
224
- dropTokenLogState(sessionId) {
208
+ async sessionDeleted(sessionId) {
209
+ await this.queues.stopAll([sessionId], { archive: false, reason: '会话删除' });
225
210
  this.tokenLogState.delete(sessionId);
226
211
  }
227
212
  /**
@@ -251,32 +236,6 @@ class DefaultAgent {
251
236
  this.logger.info(`预处理器已注册: ${name}`);
252
237
  return entry.dispose;
253
238
  }
254
- /**
255
- * 获取当前所有已注册预处理器的元信息(按注册顺序返回)
256
- */
257
- getPreprocessors() {
258
- return [...this.preprocessors.keys()].map(name => ({ name }));
259
- }
260
- /**
261
- * 获取 Agent 子系统的插件分组
262
- *
263
- * 仅纳入 Agent 直接依赖的能力提供者;不包含 `platform`
264
- * (平台属于独立子系统,由 api-platform 的 helper 负责)。
265
- */
266
- getPluginGroups() {
267
- if (!this.caps.plugins.present)
268
- return [];
269
- const pm = this.caps.plugins.use;
270
- // 子系统归属:Agent 域的服务(不含 platform——平台是独立子系统)
271
- const targetServices = new Set(['llm', 'memory', 'persona', 'message-archive']);
272
- const grouped = [];
273
- for (const p of pm.getStatus()) {
274
- if (p.provides?.some(s => targetServices.has(s))) {
275
- grouped.push(p.instanceId);
276
- }
277
- }
278
- return [{ label: 'Agent', plugins: grouped }];
279
- }
280
239
  /**
281
240
  * 消费流式 LLM 调用,累积完整响应,同时向前端推送增量事件。
282
241
  *
@@ -347,21 +306,23 @@ class DefaultAgent {
347
306
  usage = chunk.usage;
348
307
  }
349
308
  }
350
- let finalContent = content;
351
- if (content) {
352
- // 兜底剥离 LLM 端漏出的特殊 token 残渣(DSML 等),同时修复 GFM 表格
353
- const { sanitized, hadLeak } = stripLeakedSpecialTokens(content);
354
- if (hadLeak) {
355
- this.caps.logger.use.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
356
- }
357
- finalContent = normalizeAssistantContent(content);
309
+ // 经 OpenAI 兼容接口接入时,服务端漏进正文的工具调用标记只有这一层会剥:只剥不执行,落库的正文与时间线都过它。
310
+ // 时间线的正文段是 content 的切片,剥空的段不留
311
+ const { sanitized, hadLeak } = stripLeakedSpecialTokens(content);
312
+ if (hadLeak) {
313
+ this.caps.logger.use.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
358
314
  }
359
315
  return {
360
- content: finalContent,
316
+ content: sanitized,
361
317
  reasoningContent: reasoningContent || undefined,
362
318
  toolCalls,
363
319
  usage,
364
- segments,
320
+ segments: segments.flatMap((segment) => {
321
+ if (segment.type !== 'text')
322
+ return [segment];
323
+ const text = stripLeakedSpecialTokens(segment.content).sanitized;
324
+ return text ? [{ ...segment, content: text }] : [];
325
+ }),
365
326
  generating,
366
327
  };
367
328
  }
@@ -475,803 +436,782 @@ class DefaultAgent {
475
436
  lines.push(sep);
476
437
  this.logger.debug(lines.join('\n'));
477
438
  }
439
+ /** 会话管理给这个会话的代号(api-session-manager 的 generationOf);会话管理不在时没有 */
440
+ generationOf(sessionId) {
441
+ return this.caps.sessionManager.present ? this.caps.sessionManager.use.generationOf(sessionId) : undefined;
442
+ }
478
443
  async handleMessage(incoming) {
479
- const lane = this.laneKey(incoming.sessionId, incoming.source);
480
- // 仅中止同一 lane(同 session + 同 source)的旧生成;不同来源互不打断
481
- const prev = this.activeControllers.get(lane);
482
- if (prev)
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
- }
444
+ // 入口记下这个会话的代号:交给通道之前变了(会话管理这期间开始删了这个会话)的是删除之前的一代,放掉、不归档
445
+ const generation = this.generationOf(incoming.sessionId);
446
+ // Hook: agent:input:before — 每条输入一次(预处理器挂在这里),中间件可以改写这条;不调 next() 即丢掉它,不进一轮
447
+ const inputData = { message: incoming };
448
+ let placed = false;
449
+ await this.caps.hooks.use.run('agent:input:before', inputData, async () => {
450
+ placed = true;
451
+ const { message } = inputData;
452
+ // 清消息历史与删会话期间按住的会话:放开再交给通道
453
+ await this.queues.held(message.sessionId);
454
+ if (this.generationOf(incoming.sessionId) !== generation)
455
+ this.logRoute(message, '放掉');
456
+ else
457
+ await this.settleInput(message, this.queues.place(message));
458
+ });
459
+ // 输入被丢掉(如流控缓冲),通知前端结束 loading:它没进一轮,流结束标记的回合 id 只是占位
460
+ if (!placed) {
461
+ await this.caps.events.use.emit('outbound:stream', {
462
+ sessionId: incoming.sessionId,
463
+ turnId: crypto.randomUUID(),
464
+ platform: incoming.platform,
465
+ done: true,
466
+ });
491
467
  }
492
- const controller = new AbortController();
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 });
468
+ }
469
+ /** 逐条输入的默认动作:交给通道之后等这条落定(它所在的一轮结束,或它被放掉);轮到它开轮时由它跑这一轮 */
470
+ async settleInput(message, placement) {
471
+ if (placement.route === 'drop') {
472
+ this.logRoute(message, '放掉');
473
+ // 停机放掉的在这个会话在跑的一轮落定之后归档
474
+ await this.queues.settled(message.sessionId);
475
+ await this.archiveIncomingMessage(message);
476
+ return;
499
477
  }
500
- const turn = (async () => {
501
- try {
502
- await this._handleMessageInner(incoming, controller.signal, lane, turnId);
503
- }
504
- finally {
505
- // 仅清理自己创建的 controller(避免清掉后续新请求的)
506
- if (this.activeControllers.get(lane)?.controller === controller) {
507
- this.activeControllers.delete(lane);
508
- }
509
- if (this.noticeCallers.get(lane)?.controller === controller)
510
- this.noticeCallers.delete(lane);
511
- }
512
- })();
513
- this.inflightTurns.add(turn);
478
+ if (placement.route !== 'open')
479
+ this.logRoute(message, placement.route === 'join' ? '待插入' : '排队');
480
+ const turn = placement.route === 'open' ? placement.turn : await placement.ready;
481
+ if (turn)
482
+ await this.playTurn(turn);
483
+ }
484
+ /** 每条输入一行 debug:去向、到达序号、会话与回合,不记正文与昵称 */
485
+ logRoute(message, route, turnId) {
486
+ this.logger.debug(`→ ${route}${arrivalTag(message)} session=${message.sessionId}${turnId ? ` turn=${turnId}` : ''}`);
487
+ }
488
+ /**
489
+ * 跑一轮:agent:turn:before 的默认动作是这一轮的全部,中间件不调 next() 即拦下这一轮(不归档、不调模型、不发
490
+ * agent:turn:after)。之后按先后归档这期间放掉、要归档的输入,再交出下一批:agent 在一个会话里的写入因此不交错
491
+ */
492
+ async playTurn(turn) {
493
+ const { sessionId, turnId } = turn;
494
+ const inputs = turn.batch.map(message => ({ message }));
495
+ for (const message of turn.batch)
496
+ this.logRoute(message, '开轮', turnId);
497
+ const startedAt = Date.now();
498
+ let outcome;
514
499
  try {
515
- await turn;
500
+ await this.caps.hooks.use.run('agent:turn:before', { turnId, sessionId, inputs }, async () => {
501
+ outcome = await this.runTurn(turn, inputs);
502
+ });
516
503
  }
517
504
  finally {
518
- this.inflightTurns.delete(turn);
505
+ // 这一轮被拦下(如已删子会话的消息),通知前端结束 loading
506
+ if (outcome === undefined) {
507
+ await this.caps.events.use.emit('outbound:stream', {
508
+ sessionId,
509
+ turnId,
510
+ platform: inputs[0].message.platform,
511
+ done: true,
512
+ });
513
+ }
514
+ for (let dropped = this.queues.drain(turn); dropped.length > 0; dropped = this.queues.drain(turn)) {
515
+ for (const { message, archive, settle } of dropped) {
516
+ this.logRoute(message, '放掉');
517
+ if (archive)
518
+ await this.archiveIncomingMessage(message);
519
+ settle();
520
+ }
521
+ }
522
+ const arrivals = inputs.map(({ message }) => arrivalTag(message)).join('');
523
+ this.logger.info(`回合结束 turn=${turnId} session=${sessionId} 结局=${outcome ?? '拦下'}` +
524
+ `${turn.reason ? ` 原因=${turn.reason}` : ''} 用时=${Date.now() - startedAt}ms${arrivals && ` 输入${arrivals}`}`);
525
+ this.queues.release(turn);
519
526
  }
520
527
  }
521
- async _handleMessageInner(incoming, signal, lane, turnId) {
522
- // Hook: agent:input:before — 插件可以修改或拦截消息
523
- // 中间件不调用 next() 即可中断整个流程(包括 LLM 调用)
524
- const msgHookData = {
525
- message: incoming,
526
- turnId,
527
- metadata: {},
528
- };
529
- let handled = false;
530
- await this.caps.hooks.use.run('agent:input:before', msgHookData, async () => {
531
- handled = true;
532
- // ===== defaultAction: 全部消息处理逻辑在此 =====
533
- // 中间件不调用 next() → 此处永远不执行 → 消息被拦截
534
- incoming = msgHookData.message;
535
- const reportOnly = incoming.hostNotice?.reportOnly === true;
536
- const noticeLinks = reportOnly ? (incoming.content.match(NOTICE_URL) ?? []) : [];
537
- const archivedIncoming = await this.archiveIncomingMessageInOrder(lane, incoming);
538
- const resolved = await this.resolveLLM(incoming.platform, incoming.sessionId);
539
- if (!resolved) {
540
- const why = this.explainLLMFailure(incoming.platform, incoming.sessionId);
541
- this.logger.warn(`LLM 解析失败,无法处理消息:${why}`);
542
- if (!reportOnly) {
543
- await this.dispatchOutbound({
544
- content: `[系统] ${why}`,
545
- sessionId: incoming.sessionId,
546
- platform: incoming.platform,
547
- source: 'system',
548
- });
528
+ /**
529
+ * agent:turn:before 的默认动作:先按先后归档开轮的输入,再跑这一轮,返回结局。真人消息开的一轮在每次调用模型之前(第一次与
530
+ * 每批工具之后;格式重试的那几次不算)取走待插入的输入,最后一次模型调用返回不带工具调用的回复之后不再收
531
+ */
532
+ async runTurn(turn, inputs) {
533
+ const { sessionId, turnId } = turn;
534
+ const { signal } = turn.controller;
535
+ // 同一轮的输入会话与来源相同;带来源的注入(宿主通知、委派)各自成一轮,只有这一条
536
+ const first = inputs[0].message;
537
+ const { platform } = first;
538
+ const reportOnly = first.hostNotice?.reportOnly === true;
539
+ const noticeLinks = reportOnly ? (first.content.match(NOTICE_URL) ?? []) : [];
540
+ const recordOf = (outcome) => ({ outcome, ...(first.source && { source: first.source }) });
541
+ // 回合记录一轮只写一次:回复了的随回复落库时写下,之后外发或 agent:turn:after 的中间件出错不再另记一条
542
+ let recorded = false;
543
+ /** 这一轮落过工具调用组 */
544
+ let savedGroups = false;
545
+ /**
546
+ * 收尾:还没写回合记录的(静默、中止、出错)落一条只带回合 id 与记录的事件标记,不进模型、不进摘要,会话服务据此在历史里还原
547
+ * 这一轮的结局;中止的先落一条给下一轮的模型看的停下标记。再发 agent:turn:after
548
+ */
549
+ const end = async (outcome, { reply = '', error } = {}) => {
550
+ if (!recorded) {
551
+ recorded = true;
552
+ // 只在这一轮在历史里留下了输入或工具调用组时写:什么也没留下时(空闲开话题还没调工具就被停下),下一轮模型看到的上文是
553
+ // 之前已经答完的一轮,写「上面这一轮被停下了」会被读成上一句回复没发出
554
+ if (outcome === 'aborted' && (savedGroups || inputs.some(input => input.archived))) {
555
+ await this.saveToMemory(sessionId, stoppedNote(this.stopperLabel(sessionId, turn.stoppedBy), turnId));
549
556
  }
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 }),
555
- sessionId: incoming.sessionId,
556
- turnId,
557
- metadata: msgHookData.metadata,
557
+ await this.saveToMemory(sessionId, {
558
+ role: 'system',
559
+ kind: WellKnownKinds.EventMarker,
560
+ content: '',
561
+ timestamp: Date.now(),
562
+ metadata: { [WellKnownMetadataKeys.TurnId]: turnId, [WellKnownMetadataKeys.TurnRecord]: recordOf(outcome) },
558
563
  });
559
- return;
560
564
  }
561
- const llm = resolved.use;
562
- // 从 LLM model entry 读取参数。contextLength / maxOutputTokens 均为 per-model 属性,
563
- // service-granularity 后不再需要 router.getContextLengthFor() 反查,也不再会出现
564
- // 默认 provider 全局窗口与会话实际 model 不一致的偏差(Bug F 结构性修复)。
565
- const maxTokens = getModelMaxOutput(llm);
566
- const contextLength = llm.contextLength;
567
- const tokenBudget = this.computeTokenBudget(contextLength, maxTokens);
568
- // 本回合经工具循环写入 memory 的 (assistant+toolCalls + tool 结果) 消息时间戳。
569
- // 只用于中止日志里的条数:中止时这些已完成的工具调用记录保留不回滚(见下方 catch 分支)。
570
- const turnPersistedTimestamps = [];
571
- try {
572
- // 统一解析 session 配置(一次解析,多处复用)
573
- const sessionMgr = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
574
- const resolved = sessionMgr && incoming.sessionId
575
- ? sessionMgr.resolveConfig(incoming.sessionId, incoming.platform)
576
- : undefined;
577
- // 会话设置的 maxToolIterations 按登记的字段解析过(正整数),没设置时用默认值
578
- const maxToolIterations = resolved?.maxToolIterations ?? MAX_TOOL_ITERATIONS;
579
- // 构建 persona 会话选项(从 resolved config 中提取,传给 persona 服务)
580
- const personaOpts = toPersonaOptions(resolved);
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;
588
- // 通过 resolved config 获取工具分组
589
- let enabledGroups;
590
- if (resolved?.enabledToolGroups && resolved.enabledToolGroups.length > 0) {
591
- enabledGroups = resolved.enabledToolGroups;
592
- }
593
- this.logger.debug(`工具分组: platform=${incoming.platform}, enabledGroups=${enabledGroups ? JSON.stringify(enabledGroups) : '(无)'}`);
594
- const tools = reportOnly
595
- ? []
596
- : this.caps.tools.present
597
- ? this.caps.tools.use.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined)
598
- : [];
599
- const toolCtx = {
600
- sessionId: incoming.sessionId,
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,
609
- platform: incoming.platform,
610
- actor: incoming.actor,
611
- // 恒为数组:平台档没配分组时给 [](列举面本就等价于「只给无分组工具」),
612
- // 让执行面的分组闸总是生效,而不是在「未配置」时整个失效。
613
- enabledGroups: enabledGroups ?? [],
614
- // 本循环会把 ToolExecutionResult.images 挂到 tool 消息交给主模型(出口由 prepareLLMMessages 编码)
615
- acceptsImages: true,
616
- // 回合中止信号:等 owner 审批期间被 latest-wins / 手动 abort 掐掉的工具不再执行
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,
627
- };
628
- // 保存原始完整工具列表,后续迭代均以此为基础(避免被 hooks 修改后丢失)
629
- const originalTools = [...tools];
630
- // Hook: agent:llm:before — 插件可以修改消息或工具列表
631
- const llmBeforeData = {
632
- messages,
633
- tools,
634
- sessionId: incoming.sessionId,
635
- userId: incoming.userId,
636
- platform: incoming.platform,
637
- triggerType: incoming.triggerType,
638
- source: incoming.source,
639
- };
640
- // 组装先于链:贡献块(档案/技能/记忆/即时提示)先物化,拦截者审完整成品
641
- await assemblePromptContributions(this.caps, llmBeforeData, {
642
- buildTimeoutMs: this.promptBuildTimeoutMs,
643
- signal,
565
+ await this.caps.hooks.use.run('agent:turn:after', {
566
+ inputs,
567
+ reply,
568
+ outcome,
569
+ ...(error === undefined ? {} : { error }),
570
+ sessionId,
571
+ turnId,
572
+ });
573
+ return outcome;
574
+ };
575
+ for (const input of inputs)
576
+ input.archived = await this.archiveIncomingMessage(input.message, { turnId, entry: 'opened' });
577
+ // 这一轮的设置在开轮时读一次(下一轮再读,改了不必重载):插进来的输入不重读,同一轮里换模型或工具列表会改掉之后各次调用的
578
+ // 前缀。同一会话的输入会话相同,入口平台只对没有出生平台的会话起作用,取开轮那条的
579
+ const settings = this.fields.get(sessionId, { platform });
580
+ const resolved = this.resolveLLM(settings.llm);
581
+ if (!resolved) {
582
+ const why = this.explainLLMFailure(settings.llm);
583
+ this.logger.warn(`LLM 解析失败,无法处理消息:${toSource(why)}`);
584
+ if (!reportOnly) {
585
+ const text = t('systemNotice', { why });
586
+ await this.dispatchOutbound({ content: toSource(text), text, sessionId, platform, source: 'system', turnId });
587
+ }
588
+ return signal.aborted ? end('aborted') : end('error', { error: why });
589
+ }
590
+ const llm = resolved.use;
591
+ // 这一轮的主模型能不能亲眼看图:按模型服务登记的能力(端点报告或单模型能力覆盖),
592
+ // 工具上下文与 agent:llm:before 同一个值
593
+ const acceptsImages = llm.capabilities.includes(LLMCapabilities.Vision);
594
+ const maxTokens = getModelMaxOutput(llm);
595
+ const contextLength = llm.contextLength;
596
+ const tokenBudget = this.computeTokenBudget(contextLength, maxTokens);
597
+ try {
598
+ const { maxToolIterations, historyLimit } = settings;
599
+ // 人设服务按这个会话自己读它的设置(人设、额外提示、结构化输出的两个开关)
600
+ const personaOpts = { sessionId, platform };
601
+ // 会话的深度思考档位:当前模型有这一档才带上;没设置、模型没有这一档或不能调时不带,由模型服务按它的默认档
602
+ const level = settings.think;
603
+ const thinking = llm.thinking?.levels.some(({ id }) => id === level) ? { think: level } : {};
604
+ // 多方与否取根会话记下的聊天窗口,开轮时取一次:说明文字与焦点指引同一个出处
605
+ const multiParty = originChatOf(sessionId, this.caps.sessionManager)?.multiParty === true;
606
+ const built = await this.buildMessages(turn, inputs, historyLimit, acceptsImages, multiParty, personaOpts);
607
+ const messages = built.messages;
608
+ /** 会话历史的条数:这一轮开头的活跃条数(含没发给模型的),加本回合追加的工具调用、结果与插进来的输入 */
609
+ let historyMessages = built.historyMessages;
610
+ // 这个会话启用的工具分组:工具注册表按会话读它的设置,不在时没有工具
611
+ const enabledGroups = this.caps.tools.present ? this.caps.tools.use.sessionGroups(sessionId, platform) : [];
612
+ this.logger.debug(`工具分组: platform=${platform}, enabledGroups=${JSON.stringify(enabledGroups)}`);
613
+ const tools = reportOnly || !this.caps.tools.present ? [] : this.caps.tools.use.getDefinitions({ groups: enabledGroups });
614
+ /**
615
+ * 一批工具调用的上下文,按这批开始那一刻已送进模型的输入构造:会话、发言者、参与者与入站回合由 originTurnOf 拼(字段语义
616
+ * 见它与 ToolCallContext,owner 的各个身份按权限服务合成一个参与者);入站回合只有这里填,工具据此正向区分真人消息与
617
+ * 内部注入。叠进来的人从下一批起算,已执行的不追溯。续做取起它的那一轮的发言者只用在这里,归档与提示词贡献仍按输入本身的
618
+ * userId
619
+ */
620
+ const toolContext = () => ({
621
+ ...originTurnOf(inputs.map(input => input.message), this.isOwner),
622
+ // 恒为数组:没开分组时是 [](列举面本就等价于「只给无分组工具」),让执行面的分组闸总是生效
623
+ enabledGroups,
624
+ // 为 true 时本循环把 ToolExecutionResult.images 挂到 tool 消息交给主模型(出口由 prepareLLMMessages 编码)
625
+ acceptsImages,
626
+ // 回合中止信号:等审批期间这一轮被停下时,还没执行的工具不再执行
627
+ signal,
628
+ turnId,
629
+ });
630
+ // 保存原始完整工具列表,后续迭代均以此为基础(避免被 hooks 修改后丢失)
631
+ const originalTools = [...tools];
632
+ // Hook: agent:llm:before — 插件可以修改消息或工具列表
633
+ const llmBeforeData = { messages, tools, acceptsImages, sessionId, platform, source: first.source };
634
+ // 组装先于链:贡献块(档案/技能/记忆/即时提示)先物化,拦截者审完整成品
635
+ await assemblePromptContributions(this.caps, llmBeforeData, inputs, {
636
+ buildTimeoutMs: this.promptBuildTimeoutMs,
637
+ signal,
638
+ });
639
+ await this.caps.hooks.use.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
640
+ if (reportOnly) {
641
+ llmBeforeData.tools = [];
642
+ // 这是结果汇报,不续跑历史工具链。保留人设与对话,去掉会诱发继续查任务的工具协议。
643
+ llmBeforeData.messages = llmBeforeData.messages.filter(message => message.role !== 'tool' &&
644
+ !message.toolCalls?.length &&
645
+ message.metadata?.injector !== WellKnownKinds.HostNotice);
646
+ // 放在钩子之后、消息末尾,确保裁剪仍保留本次结果及转述要求。
647
+ llmBeforeData.messages.push({
648
+ role: 'system',
649
+ content: `[宿主通知]\n${first.content}${first.hostNotice?.untrusted ? `\n\n${first.hostNotice.untrusted}` : ''}\n\n` +
650
+ '本轮只转述以上已确认的结果。沿用当前人设和回复格式,向当前会话自然地说明结果,保留通知中的链接;' +
651
+ '不要调用工具、重新查询状态或继续执行,不把编号、内部流程当成回复正文,也不要编造通知里没有的细节。请给出非空回复。',
652
+ metadata: { injector: WellKnownKinds.HostNotice },
644
653
  });
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 },
654
+ }
655
+ /**
656
+ * 插入点(调用模型之前,组装与 agent:llm:before 之后、硬裁剪之前):取这一轮待插入的输入,逐条归档、追加到消息末尾、挂上
657
+ * 各自的图,再增量组装、重跑 agent:llm:before(插进来的图要经它规范化),然后再取,取空为止
658
+ */
659
+ const insertJoined = async (data) => {
660
+ for (let taken = this.queues.take(turn); taken.length > 0; taken = this.queues.take(turn)) {
661
+ const joined = [];
662
+ for (const message of taken) {
663
+ joined.push({ message, archived: await this.archiveIncomingMessage(message, { turnId, entry: 'joined' }) });
664
+ this.logRoute(message, '插入', turnId);
665
+ }
666
+ inputs.push(...joined);
667
+ // 多方房间里插进被点名的:在它们之前另加一块列出至今被点名的各条,不改前面那块(之后各次请求仍以之前的为前缀)
668
+ const focus = multiParty && joined.some(input => wasAddressed(input.message)) ? this.focusOf(inputs) : null;
669
+ if (focus)
670
+ data.messages.push(focus);
671
+ for (const input of joined)
672
+ data.messages.push(await this.inputMessage(input, acceptsImages));
673
+ historyMessages += joined.length;
674
+ await assemblePromptContributions(this.caps, data, inputs, {
675
+ buildTimeoutMs: this.promptBuildTimeoutMs,
676
+ signal,
659
677
  });
678
+ await this.caps.hooks.use.run('agent:llm:before', data, undefined, { warnOnStall: true });
660
679
  }
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,
680
+ };
681
+ await insertJoined(llmBeforeData);
682
+ /** 本回合到目前为止经过了硬裁剪:首次调用的裁剪结果写回底稿,之后各次调用都在它上面追加 */
683
+ let turnTrimmed = false;
684
+ /** 首次调用的硬裁剪从底稿里去掉的历史与工具结果:之后各次调用按底稿估算没裁剪的量时补回 */
685
+ let trimmedFromBase = 0;
686
+ /**
687
+ * 硬裁剪这次要发出的列表(按 buildMessages 交出的消息对象认出历史),删了消息时记一行日志;untrimmedHistory 按没经过
688
+ * 硬裁剪的列表估算
689
+ */
690
+ const trimForCall = (list) => {
691
+ const sent = this.trimMessages(list, tokenBudget, built.history);
692
+ if (sent.messages.length < list.length) {
693
+ this.logger.info(`上下文硬裁剪: session=${sessionId}, ${list.length} → ${sent.messages.length} 条消息`);
694
+ }
695
+ turnTrimmed ||= sent.trimmed;
696
+ return { messages: sent.messages, untrimmedHistory: estimateHistoryTokens(list) + trimmedFromBase };
697
+ };
698
+ // 裁剪消息以确保不超过上下文窗口;裁剪结果写回,作为之后各次调用的底稿
699
+ const firstSent = trimForCall(llmBeforeData.messages);
700
+ trimmedFromBase = firstSent.untrimmedHistory - estimateHistoryTokens(firstSent.messages);
701
+ llmBeforeData.messages = firstSent.messages;
702
+ // 每次模型调用之后发一次用量:拆分按这次发出的消息与工具定义估算,used 取模型服务返回的输入 token
703
+ const reportUsage = (sent, sentTools, response) => this.emitTokenUsage({
704
+ sessionId,
705
+ platform,
706
+ turnId,
707
+ contextWindow: contextLength,
708
+ maxTokens,
709
+ tokenBudget,
710
+ messages: sent.messages,
711
+ tools: sentTools,
712
+ trimmed: turnTrimmed,
713
+ untrimmedHistory: sent.untrimmedHistory,
714
+ historyMessages,
715
+ historyLimit,
716
+ promptTokens: response.usage?.promptTokens,
717
+ });
718
+ this.logger.debug(`LLM 请求: ${llmBeforeData.messages.length} 条消息, ` +
719
+ `${llmBeforeData.tools.length} 个工具, ` +
720
+ `maxTokens=${maxTokens}`);
721
+ const t0 = Date.now();
722
+ // 本轮累加的用量,用于最终 assistant 消息的 modelInfo 元数据。
723
+ // 注:缓存命中量(usage.cachedPromptTokens)不进这里——本对象只喂
724
+ // assistant 消息的 modelInfo,而其字段是逐个挑的(见下方 turnModelInfo),
725
+ // 加进来也不会流到下游。命中率的观测出口是 LLM 响应日志那一行。
726
+ const turnUsageAcc = { promptTokens: 0, completionTokens: 0 };
727
+ const accUsage = (u) => {
728
+ if (!u)
729
+ return;
730
+ // promptTokens 语义 = "生成这条回复时的上下文大小"。工具迭代循环里每轮都把同一份
731
+ // (不断增长的)上下文重新发给模型,若累加会把重复发送的上下文重复计入,导致单条
732
+ // 回复虚高到几十万 token。故取最后一次调用的 prompt(覆盖而非累加)= 回复定稿时的真实上下文。
733
+ if (u.promptTokens != null)
734
+ turnUsageAcc.promptTokens = u.promptTokens;
735
+ // completionTokens 语义 = "本回合真正新生成的输出"(含中间 reasoning / tool-call 生成
736
+ // 与最终回复文本),每次调用都是不重复的新输出,故逐次累加。
737
+ turnUsageAcc.completionTokens += u.completionTokens ?? 0;
738
+ };
739
+ const firstResult = await this.consumeStream(llm, {
740
+ messages: llmBeforeData.messages,
741
+ tools: llmBeforeData.tools.length > 0 ? llmBeforeData.tools : undefined,
742
+ maxTokens,
743
+ signal,
744
+ ...thinking,
745
+ }, turnId, sessionId, platform, !reportOnly);
746
+ await reportUsage(firstSent, llmBeforeData.tools, firstResult);
747
+ accUsage(firstResult.usage);
748
+ /** 这一次模型回复里各工具调用在生成进度里配好的 callId 与工具名,按调用的位置 */
749
+ let generating = firstResult.generating;
750
+ // 维护本轮(一次完整对话回合)的统一时间线 segments:
751
+ // 多次 LLM 调用 + 工具执行的输出按到达顺序拼接,保留模型原本的"思考/回答/工具/思考/回答"交错。
752
+ const turnSegments = [...firstResult.segments];
753
+ let response = firstResult;
754
+ this.debugLogResponse(response, Date.now() - t0);
755
+ // Hook: agent:llm:after — 插件可以处理 LLM 返回结果
756
+ const llmAfterData = { response, messages: llmBeforeData.messages };
757
+ await this.caps.hooks.use.run('agent:llm:after', llmAfterData);
758
+ response = llmAfterData.response;
759
+ const assistantMetadata = this.buildAssistantMetadata(first, turnId);
760
+ // 工具调用循环:上限没设即不限
761
+ let iterations = 0;
762
+ const withinLimit = () => maxToolIterations === undefined || iterations < maxToolIterations;
763
+ while (!reportOnly && response.toolCalls && response.toolCalls.length > 0 && withinLimit()) {
764
+ if (signal.aborted)
765
+ throw new DOMException('Generation aborted', 'AbortError');
766
+ iterations++;
767
+ this.logger.debug(`工具调用迭代 ${iterations}: ${response.toolCalls.map(tc => tc.function.name).join(', ')}`);
768
+ // 先解析各次调用的参数:解析不了的,写进本回合消息与存档的那次调用的 arguments 换成 {}(见 parseToolArguments)
769
+ const parsedCalls = response.toolCalls.map(parseToolArguments);
770
+ const toolCalls = parsedCalls.map(parsed => parsed.toolCall);
771
+ // 将 assistant 消息 (含 toolCalls) 加入历史
772
+ llmBeforeData.messages.push({
773
+ role: 'assistant',
774
+ content: response.content,
775
+ toolCalls,
776
+ reasoningContent: response.reasoningContent,
777
+ metadata: assistantMetadata,
692
778
  });
693
- this.logger.debug(`LLM 请求: ${llmBeforeData.messages.length} 条消息, ` +
694
- `${llmBeforeData.tools.length} 个工具, ` +
695
- `maxTokens=${maxTokens}`);
696
- const t0 = Date.now();
697
- // 本轮累加的用量,用于最终 assistant 消息的 modelInfo 元数据。
698
- // 注:缓存命中量(usage.cachedPromptTokens)不进这里——本对象只喂
699
- // assistant 消息的 modelInfo,而其字段是逐个挑的(见下方 turnModelInfo),
700
- // 加进来也不会流到下游。命中率的观测出口是 LLM 响应日志那一行。
701
- const turnUsageAcc = { promptTokens: 0, completionTokens: 0 };
702
- const accUsage = (u) => {
703
- if (!u)
704
- return;
705
- // promptTokens 语义 = "生成这条回复时的上下文大小"。工具迭代循环里每轮都把同一份
706
- // (不断增长的)上下文重新发给模型,若累加会把重复发送的上下文重复计入,导致单条
707
- // 回复虚高到几十万 token。故取最后一次调用的 prompt(覆盖而非累加)= 回复定稿时的真实上下文。
708
- if (u.promptTokens != null)
709
- turnUsageAcc.promptTokens = u.promptTokens;
710
- // completionTokens 语义 = "本回合真正新生成的输出"(含中间 reasoning / tool-call 生成
711
- // 与最终回复文本),每次调用都是不重复的新输出,故逐次累加。
712
- turnUsageAcc.completionTokens += u.completionTokens ?? 0;
779
+ const assistantToolMessage = {
780
+ role: 'assistant',
781
+ content: response.content,
782
+ toolCalls,
783
+ reasoningContent: response.reasoningContent,
784
+ metadata: assistantMetadata,
713
785
  };
714
- const firstResult = await this.consumeStream(llm, {
715
- messages: llmBeforeData.messages,
716
- tools: llmBeforeData.tools.length > 0 ? llmBeforeData.tools : undefined,
717
- maxTokens,
718
- signal,
719
- ...thinking,
720
- }, turnId, incoming.sessionId, incoming.platform, !reportOnly);
721
- await reportUsage(firstSent, llmBeforeData.tools, firstResult);
722
- accUsage(firstResult.usage);
723
- /** 这一次模型回复里各工具调用在生成进度里配好的 callId 与工具名,按调用的位置 */
724
- let generating = firstResult.generating;
725
- // 维护本轮(一次完整对话回合)的统一时间线 segments:
726
- // 多次 LLM 调用 + 工具执行的输出按到达顺序拼接,保留模型原本的"思考/回答/工具/思考/回答"交错。
727
- const turnSegments = [...firstResult.segments];
728
- let response = firstResult;
729
- this.debugLogResponse(response, Date.now() - t0);
730
- // Hook: agent:llm:after — 插件可以处理 LLM 返回结果
731
- const llmAfterData = { response, messages: llmBeforeData.messages };
732
- await this.caps.hooks.use.run('agent:llm:after', llmAfterData);
733
- response = llmAfterData.response;
734
- // 收集工具调用摘要
735
- const toolCallSummaries = [];
736
- const assistantMetadata = this.buildAssistantMetadata(incoming, turnId);
737
- // 工具调用循环
738
- let iterations = 0;
739
- while (!reportOnly && response.toolCalls && response.toolCalls.length > 0 && iterations < maxToolIterations) {
740
- if (signal.aborted)
741
- throw new DOMException('Generation aborted', 'AbortError');
742
- iterations++;
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);
747
- // 将 assistant 消息 (含 toolCalls) 加入历史
748
- llmBeforeData.messages.push({
749
- role: 'assistant',
750
- content: response.content,
751
- toolCalls,
752
- reasoningContent: response.reasoningContent,
753
- metadata: assistantMetadata,
786
+ const toolMessages = [];
787
+ // 并行执行所有工具调用(互不依赖的工具无需串行等待)
788
+ const toolResultMaxChars = Math.floor(contextLength * this.toolResultMaxRatio * 3.5);
789
+ // 这一批的参与者按此刻已送进模型的输入算
790
+ const toolCtx = toolContext();
791
+ const parallelResults = await Promise.all(parsedCalls.map(async (parsed, position) => {
792
+ const { toolCall } = parsed;
793
+ // 这次调用的 id:开始与结束事件、工具上下文都带它。同一位置生成参数时报过同一个工具的进度,用那时配的
794
+ // (会话里已有它的工具卡);没报过的、或钩子改过调用列表而对不上的现在生成,免得接管画着别的工具的卡。
795
+ // 自己生成而不用模型给的 toolCall.id——模型给的 id 不保证唯一(有的 provider 用毫秒时间戳加序号拼,并发的
796
+ // 两轮会撞上),而 callId 要在全局配对开始与结束。模型的 id 只留在发回模型的 tool 消息里配对调用与结果。
797
+ const generated = generating.get(position);
798
+ const callId = generated?.name === toolCall.function.name ? generated.callId : crypto.randomUUID();
799
+ // 会话里的工具卡跟着这一对事件走:没执行就失败的调用(参数不合法、执行前的钩子出错)同样发一对,
800
+ // 结束带回给模型的错误,否则那张卡一直停在生成中
801
+ const announce = (phase, toolName, toolArgs, result) => this.caps.events.use.emit('tool:execute', {
802
+ sessionId,
803
+ turnId,
804
+ callId,
805
+ platform,
806
+ toolName,
807
+ args: toolArgs,
808
+ phase,
809
+ result,
754
810
  });
755
- const assistantToolMessage = {
756
- role: 'assistant',
757
- content: response.content,
758
- toolCalls,
759
- reasoningContent: response.reasoningContent,
760
- metadata: assistantMetadata,
761
- };
762
- const toolMessages = [];
763
- // 并行执行所有工具调用(互不依赖的工具无需串行等待)
764
- const toolResultMaxChars = Math.floor(contextLength * this.toolResultMaxRatio * 3.5);
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,
811
+ if ('error' in parsed) {
812
+ // 参数不是合法 JSON 对象:跳过本次执行,把错误当工具结果回给模型重新生成 arguments。
813
+ // 不能按空参数执行:「无必填参数」的工具会真的空参跑一遍(副作用已发生,模型却不知情)。
814
+ this.logger.warn(`工具参数解析失败: ${toolCall.function.name}: ${parsed.error}`);
815
+ const failedAt = Date.now();
816
+ const result = JSON.stringify({ error: parsed.error });
817
+ await announce('start', toolCall.function.name, {});
818
+ await announce('end', toolCall.function.name, {}, result);
819
+ return {
820
+ toolCall,
778
821
  callId,
779
- platform: incoming.platform,
780
- toolName,
781
- args: toolArgs,
782
- phase,
783
822
  result,
784
- });
785
- if ('error' in parsed) {
786
- // 参数不是合法 JSON 对象:跳过本次执行,把错误当工具结果回给模型重新生成 arguments。
787
- // 不能按空参数执行:「无必填参数」的工具会真的空参跑一遍(副作用已发生,模型却不知情)。
788
- this.logger.warn(`工具参数解析失败: ${toolCall.function.name}: ${parsed.error}`);
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);
793
- return {
794
- toolCall,
795
- callId,
796
- result,
797
- resultImages: undefined,
798
- toolName: toolCall.function.name,
799
- toolArgs: {},
800
- startTime: failedAt,
801
- endTime: failedAt,
802
- };
803
- }
804
- const callCtx = { ...toolCtx, callId };
805
- // Hook: agent:tool:before — 插件可以拦截或修改工具调用
806
- const toolBeforeData = { name: toolCall.function.name, args: parsed.args, toolCallContext: callCtx };
807
- // 单个工具的链路异常(钩子 / 守卫 / 结果处理抛错)只算该工具失败,转成它自己的错误结果:
808
- // 整批照常落定、整组落库,兄弟工具已发生的副作用不会因连坐而从历史里消失。
809
- // executeDone 区分失败发生在执行前还是执行后——后者须明说「已执行」,免得模型重做有副作用的操作。
810
- let executeDone = false;
811
- let toolT0;
812
- try {
813
- await this.caps.hooks.use.run('agent:tool:before', toolBeforeData);
814
- // 通知平台:工具开始执行
815
- await announce('start', toolBeforeData.name, toolBeforeData.args);
816
- // 紧随 start 赋值:catch 据此判断 start 已发出
817
- toolT0 = Date.now();
818
- this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${formatToolArgsForLog(toolBeforeData.args)}`);
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 服务不可用' }) }));
822
- executeDone = true;
823
- let result = executed.content;
824
- // 工具交给主模型看的图:只随本回合的 tool 消息走(出口由 prepareLLMMessages 编码),
825
- // 不进钩子/事件/时间线(那些面都是文本),也不落库(见 saveToolCallGroup)。
826
- const resultImages = executed.images;
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);
835
- result = toolAfterData.result;
836
- // 工具结果截断:按上下文窗口比例限制单条工具结果长度
837
- if (result.length > toolResultMaxChars) {
838
- this.logger.info(`工具结果过长 (${result.length} 字符),截断至 ${toolResultMaxChars} 字符: ${toolBeforeData.name}`);
839
- result = truncateChars(result, toolResultMaxChars, `\n... [工具输出已截断,原始长度 ${result.length} 字符]`);
840
- }
841
- const toolEndTime = Date.now();
842
- this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
843
- // 通知平台:工具执行完成
844
- await announce('end', toolBeforeData.name, toolBeforeData.args, result);
845
- return {
846
- toolCall,
847
- callId,
848
- result,
849
- resultImages,
850
- resultMetadata: toolAfterData.metadata,
851
- toolName: toolBeforeData.name,
852
- toolArgs: toolBeforeData.args,
853
- startTime: toolT0,
854
- endTime: toolEndTime,
855
- };
856
- }
857
- catch (err) {
858
- // 执行后的失败不回退到原始结果(fail-closed):after 钩子可能是脱敏钩子,失败时原始输出不能漏给模型
859
- const reason = err instanceof Error ? err.message : String(err);
860
- this.logger.warn(`工具${executeDone ? '已执行,但结果处理' : '调用'}失败: ${toolBeforeData.name} (${reason})`);
861
- const failedAt = Date.now();
862
- const result = JSON.stringify({
863
- error: executeDone ? `工具已执行,但结果处理失败:${reason}` : `工具调用失败:${reason}`,
864
- });
865
- // 执行前就失败的补发开始,再发配对的结束(emit 恒 resolve)
866
- if (toolT0 === undefined)
867
- await announce('start', toolBeforeData.name, toolBeforeData.args);
868
- await announce('end', toolBeforeData.name, toolBeforeData.args, result);
869
- return {
870
- toolCall,
871
- callId,
872
- result,
873
- resultImages: undefined,
874
- toolName: toolBeforeData.name,
875
- toolArgs: toolBeforeData.args,
876
- startTime: toolT0 ?? failedAt,
877
- endTime: failedAt,
878
- };
879
- }
880
- }));
881
- // 工具交出的图只服务紧接着的这一次请求:上一轮工具结果里的图在这里剥掉,否则多轮
882
- // 工具迭代会让图片在上下文里累积(每张数百 token,且不受文本裁剪约束)。
883
- for (let i = 0; i < llmBeforeData.messages.length; i++) {
884
- const m = llmBeforeData.messages[i];
885
- if (m.role === 'tool' && m.images) {
886
- const { images: _spent, ...rest } = m;
887
- llmBeforeData.messages[i] = rest;
888
- }
889
- }
890
- // 按原始 toolCalls 顺序将结果推入消息列表 + 时间线
891
- for (const { toolCall, callId, result, resultImages, resultMetadata, toolName, toolArgs, startTime, endTime, } of parallelResults) {
892
- const resultPreview = result.length > 200 ? `${result.slice(0, 200)}...` : result;
893
- toolCallSummaries.push(`[${toolCall.function.name}] ${resultPreview}`);
894
- // callId 随工具结果与时间线落库:没回复就收尾的一轮只落工具调用组,历史里的工具卡靠它对回这次调用
895
- const toolMessage = {
896
- role: 'tool',
897
- content: result,
898
- toolCallId: toolCall.id,
899
- ...(resultImages && resultImages.length > 0 ? { images: resultImages } : {}),
900
- metadata: {
901
- ...resultMetadata,
902
- [WellKnownMetadataKeys.TurnId]: turnId,
903
- [WellKnownMetadataKeys.CallId]: callId,
904
- },
823
+ resultImages: undefined,
824
+ toolName: toolCall.function.name,
825
+ toolArgs: {},
826
+ startTime: failedAt,
827
+ endTime: failedAt,
905
828
  };
906
- llmBeforeData.messages.push(toolMessage);
907
- toolMessages.push(toolMessage);
908
- // 工具调用追加到本回合的统一时间线
909
- turnSegments.push({
910
- type: 'tool_call',
911
- name: toolName,
912
- args: toolArgs,
829
+ }
830
+ const callCtx = { ...toolCtx, callId };
831
+ // Hook: agent:tool:before — 插件可以拦截或修改工具调用
832
+ const toolBeforeData = { name: toolCall.function.name, args: parsed.args, toolCallContext: callCtx };
833
+ // 单个工具的链路异常(钩子 / 守卫 / 结果处理抛错)只算该工具失败,转成它自己的错误结果:
834
+ // 整批照常落定、整组落库,兄弟工具已发生的副作用不会因连坐而从历史里消失。
835
+ // executeDone 区分失败发生在执行前还是执行后——后者须明说「已执行」,免得模型重做有副作用的操作。
836
+ let executeDone = false;
837
+ let toolT0;
838
+ try {
839
+ await this.caps.hooks.use.run('agent:tool:before', toolBeforeData);
840
+ // 通知平台:工具开始执行
841
+ await announce('start', toolBeforeData.name, toolBeforeData.args);
842
+ // 紧随 start 赋值:catch 据此判断 start 已发出
843
+ toolT0 = Date.now();
844
+ this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${formatToolArgsForLog(toolBeforeData.args)}`);
845
+ const executed = await (this.caps.tools.present
846
+ ? this.caps.tools.use.execute(toolBeforeData.name, toolBeforeData.args, callCtx)
847
+ : Promise.resolve({ content: JSON.stringify({ error: 'tools 服务不可用' }) }));
848
+ executeDone = true;
849
+ let result = executed.content;
850
+ // 工具交给主模型看的图:主模型能看图时才随本回合的 tool 消息走(出口由 prepareLLMMessages 编码),
851
+ // 不能看时工具硬塞的也不交,模型只读文字结果;不进钩子/事件/时间线(那些面都是文本),
852
+ // 也不落库(见 saveToolCallGroup)。
853
+ const resultImages = acceptsImages ? executed.images : undefined;
854
+ // Hook: agent:tool:after — 插件可以处理工具执行结果,写进 metadata 的随结果落库
855
+ const toolAfterData = {
856
+ name: toolBeforeData.name,
913
857
  result,
914
- startTime,
915
- endTime,
858
+ toolCallContext: callCtx,
859
+ metadata: {},
860
+ };
861
+ await this.caps.hooks.use.run('agent:tool:after', toolAfterData);
862
+ result = toolAfterData.result;
863
+ // 工具结果截断:按上下文窗口比例限制单条工具结果长度
864
+ if (result.length > toolResultMaxChars) {
865
+ this.logger.info(`工具结果过长 (${result.length} 字符),截断至 ${toolResultMaxChars} 字符: ${toolBeforeData.name}`);
866
+ result = truncateChars(result, toolResultMaxChars, `\n... [工具输出已截断,原始长度 ${result.length} 字符]`);
867
+ }
868
+ const toolEndTime = Date.now();
869
+ this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
870
+ // 通知平台:工具执行完成
871
+ await announce('end', toolBeforeData.name, toolBeforeData.args, result);
872
+ return {
873
+ toolCall,
916
874
  callId,
875
+ result,
876
+ resultImages,
877
+ resultMetadata: toolAfterData.metadata,
878
+ toolName: toolBeforeData.name,
879
+ toolArgs: toolBeforeData.args,
880
+ startTime: toolT0,
881
+ endTime: toolEndTime,
882
+ };
883
+ }
884
+ catch (err) {
885
+ // 执行后的失败不回退到原始结果(fail-closed):after 钩子可能是脱敏钩子,失败时原始输出不能漏给模型
886
+ const reason = err instanceof Error ? err.message : String(err);
887
+ this.logger.warn(`工具${executeDone ? '已执行,但结果处理' : '调用'}失败: ${toolBeforeData.name} (${reason})`);
888
+ const failedAt = Date.now();
889
+ const result = JSON.stringify({
890
+ error: executeDone ? `工具已执行,但结果处理失败:${reason}` : `工具调用失败:${reason}`,
917
891
  });
892
+ // 执行前就失败的补发开始,再发配对的结束(emit 恒 resolve)
893
+ if (toolT0 === undefined)
894
+ await announce('start', toolBeforeData.name, toolBeforeData.args);
895
+ await announce('end', toolBeforeData.name, toolBeforeData.args, result);
896
+ return {
897
+ toolCall,
898
+ callId,
899
+ result,
900
+ resultImages: undefined,
901
+ toolName: toolBeforeData.name,
902
+ toolArgs: toolBeforeData.args,
903
+ startTime: toolT0 ?? failedAt,
904
+ endTime: failedAt,
905
+ };
918
906
  }
919
- historyMessages += 1 + toolMessages.length;
920
- await this.saveToolCallGroup(incoming.sessionId, assistantToolMessage, toolMessages, turnPersistedTimestamps);
921
- // 继续请求 LLM (再次经过 hooks),使用原始完整工具列表而非被上一轮 hooks 修改过的列表
922
- const nextLlmData = {
923
- messages: llmBeforeData.messages,
924
- tools: [...originalTools],
925
- sessionId: incoming.sessionId,
926
- userId: incoming.userId,
927
- platform: incoming.platform,
928
- triggerType: incoming.triggerType,
929
- source: incoming.source,
930
- };
931
- // 已物化的贡献按全局键跳过;本轮工具调用新注册的贡献(如新激活技能)增量落位
932
- await assemblePromptContributions(this.caps, nextLlmData, {
933
- buildTimeoutMs: this.promptBuildTimeoutMs,
934
- signal,
935
- });
936
- await this.caps.hooks.use.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
937
- // 裁剪消息以确保不超过上下文窗口
938
- const nextSent = trimForCall(nextLlmData.messages);
939
- nextLlmData.messages = nextSent.messages;
940
- const tN = Date.now();
941
- const nextResult = await this.consumeStream(llm, {
942
- messages: nextLlmData.messages,
943
- tools: nextLlmData.tools.length > 0 ? nextLlmData.tools : undefined,
944
- maxTokens,
945
- signal,
946
- ...thinking,
947
- }, turnId, incoming.sessionId, incoming.platform);
948
- await reportUsage(nextSent, nextLlmData.tools, nextResult);
949
- turnSegments.push(...nextResult.segments);
950
- response = nextResult;
951
- generating = nextResult.generating;
952
- accUsage(response.usage);
953
- this.debugLogResponse(response, Date.now() - tN, iterations);
954
- const nextLlmAfterData = { response, messages: nextLlmData.messages };
955
- await this.caps.hooks.use.run('agent:llm:after', nextLlmAfterData);
956
- response = nextLlmAfterData.response;
957
- }
958
- // 检测是否因工具调用次数达到上限而退出循环
959
- const toolLimitReached = iterations >= maxToolIterations && response.toolCalls != null && response.toolCalls.length > 0;
960
- if (toolLimitReached) {
961
- this.logger.warn(`工具调用达到上限 (${maxToolIterations}),session=${incoming.sessionId}`);
962
- }
963
- // 保留原始 LLM 输出,用于存入 memory(避免纯文本历史污染 few-shot 示例)
964
- let rawLlmContent = response.content ?? '';
965
- let replyContent = rawLlmContent;
966
- // Hook: agent:reply:before — 插件可以修改最终回复
967
- // JSON 解析/修复统一由 persona 的 agent:reply:before 钩子处理
968
- const responseData = {
969
- content: replyContent,
970
- sessionId: incoming.sessionId,
971
- platform: incoming.platform,
972
- userId: incoming.userId,
973
- triggerType: incoming.triggerType,
974
- attempt: 0,
975
- };
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
- '请沿用当前人设和规定的回复格式,给出包含结果与已有链接的非空回复。';
907
+ }));
908
+ // 工具交出的图只服务紧接着的这一次请求:上一轮工具结果里的图在这里剥掉,否则多轮
909
+ // 工具迭代会让图片在上下文里累积(每张数百 token,且不受文本裁剪约束)。
910
+ for (let i = 0; i < llmBeforeData.messages.length; i++) {
911
+ const m = llmBeforeData.messages[i];
912
+ if (m.role === 'tool' && m.images) {
913
+ const { images: _spent, ...rest } = m;
914
+ llmBeforeData.messages[i] = rest;
991
915
  }
992
- };
993
- await validateReply();
994
- // 重试循环:当 hook(如 persona 的 outputFormat 解析)报告 retryRequested 时,
995
- // 把失败的 assistant 输出 + 系统反馈追加到消息列表,重新请求 LLM;最多按 maxRetries 次。
996
- // maxRetries 由 hook 端写入(plugin-persona 从 outputFormat.retries 读取,默认 1)。
997
- // 上游吐空流同样会被判不合格:此时没有可回放的失败输出,只追加系统反馈再请求一次。
998
- const maxRetries = Math.max(0, responseData.maxRetries ?? 0);
999
- let attempt = 0;
1000
- while (responseData.retryRequested && attempt < maxRetries) {
1001
- attempt++;
1002
- this.logger.debug(`agent:reply:before 请求重试 (attempt=${attempt}/${maxRetries}, session=${incoming.sessionId}): ${responseData.retryFeedback ?? '(无反馈)'}`);
1003
- if (rawLlmContent.length > 0)
1004
- llmBeforeData.messages.push({ role: 'assistant', content: rawLlmContent });
1005
- llmBeforeData.messages.push({
1006
- role: 'system',
1007
- content: responseData.retryFeedback ?? '上一次回复未能通过格式校验,请严格按照系统提示中规定的格式重新输出。',
1008
- });
1009
- const retrySent = trimForCall(llmBeforeData.messages);
1010
- const retryResult = await this.consumeStream(llm, {
1011
- messages: retrySent.messages,
1012
- tools: undefined,
1013
- maxTokens,
1014
- signal,
1015
- ...thinking,
1016
- }, turnId, incoming.sessionId, incoming.platform, !reportOnly);
1017
- await reportUsage(retrySent, [], retryResult);
1018
- turnSegments.push(...retryResult.segments);
1019
- response = retryResult;
1020
- accUsage(response.usage);
1021
- rawLlmContent = response.content ?? '';
1022
- // 用新输出再次跑 hook;hook 端根据 attempt 决定继续重试或走兜底(静默丢弃)
1023
- responseData.content = rawLlmContent;
1024
- responseData.archiveContent = undefined;
1025
- responseData.visibleContent = undefined;
1026
- responseData.retryRequested = false;
1027
- responseData.retryFeedback = undefined;
1028
- responseData.attempt = attempt;
1029
- await validateReply();
1030
- }
1031
- // 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
1032
- // 强制把 content 置空,避免原始未校验内容被外发。
1033
- if (responseData.retryRequested) {
1034
- this.logger.warn(`agent:reply:before 重试用尽但 retryRequested 仍为 true,强制丢弃回复内容 (session=${incoming.sessionId})`);
1035
- responseData.content = '';
1036
- responseData.retryRequested = false;
1037
- }
1038
- replyContent = responseData.content;
1039
- const archiveContent = responseData.archiveContent ?? rawLlmContent;
1040
- const visibleContent = responseData.visibleContent ?? replyContent;
1041
- // 重复检测:如果回复与最近一条 assistant 消息完全相同,视为模型"卡壳",静默跳过。
1042
- // 比的是 archiveContent(与落库同源口径)——启用 outputFormat 时历史里存的是整串 JSON,
1043
- // 拿解码后的纯文本去比永远不成立。
1044
- const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
1045
- if (archiveContent && lastAssistant?.content && archiveContent === lastAssistant.content) {
1046
- this.logger.warn(`检测到重复回复,跳过发送 (session=${incoming.sessionId})`);
1047
- replyContent = '';
1048
916
  }
1049
- // 提交点:此后的流结束标记、落库与外发成组执行。流结束后到这里之间(llm:after / 工具循环退出 /
1050
- // reply:before / 重试)被 latest-wins、手动停止或拆卸掐掉的回合,走下方 AbortError 收尾,不落库、不外发。
1051
- if (signal.aborted)
1052
- throw new DOMException('Generation aborted', 'AbortError');
1053
- // 发出流结束标记
1054
- await this.caps.events.use.emit('outbound:stream', {
1055
- sessionId: incoming.sessionId,
1056
- turnId,
1057
- platform: incoming.platform,
1058
- done: true,
1059
- toolLimitReached,
1060
- });
1061
- // 空回复(outputFormat 中 reply 字段为空字符串或仅空白)时静默,不发送消息
1062
- if (replyContent.trim().length === 0) {
1063
- this.logger.debug(`空回复,跳过发送 (session=${incoming.sessionId})`);
1064
- }
1065
- else {
1066
- const visibleSegments = !reportOnly && turnSegments.length > 0 ? turnSegments : undefined;
1067
- // 在 assistant 最终持久化时记录 modelInfo:前端从消息历史可还原 "这条回复
1068
- // 由哪个 model 生成、用了多少 token",方便用户验证模型切换是否生效。
1069
- const turnModelInfo = {
1070
- provider: llm.providerId,
1071
- model: llm.id,
1072
- promptTokens: turnUsageAcc.promptTokens || undefined,
1073
- completionTokens: turnUsageAcc.completionTokens || undefined,
1074
- };
1075
- const finalAssistantMetadata = {
1076
- ...(assistantMetadata ?? {}),
1077
- [WellKnownMetadataKeys.ModelInfo]: turnModelInfo,
917
+ // 按原始 toolCalls 顺序将结果推入消息列表 + 时间线
918
+ for (const { toolCall, callId, result, resultImages, resultMetadata, toolName, toolArgs, startTime, endTime, } of parallelResults) {
919
+ // callId 随工具结果与时间线落库:没回复就收尾的一轮只落工具调用组,历史里的工具卡靠它对回这次调用
920
+ const toolMessage = {
921
+ role: 'tool',
922
+ content: result,
923
+ toolCallId: toolCall.id,
924
+ ...(resultImages && resultImages.length > 0 ? { images: resultImages } : {}),
925
+ metadata: {
926
+ ...resultMetadata,
927
+ [WellKnownMetadataKeys.TurnId]: turnId,
928
+ [WellKnownMetadataKeys.CallId]: callId,
929
+ },
1078
930
  };
1079
- // 落库的是信封(如 outputFormat 的整串 JSON)时,另带解码后的可见正文,供检索、摘要等按语义读历史的一方用
1080
- if (visibleContent !== archiveContent) {
1081
- finalAssistantMetadata[WellKnownMetadataKeys.VisibleContent] = visibleContent;
1082
- }
1083
- // 保存最终 assistant 回复:优先存 persona 修复/规范化后的 JSON,保持格式完整,
1084
- // 避免坏 JSON 或解码后纯文本污染历史 few-shot 示例导致模型不再遵守 outputFormat
1085
- await this.saveToMemory(incoming.sessionId, {
1086
- role: 'assistant',
1087
- content: archiveContent,
1088
- reasoningContent: reportOnly ? undefined : response.reasoningContent,
1089
- timestamp: Date.now(),
1090
- metadata: finalAssistantMetadata,
1091
- segments: visibleSegments,
1092
- });
1093
- // 外发给平台:只带正文与这条回复的模型和 token,思考与时间线只在上面落库的消息上
1094
- await this.dispatchOutbound({
1095
- content: replyContent,
1096
- sessionId: incoming.sessionId,
1097
- platform: incoming.platform,
1098
- ...(incoming.hostNotice
1099
- ? { hostNotice: { kind: incoming.hostNotice.kind, id: incoming.hostNotice.id } }
1100
- : {}),
1101
- ...(incoming.hostNotice?.replyTo ? { replyTo: incoming.hostNotice.replyTo } : {}),
1102
- source: 'agent',
1103
- turnId,
1104
- modelInfo: turnModelInfo,
931
+ llmBeforeData.messages.push(toolMessage);
932
+ toolMessages.push(toolMessage);
933
+ // 工具调用追加到本回合的统一时间线
934
+ turnSegments.push({
935
+ type: 'tool_call',
936
+ name: toolName,
937
+ args: toolArgs,
938
+ result,
939
+ startTime,
940
+ endTime,
941
+ callId,
1105
942
  });
1106
943
  }
1107
- // Hook: agent:turn:after — 插件可以在完整消息周期结束后做后处理
1108
- const turnOutcome = replyContent.trim().length === 0 ? 'silent' : 'replied';
1109
- await this.caps.hooks.use.run('agent:turn:after', {
1110
- message: incoming,
1111
- reply: replyContent,
1112
- outcome: turnOutcome,
1113
- sessionId: incoming.sessionId,
1114
- turnId,
1115
- metadata: msgHookData.metadata,
944
+ historyMessages += 1 + toolMessages.length;
945
+ await this.saveToolCallGroup(sessionId, assistantToolMessage, toolMessages);
946
+ savedGroups = true;
947
+ // 继续请求 LLM (再次经过 hooks),使用原始完整工具列表而非被上一轮 hooks 修改过的列表
948
+ const nextLlmData = {
949
+ messages: llmBeforeData.messages,
950
+ tools: [...originalTools],
951
+ acceptsImages,
952
+ sessionId,
953
+ platform,
954
+ source: first.source,
955
+ };
956
+ // 已物化的贡献按全局键跳过;本轮工具调用新注册的贡献(如新激活技能)增量落位
957
+ await assemblePromptContributions(this.caps, nextLlmData, inputs, {
958
+ buildTimeoutMs: this.promptBuildTimeoutMs,
959
+ signal,
1116
960
  });
961
+ await this.caps.hooks.use.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
962
+ await insertJoined(nextLlmData);
963
+ // 裁剪消息以确保不超过上下文窗口
964
+ const nextSent = trimForCall(nextLlmData.messages);
965
+ nextLlmData.messages = nextSent.messages;
966
+ const tN = Date.now();
967
+ const nextResult = await this.consumeStream(llm, {
968
+ messages: nextLlmData.messages,
969
+ tools: nextLlmData.tools.length > 0 ? nextLlmData.tools : undefined,
970
+ maxTokens,
971
+ signal,
972
+ ...thinking,
973
+ }, turnId, sessionId, platform);
974
+ await reportUsage(nextSent, nextLlmData.tools, nextResult);
975
+ turnSegments.push(...nextResult.segments);
976
+ response = nextResult;
977
+ generating = nextResult.generating;
978
+ accUsage(response.usage);
979
+ this.debugLogResponse(response, Date.now() - tN, iterations);
980
+ const nextLlmAfterData = { response, messages: nextLlmData.messages };
981
+ await this.caps.hooks.use.run('agent:llm:after', nextLlmAfterData);
982
+ response = nextLlmAfterData.response;
1117
983
  }
1118
- catch (err) {
1119
- // 中止错误 — 静默退出,前端通过 outbound:stream done 清理 buffer。
1120
- //
1121
- // 历史教训:早期版本会用 turnPersistedTimestamps 把"本轮已持久化的中间消息"全部删掉,
1122
- // 注释里写的是防"半截 assistant 内容 / 假回退"。但实际审计 saveToolCallGroup(见下方)发现:
1123
- // 1. saveToolCallGroup 只在并行工具全部落定后整组写入(assistant tool_call + 所有
1124
- // tool result 一次性 push;单个工具的异常已在闭包内转成它自己的错误结果),不存在
1125
- // 写了一半的组;turnPersistedTimestamps 里只可能是"已落定、副作用已发生"的工具调用对。
1126
- // 2. 删除这些 = 让 agent 忘记自己刚刚做过的有副作用的事(戳一戳/发送消息/调度任务/…),
1127
- // 下一轮 LLM 看不到自己的行为,会重复调用,外部观察就是"agent 一直以为戳不了"。
1128
- // 3. 真正的 orphan 风险(assistant tool_calls 缺 tool result)由 sanitizeToolCallHistory
1129
- // 在装载历史时兜底过滤,不需要 abort 路径主动删除。
1130
- // 所以这里不再回滚,让已完成的工具调用记录留在 memory,agent 下一轮能正确感知。
1131
- if (err instanceof DOMException && err.name === 'AbortError') {
1132
- this.logger.info(`生成已中止: session=${incoming.sessionId}` +
1133
- (turnPersistedTimestamps.length > 0
1134
- ? `(保留本轮已完成的 ${turnPersistedTimestamps.length} 条工具调用记录,便于下一轮 agent 感知)`
1135
- : ''));
1136
- await this.caps.events.use.emit('outbound:stream', {
1137
- sessionId: incoming.sessionId,
1138
- turnId,
1139
- platform: incoming.platform,
1140
- done: true,
1141
- });
1142
- // 中止同样是回合终态:发 agent:turn:after(outcome=aborted) 让生命周期订阅方收尾——
1143
- // 编排的 agent 节点在等这一轮的结果,不发它就要等到超时才失败。
1144
- // 文档与 agent-api 早已声明 outcome 含 aborted,此处兑现契约。
1145
- await this.caps.hooks.use.run('agent:turn:after', {
1146
- message: incoming,
1147
- reply: '',
1148
- outcome: 'aborted',
1149
- sessionId: incoming.sessionId,
1150
- turnId,
1151
- metadata: msgHookData.metadata,
1152
- });
1153
- return;
1154
- }
1155
- const message = err instanceof Error ? err.message : String(err);
1156
- this.logger.error(`处理消息失败: ${message}`);
1157
- // 异常同样要结束流:不发 done,CLI 的流式块不收尾、会话里流式输出的条目不结束,
1158
- // 下一轮 delta 还会抹掉这条 [错误] 与用户的新输入。顺序与正常收尾一致——先 done 再发消息。
1159
- await this.caps.events.use.emit('outbound:stream', {
1160
- sessionId: incoming.sessionId,
1161
- turnId,
1162
- platform: incoming.platform,
1163
- done: true,
1164
- });
1165
- if (!reportOnly) {
1166
- await this.dispatchOutbound({
1167
- content: `[错误] ${message}`,
1168
- sessionId: incoming.sessionId,
1169
- platform: incoming.platform,
1170
- source: 'system',
1171
- });
984
+ // 最后一次模型调用返回了最后的回复:这一轮不再收输入,还没插进去的转为排队
985
+ this.queues.close(turn);
986
+ // 检测是否因工具调用次数达到上限而退出循环
987
+ const toolLimitReached = !withinLimit() && response.toolCalls != null && response.toolCalls.length > 0;
988
+ if (toolLimitReached) {
989
+ this.logger.warn(`工具调用达到上限 (${maxToolIterations}),session=${sessionId}`);
990
+ }
991
+ // 保留原始 LLM 输出:没有中间件改写落库内容时,落库的就是它
992
+ let rawLlmContent = response.content ?? '';
993
+ let replyContent = rawLlmContent;
994
+ // Hook: agent:reply:before — 插件可以修改最终回复
995
+ // JSON 解析/修复统一由 persona 的 agent:reply:before 钩子处理
996
+ const responseData = {
997
+ content: replyContent,
998
+ sessionId,
999
+ platform,
1000
+ attempt: 0,
1001
+ };
1002
+ const validateReply = async () => {
1003
+ const attemptedTools = reportOnly && !!response.toolCalls?.length;
1004
+ if (attemptedTools)
1005
+ responseData.content = '';
1006
+ await this.caps.hooks.use.run('agent:reply:before', responseData);
1007
+ // 误调用工具或静默先给模型一次纠正机会,复用格式重试预算,不能无限尝试或执行工具。
1008
+ const replyLinks = reportOnly ? (responseData.content.match(NOTICE_URL) ?? []) : [];
1009
+ const missingLink = noticeLinks.some(link => !replyLinks.some(candidate => candidate === link || candidate.replace(/[.!;:]+$/u, '') === link));
1010
+ if (reportOnly && (attemptedTools || !responseData.content.trim() || missingLink)) {
1011
+ responseData.content = '';
1012
+ responseData.maxRetries = Math.max(1, responseData.maxRetries ?? 0);
1013
+ responseData.retryRequested = true;
1014
+ responseData.retryFeedback =
1015
+ `${responseData.retryFeedback ?? ''}\n本轮只需转述已给出的宿主结果,不要调用工具或查询状态。` +
1016
+ '请沿用当前人设和规定的回复格式,给出包含结果与已有链接的非空回复。';
1172
1017
  }
1173
- // 异常也是回合终态:同样发 turn:after(outcome=error),等这一轮结果的订阅方才收得了尾。
1174
- await this.caps.hooks.use.run('agent:turn:after', {
1175
- message: incoming,
1176
- reply: '',
1177
- outcome: 'error',
1178
- error: message,
1179
- sessionId: incoming.sessionId,
1180
- turnId,
1181
- metadata: msgHookData.metadata,
1018
+ };
1019
+ await validateReply();
1020
+ // 重试循环:当 hook(如 persona 的 outputFormat 解析)报告 retryRequested 时,
1021
+ // 把失败的 assistant 输出 + 系统反馈追加到消息列表,重新请求 LLM;最多按 maxRetries 次。
1022
+ // maxRetries 由 hook 端写入(plugin-persona 从 outputFormat.retries 读取,默认 1)。
1023
+ // 上游吐空流同样会被判不合格:此时没有可回放的失败输出,只追加系统反馈再请求一次。
1024
+ const maxRetries = Math.max(0, responseData.maxRetries ?? 0);
1025
+ let attempt = 0;
1026
+ while (responseData.retryRequested && attempt < maxRetries) {
1027
+ attempt++;
1028
+ this.logger.debug(`agent:reply:before 请求重试 (attempt=${attempt}/${maxRetries}, session=${sessionId}): ${responseData.retryFeedback ?? '(无反馈)'}`);
1029
+ if (rawLlmContent.length > 0)
1030
+ llmBeforeData.messages.push({ role: 'assistant', content: rawLlmContent });
1031
+ llmBeforeData.messages.push({
1032
+ role: 'system',
1033
+ content: responseData.retryFeedback ?? '上一次回复未能通过格式校验,请严格按照系统提示中规定的格式重新输出。',
1182
1034
  });
1035
+ const retrySent = trimForCall(llmBeforeData.messages);
1036
+ const retryResult = await this.consumeStream(llm, {
1037
+ messages: retrySent.messages,
1038
+ tools: undefined,
1039
+ maxTokens,
1040
+ signal,
1041
+ ...thinking,
1042
+ }, turnId, sessionId, platform, !reportOnly);
1043
+ await reportUsage(retrySent, [], retryResult);
1044
+ turnSegments.push(...retryResult.segments);
1045
+ response = retryResult;
1046
+ accUsage(response.usage);
1047
+ rawLlmContent = response.content ?? '';
1048
+ // 用新输出再次跑 hook;hook 端根据 attempt 决定继续重试或走兜底(静默丢弃)
1049
+ responseData.content = rawLlmContent;
1050
+ responseData.archiveContent = undefined;
1051
+ responseData.visibleContent = undefined;
1052
+ responseData.retryRequested = false;
1053
+ responseData.retryFeedback = undefined;
1054
+ responseData.attempt = attempt;
1055
+ await validateReply();
1183
1056
  }
1184
- });
1185
- // 消息被拦截(如流控缓冲),通知前端结束 loading
1186
- if (!handled) {
1057
+ // 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
1058
+ // 强制把 content 置空,避免原始未校验内容被外发。
1059
+ if (responseData.retryRequested) {
1060
+ this.logger.warn(`agent:reply:before 重试用尽但 retryRequested 仍为 true,强制丢弃回复内容 (session=${sessionId})`);
1061
+ responseData.content = '';
1062
+ responseData.retryRequested = false;
1063
+ }
1064
+ replyContent = responseData.content;
1065
+ const archiveContent = responseData.archiveContent ?? rawLlmContent;
1066
+ const visibleContent = responseData.visibleContent ?? replyContent;
1067
+ // 提交点:此后的流结束标记、落库与外发成组执行。流结束后到这里之间(llm:after / 工具循环退出 /
1068
+ // reply:before / 重试)被停下的回合(停止键、禁言、停机等),走下方 AbortError 收尾,不落库、不外发。
1069
+ if (signal.aborted)
1070
+ throw new DOMException('Generation aborted', 'AbortError');
1071
+ // 发出流结束标记
1187
1072
  await this.caps.events.use.emit('outbound:stream', {
1188
- sessionId: incoming.sessionId,
1073
+ sessionId,
1189
1074
  turnId,
1190
- platform: incoming.platform,
1075
+ platform,
1191
1076
  done: true,
1077
+ toolLimitReached,
1192
1078
  });
1079
+ // 空回复(outputFormat 中 reply 字段为空字符串或仅空白)时静默,不发送消息
1080
+ if (replyContent.trim().length === 0) {
1081
+ this.logger.debug(`空回复,跳过发送 (session=${sessionId})`);
1082
+ }
1083
+ else {
1084
+ const visibleSegments = !reportOnly && turnSegments.length > 0 ? turnSegments : undefined;
1085
+ // 在 assistant 最终持久化时记录 modelInfo:前端从消息历史可还原 "这条回复
1086
+ // 由哪个 model 生成、用了多少 token",方便用户验证模型切换是否生效。
1087
+ const turnModelInfo = {
1088
+ provider: llm.providerId,
1089
+ model: llm.id,
1090
+ promptTokens: turnUsageAcc.promptTokens || undefined,
1091
+ completionTokens: turnUsageAcc.completionTokens || undefined,
1092
+ };
1093
+ const finalAssistantMetadata = {
1094
+ ...(assistantMetadata ?? {}),
1095
+ [WellKnownMetadataKeys.ModelInfo]: turnModelInfo,
1096
+ [WellKnownMetadataKeys.TurnRecord]: recordOf('replied'),
1097
+ };
1098
+ // 落库的是信封(如 outputFormat 的整串 JSON)时,另带解码后的可见正文,供检索、摘要等按语义读历史的一方用
1099
+ if (visibleContent !== archiveContent) {
1100
+ finalAssistantMetadata[WellKnownMetadataKeys.VisibleContent] = visibleContent;
1101
+ }
1102
+ // 保存最终 assistant 回复:落库存信封原文,交给模型的历史取可见正文(historyForModel)
1103
+ await this.saveToMemory(sessionId, {
1104
+ role: 'assistant',
1105
+ content: archiveContent,
1106
+ reasoningContent: reportOnly ? undefined : response.reasoningContent,
1107
+ timestamp: Date.now(),
1108
+ metadata: finalAssistantMetadata,
1109
+ segments: visibleSegments,
1110
+ });
1111
+ recorded = true;
1112
+ // 外发给平台:只带正文与这条回复的模型和 token,思考与时间线只在上面落库的消息上;
1113
+ // 正文是她写的,其中的 @、媒体与引用标记交适配器解析
1114
+ await this.dispatchOutbound({
1115
+ content: replyContent,
1116
+ sessionId,
1117
+ platform,
1118
+ ...(first.hostNotice ? { hostNotice: { kind: first.hostNotice.kind, id: first.hostNotice.id } } : {}),
1119
+ ...(first.hostNotice?.replyTo ? { replyTo: first.hostNotice.replyTo } : {}),
1120
+ markup: true,
1121
+ source: 'agent',
1122
+ turnId,
1123
+ modelInfo: turnModelInfo,
1124
+ });
1125
+ }
1126
+ // Hook: agent:turn:after — 插件可以在完整消息周期结束后做后处理;在 try 里等它,它的中间件出错同样按出错收尾
1127
+ return await end(replyContent.trim().length === 0 ? 'silent' : 'replied', { reply: replyContent });
1128
+ }
1129
+ catch (err) {
1130
+ // 中止错误 — 静默退出,前端通过 outbound:stream done 清理 buffer。
1131
+ //
1132
+ // 被停下的一轮里已完成的工具调用组留在历史里,不回滚:saveToolCallGroup 只在并行工具全部落定后整组写入(单个工具的
1133
+ // 异常已在闭包内转成它自己的错误结果),不存在写了一半的组,留下的只可能是「已落定、副作用已发生」的工具调用对;删掉
1134
+ // 它们等于让 agent 忘记自己刚做过的有副作用的事(戳一戳、发送消息、调度任务等),下一轮会重复调用。不完整的组
1135
+ // (assistant tool_calls 缺 tool result)由 sanitizeToolCallHistory 在装载历史时兜底过滤。
1136
+ if (err instanceof DOMException && err.name === 'AbortError') {
1137
+ await this.caps.events.use.emit('outbound:stream', { sessionId, turnId, platform, done: true });
1138
+ // 中止同样是回合终态:发 agent:turn:after(outcome=aborted) 让生命周期订阅方收尾——
1139
+ // 编排的 agent 节点在等这一轮的结果,不发它就要等到超时才失败。
1140
+ return end('aborted');
1141
+ }
1142
+ const error = textOf(err);
1143
+ this.logger.error(`处理消息失败: ${toSource(error)}`);
1144
+ // 异常同样要结束流:不发 done,会话里流式输出的条目不结束。顺序与正常收尾一致——先 done 再发消息。
1145
+ await this.caps.events.use.emit('outbound:stream', { sessionId, turnId, platform, done: true });
1146
+ if (!reportOnly) {
1147
+ const text = t('turnError', { error });
1148
+ await this.dispatchOutbound({ content: toSource(text), text, sessionId, platform, source: 'system', turnId });
1149
+ }
1150
+ // 异常也是回合终态:同样发 turn:after(outcome=error),等这一轮结果的订阅方才收得了尾。
1151
+ return end('error', { error });
1193
1152
  }
1194
1153
  }
1195
1154
  /**
1196
- * 构建发送给 LLM 的消息列表。历史按会话的条数线读:historyLimit 为 0 或没设置时读全部活跃历史。
1197
- * historyMessages 是从记忆读进的条数(含已归档的这一轮入站消息),与之后的清洗、裁剪无关
1155
+ * 构建发送给 LLM 的消息列表。历史按会话的条数线成段丢弃(见 readHistory):historyLimit 为 0 或没设置时发全部活跃历史,
1156
+ * 跳过这一轮各条输入的归档。historyMessages 是会话活跃历史里对话消息的条数(事件标记不算;含已归档的这一轮的输入与没发给
1157
+ * 模型的部分),与之后的清洗、裁剪无关;history 是发出的历史那一段的消息对象,硬裁剪按它认出历史。历史之后依次是易变块、
1158
+ * 由排着的真人消息开的一轮的说明、多方房间里有被点名的输入时的焦点指引、各条输入。acceptsImages 是这一轮的主模型能不能看图,
1159
+ * media 缺席时由它定当轮的图交不交
1198
1160
  */
1199
- async buildMessages(incoming, historyLimit, personaOpts, archivedIncoming) {
1161
+ async buildMessages(turn, inputs, historyLimit, acceptsImages, multiParty, personaOpts) {
1200
1162
  const messages = [];
1201
1163
  let historyMessages = 0;
1164
+ const { sessionId } = turn;
1202
1165
  // 1. 系统提示
1203
1166
  const systemPrompt = this.buildSystemPrompt(personaOpts);
1204
1167
  messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
1205
- // 2. 历史消息
1206
- const memory = this.caps.memory.present ? this.caps.memory.use : undefined;
1207
- if (memory) {
1208
- try {
1209
- const stored = await memory.getHistory(incoming.sessionId, historyLimit || Infinity);
1210
- historyMessages = stored.length;
1211
- const history = this.sanitizeToolCallHistory(stored, incoming.sessionId);
1212
- const now = Date.now();
1213
- for (const m of history) {
1214
- if (archivedIncoming && this.isSameMessage(m, archivedIncoming))
1215
- continue;
1216
- if (CONTROL_KINDS.includes(m.kind ?? ''))
1217
- continue;
1218
- // 为用户消息注入时间标注,帮助 LLM 理解时间先后。标注加在副本上:记忆服务可能直接交出存着的消息对象
1219
- if (m.role === 'user' && m.timestamp && m.content) {
1220
- const timeLabel = formatTimeLabel(m.timestamp, now);
1221
- if (timeLabel && !m.content.startsWith(`(${timeLabel})`)) {
1222
- messages.push({ ...m, content: `(${timeLabel}) ${m.content}` });
1223
- continue;
1224
- }
1225
- }
1226
- messages.push(m);
1227
- }
1228
- }
1229
- catch (err) {
1230
- this.logger.warn('获取历史消息失败:', err);
1168
+ // 2. 历史消息:跳过这一轮已归档的输入
1169
+ try {
1170
+ const archived = inputs.flatMap(input => (input.archived ? [input.archived] : []));
1171
+ const past = await this.modelHistory(sessionId, historyLimit, archived);
1172
+ historyMessages = past.count;
1173
+ if (past.dropped > 0) {
1174
+ this.logger.warn(`历史消息中发现不完整工具调用组,已跳过 ${past.dropped} 条 (session=${sessionId})`);
1231
1175
  }
1176
+ messages.push(...past.messages);
1232
1177
  }
1233
- // 3. 当前消息
1234
- //
1235
- // 内容主体由 message-archive 烘焙:sender 前缀 / 引用回复 / 图片描述 / 附件描述
1236
- // 都已经写进 archivedIncoming.content。这里只在外层加一个时间标注,
1237
- // 保证「LLM 看到的当前消息」与「历史消息」「向量库存档」三者一致。
1238
- const senderLabel = getSenderLabel(incoming.nickname, incoming.userId);
1239
- const nowLabel = formatTimeLabel(Date.now(), Date.now());
1240
- const archivedBody = archivedIncoming?.content;
1241
- const fallbackBody = senderLabel ? `[${senderLabel}]: ${incoming.content}` : incoming.content;
1242
- const currentContent = `(${nowLabel}) ${archivedBody ?? fallbackBody}`;
1178
+ catch (err) {
1179
+ this.logger.warn('获取历史消息失败:', err);
1180
+ }
1181
+ const history = new Set(messages.slice(1));
1182
+ // 3. 这一轮的输入。带来源的注入各自成一轮,宿主通知与委派只有这一条
1183
+ const first = inputs[0].message;
1243
1184
  // 宿主通知分支:宿主撰写的事件通知不是任何人的发言,与 proactive 一样以 system 呈现、
1244
1185
  // 不推当前 user 消息与易变块。不可信段只在当轮接在宿主正文之后(归档只留正文),
1245
1186
  // 之后的回合从历史里只看得到宿主正文。
1246
- if (incoming.hostNotice) {
1247
- const { untrusted } = incoming.hostNotice;
1187
+ if (first.hostNotice) {
1188
+ const { untrusted } = first.hostNotice;
1248
1189
  messages.push({
1249
1190
  role: 'system',
1250
- content: `[宿主通知]\n${incoming.content}${untrusted ? `\n\n${untrusted}` : ''}`,
1191
+ content: `[宿主通知]\n${first.content}${untrusted ? `\n\n${untrusted}` : ''}`,
1251
1192
  metadata: { injector: WellKnownKinds.HostNotice },
1252
1193
  });
1253
- return { messages, historyMessages };
1194
+ return { messages, historyMessages, history };
1254
1195
  }
1255
1196
  // 3a. proactive 分支:系统代发给本会话的任务指令(如 workflow 的 agent 节点)
1256
1197
  //
1257
1198
  // 这条消息不是用户请求,必须作为 system 指令呈现给 LLM,否则 agent 会把它当成
1258
1199
  // 真实用户在指挥(典型 BUG:agent 看到 user 角色的任务指令,回复时使用「您」
1259
1200
  // 「您好」等措辞,把派发方当成了用户)。
1260
- if (incoming.triggerType === 'proactive') {
1201
+ if (first.triggerType === 'proactive') {
1261
1202
  messages.push({
1262
1203
  role: 'system',
1263
1204
  content: `[跨会话委派 — 非用户消息]\n` +
1264
- `任务: ${incoming.content}\n\n` +
1205
+ `任务: ${first.content}\n\n` +
1265
1206
  `说明: 这是你(作为同一 agent 在另一会话的实例)派发给本会话的任务指令,` +
1266
1207
  `不是用户请求。处理时不要使用「您」「请问」等面向用户的措辞,按指令直接执行并简明回报结果。\n`,
1267
1208
  metadata: { injector: WellKnownKinds.CrossSessionDelegation },
1268
1209
  });
1269
- return { messages, historyMessages };
1210
+ return { messages, historyMessages, history };
1270
1211
  }
1271
1212
  // 3b. 普通用户消息分支
1272
- // 检测预处理附件内容——引导 LLM 综合分析而非逐项转述
1273
- const hasPreprocessed = /\[图片\d*[::]|\[文件[::]|--- 文件内容 ---/.test(currentContent);
1274
- if (hasPreprocessed) {
1213
+ // 本轮附件有描述正文(媒体识别的描述、文件说明)时,引导 LLM 综合作答而非逐项转述
1214
+ if (inputs.some(input => hasAttachmentDescriptions(input.message))) {
1275
1215
  messages.push({
1276
1216
  role: 'system',
1277
1217
  content: '用户消息中包含系统预处理的附件描述(图片识别结果和/或文件内容提取)。' +
@@ -1280,14 +1220,48 @@ class DefaultAgent {
1280
1220
  metadata: { injector: 'system-other' },
1281
1221
  });
1282
1222
  }
1223
+ // 易变上下文(当前时间 / 会话环境 / 上一轮状态)落在历史**之后**:
1224
+ // provider 的前缀缓存从第一个 token 起逐位比对,这几块原本混在人设卡开头,
1225
+ // 秒级时间戳一变,人设 + 记忆 + 历史近 7 万 token 的前缀全部作废——实测每个
1226
+ // 新回合命中率恒为 0,只有同回合内的工具迭代(prompt 不重建)才命中。
1227
+ // 放到这里语义也更顺:它们本就是「此刻的事实」,紧挨当前消息说更自然。
1228
+ const persona = this.caps.persona.present ? this.caps.persona.use : undefined;
1229
+ const volatileCtx = persona?.getVolatilePrompt?.(personaOpts);
1230
+ if (volatileCtx) {
1231
+ messages.push({ role: 'system', content: volatileCtx, metadata: { injector: VOLATILE_INJECTOR } });
1232
+ }
1233
+ // 由排着的真人消息开的一轮:说明它们是上一轮进行期间到的;多方房间里全是没点名她的插话时,没有要接的可以不说话
1234
+ if (turn.queued && turn.human)
1235
+ messages.push(queuedNote(multiParty && !turn.batch.some(wasAddressed)));
1236
+ // 群聊焦点指引:多方房间里有被点名的输入时,紧贴在这一轮的输入之前,逐条列出被点名的那几条
1237
+ const focus = multiParty ? this.focusOf(inputs) : null;
1238
+ if (focus)
1239
+ messages.push(focus);
1240
+ for (const input of inputs)
1241
+ messages.push(await this.inputMessage(input, acceptsImages));
1242
+ return { messages, historyMessages, history };
1243
+ }
1244
+ /**
1245
+ * 一条输入发给模型的写法:`(时间) <归档正文>`。正文由 message-archive 烘焙(发送者前缀、引用回复、附件描述);存档不在时
1246
+ * 按同一份拼法与同一条说话人规则自己拼(schema-message 的 buildIncomingContent、api-message-archive 的 speakerOf)。时间取
1247
+ * 归档时间戳,即进入这一轮的时刻:之后进历史时是同一个标注,前缀不变。带图的挂上它自己的图。空闲开话题的提示以 system 交出
1248
+ */
1249
+ async inputMessage({ message, archived }, acceptsImages) {
1250
+ const body = archived?.content ?? buildIncomingContent(message, this.speakerOf(message));
1251
+ const at = archived?.timestamp ?? Date.now();
1252
+ const content = `(${formatTimeLabel(at, Date.now())}) ${body}`;
1253
+ // 空闲提示是宿主写给模型的指令,不是谁的发言:标成本轮的指令块,锚位、裁剪与 DeepSeek 的 system 归位都按它认
1254
+ if (message.triggerType === 'idle') {
1255
+ return { role: 'system', content, metadata: { injector: WellKnownKinds.IdlePrompt } };
1256
+ }
1283
1257
  const userMessage = {
1284
1258
  role: 'user',
1285
- content: currentContent,
1286
- name: getMessageName(incoming.userId),
1287
- timestamp: Date.now(),
1259
+ content,
1260
+ name: getMessageName(message.userId),
1261
+ timestamp: at,
1288
1262
  };
1289
1263
  // 多模态:把 attachments 中的 image 项传递给 LLM(视觉模型多模态字段)
1290
- const imageAtts = incoming.attachments?.filter(a => a.kind === 'image') ?? [];
1264
+ const imageAtts = message.attachments?.filter(a => a.kind === 'image') ?? [];
1291
1265
  if (imageAtts.length > 0) {
1292
1266
  if (this.caps.media.present) {
1293
1267
  // media 在场:原样透传。出口形态由 media 的 agent:llm:before 中间件规范化
@@ -1295,13 +1269,11 @@ class DefaultAgent {
1295
1269
  // 动图提示都按 attachment.data 原串命中,改形态会打散。
1296
1270
  userMessage.images = imageAtts.map(a => a.data);
1297
1271
  }
1298
- else {
1299
- // media 缺席兜底:只翻译**已知会炸**的一种形态——适配器落盘产生的裸相对路径
1300
- //(provider 会把它当 base64 校验,整轮请求被拒,见
1301
- // test/plugins/media-model-images.test.ts 抬头的 400 事故)——经 storage 读出
1302
- // 转 data URI,保住「视觉主模型直通」的基础体验;翻译不了(storage 缺席/读失败)
1303
- // 就丢弃该图。其余一切形态(data:/http(s)/file:// 等)原样透传,与 media 出现
1304
- // 之前的行为一致,各 provider 按自己的解析器处置。
1272
+ else if (acceptsImages) {
1273
+ // media 缺席兜底(主模型不能看图时当轮的图不交,正文里的占位照旧):存储地址(适配器落盘的附件引用)provider 解不了(当 base64 校验,整轮请求被拒,见
1274
+ // test/plugins/media-model-images.test.ts 抬头的 400 事故),经 storage 读出转 data URI,保住「视觉主模型
1275
+ // 直通」的基础体验;读不出(storage 缺席/读失败)就丢弃该图。其余形态(data URI、http(s)、file:// 等)原样
1276
+ // 交给 provider,各按自己的解析器处置。
1305
1277
  const images = [];
1306
1278
  for (const a of imageAtts) {
1307
1279
  const img = await this.materializeImageWithoutMedia(a.data, a.mimeType);
@@ -1314,23 +1286,31 @@ class DefaultAgent {
1314
1286
  userMessage.images = images;
1315
1287
  }
1316
1288
  }
1317
- // 易变上下文(当前时间 / 会话环境 / 上一轮状态)落在历史**之后**:
1318
- // provider 的前缀缓存从第一个 token 起逐位比对,这几块原本混在人设卡开头,
1319
- // 秒级时间戳一变,人设 + 记忆 + 历史近 7 万 token 的前缀全部作废——实测每个
1320
- // 新回合命中率恒为 0,只有同回合内的工具迭代(prompt 不重建)才命中。
1321
- // 放到这里语义也更顺:它们本就是「此刻的事实」,紧挨当前消息说更自然。
1322
- const persona = this.caps.persona.present ? this.caps.persona.use : undefined;
1323
- const volatileCtx = persona?.getVolatilePrompt?.(personaOpts);
1324
- if (volatileCtx) {
1325
- messages.push({ role: 'system', content: volatileCtx, metadata: { injector: VOLATILE_INJECTOR } });
1326
- }
1327
- // 群聊焦点指引:仅 sessionType=group + triggerType ∈ {direct, immediate} 时注入,
1328
- // 紧贴在当前 user 消息前,告诉 LLM "下一条就是焦点"。详细动机见 helpers.ts。
1329
- const focusGuidance = buildFocusGuidance(incoming);
1330
- if (focusGuidance)
1331
- messages.push(focusGuidance);
1332
- messages.push(userMessage);
1333
- return { messages, historyMessages };
1289
+ return userMessage;
1290
+ }
1291
+ /** 一条输入的说话人:与存档的发送者前缀同一条规则(api-message-archive 的 speakerOf) */
1292
+ speakerOf(message) {
1293
+ return speakerOf(message, {
1294
+ sessionManager: this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined,
1295
+ authority: this.caps.authority.present ? this.caps.authority.use : undefined,
1296
+ });
1297
+ }
1298
+ /** 停下标记里停下的人的称呼:与发送者前缀同一条规则,当他在这个会话里说了一句话;没有停下的人时没有 */
1299
+ stopperLabel(sessionId, by) {
1300
+ const speaker = by && this.speakerOf({ content: '', sessionId, platform: by.platform, userId: by.userId });
1301
+ return getSenderLabel(speaker?.nickname, speaker?.userId);
1302
+ }
1303
+ /**
1304
+ * 这一轮被点名的输入的焦点指引:按时间与发送者账号逐条列出(名字由各条正文的发送者前缀给,群友自己设的名字不进系统消息);
1305
+ * 没有被点名的为 null
1306
+ */
1307
+ focusOf(inputs) {
1308
+ return buildFocusGuidance(inputs.flatMap(({ message, archived }) => {
1309
+ if (!wasAddressed(message))
1310
+ return [];
1311
+ const time = formatTimeLabel(archived?.timestamp ?? Date.now(), Date.now());
1312
+ return [message.userId ? `(${time}) ${message.userId}` : `(${time})`];
1313
+ }));
1334
1314
  }
1335
1315
  /**
1336
1316
  * 裁剪预算 = 上下文长度 × trimThresholdRatio − 最大输出 token − 512 安全余量(下限 1024)。
@@ -1362,7 +1342,7 @@ class DefaultAgent {
1362
1342
  * 发 token:usage:各维度的拆分按消息与工具定义估算,used 取模型服务返回的输入 token,没有时取估算。
1363
1343
  * 等全部监听者处理完才返回:摘要插件在回合结束时按这一轮最后一条用量判定,不能让它晚于回合结束
1364
1344
  */
1365
- async emitTokenUsage({ sessionId, platform, turnId, contextWindow, maxTokens, tokenBudget, messages, tools, trimmed, untrimmedHistory, historyMessages, promptTokens, }) {
1345
+ async emitTokenUsage({ sessionId, platform, turnId, contextWindow, maxTokens, tokenBudget, messages, tools, trimmed, untrimmedHistory, historyMessages, historyLimit, promptTokens, }) {
1366
1346
  // 按来源分类统计各消息的 token 占用
1367
1347
  let historyTokens = 0;
1368
1348
  let toolResultTokens = 0;
@@ -1461,6 +1441,7 @@ class DefaultAgent {
1461
1441
  trimmed,
1462
1442
  untrimmedHistory,
1463
1443
  historyMessages,
1444
+ historyLimit,
1464
1445
  breakdown: {
1465
1446
  system: systemTokens,
1466
1447
  persona: personaTokens,
@@ -1477,16 +1458,16 @@ class DefaultAgent {
1477
1458
  reservedForReply: maxTokens,
1478
1459
  },
1479
1460
  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 },
1461
+ { label: t('partHistory'), tokens: historyTokens },
1462
+ { label: t('partToolResults'), tokens: toolResultTokens },
1463
+ { label: t('partToolDefs'), tokens: toolDefsTokens },
1464
+ { label: t('partPersona'), tokens: personaTokens },
1465
+ { label: t('partMemorySummary'), tokens: memorySummaryTokens },
1466
+ { label: t('partMemoryVector'), tokens: memoryVectorTokens },
1467
+ { label: t('partSkills'), tokens: skillsTokens },
1468
+ { label: t('partPlatform'), tokens: platformTokens },
1469
+ { label: t('partSubtask'), tokens: subtaskTokens },
1470
+ { label: t('partSystemOther'), tokens: systemOtherTokens, injectors: injectorTokens },
1490
1471
  ],
1491
1472
  });
1492
1473
  // 节流日志:与 WebUI 看到的同一份数据,让 CLI / 文件日志使用者也能看见预算消耗。
@@ -1507,23 +1488,19 @@ class DefaultAgent {
1507
1488
  }
1508
1489
  /**
1509
1490
  * media 缺席时的图片物化兜底(仅 buildMessages 的 images 拷贝点使用)。
1510
- * 只翻译一种形态:适配器落盘产生的裸相对路径 `data/{kind}s/...`——它是已确证的
1511
- * 炸弹(provider 当 base64 校验 → 400 整轮被拒),经 storage 读出转 data URI;
1512
- * storage 缺席/读失败返回 null 由调用方丢弃该图。**其余一切形态原样透传**
1513
- *(data:/http(s)/file:// 等,各 provider 自行解析)——刻意不做更宽的判别:
1514
- * 透传即维持 media 出现之前的既有行为,改写范围只覆盖已知必炸的那一种。
1491
+ * 只翻译存储地址(适配器落盘的附件引用):provider 当 base64 校验 → 400 整轮被拒,经 storage 读出转 data URI;
1492
+ * storage 缺席/读失败返回 null 由调用方丢弃该图。**其余形态原样交给 provider**(data URI、http(s)、file:// 等,各 provider
1493
+ * 自行解析)。
1515
1494
  */
1516
1495
  async materializeImageWithoutMedia(data, mimeType) {
1517
1496
  const t = data.trim();
1518
- const rel = t.match(/^data\/((?:images|videos|audios|files)\/.+)$/);
1519
- if (!rel)
1497
+ if (!isStorageUri(t))
1520
1498
  return t;
1521
- // storage 按 URI 的根名路由;缺 data 根(含 storage 整体缺席)时抛错,落到下面的 catch
1499
+ // storage 按 URI 的根名路由;根不认识(含 storage 整体缺席)时抛错,落到下面的 catch
1522
1500
  const storage = this.caps.storage.use;
1523
1501
  try {
1524
- const raw = (await storage.readFile(`data:/${rel[1]}`));
1525
- // mime 优先取附件自带的 mimeType;缺省按 basename 扩展名推断(适配器落盘只产
1526
- // png/jpg/gif/webp 四种,见 attachment-cache 的 detectExtensionFromBuffer)
1502
+ const raw = (await storage.readFile(t));
1503
+ // mime 优先取附件自带的 mimeType;缺省按 basename 扩展名推断(适配器落盘的扩展名取文件头判定的格式)
1527
1504
  const base = t.slice(t.lastIndexOf('/') + 1);
1528
1505
  const dot = base.lastIndexOf('.');
1529
1506
  const ext = dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
@@ -1536,224 +1513,173 @@ class DefaultAgent {
1536
1513
  }
1537
1514
  }
1538
1515
  /**
1539
- * 裁剪消息列表,使总 token 数不超过预算;trimmed 报告入口估算超过了预算、进入了硬裁剪(不一定真裁掉了内容)。
1540
- * 不记日志:token:request 的快照同样走这里,删了消息的日志由真实回合的调用点记
1516
+ * 硬裁剪这次要发出的列表,使估算回到预算内;trimmed 报告进入了硬裁剪:入口估算超过了预算(不一定真裁掉了内容),或历史超过
1517
+ * 它的可用量、丢了一段。不记日志:token:request 的快照同样走这里,删了消息的日志由真实回合的调用点记。没改动的消息原样交出
1518
+ * (同一个对象:首次调用的结果写回底稿之后,history 照样认得出历史),改动的换成副本。
1541
1519
  *
1542
- * 渐进式压缩策略(保证 agent 在长工具链中无限运行):
1543
- * 1. 首条 system(主提示词)和末条消息永不删除
1544
- * 2. 缩减超出预留额度的 system 消息(长期记忆)
1545
- * 3. 截断过长的 tool 输出内容(保留头部关键信息)
1546
- * 4. 将最旧的 assistant+tool 组压缩为紧凑摘要(保留决策上下文)
1547
- * 5. 删除最旧的非 system 消息
1548
- * 6. 最后手段:删除 hook 注入的 system 消息
1520
+ * 列表分三段:头部(第一条非 system 之前:人设、技能、摘要等;属于历史的 system 消息不算)、历史(history 里的消息对象,即
1521
+ * 读出、清洗后发出的那段)、本轮(其余:易变块、本轮材料与提示、本轮的输入、本轮追加的工具调用与结果)。依次裁,第 1 步总做,
1522
+ * 之后任一步回到预算内即停:
1523
+ * 1. 历史按 token 成段丢:单位 U = ⌈预算 / 4⌉,历史可用 H = 预算 − 头部 − U(本轮固定留一个单位);历史超过 H 时(不看本轮
1524
+ * 多大)从第一条起丢到累计第一次达到 ⌈(历史 − H) / U⌉·U 的那一条,切点落在工具调用组中间时组里剩下的结果一起丢(对齐同
1525
+ * readHistory)。切点只由头部与历史定:头部两次压缩之间不变、历史只追加,切点只在历史跨过一个单位时前移,两次丢弃之间
1526
+ * 发出的开头逐字不变,前缀缓存才命中
1527
+ * 2. 只动本轮追加的:截短过长的工具结果、缩推理内容、把较早的工具调用组压成摘要。历史里的不动(会改开头),超了按第 1 步
1528
+ * 整段丢
1529
+ * 3. 本轮超过一个单位时仍超:历史接着按同一套单位往后丢,丢到累计第一次达到下一个单位整数倍的那一条(本轮在同一个单位内
1530
+ * 变大时切点不动)或丢完;会改开头
1531
+ * 4. 会改开头:注入的 system 块合计超过 memoryTokenBudget 时按比例缩,仍超时从最靠前的删起
1532
+ * 5. 最后手段:最后一组工具调用也截短过长的结果、缩推理内容,请求不超窗发出
1533
+ * 始终不动:首条 system、本轮的输入(user 消息,与宿主通知、委派、空闲开话题那一轮的指令块)。不删不压、只在第 5 步截短:
1534
+ * 最后一组工具调用、末条消息(格式重试的反馈)
1549
1535
  */
1550
- trimMessages(messages, budget) {
1551
- const result = messages.map(m => ({ ...m }));
1536
+ trimMessages(messages, budget, history) {
1537
+ let result = [...messages];
1552
1538
  let estimated = this.estimateTokens(result);
1553
- if (estimated <= budget)
1554
- return { messages: result, trimmed: false };
1555
- /** 重新扫描 system 消息索引(首条和末条之间) */
1556
- const findSystemIndices = () => {
1557
- const indices = [];
1558
- for (let i = 1; i < result.length - 1; i++) {
1559
- if (result[i].role === 'system')
1560
- indices.push(i);
1561
- }
1562
- return indices;
1563
- };
1564
- // === Phase 1: 缩减超出预留额度的 system 消息 ===
1565
- {
1566
- const sysIdx = findSystemIndices();
1567
- const sysTokens = sysIdx.reduce((s, i) => s + estimateMsgTokens(result[i]), 0);
1568
- if (sysTokens > this.memoryTokenBudget && sysIdx.length > 0) {
1569
- const ratio = this.memoryTokenBudget / sysTokens;
1570
- for (const idx of sysIdx) {
1571
- const msg = result[idx];
1572
- if (msg.content && msg.content.length > 200) {
1573
- const oldTokens = estimateMsgTokens(msg);
1574
- const targetLen = Math.max(200, Math.floor(msg.content.length * ratio));
1575
- msg.content = truncateChars(msg.content, targetLen, '\n... [记忆内容已缩减]');
1576
- estimated -= oldTokens - estimateMsgTokens(msg);
1577
- }
1578
- }
1579
- }
1580
- }
1581
- if (estimated <= budget)
1582
- return { messages: result, trimmed: true };
1583
- // === Phase 2: 截断过长的 tool 输出 ===
1584
- for (let i = 1; i < result.length - 1; i++) {
1585
- if (estimated <= budget)
1539
+ const over = estimated > budget;
1540
+ const directive = (m) => DIRECTIVE_KINDS.includes(String(m.metadata?.injector));
1541
+ // === 1. 历史按 token 成段丢 ===
1542
+ const unit = Math.ceil(budget / 4);
1543
+ /** 丢到不少于 tokens 的下一个单位整数倍 */
1544
+ const onGrid = (tokens) => Math.ceil(tokens / unit) * unit;
1545
+ let head = 0;
1546
+ for (const m of result) {
1547
+ if (m.role !== 'system')
1586
1548
  break;
1587
- if (result[i].role === 'tool' && result[i].content && result[i].content.length > 1500) {
1588
- const oldTokens = estimateMsgTokens(result[i]);
1589
- result[i].content = truncateChars(result[i].content, 500, '\n... [工具输出已截断]');
1590
- estimated -= oldTokens - estimateMsgTokens(result[i]);
1591
- }
1549
+ if (!history.has(m))
1550
+ head += estimateMsgTokens(m);
1592
1551
  }
1552
+ const past = result.filter(m => history.has(m));
1553
+ let cut = 0;
1554
+ let dropped = 0;
1555
+ /** 从历史开头起丢到累计估算达到 tokens 的那一条(含);切点落在工具调用组中间时,组里剩下的结果一起丢 */
1556
+ const dropPast = (tokens) => {
1557
+ const from = cut;
1558
+ const before = dropped;
1559
+ while (cut < past.length && dropped < tokens)
1560
+ dropped += estimateMsgTokens(past[cut++]);
1561
+ while (cut < past.length && past[cut].role === 'tool')
1562
+ dropped += estimateMsgTokens(past[cut++]);
1563
+ const gone = new Set(past.slice(from, cut));
1564
+ result = result.filter(m => !gone.has(m));
1565
+ estimated -= dropped - before;
1566
+ };
1567
+ const room = budget - head - unit;
1568
+ const pastTokens = past.reduce((sum, m) => sum + estimateMsgTokens(m), 0);
1569
+ if (pastTokens > room)
1570
+ dropPast(onGrid(pastTokens - room));
1593
1571
  if (estimated <= budget)
1594
- return { messages: result, trimmed: true };
1595
- // === Phase 2.5: 缩减 assistant 消息的 reasoningContent ===
1596
- // 深度思考模型的推理内容可能非常长(数万 token),优先缩减旧迭代的推理,
1597
- // 仍超预算时缩减最新一条(保留头尾摘要以保持上下文连贯性)
1598
- {
1599
- // 收集带 reasoningContent 的 assistant 消息索引(从旧到新)
1600
- const rcIndices = [];
1601
- for (let i = 1; i < result.length; i++) {
1602
- if (result[i].role === 'assistant' && result[i].reasoningContent && result[i].reasoningContent.length > 200) {
1603
- rcIndices.push(i);
1604
- }
1572
+ return { messages: result, trimmed: over || cut > 0 };
1573
+ // === 2. 只动本轮追加的,最后一组工具调用(本轮最后一条带调用的 assistant 与紧跟的结果)不动 ===
1574
+ const last = new Set();
1575
+ for (let i = result.length - 1; i >= 0; i--) {
1576
+ if (history.has(result[i]) || !result[i].toolCalls?.length)
1577
+ continue;
1578
+ for (let j = i; j < result.length && (j === i || result[j].role === 'tool'); j++)
1579
+ last.add(result[j]);
1580
+ break;
1581
+ }
1582
+ const appended = (m) => !history.has(m) && !last.has(m);
1583
+ /** 截短 pick 选中的过长工具结果(1500 字以上的留 500) */
1584
+ const shortenResults = (pick) => {
1585
+ for (let i = 0; i < result.length && estimated > budget; i++) {
1586
+ const m = result[i];
1587
+ if (m.role !== 'tool' || !pick(m) || !m.content || m.content.length <= 1500)
1588
+ continue;
1589
+ const shorter = { ...m, content: truncateChars(m.content, 500, '\n... [工具输出已截断]') };
1590
+ estimated -= estimateMsgTokens(m) - estimateMsgTokens(shorter);
1591
+ result[i] = shorter;
1605
1592
  }
1606
- // 从最旧开始,先截断非最后一条的推理,仍不够时截断最后一条
1607
- // 注意:DeepSeek 思考模式要求历史中凡有 reasoning_content 的消息必须原样带回,
1608
- // 不能将字段设为 undefined,否则 API 返回 400。因此只截断,不删除。
1609
- for (let k = 0; k < rcIndices.length && estimated > budget; k++) {
1610
- const idx = rcIndices[k];
1611
- const msg = result[idx];
1612
- const oldTokens = estimateMsgTokens(msg);
1613
- // 所有条目统一保留头部 200 字符(最新条保留稍多以保持上下文连贯性)
1614
- const keepLen = k < rcIndices.length - 1 ? 200 : 400;
1615
- msg.reasoningContent = truncateChars(msg.reasoningContent, keepLen, '\n... [推理内容已缩减]');
1616
- estimated -= oldTokens - estimateMsgTokens(msg);
1593
+ };
1594
+ /**
1595
+ * 缩 pick 选中的推理内容:从旧到新,旧的留 200 字,最新一条留 400。DeepSeek 思考模式要求历史中凡有 reasoning_content 的消息
1596
+ * 原样带回这个字段,设成 undefined 会被拒(400),所以只截断、不删除
1597
+ */
1598
+ const shrinkReasoning = (pick) => {
1599
+ const reasoned = result.flatMap((m, i) => m.role === 'assistant' && pick(m) && (m.reasoningContent?.length ?? 0) > 200 ? [i] : []);
1600
+ for (let k = 0; k < reasoned.length && estimated > budget; k++) {
1601
+ const m = result[reasoned[k]];
1602
+ const keep = k < reasoned.length - 1 ? 200 : 400;
1603
+ const shorter = {
1604
+ ...m,
1605
+ reasoningContent: truncateChars(m.reasoningContent ?? '', keep, '\n... [推理内容已缩减]'),
1606
+ };
1607
+ estimated -= estimateMsgTokens(m) - estimateMsgTokens(shorter);
1608
+ result[reasoned[k]] = shorter;
1609
+ }
1610
+ };
1611
+ shortenResults(appended);
1612
+ shrinkReasoning(appended);
1613
+ // 较早的工具调用组从最旧的起压成一条摘要(确实省 token 时)
1614
+ for (let i = 0; i < result.length && estimated > budget; i++) {
1615
+ const call = result[i];
1616
+ if (call.role !== 'assistant' || !call.toolCalls?.length || !appended(call))
1617
+ continue;
1618
+ let end = i + 1;
1619
+ while (end < result.length && result[end].role === 'tool')
1620
+ end++;
1621
+ const outputs = result.slice(i + 1, end);
1622
+ const before = [call, ...outputs].reduce((sum, m) => sum + estimateMsgTokens(m), 0);
1623
+ const previews = outputs.map(({ content }) => {
1624
+ const output = content ?? '';
1625
+ return output.length > 100 ? `${output.slice(0, 100)}...` : output;
1626
+ });
1627
+ const parts = call.toolCalls.map((tc, idx) => `${tc.function.name} → ${previews[idx] ?? '(无结果)'}`);
1628
+ const text = `[历史工具调用] ${parts.join(' | ')}`;
1629
+ const summary = { role: 'assistant', content: call.content ? `${call.content}\n${text}` : text };
1630
+ const after = estimateMsgTokens(summary);
1631
+ if (after < before) {
1632
+ result.splice(i, end - i, summary);
1633
+ estimated -= before - after;
1617
1634
  }
1618
1635
  }
1619
1636
  if (estimated <= budget)
1620
1637
  return { messages: result, trimmed: true };
1621
- // === Phase 3: 将旧的 assistant+tool 组压缩为摘要 ===
1622
- // 识别所有工具调用组 (assistant(toolCalls) + 紧跟的 tool 消息)
1623
- {
1624
- const groups = [];
1625
- for (let j = 1; j < result.length; j++) {
1626
- if (result[j].role === 'assistant' && result[j].toolCalls?.length) {
1627
- let end = j + 1;
1628
- while (end < result.length && result[end].role === 'tool')
1629
- end++;
1630
- groups.push({ start: j, end });
1631
- j = end - 1;
1632
- }
1633
- }
1634
- // 从最旧开始压缩,保护最后一组(当前工具链需要完整结果)
1635
- let offset = 0;
1636
- for (let g = 0; g < groups.length - 1 && estimated > budget; g++) {
1637
- const start = groups[g].start - offset;
1638
- const end = groups[g].end - offset;
1639
- const groupLen = end - start;
1640
- // 计算当前组的 token
1641
- let groupTokens = 0;
1642
- const toolPreviews = [];
1643
- for (let j = start; j < end; j++) {
1644
- groupTokens += estimateMsgTokens(result[j]);
1645
- if (result[j].role === 'tool') {
1646
- const c = result[j].content ?? '';
1647
- toolPreviews.push(c.length > 100 ? `${c.slice(0, 100)}...` : c);
1648
- }
1649
- }
1650
- // 构建紧凑摘要
1651
- const aMsg = result[start];
1652
- const names = aMsg.toolCalls.map(tc => tc.function.name);
1653
- const parts = names.map((n, idx) => `${n} → ${toolPreviews[idx] ?? '(无结果)'}`);
1654
- let text = `[历史工具调用] ${parts.join(' | ')}`;
1655
- if (aMsg.content)
1656
- text = `${aMsg.content}\n${text}`;
1657
- const summaryMsg = { role: 'assistant', content: text };
1658
- const summaryTokens = estimateMsgTokens(summaryMsg);
1659
- // 仅在确实能节省 token 时压缩
1660
- if (summaryTokens < groupTokens) {
1661
- result.splice(start, groupLen, summaryMsg);
1662
- estimated -= groupTokens - summaryTokens;
1663
- offset += groupLen - 1;
1664
- }
1665
- }
1666
- }
1638
+ // === 3. 本轮超过一个单位:历史接着按单位丢 ===
1639
+ dropPast(onGrid(dropped + estimated - budget));
1667
1640
  if (estimated <= budget)
1668
1641
  return { messages: result, trimmed: true };
1669
- // === Phase 4: 删除最旧的非 system 消息(保护最后一组工具调用 + 最新用户消息) ===
1670
- {
1671
- // 识别最后一组工具调用的索引范围,确保不被删除
1672
- const lastGroupIndices = new Set();
1673
- for (let j = result.length - 1; j >= 1; j--) {
1674
- if (result[j].role === 'assistant' && result[j].toolCalls?.length) {
1675
- lastGroupIndices.add(j);
1676
- let k = j + 1;
1677
- while (k < result.length && result[k].role === 'tool') {
1678
- lastGroupIndices.add(k);
1679
- k++;
1680
- }
1681
- break;
1682
- }
1683
- }
1684
- // 保护最新的 user 消息(用户发起任务的请求,删掉会导致模型丢失任务上下文)
1685
- let lastUserIdx = -1;
1686
- for (let j = result.length - 1; j >= 1; j--) {
1687
- if (result[j].role === 'user') {
1688
- lastUserIdx = j;
1689
- break;
1690
- }
1691
- }
1692
- const adjustAfterSplice = (splicedAt) => {
1693
- const updated = new Set();
1694
- for (const idx of lastGroupIndices)
1695
- updated.add(idx > splicedAt ? idx - 1 : idx);
1696
- lastGroupIndices.clear();
1697
- for (const idx of updated)
1698
- lastGroupIndices.add(idx);
1699
- if (lastUserIdx > splicedAt)
1700
- lastUserIdx--;
1701
- };
1702
- let i = 1;
1703
- while (estimated > budget && i < result.length - 1) {
1704
- if (result[i].role === 'system' || lastGroupIndices.has(i) || i === lastUserIdx) {
1705
- i++;
1642
+ // === 4. 注入的 system 块:首条与末条之间、不是本轮指令块的 ===
1643
+ const injected = () => result.flatMap((m, i) => (i > 0 && i < result.length - 1 && m.role === 'system' && !directive(m) ? [i] : []));
1644
+ const total = injected().reduce((sum, i) => sum + estimateMsgTokens(result[i]), 0);
1645
+ if (total > this.memoryTokenBudget) {
1646
+ const ratio = this.memoryTokenBudget / total;
1647
+ for (const i of injected()) {
1648
+ const m = result[i];
1649
+ if (!m.content || m.content.length <= 200)
1706
1650
  continue;
1707
- }
1708
- // assistant(含toolCalls) + 紧跟的 tool 消息成组删除
1709
- if (result[i].role === 'assistant' && result[i].toolCalls?.length) {
1710
- estimated -= estimateMsgTokens(result[i]);
1711
- result.splice(i, 1);
1712
- adjustAfterSplice(i);
1713
- while (i < result.length - 1 && result[i].role === 'tool') {
1714
- if (lastGroupIndices.has(i))
1715
- break;
1716
- estimated -= estimateMsgTokens(result[i]);
1717
- result.splice(i, 1);
1718
- adjustAfterSplice(i);
1719
- }
1720
- continue;
1721
- }
1722
- estimated -= estimateMsgTokens(result[i]);
1723
- result.splice(i, 1);
1724
- adjustAfterSplice(i);
1651
+ const targetLen = Math.max(200, Math.floor(m.content.length * ratio));
1652
+ const shorter = { ...m, content: truncateChars(m.content, targetLen, '\n... [记忆内容已缩减]') };
1653
+ estimated -= estimateMsgTokens(m) - estimateMsgTokens(shorter);
1654
+ result[i] = shorter;
1725
1655
  }
1726
1656
  }
1727
- if (estimated <= budget)
1728
- return { messages: result, trimmed: true };
1729
- // === Phase 5: 极端情况 — 删除 hook 注入的 system 消息 ===
1730
- // 从最靠前的块开始删。turn-context 迁移后头部只剩技能正文/会话摘要、
1731
- // 档案/记忆类材料都在尾部——正序扫描意味着极端裁剪时**先牺牲头部稳定块**、
1732
- // 可再生的尾部材料反而最后动,与理想优先级相反。维持正序是刻意取舍:
1733
- // 本相位是罕见的最后手段,且反转方向会先删掉紧邻当前消息的当轮提示
1734
- // (focus/平台事件说明),代价更直观;按 injector 分级则是为极端路径
1735
- // 引入一张需要长期维护的优先级表。真在生产观察到本相位高频触发时再议。
1736
- {
1737
- const sysIdx = findSystemIndices();
1738
- let removed = 0;
1739
- for (let j = 0; j < sysIdx.length && estimated > budget; j++) {
1740
- const idx = sysIdx[j] - removed;
1741
- estimated -= estimateMsgTokens(result[idx]);
1742
- result.splice(idx, 1);
1743
- removed++;
1744
- }
1657
+ // 仍超时从最靠前的块删起。头部只有技能正文/会话摘要、档案/记忆类材料都在尾部——正序扫描意味着极端
1658
+ // 裁剪时**先牺牲头部稳定块**、可再生的尾部材料反而最后动,与理想优先级相反。维持正序是刻意取舍:这是罕见的最后手段,且
1659
+ // 反转方向会先删掉紧邻当前消息的当轮提示(focus/平台事件说明),代价更直观;按 injector 分级则是为极端路径引入一张需要
1660
+ // 长期维护的优先级表。
1661
+ let removed = 0;
1662
+ for (const i of injected()) {
1663
+ if (estimated <= budget)
1664
+ break;
1665
+ estimated -= estimateMsgTokens(result[i - removed]);
1666
+ result.splice(i - removed, 1);
1667
+ removed++;
1745
1668
  }
1746
- // 大幅裁剪后注入继续执行提示,防止模型因上下文缺失而中止任务
1669
+ // === 5. 最后一组也截短过长的结果、缩推理内容 ===
1670
+ const inLast = (m) => last.has(m);
1671
+ shortenResults(inLast);
1672
+ shrinkReasoning(inLast);
1673
+ // 大幅裁剪后注入继续执行提示,防止模型因上下文缺失而中止任务:放在本轮最后一条输入之后,没有输入(快照)时放在末尾
1747
1674
  if (messages.length - result.length >= 6) {
1748
1675
  const hint = {
1749
1676
  role: 'system',
1750
1677
  content: '[系统提示] 由于上下文长度限制,部分历史消息已被压缩或移除。请基于当前可见的上下文和最新用户请求继续完成任务,不要因为看不到之前的细节而停止工作。如果你之前有正在执行的多步骤任务或计划,请查看对话摘要和 todo-list 工具确认当前进度,然后继续未完成的步骤。',
1751
1678
  metadata: { injector: 'system-other' },
1752
1679
  };
1753
- // 插入到最后一条 user 消息之后(如果有),否则插到末尾前
1754
- let insertIdx = result.length - 1;
1755
- for (let j = result.length - 1; j >= 1; j--) {
1756
- if (result[j].role === 'user') {
1680
+ let insertIdx = result.length;
1681
+ for (let j = result.length - 1; j >= 0; j--) {
1682
+ if (!history.has(result[j]) && (result[j].role === 'user' || directive(result[j]))) {
1757
1683
  insertIdx = j + 1;
1758
1684
  break;
1759
1685
  }
@@ -1778,13 +1704,18 @@ class DefaultAgent {
1778
1704
  await archive.saveMessage(sessionId, message);
1779
1705
  }
1780
1706
  catch (err) {
1781
- this.logger.warn('保存消息到记忆失败:', err);
1707
+ this.logger.warn(`保存消息到记忆失败 (session=${sessionId}):`, err);
1782
1708
  }
1783
1709
  }
1784
1710
  }
1785
- /** 这一轮落库的 assistant 消息的元数据;带 turnId,会话的客户端据此把存档归回它所在的回合 */
1711
+ /**
1712
+ * 这一轮落库的 assistant 消息的元数据;带 turnId,会话的客户端据此把存档归回它所在的回合。自身账号随窗口:子会话没有
1713
+ * 窗口,取根会话的
1714
+ */
1786
1715
  buildAssistantMetadata(incoming, turnId) {
1787
- const identity = getPlatformSelfIdentity(this.caps.platform, incoming.platform, incoming.sessionId);
1716
+ const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
1717
+ const root = sm && rootSessionOf(sm.getSession(incoming.sessionId), id => sm.getSession(id));
1718
+ const identity = getPlatformSelfIdentity(this.caps.platform, incoming.platform, root?.id ?? incoming.sessionId);
1788
1719
  const metadata = {
1789
1720
  [WellKnownMetadataKeys.Platform]: incoming.platform,
1790
1721
  senderType: 'assistant',
@@ -1802,84 +1733,67 @@ class DefaultAgent {
1802
1733
  metadata[WellKnownMetadataKeys.SessionType] = incoming.sessionType;
1803
1734
  return Object.keys(metadata).length > 0 ? metadata : undefined;
1804
1735
  }
1805
- async saveToolCallGroup(sessionId, assistantMessage, toolMessages, persistedTimestamps) {
1736
+ async saveToolCallGroup(sessionId, assistantMessage, toolMessages) {
1806
1737
  const timestamp = Date.now();
1807
1738
  await this.saveToMemory(sessionId, { ...assistantMessage, timestamp });
1808
- persistedTimestamps?.push(timestamp);
1809
1739
  for (let i = 0; i < toolMessages.length; i++) {
1810
- const ts = timestamp + i + 1;
1811
1740
  // 工具交出的图只服务本回合的主模型(data URI 体积大、历史回看也不再需要它),不落库
1812
1741
  const { images: _transient, ...persisted } = toolMessages[i];
1813
- await this.saveToMemory(sessionId, { ...persisted, timestamp: ts });
1814
- persistedTimestamps?.push(ts);
1742
+ await this.saveToMemory(sessionId, { ...persisted, timestamp: timestamp + i + 1 });
1815
1743
  }
1816
1744
  }
1817
- sanitizeToolCallHistory(history, sessionId) {
1818
- const result = [];
1819
- let dropped = 0;
1820
- for (let i = 0; i < history.length; i++) {
1821
- const message = history[i];
1822
- if (message.role === 'tool') {
1823
- dropped++;
1745
+ /**
1746
+ * 交给模型的会话历史,真实回合与 token:request 的快照共用:按条数线读出的那一段(见 readHistory)清洗工具调用组,跳过控制类
1747
+ * 与 skip 里的各条,用户消息加时间标注,她的回复取可见正文(historyForModel)。改动都在副本上:记忆服务可能直接交出存着的
1748
+ * 消息对象。count 同 readHistory,dropped 是清洗跳过的条数;没有记忆服务时为空。不记日志:快照同样走这里,跳过的条数由真实
1749
+ * 回合的调用点记
1750
+ */
1751
+ async modelHistory(sessionId, historyLimit, skip = []) {
1752
+ if (!this.caps.memory.present)
1753
+ return { messages: [], count: 0, dropped: 0 };
1754
+ const stored = await readHistory(this.caps.memory.use, sessionId, historyLimit);
1755
+ const { kept, dropped } = this.sanitizeToolCallHistory(stored.messages);
1756
+ const now = Date.now();
1757
+ const messages = [];
1758
+ for (const m of kept) {
1759
+ if (skip.some(input => isSameMessage(m, input)))
1824
1760
  continue;
1825
- }
1826
- if (message.role === 'assistant' && message.toolCalls?.length) {
1827
- const expectedIds = new Set(message.toolCalls.map(tc => tc.id));
1828
- const seenIds = new Set();
1829
- const tools = [];
1830
- let j = i + 1;
1831
- while (j < history.length && history[j].role === 'tool') {
1832
- const toolMessage = history[j];
1833
- const id = toolMessage.toolCallId;
1834
- if (!id || !expectedIds.has(id) || seenIds.has(id))
1835
- break;
1836
- tools.push(toolMessage);
1837
- seenIds.add(id);
1838
- j++;
1839
- }
1840
- if (seenIds.size === expectedIds.size) {
1841
- result.push(message, ...tools);
1842
- }
1843
- else {
1844
- dropped += 1 + tools.length;
1845
- }
1846
- i = j - 1;
1761
+ if (CONTROL_KINDS.includes(m.kind ?? ''))
1847
1762
  continue;
1763
+ // 为用户消息注入时间标注,帮助 LLM 理解时间先后
1764
+ if (m.role === 'user' && m.timestamp && m.content) {
1765
+ const timeLabel = formatTimeLabel(m.timestamp, now);
1766
+ if (timeLabel && !m.content.startsWith(`(${timeLabel})`)) {
1767
+ messages.push({ ...m, content: `(${timeLabel}) ${m.content}` });
1768
+ continue;
1769
+ }
1848
1770
  }
1849
- result.push(message);
1771
+ messages.push(historyForModel(m));
1850
1772
  }
1851
- if (dropped > 0) {
1852
- this.logger.warn(`历史消息中发现不完整工具调用组,已跳过 ${dropped} 条 (session=${sessionId})`);
1853
- }
1854
- return result;
1855
- }
1856
- isSameMessage(a, b) {
1857
- return isSameMessage(a, b);
1773
+ return { messages, count: stored.count, dropped };
1858
1774
  }
1859
- async archiveIncomingMessageInOrder(lane, incoming) {
1860
- const previous = this.archiveQueues.get(lane) ?? Promise.resolve();
1861
- const current = previous.catch(() => undefined).then(() => this.archiveIncomingMessage(incoming));
1862
- const tail = current.then(() => undefined, () => undefined);
1863
- this.archiveQueues.set(lane, tail);
1864
- try {
1865
- return await current;
1866
- }
1867
- finally {
1868
- if (this.archiveQueues.get(lane) === tail) {
1869
- this.archiveQueues.delete(lane);
1870
- }
1775
+ /**
1776
+ * 装载历史时清洗工具调用组:按 schema-message 的 groupToolCalls 归组(夹进调用与结果之间的别的消息挪到组后),结果不齐的组
1777
+ * 连同找到的结果跳过,不在任何组里的结果跳过。dropped 是跳过的条数
1778
+ */
1779
+ sanitizeToolCallHistory(history) {
1780
+ const kept = [];
1781
+ let dropped = 0;
1782
+ for (const { at, shape } of groupToolCalls(history)) {
1783
+ if (shape === 'partial' || shape === 'loose')
1784
+ dropped += at.length;
1785
+ else
1786
+ kept.push(...at.map(i => history[i]));
1871
1787
  }
1788
+ return { kept, dropped };
1872
1789
  }
1873
- async archiveIncomingMessage(incoming) {
1874
- // 跳过非真实用户输入:闲聊主动触发是系统提示,不应作为 user 消息写入历史
1875
- if (incoming.source === 'idle-trigger')
1876
- return undefined;
1790
+ /** 归档一条输入:进了一轮的带回合 id 与进入方式(开轮 opened,插进来 joined),放掉的不带 */
1791
+ async archiveIncomingMessage(message, turn) {
1877
1792
  const archive = this.getArchiveForWrite();
1878
1793
  if (!archive)
1879
1794
  return undefined;
1880
1795
  try {
1881
- const result = await archive.archiveIncoming(incoming);
1882
- return result.message;
1796
+ return (await archive.archiveIncoming(message, turn))?.message;
1883
1797
  }
1884
1798
  catch (err) {
1885
1799
  this.logger.warn('归档用户消息失败:', err);
@@ -1912,198 +1826,106 @@ class DefaultAgent {
1912
1826
  const configSchema = defineConfig({
1913
1827
  systemPrompt: {
1914
1828
  type: 'textarea',
1915
- label: '行为准则提示词',
1916
- description: '定义 Agent 的行为准则。当人设插件存在时,身份描述由人设提供,此处仅作为行为指令追加。',
1829
+ label: t('systemPromptLabel'),
1830
+ description: t('systemPromptDescription'),
1917
1831
  default: '',
1918
1832
  },
1919
1833
  memoryTokenBudget: {
1920
1834
  type: 'number',
1921
- label: '长期记忆预留 Token',
1835
+ label: t('memoryTokenBudgetLabel'),
1922
1836
  default: 4096,
1923
1837
  secret: false,
1924
- description: '为注入的 system 消息预留的 token 额度:超出时先按比例缩减,极端情况下(最后阶段)仍会删除',
1838
+ description: t('memoryTokenBudgetDescription'),
1925
1839
  },
1926
1840
  promptBuildTimeoutMs: {
1927
1841
  type: 'number',
1928
- label: '提示词贡献构建超时 (ms)',
1842
+ label: t('promptBuildTimeoutMsLabel'),
1929
1843
  default: 10000,
1930
- description: '单个 agent:prompt 贡献 build 的等待上限。挂死的构建(如网络检索卡住)超时后本轮缺席、其余照常,避免拖住每次 LLM 调用。0 表示不设限。',
1844
+ description: t('promptBuildTimeoutMsDescription'),
1931
1845
  },
1932
1846
  toolResultMaxRatio: {
1933
1847
  type: 'number',
1934
- label: '工具结果最大比例',
1848
+ label: t('toolResultMaxRatioLabel'),
1935
1849
  default: 0.15,
1936
- description: '单条工具结果占上下文窗口的最大比例 (0~1),超出则截断。例如 0.15 表示 15%',
1850
+ description: t('toolResultMaxRatioDescription'),
1937
1851
  },
1938
1852
  trimThresholdRatio: {
1939
1853
  type: 'number',
1940
- label: '裁剪触发比例',
1854
+ label: t('trimThresholdRatioLabel'),
1941
1855
  default: 1.0,
1942
- description: '裁剪预算 = 上下文长度 × 该比例 − 最大输出 token − 512 安全余量(下限 1024)。本次调用估算输入 token 超过该预算才会对消息列表做内存裁剪(不影响 DB)。默认 1.0 表示用满扣除输出预留后的可用窗口;调低可提前裁剪。压缩触发在会话设置里调:自动压缩线(摘要插件登记)与历史条数上限(本插件登记)。',
1856
+ description: t('trimThresholdRatioDescription'),
1943
1857
  },
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
- },
1858
+ // ----- 按会话字段:各层可覆盖,每一轮按会话读 -----
1859
+ llm: {
1860
+ type: 'llm-ref',
1861
+ label: t('llmLabel'),
1862
+ description: t('llmDescription'),
1863
+ ...MODEL_REF_TEXT,
1864
+ // 全局层不在插件配置里:是本插件的 llm 偏好
1865
+ session: { prominence: 'composer', preference: 'llm', unset: t('llmUnset'), minLevel: 1 },
1969
1866
  },
1970
- {
1971
- key: 'think',
1972
- field: {
1973
- type: 'select',
1974
- label: '深度思考',
1975
- dynamicOptions: 'llm-thinking',
1976
- description: '可选的档位随所用的模型;没设置或当前模型没有这一档时按模型的默认档',
1977
- },
1867
+ think: {
1868
+ type: 'select',
1869
+ label: t('thinkLabel'),
1870
+ description: t('thinkDescription'),
1978
1871
  // 可选项是「模型」这一项生效的那个模型声明的档位
1979
- dependsOn: 'llm',
1980
- appliesTo: ['owner', 'room', 'task'],
1981
- prominence: 'common',
1982
- order: 20,
1983
- validate: value => (typeof value === 'string' && value !== '' ? undefined : '要是档位 id(非空字符串)'),
1872
+ dynamicOptions: 'llm-thinking',
1873
+ session: { prominence: 'common', order: 20, dependsOn: 'llm', unset: t('thinkUnset'), minLevel: 1 },
1984
1874
  },
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',
1875
+ maxToolIterations: {
1876
+ type: 'number',
1877
+ label: t('maxToolIterationsLabel'),
1878
+ description: t('maxToolIterationsDescription'),
1879
+ integer: true,
1880
+ min: 1,
1881
+ session: { prominence: 'advanced', unset: t('maxToolIterationsUnset') },
1997
1882
  },
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,
1883
+ historyLimit: {
1884
+ type: 'number',
1885
+ label: t('historyLimitLabel'),
1886
+ description: t('historyLimitDescription'),
1887
+ integer: true,
1888
+ min: 0,
1889
+ default: 0,
1890
+ session: { prominence: 'advanced' },
2012
1891
  },
2013
- ];
1892
+ });
2014
1893
  function run(caps) {
2015
1894
  const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
2016
- for (const setting of SESSION_SETTINGS)
2017
- caps.sessionManager.use.registerSetting(setting);
2018
1895
  const agentImpl = new DefaultAgent(caps, cfg, caps.lifecycle.use.id);
2019
1896
  caps.provide.use(agentService, agentImpl);
2020
- // 收尾段中止在飞回合并等 AbortError 落定。
2021
- // 第一方栈由 core 关停编排(optional 环成员先全部 drain)与 agent:turn:after 收口;
1897
+ // 收尾段停下全部在跑的一轮、放掉排着的(归档;按 core 的关停编排 agent 关完之前存档还在),等它们以 AbortError 收尾落定;
1898
+ // 之后到的输入一律放掉。第一方栈由 core 关停编排(optional 环成员先全部 drain)与 agent:turn:after 收口;
2022
1899
  // session-manager 的 onDrain 兜的是未装 agent 或钩子未挂上的路径。
2023
1900
  // 超时沿用 core 对 onDrain 的 disposeTimeoutMs,不另加配置键。
2024
- caps.lifecycle.use.onDrain(() => agentImpl.abortInflightAndSettle(), '中止在飞回合');
2025
- // 清理段再 abort 一次:drain 超时或未走到收尾时的兜底,表空则空操作。
2026
- caps.lifecycle.use.onDispose(() => agentImpl.abortAll(), '中止在飞回合(兜底)');
2027
- // 会话删除:丢弃该会话的 token 日志节流状态(与 todo-list / file-reader 走同一条会话清理路径)
2028
- caps.events.use.on('session:deleted', sessionId => agentImpl.dropTokenLogState(sessionId));
1901
+ caps.lifecycle.use.onDrain(() => agentImpl.queues.shutdown(), '停下在跑的回合');
1902
+ // 清理段再停一次:drain 超时或未走到收尾时的兜底,不再等落定;已停下的是空操作。
1903
+ caps.lifecycle.use.onDispose(() => void agentImpl.queues.shutdown(), '停下在跑的回合(兜底)');
1904
+ // 会话删除:停下这个会话(见 sessionDeleted)
1905
+ caps.events.use.on('session:deleted', sessionId => agentImpl.sessionDeleted(sessionId));
2029
1906
  const agent = agentImpl;
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}』`;
2052
- }
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}]`];
2061
- }
2062
- const fmtModel = (c) => (c?.llm ? `${c.llm.provider}/${c.llm.model}` : undefined);
2063
- const fmtPersona = (c) => c?.persona;
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
- }
1907
+ // /model 列可用模型、/persona 列可用人设;会话设置的查看与修改是会话设置插件的 /session 系列
2087
1908
  // 分页渲染 helper:限制每页 PAGE_SIZE 条 + 关键词过滤 + 翻页提示(/model 与 /persona 共用)。
2088
1909
  const PAGE_SIZE = 20;
2089
- function renderPaged(all, keyword, page, label, cmd) {
1910
+ function renderPaged(all, keyword, page, label, command) {
2090
1911
  const kw = keyword.trim().toLowerCase();
2091
1912
  const list = kw ? all.filter(x => x.toLowerCase().includes(kw)) : all;
2092
1913
  if (list.length === 0)
2093
- return kw ? `无匹配 "${keyword}" 的${label}` : `(无可用${label})`;
1914
+ return kw ? t('pagedNoMatch', { keyword, label }) : t('pagedNone', { label });
2094
1915
  const pages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
2095
1916
  const p = Math.min(Math.max(1, Math.trunc(page) || 1), pages);
2096
1917
  const body = list.slice((p - 1) * PAGE_SIZE, p * PAGE_SIZE).map(x => `- ${x}`);
2097
- const lines = [`${label}(${kw ? `含"${keyword}" ` : ''}共 ${list.length} · 第 ${p}/${pages} 页):`, ...body];
1918
+ const counts = { label, count: list.length, page: p, pages };
1919
+ const lines = [kw ? t('pagedHeaderMatching', { ...counts, keyword }) : t('pagedHeader', counts), ...body];
2098
1920
  if (pages > 1) {
2099
1921
  const next = p < pages ? p + 1 : 1;
2100
- lines.push(`—— 翻页: ${cmd}${kw ? ` ${keyword}` : ''} -p ${next}${kw ? '' : ` 搜索: ${cmd} <关键词>`}`);
1922
+ lines.push(kw ? t('pagedHintMatching', { command, keyword, next }) : t('pagedHint', { command, next }));
2101
1923
  }
2102
- return lines.join('\n');
1924
+ return textLines(lines);
2103
1925
  }
2104
1926
  caps.commands.use
2105
- .command('model [keyword:string]', '列出/搜索可用对话模型。分页显示,-p 翻页;如 /model grok')
2106
- .option('page', '-p <n:number>', { description: '页码(默认 1)' })
1927
+ .command('model [keyword:string]', t('modelDescription'))
1928
+ .option('page', '-p <n:number>', { description: t('pageOptionDescription') })
2107
1929
  .action(async (argv, keyword) => {
2108
1930
  const seen = new Set();
2109
1931
  const ids = [];
@@ -2114,156 +1936,31 @@ function run(caps) {
2114
1936
  ids.push(e.id);
2115
1937
  }
2116
1938
  const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
2117
- return renderPaged(ids, String(keyword ?? ''), page, '可用模型', '/model');
1939
+ return renderPaged(ids, String(keyword ?? ''), page, t('modelsLabel'), '/model');
2118
1940
  });
2119
1941
  caps.commands.use
2120
- .command('persona [keyword:string]', '列出/搜索可用人设(分页,-p 翻页)')
2121
- .option('page', '-p <n:number>', { description: '页码(默认 1)' })
1942
+ .command('persona [keyword:string]', t('personaDescription'))
1943
+ .option('page', '-p <n:number>', { description: t('pageOptionDescription') })
2122
1944
  .action(async (argv, keyword) => {
2123
1945
  const persona = caps.persona.present ? caps.persona.use : undefined;
2124
1946
  const names = persona?.listModels ? await persona.listModels() : [];
2125
1947
  const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
2126
- return renderPaged(names, String(keyword ?? ''), page, '可用人设', '/persona');
2127
- });
2128
- // ---- 会话级「模型 + 人设 + 深度思考 + 名称」配置(onebot 等平台对话直接改当前对话生效) ----
2129
- caps.commands.use
2130
- .command('session', '查看当前对话生效的设定及来源。设定指模型、人设、深度思考与名称', { risk: 'sensitive' })
2131
- .action(async (argv) => {
2132
- if (!caps.sessionManager.present)
2133
- return NO_SESSION_MANAGER;
2134
- const smSvc = caps.sessionManager.use;
2135
- const sid = argv.session.sessionId;
2136
- const session = smSvc.getSession(sid);
2137
- return [
2138
- `会话: ${session?.name ?? sid}`,
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),
2144
- session ? '' : '(本对话尚无独立配置记录,全部继承默认)',
2145
- '────',
2146
- '改配置: /session.set -m <模型> -p <人设> -t <档位> [-n <名>] (/model 看可用模型/人设名错时也会列出)',
2147
- '复位: /session.reset (-m 仅模型 / -p 仅人设 / -t 仅深度思考)',
2148
- ]
2149
- .filter(Boolean)
2150
- .join('\n');
2151
- });
2152
- caps.commands.use
2153
- .command('session.set', '设定当前对话的模型、人设、深度思考或显示名。会话级覆盖,持久化,重启不丢', {
2154
- risk: 'sensitive',
2155
- examples: [
2156
- '/session.set -m @aalis/plugin-llm-openai:main/gpt-4o -p catgirl',
2157
- '/session.set -p strict-reviewer',
2158
- '/session.set -t off',
2159
- '/session.set -n 深夜助手',
2160
- ],
2161
- })
2162
- .option('model', '-m <ref:string>', { description: '模型 provider/model(LLM entry 的 contextId;/model 可列出)' })
2163
- .option('persona', '-p <name:string>', { description: '人设卡名(不含后缀)' })
2164
- .option('think', '-t <level:string>', {
2165
- description: '深度思考的档位 id,按当前模型的档位(/session 列出;会话级覆盖,/session.reset 后回落默认)',
2166
- })
2167
- .option('name', '-n <label:string>', { description: '会话显示名(可选)' })
2168
- .action(async (argv) => {
2169
- if (!caps.sessionManager.present)
2170
- return NO_SESSION_MANAGER;
2171
- const smSvc = caps.sessionManager.use;
2172
- const modelRef = typeof argv.options.model === 'string' ? argv.options.model.trim() : '';
2173
- const personaName = typeof argv.options.persona === 'string' ? argv.options.persona.trim() : '';
2174
- const level = typeof argv.options.think === 'string' ? argv.options.think.trim() : '';
2175
- const displayName = typeof argv.options.name === 'string' ? argv.options.name.trim() : '';
2176
- if (!modelRef && !personaName && !level && !displayName) {
2177
- return '至少给一项。敲 /session.set -h 看用法';
2178
- }
2179
- const config = {};
2180
- // 模型:校验存在再落,避免错名静默回退默认
2181
- let chosen;
2182
- if (modelRef) {
2183
- const lastSlash = modelRef.lastIndexOf('/');
2184
- if (lastSlash <= 0 || lastSlash === modelRef.length - 1) {
2185
- return '模型格式错误。请用 `provider/model`,例如 `@aalis/plugin-llm-openai:main/gpt-4o`';
2186
- }
2187
- const chatModels = listLLMModels(caps.llm, { caps: ['chat'] });
2188
- chosen = chatModels.find(e => e.id === modelRef);
2189
- if (!chosen) {
2190
- return `未知模型 "${modelRef}"。用 /model <关键词> 搜索(如 /model grok),共 ${chatModels.length} 个可用。`;
2191
- }
2192
- config.llm = { provider: modelRef.slice(0, lastSlash), model: modelRef.slice(lastSlash + 1) };
2193
- }
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 拒绝写入
2206
- if (personaName) {
2207
- const persona = caps.persona.present ? caps.persona.use : undefined;
2208
- if (persona?.listModels) {
2209
- const names = await persona.listModels();
2210
- if (!names.includes(personaName)) {
2211
- return `未知人设 "${personaName}"。用 /persona 查看可用人设(共 ${names.length} 个)。`;
2212
- }
2213
- }
2214
- config.persona = personaName;
2215
- }
2216
- await smSvc.ensureSession(argv.session.sessionId, { config, ...(displayName ? { name: displayName } : {}) });
2217
- const lines = ['本对话已切换 →'];
2218
- if (config.llm)
2219
- lines.push(`· 模型: ${config.llm.provider}/${config.llm.model}`);
2220
- if (config.persona)
2221
- lines.push(`· 人设: ${config.persona}`);
2222
- if (config.think)
2223
- lines.push(`· 深度思考: ${config.think}`);
2224
- if (displayName)
2225
- lines.push(`· 名称: ${displayName}`);
2226
- lines.push('后续消息持续生效(已持久化,重启不丢)。/session.reset 可复位。');
2227
- return lines.join('\n');
1948
+ return renderPaged(names, String(keyword ?? ''), page, t('personasLabel'), '/persona');
2228
1949
  });
2229
- caps.commands.use
2230
- .command('session.reset', '复位当前对话的会话级覆盖。默认清模型、人设与深度思考,-m/-p/-t 单独清对应项', {
2231
- risk: 'sensitive',
2232
- examples: ['/session.reset', '/session.reset -m', '/session.reset -p', '/session.reset -t'],
2233
- })
2234
- .option('model', '-m', { description: '仅复位模型覆盖' })
2235
- .option('persona', '-p', { description: '仅复位人设覆盖' })
2236
- .option('think', '-t', { description: '仅复位深度思考覆盖' })
2237
- .action(async (argv) => {
2238
- if (!caps.sessionManager.present)
2239
- return NO_SESSION_MANAGER;
2240
- const smSvc = caps.sessionManager.use;
2241
- const mFlag = argv.options.model === true;
2242
- const pFlag = argv.options.persona === true;
2243
- const tFlag = argv.options.think === true;
2244
- // 都不给 = 全清;给了任意 flag = 只清点名的那些
2245
- const noFlags = !mFlag && !pFlag && !tFlag;
2246
- const clearModel = mFlag || noFlags;
2247
- const clearPersona = pFlag || noFlags;
2248
- const clearThink = tFlag || noFlags;
2249
- const sid = argv.session.sessionId;
2250
- const session = smSvc.getSession(sid);
2251
- const willClear = (clearModel && session?.config?.llm) ||
2252
- (clearPersona && session?.config?.persona) ||
2253
- (clearThink && session?.config?.think != null);
2254
- if (willClear) {
2255
- // null 删掉这一项,回到继承链
2256
- const patch = {};
2257
- if (clearModel)
2258
- patch.llm = null;
2259
- if (clearPersona)
2260
- patch.persona = null;
2261
- if (clearThink)
2262
- patch.think = null;
2263
- await smSvc.updateSession(sid, { config: patch });
2264
- }
2265
- const eff = smSvc.resolveConfig(sid, argv.session.platform);
2266
- return `已复位 → 模型: ${eff.llm ? `${eff.llm.provider}/${eff.llm.model}` : '(默认)'},人设: ${eff.persona ?? '(默认)'},深度思考: ${eff.think ?? '(默认)'}`;
1950
+ // /stop 同停止键:只停本会话在跑的一轮,排着的照常开下一轮。不看等级:发出者是这一轮的参与者(进了这一轮的各条输入按
1951
+ // originTurnOf 算:真人的话是发言者,续做与子任务是起它的那一轮的参与者,定时任务与编排是它的授权身份),或是 owner;排队与
1952
+ // 待插入的不算,没有账号的不算任何人
1953
+ caps.commands.use.command('stop', t('stopDescription'), { risk: 'safe' }).action(({ session }) => {
1954
+ const { sessionId, platform, userId } = session;
1955
+ const inputs = agentImpl.queues.inputs(sessionId);
1956
+ if (!inputs)
1957
+ return t('stopIdle');
1958
+ const { participants } = originTurnOf(inputs, agentImpl.isOwner);
1959
+ const inTurn = Boolean(userId) && participants.some(who => who.platform === platform && who.userId === userId);
1960
+ if (!inTurn && !agentImpl.isOwner(platform, userId))
1961
+ return t('stopDenied');
1962
+ agentImpl.abort(sessionId, { reason: '/stop', ...(userId ? { by: { platform, userId } } : {}) });
1963
+ return t('stopped');
2267
1964
  });
2268
1965
  // 监听 token:request 事件 — 客户端刷新/重连时主动请求 token 用量
2269
1966
  caps.events.use.on('token:request', async (data) => {
@@ -2276,42 +1973,41 @@ function run(caps) {
2276
1973
  const origin = sm && rootSessionOf(sm.getSession(data.sessionId), id => sm.getSession(id))?.originPlatform;
2277
1974
  const platform = data.platform ?? origin ?? 'webui';
2278
1975
  try {
2279
- const resolved = await agent.resolveLLM(platform, data.sessionId);
1976
+ // 与真实回合同一份会话读法:模型、条数线;人设覆盖、结构化输出开关、额外提示由人设服务按会话读,都影响系统提示长度
1977
+ const settings = agent.fields.get(data.sessionId, { platform });
1978
+ const resolved = agent.resolveLLM(settings.llm);
2280
1979
  if (!resolved)
2281
1980
  return;
2282
1981
  const llm = resolved.use;
2283
1982
  const contextLength = llm.contextLength;
2284
1983
  const maxTokens = getModelMaxOutput(llm);
2285
1984
  const tokenBudget = agent.computeTokenBudget(contextLength, maxTokens);
2286
- // 与真实回合同一份会话解析:人设覆盖、结构化输出开关、额外提示都影响系统提示长度
2287
- const sessionResolved = sm ? sm.resolveConfig(data.sessionId, platform) : undefined;
2288
- // 获取历史消息并构建基础消息列表
2289
- const memory = caps.memory.present ? caps.memory.use : undefined;
1985
+ // 构建基础消息列表
2290
1986
  const messages = [];
2291
1987
  // 系统提示
2292
- const systemPrompt = agent.buildSystemPrompt(toPersonaOptions(sessionResolved));
1988
+ const systemPrompt = agent.buildSystemPrompt({ sessionId: data.sessionId, platform });
2293
1989
  messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
2294
- // 历史消息:与真实回合同样按会话的条数线读
2295
- const history = memory ? await memory.getHistory(data.sessionId, sessionResolved?.historyLimit || Infinity) : [];
2296
- messages.push(...history.filter(m => !CONTROL_KINDS.includes(m.kind ?? '')));
1990
+ // 历史消息:与真实回合同一个读法
1991
+ const history = await agent.modelHistory(data.sessionId, settings.historyLimit);
1992
+ messages.push(...history.messages);
2297
1993
  // 与真实回合同序:先组装 agent:prompt 贡献(摘要/向量记忆/档案等物化为 system 块),
2298
1994
  // 再跑 agent:llm:before(工具搜索层过滤等拦截职责),使快照贴近实际送入 LLM 的形态
2299
- const enabledGroups = sessionResolved?.enabledToolGroups?.length ? sessionResolved.enabledToolGroups : undefined;
2300
1995
  const tools = caps.tools.present
2301
- ? caps.tools.use.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined)
1996
+ ? caps.tools.use.getDefinitions({ groups: caps.tools.use.sessionGroups(data.sessionId, platform) })
2302
1997
  : [];
2303
1998
  const llmBeforeData = {
2304
1999
  messages,
2305
2000
  tools,
2001
+ acceptsImages: llm.capabilities.includes(LLMCapabilities.Vision),
2306
2002
  sessionId: data.sessionId,
2307
- userId: '',
2308
2003
  platform,
2309
2004
  dryRun: true, // 纯统计路径:昂贵注入者(向量检索/档案加载)据此跳过副作用
2310
2005
  };
2311
- await assemblePromptContributions(caps, llmBeforeData, { buildTimeoutMs: agent.promptBuildTimeoutMs });
2006
+ // 不在回合里:没有输入,各贡献按没有输入处理
2007
+ await assemblePromptContributions(caps, llmBeforeData, [], { buildTimeoutMs: agent.promptBuildTimeoutMs });
2312
2008
  await caps.hooks.use.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
2313
2009
  // 与真实回合同样过硬裁剪,拆分按裁剪之后的消息算
2314
- const sent = agent.trimMessages(llmBeforeData.messages, tokenBudget);
2010
+ const sent = agent.trimMessages(llmBeforeData.messages, tokenBudget, new Set(history.messages));
2315
2011
  await agent.emitTokenUsage({
2316
2012
  sessionId: data.sessionId,
2317
2013
  platform,
@@ -2322,7 +2018,8 @@ function run(caps) {
2322
2018
  tools: llmBeforeData.tools,
2323
2019
  trimmed: sent.trimmed,
2324
2020
  untrimmedHistory: estimateHistoryTokens(llmBeforeData.messages),
2325
- historyMessages: history.length,
2021
+ historyMessages: history.count,
2022
+ historyLimit: settings.historyLimit,
2326
2023
  });
2327
2024
  }
2328
2025
  catch (err) {
@@ -2332,7 +2029,7 @@ function run(caps) {
2332
2029
  }
2333
2030
  export default definePlugin({
2334
2031
  name: '@aalis/plugin-agent',
2335
- displayName: '默认 Agent',
2032
+ displayName: t('displayName'),
2336
2033
  subsystem: 'agent',
2337
2034
  configSchema,
2338
2035
  provides: [agentService],