evolcore 0.0.3 → 0.0.4

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 (143) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +45 -10
  3. package/bin/ec-safe-output.js +89 -24
  4. package/dist/agents/baseagent.js +46 -0
  5. package/dist/agents/claude-runner.js +1 -31
  6. package/dist/agents/codex-app-server-client.js +68 -0
  7. package/dist/agents/codex-runner.js +157 -5
  8. package/dist/agents/runner-types.js +2 -2
  9. package/dist/aun/aid/agentmd.js +79 -0
  10. package/dist/aun/aid/encryption-seed-policy.js +29 -0
  11. package/dist/aun/aid/index.js +1 -1
  12. package/dist/aun/aid/store.js +2 -5
  13. package/dist/channels/aun.js +220 -112
  14. package/dist/channels/daemon.js +18 -4
  15. package/dist/channels/dingtalk.js +52 -137
  16. package/dist/channels/feishu.js +56 -4
  17. package/dist/channels/qqbot.js +23 -1
  18. package/dist/channels/wechat.js +303 -155
  19. package/dist/channels/wecom.js +383 -7
  20. package/dist/cli/aun-commands.js +11 -11
  21. package/dist/cli/handoff-command.js +2 -1
  22. package/dist/cli/init-channel.js +185 -67
  23. package/dist/cli/init.js +7 -5
  24. package/dist/cli/trigger-command.js +92 -11
  25. package/dist/config/access-policy-domain.js +38 -0
  26. package/dist/config/access-policy.js +134 -0
  27. package/dist/config/builtin-role-templates.js +27 -14
  28. package/dist/config/builtin-roles.js +25 -6
  29. package/dist/config/config-field-policy.js +17 -5
  30. package/dist/config/config-manager.js +198 -22
  31. package/dist/config/config-operation-service.js +35 -38
  32. package/dist/config/contact-bind-code.js +274 -0
  33. package/dist/config/contact-book-store.js +173 -5
  34. package/dist/config/contact-book.js +32 -0
  35. package/dist/config/contact-operation-service.js +9 -3
  36. package/dist/config/contact-request-service.js +331 -0
  37. package/dist/config/peer-role-resolver.js +3 -1
  38. package/dist/config/schema-registry.js +49 -24
  39. package/dist/config-store.js +14 -2
  40. package/dist/core/auth/agent-delegation.js +105 -11
  41. package/dist/core/auth/authorization-audit.js +6 -0
  42. package/dist/core/auth/operation-authorizer.js +8 -0
  43. package/dist/core/auth/operation-catalog.js +51 -3
  44. package/dist/core/auth/trigger-authorization.js +15 -0
  45. package/dist/core/bootstrap-service.js +2 -9
  46. package/dist/core/channel-loader.js +6 -2
  47. package/dist/core/command/command-handler.js +10 -13
  48. package/dist/core/command/connect-menu.js +249 -24
  49. package/dist/core/command/evol-menu-version-gate.js +38 -0
  50. package/dist/core/command/group-menu.js +421 -0
  51. package/dist/core/command/menu-handler.js +269 -85
  52. package/dist/core/command/menu-protocol.js +8 -2
  53. package/dist/core/command/menu-token-store.js +102 -0
  54. package/dist/core/command/slash-gate.js +1 -1
  55. package/dist/core/command/slash-handler.js +158 -31
  56. package/dist/core/daemon-file-cache.js +28 -0
  57. package/dist/core/event-catalog.js +29 -0
  58. package/dist/core/evolagent-registry.js +4 -39
  59. package/dist/core/handoff/runtime.js +162 -37
  60. package/dist/core/handoff/store.js +46 -2
  61. package/dist/core/handoff/types.js +1 -0
  62. package/dist/core/inference/text-inference.js +16 -74
  63. package/dist/core/message/file-markers.js +7 -0
  64. package/dist/core/message/im-renderer.js +18 -14
  65. package/dist/core/message/inbound-admission.js +124 -0
  66. package/dist/core/message/message-bridge.js +616 -74
  67. package/dist/core/message/message-log.js +1 -0
  68. package/dist/core/message/message-queue.js +185 -10
  69. package/dist/core/message/peer-mode.js +7 -8
  70. package/dist/core/message/response-engine.js +381 -79
  71. package/dist/core/permission/approval-gateway.js +17 -10
  72. package/dist/core/permission/ec-command-parser.js +101 -46
  73. package/dist/core/permission/tool-policy.js +69 -24
  74. package/dist/core/protected-paths.js +36 -11
  75. package/dist/core/session/session-manager.js +25 -3
  76. package/dist/core/session/session-mapper.js +2 -0
  77. package/dist/core/session/session-renew.js +125 -69
  78. package/dist/core/session/session-turn-coordinator.js +5 -1
  79. package/dist/eck/kit-renderer.js +12 -1
  80. package/dist/eck/message-renderer.js +79 -1
  81. package/dist/index.js +224 -89
  82. package/dist/ipc.js +81 -4
  83. package/dist/paths.js +3 -0
  84. package/dist/response-system/config-resolver.js +29 -0
  85. package/dist/response-system/coordinator.js +8 -32
  86. package/dist/response-system/engines/v1/proactive-flow.js +1 -1
  87. package/dist/response-system/index.js +1 -0
  88. package/dist/response-system/modes/single-session/index.js +7 -4
  89. package/dist/trigger/parser.js +55 -15
  90. package/dist/trigger/patch.js +4 -1
  91. package/dist/trigger/scheduler.js +441 -39
  92. package/dist/utils/error-dict.json +7 -0
  93. package/dist/utils/evolcore-version.js +21 -0
  94. package/dist/utils/logger.js +2 -2
  95. package/dist/utils/stable-semver.js +21 -0
  96. package/dist/utils/stats.js +33 -9
  97. package/kits/docs/channels/aun.md +4 -13
  98. package/kits/docs/evolcore/INDEX.md +1 -1
  99. package/kits/docs/evolcore/config.md +47 -2
  100. package/kits/docs/evolcore/contact.md +7 -3
  101. package/kits/docs/evolcore/group-rules.md +46 -4
  102. package/kits/docs/evolcore/group.md +4 -4
  103. package/kits/docs/evolcore/msg.md +5 -5
  104. package/kits/docs/evolcore/trigger.md +25 -8
  105. package/kits/docs/path-registry.md +36 -17
  106. package/kits/eck_message_manifest.json +12 -1
  107. package/kits/migrations/migrate-contact-book-v2.mjs +7 -0
  108. package/kits/rules/01-overview.md +17 -7
  109. package/kits/rules/02-navigation.md +38 -18
  110. package/kits/rules/03-identity.md +28 -24
  111. package/kits/rules/04-relation.md +44 -28
  112. package/kits/rules/05-venue.md +31 -15
  113. package/kits/rules/06-channel.md +11 -7
  114. package/kits/schemas/_meta.json +18 -7
  115. package/kits/schemas/agent-config.schema.7.json +303 -0
  116. package/kits/schemas/agent-config.schema.8.json +304 -0
  117. package/kits/schemas/agent-config.schema.9.json +374 -0
  118. package/kits/schemas/contact-book.schema.3.json +67 -0
  119. package/kits/schemas/daemon.schema.1.json +3 -2
  120. package/kits/schemas/daemon.schema.2.json +101 -0
  121. package/kits/schemas/daemon.schema.3.json +123 -0
  122. package/kits/schemas/defaults.schema.2.json +85 -0
  123. package/kits/schemas/defaults.schema.3.json +73 -0
  124. package/kits/schemas/relation-config.schema.6.json +46 -0
  125. package/kits/schemas/relation-config.schema.7.json +59 -0
  126. package/kits/schemas/single-session.schema.2.json +57 -0
  127. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
  128. package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
  129. package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
  130. package/kits/templates/roles/admin.json +46 -0
  131. package/kits/templates/roles/member.json +4 -0
  132. package/kits/templates/roles/visitor.json +4 -0
  133. package/kits/templates/system-fragments/channel.md +12 -2
  134. package/kits/templates/system-fragments/identity.md +3 -1
  135. package/kits/templates/system-fragments/relation.md +1 -1
  136. package/kits/templates/system-fragments/session.md +6 -2
  137. package/package.json +4 -2
  138. package/MIGRATION-0.5.0.md +0 -378
  139. package/ROLE_ACCESS_CONTROL.md +0 -174
  140. package/dist/channels/contact-bind-code.js +0 -134
  141. package/dist/channels/wecom-card.js +0 -101
  142. package/dist/channels/wecom-onboarding.js +0 -82
  143. package/dist/channels/wecom-state.js +0 -191
@@ -5,6 +5,7 @@ import crypto from 'crypto';
5
5
  import { BaseagentRunnerUnavailableError, hasCompact, hasClearSession, autoCompactWindowForModel, isClaudeContextUsageModel } from '../../agents/runner-types.js';
6
6
  import { buildTaskRuntimeEnv } from '../../cli/task-context.js';
7
7
  import { IMRenderer } from './im-renderer.js';
8
+ import { createSendFileMarkerPattern } from './file-markers.js';
8
9
  import { createTextInferenceProvider } from '../inference/text-inference.js';
9
10
  import { StreamIdleMonitor } from './stream-idle-monitor.js';
10
11
  import { logger } from '../../utils/logger.js';
@@ -90,6 +91,45 @@ export function normalizeCompleteAgentEvent(event) {
90
91
  terminalReason: normalizedTerminalReason,
91
92
  };
92
93
  }
94
+ class RetryAttemptTimeoutError extends Error {
95
+ timeoutMs;
96
+ constructor(timeoutMs) {
97
+ super(`RETRY_ATTEMPT_STALLED: no agent events for ${Math.round(timeoutMs / 1000)}s`);
98
+ this.timeoutMs = timeoutMs;
99
+ this.name = 'RetryAttemptTimeoutError';
100
+ }
101
+ }
102
+ function createRetryAttemptTimeout(timeoutMs) {
103
+ let timer;
104
+ let settled = false;
105
+ let rejectTimeout;
106
+ const promise = new Promise((_, reject) => {
107
+ rejectTimeout = reject;
108
+ });
109
+ const arm = () => {
110
+ if (settled)
111
+ return;
112
+ if (timer)
113
+ clearTimeout(timer);
114
+ timer = setTimeout(() => {
115
+ settled = true;
116
+ timer = undefined;
117
+ rejectTimeout(new RetryAttemptTimeoutError(timeoutMs));
118
+ }, timeoutMs);
119
+ timer.unref?.();
120
+ };
121
+ arm();
122
+ return {
123
+ promise,
124
+ reset: arm,
125
+ clear: () => {
126
+ settled = true;
127
+ if (timer)
128
+ clearTimeout(timer);
129
+ timer = undefined;
130
+ },
131
+ };
132
+ }
93
133
  const RETRY_EXHAUSTED_MARK = Symbol('evolcore.retryExhausted');
94
134
  const RETRY_MADE_PROGRESS_MARK = Symbol('evolcore.retryMadeProgress');
95
135
  const RETRY_HEALTH_RECORDED_MARK = Symbol('evolcore.retryHealthRecorded');
@@ -123,6 +163,12 @@ function markRetryHealthRecorded(error) {
123
163
  function wasRetryHealthRecorded(error) {
124
164
  return !!(error && typeof error === 'object' && error[RETRY_HEALTH_RECORDED_MARK] === true);
125
165
  }
166
+ function isNonErrorInterruptReason(reason) {
167
+ return reason === 'new_message'
168
+ || reason === 'stop'
169
+ || reason === 'recalled'
170
+ || reason === 'daemon_restart';
171
+ }
126
172
  function formatRetryableErrorFinalMessage(error, retries) {
127
173
  const reason = getErrorMessage(error, undefined, false) || 'API 暂时不可用';
128
174
  return `❌ API 暂时不可用,已自动重试 ${retries} 次仍失败,任务已停止。\n原因:${reason}`;
@@ -171,6 +217,25 @@ function streamHitContextLimit(result) {
171
217
  isContextTooLongText(result.errors?.join(' ') || '') ||
172
218
  isContextTooLongText(result.fullText);
173
219
  }
220
+ /**
221
+ * `isContextTooLongText` 是给错误分类用的宽松匹配,不能拿来删除模型正文:正常回答
222
+ * 可能会说明或引用 `Prompt is too long`。这里只识别网关会单独输出的短错误行。
223
+ */
224
+ function isStandaloneContextLimitErrorLine(line) {
225
+ return /^(?:(?:api )?error\s*:\s*)?(?:prompt is too long|input is too long|context too long|context_length_exceeded|context_window_exceeded|context window limit|maximum context length|上下文过长)\s*[.!。]?$/i.test(line.trim());
226
+ }
227
+ /**
228
+ * 网关有时把上下文超限错误包装进 text event。只丢弃独立错误行,保留同一 event
229
+ * 中其它行及包含错误字面量的正常输出;原文本仍由调用方保留作恢复判定。
230
+ */
231
+ function stripContextLimitErrorLines(text) {
232
+ if (!isContextTooLongText(text))
233
+ return text;
234
+ return text
235
+ .split(/\r?\n/)
236
+ .filter(line => !isStandaloneContextLimitErrorLine(line))
237
+ .join('\n');
238
+ }
174
239
  const SHELL_CONTROL_RE = /[;&|`]|[$][(]|\r|\n/;
175
240
  function isCtlQueueReadCommand(toolName, input) {
176
241
  if (toolName !== 'Bash' && toolName !== 'Shell')
@@ -230,6 +295,15 @@ function canCompactAgent(agent) {
230
295
  function canClearAgent(agent) {
231
296
  return hasClearSession(agent) && agent.capabilities?.clear !== false;
232
297
  }
298
+ export async function resolveGroupRulesForTurn(opts, sync = syncGroupRulesContext) {
299
+ if (!opts.isAunGroup)
300
+ return {};
301
+ if (opts.mode === 'ignore') {
302
+ logger.debug(`[GroupRules] skipped by local policy: group=${opts.groupId ?? '<unknown>'} mode=ignore`);
303
+ return { groupRulesStatus: 'ignored' };
304
+ }
305
+ return sync({ selfAid: opts.selfAid, groupId: opts.groupId, channel: opts.channel });
306
+ }
233
307
  function autoCompactTokensFromMaxTokens(maxTokens) {
234
308
  if (!maxTokens || maxTokens <= 0)
235
309
  return undefined;
@@ -259,8 +333,10 @@ export class ResponseEngine {
259
333
  commandHandler;
260
334
  channels = new Map();
261
335
  channelTypeMap = new Map(); // channelType → channelName(首个实例)
262
- currentRenderer;
263
- shouldSuppressActivities = false;
336
+ /** 仅登记正在处理的任务,runner 的 session 回调绝不能投递到其它会话。 */
337
+ activeRenderers = new Map();
338
+ /** 已主动发送开始提示时,忽略 runner 对同一次 compact 的回调。 */
339
+ announcedCompactStarts = new Set();
264
340
  agentMap;
265
341
  primaryRunnerKey;
266
342
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
@@ -274,6 +350,10 @@ export class ResponseEngine {
274
350
  activeMonitors = new Map();
275
351
  /** sessionId → 当前正在处理任务的运行时上下文,供 in-task CLI 通过 IPC 查询。 */
276
352
  activeTaskRuntimeContexts = new Map();
353
+ /** task.run started 后立即登记,覆盖运行时上下文尚未建立的 shutdown 窗口。 */
354
+ activeTaskSpans = new Map();
355
+ /** sessionId → 尚未收到 runner:task-notification 的子任务。 */
356
+ activeRunnerTasks = new Map();
277
357
  triggerTerminalMessages = new WeakSet();
278
358
  agentDelegationRegistry;
279
359
  turnCoordinator;
@@ -402,6 +482,14 @@ export class ResponseEngine {
402
482
  this.agentDelegationRegistry?.revokeSession(event.sessionId);
403
483
  }
404
484
  });
485
+ this.eventBus.subscribe('task:completed', event => {
486
+ if ('sessionId' in event && event.sessionId)
487
+ this.activeTaskSpans.delete(event.sessionId);
488
+ });
489
+ this.eventBus.subscribe('task:error', event => {
490
+ if ('sessionId' in event && event.sessionId)
491
+ this.activeTaskSpans.delete(event.sessionId);
492
+ });
405
493
  // 初始化响应模式协调器,注册内置模式(interactive/proactive)
406
494
  const registry = new ResponseModeRegistry();
407
495
  registerBuiltinModes(registry);
@@ -428,17 +516,50 @@ export class ResponseEngine {
428
516
  if (!session)
429
517
  return;
430
518
  this.turnCoordinator.hydrate(session);
431
- const interruptedTurn = this.turnCoordinator.invalidate(sessionId, reason);
519
+ const effectiveReason = reason === 'daemon_restart'
520
+ || this.interruptedSessions.get(sessionId) === 'daemon_restart'
521
+ ? 'daemon_restart'
522
+ : reason;
523
+ const interruptedTurn = this.turnCoordinator.invalidate(sessionId, effectiveReason);
432
524
  const interruptedTaskId = interruptedTurn?.active?.taskId;
433
- this.interruptedSessions.set(sessionId, reason);
434
- await this.interactionRouter?.cancelAll(sessionId, reason);
525
+ this.interruptedSessions.set(sessionId, effectiveReason);
526
+ this.messageQueue?.markActiveTerminal?.(sessionId, effectiveReason);
527
+ // 终态必须先于渠道发送和 runner I/O 落盘。否则任一外部调用卡住时,
528
+ // shutdown 超时退出会再次留下 started/interrupting 的悬空任务。
529
+ const runtimeContext = this.activeTaskRuntimeContexts.get(sessionId);
530
+ const taskSpan = this.activeTaskSpans.get(sessionId);
531
+ const taskId = (await this.turnCoordinator.completeInterrupt(session, effectiveReason))
532
+ ?? interruptedTaskId
533
+ ?? taskSpan?.taskId;
534
+ if (taskId) {
535
+ const taskCausation = taskSpan?.causation ?? runtimeContext?.causation;
536
+ if (taskCausation && effectiveReason === 'daemon_restart') {
537
+ recordCausationSpan(taskCausation, 'task.run', {
538
+ status: 'interrupted',
539
+ refs: {
540
+ taskId,
541
+ sessionId,
542
+ messageId: taskSpan?.messageId ?? runtimeContext?.messageId,
543
+ },
544
+ reason: effectiveReason,
545
+ });
546
+ }
547
+ this.activeTaskSpans.delete(sessionId);
548
+ this.sessionManager.clearProcessingIfTask(sessionId, taskId);
549
+ if (this.activeTaskRuntimeContexts.get(sessionId)?.taskId === taskId) {
550
+ this.activeTaskRuntimeContexts.delete(sessionId);
551
+ }
552
+ this.agentDelegationRegistry?.revokeTask(sessionId, taskId);
553
+ }
554
+ this.closeActiveRunnerTasks(sessionId, effectiveReason, taskSpan?.causation ?? runtimeContext?.causation);
555
+ await this.interactionRouter?.cancelAll(sessionId, effectiveReason);
435
556
  const channelKey = session.metadata?.channelKey || session.channel;
436
557
  const agent = this.getAgent(channelKey, session.baseagent, session.selfAID);
437
558
  const terminalStatus = (() => {
438
559
  // TimeoutController owns timeout delivery (including user-facing timeout
439
560
  // details) and already guarantees a single terminal status. Sending it
440
561
  // here as well would race that barrier and duplicate status.timeout.
441
- if (!interruptedTaskId || reason === 'timeout')
562
+ if (!interruptedTaskId || effectiveReason === 'timeout')
442
563
  return Promise.resolve();
443
564
  const channelInfo = this.resolveChannelInfo(channelKey);
444
565
  if (!channelInfo)
@@ -458,7 +579,7 @@ export class ResponseEngine {
458
579
  });
459
580
  const payload = {
460
581
  kind: 'status.interrupted',
461
- metadata: { reason },
582
+ metadata: { reason: effectiveReason },
462
583
  };
463
584
  return channelInfo.adapter.send(envelope, payload).then(() => undefined).catch(error => {
464
585
  logger.debug(`[ResponseEngine] interrupt terminal status failure: session=${sessionId} task=${interruptedTaskId} error=${error instanceof Error ? error.message : String(error)}`);
@@ -468,13 +589,33 @@ export class ResponseEngine {
468
589
  logger.debug(`[ResponseEngine] interrupt barrier runner failure: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
469
590
  });
470
591
  await Promise.all([terminalStatus, runnerInterrupt]);
471
- const taskId = (await this.turnCoordinator.completeInterrupt(session, reason)) ?? interruptedTaskId;
472
- if (taskId) {
473
- this.sessionManager.clearProcessingIfTask(sessionId, taskId);
474
- if (this.activeTaskRuntimeContexts.get(sessionId)?.taskId === taskId) {
475
- this.activeTaskRuntimeContexts.delete(sessionId);
476
- }
477
- this.agentDelegationRegistry?.revokeTask(sessionId, taskId);
592
+ }
593
+ closeActiveRunnerTasks(sessionId, reason, causation) {
594
+ const tasks = this.activeRunnerTasks.get(sessionId);
595
+ if (!tasks || tasks.size === 0)
596
+ return;
597
+ this.activeRunnerTasks.delete(sessionId);
598
+ for (const task of tasks.values()) {
599
+ const hasUsage = task.totalTokens != null || task.toolUses != null || task.durationMs != null;
600
+ this.eventBus.publish({
601
+ type: 'runner:task-notification',
602
+ sessionId,
603
+ taskId: task.taskId,
604
+ taskKind: task.taskKind,
605
+ toolUseId: task.toolUseId,
606
+ description: task.description,
607
+ subagentType: task.subagentType,
608
+ status: 'stopped',
609
+ terminalReason: reason,
610
+ usage: hasUsage ? {
611
+ totalTokens: task.totalTokens,
612
+ toolUses: task.toolUses,
613
+ durationMs: task.durationMs,
614
+ } : undefined,
615
+ skipTranscript: task.skipTranscript,
616
+ timestamp: Date.now(),
617
+ causation,
618
+ });
478
619
  }
479
620
  }
480
621
  setHandoffRuntime(runtime) {
@@ -493,7 +634,7 @@ export class ResponseEngine {
493
634
  if (!params.sessionId)
494
635
  return { ok: false, code: 'HANDOFF_ID_REQUIRED', error: 'current session is required' };
495
636
  if (this.agentDelegationRegistry) {
496
- const delegation = this.agentDelegationRegistry.validate(params.delegationToken, params.sessionId);
637
+ const delegation = this.agentDelegationRegistry.validate(params.delegationToken, params.sessionId, params.delegationCommandHash);
497
638
  if (!delegation.ok)
498
639
  return { ok: false, code: delegation.code, error: delegation.reason };
499
640
  }
@@ -653,26 +794,44 @@ export class ResponseEngine {
653
794
  if (sessionId) {
654
795
  this.eventBus.publish({ type: 'runner:compact-start', sessionId });
655
796
  }
656
- if (this.currentRenderer && !this.shouldSuppressActivities) {
657
- this.currentRenderer.addNotice('\u23f3 会话压缩中...', 'info', 'compact-start', true);
658
- }
797
+ if (!sessionId || this.announcedCompactStarts.has(sessionId))
798
+ return;
799
+ const active = this.activeRenderers.get(sessionId);
800
+ if (!active || active.suppressActivities)
801
+ return;
802
+ void this.emitOperationalNotice(active.renderer, '\u23f3 会话压缩中...', 'info', 'compact-start');
803
+ }
804
+ async emitOperationalNotice(renderer, text, severity, subtype) {
805
+ if (await renderer.sendOperationalNoticeAsText(text))
806
+ return;
807
+ renderer.addNotice(text, severity, subtype, true);
659
808
  }
660
809
  async retryAfterContextRecovery(prompt, opts) {
661
810
  const { streamKey, renderer, agent, session, absoluteProjectPath, effectiveSystemPrompt, modelOverride, runtimeEnv, resetTimer, shouldSuppress, proactive, turnLease, } = opts;
662
811
  if (!session.agentSessionId || !canCompactAgent(agent)) {
663
812
  throw new Error('CONTEXT_COMPACT_FAILED');
664
813
  }
665
- renderer.addNotice('上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
814
+ // text event 在进入 renderer 前已经剔除了上下文超限错误行。保留缓冲中的
815
+ // 有效正文,由 sendOperationalNoticeAsText 先 flush,避免恢复过程丢失已有模型输出。
816
+ await this.emitOperationalNotice(renderer, '上下文过长,正在压缩会话...', 'warn', 'compact-trigger');
666
817
  await renderer.flush();
667
- const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
818
+ this.announcedCompactStarts.add(session.id);
819
+ let compacted;
820
+ try {
821
+ compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
822
+ }
823
+ finally {
824
+ this.announcedCompactStarts.delete(session.id);
825
+ }
668
826
  if (compacted) {
669
- renderer.addNotice('✅ 压缩完成,继续处理...', 'info', 'compact-retry', true);
827
+ await this.emitOperationalNotice(renderer, '✅ 压缩完成,继续处理...', 'info', 'compact-retry');
828
+ await renderer.flush();
670
829
  const retryStream = await agent.runQuery(session.id, prompt, absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
671
830
  agent.registerStream(streamKey, retryStream);
672
831
  return await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress, proactive, undefined, // 重试分支不调插件钩子
673
832
  undefined, turnLease);
674
833
  }
675
- renderer.addNotice('⚠️ 压缩失败,尝试清空会话历史后重试...', 'warn', 'compact-failed-clear', true);
834
+ await this.emitOperationalNotice(renderer, '⚠️ 压缩失败,尝试清空会话历史后重试...', 'warn', 'compact-failed-clear');
676
835
  await renderer.flush();
677
836
  if (!canClearAgent(agent)) {
678
837
  throw new Error('CONTEXT_COMPACT_FAILED');
@@ -690,7 +849,8 @@ export class ResponseEngine {
690
849
  }
691
850
  session.agentSessionId = undefined;
692
851
  await this.sessionManager.updateSession(session.id, { agentSessionId: null });
693
- renderer.addNotice('✅ 会话已清空,继续处理...', 'info', 'clear-retry', true);
852
+ await this.emitOperationalNotice(renderer, '✅ 会话已清空,继续处理...', 'info', 'clear-retry');
853
+ await renderer.flush();
694
854
  const retryStream = await agent.runQuery(session.id, '会话历史已清空,请继续之前未完成的任务。', absoluteProjectPath, undefined, undefined, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
695
855
  agent.registerStream(streamKey, retryStream);
696
856
  return await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress, proactive, undefined, // 重试分支不调插件钩子
@@ -911,6 +1071,19 @@ export class ResponseEngine {
911
1071
  const accessAdapter = accessChannelInfo?.adapter;
912
1072
  const accessAdapterSelfAid = typeof accessAdapter?._selfAid === 'function' ? accessAdapter._selfAid() : undefined;
913
1073
  const selfAidForAccess = accessAdapterSelfAid || message.selfAID || session.selfAID || undefined;
1074
+ if (message.handoffDelivery?.direction === 'target'
1075
+ && this.handoffRuntime
1076
+ && selfAidForAccess) {
1077
+ const handoffIds = message.handoffDelivery.handoffIds?.length
1078
+ ? message.handoffDelivery.handoffIds
1079
+ : [message.handoffDelivery.handoffId];
1080
+ const validation = this.handoffRuntime.validateTargetSessionBinding(selfAidForAccess, handoffIds, session.id);
1081
+ if (!validation.ok) {
1082
+ logger.error(`[ResponseEngine] Refusing mismatched handoff context: executionSession=${session.id}`
1083
+ + ` handoffs=${validation.invalidHandoffIds.join(',')}`);
1084
+ delete message.handoffDelivery;
1085
+ }
1086
+ }
914
1087
  const triggerExecutionIdentity = this.resolveTriggerExecutionIdentity(message, selfAidForAccess);
915
1088
  const handoffExecutionIdentity = this.resolveHandoffOriginExecutionIdentity(message, selfAidForAccess);
916
1089
  const executionIdentity = triggerExecutionIdentity ?? handoffExecutionIdentity;
@@ -1012,7 +1185,6 @@ export class ResponseEngine {
1012
1185
  const shouldSuppress = () => {
1013
1186
  return outputState.middleOutputMode === 'none';
1014
1187
  };
1015
- this.shouldSuppressActivities = shouldSuppress();
1016
1188
  let monitor;
1017
1189
  let monitorInterval;
1018
1190
  let totalExecutionTimer;
@@ -1173,6 +1345,18 @@ export class ResponseEngine {
1173
1345
  ? configuredSeconds * 1000
1174
1346
  : 60 * 60 * 1000;
1175
1347
  }
1348
+ retryAttemptTimeoutMs() {
1349
+ if (this.globalSettings.idleMonitor?.enabled === false)
1350
+ return undefined;
1351
+ const configuredSeconds = this.globalSettings.idleMonitor?.retryAttemptTimeout
1352
+ ?? this.globalSettings.idleMonitor?.timeout
1353
+ ?? 120;
1354
+ return typeof configuredSeconds === 'number'
1355
+ && Number.isFinite(configuredSeconds)
1356
+ && configuredSeconds > 0
1357
+ ? configuredSeconds * 1000
1358
+ : undefined;
1359
+ }
1176
1360
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
1177
1361
  async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, outputState = { middleOutputMode: 'all' }, timeoutControl) {
1178
1362
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
@@ -1231,6 +1415,11 @@ export class ResponseEngine {
1231
1415
  status: 'started',
1232
1416
  refs: { taskId, sessionId: session.id, messageId: message.messageId },
1233
1417
  });
1418
+ this.activeTaskSpans.set(session.id, {
1419
+ taskId,
1420
+ messageId: message.messageId,
1421
+ causation: taskCausation,
1422
+ });
1234
1423
  for (const item of message.items ?? []) {
1235
1424
  const linked = normalizeCausation(item.causation);
1236
1425
  if (!linked || linked.spanId === inputCausation.spanId)
@@ -1255,7 +1444,9 @@ export class ResponseEngine {
1255
1444
  const adapterAny = channelInfo.adapter;
1256
1445
  const adapterSelfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
1257
1446
  const selfAid = adapterSelfAid || message.selfAID || session.selfAID || undefined;
1258
- const selfName = typeof adapterAny._selfName === 'function' ? adapterAny._selfName() : undefined;
1447
+ const registrySelfName = selfAid ? this.agentRegistry?.resolveDisplayName?.(selfAid) : undefined;
1448
+ const adapterSelfName = typeof adapterAny._selfName === 'function' ? adapterAny._selfName() : undefined;
1449
+ const selfName = registrySelfName ?? adapterSelfName;
1259
1450
  const peerName = message.peerName || session.metadata?.peerName;
1260
1451
  const peerIdRaw = message.peerId;
1261
1452
  const triggerExecutionIdentity = this.resolveTriggerExecutionIdentity(message, selfAid);
@@ -1389,7 +1580,6 @@ export class ResponseEngine {
1389
1580
  ? 'none'
1390
1581
  : configuredMiddleOutputMode;
1391
1582
  outputState.middleOutputMode = middleOutputMode;
1392
- this.shouldSuppressActivities = shouldSuppress();
1393
1583
  // 诊断日志:记录 inbound message_id 和生成的 task_id 的对应关系
1394
1584
  logger.info(`[ResponseEngine] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode} mode=${resolvedMode?.mode.id ?? 'override/fallback'}`);
1395
1585
  // 构建带 taskId/chatmode 的 ReplyContext(本次任务所有出站消息共用)
@@ -1617,17 +1807,16 @@ export class ResponseEngine {
1617
1807
  taskId,
1618
1808
  channelId: message.channelId,
1619
1809
  messageIds: message.sourceMessageIds,
1810
+ restartResume: message.restartResume?.submitted === true,
1620
1811
  }).catch(() => { });
1621
1812
  }
1622
1813
  adapter.send(envelope, { kind: 'status.started' }).catch(() => { });
1623
- await this.runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope, false);
1624
1814
  logger.message({
1625
1815
  msgId: messageId,
1626
1816
  sessionId: session.id,
1627
1817
  dir: 'inbound',
1628
1818
  status: 'processing'
1629
1819
  });
1630
- const startTime = Date.now();
1631
1820
  // 创建 IMRenderer(统一 interactive/proactive 两条路径)
1632
1821
  let firstReply = true;
1633
1822
  const renderer = new IMRenderer({
@@ -1636,6 +1825,7 @@ export class ResponseEngine {
1636
1825
  flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
1637
1826
  suppressActivityItems: isProactive ? false : middleOutputMode !== 'all',
1638
1827
  suppressIntermediateText: isProactive ? false : middleOutputMode === 'none',
1828
+ operationalNoticesAsText: !isProactive && middleOutputMode !== 'none',
1639
1829
  fileMarkerPattern: options?.fileMarkerPattern,
1640
1830
  diagEnabled: this.globalSettings.debug?.flusherDiag,
1641
1831
  send: async (payload) => {
@@ -1679,8 +1869,17 @@ export class ResponseEngine {
1679
1869
  await adapter.send(enrichedEnvelope, payload);
1680
1870
  },
1681
1871
  });
1682
- this.currentRenderer = renderer;
1872
+ this.activeRenderers.set(session.id, {
1873
+ taskId,
1874
+ renderer,
1875
+ suppressActivities: shouldSuppress(),
1876
+ });
1683
1877
  renderer.addLifecycle('started');
1878
+ // 预压缩与任务过程中的自动压缩共用同一投影规则:interactive 的 text/all
1879
+ // 均以普通非最终文本显示运行提示;none 不显示。其它 activity 在 all 下仍保持结构化。
1880
+ // 它仍发生在真正的 prompt 执行之前。
1881
+ await this.runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, renderer);
1882
+ const startTime = Date.now();
1684
1883
  if (isProactive) {
1685
1884
  logger.info(`[ResponseEngine] proactive mode: outputs via thought.put task=${taskId}`);
1686
1885
  }
@@ -1821,6 +2020,14 @@ export class ResponseEngine {
1821
2020
  approvalRouting,
1822
2021
  approvalInteractionPolicy: authorizationIdentity ? 'deny' : 'interactive',
1823
2022
  recordExecutionAnomaly,
2023
+ armApprovedDelegationCommand: this.agentDelegationRegistry
2024
+ ? (carrierToken, commandHash) => this.agentDelegationRegistry.armApprovedCommand({
2025
+ carrierToken,
2026
+ sessionId: session.id,
2027
+ taskId,
2028
+ commandHash,
2029
+ })
2030
+ : undefined,
1824
2031
  turn: {
1825
2032
  taskId,
1826
2033
  turnId: turnLease.turnId,
@@ -2125,13 +2332,13 @@ export class ResponseEngine {
2125
2332
  + ` modelSource=${effectiveModelSource ?? 'runner-default'}`
2126
2333
  + ` effort=${effectiveEffort ?? '<runner-default>'}`
2127
2334
  + ` effortSource=${effectiveEffortSource ?? 'runner-default'}`);
2128
- const groupRulesVars = session.chatType === 'group' && currentChannelType === 'aun'
2129
- ? await syncGroupRulesContext({
2130
- selfAid,
2131
- groupId: session.metadata?.groupId || message.channelId,
2132
- channel: currentChannelType || message.channel,
2133
- })
2134
- : {};
2335
+ const groupRulesVars = await resolveGroupRulesForTurn({
2336
+ isAunGroup: session.chatType === 'group' && currentChannelType === 'aun',
2337
+ mode: effectiveAgentConfig?.groupRules?.mode ?? 'load',
2338
+ selfAid,
2339
+ groupId: session.metadata?.groupId || message.channelId,
2340
+ channel: currentChannelType || message.channel,
2341
+ });
2135
2342
  // Kit renderer: 组装上下文
2136
2343
  const pkgRoot = getPackageRoot();
2137
2344
  const kitCtx = {
@@ -2197,6 +2404,7 @@ export class ResponseEngine {
2197
2404
  // Stage 3: sessionKey 持久化字段
2198
2405
  sessionKey: session.sessionKey,
2199
2406
  chatMode: isProactive ? 'proactive' : 'interactive',
2407
+ isTriggerTask: causationPath.length > 0 || message.source === 'trigger',
2200
2408
  // proactive 为 null(interactive 模式,这些开关不适用)时如实为 false——
2201
2409
  // 默认值不在此硬编码,出厂默认由模式 schema 声明、经 buildState 落入 proactive.*。
2202
2410
  proactivePreTool1stMsgChk: proactive?.preTool1stMsgChk ?? false,
@@ -2276,9 +2484,9 @@ export class ResponseEngine {
2276
2484
  if (message.handoffDelivery && this.handoffRuntime) {
2277
2485
  const items = this.handoffRuntime.buildPromptItems(message);
2278
2486
  if (items.length > 0) {
2279
- v2HandoffIds = items
2280
- .map(item => item.handoff?.handoffId)
2281
- .filter((handoffId) => !!handoffId);
2487
+ v2HandoffIds = Array.from(new Set(items.flatMap(item => (item.handoff?.handoffIds?.length
2488
+ ? item.handoff.handoffIds
2489
+ : (item.handoff?.handoffId ? [item.handoff.handoffId] : [])))));
2282
2490
  v2HandoffDirection = message.handoffDelivery.direction;
2283
2491
  return items;
2284
2492
  }
@@ -2345,9 +2553,12 @@ export class ResponseEngine {
2345
2553
  this.publishTriggerExecutionSkipped(message, 'empty_prompt', taskCausation);
2346
2554
  return;
2347
2555
  }
2348
- if (v2HandoffIds.length > 0 && v2HandoffDirection === 'origin' && handoffPromptRendered && this.handoffRuntime && selfAid) {
2556
+ if (v2HandoffIds.length > 0 && handoffPromptRendered && this.handoffRuntime && selfAid) {
2349
2557
  for (const handoffId of v2HandoffIds) {
2350
- this.handoffRuntime.completeOriginContext(selfAid, handoffId);
2558
+ if (v2HandoffDirection === 'origin')
2559
+ this.handoffRuntime.completeOriginContext(selfAid, handoffId);
2560
+ else if (v2HandoffDirection === 'target')
2561
+ this.handoffRuntime.completeTargetContext(selfAid, handoffId);
2351
2562
  }
2352
2563
  }
2353
2564
  const taskRuntimeContext = {
@@ -2395,6 +2606,7 @@ export class ResponseEngine {
2395
2606
  // 可重试错误(403/429/5xx/模型繁忙)按明确退避序列重试。
2396
2607
  const RETRY_DELAYS_MS = [5_000, 10_000, 30_000];
2397
2608
  const MAX_RETRIES = RETRY_DELAYS_MS.length;
2609
+ const retryAttemptTimeoutMs = this.retryAttemptTimeoutMs();
2398
2610
  let runAttempt = 1;
2399
2611
  let consecutiveRetryFailures = 0;
2400
2612
  const recordRetryHealthError = async (retryError) => {
@@ -2412,12 +2624,24 @@ export class ResponseEngine {
2412
2624
  };
2413
2625
  while (true) {
2414
2626
  let streamRegistered = false;
2627
+ const shouldTimeoutRetryAttempt = consecutiveRetryFailures > 0 && retryAttemptTimeoutMs !== undefined;
2628
+ let attemptTimeout;
2415
2629
  try {
2416
2630
  logger.info(`[ResponseEngine] agent.runQuery start: agent=${agent.name} session=${session.id} task=${taskId} attempt=${runAttempt} consecutiveFailures=${consecutiveRetryFailures} agentSessionId=${session.agentSessionId ?? 'none'}`);
2417
2631
  const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, renderResult?.images.length ? renderResult.images : message.images, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
2418
2632
  agent.registerStream(streamKey, stream);
2419
2633
  streamRegistered = true;
2420
- streamResult = await this.processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease);
2634
+ if (shouldTimeoutRetryAttempt) {
2635
+ attemptTimeout = createRetryAttemptTimeout(retryAttemptTimeoutMs);
2636
+ resetTimer('retry_attempt');
2637
+ }
2638
+ const processAttempt = this.processEventStream(stream, session, agent, renderer, (eventType, toolName) => {
2639
+ resetTimer(eventType, toolName);
2640
+ attemptTimeout?.reset();
2641
+ }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease);
2642
+ streamResult = attemptTimeout
2643
+ ? await Promise.race([processAttempt, attemptTimeout.promise])
2644
+ : await processAttempt;
2421
2645
  if (streamResult.isError && !streamHitContextLimit(streamResult)) {
2422
2646
  const streamErrorText = getStreamErrorText(streamResult);
2423
2647
  if (streamErrorText && isRetryableError(new Error(streamErrorText))) {
@@ -2433,6 +2657,14 @@ export class ResponseEngine {
2433
2657
  break; // 成功,跳出重试循环
2434
2658
  }
2435
2659
  catch (retryError) {
2660
+ const retryAttemptTimedOut = retryError instanceof RetryAttemptTimeoutError;
2661
+ if (retryAttemptTimedOut) {
2662
+ logger.warn(`[ResponseEngine] Retry attempt ${runAttempt} timed out after ${retryError.timeoutMs}ms without agent events; interrupting current stream`);
2663
+ renderer.discardPending();
2664
+ await agent.interrupt(session.id).catch(interruptError => {
2665
+ logger.warn(`[ResponseEngine] Failed to interrupt timed-out retry attempt: ${interruptError instanceof Error ? interruptError.message : String(interruptError)}`);
2666
+ });
2667
+ }
2436
2668
  if (streamRegistered) {
2437
2669
  agent.cleanupStream(streamKey);
2438
2670
  }
@@ -2463,7 +2695,7 @@ export class ResponseEngine {
2463
2695
  runAttempt++;
2464
2696
  continue;
2465
2697
  }
2466
- if (isRetryableError(retryError)) {
2698
+ if (retryAttemptTimedOut || isRetryableError(retryError)) {
2467
2699
  if (didRetryMakeProgress(retryError)) {
2468
2700
  consecutiveRetryFailures = 0;
2469
2701
  }
@@ -2479,7 +2711,7 @@ export class ResponseEngine {
2479
2711
  const nextRetryNumber = consecutiveRetryFailures + 1;
2480
2712
  consecutiveRetryFailures++;
2481
2713
  logger.warn(`[ResponseEngine] Retryable error (attempt ${runAttempt}, consecutive ${consecutiveRetryFailures}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
2482
- renderer.addNotice(`API 不可用,${delay / 1000}秒后重试 ${nextRetryNumber}/${MAX_RETRIES}`, 'warn', 'retry', true);
2714
+ await this.emitOperationalNotice(renderer, `API 不可用,${delay / 1000}秒后重试 ${nextRetryNumber}/${MAX_RETRIES}`, 'warn', 'retry');
2483
2715
  await renderer.flush();
2484
2716
  await new Promise(resolve => setTimeout(resolve, delay));
2485
2717
  // 延迟后再次检查中断状态(延迟期间可能收到中断信号)
@@ -2495,6 +2727,9 @@ export class ResponseEngine {
2495
2727
  }
2496
2728
  throw retryError; // 不可重试或已耗尽重试次数
2497
2729
  }
2730
+ finally {
2731
+ attemptTimeout?.clear();
2732
+ }
2498
2733
  }
2499
2734
  }
2500
2735
  catch (error) {
@@ -2557,12 +2792,12 @@ export class ResponseEngine {
2557
2792
  // 重试后仍然 prompt_too_long:显示友好提示
2558
2793
  const retryStillTooLong = streamResult.isError && streamHitContextLimit(streamResult);
2559
2794
  if (retryStillTooLong) {
2560
- renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
2795
+ await this.emitOperationalNotice(renderer, getContextTooLongHint(agent), 'warn', 'context-too-long');
2561
2796
  }
2562
2797
  }
2563
2798
  else if (streamResult.isError && streamHitContextLimit(streamResult)) {
2564
2799
  // 上下文过长但无法 auto-compact(无 session ID 或 agent 不支持),显示友好提示
2565
- renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
2800
+ await this.emitOperationalNotice(renderer, getContextTooLongHint(agent), 'warn', 'context-too-long');
2566
2801
  }
2567
2802
  if (!streamResult.isError) {
2568
2803
  const commitEvidence = this.buildTurnCommitEvidence(session, streamResult);
@@ -2634,7 +2869,7 @@ export class ResponseEngine {
2634
2869
  if (!this.turnCoordinator.isCurrent(turnLease))
2635
2870
  return 0;
2636
2871
  // 仅支持公开文件标记语法:[SEND_FILE:path] 或 [SEND_FILE:channel:path]。
2637
- const SEND_FILE_RE = options?.fileMarkerPattern ?? /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
2872
+ const SEND_FILE_RE = options?.fileMarkerPattern ?? createSendFileMarkerPattern();
2638
2873
  const sendFileMatches = [...scanText.matchAll(SEND_FILE_RE)];
2639
2874
  if (sendFileMatches.length === 0)
2640
2875
  return 0;
@@ -2730,6 +2965,7 @@ export class ResponseEngine {
2730
2965
  }
2731
2966
  // 最终回复文本:suppressed 模式或无 text 事件时需要兜底添加
2732
2967
  const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
2968
+ const visibleFinalReplyText = stripContextLimitErrorLines(finalReplyText);
2733
2969
  if (!this.turnCoordinator.isCurrent(turnLease)) {
2734
2970
  agent.cleanupStream(streamKey);
2735
2971
  this.sessionManager.clearProcessingIfTask(session.id, taskId);
@@ -2741,9 +2977,11 @@ export class ResponseEngine {
2741
2977
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, 'stale_turn', taskCausation);
2742
2978
  return;
2743
2979
  }
2744
- if (finalReplyText) {
2745
- if (shouldSuppress() || !streamResult.hasReceivedText) {
2746
- renderer.addText(finalReplyText);
2980
+ if (visibleFinalReplyText) {
2981
+ // `hasReceivedText` 是任务级计数。工具调用前的文字已经显示,并不表示最后一轮
2982
+ // (通常来自 complete.result)的文字也已显示;用本轮投影标记避免终态丢失。
2983
+ if (shouldSuppress() || !streamResult.hasProjectedCurrentReplyText) {
2984
+ renderer.addText(visibleFinalReplyText);
2747
2985
  }
2748
2986
  }
2749
2987
  // 先清理流和处理中状态(保证即使 flush 卡住,session 也不会永久处于"处理中")
@@ -2771,7 +3009,7 @@ export class ResponseEngine {
2771
3009
  }
2772
3010
  // 被用户中断(新消息打断)时跳过 flush — 新 task 已接管渠道,旧 task 的 flush 无意义且可能卡住
2773
3011
  const preFlushInterrupt = this.interruptedSessions.get(session.id);
2774
- if (preFlushInterrupt === 'new_message' || preFlushInterrupt === 'stop' || preFlushInterrupt === 'recalled') {
3012
+ if (isNonErrorInterruptReason(preFlushInterrupt)) {
2775
3013
  logger.info(`[ResponseEngine] Skipping flush for interrupted task=${taskId} reason=${preFlushInterrupt}`);
2776
3014
  }
2777
3015
  else {
@@ -2812,13 +3050,13 @@ export class ResponseEngine {
2812
3050
  // 用户主动打断(新消息/​/stop/​撤回)会让 SDK 流在工具调用中途被掐断,
2813
3051
  // 末尾 result message 形状异常并被标记为 error(含 SDK 内部 ede_diagnostic 串)。
2814
3052
  // 这不是真正的失败,不应把诊断串暴露给用户,也不计入错误统计。
2815
- const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
2816
- if (!isUserInterrupt && !permissionTerminalFailure) {
3053
+ const isExpectedInterrupt = isNonErrorInterruptReason(interruptReason);
3054
+ if (!isExpectedInterrupt && !permissionTerminalFailure) {
2817
3055
  await adapter.send(envelope, { kind: 'result.error', text: userErrorSummary, reason: rawSubtype }).catch(() => { });
2818
3056
  await adapter.send(envelope, { kind: 'status.error', metadata: { errorType: rawSubtype } }).catch(() => { });
2819
3057
  this.touchAgentActivity(channelKey);
2820
3058
  }
2821
- if (isUserInterrupt) {
3059
+ if (isExpectedInterrupt) {
2822
3060
  // 用户打断:打断本身已由 message-queue 发过 task:interrupted 事件,
2823
3061
  // 这里不再补发 task:error(否则同一次打断被记两遍且错误归类为 error)。
2824
3062
  // 仅记 info 日志收尾。注意:task:interrupted 已填充 interruptedSessions,
@@ -2835,6 +3073,8 @@ export class ResponseEngine {
2835
3073
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, interruptReason ?? 'interrupted', taskCausation);
2836
3074
  }
2837
3075
  else {
3076
+ logger.error(`[ResponseEngine] Task failed: session=${session.id} task=${taskId} ` +
3077
+ `type=${errorType} terminalReason=${streamResult.terminalReason ?? 'none'} error=${errorSummary}`);
2838
3078
  this.publishTriggerExecutionFailure(message, errorSummary, { messageId, causation: taskCausation });
2839
3079
  this.eventBus.publish({
2840
3080
  type: 'task:error',
@@ -3128,10 +3368,7 @@ export class ResponseEngine {
3128
3368
  && interruptReason !== 'timeout'
3129
3369
  && !isTimeout
3130
3370
  && !isTotalExecutionTimeout;
3131
- const isUserInterrupt = interruptReason === 'new_message'
3132
- || interruptReason === 'stop'
3133
- || interruptReason === 'recalled'
3134
- || staleSupersededTurn;
3371
+ const isUserInterrupt = isNonErrorInterruptReason(interruptReason) || staleSupersededTurn;
3135
3372
  const errorMsg = error instanceof Error ? error.message : String(error);
3136
3373
  if (isUserInterrupt) {
3137
3374
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, interruptReason ?? 'stale_turn', taskCausation);
@@ -3257,6 +3494,12 @@ export class ResponseEngine {
3257
3494
  timeoutControl.terminalDelivered = true;
3258
3495
  }
3259
3496
  }
3497
+ finally {
3498
+ // 同一 session 的新任务可能已替换登记;旧任务结束时不得删掉新任务的 renderer。
3499
+ if (this.activeRenderers.get(session.id)?.taskId === taskId) {
3500
+ this.activeRenderers.delete(session.id);
3501
+ }
3502
+ }
3260
3503
  // [迁移探针] 任务收尾:记录工具提醒最终状态并落盘(防线 1)
3261
3504
  if (snapshot.isEnabled()) {
3262
3505
  snapshot.set(session.id, taskId, {
@@ -3265,7 +3508,7 @@ export class ResponseEngine {
3265
3508
  snapshot.end(session.id, taskId);
3266
3509
  }
3267
3510
  }
3268
- async runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope, suppressOutput = false) {
3511
+ async runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, renderer) {
3269
3512
  if (!session.agentSessionId || !canCompactAgent(agent)) {
3270
3513
  logger.debug(`[ResponseEngine] Auto compact skipped: session=${session.id} agentSessionId=${session.agentSessionId || 'none'} canCompact=${canCompactAgent(agent)} agent=${agent.name}`);
3271
3514
  return;
@@ -3276,15 +3519,14 @@ export class ResponseEngine {
3276
3519
  return;
3277
3520
  }
3278
3521
  logger.info(`[ResponseEngine] Auto compact at task.start: session=${session.id} totalTokens=${ctx.totalTokens} autoCompactTokens=${ctx.autoCompactTokens}`);
3279
- if (!suppressOutput) {
3280
- await adapter.send(envelope, { kind: 'system.notice', text: '上下文接近上限,正在压缩会话...', subtype: 'auto-compact-start' }).catch(() => { });
3281
- }
3522
+ await this.emitOperationalNotice(renderer, '上下文接近上限,正在压缩会话...', 'info', 'auto-compact-start');
3523
+ await renderer.flush();
3524
+ this.announcedCompactStarts.add(session.id);
3282
3525
  try {
3283
3526
  const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
3284
3527
  if (compacted) {
3285
- if (!suppressOutput) {
3286
- await adapter.send(envelope, { kind: 'system.notice', text: '✅ 上下文压缩完成,继续处理...', subtype: 'auto-compact-complete' }).catch(() => { });
3287
- }
3528
+ await this.emitOperationalNotice(renderer, '✅ 上下文压缩完成,继续处理...', 'info', 'auto-compact-complete');
3529
+ await renderer.flush();
3288
3530
  }
3289
3531
  else {
3290
3532
  logger.warn(`[ResponseEngine] Auto compact at task.start returned false (session=${session.id})`);
@@ -3293,6 +3535,9 @@ export class ResponseEngine {
3293
3535
  catch (err) {
3294
3536
  logger.warn(`[ResponseEngine] Auto compact at task.start failed (non-fatal):`, err);
3295
3537
  }
3538
+ finally {
3539
+ this.announcedCompactStarts.delete(session.id);
3540
+ }
3296
3541
  }
3297
3542
  async readLastModelCallContextUsage(sessionId, agentSessionId) {
3298
3543
  try {
@@ -3359,17 +3604,28 @@ export class ResponseEngine {
3359
3604
  const owningAgent = this.agentRegistry?.resolveByChannel(message.channel);
3360
3605
  const projectPath = owningAgent?.projectPath || process.cwd();
3361
3606
  const resolvedBaseagent = message.baseagent || owningAgent?.baseagent || (!this.agentRegistry ? this.inferPrimaryBaseagent() : undefined);
3362
- const resolvedIdentity = this.inferDirectSessionIdentity(message, owningAgent);
3607
+ // 优先复用 MessageBridge 入队前已解析的身份,避免二次独立解析产生角色漂移
3608
+ // (曾观测到瞬时竞态下把 owner 误判为 none,导致合法用户被静默拒绝)。
3609
+ // 仅当消息不经 MessageBridge.register()(如 trigger 直接投递)时才回退重新解析。
3610
+ const resolvedIdentity = message.resolvedIdentity ?? this.inferDirectSessionIdentity(message, owningAgent);
3363
3611
  if (!resolvedBaseagent) {
3364
3612
  throw new Error(`[ResponseEngine] resolveSession: baseagent could not be determined (message.baseagent=${message.baseagent}, owningAgent=${owningAgent?.name || 'none'})`);
3365
3613
  }
3366
3614
  // 话题创建权限守卫已统一移至 MessageBridge.canCreateThreadSession(enqueue 前拦截),
3367
3615
  // 此处不再重复检查——bridge 层拒绝后消息根本不会到达 processMessage。
3368
- // current strategy: resume bound session, make it active so output is not suppressed
3369
- if (message.triggerMeta?.boundSessionId) {
3370
- const bound = await this.sessionManager.getSessionById(message.triggerMeta.boundSessionId);
3616
+ // Resume the exact session selected before enqueue. Handoff binding and model
3617
+ // execution must never independently re-resolve to different sessions.
3618
+ const boundSessionId = message.boundSessionId ?? message.triggerMeta?.boundSessionId;
3619
+ if (boundSessionId) {
3620
+ const bound = await this.sessionManager.getSessionById(boundSessionId);
3371
3621
  if (bound) {
3372
3622
  this.ensureSessionBaseagent(bound, resolvedBaseagent);
3623
+ // Session.identity is deliberately runtime-only and is not persisted in
3624
+ // active.json/session JSONL. Re-attaching a bridge-bound session without
3625
+ // restoring the freshly resolved identity would therefore downgrade a
3626
+ // valid mapped owner/admin to role=none at the execution boundary.
3627
+ if (resolvedIdentity)
3628
+ bound.identity = resolvedIdentity;
3373
3629
  if (bound.threadId) {
3374
3630
  const absoluteProjectPath = path.isAbsolute(bound.projectPath)
3375
3631
  ? bound.projectPath : path.resolve(process.cwd(), bound.projectPath);
@@ -3378,14 +3634,22 @@ export class ResponseEngine {
3378
3634
  const switched = await this.sessionManager.switchToSession(bound.channel, bound.channelId, bound.id);
3379
3635
  if (switched) {
3380
3636
  this.ensureSessionBaseagent(switched, resolvedBaseagent);
3637
+ if (resolvedIdentity)
3638
+ switched.identity = resolvedIdentity;
3381
3639
  const absoluteProjectPath = path.isAbsolute(switched.projectPath)
3382
3640
  ? switched.projectPath : path.resolve(process.cwd(), switched.projectPath);
3383
3641
  return { session: switched, absoluteProjectPath };
3384
3642
  }
3643
+ if (message.boundSessionId) {
3644
+ throw new Error(`[ResponseEngine] Bound session ${bound.id} could not be activated`);
3645
+ }
3385
3646
  logger.warn(`[ResponseEngine] switchToSession failed for bound session ${bound.id}, falling back to latest`);
3386
3647
  }
3387
3648
  else {
3388
- logger.warn(`[ResponseEngine] Bound session ${message.triggerMeta.boundSessionId} not found, falling back to latest`);
3649
+ if (message.boundSessionId) {
3650
+ throw new Error(`[ResponseEngine] Bound session ${boundSessionId} not found`);
3651
+ }
3652
+ logger.warn(`[ResponseEngine] Bound session ${boundSessionId} not found, falling back to latest`);
3389
3653
  }
3390
3654
  }
3391
3655
  const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, resolvedBaseagent, message.selfAID, message.channelType, message.peerType, resolvedIdentity);
@@ -3444,6 +3708,7 @@ export class ResponseEngine {
3444
3708
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
3445
3709
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
3446
3710
  let hasReceivedText = false;
3711
+ let hasProjectedCurrentReplyText = false;
3447
3712
  let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
3448
3713
  let madeProgress = false;
3449
3714
  let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
@@ -3457,6 +3722,7 @@ export class ResponseEngine {
3457
3722
  const finalizeResult = () => ({
3458
3723
  ...completeResult,
3459
3724
  hasReceivedText,
3725
+ hasProjectedCurrentReplyText,
3460
3726
  inputAccepted,
3461
3727
  assistantUuid,
3462
3728
  protocolIncompleteReason,
@@ -3583,6 +3849,19 @@ export class ResponseEngine {
3583
3849
  continue;
3584
3850
  }
3585
3851
  if (event.type === 'task_started') {
3852
+ let tasks = this.activeRunnerTasks.get(session.id);
3853
+ if (!tasks) {
3854
+ tasks = new Map();
3855
+ this.activeRunnerTasks.set(session.id, tasks);
3856
+ }
3857
+ tasks.set(event.taskId, {
3858
+ taskId: event.taskId,
3859
+ taskKind: event.taskKind,
3860
+ toolUseId: event.toolUseId,
3861
+ description: event.description,
3862
+ subagentType: event.subagentType,
3863
+ skipTranscript: event.skipTranscript,
3864
+ });
3586
3865
  this.eventBus.publish({
3587
3866
  type: 'runner:task-started',
3588
3867
  sessionId: session.id,
@@ -3622,6 +3901,10 @@ export class ResponseEngine {
3622
3901
  continue;
3623
3902
  }
3624
3903
  if (event.type === 'task_notification') {
3904
+ const tasks = this.activeRunnerTasks.get(session.id);
3905
+ tasks?.delete(event.taskId);
3906
+ if (tasks?.size === 0)
3907
+ this.activeRunnerTasks.delete(session.id);
3625
3908
  this.eventBus.publish({
3626
3909
  type: 'runner:task-notification',
3627
3910
  sessionId: session.id,
@@ -3656,6 +3939,16 @@ export class ResponseEngine {
3656
3939
  continue;
3657
3940
  }
3658
3941
  if (event.type === 'task_progress') {
3942
+ if (event.taskId) {
3943
+ const task = this.activeRunnerTasks.get(session.id)?.get(event.taskId);
3944
+ if (task) {
3945
+ task.description = task.description || event.description;
3946
+ task.subagentType = task.subagentType || event.subagentType;
3947
+ task.totalTokens = event.totalTokens ?? task.totalTokens;
3948
+ task.toolUses = event.toolUses ?? task.toolUses;
3949
+ task.durationMs = event.durationMs ?? task.durationMs;
3950
+ }
3951
+ }
3659
3952
  this.eventBus.publish({
3660
3953
  type: 'runner:task-progress',
3661
3954
  sessionId: session.id,
@@ -3678,13 +3971,21 @@ export class ResponseEngine {
3678
3971
  if (!isCurrentlyBackground) {
3679
3972
  // 流式文本
3680
3973
  if (event.type === 'text') {
3681
- hasReceivedText = true;
3682
- if (event.text.trim())
3683
- madeProgress = true;
3974
+ // 网关偶尔会把上下文超限错误伪装成普通 text event。不要向事件总线或
3975
+ // 渲染器投影错误行;lastReplyText 仍保留原文本,以便流结束后触发恢复。
3976
+ const visibleText = stripContextLimitErrorLines(event.text);
3977
+ if (visibleText) {
3978
+ hasReceivedText = true;
3979
+ if (visibleText.trim())
3980
+ madeProgress = true;
3981
+ }
3684
3982
  lastReplyText += event.text;
3685
- this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
3686
- if (!shouldSuppress()) {
3687
- renderer.addText(event.text, event.outputTokens, event.turn);
3983
+ if (visibleText) {
3984
+ this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: visibleText, isFinal: false });
3985
+ }
3986
+ if (visibleText && !shouldSuppress()) {
3987
+ renderer.addText(visibleText, event.outputTokens, event.turn);
3988
+ hasProjectedCurrentReplyText = true;
3688
3989
  }
3689
3990
  }
3690
3991
  // compact 完成
@@ -3692,7 +3993,7 @@ export class ResponseEngine {
3692
3993
  madeProgress = true;
3693
3994
  this.eventBus.publish({ type: 'runner:compact-complete', sessionId: session.id, preTokens: event.preTokens });
3694
3995
  if (!shouldSuppress()) {
3695
- renderer.addNotice(`\ud83d\udca1 会话压缩完成,继续执行...)`, 'info', 'compact');
3996
+ await this.emitOperationalNotice(renderer, '\ud83d\udca1 会话压缩完成,继续执行...', 'info', 'compact');
3696
3997
  }
3697
3998
  }
3698
3999
  // 子任务进度
@@ -3718,6 +4019,7 @@ export class ResponseEngine {
3718
4019
  }
3719
4020
  // 重置最后回复追踪
3720
4021
  lastReplyText = '';
4022
+ hasProjectedCurrentReplyText = false;
3721
4023
  this.eventBus.publish({
3722
4024
  type: 'tool:use',
3723
4025
  sessionId: session.id,
@@ -3830,7 +4132,7 @@ export class ResponseEngine {
3830
4132
  // 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
3831
4133
  // 上下文过长的错误留给外层 isPromptTooLong 触发 auto-compact,不在此处输出
3832
4134
  const interruptReason = this.interruptedSessions.get(session.id);
3833
- const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
4135
+ const isUserInterrupt = isNonErrorInterruptReason(interruptReason);
3834
4136
  const isContextTooLong = event.terminalReason === 'prompt_too_long'
3835
4137
  || isContextTooLongText(event.errors?.join(' ') || '')
3836
4138
  || isContextTooLongText(lastReplyText);
@@ -3899,7 +4201,7 @@ export class ResponseEngine {
3899
4201
  catch (error) {
3900
4202
  // User interrupt (AbortError) is expected, log at info level
3901
4203
  const catchInterruptReason = this.interruptedSessions.get(session.id);
3902
- const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop' || catchInterruptReason === 'recalled';
4204
+ const catchIsUserInterrupt = isNonErrorInterruptReason(catchInterruptReason);
3903
4205
  if (catchInterruptReason === 'timeout') {
3904
4206
  logger.info('[ResponseEngine] Stream closed by timeout barrier');
3905
4207
  throw this.timeoutErrors.get(session.id) ?? new Error('SDK_TIMEOUT');