evolclaw 3.2.0 → 3.4.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -4
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -31
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1152 -140
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +58 -0
  9. package/dist/aun/aid/store.js +1 -1
  10. package/dist/aun/outbox.js +14 -2
  11. package/dist/aun/storage/download.js +1 -1
  12. package/dist/aun/storage/upload.js +13 -1
  13. package/dist/channels/aun.js +869 -358
  14. package/dist/channels/dingtalk.js +77 -140
  15. package/dist/channels/feishu.js +125 -154
  16. package/dist/channels/qqbot.js +75 -138
  17. package/dist/channels/wechat.js +75 -136
  18. package/dist/channels/wecom.js +75 -138
  19. package/dist/cli/agent-command.js +591 -0
  20. package/dist/cli/agent.js +23 -8
  21. package/dist/cli/aun-commands.js +1444 -0
  22. package/dist/cli/ctl-command.js +78 -0
  23. package/dist/cli/daemon-commands.js +2707 -0
  24. package/dist/cli/index.js +23 -4905
  25. package/dist/cli/init.js +33 -6
  26. package/dist/cli/model.js +1 -1
  27. package/dist/cli/restart-monitor.js +539 -0
  28. package/dist/cli/stats.js +558 -0
  29. package/dist/cli/version.js +87 -0
  30. package/dist/cli/watch-logs.js +33 -0
  31. package/dist/cli/watch-msg.js +5 -2
  32. package/dist/config-store.js +12 -6
  33. package/dist/core/channel-loader.js +88 -83
  34. package/dist/core/command/command-handler.js +1189 -0
  35. package/dist/core/command/menu-handler.js +1478 -0
  36. package/dist/core/command/slash-gate.js +142 -0
  37. package/dist/core/command/slash-handler.js +2090 -0
  38. package/dist/core/evolagent-registry.js +82 -0
  39. package/dist/core/evolagent.js +17 -1
  40. package/dist/core/interaction-router.js +8 -0
  41. package/dist/core/message/command-handler-agent-control.js +63 -1
  42. package/dist/core/message/im-renderer.js +91 -51
  43. package/dist/core/message/items-formatter.js +9 -1
  44. package/dist/core/message/message-bridge.js +73 -24
  45. package/dist/core/message/message-log.js +1 -0
  46. package/dist/core/message/message-processor.js +432 -94
  47. package/dist/core/message/message-queue.js +70 -2
  48. package/dist/core/message/pending-hints.js +232 -0
  49. package/dist/core/model/model-catalog.js +1 -1
  50. package/dist/core/model/model-scope.js +2 -2
  51. package/dist/core/permission.js +25 -12
  52. package/dist/core/relation/peer-identity.js +16 -1
  53. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  54. package/dist/core/session/session-manager.js +86 -26
  55. package/dist/core/session/session-title.js +26 -0
  56. package/dist/core/stats/billing.js +151 -0
  57. package/dist/core/stats/budget.js +93 -0
  58. package/dist/core/stats/db.js +334 -0
  59. package/dist/core/stats/eck-vars.js +84 -0
  60. package/dist/core/stats/index.js +10 -0
  61. package/dist/core/stats/normalizer.js +78 -0
  62. package/dist/core/stats/query.js +760 -0
  63. package/dist/core/stats/writer.js +115 -0
  64. package/dist/core/trigger/manager.js +34 -0
  65. package/dist/core/trigger/parser.js +9 -3
  66. package/dist/core/trigger/scheduler.js +20 -17
  67. package/dist/data/error-dict.json +7 -0
  68. package/dist/{agents → eck}/manifest-engine.js +20 -1
  69. package/dist/{agents → eck}/message-renderer.js +24 -1
  70. package/dist/index.js +174 -9
  71. package/dist/ipc.js +116 -1
  72. package/dist/utils/cross-platform.js +58 -5
  73. package/dist/utils/ecweb-launch.js +49 -0
  74. package/dist/utils/ecweb-pair.js +20 -0
  75. package/dist/utils/error-utils.js +18 -5
  76. package/dist/utils/npm-ops.js +38 -8
  77. package/dist/utils/stats.js +77 -6
  78. package/kits/docs/evolclaw/INDEX.md +3 -1
  79. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  80. package/kits/docs/evolclaw/fs.md +131 -0
  81. package/kits/docs/evolclaw/group-fs.md +209 -0
  82. package/kits/docs/evolclaw/stats.md +70 -0
  83. package/kits/docs/venues/aun-group.md +29 -6
  84. package/kits/docs/venues/group.md +5 -4
  85. package/kits/eck_message_manifest.json +30 -3
  86. package/kits/rules/05-venue.md +1 -1
  87. package/kits/templates/message-fragments/inject-default.md +2 -0
  88. package/package.json +5 -6
  89. package/dist/agents/baseagent-normalize.js +0 -19
  90. package/dist/core/command-handler.js +0 -3876
  91. package/dist/core/relation/peer-key.js +0 -16
  92. package/dist/evolclaw-config.js +0 -11
  93. package/dist/utils/channel-helpers.js +0 -46
  94. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  95. /package/dist/{agents → eck}/kit-renderer.js +0 -0
@@ -2,20 +2,24 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
- import { hasCompact } from '../../agents/claude-runner.js';
5
+ import { hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel } from '../../agents/runner-types.js';
6
6
  import { IMRenderer } from './im-renderer.js';
7
7
  import { StreamIdleMonitor } from './stream-idle-monitor.js';
8
8
  import { logger } from '../../utils/logger.js';
9
- import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
9
+ import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError, isContextTooLongText } from '../../utils/error-utils.js';
10
10
  import { summarizeToolInput } from '../permission.js';
11
11
  import { DEFAULT_PERMISSION_MODE } from '../../types.js';
12
- import { getPackageRoot, resolveRoot } from '../../paths.js';
13
- import { renderKitSections } from '../../agents/kit-renderer.js';
14
- import { renderMessageBody } from '../../agents/message-renderer.js';
15
- import { normalizeBaseagent } from '../../agents/baseagent-normalize.js';
12
+ import { getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
13
+ import { renderKitSections } from '../../eck/kit-renderer.js';
14
+ import { renderMessageBody } from '../../eck/message-renderer.js';
15
+ import { consumeHints, hintsToSubMessages, composeHintFallback } from './pending-hints.js';
16
+ import { normalizeBaseagent } from '../../agents/baseagent.js';
16
17
  import { renderActionAsText, renderCommandCardAsText } from '../interaction-router.js';
17
- import { formatPeerKey } from '../relation/peer-key.js';
18
+ import { formatPeerKey } from '../relation/peer-identity.js';
18
19
  import { resolveEffectiveModel } from '../model/model-scope.js';
20
+ import { insertUsageEvent, insertContextBreakdown, insertModelCalls } from '../stats/writer.js';
21
+ import { normalizeUsage } from '../stats/normalizer.js';
22
+ import { getBudgetStatus } from '../stats/budget.js';
19
23
  /** OS 信息在进程生命周期内是常量,模块加载时算一次。例: "Windows 11 Pro (win32 10.0.26200)" */
20
24
  const OS_INFO = (() => {
21
25
  let label = '';
@@ -61,6 +65,11 @@ function getContextCompactFailedHint(agent) {
61
65
  function canCompactAgent(agent) {
62
66
  return hasCompact(agent) && agent.capabilities?.compact !== false;
63
67
  }
68
+ function autoCompactTokensFromMaxTokens(maxTokens) {
69
+ if (!maxTokens || maxTokens <= 0)
70
+ return undefined;
71
+ return maxTokens >= 1000000 ? maxTokens - 100000 : maxTokens;
72
+ }
64
73
  /**
65
74
  * 构造 OutboundEnvelope —— 出站三件套的信封部分。
66
75
  *
@@ -182,6 +191,12 @@ export class MessageProcessor {
182
191
  setAgentRegistry(registry) {
183
192
  this.agentRegistry = registry;
184
193
  }
194
+ /** 更新 EvolAgent.lastActivity —— 每次发出 status.* 事件(含 progress)时调用 */
195
+ touchAgentActivity(channelKey) {
196
+ const owning = this.agentRegistry?.resolveByChannel(channelKey);
197
+ if (owning)
198
+ owning.lastActivity = Date.now();
199
+ }
185
200
  getAgentContext(channelName, chatType) {
186
201
  if (!this.agentRegistry)
187
202
  return null;
@@ -192,6 +207,34 @@ export class MessageProcessor {
192
207
  const globalCm = agent.config?.chatmode ?? this.globalSettings.chatmode;
193
208
  return agent.getContext(channelName, chatType, globalCm);
194
209
  }
210
+ /**
211
+ * 观察者插话(v0.3):消费当前 (对端, thread) 的待用提示,转成 owner-hint SubMessage。
212
+ * 一次性语义:consumeHints 回放算有效集后清该 thread(其它 thread 残留则保留,否则删文件)。
213
+ * 仅 aun 渠道(pending-hints 落在 sessions/aun/<self>/<对端>/)。
214
+ */
215
+ consumeOwnerHints(session, message) {
216
+ const channelType = session.channelType || message.channelType || session.channel;
217
+ if (channelType !== 'aun')
218
+ return [];
219
+ const selfAID = session.selfAID || message.selfAID;
220
+ if (!selfAID)
221
+ return [];
222
+ // 会话定位键:私聊=对端 AID,群聊=groupId(均为 session.channelId)。
223
+ const peerChannelId = session.channelId;
224
+ if (!peerChannelId)
225
+ return [];
226
+ try {
227
+ const hints = consumeHints(resolvePaths().sessionsDir, 'aun', peerChannelId, selfAID, session.threadId);
228
+ if (hints.length === 0)
229
+ return [];
230
+ logger.info(`[MessageProcessor] consumed ${hints.length} owner-hint(s) for ${peerChannelId} thread=${session.threadId || 'main'}`);
231
+ return hintsToSubMessages(hints);
232
+ }
233
+ catch (e) {
234
+ logger.warn(`[MessageProcessor] consumeOwnerHints failed: ${e instanceof Error ? e.message : String(e)}`);
235
+ return [];
236
+ }
237
+ }
195
238
  /**
196
239
  * 注册渠道适配器
197
240
  */
@@ -203,6 +246,25 @@ export class MessageProcessor {
203
246
  this.channelTypeMap.set(type, adapter.channelName);
204
247
  }
205
248
  }
249
+ /**
250
+ * 注销渠道适配器(热重载断开渠道时调用,避免遗留死实例)。
251
+ * channelTypeMap 若指向被删实例,重定向到同类型的另一存活实例(无则删除映射)。
252
+ */
253
+ unregisterChannel(channelName) {
254
+ const info = this.channels.get(channelName);
255
+ this.channels.delete(channelName);
256
+ const type = info?.options?.channelType || channelName;
257
+ if (this.channelTypeMap.get(type) === channelName) {
258
+ this.channelTypeMap.delete(type);
259
+ // 重定向到同类型的另一存活实例(保持按类型名路由可用)
260
+ for (const [name, ci] of this.channels) {
261
+ if ((ci.options?.channelType || name) === type) {
262
+ this.channelTypeMap.set(type, name);
263
+ break;
264
+ }
265
+ }
266
+ }
267
+ }
206
268
  /**
207
269
  * 获取渠道适配器(支持实例名和 channelType)
208
270
  */
@@ -461,6 +523,18 @@ export class MessageProcessor {
461
523
  agentName: agentNameForStats,
462
524
  timestamp: Date.now()
463
525
  });
526
+ // ── 硬上限检查:超限直接返回提示,不调模型 ──
527
+ {
528
+ const budgetAgentAid = session.selfAID || message.selfAID || '';
529
+ const budgetPeerKey = formatPeerKey(message.channel, message.channelId);
530
+ const budgetStatus = getBudgetStatus(resolveRoot(), budgetAgentAid, budgetPeerKey);
531
+ if (budgetStatus.hard_blocked) {
532
+ logger.warn(`[MessageProcessor] Budget hard limit reached: agent=${budgetAgentAid} peer=${budgetPeerKey} pct=${budgetStatus.pct_used.toFixed(1)}%`);
533
+ this.touchAgentActivity(channelKey);
534
+ adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs: 0 } }).catch(() => { });
535
+ return;
536
+ }
537
+ }
464
538
  const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
465
539
  const modeInfo = isBackground ? ' [\u540e\u53f0]' : '';
466
540
  const e2eeInfo = message.replyContext?.metadata?.encrypted != null ? ` encrypt=${message.replyContext.metadata.encrypted}` : '';
@@ -476,8 +550,10 @@ export class MessageProcessor {
476
550
  this.eventBus.publish({ type: 'task:started', sessionId: session.id, agentName: agentNameForStats, encrypt: taskEncrypt, chatmode: session.sessionMode || 'interactive' });
477
551
  // 触发器消息不发 processing status(无需通知用户)
478
552
  if (message.source !== 'trigger') {
553
+ this.touchAgentActivity(channelKey);
479
554
  adapter.send(envelope, { kind: 'status.started' }).catch(() => { });
480
555
  }
556
+ await this.runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope);
481
557
  logger.message({
482
558
  msgId: messageId,
483
559
  sessionId: session.id,
@@ -519,6 +595,8 @@ export class MessageProcessor {
519
595
  opts.title = '\u2705 \u6700\u7ec8\u56de\u590d:';
520
596
  }
521
597
  opts.metadata = { ...(opts.metadata ?? {}), taskId, chatmode };
598
+ if (payload.kind.startsWith('status.'))
599
+ this.touchAgentActivity(channelKey);
522
600
  const enrichedEnvelope = { ...envelope, replyContext: opts };
523
601
  await adapter.send(enrichedEnvelope, payload);
524
602
  },
@@ -546,6 +624,9 @@ export class MessageProcessor {
546
624
  agentName: agentNameForStats,
547
625
  taskId,
548
626
  chatmode: isProactive ? 'proactive' : 'interactive',
627
+ flushPending: async () => {
628
+ await renderer.flush(false);
629
+ },
549
630
  interceptNextMessage: this.messageQueue
550
631
  ? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
551
632
  : undefined,
@@ -582,7 +663,8 @@ export class MessageProcessor {
582
663
  const currentChannelType = options?.channelType || message.channel;
583
664
  // 提取 self 信息
584
665
  const adapterAny = channelInfo.adapter;
585
- const selfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
666
+ const adapterSelfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
667
+ const selfAid = adapterSelfAid || message.selfAID || session.selfAID || undefined;
586
668
  const selfName = typeof adapterAny._selfName === 'function' ? adapterAny._selfName() : undefined;
587
669
  const peerName = message.peerName || session.metadata?.peerName;
588
670
  // 通道能力
@@ -606,6 +688,7 @@ export class MessageProcessor {
606
688
  const peerKey = (currentChannelType && peerIdRaw)
607
689
  ? formatPeerKey(currentChannelType, peerIdRaw)
608
690
  : undefined;
691
+ const normalizedBaseagent = normalizeBaseagent(agent.name);
609
692
  // 按 关系级 > agent级 > 全局 解析本次调用的模型/强度,作为 per-call 入参传入 runQuery。
610
693
  // 不缓存、不绑会话——改关系级/agent级后该范围所有会话的下条消息即时生效;
611
694
  // 多对端并发各自独立解析、各自传参,无共享状态可被污染。
@@ -625,7 +708,7 @@ export class MessageProcessor {
625
708
  let evolclawModelOverride;
626
709
  if (!skipEvolclawModel) {
627
710
  try {
628
- const resolved = resolveEffectiveModel({ self: selfAid || undefined, peerKey });
711
+ const resolved = resolveEffectiveModel({ self: selfAid || undefined, peerKey }, normalizedBaseagent.canonical);
629
712
  if (resolved.model) {
630
713
  evolclawModelOverride = { model: resolved.model, effort: resolved.effort };
631
714
  effectiveModel = resolved.model;
@@ -636,7 +719,6 @@ export class MessageProcessor {
636
719
  }
637
720
  modelOverride = evolclawModelOverride;
638
721
  }
639
- const normalizedBaseagent = normalizeBaseagent(agent.name);
640
722
  agentModel = (typeof agent.getModel === 'function') ? agent.getModel() : undefined;
641
723
  // Kit renderer: 组装上下文
642
724
  const pkgRoot = getPackageRoot();
@@ -680,7 +762,8 @@ export class MessageProcessor {
680
762
  channel: currentChannelType || null,
681
763
  venueUid: undefined,
682
764
  // 群分发模式 / 客户端类型 / 权限模式
683
- dispatch: session.metadata?.dispatchMode || undefined,
765
+ // 优先本地 session 覆盖(/dispatch 命令),fallback 到服务器 dispatch_mode 缓存
766
+ dispatch: (session.metadata?.dispatchModeOverride ?? session.metadata?.dispatchMode ?? message.dispatchMode) || undefined,
684
767
  clientType: message.clientType || undefined,
685
768
  permissionMode: session.metadata?.permissionMode || 'auto',
686
769
  capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
@@ -706,6 +789,8 @@ export class MessageProcessor {
706
789
  modelFallbackActive: (fbState.fallbackActive || skipEvolclawModel) ? true : undefined,
707
790
  modelFallbackModel: (fbState.fallbackActive || skipEvolclawModel) ? (agentModel || undefined) : undefined,
708
791
  agentSessionId: session.agentSessionId || undefined,
792
+ // 渲染模式:各类型当前激活的 modeName(从内存 config 读,渲染层据此选 manifest section)。
793
+ renderModes: this.agentRegistry?.resolveByChannel(channelKey)?.config?.render ?? undefined,
709
794
  },
710
795
  sessionId: session.id,
711
796
  };
@@ -713,35 +798,73 @@ export class MessageProcessor {
713
798
  if (kitContext)
714
799
  contextParts.push(kitContext);
715
800
  effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
801
+ // ── Stats: context_breakdown 旁路采集(各段估算 token 数,字符数/4 近似) ──
802
+ try {
803
+ const estTokens = (s) => s ? Math.ceil(s.length / 4) : 0;
804
+ const cbModel = effectiveModel || agentModel || 'unknown';
805
+ const cbMaxTokens = 200000; // 保守默认,后续可从 model-catalog 取
806
+ const systemPromptTokens = estTokens(options?.systemPromptAppend);
807
+ const personaTokens = estTokens(persona);
808
+ const workingTokens = estTokens(working);
809
+ const kitTokens = estTokens(kitContext);
810
+ const totalEst = estTokens(effectiveSystemPrompt);
811
+ insertContextBreakdown(resolveRoot(), {
812
+ ts: Date.now(),
813
+ agent_aid: selfAid || session.selfAID || '',
814
+ session_id: session.id,
815
+ turn_count: 0, // 按 ts 排序得轮次
816
+ model: cbModel,
817
+ max_tokens: cbMaxTokens,
818
+ system_prompt: systemPromptTokens + personaTokens + workingTokens,
819
+ system_tools: 0, // 工具 schema 不在此层,留 0(后续 runner 层补)
820
+ mcp_tools: 0,
821
+ custom_agents: 0,
822
+ memory_files: kitTokens, // ECK 渲染的所有段(含 memory/skills/rules)
823
+ skills: 0,
824
+ messages: 0, // messages 段在 runner 层才知道
825
+ free_space: Math.max(0, cbMaxTokens - totalEst),
826
+ total_estimated: totalEst,
827
+ });
828
+ }
829
+ catch { /* non-fatal */ }
716
830
  // 消息渲染层:用 message manifest 逐条渲染(时间 + 群聊发送者),组装成最终正文。
717
831
  // 单条消息构造单元素 items;批量合并的消息 message.items 已由队列填充。
718
832
  let renderResult;
719
833
  const hasContent = message.content.trim() || (message.items && message.items.length > 0);
720
834
  if (hasContent) {
835
+ const peerItems = message.items && message.items.length > 0
836
+ ? message.items
837
+ : [{
838
+ peerId: message.peerId, peerName: peerName || undefined,
839
+ peerType: message.peerType,
840
+ sameDevice: message.sameDevice, sameNetwork: message.sameNetwork, sameEgressIp: message.sameEgressIp,
841
+ content: message.content, timestamp: message.timestamp,
842
+ images: message.images,
843
+ mentionAids: message.mentionAids,
844
+ }];
845
+ // 观察者插话(v0.3):消费 (对端, thread) 的待用提示,包成 owner-hint item 排在对端消息前。
846
+ // 一次性语义:consumeOwnerHints 读取并删除(见 pending-hints.ts)。在 try 外消费,
847
+ // 这样即便 renderMessageBody 抛错走 raw 兜底,也把提示原文拼进去——绝不静默丢提示。
848
+ const hintItems = this.consumeOwnerHints(session, message);
849
+ const renderItems = hintItems.length > 0 ? [...hintItems, ...peerItems] : peerItems;
721
850
  try {
722
- const renderItems = message.items && message.items.length > 0
723
- ? message.items
724
- : [{
725
- peerId: message.peerId, peerName: peerName || undefined,
726
- peerType: message.peerType,
727
- sameDevice: message.sameDevice, sameNetwork: message.sameNetwork, sameEgressIp: message.sameEgressIp,
728
- content: message.content, timestamp: message.timestamp,
729
- images: message.images,
730
- mentionAids: message.mentionAids,
731
- }];
732
851
  renderResult = renderMessageBody(renderItems, kitCtx.vars, session.id);
733
852
  if (renderResult.body.trim())
734
853
  effectivePrompt = wrapPrompt(renderResult.body);
735
854
  else
736
- effectivePrompt = wrapPrompt(message.content);
855
+ effectivePrompt = wrapPrompt(composeHintFallback(hintItems, message.content));
737
856
  }
738
857
  catch (e) {
739
858
  logger.warn(`[MessageProcessor] renderMessageBody failed, using raw content: ${e instanceof Error ? e.message : String(e)}`);
740
- effectivePrompt = wrapPrompt(message.content);
859
+ effectivePrompt = wrapPrompt(composeHintFallback(hintItems, message.content));
741
860
  }
742
861
  }
743
862
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
744
863
  const MAX_RETRIES = 3;
864
+ // Runner 开始执行前:将 Pin 升级为 CheckMark(表示"正在处理")
865
+ if (message.messageId && message.source !== 'trigger') {
866
+ adapter.promoteAck?.(message.messageId).catch(() => { });
867
+ }
745
868
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
746
869
  let streamRegistered = false;
747
870
  try {
@@ -810,12 +933,12 @@ export class MessageProcessor {
810
933
  }
811
934
  // prompt_too_long:SDK 以 complete 事件(非异常)返回,需在此处触发 compact
812
935
  // 检测条件:terminalReason 明确为 prompt_too_long,或文本/errors 包含相关错误文本
813
- const contextTooLongPattern = /prompt is too long|input is too long|上下文过长/i;
814
- const errorsText = streamResult.errors?.join(' ') || '';
815
- const isPromptTooLong = streamResult.isError && session.agentSessionId && canCompactAgent(agent) && (streamResult.terminalReason === 'prompt_too_long' ||
816
- contextTooLongPattern.test(streamResult.lastReplyText) ||
817
- contextTooLongPattern.test(errorsText) ||
818
- contextTooLongPattern.test(streamResult.fullText));
936
+ const streamHitContextLimit = (sr) => sr.terminalReason === 'prompt_too_long' ||
937
+ isContextTooLongText(sr.lastReplyText) ||
938
+ isContextTooLongText(sr.errors?.join(' ') || '') ||
939
+ isContextTooLongText(sr.fullText);
940
+ const isPromptTooLong = streamResult.isError && !!session.agentSessionId && canCompactAgent(agent)
941
+ && streamHitContextLimit(streamResult);
819
942
  if (isPromptTooLong) {
820
943
  renderer.addNotice('上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
821
944
  await renderer.flush();
@@ -826,11 +949,7 @@ export class MessageProcessor {
826
949
  agent.registerStream(streamKey, retryStream);
827
950
  streamResult = await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress);
828
951
  // 重试后仍然 prompt_too_long:清理 renderer 中可能混入的错误文本,显示友好提示
829
- const retryErrorsText = streamResult.errors?.join(' ') || '';
830
- const retryStillTooLong = streamResult.isError && (streamResult.terminalReason === 'prompt_too_long' ||
831
- contextTooLongPattern.test(streamResult.lastReplyText) ||
832
- contextTooLongPattern.test(retryErrorsText) ||
833
- contextTooLongPattern.test(streamResult.fullText));
952
+ const retryStillTooLong = streamResult.isError && streamHitContextLimit(streamResult);
834
953
  if (retryStillTooLong) {
835
954
  renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
836
955
  }
@@ -839,10 +958,7 @@ export class MessageProcessor {
839
958
  throw new Error('CONTEXT_COMPACT_FAILED');
840
959
  }
841
960
  }
842
- else if (streamResult.isError && !isPromptTooLong && (streamResult.terminalReason === 'prompt_too_long' ||
843
- contextTooLongPattern.test(streamResult.lastReplyText) ||
844
- contextTooLongPattern.test(errorsText) ||
845
- contextTooLongPattern.test(streamResult.fullText))) {
961
+ else if (streamResult.isError && streamHitContextLimit(streamResult)) {
846
962
  // 上下文过长但无法 auto-compact(无 session ID 或 agent 不支持),显示友好提示
847
963
  renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
848
964
  }
@@ -971,12 +1087,6 @@ export class MessageProcessor {
971
1087
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
972
1088
  await renderer.flush(true);
973
1089
  }
974
- // 更新 EvolAgent.lastActivity
975
- if (this.agentRegistry) {
976
- const owningAgent = this.agentRegistry.resolveByChannel(channelKey);
977
- if (owningAgent)
978
- owningAgent.lastActivity = Date.now();
979
- }
980
1090
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
981
1091
  const interruptReason = this.interruptedSessions.get(session.id);
982
1092
  if (streamResult.isError) {
@@ -984,55 +1094,193 @@ export class MessageProcessor {
984
1094
  const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
985
1095
  const rawSubtype = streamResult.subtype || 'agent_error';
986
1096
  const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
987
- if (message.source !== 'trigger') {
1097
+ // 用户主动打断(新消息/​/stop/​撤回)会让 SDK 流在工具调用中途被掐断,
1098
+ // 末尾 result message 形状异常并被标记为 error(含 SDK 内部 ede_diagnostic 串)。
1099
+ // 这不是真正的失败,不应把诊断串暴露给用户,也不计入错误统计。
1100
+ const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
1101
+ if (message.source !== 'trigger' && !isUserInterrupt) {
1102
+ await adapter.send(envelope, { kind: 'result.error', text: errorSummary, reason: rawSubtype }).catch(() => { });
988
1103
  adapter.send(envelope, { kind: 'status.error', metadata: { errorType: rawSubtype } }).catch(() => { });
1104
+ this.touchAgentActivity(channelKey);
989
1105
  }
990
- if (message.triggerMeta) {
991
- this.eventBus.publish({ type: 'trigger:failed', triggerId: message.triggerMeta.triggerId, messageId: messageId, error: errorSummary });
1106
+ if (isUserInterrupt) {
1107
+ // 用户打断:打断本身已由 message-queue 发过 task:interrupted 事件,
1108
+ // 这里不再补发 task:error(否则同一次打断被记两遍且错误归类为 error)。
1109
+ // 仅记 info 日志收尾。注意:task:interrupted 已填充 interruptedSessions,
1110
+ // stats 侧已据此收尾任务生命周期,无需在此重复发事件。
1111
+ logger.info(`[${message.channel}] Stream result error suppressed (user interrupt: ${interruptReason}): ${errorSummary}`);
1112
+ logger.message({
1113
+ msgId: messageId,
1114
+ sessionId: session.id,
1115
+ dir: 'inbound',
1116
+ status: 'interrupted',
1117
+ error: errorSummary,
1118
+ terminalReason: streamResult.terminalReason
1119
+ });
992
1120
  }
993
- this.eventBus.publish({
994
- type: 'task:error',
995
- sessionId: session.id,
996
- error: errorSummary,
997
- errorType,
998
- agentName: agentNameForStats,
999
- terminalReason: streamResult.terminalReason
1000
- });
1001
- // 系统级 subtype 仍累计错误计数,供 /status 诊断使用
1002
- if (isInfraError(rawSubtype, streamResult.terminalReason)) {
1003
- const chatType = message.chatType || 'private';
1004
- const identityRole = session.identity?.role || 'anonymous';
1005
- const { policy } = channelInfo;
1006
- if (policy.accumulateErrors(chatType, identityRole)) {
1007
- await this.sessionManager.recordError(session.id, errorType, errorSummary);
1121
+ else {
1122
+ if (message.triggerMeta) {
1123
+ this.eventBus.publish({ type: 'trigger:failed', triggerId: message.triggerMeta.triggerId, name: message.triggerMeta.triggerName ?? '', messageId: messageId, error: errorSummary, targetChannel: message.channel, targetChannelId: message.channelId, fireTime: message.triggerMeta.fireTime ?? 0, phase: 'execute' });
1124
+ }
1125
+ this.eventBus.publish({
1126
+ type: 'task:error',
1127
+ sessionId: session.id,
1128
+ error: errorSummary,
1129
+ errorType,
1130
+ agentName: agentNameForStats,
1131
+ terminalReason: streamResult.terminalReason
1132
+ });
1133
+ // 系统级 subtype 仍累计错误计数,供 /status 诊断使用
1134
+ if (isInfraError(rawSubtype, streamResult.terminalReason)) {
1135
+ const chatType = message.chatType || 'private';
1136
+ const identityRole = session.identity?.role || 'anonymous';
1137
+ const { policy } = channelInfo;
1138
+ if (policy.accumulateErrors(chatType, identityRole)) {
1139
+ await this.sessionManager.recordError(session.id, errorType, errorSummary);
1140
+ }
1008
1141
  }
1142
+ logger.message({
1143
+ msgId: messageId,
1144
+ sessionId: session.id,
1145
+ dir: 'inbound',
1146
+ status: 'failed',
1147
+ error: errorSummary,
1148
+ terminalReason: streamResult.terminalReason
1149
+ });
1009
1150
  }
1010
- logger.message({
1011
- msgId: messageId,
1012
- sessionId: session.id,
1013
- dir: 'inbound',
1014
- status: 'failed',
1015
- error: errorSummary,
1016
- terminalReason: streamResult.terminalReason
1017
- });
1018
1151
  }
1019
1152
  else {
1020
1153
  // 真正的成功
1021
1154
  const durationMs = Date.now() - startTime;
1155
+ // ── Stats: 写入 usage_events(在 status.completed 之前,以便带上 cost) ──
1156
+ let statsCostUsd = 0;
1157
+ let statsCostCny = 0;
1158
+ let statsCacheHitRate = 0;
1159
+ if (streamResult.tokenUsage) {
1160
+ try {
1161
+ const statsAgentAid = session.selfAID || message.selfAID || '';
1162
+ const statsPeerKey = formatPeerKey(message.channel, message.channelId);
1163
+ const statsModel = streamResult.contextUsage?.model || 'unknown';
1164
+ const ctxPct = streamResult.contextUsage?.percentage;
1165
+ const event = normalizeUsage(streamResult.tokenUsage, {
1166
+ ts: Date.now(),
1167
+ agent_aid: statsAgentAid,
1168
+ peer_key: statsPeerKey,
1169
+ peer_type: session.chatType || undefined,
1170
+ session_id: session.id,
1171
+ model: statsModel,
1172
+ turns: streamResult.numTurns,
1173
+ duration_ms: durationMs,
1174
+ context_window_pct: ctxPct,
1175
+ });
1176
+ insertUsageEvent(resolveRoot(), event);
1177
+ // 逐次大模型调用明细落库(model_calls 表)
1178
+ if (streamResult.modelCalls?.length) {
1179
+ const mcRows = streamResult.modelCalls.map(mc => ({
1180
+ ts: event.ts,
1181
+ task_id: taskId,
1182
+ session_id: session.id,
1183
+ agent_session_id: session.agentSessionId ?? undefined,
1184
+ agent_aid: statsAgentAid,
1185
+ peer_key: statsPeerKey,
1186
+ call_index: mc.call_index,
1187
+ model: mc.model || statsModel,
1188
+ request_id: mc.request_id,
1189
+ message_id: mc.message_id,
1190
+ input_tokens: mc.tokenUsage.input_tokens ?? 0,
1191
+ output_tokens: mc.tokenUsage.output_tokens ?? 0,
1192
+ cache_creation_tokens: mc.tokenUsage.cache_creation_input_tokens ?? 0,
1193
+ cache_read_tokens: mc.tokenUsage.cache_read_input_tokens ?? 0,
1194
+ context_tokens: mc.contextUsage?.totalTokens,
1195
+ max_tokens: mc.contextUsage?.maxTokens,
1196
+ auto_compact_tokens: mc.contextUsage?.autoCompactTokens,
1197
+ degraded: mc.degraded ? 1 : 0,
1198
+ }));
1199
+ insertModelCalls(resolveRoot(), mcRows);
1200
+ }
1201
+ // 计算费用(用于合入 status.completed)
1202
+ const { calcCost } = await import('../stats/billing.js');
1203
+ const cost = calcCost(resolveRoot(), { ...event, ts: event.ts, model: event.model, billing_fn: event.billing_fn });
1204
+ statsCostUsd = cost.usd ?? 0;
1205
+ statsCostCny = cost.cny ?? 0;
1206
+ const totalIn = event.input_tokens + event.cache_read_tokens;
1207
+ statsCacheHitRate = totalIn > 0 ? Math.round((event.cache_read_tokens / totalIn) * 100) / 100 : 0;
1208
+ }
1209
+ catch (e) {
1210
+ logger.debug(`[MessageProcessor] Stats write failed (non-fatal): ${e}`);
1211
+ }
1212
+ }
1213
+ // 会话累计 + model spec(用于 status.completed 统计细目)
1214
+ let sessionStats;
1215
+ let modelSpec;
1216
+ try {
1217
+ const { openReadonlyDb, getDbPath } = await import('../stats/db.js');
1218
+ const { resolveModelSpec } = await import('../stats/billing.js');
1219
+ const statsModel = streamResult.contextUsage?.model || 'unknown';
1220
+ modelSpec = resolveModelSpec(resolveRoot(), statsModel);
1221
+ const rdb = openReadonlyDb(getDbPath(resolveRoot()));
1222
+ if (rdb) {
1223
+ try {
1224
+ const row = rdb.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS input_tokens, COALESCE(SUM(output_tokens),0) AS output_tokens,
1225
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens, COALESCE(SUM(cache_creation_tokens),0) AS cache_creation_tokens,
1226
+ COUNT(*) AS call_count FROM usage_events WHERE session_id = ?`).get(session.id);
1227
+ if (row) {
1228
+ // 逐行算费用太贵,用近似:最后一轮的 cost 乘以次数不准,所以这里用累加 token 近似
1229
+ sessionStats = {
1230
+ input_tokens: row.input_tokens,
1231
+ output_tokens: row.output_tokens,
1232
+ cache_read_tokens: row.cache_read_tokens,
1233
+ cache_creation_tokens: row.cache_creation_tokens,
1234
+ cost_usd: 0, cost_cny: 0,
1235
+ call_count: row.call_count,
1236
+ };
1237
+ // 快速费用估算:用会话所有行逐行算
1238
+ const rows = rdb.prepare(`SELECT * FROM usage_events WHERE session_id = ?`).all(session.id);
1239
+ const { calcCost: cc } = await import('../stats/billing.js');
1240
+ for (const r of rows) {
1241
+ const c = cc(resolveRoot(), r);
1242
+ sessionStats.cost_usd += c.usd ?? 0;
1243
+ sessionStats.cost_cny += c.cny ?? 0;
1244
+ }
1245
+ }
1246
+ }
1247
+ finally {
1248
+ rdb.close();
1249
+ }
1250
+ }
1251
+ }
1252
+ catch { /* non-fatal */ }
1022
1253
  if (message.source !== 'trigger') {
1254
+ this.touchAgentActivity(channelKey);
1023
1255
  if (interruptReason) {
1024
1256
  adapter.send(envelope, { kind: 'status.interrupted', metadata: { reason: interruptReason } }).catch(() => { });
1025
1257
  }
1026
1258
  else {
1027
- adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs, ttftMs: streamResult.ttftMs, numTurns: streamResult.numTurns, tokenUsage: streamResult.tokenUsage, contextUsage: streamResult.contextUsage } }).catch(() => { });
1259
+ adapter.send(envelope, { kind: 'status.completed', metadata: {
1260
+ durationMs,
1261
+ ttftMs: streamResult.ttftMs,
1262
+ numTurns: streamResult.numTurns,
1263
+ tokenUsage: streamResult.tokenUsage,
1264
+ contextUsage: streamResult.contextUsage,
1265
+ lastModelCall: streamResult.lastModelCall,
1266
+ cost_usd: statsCostUsd,
1267
+ cost_cny: statsCostCny,
1268
+ cache_hit_rate: statsCacheHitRate,
1269
+ model_spec: modelSpec,
1270
+ session_total: sessionStats,
1271
+ queue: {
1272
+ pending: this.messageQueue?.getQueueLength(session.id) ?? 0,
1273
+ processing: this.messageQueue?.isProcessing(session.id) ? 1 : 0,
1274
+ },
1275
+ } }).catch(() => { });
1028
1276
  }
1029
1277
  }
1030
1278
  if (message.triggerMeta) {
1031
1279
  if (interruptReason) {
1032
- this.eventBus.publish({ type: 'trigger:skipped', triggerId: message.triggerMeta.triggerId, reason: 'interrupted' });
1280
+ this.eventBus.publish({ type: 'trigger:skipped', triggerId: message.triggerMeta.triggerId, name: message.triggerMeta.triggerName ?? '', reason: 'interrupted', targetChannel: message.channel, targetChannelId: message.channelId });
1033
1281
  }
1034
1282
  else {
1035
- this.eventBus.publish({ type: 'trigger:completed', triggerId: message.triggerMeta.triggerId, messageId: messageId, durationMs });
1283
+ this.eventBus.publish({ type: 'trigger:completed', triggerId: message.triggerMeta.triggerId, name: message.triggerMeta.triggerName ?? '', messageId: messageId, durationMs, targetChannel: message.channel, targetChannelId: message.channelId, fireTime: message.triggerMeta.fireTime ?? 0 });
1036
1284
  }
1037
1285
  }
1038
1286
  await this.sessionManager.recordSuccess(session.id);
@@ -1098,6 +1346,7 @@ export class MessageProcessor {
1098
1346
  ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
1099
1347
  : { kind: 'status.error' };
1100
1348
  adapter.send(envelope, statusPayload).catch(() => { });
1349
+ this.touchAgentActivity(channelKey);
1101
1350
  }
1102
1351
  // 用户主动中断时降级日志;其余仍按 error 记录
1103
1352
  if (isUserInterrupt) {
@@ -1108,19 +1357,25 @@ export class MessageProcessor {
1108
1357
  }
1109
1358
  const errorMsg = error instanceof Error ? error.message : String(error);
1110
1359
  const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
1111
- this.eventBus.publish({
1112
- type: 'task:error',
1113
- sessionId: session.id,
1114
- error: errorMsg,
1115
- errorType,
1116
- agentName: agentNameForStats,
1117
- });
1360
+ // 用户主动打断:流被掐断抛出的异常不是真正的失败。打断发生时 source
1361
+ // (message-queue / slash-handler)已发过 task:interrupted(它填充了
1362
+ // interruptedSessions,isUserInterrupt 才会为真),stats 侧已据此收尾任务。
1363
+ // 此处不再发任何事件——发 task:error 会误归类,重发 task:interrupted 会重复记账。
1364
+ if (!isUserInterrupt) {
1365
+ this.eventBus.publish({
1366
+ type: 'task:error',
1367
+ sessionId: session.id,
1368
+ error: errorMsg,
1369
+ errorType,
1370
+ agentName: agentNameForStats,
1371
+ });
1372
+ }
1118
1373
  // 记录处理失败
1119
1374
  logger.message({
1120
1375
  msgId: messageId,
1121
1376
  sessionId: session.id,
1122
1377
  dir: 'inbound',
1123
- status: 'failed',
1378
+ status: isUserInterrupt ? 'interrupted' : 'failed',
1124
1379
  error: error instanceof Error ? error.message : String(error)
1125
1380
  });
1126
1381
  if (error instanceof Error && !isUserInterrupt) {
@@ -1154,23 +1409,98 @@ export class MessageProcessor {
1154
1409
  ...(sendOpts ?? {}),
1155
1410
  metadata: { ...(sendOpts?.metadata ?? {}), taskId, chatmode },
1156
1411
  };
1157
- const errorPayload = isTimeout
1158
- ? { kind: 'result.error', text: userMessage, reason: 'timeout' }
1159
- : { kind: 'result.text', text: userMessage, isFinal: true };
1412
+ const errorPayload = {
1413
+ kind: 'result.error',
1414
+ text: userMessage,
1415
+ reason: isTimeout ? 'timeout' : errType,
1416
+ };
1160
1417
  await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
1161
1418
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
1162
1419
  }
1163
1420
  }
1164
1421
  }
1422
+ async runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope) {
1423
+ if (!session.agentSessionId || !canCompactAgent(agent))
1424
+ return;
1425
+ const ctx = await this.readLastModelCallContextUsage(session.id, session.agentSessionId);
1426
+ if (!ctx || ctx.totalTokens < ctx.autoCompactTokens)
1427
+ return;
1428
+ logger.info(`[MessageProcessor] Auto compact at task.start: session=${session.id} totalTokens=${ctx.totalTokens} autoCompactTokens=${ctx.autoCompactTokens}`);
1429
+ await adapter.send(envelope, { kind: 'system.notice', text: '上下文接近上限,正在压缩会话...', subtype: 'auto-compact-start' }).catch(() => { });
1430
+ try {
1431
+ const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
1432
+ if (compacted) {
1433
+ await adapter.send(envelope, { kind: 'system.notice', text: '✅ 上下文压缩完成,继续处理...', subtype: 'auto-compact-complete' }).catch(() => { });
1434
+ }
1435
+ else {
1436
+ logger.warn(`[MessageProcessor] Auto compact at task.start returned false (session=${session.id})`);
1437
+ }
1438
+ }
1439
+ catch (err) {
1440
+ logger.warn(`[MessageProcessor] Auto compact at task.start failed (non-fatal):`, err);
1441
+ }
1442
+ }
1443
+ async readLastModelCallContextUsage(sessionId, agentSessionId) {
1444
+ try {
1445
+ const { openReadonlyDb, getDbPath } = await import('../stats/db.js');
1446
+ const rdb = openReadonlyDb(getDbPath(resolveRoot()));
1447
+ if (!rdb)
1448
+ return undefined;
1449
+ try {
1450
+ const row = rdb.prepare(`SELECT model, input_tokens, cache_creation_tokens, cache_read_tokens,
1451
+ context_tokens, max_tokens, auto_compact_tokens
1452
+ FROM model_calls
1453
+ WHERE session_id = ?
1454
+ AND agent_session_id = ?
1455
+ ORDER BY ts DESC, call_index DESC
1456
+ LIMIT 1`).get(sessionId, agentSessionId);
1457
+ if (!row)
1458
+ return undefined;
1459
+ const model = row.model || '';
1460
+ const recordedTotalTokens = row.context_tokens ?? undefined;
1461
+ let totalTokens;
1462
+ if (recordedTotalTokens && recordedTotalTokens > 0) {
1463
+ totalTokens = recordedTotalTokens;
1464
+ }
1465
+ else if (isClaudeContextUsageModel(model)) {
1466
+ totalTokens = (row.input_tokens ?? 0) + (row.cache_creation_tokens ?? 0) + (row.cache_read_tokens ?? 0);
1467
+ }
1468
+ else {
1469
+ totalTokens = row.input_tokens ?? 0;
1470
+ }
1471
+ if (totalTokens <= 0)
1472
+ return undefined;
1473
+ const recordedAutoCompactTokens = row.auto_compact_tokens ?? undefined;
1474
+ const inferredAutoCompactTokens = autoCompactTokensFromMaxTokens(row.max_tokens ?? undefined);
1475
+ const autoCompactTokens = recordedAutoCompactTokens && recordedAutoCompactTokens > 0
1476
+ ? recordedAutoCompactTokens
1477
+ : inferredAutoCompactTokens ?? autoCompactWindowForModel(model);
1478
+ return { totalTokens, autoCompactTokens };
1479
+ }
1480
+ finally {
1481
+ rdb.close();
1482
+ }
1483
+ }
1484
+ catch (err) {
1485
+ logger.debug(`[MessageProcessor] Failed to read last model call context usage: ${err}`);
1486
+ return undefined;
1487
+ }
1488
+ }
1165
1489
  /**
1166
1490
  * 解析会话和项目路径
1167
1491
  */
1168
1492
  async resolveSession(message) {
1169
- // 话题会话创建时写入 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
1170
- const metadata = (message.threadId && message.replyContext)
1171
- ? { replyContext: message.replyContext }
1493
+ // 话题会话创建时写入创建者和 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
1494
+ const metadata = message.threadId
1495
+ ? {
1496
+ ...(message.replyContext ? { replyContext: message.replyContext } : {}),
1497
+ ...(message.peerId ? { peerId: message.peerId } : {}),
1498
+ ...(message.peerName ? { peerName: message.peerName } : {}),
1499
+ }
1172
1500
  : undefined;
1173
1501
  const projectPath = this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd();
1502
+ // 话题创建权限守卫已统一移至 MessageBridge.canCreateThreadSession(enqueue 前拦截),
1503
+ // 此处不再重复检查——bridge 层拒绝后消息根本不会到达 processMessage。
1174
1504
  // current strategy: resume bound session, make it active so output is not suppressed
1175
1505
  if (message.triggerMeta?.boundSessionId) {
1176
1506
  const bound = await this.sessionManager.getSessionById(message.triggerMeta.boundSessionId);
@@ -1187,7 +1517,7 @@ export class MessageProcessor {
1187
1517
  logger.warn(`[MessageProcessor] Bound session ${message.triggerMeta.boundSessionId} not found, falling back to latest`);
1188
1518
  }
1189
1519
  }
1190
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, undefined, message.peerId, message.chatType, undefined, message.selfAID, message.channelType, message.peerType);
1520
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, undefined, message.selfAID, message.channelType, message.peerType);
1191
1521
  // 兜底纠正1:群聊强制 proactive
1192
1522
  if (message.chatType === 'group' && session.sessionMode !== 'proactive') {
1193
1523
  logger.info(`[MessageProcessor] group proactive upgrade: sessionId=${session.id} ${session.sessionMode} -> proactive`);
@@ -1204,6 +1534,14 @@ export class MessageProcessor {
1204
1534
  await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1205
1535
  }
1206
1536
  }
1537
+ // 群聊分发模式同步:aun.ts 从服务器信封解析的 dispatchMode 注入到 message,
1538
+ // 此处写入 session.metadata,确保 ECK 上下文的 venue fragment 正确渲染 dispatch 变量。
1539
+ // 仅当 message.dispatchMode 有值且与 session 记录不一致时更新。
1540
+ if (message.chatType === 'group' && message.dispatchMode && session.metadata?.dispatchMode !== message.dispatchMode) {
1541
+ logger.info(`[MessageProcessor] dispatchMode sync: sessionId=${session.id} ${session.metadata?.dispatchMode ?? 'none'} -> ${message.dispatchMode}`);
1542
+ session.metadata = { ...(session.metadata || {}), dispatchMode: message.dispatchMode };
1543
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1544
+ }
1207
1545
  // 兜底纠正2:旧 session 创建时没传 peerType(建为 interactive),后续非 human 消息进来时升级为 proactive。
1208
1546
  // 新建场景已由 getOrCreateSession 内部 resolveDefaultSessionMode 处理,这里只兜底历史会话。
1209
1547
  if (message.peerType && message.peerType !== 'human' && message.peerType !== 'unknown' && session.sessionMode !== 'proactive') {
@@ -1278,6 +1616,7 @@ export class MessageProcessor {
1278
1616
  // session_id 已在 AgentRunner.transformStream 中处理,此处仅记录
1279
1617
  if (event.type === 'session_id') {
1280
1618
  logger.debug(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
1619
+ session.agentSessionId = event.sessionId;
1281
1620
  continue;
1282
1621
  }
1283
1622
  // session 状态变更(idle/running/requires_action)
@@ -1359,7 +1698,6 @@ export class MessageProcessor {
1359
1698
  sessionId: session.id,
1360
1699
  toolName: event.name,
1361
1700
  isError: event.isError,
1362
- content: event.result,
1363
1701
  agentName: agentNameForStats,
1364
1702
  timestamp: Date.now()
1365
1703
  });
@@ -1382,7 +1720,7 @@ export class MessageProcessor {
1382
1720
  // 记录错误文本到 lastReplyText,供后续 isPromptTooLong 检测
1383
1721
  lastReplyText += event.error || '';
1384
1722
  // 上下文过长的错误不在此处输出 notice,留给外层 isPromptTooLong 触发 auto-compact
1385
- const isContextError = /prompt is too long|input is too long|上下文过长/i.test(event.error || '');
1723
+ const isContextError = isContextTooLongText(event.error || '');
1386
1724
  if (!isContextError && !hasErrorResult && !shouldSuppress()) {
1387
1725
  hasErrorResult = true;
1388
1726
  renderer.addNotice(`${event.error}`, 'warn', 'runtime-error', true);
@@ -1400,7 +1738,7 @@ export class MessageProcessor {
1400
1738
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1401
1739
  }
1402
1740
  // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
1403
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage };
1741
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall, modelCalls: event.modelCalls };
1404
1742
  // thought jsonl 写入已下沉到 aun.ts:sendThought 成功后,
1405
1743
  // 由那里按 LLM 输出的每个 text item 单独写一条,此处不再写。
1406
1744
  // 失败且无前置错误输出:显示 errors 摘要
@@ -1409,8 +1747,8 @@ export class MessageProcessor {
1409
1747
  const interruptReason = this.interruptedSessions.get(session.id);
1410
1748
  const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
1411
1749
  const isContextTooLong = event.terminalReason === 'prompt_too_long'
1412
- || /prompt is too long|input is too long|上下文过长/i.test(event.errors?.join(' ') || '')
1413
- || /prompt is too long|input is too long|上下文过长/i.test(lastReplyText);
1750
+ || isContextTooLongText(event.errors?.join(' ') || '')
1751
+ || isContextTooLongText(lastReplyText);
1414
1752
  if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt && !isContextTooLong) {
1415
1753
  const errorSummary = event.errors?.join('; ') || '任务执行失败';
1416
1754
  // 使用 terminalReason 提供更友好的错误提示(不带 emoji,由 formatter 统一加)
@@ -1456,7 +1794,7 @@ export class MessageProcessor {
1456
1794
  logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
1457
1795
  }
1458
1796
  // 记录完成状态
1459
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage };
1797
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall };
1460
1798
  if (event.subtype === 'success') {
1461
1799
  this.messageCache.addEvent(session.id, {
1462
1800
  type: 'completed',