evolclaw 3.3.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 (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +7 -3
  3. package/dist/agents/claude-runner.js +23 -27
  4. package/dist/agents/codex-runner.js +90 -6
  5. package/dist/agents/runner-types.js +30 -0
  6. package/dist/aun/outbox.js +14 -2
  7. package/dist/channels/aun.js +506 -108
  8. package/dist/channels/feishu.js +29 -5
  9. package/dist/cli/agent-command.js +591 -0
  10. package/dist/cli/agent.js +15 -3
  11. package/dist/cli/aun-commands.js +1444 -0
  12. package/dist/cli/ctl-command.js +78 -0
  13. package/dist/cli/daemon-commands.js +2707 -0
  14. package/dist/cli/index.js +12 -5027
  15. package/dist/cli/restart-monitor.js +539 -0
  16. package/dist/cli/watch-logs.js +33 -0
  17. package/dist/core/channel-loader.js +4 -1
  18. package/dist/core/command/command-handler.js +1189 -0
  19. package/dist/core/command/menu-handler.js +1478 -0
  20. package/dist/core/command/slash-gate.js +142 -0
  21. package/dist/core/command/slash-handler.js +2090 -0
  22. package/dist/core/evolagent-registry.js +81 -0
  23. package/dist/core/evolagent.js +16 -0
  24. package/dist/core/message/im-renderer.js +67 -49
  25. package/dist/core/message/message-bridge.js +30 -9
  26. package/dist/core/message/message-processor.js +200 -122
  27. package/dist/core/message/message-queue.js +68 -0
  28. package/dist/core/permission.js +16 -0
  29. package/dist/core/session/session-manager.js +59 -13
  30. package/dist/core/stats/db.js +20 -0
  31. package/dist/core/stats/writer.js +3 -3
  32. package/dist/data/error-dict.json +7 -0
  33. package/dist/index.js +49 -6
  34. package/dist/ipc.js +99 -0
  35. package/dist/utils/cross-platform.js +35 -0
  36. package/dist/utils/ecweb-launch.js +49 -0
  37. package/dist/utils/error-utils.js +18 -5
  38. package/dist/utils/npm-ops.js +38 -8
  39. package/dist/utils/stats.js +63 -6
  40. package/kits/eck_manifest.json +0 -12
  41. package/package.json +2 -3
  42. package/dist/core/command-handler.js +0 -4235
  43. package/dist/core/message/response-depth.js +0 -56
  44. package/kits/templates/system-fragments/response-depth.md +0 -16
@@ -2,18 +2,17 @@ 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/runner-types.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
12
  import { getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
13
13
  import { renderKitSections } from '../../eck/kit-renderer.js';
14
14
  import { renderMessageBody } from '../../eck/message-renderer.js';
15
15
  import { consumeHints, hintsToSubMessages, composeHintFallback } from './pending-hints.js';
16
- import { resolveResponseDepth as computeResponseDepth } from './response-depth.js';
17
16
  import { normalizeBaseagent } from '../../agents/baseagent.js';
18
17
  import { renderActionAsText, renderCommandCardAsText } from '../interaction-router.js';
19
18
  import { formatPeerKey } from '../relation/peer-identity.js';
@@ -66,6 +65,11 @@ function getContextCompactFailedHint(agent) {
66
65
  function canCompactAgent(agent) {
67
66
  return hasCompact(agent) && agent.capabilities?.compact !== false;
68
67
  }
68
+ function autoCompactTokensFromMaxTokens(maxTokens) {
69
+ if (!maxTokens || maxTokens <= 0)
70
+ return undefined;
71
+ return maxTokens >= 1000000 ? maxTokens - 100000 : maxTokens;
72
+ }
69
73
  /**
70
74
  * 构造 OutboundEnvelope —— 出站三件套的信封部分。
71
75
  *
@@ -187,6 +191,12 @@ export class MessageProcessor {
187
191
  setAgentRegistry(registry) {
188
192
  this.agentRegistry = registry;
189
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
+ }
190
200
  getAgentContext(channelName, chatType) {
191
201
  if (!this.agentRegistry)
192
202
  return null;
@@ -236,6 +246,25 @@ export class MessageProcessor {
236
246
  this.channelTypeMap.set(type, adapter.channelName);
237
247
  }
238
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
+ }
239
268
  /**
240
269
  * 获取渠道适配器(支持实例名和 channelType)
241
270
  */
@@ -295,8 +324,6 @@ export class MessageProcessor {
295
324
  // 先解析会话,再优先用 session.metadata.channelKey 精确定位实例级 adapter
296
325
  // message.channel 现在存实例名(channelName),可直接用于精确路由
297
326
  const { session, absoluteProjectPath } = await this.resolveSession(message);
298
- // 群聊响应深度决策(resolveSession 之后、_processMessageInternal 之前)
299
- const responseDepth = await this.resolveResponseDepth(message, session);
300
327
  // thread(feishu) pending strategy: inject replyContext so first reply creates the thread
301
328
  if (message.triggerMeta?.pendingThread && message.triggerMeta?.rootMessageId) {
302
329
  const triggerId = message.triggerMeta.triggerId;
@@ -386,7 +413,7 @@ export class MessageProcessor {
386
413
  });
387
414
  try {
388
415
  await Promise.race([
389
- this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec, responseDepth),
416
+ this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec),
390
417
  timeoutPromise
391
418
  ]);
392
419
  }
@@ -434,7 +461,7 @@ export class MessageProcessor {
434
461
  return message.replyContext;
435
462
  }
436
463
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
437
- async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, responseDepth) {
464
+ async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec) {
438
465
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
439
466
  const channelKey = session.metadata?.channelKey || message.channel;
440
467
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -458,7 +485,7 @@ export class MessageProcessor {
458
485
  const taskId = `task-${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`;
459
486
  const chatmode = session.sessionMode ?? 'interactive';
460
487
  // 诊断日志:记录 inbound message_id 和生成的 task_id 的对应关系
461
- logger.info(`[MessageProcessor] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode}${responseDepth && responseDepth !== 'standard' ? ` depth=${responseDepth}` : ''}`);
488
+ logger.info(`[MessageProcessor] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode}`);
462
489
  // 构建带 taskId/chatmode 的 ReplyContext(本次任务所有出站消息共用)
463
490
  const taskReplyContext = () => {
464
491
  const base = this.getReplyContext(message);
@@ -503,6 +530,7 @@ export class MessageProcessor {
503
530
  const budgetStatus = getBudgetStatus(resolveRoot(), budgetAgentAid, budgetPeerKey);
504
531
  if (budgetStatus.hard_blocked) {
505
532
  logger.warn(`[MessageProcessor] Budget hard limit reached: agent=${budgetAgentAid} peer=${budgetPeerKey} pct=${budgetStatus.pct_used.toFixed(1)}%`);
533
+ this.touchAgentActivity(channelKey);
506
534
  adapter.send(envelope, { kind: 'status.completed', metadata: { durationMs: 0 } }).catch(() => { });
507
535
  return;
508
536
  }
@@ -522,8 +550,10 @@ export class MessageProcessor {
522
550
  this.eventBus.publish({ type: 'task:started', sessionId: session.id, agentName: agentNameForStats, encrypt: taskEncrypt, chatmode: session.sessionMode || 'interactive' });
523
551
  // 触发器消息不发 processing status(无需通知用户)
524
552
  if (message.source !== 'trigger') {
553
+ this.touchAgentActivity(channelKey);
525
554
  adapter.send(envelope, { kind: 'status.started' }).catch(() => { });
526
555
  }
556
+ await this.runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope);
527
557
  logger.message({
528
558
  msgId: messageId,
529
559
  sessionId: session.id,
@@ -565,6 +595,8 @@ export class MessageProcessor {
565
595
  opts.title = '\u2705 \u6700\u7ec8\u56de\u590d:';
566
596
  }
567
597
  opts.metadata = { ...(opts.metadata ?? {}), taskId, chatmode };
598
+ if (payload.kind.startsWith('status.'))
599
+ this.touchAgentActivity(channelKey);
568
600
  const enrichedEnvelope = { ...envelope, replyContext: opts };
569
601
  await adapter.send(enrichedEnvelope, payload);
570
602
  },
@@ -687,20 +719,6 @@ export class MessageProcessor {
687
719
  }
688
720
  modelOverride = evolclawModelOverride;
689
721
  }
690
- // ④ 群聊 responseDepth → effort 动态映射
691
- // 仅当群聊且 evolclaw 作用域未显式指定 effort 时生效(显式配置优先)
692
- if (message.chatType === 'group' && responseDepth && !(modelOverride?.effort)) {
693
- const depthEffortMap = {
694
- lightweight: 'low',
695
- standard: 'medium',
696
- deep: 'high',
697
- };
698
- const mappedEffort = depthEffortMap[responseDepth];
699
- if (mappedEffort) {
700
- modelOverride = { ...(modelOverride || {}), effort: mappedEffort };
701
- logger.info(`[MessageProcessor] Group depth→effort: ${responseDepth} → ${mappedEffort} session=${session.id}`);
702
- }
703
- }
704
722
  agentModel = (typeof agent.getModel === 'function') ? agent.getModel() : undefined;
705
723
  // Kit renderer: 组装上下文
706
724
  const pkgRoot = getPackageRoot();
@@ -744,9 +762,8 @@ export class MessageProcessor {
744
762
  channel: currentChannelType || null,
745
763
  venueUid: undefined,
746
764
  // 群分发模式 / 客户端类型 / 权限模式
747
- // 优先本地 session 覆盖(/dispatch 命令),fallback 到服务器 dispatch_mode
748
- dispatch: session.metadata?.dispatchMode || message.dispatchMode || undefined,
749
- responseDepth: responseDepth || undefined,
765
+ // 优先本地 session 覆盖(/dispatch 命令),fallback 到服务器 dispatch_mode 缓存
766
+ dispatch: (session.metadata?.dispatchModeOverride ?? session.metadata?.dispatchMode ?? message.dispatchMode) || undefined,
750
767
  clientType: message.clientType || undefined,
751
768
  permissionMode: session.metadata?.permissionMode || 'auto',
752
769
  capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
@@ -844,6 +861,10 @@ export class MessageProcessor {
844
861
  }
845
862
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
846
863
  const MAX_RETRIES = 3;
864
+ // Runner 开始执行前:将 Pin 升级为 CheckMark(表示"正在处理")
865
+ if (message.messageId && message.source !== 'trigger') {
866
+ adapter.promoteAck?.(message.messageId).catch(() => { });
867
+ }
847
868
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
848
869
  let streamRegistered = false;
849
870
  try {
@@ -912,12 +933,12 @@ export class MessageProcessor {
912
933
  }
913
934
  // prompt_too_long:SDK 以 complete 事件(非异常)返回,需在此处触发 compact
914
935
  // 检测条件:terminalReason 明确为 prompt_too_long,或文本/errors 包含相关错误文本
915
- const contextTooLongPattern = /prompt is too long|input is too long|上下文过长/i;
916
- const errorsText = streamResult.errors?.join(' ') || '';
917
- const isPromptTooLong = streamResult.isError && session.agentSessionId && canCompactAgent(agent) && (streamResult.terminalReason === 'prompt_too_long' ||
918
- contextTooLongPattern.test(streamResult.lastReplyText) ||
919
- contextTooLongPattern.test(errorsText) ||
920
- 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);
921
942
  if (isPromptTooLong) {
922
943
  renderer.addNotice('上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
923
944
  await renderer.flush();
@@ -928,11 +949,7 @@ export class MessageProcessor {
928
949
  agent.registerStream(streamKey, retryStream);
929
950
  streamResult = await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress);
930
951
  // 重试后仍然 prompt_too_long:清理 renderer 中可能混入的错误文本,显示友好提示
931
- const retryErrorsText = streamResult.errors?.join(' ') || '';
932
- const retryStillTooLong = streamResult.isError && (streamResult.terminalReason === 'prompt_too_long' ||
933
- contextTooLongPattern.test(streamResult.lastReplyText) ||
934
- contextTooLongPattern.test(retryErrorsText) ||
935
- contextTooLongPattern.test(streamResult.fullText));
952
+ const retryStillTooLong = streamResult.isError && streamHitContextLimit(streamResult);
936
953
  if (retryStillTooLong) {
937
954
  renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
938
955
  }
@@ -941,10 +958,7 @@ export class MessageProcessor {
941
958
  throw new Error('CONTEXT_COMPACT_FAILED');
942
959
  }
943
960
  }
944
- else if (streamResult.isError && !isPromptTooLong && (streamResult.terminalReason === 'prompt_too_long' ||
945
- contextTooLongPattern.test(streamResult.lastReplyText) ||
946
- contextTooLongPattern.test(errorsText) ||
947
- contextTooLongPattern.test(streamResult.fullText))) {
961
+ else if (streamResult.isError && streamHitContextLimit(streamResult)) {
948
962
  // 上下文过长但无法 auto-compact(无 session ID 或 agent 不支持),显示友好提示
949
963
  renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
950
964
  }
@@ -1073,12 +1087,6 @@ export class MessageProcessor {
1073
1087
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
1074
1088
  await renderer.flush(true);
1075
1089
  }
1076
- // 更新 EvolAgent.lastActivity
1077
- if (this.agentRegistry) {
1078
- const owningAgent = this.agentRegistry.resolveByChannel(channelKey);
1079
- if (owningAgent)
1080
- owningAgent.lastActivity = Date.now();
1081
- }
1082
1090
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
1083
1091
  const interruptReason = this.interruptedSessions.get(session.id);
1084
1092
  if (streamResult.isError) {
@@ -1086,37 +1094,60 @@ export class MessageProcessor {
1086
1094
  const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
1087
1095
  const rawSubtype = streamResult.subtype || 'agent_error';
1088
1096
  const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
1089
- 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(() => { });
1090
1103
  adapter.send(envelope, { kind: 'status.error', metadata: { errorType: rawSubtype } }).catch(() => { });
1104
+ this.touchAgentActivity(channelKey);
1091
1105
  }
1092
- if (message.triggerMeta) {
1093
- 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' });
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
+ });
1094
1120
  }
1095
- this.eventBus.publish({
1096
- type: 'task:error',
1097
- sessionId: session.id,
1098
- error: errorSummary,
1099
- errorType,
1100
- agentName: agentNameForStats,
1101
- terminalReason: streamResult.terminalReason
1102
- });
1103
- // 系统级 subtype 仍累计错误计数,供 /status 诊断使用
1104
- if (isInfraError(rawSubtype, streamResult.terminalReason)) {
1105
- const chatType = message.chatType || 'private';
1106
- const identityRole = session.identity?.role || 'anonymous';
1107
- const { policy } = channelInfo;
1108
- if (policy.accumulateErrors(chatType, identityRole)) {
1109
- 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
+ }
1110
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
+ });
1111
1150
  }
1112
- logger.message({
1113
- msgId: messageId,
1114
- sessionId: session.id,
1115
- dir: 'inbound',
1116
- status: 'failed',
1117
- error: errorSummary,
1118
- terminalReason: streamResult.terminalReason
1119
- });
1120
1151
  }
1121
1152
  else {
1122
1153
  // 真正的成功
@@ -1160,6 +1191,9 @@ export class MessageProcessor {
1160
1191
  output_tokens: mc.tokenUsage.output_tokens ?? 0,
1161
1192
  cache_creation_tokens: mc.tokenUsage.cache_creation_input_tokens ?? 0,
1162
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,
1163
1197
  degraded: mc.degraded ? 1 : 0,
1164
1198
  }));
1165
1199
  insertModelCalls(resolveRoot(), mcRows);
@@ -1217,6 +1251,7 @@ export class MessageProcessor {
1217
1251
  }
1218
1252
  catch { /* non-fatal */ }
1219
1253
  if (message.source !== 'trigger') {
1254
+ this.touchAgentActivity(channelKey);
1220
1255
  if (interruptReason) {
1221
1256
  adapter.send(envelope, { kind: 'status.interrupted', metadata: { reason: interruptReason } }).catch(() => { });
1222
1257
  }
@@ -1311,6 +1346,7 @@ export class MessageProcessor {
1311
1346
  ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
1312
1347
  : { kind: 'status.error' };
1313
1348
  adapter.send(envelope, statusPayload).catch(() => { });
1349
+ this.touchAgentActivity(channelKey);
1314
1350
  }
1315
1351
  // 用户主动中断时降级日志;其余仍按 error 记录
1316
1352
  if (isUserInterrupt) {
@@ -1321,19 +1357,25 @@ export class MessageProcessor {
1321
1357
  }
1322
1358
  const errorMsg = error instanceof Error ? error.message : String(error);
1323
1359
  const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
1324
- this.eventBus.publish({
1325
- type: 'task:error',
1326
- sessionId: session.id,
1327
- error: errorMsg,
1328
- errorType,
1329
- agentName: agentNameForStats,
1330
- });
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
+ }
1331
1373
  // 记录处理失败
1332
1374
  logger.message({
1333
1375
  msgId: messageId,
1334
1376
  sessionId: session.id,
1335
1377
  dir: 'inbound',
1336
- status: 'failed',
1378
+ status: isUserInterrupt ? 'interrupted' : 'failed',
1337
1379
  error: error instanceof Error ? error.message : String(error)
1338
1380
  });
1339
1381
  if (error instanceof Error && !isUserInterrupt) {
@@ -1367,14 +1409,83 @@ export class MessageProcessor {
1367
1409
  ...(sendOpts ?? {}),
1368
1410
  metadata: { ...(sendOpts?.metadata ?? {}), taskId, chatmode },
1369
1411
  };
1370
- const errorPayload = isTimeout
1371
- ? { kind: 'result.error', text: userMessage, reason: 'timeout' }
1372
- : { kind: 'result.text', text: userMessage, isFinal: true };
1412
+ const errorPayload = {
1413
+ kind: 'result.error',
1414
+ text: userMessage,
1415
+ reason: isTimeout ? 'timeout' : errType,
1416
+ };
1373
1417
  await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
1374
1418
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
1375
1419
  }
1376
1420
  }
1377
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
+ }
1378
1489
  /**
1379
1490
  * 解析会话和项目路径
1380
1491
  */
@@ -1388,15 +1499,8 @@ export class MessageProcessor {
1388
1499
  }
1389
1500
  : undefined;
1390
1501
  const projectPath = this.agentRegistry?.resolveByChannel(message.channel)?.projectPath || process.cwd();
1391
- if (message.chatType === 'group' && message.threadId && message.source !== 'trigger' && message.source !== 'owner-inject') {
1392
- const existing = await this.sessionManager.getThreadSession(message.channel, message.channelId, message.threadId);
1393
- if (!existing) {
1394
- const role = this.sessionManager.resolveIdentity(message.channel, message.peerId).role;
1395
- if (role !== 'owner' && role !== 'admin') {
1396
- throw new Error('群聊中无权限创建话题');
1397
- }
1398
- }
1399
- }
1502
+ // 话题创建权限守卫已统一移至 MessageBridge.canCreateThreadSession(enqueue 前拦截),
1503
+ // 此处不再重复检查——bridge 层拒绝后消息根本不会到达 processMessage。
1400
1504
  // current strategy: resume bound session, make it active so output is not suppressed
1401
1505
  if (message.triggerMeta?.boundSessionId) {
1402
1506
  const bound = await this.sessionManager.getSessionById(message.triggerMeta.boundSessionId);
@@ -1458,32 +1562,6 @@ export class MessageProcessor {
1458
1562
  : path.resolve(process.cwd(), session.projectPath);
1459
1563
  return { session, absoluteProjectPath };
1460
1564
  }
1461
- /**
1462
- * 群聊响应深度决策。根据 dispatch 模式、消息特征、话题轮次综合判断。
1463
- * 返回 per-message 的瞬时深度枚举,不持久化到 session.metadata。
1464
- * 同时更新 session.metadata 中的 topicRounds/lastTopicHash(话题追踪状态)。
1465
- */
1466
- async resolveResponseDepth(message, session) {
1467
- const result = computeResponseDepth({
1468
- chatType: message.chatType,
1469
- content: message.content,
1470
- selfAid: session.selfAID || message.selfAID,
1471
- mentionAids: message.mentionAids,
1472
- dispatch: session.metadata?.dispatchMode || message.dispatchMode,
1473
- topicRounds: session.metadata?.topicRounds ?? 0,
1474
- lastTopicHash: session.metadata?.lastTopicHash,
1475
- });
1476
- // 持久化话题追踪状态(仅群聊时有意义)
1477
- if (message.chatType === 'group') {
1478
- session.metadata = {
1479
- ...(session.metadata || {}),
1480
- topicRounds: result.topicRounds,
1481
- lastTopicHash: result.topicHash,
1482
- };
1483
- await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1484
- }
1485
- return result.depth;
1486
- }
1487
1565
  /**
1488
1566
  * 处理标准事件流(AgentEvent)
1489
1567
  *
@@ -1538,6 +1616,7 @@ export class MessageProcessor {
1538
1616
  // session_id 已在 AgentRunner.transformStream 中处理,此处仅记录
1539
1617
  if (event.type === 'session_id') {
1540
1618
  logger.debug(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
1619
+ session.agentSessionId = event.sessionId;
1541
1620
  continue;
1542
1621
  }
1543
1622
  // session 状态变更(idle/running/requires_action)
@@ -1619,7 +1698,6 @@ export class MessageProcessor {
1619
1698
  sessionId: session.id,
1620
1699
  toolName: event.name,
1621
1700
  isError: event.isError,
1622
- content: event.result,
1623
1701
  agentName: agentNameForStats,
1624
1702
  timestamp: Date.now()
1625
1703
  });
@@ -1642,7 +1720,7 @@ export class MessageProcessor {
1642
1720
  // 记录错误文本到 lastReplyText,供后续 isPromptTooLong 检测
1643
1721
  lastReplyText += event.error || '';
1644
1722
  // 上下文过长的错误不在此处输出 notice,留给外层 isPromptTooLong 触发 auto-compact
1645
- const isContextError = /prompt is too long|input is too long|上下文过长/i.test(event.error || '');
1723
+ const isContextError = isContextTooLongText(event.error || '');
1646
1724
  if (!isContextError && !hasErrorResult && !shouldSuppress()) {
1647
1725
  hasErrorResult = true;
1648
1726
  renderer.addNotice(`${event.error}`, 'warn', 'runtime-error', true);
@@ -1669,8 +1747,8 @@ export class MessageProcessor {
1669
1747
  const interruptReason = this.interruptedSessions.get(session.id);
1670
1748
  const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
1671
1749
  const isContextTooLong = event.terminalReason === 'prompt_too_long'
1672
- || /prompt is too long|input is too long|上下文过长/i.test(event.errors?.join(' ') || '')
1673
- || /prompt is too long|input is too long|上下文过长/i.test(lastReplyText);
1750
+ || isContextTooLongText(event.errors?.join(' ') || '')
1751
+ || isContextTooLongText(lastReplyText);
1674
1752
  if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt && !isContextTooLong) {
1675
1753
  const errorSummary = event.errors?.join('; ') || '任务执行失败';
1676
1754
  // 使用 terminalReason 提供更友好的错误提示(不带 emoji,由 formatter 统一加)
@@ -16,6 +16,7 @@ export class MessageQueue {
16
16
  recentMessageIds = new Set();
17
17
  DEDUP_WINDOW = 60_000; // 1 分钟窗口
18
18
  interceptors = new Map();
19
+ mutedAgents = new Set(); // 禁言的 agent:消息照常入队,但不取出给大模型
19
20
  constructor(handler) {
20
21
  this.handler = handler;
21
22
  }
@@ -145,6 +146,14 @@ export class MessageQueue {
145
146
  this.activeMessageIds.clear();
146
147
  return;
147
148
  }
149
+ // 禁言:消息留在队列里,暂停消费(解禁后由 unmuteAgent 重新触发 processNext)
150
+ const headAgent = queue[0].agentName || DEFAULT_AGENT_NAME;
151
+ if (this.mutedAgents.has(headAgent)) {
152
+ logger.info(`[Queue] processNext: agent ${headAgent} muted, pausing key=${queueKey} (${queue.length} queued)`);
153
+ this.processing.delete(queueKey);
154
+ this.processingAgent.delete(queueKey);
155
+ return;
156
+ }
148
157
  // FIFO 贪心合并:弹出队首连续同 peerId 的消息
149
158
  const items = this.dequeueGreedy(queue);
150
159
  const merged = items.length === 1 ? items[0] : this.mergeItems(items);
@@ -377,4 +386,63 @@ export class MessageQueue {
377
386
  }
378
387
  return total;
379
388
  }
389
+ /**
390
+ * 清空指定 agent 的待处理消息(不影响正在处理中的消息)。
391
+ * 被移除的消息直接 resolve(与 cancel 一致),让 enqueue 的等待方正常解除阻塞。
392
+ * @returns 被清除的消息数量
393
+ */
394
+ clearByAgent(agentName) {
395
+ let cleared = 0;
396
+ for (const queue of this.queues.values()) {
397
+ for (let i = queue.length - 1; i >= 0; i--) {
398
+ if ((queue[i].agentName || DEFAULT_AGENT_NAME) === agentName) {
399
+ const [removed] = queue.splice(i, 1);
400
+ removed.resolve();
401
+ cleared++;
402
+ }
403
+ }
404
+ }
405
+ if (cleared > 0)
406
+ logger.info(`[Queue] Cleared ${cleared} pending message(s) for agent ${agentName}`);
407
+ return cleared;
408
+ }
409
+ /** 禁言 agent:后续消息照常入队,但 processNext 不再取出处理。 */
410
+ muteAgent(agentName) {
411
+ this.mutedAgents.add(agentName);
412
+ logger.info(`[Queue] Muted agent ${agentName}`);
413
+ }
414
+ /** 解除禁言:重新触发该 agent 已积压、且当前未在处理的队列。 */
415
+ unmuteAgent(agentName) {
416
+ if (!this.mutedAgents.delete(agentName))
417
+ return;
418
+ logger.info(`[Queue] Unmuted agent ${agentName}, resuming queued messages`);
419
+ for (const [key, queue] of this.queues) {
420
+ if (queue.length > 0 && !this.processing.has(key)) {
421
+ const headAgent = queue[0].agentName || DEFAULT_AGENT_NAME;
422
+ if (headAgent === agentName)
423
+ this.processNext(key);
424
+ }
425
+ }
426
+ }
427
+ isAgentMuted(agentName) {
428
+ return this.mutedAgents.has(agentName);
429
+ }
430
+ /** 中断指定 agent 所有正在处理中的会话(停止 agent 时调用)。 */
431
+ interruptByAgent(agentName) {
432
+ for (const [queueKey, name] of this.processingAgent) {
433
+ if ((name || DEFAULT_AGENT_NAME) === agentName) {
434
+ const sessionKey = queueKey.split('::')[0];
435
+ logger.info(`[Queue] Interrupting session ${sessionKey} for stopped agent ${agentName}`);
436
+ this.eventBus?.publish({
437
+ type: 'task:interrupted',
438
+ sessionId: sessionKey,
439
+ reason: 'new_message',
440
+ agentName: name,
441
+ });
442
+ if (this.interruptCallback) {
443
+ this.interruptCallback(sessionKey, this.currentAgentId, name).catch(() => { });
444
+ }
445
+ }
446
+ }
447
+ }
380
448
  }