@aalis/plugin-agent 0.13.6 → 0.14.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,12 +1,48 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { useCommandService } from '@aalis/api-commands';
3
- import { listLLMModels, resolveLLMModel } from '@aalis/api-llm';
4
- import { getPlatformSelfIdentity } from '@aalis/api-platform';
5
- import { asToolExecutionResult } from '@aalis/api-tools';
2
+ import { agent as agentService, } from '@aalis/api-agent';
3
+ import { commands as commandsService } from '@aalis/api-commands';
4
+ import { gateway as gatewayService } from '@aalis/api-gateway';
5
+ import { listLLMModels, llm as llmService, resolveLLMModel } from '@aalis/api-llm';
6
+ import { media as mediaService } from '@aalis/api-media';
7
+ import { memory as memoryService } from '@aalis/api-memory';
8
+ import { messageArchive as messageArchiveService } from '@aalis/api-message-archive';
9
+ import { persona as personaService } from '@aalis/api-persona';
10
+ import { getPlatformSelfIdentity, platform as platformService } from '@aalis/api-platform';
11
+ import { sessionManager as sessionManagerService } from '@aalis/api-session-manager';
12
+ import { storage as storageService } from '@aalis/api-storage';
13
+ import { asToolExecutionResult, tools as toolsService, } from '@aalis/api-tools';
14
+ import { config as configCap, contributions, definePlugin, events, hooks, lifecycle, logger as loggerCap, optional, pluginsService, provide, services, } from '@aalis/core';
6
15
  import { CONTROL_KINDS, getMessageName, getSenderLabel, WellKnownKinds } from '@aalis/schema-message';
7
16
  import { normalizeAssistantContent, stripLeakedSpecialTokens, truncateChars } from '@aalis/util-text-normalize';
8
17
  import { buildFocusGuidance, describeLLMFailure, estimateMsgTokens, estimateTextTokens, estimateTokens, formatTimeLabel, INPUT_CONVENTIONS, isSameMessage, } from './helpers.js';
9
18
  import { assemblePromptContributions, VOLATILE_INJECTOR } from './prompt-assembly.js';
19
+ // ===== 能力声明 =====
20
+ //
21
+ // 对话编排要碰的东西全在这里:没写进 uses 的,插件碰不到。全部 optional——少一样能力
22
+ // 只是少一段能力(没有 memory 就没有历史、没有 tools 就把「工具不可用」回给模型、没有
23
+ // gateway 就直接走事件总线),不该让整个 agent 停活。
24
+ const uses = {
25
+ logger: loggerCap,
26
+ config: configCap,
27
+ events,
28
+ hooks,
29
+ contributions,
30
+ lifecycle,
31
+ provide,
32
+ services,
33
+ commands: optional(commandsService),
34
+ tools: optional(toolsService),
35
+ llm: optional(llmService),
36
+ memory: optional(memoryService),
37
+ persona: optional(personaService),
38
+ messageArchive: optional(messageArchiveService),
39
+ sessionManager: optional(sessionManagerService),
40
+ platform: optional(platformService),
41
+ media: optional(mediaService),
42
+ storage: optional(storageService),
43
+ gateway: optional(gatewayService),
44
+ plugins: optional(pluginsService),
45
+ };
10
46
  /**
11
47
  * 默认 Agent 实现 —— 对话编排器
12
48
  *
@@ -26,7 +62,7 @@ function getModelMaxOutput(llm) {
26
62
  return llm.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
27
63
  }
28
64
  class DefaultAgent {
29
- ctx;
65
+ caps;
30
66
  logger;
31
67
  systemPrompt;
32
68
  memoryTokenBudget;
@@ -45,8 +81,15 @@ class DefaultAgent {
45
81
  * 独立管理,互不打断;同来源新消息会中止旧的生成。
46
82
  */
47
83
  activeControllers = new Map();
84
+ /**
85
+ * 在飞回合 Promise。关停时 abort 之后等它们以 AbortError(或正常完成)收尾,
86
+ * 而不是等 LLM 跑完——收尾里的钩子 / 出站事件还要用 memory。
87
+ */
88
+ inflightTurns = new Set();
48
89
  /** 同一 lane 的入站消息归档串行化,避免连续消息读取历史时漏掉前一条输入。 */
49
90
  archiveQueues = new Map();
91
+ /** 每次激活只在实际写入缺少归档时告警一次,不因插件加载顺序误报。 */
92
+ archiveUnavailableWarned = false;
50
93
  /**
51
94
  * 节流日志状态:记录每个 session 上次 token:usage 日志的"轮次"与 ratio 桶。
52
95
  * - 跨过 0.5/0.7/0.85 三个阈值必输出
@@ -56,9 +99,10 @@ class DefaultAgent {
56
99
  tokenLogState = new Map();
57
100
  /** 已注册的预处理器(name → { priority, dispose }) */
58
101
  preprocessors = new Map();
59
- constructor(ctx, config) {
60
- this.ctx = ctx;
61
- this.logger = ctx.logger.child('agent');
102
+ constructor(caps) {
103
+ this.caps = caps;
104
+ this.logger = caps.logger.child('agent');
105
+ const config = caps.config;
62
106
  this.systemPrompt = config.systemPrompt || '';
63
107
  this.memoryTokenBudget = config.memoryTokenBudget ?? 4096;
64
108
  this.historyLimit = config.historyLimit ?? 50;
@@ -82,25 +126,25 @@ class DefaultAgent {
82
126
  async resolveLLM(platform, sessionId) {
83
127
  let ref;
84
128
  // session-manager 一步到位:会话 > 父 sessionDefaults > platform profile
85
- const sm = this.ctx.getService('session-manager');
129
+ const sm = this.caps.sessionManager.current;
86
130
  if (sm && sessionId) {
87
131
  const resolved = sm.resolveConfig(sessionId, platform);
88
132
  if (resolved.llm?.provider && resolved.llm?.model)
89
133
  ref = resolved.llm;
90
134
  }
91
135
  // 解析为具体 LLMModel entry(要求至少 chat 能力;ref 为空时走 ServicePreference / 优先级)
92
- return resolveLLMModel(this.ctx, ref, ['chat']);
136
+ return resolveLLMModel(this.caps.llm, ref, ['chat']);
93
137
  }
94
138
  /** 解析失败时说清**为什么**(取数在此,措辞在 helpers 的 describeLLMFailure,纯函数便于单测)。 */
95
139
  explainLLMFailure(platform, sessionId) {
96
- const available = listLLMModels(this.ctx, { caps: ['chat'] }).map(e => e.contextId);
97
- const sm = this.ctx.getService('session-manager');
140
+ const available = listLLMModels(this.caps.llm, { caps: ['chat'] }).map(e => e.contextId);
141
+ const sm = this.caps.sessionManager.current;
98
142
  const wanted = sm && sessionId ? sm.resolveConfig(sessionId, platform).llm : undefined;
99
143
  return describeLLMFailure(available, wanted, this.erroredLLMPlugins());
100
144
  }
101
145
  /** 激活失败且声明提供 llm 的插件——「一个模型都没有」时,病因通常就在它们身上(多为缺 apiKey)。 */
102
146
  erroredLLMPlugins() {
103
- const pm = this.ctx.getService('plugins');
147
+ const pm = this.caps.plugins.current;
104
148
  if (!pm)
105
149
  return [];
106
150
  // 这串会拼进**发到聊天**的文案里,原因文本来自插件自抛的 error,长度不可控:
@@ -129,10 +173,10 @@ class DefaultAgent {
129
173
  /**
130
174
  * 中止全部在飞回合(拆卸路径用)。
131
175
  *
132
- * activeControllers 是实例私有的,bounce 后新实例看不见旧实例的在飞回合;而拆卸链不等任何
133
- * 回合——旧回合会在已 dispose 的 ctx 上跑完并投递(人设/模型都是 bounce 前的),用户在它
134
- * 结束前再发一条,两个实例就并发答同一会话。abort 走的是已有的 AbortError 收尾
135
- * (outbound:stream done + turn:after outcome='aborted'),与 WebUI「停止生成」同语义。
176
+ * activeControllers 是实例私有的,bounce 后新实例看不见旧实例的在飞回合。
177
+ * abort 走已有的 AbortError 收尾(outbound:stream done + turn:after outcome='aborted'),
178
+ * 与 WebUI「停止生成」同语义。关停在 onDrain 里先调本方法再等回合 Promise;
179
+ * onDispose 再调一次作兜底(已中止的 controller 不在表里,是空操作)。
136
180
  */
137
181
  abortAll() {
138
182
  let n = 0;
@@ -144,6 +188,16 @@ class DefaultAgent {
144
188
  if (n > 0)
145
189
  this.logger.info(`拆卸:已中止 ${n} 个在飞回合`);
146
190
  }
191
+ /**
192
+ * 关停收尾:中止在飞回合并等待其 AbortError(或正常完成)收尾落定。
193
+ * 超时由 core 对 onDrain 回调的 disposeTimeoutMs 护栏负责,不另加配置键。
194
+ */
195
+ async abortInflightAndSettle() {
196
+ this.abortAll();
197
+ if (this.inflightTurns.size === 0)
198
+ return;
199
+ await Promise.allSettled([...this.inflightTurns]);
200
+ }
147
201
  /**
148
202
  * 注册输入预处理器(如图片识别、文件读取、用户画像等)。
149
203
  * 多个预处理器按注册顺序串行执行(Koa-style 洋葱模型)。
@@ -154,7 +208,7 @@ class DefaultAgent {
154
208
  registerPreprocessor(name, handler) {
155
209
  // 同名替换
156
210
  this.preprocessors.get(name)?.dispose();
157
- const dispose = this.ctx.middleware('agent:input:before', async (data, next) => {
211
+ const dispose = this.caps.hooks.middleware('agent:input:before', async (data, next) => {
158
212
  await handler(data.message, next);
159
213
  });
160
214
  // 退订按条目引用比对:同名重注册后,旧退订闭包只摘自己的中间件,不动新登记的账目
@@ -184,7 +238,7 @@ class DefaultAgent {
184
238
  * (平台属于独立子系统,由 api-platform 的 helper 负责)。
185
239
  */
186
240
  getPluginGroups() {
187
- const pm = this.ctx.getService('plugins');
241
+ const pm = this.caps.plugins.current;
188
242
  if (!pm)
189
243
  return [];
190
244
  // 子系统归属:Agent 域的服务(不含 platform——平台是独立子系统)
@@ -226,7 +280,7 @@ class DefaultAgent {
226
280
  if (chunk.contentDelta) {
227
281
  content += chunk.contentDelta;
228
282
  appendDelta('text', chunk.contentDelta);
229
- await this.ctx.emit('outbound:stream', {
283
+ await this.caps.events.emit('outbound:stream', {
230
284
  sessionId,
231
285
  platform,
232
286
  contentDelta: chunk.contentDelta,
@@ -235,14 +289,14 @@ class DefaultAgent {
235
289
  if (chunk.reasoningDelta) {
236
290
  reasoningContent += chunk.reasoningDelta;
237
291
  appendDelta('reasoning_text', chunk.reasoningDelta);
238
- await this.ctx.emit('outbound:stream', {
292
+ await this.caps.events.emit('outbound:stream', {
239
293
  sessionId,
240
294
  platform,
241
295
  reasoningDelta: chunk.reasoningDelta,
242
296
  });
243
297
  }
244
298
  if (chunk.toolCallProgress) {
245
- await this.ctx.emit('outbound:stream', {
299
+ await this.caps.events.emit('outbound:stream', {
246
300
  sessionId,
247
301
  platform,
248
302
  toolCallProgress: chunk.toolCallProgress,
@@ -260,7 +314,7 @@ class DefaultAgent {
260
314
  // 兜底剥离 LLM 端漏出的特殊 token 残渣(DSML 等),同时修复 GFM 表格
261
315
  const { sanitized, hadLeak } = stripLeakedSpecialTokens(content);
262
316
  if (hadLeak) {
263
- this.ctx.logger.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
317
+ this.caps.logger.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
264
318
  }
265
319
  finalContent = normalizeAssistantContent(content);
266
320
  }
@@ -383,14 +437,23 @@ class DefaultAgent {
383
437
  prev.abort();
384
438
  const controller = new AbortController();
385
439
  this.activeControllers.set(lane, controller);
440
+ const turn = (async () => {
441
+ try {
442
+ await this._handleMessageInner(incoming, controller.signal, lane);
443
+ }
444
+ finally {
445
+ // 仅清理自己创建的 controller(避免清掉后续新请求的)
446
+ if (this.activeControllers.get(lane) === controller) {
447
+ this.activeControllers.delete(lane);
448
+ }
449
+ }
450
+ })();
451
+ this.inflightTurns.add(turn);
386
452
  try {
387
- await this._handleMessageInner(incoming, controller.signal, lane);
453
+ await turn;
388
454
  }
389
455
  finally {
390
- // 仅清理自己创建的 controller(避免清掉后续新请求的)
391
- if (this.activeControllers.get(lane) === controller) {
392
- this.activeControllers.delete(lane);
393
- }
456
+ this.inflightTurns.delete(turn);
394
457
  }
395
458
  }
396
459
  async _handleMessageInner(incoming, signal, lane) {
@@ -401,7 +464,7 @@ class DefaultAgent {
401
464
  metadata: {},
402
465
  };
403
466
  let handled = false;
404
- await this.ctx.runHook('agent:input:before', msgHookData, async () => {
467
+ await this.caps.hooks.run('agent:input:before', msgHookData, async () => {
405
468
  handled = true;
406
469
  // ===== defaultAction: 全部消息处理逻辑在此 =====
407
470
  // 中间件不调用 next() → 此处永远不执行 → 消息被拦截
@@ -434,7 +497,7 @@ class DefaultAgent {
434
497
  const turnPersistedTimestamps = [];
435
498
  try {
436
499
  // 统一解析 session 配置(一次解析,多处复用)
437
- const sessionMgr = this.ctx.getService('session-manager');
500
+ const sessionMgr = this.caps.sessionManager.current;
438
501
  const resolved = sessionMgr && incoming.sessionId
439
502
  ? sessionMgr.resolveConfig(incoming.sessionId, incoming.platform)
440
503
  : undefined;
@@ -461,9 +524,7 @@ class DefaultAgent {
461
524
  enabledGroups = resolved.enabledToolGroups;
462
525
  }
463
526
  this.logger.debug(`工具分组: platform=${incoming.platform}, enabledGroups=${enabledGroups ? JSON.stringify(enabledGroups) : '(无)'}`);
464
- const tools = this.ctx
465
- .getService('tools')
466
- ?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ?? [];
527
+ const tools = this.caps.tools.current?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ?? [];
467
528
  const toolCtx = {
468
529
  sessionId: incoming.sessionId,
469
530
  // platform/userId 保持**会话/物理**语义(定时任务归属、平台档继承、记忆平台域、
@@ -493,11 +554,11 @@ class DefaultAgent {
493
554
  triggerType: incoming.triggerType,
494
555
  };
495
556
  // 组装先于链:贡献块(档案/技能/记忆/即时提示)先物化,拦截者审完整成品
496
- await assemblePromptContributions(this.ctx, llmBeforeData, {
557
+ await assemblePromptContributions(this.caps, llmBeforeData, {
497
558
  buildTimeoutMs: this.promptBuildTimeoutMs,
498
559
  signal,
499
560
  });
500
- await this.ctx.runHook('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
561
+ await this.caps.hooks.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
501
562
  // 裁剪消息以确保不超过上下文窗口
502
563
  llmBeforeData.messages = this.trimMessages(llmBeforeData.messages, tokenBudget);
503
564
  // 推送 token 使用量统计
@@ -540,7 +601,7 @@ class DefaultAgent {
540
601
  this.debugLogResponse(response, Date.now() - t0);
541
602
  // Hook: agent:llm:after — 插件可以处理 LLM 返回结果
542
603
  const llmAfterData = { response, messages: llmBeforeData.messages };
543
- await this.ctx.runHook('agent:llm:after', llmAfterData);
604
+ await this.caps.hooks.run('agent:llm:after', llmAfterData);
544
605
  response = llmAfterData.response;
545
606
  // 收集所有思考内容
546
607
  const allReasoning = [];
@@ -600,9 +661,9 @@ class DefaultAgent {
600
661
  }
601
662
  // Hook: agent:tool:before — 插件可以拦截或修改工具调用
602
663
  const toolBeforeData = { name: toolCall.function.name, args, toolCallContext: toolCtx };
603
- await this.ctx.runHook('agent:tool:before', toolBeforeData);
664
+ await this.caps.hooks.run('agent:tool:before', toolBeforeData);
604
665
  // 通知平台:工具开始执行
605
- await this.ctx.emit('tool:execute', {
666
+ await this.caps.events.emit('tool:execute', {
606
667
  sessionId: incoming.sessionId,
607
668
  platform: incoming.platform,
608
669
  toolName: toolBeforeData.name,
@@ -611,9 +672,7 @@ class DefaultAgent {
611
672
  });
612
673
  this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${JSON.stringify(toolBeforeData.args)}`);
613
674
  const toolT0 = Date.now();
614
- const executed = asToolExecutionResult(await (this.ctx
615
- .getService('tools')
616
- ?.execute(toolBeforeData.name, toolBeforeData.args, toolCtx) ??
675
+ const executed = asToolExecutionResult(await (this.caps.tools.current?.execute(toolBeforeData.name, toolBeforeData.args, toolCtx) ??
617
676
  Promise.resolve({ content: JSON.stringify({ error: 'tools 服务不可用' }) })));
618
677
  let result = executed.content;
619
678
  // 工具交给主模型看的图:只随本回合的 tool 消息走(出口由 prepareLLMMessages 编码),
@@ -621,7 +680,7 @@ class DefaultAgent {
621
680
  const resultImages = executed.images;
622
681
  // Hook: agent:tool:after — 插件可以处理工具执行结果
623
682
  const toolAfterData = { name: toolBeforeData.name, result, toolCallContext: toolCtx };
624
- await this.ctx.runHook('agent:tool:after', toolAfterData);
683
+ await this.caps.hooks.run('agent:tool:after', toolAfterData);
625
684
  result = toolAfterData.result;
626
685
  // 工具结果截断:按上下文窗口比例限制单条工具结果长度
627
686
  if (result.length > toolResultMaxChars) {
@@ -631,7 +690,7 @@ class DefaultAgent {
631
690
  const toolEndTime = Date.now();
632
691
  this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
633
692
  // 通知平台:工具执行完成
634
- await this.ctx.emit('tool:execute', {
693
+ await this.caps.events.emit('tool:execute', {
635
694
  sessionId: incoming.sessionId,
636
695
  platform: incoming.platform,
637
696
  toolName: toolBeforeData.name,
@@ -691,11 +750,11 @@ class DefaultAgent {
691
750
  triggerType: incoming.triggerType,
692
751
  };
693
752
  // 已物化的贡献按全局键跳过;本轮工具调用新注册的贡献(如新激活技能)增量落位
694
- await assemblePromptContributions(this.ctx, nextLlmData, {
753
+ await assemblePromptContributions(this.caps, nextLlmData, {
695
754
  buildTimeoutMs: this.promptBuildTimeoutMs,
696
755
  signal,
697
756
  });
698
- await this.ctx.runHook('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
757
+ await this.caps.hooks.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
699
758
  // 裁剪消息以确保不超过上下文窗口
700
759
  nextLlmData.messages = this.trimMessages(nextLlmData.messages, tokenBudget);
701
760
  // 推送 token 使用量统计
@@ -713,7 +772,7 @@ class DefaultAgent {
713
772
  accUsage(response.usage);
714
773
  this.debugLogResponse(response, Date.now() - tN, iterations);
715
774
  const nextLlmAfterData = { response, messages: nextLlmData.messages };
716
- await this.ctx.runHook('agent:llm:after', nextLlmAfterData);
775
+ await this.caps.hooks.run('agent:llm:after', nextLlmAfterData);
717
776
  response = nextLlmAfterData.response;
718
777
  if (response.reasoningContent) {
719
778
  allReasoning.push(response.reasoningContent);
@@ -735,7 +794,7 @@ class DefaultAgent {
735
794
  triggerType: incoming.triggerType,
736
795
  attempt: 0,
737
796
  };
738
- await this.ctx.runHook('agent:reply:before', responseData);
797
+ await this.caps.hooks.run('agent:reply:before', responseData);
739
798
  // 重试循环:当 hook(如 persona 的 outputFormat 解析)报告 retryRequested 时,
740
799
  // 把失败的 assistant 输出 + 系统反馈追加到消息列表,重新请求 LLM;最多按 maxRetries 次。
741
800
  // maxRetries 由 hook 端写入(plugin-persona 从 outputFormat.retries 读取,默认 1)。
@@ -769,7 +828,7 @@ class DefaultAgent {
769
828
  responseData.retryRequested = false;
770
829
  responseData.retryFeedback = undefined;
771
830
  responseData.attempt = attempt;
772
- await this.ctx.runHook('agent:reply:before', responseData);
831
+ await this.caps.hooks.run('agent:reply:before', responseData);
773
832
  }
774
833
  // 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
775
834
  // 强制把 content 置空,避免原始未校验内容被外发。
@@ -789,7 +848,7 @@ class DefaultAgent {
789
848
  replyContent = '';
790
849
  }
791
850
  // 发出流结束标记
792
- await this.ctx.emit('outbound:stream', {
851
+ await this.caps.events.emit('outbound:stream', {
793
852
  sessionId: incoming.sessionId,
794
853
  platform: incoming.platform,
795
854
  done: true,
@@ -838,7 +897,7 @@ class DefaultAgent {
838
897
  }
839
898
  // Hook: agent:turn:after — 插件可以在完整消息周期结束后做后处理
840
899
  const turnOutcome = replyContent.trim().length === 0 ? 'silent' : 'replied';
841
- await this.ctx.runHook('agent:turn:after', {
900
+ await this.caps.hooks.run('agent:turn:after', {
842
901
  message: incoming,
843
902
  reply: replyContent,
844
903
  outcome: turnOutcome,
@@ -864,7 +923,7 @@ class DefaultAgent {
864
923
  (turnPersistedTimestamps.length > 0
865
924
  ? `(保留本轮已完成的 ${turnPersistedTimestamps.length} 条工具调用记录,便于下一轮 agent 感知)`
866
925
  : ''));
867
- await this.ctx.emit('outbound:stream', {
926
+ await this.caps.events.emit('outbound:stream', {
868
927
  sessionId: incoming.sessionId,
869
928
  platform: incoming.platform,
870
929
  done: true,
@@ -873,7 +932,7 @@ class DefaultAgent {
873
932
  // session-manager 把会话状态从 active 收口为 completed(否则永远停在"进行中"),
874
933
  // checkpoint 关闭当前回合(否则中止后回合不关闭、长期泄漏)。
875
934
  // 文档与 agent-api 早已声明 outcome 含 aborted,此处兑现契约。
876
- await this.ctx.runHook('agent:turn:after', {
935
+ await this.caps.hooks.run('agent:turn:after', {
877
936
  message: incoming,
878
937
  reply: '',
879
938
  outcome: 'aborted',
@@ -886,7 +945,7 @@ class DefaultAgent {
886
945
  this.logger.error(`处理消息失败: ${message}`);
887
946
  // 异常同样要结束流:不发 done,CLI 的流式块不收尾、WebUI 的工具进度与上限标记不复位,
888
947
  // 下一轮 delta 还会抹掉这条 [错误] 与用户的新输入。顺序与正常收尾一致——先 done 再发消息。
889
- await this.ctx.emit('outbound:stream', {
948
+ await this.caps.events.emit('outbound:stream', {
890
949
  sessionId: incoming.sessionId,
891
950
  platform: incoming.platform,
892
951
  done: true,
@@ -900,7 +959,7 @@ class DefaultAgent {
900
959
  // 异常也是回合终态:同样发 turn:after(outcome=error) 让 checkpoint 关闭回合、
901
960
  // session-manager 收口状态。dispatchOutbound 已发系统错误消息,状态可被 outbound:message
902
961
  // 与本钩子双路径幂等收口。
903
- await this.ctx.runHook('agent:turn:after', {
962
+ await this.caps.hooks.run('agent:turn:after', {
904
963
  message: incoming,
905
964
  reply: '',
906
965
  outcome: 'error',
@@ -911,7 +970,7 @@ class DefaultAgent {
911
970
  });
912
971
  // 消息被拦截(如流控缓冲),通知前端结束 loading
913
972
  if (!handled) {
914
- await this.ctx.emit('outbound:stream', {
973
+ await this.caps.events.emit('outbound:stream', {
915
974
  sessionId: incoming.sessionId,
916
975
  platform: incoming.platform,
917
976
  done: true,
@@ -927,7 +986,7 @@ class DefaultAgent {
927
986
  const systemPrompt = this.buildSystemPrompt(personaOpts);
928
987
  messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
929
988
  // 2. 历史消息
930
- const memory = this.ctx.getService('memory');
989
+ const memory = this.caps.memory.current;
931
990
  if (memory) {
932
991
  try {
933
992
  const history = this.sanitizeToolCallHistory(await memory.getHistory(incoming.sessionId, this.historyLimit), incoming.sessionId);
@@ -1011,7 +1070,7 @@ class DefaultAgent {
1011
1070
  // 多模态:把 attachments 中的 image 项传递给 LLM(视觉模型多模态字段)
1012
1071
  const imageAtts = incoming.attachments?.filter(a => a.kind === 'image') ?? [];
1013
1072
  if (imageAtts.length > 0) {
1014
- if (this.ctx.getService('media') !== undefined) {
1073
+ if (this.caps.media.current !== undefined) {
1015
1074
  // media 在场:原样透传。出口形态由 media 的 agent:llm:before 中间件规范化
1016
1075
  //(describe 剥离 / passthrough 物化),此处不做任何转换——它的描述缓存键与
1017
1076
  // 动图提示都按 attachment.data 原串命中,改形态会打散。
@@ -1041,7 +1100,7 @@ class DefaultAgent {
1041
1100
  // 秒级时间戳一变,人设 + 记忆 + 历史近 7 万 token 的前缀全部作废——实测每个
1042
1101
  // 新回合命中率恒为 0,只有同回合内的工具迭代(prompt 不重建)才命中。
1043
1102
  // 放到这里语义也更顺:它们本就是「此刻的事实」,紧挨当前消息说更自然。
1044
- const persona = this.ctx.getService('persona');
1103
+ const persona = this.caps.persona.current;
1045
1104
  const volatileCtx = persona?.getVolatilePrompt?.(personaOpts);
1046
1105
  if (volatileCtx) {
1047
1106
  messages.push({ role: 'system', content: volatileCtx, metadata: { injector: VOLATILE_INJECTOR } });
@@ -1058,7 +1117,7 @@ class DefaultAgent {
1058
1117
  * 构建系统提示词
1059
1118
  */
1060
1119
  buildSystemPrompt(personaOpts) {
1061
- const persona = this.ctx.getService('persona');
1120
+ const persona = this.caps.persona.current;
1062
1121
  const base = persona
1063
1122
  ? this.systemPrompt
1064
1123
  ? `${persona.getSystemPrompt(personaOpts)}\n\n${this.systemPrompt}`
@@ -1092,7 +1151,7 @@ class DefaultAgent {
1092
1151
  for (const msg of messages) {
1093
1152
  const t = estimateMsgTokens(msg);
1094
1153
  if (msg.role === 'system') {
1095
- // 贡献点物化块的 injector 是全局键 `${ctx.id}/${局部标签}`(ctx.id 含 '/'),
1154
+ // 贡献点物化块的 injector 是全局键 `${贡献方实例 id}/${局部标签}`(实例 id 含 '/'),
1096
1155
  // 归桶按局部标签(末段)匹配——桶规则与统计明细键保持迁移前的稳定值;
1097
1156
  // 非贡献来源(persona / system-other 等)无 '/',末段即原值,不受影响。
1098
1157
  const rawSource = msg.metadata?.injector;
@@ -1163,7 +1222,7 @@ class DefaultAgent {
1163
1222
  systemOtherTokens;
1164
1223
  const totalUsed = systemTokens + historyTokens + toolResultTokens + toolDefsTokens;
1165
1224
  const usageRatio = contextLength > 0 ? totalUsed / contextLength : 0;
1166
- this.ctx
1225
+ this.caps.events
1167
1226
  .emit('token:usage', {
1168
1227
  sessionId,
1169
1228
  platform,
@@ -1218,7 +1277,7 @@ class DefaultAgent {
1218
1277
  const rel = t.match(/^data\/((?:images|videos|audios|files)\/.+)$/);
1219
1278
  if (!rel)
1220
1279
  return t;
1221
- const storage = this.ctx.getService('storage');
1280
+ const storage = this.caps.storage.current;
1222
1281
  if (!storage)
1223
1282
  return null;
1224
1283
  try {
@@ -1472,11 +1531,17 @@ class DefaultAgent {
1472
1531
  }
1473
1532
  return result;
1474
1533
  }
1475
- /**
1476
- * 保存消息到记忆服务
1477
- */
1534
+ getArchiveForWrite() {
1535
+ const archive = this.caps.messageArchive.current;
1536
+ if (!archive && !this.archiveUnavailableWarned) {
1537
+ this.archiveUnavailableWarned = true;
1538
+ this.logger.warn('message-archive 服务不可用,缺席期间的新消息不会写入记忆,对话继续。请加载 @aalis/plugin-message-archive 及其 memory 依赖。');
1539
+ }
1540
+ return archive;
1541
+ }
1542
+ /** 保存消息到记忆服务 */
1478
1543
  async saveToMemory(sessionId, message) {
1479
- const archive = this.ctx.getService('message-archive');
1544
+ const archive = this.getArchiveForWrite();
1480
1545
  if (archive) {
1481
1546
  try {
1482
1547
  await archive.saveMessage(sessionId, message);
@@ -1487,7 +1552,7 @@ class DefaultAgent {
1487
1552
  }
1488
1553
  }
1489
1554
  buildAssistantMetadata(incoming) {
1490
- const identity = getPlatformSelfIdentity(this.ctx, incoming.platform, incoming.sessionId);
1555
+ const identity = getPlatformSelfIdentity(this.caps.platform, incoming.platform, incoming.sessionId);
1491
1556
  const metadata = {
1492
1557
  platform: incoming.platform,
1493
1558
  senderType: 'assistant',
@@ -1576,7 +1641,7 @@ class DefaultAgent {
1576
1641
  // 跳过非真实用户输入:闲聊主动触发是系统提示,不应作为 user 消息写入历史
1577
1642
  if (incoming.source === 'idle-trigger')
1578
1643
  return undefined;
1579
- const archive = this.ctx.getService('message-archive');
1644
+ const archive = this.getArchiveForWrite();
1580
1645
  if (!archive)
1581
1646
  return undefined;
1582
1647
  try {
@@ -1595,35 +1660,27 @@ class DefaultAgent {
1595
1660
  * 此时该 fallback 永远不命中——出站消息总是经过 outbound:dispatch 钩子链
1596
1661
  * (审计 / 脱敏 / 限速 / authority 等中间件)。
1597
1662
  *
1598
- * 由于 plugin-agent-default 未在 inject.required 中声明 'gateway',
1599
- * 即使 gateway 未加载本插件仍会激活,因此保留该 fallback 以便:
1663
+ * gateway 只是本插件的 optional 依赖,它不在场时 agent 照常激活,因此保留该 fallback 以便:
1600
1664
  * - 集成测试不必启动 gateway
1601
1665
  * - 嵌入式 / 单 agent 部署场景
1602
1666
  * 生产部署务必确保 plugin-gateway 已加载,否则中间件链会被跳过。
1603
1667
  */
1604
1668
  async dispatchOutbound(message) {
1605
- const gateway = this.ctx.getService('gateway');
1669
+ const gateway = this.caps.gateway.current;
1606
1670
  if (gateway) {
1607
1671
  await gateway.dispatchOutbound(message);
1608
1672
  return;
1609
1673
  }
1610
- this.logger.warn('Gateway 服务不可用,回退至 ctx.emit(outbound:message)(中间件链被跳过)');
1611
- await this.ctx.emit('outbound:message', message);
1674
+ this.logger.warn('Gateway 服务不可用,回退至 outbound:message 事件(中间件链被跳过)');
1675
+ await this.caps.events.emit('outbound:message', message);
1612
1676
  }
1613
1677
  }
1614
1678
  // ----- 插件导出 -----
1615
- export const name = '@aalis/plugin-agent';
1616
- export const displayName = '默认 Agent';
1617
- export const subsystem = 'agent';
1618
- export const provides = ['agent'];
1619
- export const inject = {
1620
- optional: ['llm', 'memory', 'persona', 'message-archive', 'platform', 'media', 'storage'],
1621
- };
1622
- export const configSchema = {
1679
+ const configSchema = {
1623
1680
  defaultLLM: {
1624
1681
  type: 'llm-ref',
1625
1682
  label: '默认对话模型',
1626
- description: '全局默认 LLM。apply() 时调用 ctx.preferService("llm", `provider/model`) 锁定 ServiceContainer 偏好。会话 / 平台 profile 未覆盖时生效。',
1683
+ description: '全局默认 LLM。apply() 时经 services.prefer 锁定 llm 服务的偏好提供者。会话 / 平台 profile 未覆盖时生效。',
1627
1684
  },
1628
1685
  systemPrompt: {
1629
1686
  type: 'textarea',
@@ -1668,21 +1725,26 @@ export const configSchema = {
1668
1725
  description: '裁剪预算 = 上下文长度 × 该比例 − 最大输出 token − 512 安全余量(下限 1024)。本次调用估算输入 token 超过该预算才会对消息列表做内存裁剪(不影响 DB)。默认 1.0 表示用满扣除输出预留后的可用窗口;调低可提前裁剪。压缩触发请在“@aalis/plugin-memory-summary”中配置。',
1669
1726
  },
1670
1727
  };
1671
- export function apply(ctx, config) {
1672
- const agentImpl = new DefaultAgent(ctx, config);
1673
- ctx.provide('agent', agentImpl);
1674
- // 拆卸即中止在飞回合:bounce / disable / unload / 停机都经此,理由见 abortAll。
1675
- ctx.onDispose(() => agentImpl.abortAll());
1728
+ function run(caps) {
1729
+ const agentImpl = new DefaultAgent(caps);
1730
+ caps.provide(agentService, agentImpl);
1731
+ // 收尾段中止在飞回合并等 AbortError 落定。
1732
+ // 第一方栈由 core 关停编排(optional 环成员先全部 drain)与 agent:turn:after 收口;
1733
+ // session-manager 的 onDrain 兜的是未装 agent 或钩子未挂上的路径。
1734
+ // 超时沿用 core 对 onDrain 的 disposeTimeoutMs,不另加配置键。
1735
+ caps.lifecycle.onDrain(() => agentImpl.abortInflightAndSettle(), '中止在飞回合');
1736
+ // 清理段再 abort 一次:drain 超时或未走到收尾时的兜底,表空则空操作。
1737
+ caps.lifecycle.onDispose(() => agentImpl.abortAll(), '中止在飞回合(兜底)');
1676
1738
  const agent = agentImpl;
1677
- // 全局默认 LLM:通过 ServicePreference 锁定 ctx.getService<'llm'>() 的首选 entry。
1739
+ // 全局默认 LLM:通过 ServicePreference 锁定 llm 服务的首选 entry。
1678
1740
  // 偏好持久化由 core 配置层负责(servicePreferences 字段);这里只是开机时按 agent
1679
1741
  // 自己的 cfg.defaultLLM 覆写一次,便于纯文件配置流(无 webui 干预)也能生效。
1680
- const defaultLLM = config.defaultLLM;
1742
+ const defaultLLM = caps.config.defaultLLM;
1681
1743
  if (defaultLLM?.provider && defaultLLM?.model) {
1682
- ctx.preferService('llm', `${defaultLLM.provider}/${defaultLLM.model}`);
1744
+ caps.services.prefer(llmService, `${defaultLLM.provider}/${defaultLLM.model}`);
1683
1745
  }
1684
1746
  function resolutionLines(sessionId, platform, label, pick) {
1685
- const smSvc = ctx.getService('session-manager');
1747
+ const smSvc = caps.sessionManager.current;
1686
1748
  if (!smSvc)
1687
1749
  return [`${label}: (session-manager 不可用)`];
1688
1750
  const session = smSvc.getSession(sessionId);
@@ -1734,13 +1796,13 @@ export function apply(ctx, config) {
1734
1796
  }
1735
1797
  return lines.join('\n');
1736
1798
  }
1737
- useCommandService(ctx)
1799
+ caps.commands
1738
1800
  .command('model [keyword:string]', '列出/搜索可用对话模型(分页,-p 翻页;如 /model grok)')
1739
1801
  .option('page', '-p <n:number>', { description: '页码(默认 1)' })
1740
1802
  .action(async (argv, keyword) => {
1741
1803
  const seen = new Set();
1742
1804
  const ids = [];
1743
- for (const e of listLLMModels(ctx, { caps: ['chat'] })) {
1805
+ for (const e of listLLMModels(caps.llm, { caps: ['chat'] })) {
1744
1806
  if (seen.has(e.contextId))
1745
1807
  continue;
1746
1808
  seen.add(e.contextId);
@@ -1749,20 +1811,20 @@ export function apply(ctx, config) {
1749
1811
  const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
1750
1812
  return renderPaged(ids, String(keyword ?? ''), page, '可用模型', '/model');
1751
1813
  });
1752
- useCommandService(ctx)
1814
+ caps.commands
1753
1815
  .command('persona [keyword:string]', '列出/搜索可用人设(分页,-p 翻页)')
1754
1816
  .option('page', '-p <n:number>', { description: '页码(默认 1)' })
1755
1817
  .action(async (argv, keyword) => {
1756
- const persona = ctx.getService('persona');
1818
+ const persona = caps.persona.current;
1757
1819
  const names = persona?.listModels ? await persona.listModels() : [];
1758
1820
  const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
1759
1821
  return renderPaged(names, String(keyword ?? ''), page, '可用人设', '/persona');
1760
1822
  });
1761
1823
  // ---- 会话级「模型 + 人设 + thinking + 名称」配置(onebot 等平台对话直接改当前对话生效) ----
1762
- useCommandService(ctx)
1824
+ caps.commands
1763
1825
  .command('session', '查看当前对话生效的模型 / 人设 / thinking / 名称及来源与解析链', { risk: 'sensitive' })
1764
1826
  .action(async (argv) => {
1765
- const smSvc = ctx.getService('session-manager');
1827
+ const smSvc = caps.sessionManager.current;
1766
1828
  if (!smSvc)
1767
1829
  return 'session-manager 服务不可用';
1768
1830
  const sid = argv.session.sessionId;
@@ -1780,7 +1842,7 @@ export function apply(ctx, config) {
1780
1842
  .filter(Boolean)
1781
1843
  .join('\n');
1782
1844
  });
1783
- useCommandService(ctx)
1845
+ caps.commands
1784
1846
  .command('session.set', '设定当前对话的模型 / 人设 / thinking / 显示名(会话级覆盖,持久化,重启不丢)', {
1785
1847
  risk: 'sensitive',
1786
1848
  examples: [
@@ -1797,7 +1859,7 @@ export function apply(ctx, config) {
1797
1859
  })
1798
1860
  .option('name', '-n <label:string>', { description: '会话显示名(可选)' })
1799
1861
  .action(async (argv) => {
1800
- const smSvc = ctx.getService('session-manager');
1862
+ const smSvc = caps.sessionManager.current;
1801
1863
  if (!smSvc)
1802
1864
  return 'session-manager 服务不可用';
1803
1865
  const modelRef = typeof argv.options.model === 'string' ? argv.options.model.trim() : '';
@@ -1819,7 +1881,7 @@ export function apply(ctx, config) {
1819
1881
  if (lastSlash <= 0 || lastSlash === modelRef.length - 1) {
1820
1882
  return '模型格式错误。请用 `provider/model`,例如 `@aalis/plugin-llm-openai:main/gpt-4o`';
1821
1883
  }
1822
- const chatModels = listLLMModels(ctx, { caps: ['chat'] });
1884
+ const chatModels = listLLMModels(caps.llm, { caps: ['chat'] });
1823
1885
  if (!chatModels.some(e => e.contextId === modelRef)) {
1824
1886
  return `未知模型 "${modelRef}"。用 /model <关键词> 搜索(如 /model grok),共 ${chatModels.length} 个可用。`;
1825
1887
  }
@@ -1827,7 +1889,7 @@ export function apply(ctx, config) {
1827
1889
  }
1828
1890
  // 人设:listModels 可选,缺失则不校验(persona 插件缺席时不误拒)
1829
1891
  if (personaName) {
1830
- const persona = ctx.getService('persona');
1892
+ const persona = caps.persona.current;
1831
1893
  if (persona?.listModels) {
1832
1894
  const names = await persona.listModels();
1833
1895
  if (!names.includes(personaName)) {
@@ -1853,7 +1915,7 @@ export function apply(ctx, config) {
1853
1915
  lines.push('后续消息持续生效(已持久化,重启不丢)。/session.reset 可复位。');
1854
1916
  return lines.join('\n');
1855
1917
  });
1856
- useCommandService(ctx)
1918
+ caps.commands
1857
1919
  .command('session.reset', '复位当前对话的会话级覆盖(默认清模型+人设+thinking;-m/-p/-t 单独清对应项)', {
1858
1920
  risk: 'sensitive',
1859
1921
  examples: ['/session.reset', '/session.reset -m', '/session.reset -p', '/session.reset -t'],
@@ -1862,7 +1924,7 @@ export function apply(ctx, config) {
1862
1924
  .option('persona', '-p', { description: '仅复位人设覆盖' })
1863
1925
  .option('think', '-t', { description: '仅复位 thinking 覆盖' })
1864
1926
  .action(async (argv) => {
1865
- const smSvc = ctx.getService('session-manager');
1927
+ const smSvc = caps.sessionManager.current;
1866
1928
  if (!smSvc)
1867
1929
  return 'session-manager 服务不可用';
1868
1930
  const mFlag = argv.options.model === true;
@@ -1894,8 +1956,7 @@ export function apply(ctx, config) {
1894
1956
  return `已复位 → 模型: ${eff.llm ? `${eff.llm.provider}/${eff.llm.model}` : '(默认)'},人设: ${eff.persona ?? '(默认)'},thinking: ${thinkStr}`;
1895
1957
  });
1896
1958
  // 监听 token:request 事件 — 客户端刷新/重连时主动请求 token 用量
1897
- ctx.on('token:request', async (...args) => {
1898
- const data = args[0];
1959
+ caps.events.on('token:request', async (data) => {
1899
1960
  if (!data?.sessionId)
1900
1961
  return;
1901
1962
  // 唯一发射方是 WebUI;平台缺省按 'webui' 兜底,否则模型/会话配置解析会绕过平台 profile 层
@@ -1909,7 +1970,7 @@ export function apply(ctx, config) {
1909
1970
  const maxTokens = getModelMaxOutput(llm);
1910
1971
  const tokenBudget = Math.max(1024, contextLength - maxTokens - 512);
1911
1972
  // 获取历史消息并构建基础消息列表
1912
- const memory = ctx.getService('memory');
1973
+ const memory = caps.memory.current;
1913
1974
  const messages = [];
1914
1975
  // 系统提示
1915
1976
  const systemPrompt = agent.buildSystemPrompt();
@@ -1921,11 +1982,10 @@ export function apply(ctx, config) {
1921
1982
  }
1922
1983
  // 与真实回合同序:先组装 agent:prompt 贡献(摘要/向量记忆/档案等物化为 system 块),
1923
1984
  // 再跑 agent:llm:before(工具搜索层过滤等拦截职责),使快照贴近实际送入 LLM 的形态
1924
- const sm = ctx.getService('session-manager');
1985
+ const sm = caps.sessionManager.current;
1925
1986
  const sessionResolved = sm ? sm.resolveConfig(data.sessionId, platform) : undefined;
1926
1987
  const enabledGroups = sessionResolved?.enabledToolGroups?.length ? sessionResolved.enabledToolGroups : undefined;
1927
- const tools = ctx.getService('tools')?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ??
1928
- [];
1988
+ const tools = caps.tools.current?.getDefinitions(enabledGroups ? { groups: enabledGroups } : undefined) ?? [];
1929
1989
  const llmBeforeData = {
1930
1990
  messages,
1931
1991
  tools,
@@ -1934,13 +1994,22 @@ export function apply(ctx, config) {
1934
1994
  platform,
1935
1995
  dryRun: true, // 纯统计路径:昂贵注入者(向量检索/档案加载)据此跳过副作用
1936
1996
  };
1937
- await assemblePromptContributions(ctx, llmBeforeData, { buildTimeoutMs: agent.promptBuildTimeoutMs });
1938
- await ctx.runHook('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
1997
+ await assemblePromptContributions(caps, llmBeforeData, { buildTimeoutMs: agent.promptBuildTimeoutMs });
1998
+ await caps.hooks.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
1939
1999
  agent.emitTokenUsage(data.sessionId, platform, llmBeforeData.messages, llmBeforeData.tools, contextLength, maxTokens, tokenBudget);
1940
2000
  }
1941
2001
  catch (err) {
1942
- ctx.logger.debug('token:request 处理失败:', err);
2002
+ caps.logger.debug('token:request 处理失败:', err);
1943
2003
  }
1944
2004
  });
1945
2005
  }
2006
+ export default definePlugin({
2007
+ name: '@aalis/plugin-agent',
2008
+ displayName: '默认 Agent',
2009
+ subsystem: 'agent',
2010
+ configSchema,
2011
+ provides: [agentService],
2012
+ uses,
2013
+ apply: run,
2014
+ });
1946
2015
  //# sourceMappingURL=index.js.map