evolcore 0.0.2 → 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 (206) hide show
  1. package/CHANGELOG.md +93 -778
  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 +198 -48
  6. package/dist/agents/codex-app-server-client.js +68 -0
  7. package/dist/agents/codex-runner.js +203 -8
  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/aun/outbox.js +8 -0
  14. package/dist/channels/aun.js +241 -116
  15. package/dist/channels/daemon.js +18 -4
  16. package/dist/channels/dingtalk.js +1005 -260
  17. package/dist/channels/feishu.js +184 -56
  18. package/dist/channels/qqbot.js +23 -1
  19. package/dist/channels/wechat.js +303 -155
  20. package/dist/channels/wecom.js +1132 -164
  21. package/dist/cli/agent-command.js +2 -1
  22. package/dist/cli/aun-commands.js +98 -43
  23. package/dist/cli/bench.js +2 -2
  24. package/dist/cli/contact.js +71 -0
  25. package/dist/cli/ctl-command.js +2 -2
  26. package/dist/cli/daemon-commands.js +77 -214
  27. package/dist/cli/handoff-command.js +4 -3
  28. package/dist/cli/help.js +9 -5
  29. package/dist/cli/index.js +132 -116
  30. package/dist/cli/init-channel.js +242 -129
  31. package/dist/cli/init.js +70 -31
  32. package/dist/cli/model.js +2 -1
  33. package/dist/cli/net-check.js +2 -2
  34. package/dist/cli/queue-command.js +30 -6
  35. package/dist/cli/raw-key-input.js +25 -0
  36. package/dist/cli/response.js +5 -6
  37. package/dist/cli/restart-monitor.js +25 -1
  38. package/dist/cli/stats.js +6 -4
  39. package/dist/cli/trigger-command.js +146 -25
  40. package/dist/cli/version.js +6 -1
  41. package/dist/config/access-policy-domain.js +38 -0
  42. package/dist/config/access-policy.js +134 -0
  43. package/dist/config/builtin-role-templates.js +42 -17
  44. package/dist/config/builtin-roles.js +31 -6
  45. package/dist/config/config-field-policy.js +17 -5
  46. package/dist/config/config-manager.js +409 -29
  47. package/dist/config/config-operation-service.js +35 -38
  48. package/dist/config/contact-alias.js +68 -0
  49. package/dist/config/contact-bind-code.js +274 -0
  50. package/dist/config/contact-book-store.js +622 -0
  51. package/dist/config/contact-book-v2-startup.js +35 -0
  52. package/dist/config/contact-book.js +185 -300
  53. package/dist/config/contact-operation-service.js +116 -0
  54. package/dist/config/contact-request-service.js +331 -0
  55. package/dist/config/peer-role-resolver.js +136 -55
  56. package/dist/config/role-ranks.js +18 -0
  57. package/dist/config/role-service.js +16 -19
  58. package/dist/config/role-store.js +16 -5
  59. package/dist/config/roles.js +10 -1
  60. package/dist/config/schema-registry.js +49 -24
  61. package/dist/config-store.js +14 -4
  62. package/dist/core/auth/agent-delegation.js +105 -11
  63. package/dist/core/auth/authorization-audit.js +16 -1
  64. package/dist/core/auth/operation-authorizer.js +10 -0
  65. package/dist/core/auth/operation-catalog.js +105 -1
  66. package/dist/core/auth/trigger-authorization.js +15 -0
  67. package/dist/core/bootstrap-service.js +2 -9
  68. package/dist/core/channel-loader.js +6 -2
  69. package/dist/core/command/command-handler.js +113 -21
  70. package/dist/core/command/connect-menu.js +599 -0
  71. package/dist/core/command/evol-menu-version-gate.js +38 -0
  72. package/dist/core/command/group-menu.js +421 -0
  73. package/dist/core/command/menu-handler.js +382 -100
  74. package/dist/core/command/menu-protocol.js +8 -2
  75. package/dist/core/command/menu-token-store.js +102 -0
  76. package/dist/core/command/role-menu.js +128 -29
  77. package/dist/core/command/slash-gate.js +1 -1
  78. package/dist/core/command/slash-handler.js +186 -49
  79. package/dist/core/daemon-file-cache.js +40 -6
  80. package/dist/core/event-catalog.js +99 -0
  81. package/dist/core/evolagent-registry.js +4 -40
  82. package/dist/core/evolagent.js +20 -9
  83. package/dist/core/handoff/runtime.js +162 -37
  84. package/dist/core/handoff/store.js +46 -2
  85. package/dist/core/handoff/types.js +1 -0
  86. package/dist/core/inference/text-inference.js +16 -74
  87. package/dist/core/message/file-markers.js +7 -0
  88. package/dist/core/message/im-renderer.js +20 -14
  89. package/dist/core/message/inbound-admission.js +124 -0
  90. package/dist/core/message/message-bridge.js +670 -57
  91. package/dist/core/message/message-log.js +1 -0
  92. package/dist/core/message/message-queue.js +195 -10
  93. package/dist/core/message/message-utils.js +8 -2
  94. package/dist/core/message/peer-mode.js +7 -8
  95. package/dist/core/message/response-engine.js +642 -96
  96. package/dist/core/message/send-receipt.js +24 -0
  97. package/dist/core/message/stream-debouncer.js +11 -2
  98. package/dist/core/permission/approval-gateway.js +17 -10
  99. package/dist/core/permission/ec-command-parser.js +101 -46
  100. package/dist/core/permission/tool-policy.js +115 -38
  101. package/dist/core/protected-paths.js +38 -11
  102. package/dist/core/session/session-fs-store.js +44 -3
  103. package/dist/core/session/session-manager.js +86 -8
  104. package/dist/core/session/session-mapper.js +2 -0
  105. package/dist/core/session/session-renew.js +125 -69
  106. package/dist/core/session/session-turn-coordinator.js +5 -1
  107. package/dist/eck/kit-renderer.js +12 -1
  108. package/dist/eck/message-renderer.js +79 -1
  109. package/dist/index.js +285 -94
  110. package/dist/ipc.js +114 -8
  111. package/dist/paths.js +3 -0
  112. package/dist/response-system/config-resolver.js +29 -0
  113. package/dist/response-system/coordinator.js +8 -32
  114. package/dist/response-system/engines/v1/proactive-flow.js +1 -1
  115. package/dist/response-system/index.js +1 -0
  116. package/dist/response-system/modes/single-session/index.js +7 -4
  117. package/dist/stats/billing.js +20 -8
  118. package/dist/trigger/manager.js +3 -0
  119. package/dist/trigger/parser.js +126 -11
  120. package/dist/trigger/patch.js +12 -2
  121. package/dist/trigger/scheduler.js +444 -40
  122. package/dist/trigger/validation.js +13 -0
  123. package/dist/utils/aid-bind.js +43 -29
  124. package/dist/utils/error-dict.json +7 -0
  125. package/dist/utils/evolcore-version.js +21 -0
  126. package/dist/utils/instance-registry.js +14 -7
  127. package/dist/utils/log-writer.js +46 -0
  128. package/dist/utils/logger.js +2 -2
  129. package/dist/utils/media-cache.js +4 -1
  130. package/dist/utils/model-prices.jsonl +6 -3
  131. package/dist/utils/restart-safety.js +31 -0
  132. package/dist/utils/stable-semver.js +21 -0
  133. package/dist/utils/stats.js +33 -9
  134. package/dist/utils/system-memory.js +62 -0
  135. package/kits/docs/INDEX.md +2 -1
  136. package/kits/docs/channels/aun.md +4 -13
  137. package/kits/docs/evolcore/INDEX.md +5 -3
  138. package/kits/docs/evolcore/agent.md +9 -1
  139. package/kits/docs/evolcore/aid.md +5 -2
  140. package/kits/docs/evolcore/config.md +47 -2
  141. package/kits/docs/evolcore/contact.md +61 -0
  142. package/kits/docs/evolcore/fs.md +9 -0
  143. package/kits/docs/evolcore/group-rules.md +46 -4
  144. package/kits/docs/evolcore/group.md +15 -6
  145. package/kits/docs/evolcore/model.md +4 -1
  146. package/kits/docs/evolcore/msg.md +12 -6
  147. package/kits/docs/evolcore/response.md +16 -21
  148. package/kits/docs/evolcore/rpc.md +2 -0
  149. package/kits/docs/evolcore/stats.md +15 -2
  150. package/kits/docs/evolcore/storage.md +1 -0
  151. package/kits/docs/evolcore/trigger.md +42 -10
  152. package/kits/docs/path-registry.md +36 -17
  153. package/kits/eck_manifest.json +12 -0
  154. package/kits/eck_message_manifest.json +12 -1
  155. package/kits/migrations/migrate-contact-book-v2.mjs +754 -0
  156. package/kits/rules/01-overview.md +17 -7
  157. package/kits/rules/02-navigation.md +38 -18
  158. package/kits/rules/03-identity.md +28 -24
  159. package/kits/rules/04-relation.md +44 -28
  160. package/kits/rules/05-venue.md +31 -15
  161. package/kits/rules/06-channel.md +11 -7
  162. package/kits/schemas/_meta.json +21 -7
  163. package/kits/schemas/agent-config.schema.6.json +322 -0
  164. package/kits/schemas/agent-config.schema.7.json +303 -0
  165. package/kits/schemas/agent-config.schema.8.json +304 -0
  166. package/kits/schemas/agent-config.schema.9.json +374 -0
  167. package/kits/schemas/contact-book.schema.2.json +43 -0
  168. package/kits/schemas/contact-book.schema.3.json +67 -0
  169. package/kits/schemas/daemon.schema.1.json +3 -2
  170. package/kits/schemas/daemon.schema.2.json +101 -0
  171. package/kits/schemas/daemon.schema.3.json +123 -0
  172. package/kits/schemas/defaults.schema.2.json +85 -0
  173. package/kits/schemas/defaults.schema.3.json +73 -0
  174. package/kits/schemas/relation-config.schema.5.json +47 -0
  175. package/kits/schemas/relation-config.schema.6.json +46 -0
  176. package/kits/schemas/relation-config.schema.7.json +59 -0
  177. package/kits/schemas/role-config.schema.1.json +1 -0
  178. package/kits/schemas/role-registry.schema.1.json +2 -2
  179. package/kits/schemas/single-session.schema.2.json +57 -0
  180. package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
  181. package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
  182. package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
  183. package/kits/templates/roles/admin.json +47 -0
  184. package/kits/templates/roles/member.json +5 -0
  185. package/kits/templates/roles/owner.json +1 -0
  186. package/kits/templates/roles/visitor.json +5 -0
  187. package/kits/templates/system-fragments/channel.md +12 -2
  188. package/kits/templates/system-fragments/commands.md +3 -1
  189. package/kits/templates/system-fragments/identity.md +3 -1
  190. package/kits/templates/system-fragments/relation.md +1 -1
  191. package/kits/templates/system-fragments/session.md +6 -2
  192. package/package.json +7 -5
  193. package/MIGRATION-0.5.0.md +0 -378
  194. package/ROLE_ACCESS_CONTROL.md +0 -174
  195. package/assets/brand/evolcore/README.md +0 -19
  196. package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
  197. package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
  198. package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
  199. package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
  200. package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
  201. package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
  202. package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
  203. package/assets/brand/evolcore/evolcore-logo.png +0 -0
  204. package/assets/brand/evolcore/evolcore-logo.svg +0 -14
  205. package/assets/brand/evolcore/evolcore-mark.png +0 -0
  206. package/assets/brand/evolcore/evolcore-mark.svg +0 -10
@@ -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,21 +516,107 @@ export class ResponseEngine {
428
516
  if (!session)
429
517
  return;
430
518
  this.turnCoordinator.hydrate(session);
431
- this.turnCoordinator.invalidate(sessionId, reason);
432
- this.interruptedSessions.set(sessionId, reason);
433
- await this.interactionRouter?.cancelAll(sessionId, reason);
434
- const agent = this.getAgent(session.metadata?.channelKey || session.channel, session.baseagent, session.selfAID);
435
- await agent.interrupt(sessionId).catch(error => {
436
- logger.debug(`[ResponseEngine] interrupt barrier runner failure: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
437
- });
438
- const taskId = await this.turnCoordinator.completeInterrupt(session, 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);
524
+ const interruptedTaskId = interruptedTurn?.active?.taskId;
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;
439
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);
440
548
  this.sessionManager.clearProcessingIfTask(sessionId, taskId);
441
549
  if (this.activeTaskRuntimeContexts.get(sessionId)?.taskId === taskId) {
442
550
  this.activeTaskRuntimeContexts.delete(sessionId);
443
551
  }
444
552
  this.agentDelegationRegistry?.revokeTask(sessionId, taskId);
445
553
  }
554
+ this.closeActiveRunnerTasks(sessionId, effectiveReason, taskSpan?.causation ?? runtimeContext?.causation);
555
+ await this.interactionRouter?.cancelAll(sessionId, effectiveReason);
556
+ const channelKey = session.metadata?.channelKey || session.channel;
557
+ const agent = this.getAgent(channelKey, session.baseagent, session.selfAID);
558
+ const terminalStatus = (() => {
559
+ // TimeoutController owns timeout delivery (including user-facing timeout
560
+ // details) and already guarantees a single terminal status. Sending it
561
+ // here as well would race that barrier and duplicate status.timeout.
562
+ if (!interruptedTaskId || effectiveReason === 'timeout')
563
+ return Promise.resolve();
564
+ const channelInfo = this.resolveChannelInfo(channelKey);
565
+ if (!channelInfo)
566
+ return Promise.resolve();
567
+ const envelope = buildEnvelope({
568
+ taskId: interruptedTaskId,
569
+ sessionId: session.id,
570
+ channel: channelInfo.adapter.channelName,
571
+ channelId: session.channelId,
572
+ agentName: agent.name,
573
+ chatmode: this.resolveEffectiveChatmodeForSession(session, channelKey, session.channelId),
574
+ replyContext: {
575
+ ...(session.metadata?.replyContext ?? {}),
576
+ sessionId: session.id,
577
+ threadId: session.threadId || session.metadata?.replyContext?.threadId,
578
+ },
579
+ });
580
+ const payload = {
581
+ kind: 'status.interrupted',
582
+ metadata: { reason: effectiveReason },
583
+ };
584
+ return channelInfo.adapter.send(envelope, payload).then(() => undefined).catch(error => {
585
+ logger.debug(`[ResponseEngine] interrupt terminal status failure: session=${sessionId} task=${interruptedTaskId} error=${error instanceof Error ? error.message : String(error)}`);
586
+ });
587
+ })();
588
+ const runnerInterrupt = agent.interrupt(sessionId).catch(error => {
589
+ logger.debug(`[ResponseEngine] interrupt barrier runner failure: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
590
+ });
591
+ await Promise.all([terminalStatus, runnerInterrupt]);
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
+ });
619
+ }
446
620
  }
447
621
  setHandoffRuntime(runtime) {
448
622
  this.handoffRuntime = runtime;
@@ -460,7 +634,7 @@ export class ResponseEngine {
460
634
  if (!params.sessionId)
461
635
  return { ok: false, code: 'HANDOFF_ID_REQUIRED', error: 'current session is required' };
462
636
  if (this.agentDelegationRegistry) {
463
- const delegation = this.agentDelegationRegistry.validate(params.delegationToken, params.sessionId);
637
+ const delegation = this.agentDelegationRegistry.validate(params.delegationToken, params.sessionId, params.delegationCommandHash);
464
638
  if (!delegation.ok)
465
639
  return { ok: false, code: delegation.code, error: delegation.reason };
466
640
  }
@@ -620,26 +794,44 @@ export class ResponseEngine {
620
794
  if (sessionId) {
621
795
  this.eventBus.publish({ type: 'runner:compact-start', sessionId });
622
796
  }
623
- if (this.currentRenderer && !this.shouldSuppressActivities) {
624
- this.currentRenderer.addNotice('\u23f3 会话压缩中...', 'info', 'compact-start', true);
625
- }
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);
626
808
  }
627
809
  async retryAfterContextRecovery(prompt, opts) {
628
810
  const { streamKey, renderer, agent, session, absoluteProjectPath, effectiveSystemPrompt, modelOverride, runtimeEnv, resetTimer, shouldSuppress, proactive, turnLease, } = opts;
629
811
  if (!session.agentSessionId || !canCompactAgent(agent)) {
630
812
  throw new Error('CONTEXT_COMPACT_FAILED');
631
813
  }
632
- renderer.addNotice('上下文过长,正在压缩会话...', 'warn', 'compact-trigger', true);
814
+ // text event 在进入 renderer 前已经剔除了上下文超限错误行。保留缓冲中的
815
+ // 有效正文,由 sendOperationalNoticeAsText 先 flush,避免恢复过程丢失已有模型输出。
816
+ await this.emitOperationalNotice(renderer, '上下文过长,正在压缩会话...', 'warn', 'compact-trigger');
633
817
  await renderer.flush();
634
- 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
+ }
635
826
  if (compacted) {
636
- renderer.addNotice('✅ 压缩完成,继续处理...', 'info', 'compact-retry', true);
827
+ await this.emitOperationalNotice(renderer, '✅ 压缩完成,继续处理...', 'info', 'compact-retry');
828
+ await renderer.flush();
637
829
  const retryStream = await agent.runQuery(session.id, prompt, absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
638
830
  agent.registerStream(streamKey, retryStream);
639
831
  return await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress, proactive, undefined, // 重试分支不调插件钩子
640
832
  undefined, turnLease);
641
833
  }
642
- renderer.addNotice('⚠️ 压缩失败,尝试清空会话历史后重试...', 'warn', 'compact-failed-clear', true);
834
+ await this.emitOperationalNotice(renderer, '⚠️ 压缩失败,尝试清空会话历史后重试...', 'warn', 'compact-failed-clear');
643
835
  await renderer.flush();
644
836
  if (!canClearAgent(agent)) {
645
837
  throw new Error('CONTEXT_COMPACT_FAILED');
@@ -657,7 +849,8 @@ export class ResponseEngine {
657
849
  }
658
850
  session.agentSessionId = undefined;
659
851
  await this.sessionManager.updateSession(session.id, { agentSessionId: null });
660
- renderer.addNotice('✅ 会话已清空,继续处理...', 'info', 'clear-retry', true);
852
+ await this.emitOperationalNotice(renderer, '✅ 会话已清空,继续处理...', 'info', 'clear-retry');
853
+ await renderer.flush();
661
854
  const retryStream = await agent.runQuery(session.id, '会话历史已清空,请继续之前未完成的任务。', absoluteProjectPath, undefined, undefined, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
662
855
  agent.registerStream(streamKey, retryStream);
663
856
  return await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress, proactive, undefined, // 重试分支不调插件钩子
@@ -878,6 +1071,19 @@ export class ResponseEngine {
878
1071
  const accessAdapter = accessChannelInfo?.adapter;
879
1072
  const accessAdapterSelfAid = typeof accessAdapter?._selfAid === 'function' ? accessAdapter._selfAid() : undefined;
880
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
+ }
881
1087
  const triggerExecutionIdentity = this.resolveTriggerExecutionIdentity(message, selfAidForAccess);
882
1088
  const handoffExecutionIdentity = this.resolveHandoffOriginExecutionIdentity(message, selfAidForAccess);
883
1089
  const executionIdentity = triggerExecutionIdentity ?? handoffExecutionIdentity;
@@ -979,26 +1185,40 @@ export class ResponseEngine {
979
1185
  const shouldSuppress = () => {
980
1186
  return outputState.middleOutputMode === 'none';
981
1187
  };
982
- this.shouldSuppressActivities = shouldSuppress();
983
1188
  let monitor;
984
1189
  let monitorInterval;
985
1190
  let totalExecutionTimer;
986
1191
  let rejectFn;
987
1192
  let lastIdleSec = 0;
988
1193
  let timeoutTriggered = false;
1194
+ const timeoutControl = {
1195
+ claimedByInternal: false,
1196
+ terminalDelivered: false,
1197
+ claim: () => {
1198
+ if (!timeoutControl.error || this.interruptedSessions.get(session.id) !== 'timeout')
1199
+ return undefined;
1200
+ timeoutControl.claimedByInternal = true;
1201
+ return timeoutControl.error;
1202
+ },
1203
+ };
989
1204
  const rejectAfterInterruptBarrier = (error) => {
990
1205
  if (timeoutTriggered)
991
1206
  return;
992
1207
  timeoutTriggered = true;
1208
+ timeoutControl.error = error;
993
1209
  this.timeoutErrors.set(session.id, error);
994
1210
  this.turnCoordinator.hydrate(session);
995
1211
  this.turnCoordinator.invalidate(session.id, 'timeout');
996
1212
  this.interruptedSessions.set(session.id, 'timeout');
997
- void this.interruptSession(session.id, 'timeout')
1213
+ const barrier = this.interruptSession(session.id, 'timeout')
998
1214
  .catch(interruptError => {
999
1215
  logger.debug(`[ResponseEngine] Timeout interrupt barrier failed: ${interruptError instanceof Error ? interruptError.message : String(interruptError)}`);
1000
- })
1001
- .finally(() => rejectFn(error));
1216
+ });
1217
+ timeoutControl.barrier = barrier;
1218
+ void barrier.finally(() => {
1219
+ if (!timeoutControl.claimedByInternal)
1220
+ rejectFn(error);
1221
+ });
1002
1222
  };
1003
1223
  const triggerAttemptId = message.replyContext?.metadata?.triggerAttemptId;
1004
1224
  const resetTimer = (eventType, toolName) => {
@@ -1052,13 +1272,27 @@ export class ResponseEngine {
1052
1272
  totalExecutionTimer.unref?.();
1053
1273
  });
1054
1274
  try {
1275
+ const processingPromise = this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec, outputState, timeoutControl);
1276
+ const guardedProcessingPromise = processingPromise.then(async () => {
1277
+ if (!timeoutControl.error)
1278
+ return;
1279
+ await timeoutControl.barrier;
1280
+ throw timeoutControl.error;
1281
+ });
1055
1282
  await Promise.race([
1056
- this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec, outputState),
1283
+ guardedProcessingPromise,
1057
1284
  timeoutPromise,
1058
1285
  totalExecutionPromise,
1059
1286
  ]);
1060
1287
  }
1061
1288
  catch (error) {
1289
+ if (error instanceof Error && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
1290
+ await timeoutControl.barrier;
1291
+ if (!timeoutControl.terminalDelivered) {
1292
+ await timeoutControl.deliverUnhandled?.(error);
1293
+ }
1294
+ this.timeoutErrors.delete(session.id);
1295
+ }
1062
1296
  // 超时错误:kill 级别已发送诊断信息,无需再发
1063
1297
  // 非超时错误走通用处理
1064
1298
  // 记录错误到健康状态(复用已有 session)
@@ -1111,8 +1345,20 @@ export class ResponseEngine {
1111
1345
  ? configuredSeconds * 1000
1112
1346
  : 60 * 60 * 1000;
1113
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
+ }
1114
1360
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
1115
- async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, outputState = { middleOutputMode: 'all' }) {
1361
+ async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, outputState = { middleOutputMode: 'all' }, timeoutControl) {
1116
1362
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
1117
1363
  const channelKey = this.messageChannelKey(message, session);
1118
1364
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -1169,6 +1415,11 @@ export class ResponseEngine {
1169
1415
  status: 'started',
1170
1416
  refs: { taskId, sessionId: session.id, messageId: message.messageId },
1171
1417
  });
1418
+ this.activeTaskSpans.set(session.id, {
1419
+ taskId,
1420
+ messageId: message.messageId,
1421
+ causation: taskCausation,
1422
+ });
1172
1423
  for (const item of message.items ?? []) {
1173
1424
  const linked = normalizeCausation(item.causation);
1174
1425
  if (!linked || linked.spanId === inputCausation.spanId)
@@ -1193,7 +1444,9 @@ export class ResponseEngine {
1193
1444
  const adapterAny = channelInfo.adapter;
1194
1445
  const adapterSelfAid = typeof adapterAny._selfAid === 'function' ? adapterAny._selfAid() : undefined;
1195
1446
  const selfAid = adapterSelfAid || message.selfAID || session.selfAID || undefined;
1196
- 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;
1197
1450
  const peerName = message.peerName || session.metadata?.peerName;
1198
1451
  const peerIdRaw = message.peerId;
1199
1452
  const triggerExecutionIdentity = this.resolveTriggerExecutionIdentity(message, selfAid);
@@ -1327,7 +1580,6 @@ export class ResponseEngine {
1327
1580
  ? 'none'
1328
1581
  : configuredMiddleOutputMode;
1329
1582
  outputState.middleOutputMode = middleOutputMode;
1330
- this.shouldSuppressActivities = shouldSuppress();
1331
1583
  // 诊断日志:记录 inbound message_id 和生成的 task_id 的对应关系
1332
1584
  logger.info(`[ResponseEngine] Task created: inboundMsgId=${message.messageId ?? 'none'} taskId=${taskId} sessionId=${session.id} chatmode=${chatmode} mode=${resolvedMode?.mode.id ?? 'override/fallback'}`);
1333
1585
  // 构建带 taskId/chatmode 的 ReplyContext(本次任务所有出站消息共用)
@@ -1386,6 +1638,71 @@ export class ResponseEngine {
1386
1638
  replyContext: taskReplyContext(),
1387
1639
  causation: taskCausation,
1388
1640
  });
1641
+ if (timeoutControl) {
1642
+ timeoutControl.deliverUnhandled = async (timeoutError) => {
1643
+ if (timeoutControl.terminalDelivered)
1644
+ return;
1645
+ timeoutControl.terminalDelivered = true;
1646
+ const isTotalExecutionTimeout = timeoutError.message === 'TOTAL_EXECUTION_TIMEOUT';
1647
+ const totalExecutionMs = this.totalExecutionLimitMs();
1648
+ const statusPayload = {
1649
+ kind: 'status.timeout',
1650
+ metadata: isTotalExecutionTimeout
1651
+ ? { totalExecutionMs }
1652
+ : { idleSec: getLastIdleSec?.() || undefined },
1653
+ };
1654
+ const idleSec = getLastIdleSec?.() || 0;
1655
+ const userMessage = isTotalExecutionTimeout
1656
+ ? `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`
1657
+ : idleSec > 0
1658
+ ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断`
1659
+ : '⚠️ 任务超时,已自动中断';
1660
+ agent.cleanupStream(streamKey);
1661
+ this.sessionManager.clearProcessingIfTask(session.id, taskId);
1662
+ if (this.activeTaskRuntimeContexts.get(session.id)?.taskId === taskId) {
1663
+ this.activeTaskRuntimeContexts.delete(session.id);
1664
+ }
1665
+ this.agentDelegationRegistry?.revokeTask(session.id, taskId);
1666
+ const daemonTrigger = this.isTrustedDaemonTrigger(message);
1667
+ if (!daemonTrigger) {
1668
+ await adapter.send(envelope, statusPayload).catch(() => { });
1669
+ }
1670
+ await adapter.send(envelope, {
1671
+ kind: 'result.error',
1672
+ text: userMessage,
1673
+ reason: isTotalExecutionTimeout ? 'total_execution_timeout' : 'timeout',
1674
+ }).catch(() => { });
1675
+ if (daemonTrigger) {
1676
+ await adapter.send(envelope, statusPayload).catch(() => { });
1677
+ }
1678
+ this.touchAgentActivity(channelKey);
1679
+ this.publishTriggerExecutionFailure(message, timeoutError.message, {
1680
+ messageId,
1681
+ causation: taskCausation,
1682
+ });
1683
+ const errorType = prefixErrorType(ERROR_PREFIX.INFRA, classifyError(timeoutError));
1684
+ this.eventBus.publish({
1685
+ type: 'task:error',
1686
+ sessionId: session.id,
1687
+ error: timeoutError.message,
1688
+ errorType,
1689
+ agentName: agentNameForStats,
1690
+ causation: taskCausation,
1691
+ });
1692
+ recordCausationSpan(taskCausation, 'task.run', {
1693
+ status: 'failed',
1694
+ refs: { taskId, sessionId: session.id, messageId },
1695
+ reason: errorType,
1696
+ });
1697
+ logger.message({
1698
+ msgId: messageId,
1699
+ sessionId: session.id,
1700
+ dir: 'inbound',
1701
+ status: 'failed',
1702
+ error: timeoutError.message,
1703
+ });
1704
+ };
1705
+ }
1389
1706
  try {
1390
1707
  const isBackground = this.isBackgroundSession(session, message.channel, message.channelId);
1391
1708
  // 记录收到消息
@@ -1474,19 +1791,32 @@ export class ResponseEngine {
1474
1791
  const peerShort = peerId ? peerId.split('.')[0].split(':')[0] : '?';
1475
1792
  const peerLabel = peerName && peerName !== peerShort ? `${peerShort}(${peerName})` : peerShort;
1476
1793
  logger.info(`[ResponseEngine] session=${session.id} task=${taskId} peer=${peerLabel} chatType=${session.chatType} chatMode=${effectiveChatMode} baseagent=${session.baseagent} msgChatType=${message.chatType ?? 'n/a'}`);
1794
+ // Establish the durable turn before exposing any processing state. This
1795
+ // lets an immediate /stop or replacement message close the same task and
1796
+ // prevents a BusinessTrip reaction from becoming orphaned during setup.
1797
+ turnLease = await this.turnCoordinator.begin(session, taskId);
1477
1798
  // 记录开始处理
1478
1799
  const taskEncrypt = message.replyContext?.metadata?.encrypted != null ? !!(message.replyContext.metadata.encrypted) : undefined;
1479
1800
  this.eventBus.publish({ type: 'task:started', sessionId: session.id, agentName: agentNameForStats, encrypt: taskEncrypt, chatmode, causation: taskCausation });
1480
1801
  this.touchAgentActivity(channelKey);
1802
+ // Upgrade the channel acknowledgement at task start, before compaction and
1803
+ // runner setup, so processing feedback remains visible for the whole run.
1804
+ // Trigger messageId is an internal runId and has no channel reaction.
1805
+ if (message.messageId && message.source !== 'trigger') {
1806
+ adapter.promoteAck?.(message.messageId, {
1807
+ taskId,
1808
+ channelId: message.channelId,
1809
+ messageIds: message.sourceMessageIds,
1810
+ restartResume: message.restartResume?.submitted === true,
1811
+ }).catch(() => { });
1812
+ }
1481
1813
  adapter.send(envelope, { kind: 'status.started' }).catch(() => { });
1482
- await this.runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope, false);
1483
1814
  logger.message({
1484
1815
  msgId: messageId,
1485
1816
  sessionId: session.id,
1486
1817
  dir: 'inbound',
1487
1818
  status: 'processing'
1488
1819
  });
1489
- const startTime = Date.now();
1490
1820
  // 创建 IMRenderer(统一 interactive/proactive 两条路径)
1491
1821
  let firstReply = true;
1492
1822
  const renderer = new IMRenderer({
@@ -1495,6 +1825,7 @@ export class ResponseEngine {
1495
1825
  flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
1496
1826
  suppressActivityItems: isProactive ? false : middleOutputMode !== 'all',
1497
1827
  suppressIntermediateText: isProactive ? false : middleOutputMode === 'none',
1828
+ operationalNoticesAsText: !isProactive && middleOutputMode !== 'none',
1498
1829
  fileMarkerPattern: options?.fileMarkerPattern,
1499
1830
  diagEnabled: this.globalSettings.debug?.flusherDiag,
1500
1831
  send: async (payload) => {
@@ -1538,8 +1869,17 @@ export class ResponseEngine {
1538
1869
  await adapter.send(enrichedEnvelope, payload);
1539
1870
  },
1540
1871
  });
1541
- this.currentRenderer = renderer;
1872
+ this.activeRenderers.set(session.id, {
1873
+ taskId,
1874
+ renderer,
1875
+ suppressActivities: shouldSuppress(),
1876
+ });
1542
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();
1543
1883
  if (isProactive) {
1544
1884
  logger.info(`[ResponseEngine] proactive mode: outputs via thought.put task=${taskId}`);
1545
1885
  }
@@ -1659,7 +1999,6 @@ export class ResponseEngine {
1659
1999
  recordTriggerExecutionAnomaly(triggerRunId, anomaly);
1660
2000
  }
1661
2001
  : undefined;
1662
- turnLease = await this.turnCoordinator.begin(session, taskId);
1663
2002
  // 设置权限审批的交互上下文(支持交互卡片)
1664
2003
  agent.setPermissionContext?.(session.id, {
1665
2004
  sendPrompt: permissionPromptForOrigin,
@@ -1681,6 +2020,14 @@ export class ResponseEngine {
1681
2020
  approvalRouting,
1682
2021
  approvalInteractionPolicy: authorizationIdentity ? 'deny' : 'interactive',
1683
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,
1684
2031
  turn: {
1685
2032
  taskId,
1686
2033
  turnId: turnLease.turnId,
@@ -1985,13 +2332,13 @@ export class ResponseEngine {
1985
2332
  + ` modelSource=${effectiveModelSource ?? 'runner-default'}`
1986
2333
  + ` effort=${effectiveEffort ?? '<runner-default>'}`
1987
2334
  + ` effortSource=${effectiveEffortSource ?? 'runner-default'}`);
1988
- const groupRulesVars = session.chatType === 'group' && currentChannelType === 'aun'
1989
- ? await syncGroupRulesContext({
1990
- selfAid,
1991
- groupId: session.metadata?.groupId || message.channelId,
1992
- channel: currentChannelType || message.channel,
1993
- })
1994
- : {};
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
+ });
1995
2342
  // Kit renderer: 组装上下文
1996
2343
  const pkgRoot = getPackageRoot();
1997
2344
  const kitCtx = {
@@ -2057,6 +2404,7 @@ export class ResponseEngine {
2057
2404
  // Stage 3: sessionKey 持久化字段
2058
2405
  sessionKey: session.sessionKey,
2059
2406
  chatMode: isProactive ? 'proactive' : 'interactive',
2407
+ isTriggerTask: causationPath.length > 0 || message.source === 'trigger',
2060
2408
  // proactive 为 null(interactive 模式,这些开关不适用)时如实为 false——
2061
2409
  // 默认值不在此硬编码,出厂默认由模式 schema 声明、经 buildState 落入 proactive.*。
2062
2410
  proactivePreTool1stMsgChk: proactive?.preTool1stMsgChk ?? false,
@@ -2136,9 +2484,9 @@ export class ResponseEngine {
2136
2484
  if (message.handoffDelivery && this.handoffRuntime) {
2137
2485
  const items = this.handoffRuntime.buildPromptItems(message);
2138
2486
  if (items.length > 0) {
2139
- v2HandoffIds = items
2140
- .map(item => item.handoff?.handoffId)
2141
- .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] : [])))));
2142
2490
  v2HandoffDirection = message.handoffDelivery.direction;
2143
2491
  return items;
2144
2492
  }
@@ -2205,9 +2553,12 @@ export class ResponseEngine {
2205
2553
  this.publishTriggerExecutionSkipped(message, 'empty_prompt', taskCausation);
2206
2554
  return;
2207
2555
  }
2208
- if (v2HandoffIds.length > 0 && v2HandoffDirection === 'origin' && handoffPromptRendered && this.handoffRuntime && selfAid) {
2556
+ if (v2HandoffIds.length > 0 && handoffPromptRendered && this.handoffRuntime && selfAid) {
2209
2557
  for (const handoffId of v2HandoffIds) {
2210
- 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);
2211
2562
  }
2212
2563
  }
2213
2564
  const taskRuntimeContext = {
@@ -2255,6 +2606,7 @@ export class ResponseEngine {
2255
2606
  // 可重试错误(403/429/5xx/模型繁忙)按明确退避序列重试。
2256
2607
  const RETRY_DELAYS_MS = [5_000, 10_000, 30_000];
2257
2608
  const MAX_RETRIES = RETRY_DELAYS_MS.length;
2609
+ const retryAttemptTimeoutMs = this.retryAttemptTimeoutMs();
2258
2610
  let runAttempt = 1;
2259
2611
  let consecutiveRetryFailures = 0;
2260
2612
  const recordRetryHealthError = async (retryError) => {
@@ -2270,19 +2622,26 @@ export class ResponseEngine {
2270
2622
  logger.error('[ResponseEngine] Failed to record retry health status:', statusError);
2271
2623
  }
2272
2624
  };
2273
- // Runner 开始执行前:将 Pin 升级为 CheckMark(表示"正在处理")。
2274
- // Trigger messageId is an internal runId, so there is no channel ack to promote.
2275
- if (message.messageId && message.source !== 'trigger') {
2276
- adapter.promoteAck?.(message.messageId).catch(() => { });
2277
- }
2278
2625
  while (true) {
2279
2626
  let streamRegistered = false;
2627
+ const shouldTimeoutRetryAttempt = consecutiveRetryFailures > 0 && retryAttemptTimeoutMs !== undefined;
2628
+ let attemptTimeout;
2280
2629
  try {
2281
2630
  logger.info(`[ResponseEngine] agent.runQuery start: agent=${agent.name} session=${session.id} task=${taskId} attempt=${runAttempt} consecutiveFailures=${consecutiveRetryFailures} agentSessionId=${session.agentSessionId ?? 'none'}`);
2282
2631
  const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, renderResult?.images.length ? renderResult.images : message.images, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
2283
2632
  agent.registerStream(streamKey, stream);
2284
2633
  streamRegistered = true;
2285
- 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;
2286
2645
  if (streamResult.isError && !streamHitContextLimit(streamResult)) {
2287
2646
  const streamErrorText = getStreamErrorText(streamResult);
2288
2647
  if (streamErrorText && isRetryableError(new Error(streamErrorText))) {
@@ -2298,6 +2657,14 @@ export class ResponseEngine {
2298
2657
  break; // 成功,跳出重试循环
2299
2658
  }
2300
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
+ }
2301
2668
  if (streamRegistered) {
2302
2669
  agent.cleanupStream(streamKey);
2303
2670
  }
@@ -2328,7 +2695,7 @@ export class ResponseEngine {
2328
2695
  runAttempt++;
2329
2696
  continue;
2330
2697
  }
2331
- if (isRetryableError(retryError)) {
2698
+ if (retryAttemptTimedOut || isRetryableError(retryError)) {
2332
2699
  if (didRetryMakeProgress(retryError)) {
2333
2700
  consecutiveRetryFailures = 0;
2334
2701
  }
@@ -2344,7 +2711,7 @@ export class ResponseEngine {
2344
2711
  const nextRetryNumber = consecutiveRetryFailures + 1;
2345
2712
  consecutiveRetryFailures++;
2346
2713
  logger.warn(`[ResponseEngine] Retryable error (attempt ${runAttempt}, consecutive ${consecutiveRetryFailures}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
2347
- renderer.addNotice(`API 不可用,${delay / 1000}秒后重试 ${nextRetryNumber}/${MAX_RETRIES}`, 'warn', 'retry', true);
2714
+ await this.emitOperationalNotice(renderer, `API 不可用,${delay / 1000}秒后重试 ${nextRetryNumber}/${MAX_RETRIES}`, 'warn', 'retry');
2348
2715
  await renderer.flush();
2349
2716
  await new Promise(resolve => setTimeout(resolve, delay));
2350
2717
  // 延迟后再次检查中断状态(延迟期间可能收到中断信号)
@@ -2360,6 +2727,9 @@ export class ResponseEngine {
2360
2727
  }
2361
2728
  throw retryError; // 不可重试或已耗尽重试次数
2362
2729
  }
2730
+ finally {
2731
+ attemptTimeout?.clear();
2732
+ }
2363
2733
  }
2364
2734
  }
2365
2735
  catch (error) {
@@ -2383,6 +2753,10 @@ export class ResponseEngine {
2383
2753
  throw error;
2384
2754
  }
2385
2755
  }
2756
+ const pendingTimeoutError = timeoutControl?.claim();
2757
+ if (pendingTimeoutError) {
2758
+ throw pendingTimeoutError;
2759
+ }
2386
2760
  if (permissionTerminalFailure) {
2387
2761
  renderer.discardPending();
2388
2762
  streamResult = {
@@ -2418,12 +2792,12 @@ export class ResponseEngine {
2418
2792
  // 重试后仍然 prompt_too_long:显示友好提示
2419
2793
  const retryStillTooLong = streamResult.isError && streamHitContextLimit(streamResult);
2420
2794
  if (retryStillTooLong) {
2421
- renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
2795
+ await this.emitOperationalNotice(renderer, getContextTooLongHint(agent), 'warn', 'context-too-long');
2422
2796
  }
2423
2797
  }
2424
2798
  else if (streamResult.isError && streamHitContextLimit(streamResult)) {
2425
2799
  // 上下文过长但无法 auto-compact(无 session ID 或 agent 不支持),显示友好提示
2426
- renderer.addNotice(getContextTooLongHint(agent), 'warn', 'context-too-long', true);
2800
+ await this.emitOperationalNotice(renderer, getContextTooLongHint(agent), 'warn', 'context-too-long');
2427
2801
  }
2428
2802
  if (!streamResult.isError) {
2429
2803
  const commitEvidence = this.buildTurnCommitEvidence(session, streamResult);
@@ -2495,7 +2869,7 @@ export class ResponseEngine {
2495
2869
  if (!this.turnCoordinator.isCurrent(turnLease))
2496
2870
  return 0;
2497
2871
  // 仅支持公开文件标记语法:[SEND_FILE:path] 或 [SEND_FILE:channel:path]。
2498
- const SEND_FILE_RE = options?.fileMarkerPattern ?? /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
2872
+ const SEND_FILE_RE = options?.fileMarkerPattern ?? createSendFileMarkerPattern();
2499
2873
  const sendFileMatches = [...scanText.matchAll(SEND_FILE_RE)];
2500
2874
  if (sendFileMatches.length === 0)
2501
2875
  return 0;
@@ -2591,6 +2965,7 @@ export class ResponseEngine {
2591
2965
  }
2592
2966
  // 最终回复文本:suppressed 模式或无 text 事件时需要兜底添加
2593
2967
  const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
2968
+ const visibleFinalReplyText = stripContextLimitErrorLines(finalReplyText);
2594
2969
  if (!this.turnCoordinator.isCurrent(turnLease)) {
2595
2970
  agent.cleanupStream(streamKey);
2596
2971
  this.sessionManager.clearProcessingIfTask(session.id, taskId);
@@ -2602,9 +2977,11 @@ export class ResponseEngine {
2602
2977
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, 'stale_turn', taskCausation);
2603
2978
  return;
2604
2979
  }
2605
- if (finalReplyText) {
2606
- if (shouldSuppress() || !streamResult.hasReceivedText) {
2607
- renderer.addText(finalReplyText);
2980
+ if (visibleFinalReplyText) {
2981
+ // `hasReceivedText` 是任务级计数。工具调用前的文字已经显示,并不表示最后一轮
2982
+ // (通常来自 complete.result)的文字也已显示;用本轮投影标记避免终态丢失。
2983
+ if (shouldSuppress() || !streamResult.hasProjectedCurrentReplyText) {
2984
+ renderer.addText(visibleFinalReplyText);
2608
2985
  }
2609
2986
  }
2610
2987
  // 先清理流和处理中状态(保证即使 flush 卡住,session 也不会永久处于"处理中")
@@ -2632,7 +3009,7 @@ export class ResponseEngine {
2632
3009
  }
2633
3010
  // 被用户中断(新消息打断)时跳过 flush — 新 task 已接管渠道,旧 task 的 flush 无意义且可能卡住
2634
3011
  const preFlushInterrupt = this.interruptedSessions.get(session.id);
2635
- if (preFlushInterrupt === 'new_message' || preFlushInterrupt === 'stop' || preFlushInterrupt === 'recalled') {
3012
+ if (isNonErrorInterruptReason(preFlushInterrupt)) {
2636
3013
  logger.info(`[ResponseEngine] Skipping flush for interrupted task=${taskId} reason=${preFlushInterrupt}`);
2637
3014
  }
2638
3015
  else {
@@ -2673,13 +3050,13 @@ export class ResponseEngine {
2673
3050
  // 用户主动打断(新消息/​/stop/​撤回)会让 SDK 流在工具调用中途被掐断,
2674
3051
  // 末尾 result message 形状异常并被标记为 error(含 SDK 内部 ede_diagnostic 串)。
2675
3052
  // 这不是真正的失败,不应把诊断串暴露给用户,也不计入错误统计。
2676
- const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
2677
- if (!isUserInterrupt && !permissionTerminalFailure) {
3053
+ const isExpectedInterrupt = isNonErrorInterruptReason(interruptReason);
3054
+ if (!isExpectedInterrupt && !permissionTerminalFailure) {
2678
3055
  await adapter.send(envelope, { kind: 'result.error', text: userErrorSummary, reason: rawSubtype }).catch(() => { });
2679
3056
  await adapter.send(envelope, { kind: 'status.error', metadata: { errorType: rawSubtype } }).catch(() => { });
2680
3057
  this.touchAgentActivity(channelKey);
2681
3058
  }
2682
- if (isUserInterrupt) {
3059
+ if (isExpectedInterrupt) {
2683
3060
  // 用户打断:打断本身已由 message-queue 发过 task:interrupted 事件,
2684
3061
  // 这里不再补发 task:error(否则同一次打断被记两遍且错误归类为 error)。
2685
3062
  // 仅记 info 日志收尾。注意:task:interrupted 已填充 interruptedSessions,
@@ -2696,6 +3073,8 @@ export class ResponseEngine {
2696
3073
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, interruptReason ?? 'interrupted', taskCausation);
2697
3074
  }
2698
3075
  else {
3076
+ logger.error(`[ResponseEngine] Task failed: session=${session.id} task=${taskId} ` +
3077
+ `type=${errorType} terminalReason=${streamResult.terminalReason ?? 'none'} error=${errorSummary}`);
2699
3078
  this.publishTriggerExecutionFailure(message, errorSummary, { messageId, causation: taskCausation });
2700
3079
  this.eventBus.publish({
2701
3080
  type: 'task:error',
@@ -2734,6 +3113,12 @@ export class ResponseEngine {
2734
3113
  return;
2735
3114
  }
2736
3115
  const durationMs = Date.now() - startTime;
3116
+ const statsModel = streamResult.lastModelCall?.model
3117
+ || streamResult.model
3118
+ || streamResult.contextUsage?.model
3119
+ || modelOverride?.model
3120
+ || agentModel
3121
+ || 'unknown';
2737
3122
  // ── Stats: 写入 usage_events(在 status.completed 之前,以便带上 cost) ──
2738
3123
  // cost 含原价(official)与网关实际价(gateway)两套,由 insertUsageEvent 写库时一并算出。
2739
3124
  let turnCost = { official: null, gateway: null };
@@ -2745,7 +3130,6 @@ export class ResponseEngine {
2745
3130
  || (configActorId
2746
3131
  ? formatPeerKey(configChannelType, configActorId)
2747
3132
  : formatPeerKey(configChannelType, message.channelId));
2748
- const statsModel = streamResult.contextUsage?.model || 'unknown';
2749
3133
  const ctxPct = streamResult.contextUsage?.percentage;
2750
3134
  const event = normalizeUsage(streamResult.tokenUsage, {
2751
3135
  ts: Date.now(),
@@ -2803,7 +3187,6 @@ export class ResponseEngine {
2803
3187
  try {
2804
3188
  const { resolveModelSpec } = await import('../../stats/billing.js');
2805
3189
  const { querySessionSummary } = await import('../../stats/query.js');
2806
- const statsModel = streamResult.contextUsage?.model || 'unknown';
2807
3190
  modelSpec = resolveModelSpec(resolveRoot(), statsModel);
2808
3191
  const sum = querySessionSummary(resolveRoot(), session.id);
2809
3192
  if (sum.calls > 0) {
@@ -2848,7 +3231,7 @@ export class ResponseEngine {
2848
3231
  let lastModelCall = streamResult.lastModelCall;
2849
3232
  if (lastModelCall?.tokenUsage) {
2850
3233
  try {
2851
- const lastModel = lastModelCall.model || streamResult.contextUsage?.model || 'unknown';
3234
+ const lastModel = lastModelCall.model || statsModel;
2852
3235
  const lastEvent = normalizeUsage(lastModelCall.tokenUsage, {
2853
3236
  ts: Date.now(), agent_aid: '', peer_key: '', session_id: session.id,
2854
3237
  model: lastModel, turns: 1,
@@ -2950,7 +3333,14 @@ export class ResponseEngine {
2950
3333
  });
2951
3334
  }
2952
3335
  catch (error) {
2953
- if (error instanceof Error && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
3336
+ const authoritativeTimeoutError = timeoutControl?.claim();
3337
+ if (authoritativeTimeoutError) {
3338
+ error = authoritativeTimeoutError;
3339
+ await timeoutControl?.barrier;
3340
+ }
3341
+ const handlingTimeout = error instanceof Error
3342
+ && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT');
3343
+ if (handlingTimeout) {
2954
3344
  this.timeoutErrors.delete(session.id);
2955
3345
  }
2956
3346
  const leaseWasCurrent = !!turnLease && this.turnCoordinator.isCurrent(turnLease);
@@ -2978,10 +3368,7 @@ export class ResponseEngine {
2978
3368
  && interruptReason !== 'timeout'
2979
3369
  && !isTimeout
2980
3370
  && !isTotalExecutionTimeout;
2981
- const isUserInterrupt = interruptReason === 'new_message'
2982
- || interruptReason === 'stop'
2983
- || interruptReason === 'recalled'
2984
- || staleSupersededTurn;
3371
+ const isUserInterrupt = isNonErrorInterruptReason(interruptReason) || staleSupersededTurn;
2985
3372
  const errorMsg = error instanceof Error ? error.message : String(error);
2986
3373
  if (isUserInterrupt) {
2987
3374
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, interruptReason ?? 'stale_turn', taskCausation);
@@ -3055,7 +3442,7 @@ export class ResponseEngine {
3055
3442
  if (isUserInterrupt) {
3056
3443
  logger.info(`[ResponseEngine] User interrupt by new_message, skip sending error message`);
3057
3444
  }
3058
- else if (error?._errorAlreadySent && !retryExhaustedCount) {
3445
+ else if (error?._errorAlreadySent && !retryExhaustedCount && !isTimeout && !isTotalExecutionTimeout) {
3059
3446
  logger.info(`[ResponseEngine] Error already sent via renderer, skip sending duplicate message`);
3060
3447
  }
3061
3448
  else {
@@ -3103,6 +3490,15 @@ export class ResponseEngine {
3103
3490
  }).catch(() => { });
3104
3491
  }
3105
3492
  }
3493
+ if (handlingTimeout && timeoutControl) {
3494
+ timeoutControl.terminalDelivered = true;
3495
+ }
3496
+ }
3497
+ finally {
3498
+ // 同一 session 的新任务可能已替换登记;旧任务结束时不得删掉新任务的 renderer。
3499
+ if (this.activeRenderers.get(session.id)?.taskId === taskId) {
3500
+ this.activeRenderers.delete(session.id);
3501
+ }
3106
3502
  }
3107
3503
  // [迁移探针] 任务收尾:记录工具提醒最终状态并落盘(防线 1)
3108
3504
  if (snapshot.isEnabled()) {
@@ -3112,7 +3508,7 @@ export class ResponseEngine {
3112
3508
  snapshot.end(session.id, taskId);
3113
3509
  }
3114
3510
  }
3115
- async runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope, suppressOutput = false) {
3511
+ async runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, renderer) {
3116
3512
  if (!session.agentSessionId || !canCompactAgent(agent)) {
3117
3513
  logger.debug(`[ResponseEngine] Auto compact skipped: session=${session.id} agentSessionId=${session.agentSessionId || 'none'} canCompact=${canCompactAgent(agent)} agent=${agent.name}`);
3118
3514
  return;
@@ -3123,15 +3519,14 @@ export class ResponseEngine {
3123
3519
  return;
3124
3520
  }
3125
3521
  logger.info(`[ResponseEngine] Auto compact at task.start: session=${session.id} totalTokens=${ctx.totalTokens} autoCompactTokens=${ctx.autoCompactTokens}`);
3126
- if (!suppressOutput) {
3127
- await adapter.send(envelope, { kind: 'system.notice', text: '上下文接近上限,正在压缩会话...', subtype: 'auto-compact-start' }).catch(() => { });
3128
- }
3522
+ await this.emitOperationalNotice(renderer, '上下文接近上限,正在压缩会话...', 'info', 'auto-compact-start');
3523
+ await renderer.flush();
3524
+ this.announcedCompactStarts.add(session.id);
3129
3525
  try {
3130
3526
  const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
3131
3527
  if (compacted) {
3132
- if (!suppressOutput) {
3133
- await adapter.send(envelope, { kind: 'system.notice', text: '✅ 上下文压缩完成,继续处理...', subtype: 'auto-compact-complete' }).catch(() => { });
3134
- }
3528
+ await this.emitOperationalNotice(renderer, '✅ 上下文压缩完成,继续处理...', 'info', 'auto-compact-complete');
3529
+ await renderer.flush();
3135
3530
  }
3136
3531
  else {
3137
3532
  logger.warn(`[ResponseEngine] Auto compact at task.start returned false (session=${session.id})`);
@@ -3140,6 +3535,9 @@ export class ResponseEngine {
3140
3535
  catch (err) {
3141
3536
  logger.warn(`[ResponseEngine] Auto compact at task.start failed (non-fatal):`, err);
3142
3537
  }
3538
+ finally {
3539
+ this.announcedCompactStarts.delete(session.id);
3540
+ }
3143
3541
  }
3144
3542
  async readLastModelCallContextUsage(sessionId, agentSessionId) {
3145
3543
  try {
@@ -3206,17 +3604,28 @@ export class ResponseEngine {
3206
3604
  const owningAgent = this.agentRegistry?.resolveByChannel(message.channel);
3207
3605
  const projectPath = owningAgent?.projectPath || process.cwd();
3208
3606
  const resolvedBaseagent = message.baseagent || owningAgent?.baseagent || (!this.agentRegistry ? this.inferPrimaryBaseagent() : undefined);
3209
- const resolvedIdentity = this.inferDirectSessionIdentity(message, owningAgent);
3607
+ // 优先复用 MessageBridge 入队前已解析的身份,避免二次独立解析产生角色漂移
3608
+ // (曾观测到瞬时竞态下把 owner 误判为 none,导致合法用户被静默拒绝)。
3609
+ // 仅当消息不经 MessageBridge.register()(如 trigger 直接投递)时才回退重新解析。
3610
+ const resolvedIdentity = message.resolvedIdentity ?? this.inferDirectSessionIdentity(message, owningAgent);
3210
3611
  if (!resolvedBaseagent) {
3211
3612
  throw new Error(`[ResponseEngine] resolveSession: baseagent could not be determined (message.baseagent=${message.baseagent}, owningAgent=${owningAgent?.name || 'none'})`);
3212
3613
  }
3213
3614
  // 话题创建权限守卫已统一移至 MessageBridge.canCreateThreadSession(enqueue 前拦截),
3214
3615
  // 此处不再重复检查——bridge 层拒绝后消息根本不会到达 processMessage。
3215
- // current strategy: resume bound session, make it active so output is not suppressed
3216
- if (message.triggerMeta?.boundSessionId) {
3217
- 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);
3218
3621
  if (bound) {
3219
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;
3220
3629
  if (bound.threadId) {
3221
3630
  const absoluteProjectPath = path.isAbsolute(bound.projectPath)
3222
3631
  ? bound.projectPath : path.resolve(process.cwd(), bound.projectPath);
@@ -3225,14 +3634,22 @@ export class ResponseEngine {
3225
3634
  const switched = await this.sessionManager.switchToSession(bound.channel, bound.channelId, bound.id);
3226
3635
  if (switched) {
3227
3636
  this.ensureSessionBaseagent(switched, resolvedBaseagent);
3637
+ if (resolvedIdentity)
3638
+ switched.identity = resolvedIdentity;
3228
3639
  const absoluteProjectPath = path.isAbsolute(switched.projectPath)
3229
3640
  ? switched.projectPath : path.resolve(process.cwd(), switched.projectPath);
3230
3641
  return { session: switched, absoluteProjectPath };
3231
3642
  }
3643
+ if (message.boundSessionId) {
3644
+ throw new Error(`[ResponseEngine] Bound session ${bound.id} could not be activated`);
3645
+ }
3232
3646
  logger.warn(`[ResponseEngine] switchToSession failed for bound session ${bound.id}, falling back to latest`);
3233
3647
  }
3234
3648
  else {
3235
- 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`);
3236
3653
  }
3237
3654
  }
3238
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);
@@ -3291,6 +3708,7 @@ export class ResponseEngine {
3291
3708
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
3292
3709
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
3293
3710
  let hasReceivedText = false;
3711
+ let hasProjectedCurrentReplyText = false;
3294
3712
  let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
3295
3713
  let madeProgress = false;
3296
3714
  let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
@@ -3304,6 +3722,7 @@ export class ResponseEngine {
3304
3722
  const finalizeResult = () => ({
3305
3723
  ...completeResult,
3306
3724
  hasReceivedText,
3725
+ hasProjectedCurrentReplyText,
3307
3726
  inputAccepted,
3308
3727
  assistantUuid,
3309
3728
  protocolIncompleteReason,
@@ -3374,7 +3793,7 @@ export class ResponseEngine {
3374
3793
  assistantUuid = event.assistantUuid ?? assistantUuid;
3375
3794
  protocolIncompleteReason = event.protocolIncompleteReason ?? protocolIncompleteReason;
3376
3795
  }
3377
- // 记录事件类型:高价值事件(text/tool_use/tool_result/complete/error/compact/task_progress)INFO,
3796
+ // 记录事件类型:高价值事件(含子任务生命周期)INFO,
3378
3797
  // 框架事件(session_id/state_changed/status)DEBUG
3379
3798
  let eventDetail = '';
3380
3799
  if (event.type === 'text' && event.text) {
@@ -3429,18 +3848,144 @@ export class ResponseEngine {
3429
3848
  });
3430
3849
  continue;
3431
3850
  }
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
+ });
3865
+ this.eventBus.publish({
3866
+ type: 'runner:task-started',
3867
+ sessionId: session.id,
3868
+ taskId: event.taskId,
3869
+ taskKind: event.taskKind,
3870
+ toolUseId: event.toolUseId,
3871
+ description: event.description,
3872
+ subagentType: event.subagentType,
3873
+ taskType: event.taskType,
3874
+ workflowName: event.workflowName,
3875
+ prompt: event.prompt,
3876
+ skipTranscript: event.skipTranscript,
3877
+ timestamp: Date.now(),
3878
+ causation,
3879
+ });
3880
+ if (!event.skipTranscript) {
3881
+ renderer.addLifecycle('started', {
3882
+ scope: 'runner_task',
3883
+ taskId: event.taskId,
3884
+ taskKind: event.taskKind,
3885
+ toolUseId: event.toolUseId,
3886
+ description: event.description,
3887
+ subagentType: event.subagentType,
3888
+ taskType: event.taskType,
3889
+ }, `${event.taskKind === 'agent' ? 'Agent' : 'Background'} task started: ${event.description}`);
3890
+ }
3891
+ continue;
3892
+ }
3893
+ if (event.type === 'background_tasks_changed') {
3894
+ this.eventBus.publish({
3895
+ type: 'runner:background-tasks-changed',
3896
+ sessionId: session.id,
3897
+ tasks: event.tasks,
3898
+ timestamp: Date.now(),
3899
+ causation,
3900
+ });
3901
+ continue;
3902
+ }
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);
3908
+ this.eventBus.publish({
3909
+ type: 'runner:task-notification',
3910
+ sessionId: session.id,
3911
+ taskId: event.taskId,
3912
+ taskKind: event.taskKind,
3913
+ toolUseId: event.toolUseId,
3914
+ description: event.description,
3915
+ subagentType: event.subagentType,
3916
+ status: event.status,
3917
+ outputFile: event.outputFile,
3918
+ summary: event.summary,
3919
+ usage: event.usage,
3920
+ skipTranscript: event.skipTranscript,
3921
+ timestamp: Date.now(),
3922
+ causation,
3923
+ });
3924
+ if (!event.skipTranscript) {
3925
+ const label = event.description || event.taskId;
3926
+ renderer.addLifecycle('completed', {
3927
+ scope: 'runner_task',
3928
+ taskId: event.taskId,
3929
+ taskKind: event.taskKind,
3930
+ toolUseId: event.toolUseId,
3931
+ description: event.description,
3932
+ subagentType: event.subagentType,
3933
+ status: event.status,
3934
+ outputFile: event.outputFile,
3935
+ summary: event.summary,
3936
+ usage: event.usage,
3937
+ }, `${event.taskKind === 'agent' ? 'Agent' : 'Background'} task ${event.status}: ${label}`);
3938
+ }
3939
+ continue;
3940
+ }
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
+ }
3952
+ this.eventBus.publish({
3953
+ type: 'runner:task-progress',
3954
+ sessionId: session.id,
3955
+ taskId: event.taskId,
3956
+ taskKind: event.taskKind,
3957
+ toolUseId: event.toolUseId,
3958
+ description: event.description,
3959
+ subagentType: event.subagentType,
3960
+ summary: event.summary,
3961
+ lastToolName: event.lastToolName,
3962
+ totalTokens: event.totalTokens,
3963
+ toolUses: event.toolUses,
3964
+ durationMs: event.durationMs,
3965
+ timestamp: Date.now(),
3966
+ causation,
3967
+ });
3968
+ }
3432
3969
  const isCurrentlyBackground = this.isBackgroundSession(session, session.channel, session.channelId);
3433
3970
  // === 前台任务:正常处理所有事件 ===
3434
3971
  if (!isCurrentlyBackground) {
3435
3972
  // 流式文本
3436
3973
  if (event.type === 'text') {
3437
- hasReceivedText = true;
3438
- if (event.text.trim())
3439
- 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
+ }
3440
3982
  lastReplyText += event.text;
3441
- this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
3442
- if (!shouldSuppress()) {
3443
- 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;
3444
3989
  }
3445
3990
  }
3446
3991
  // compact 完成
@@ -3448,7 +3993,7 @@ export class ResponseEngine {
3448
3993
  madeProgress = true;
3449
3994
  this.eventBus.publish({ type: 'runner:compact-complete', sessionId: session.id, preTokens: event.preTokens });
3450
3995
  if (!shouldSuppress()) {
3451
- renderer.addNotice(`\ud83d\udca1 会话压缩完成,继续执行...)`, 'info', 'compact');
3996
+ await this.emitOperationalNotice(renderer, '\ud83d\udca1 会话压缩完成,继续执行...', 'info', 'compact');
3452
3997
  }
3453
3998
  }
3454
3999
  // 子任务进度
@@ -3474,6 +4019,7 @@ export class ResponseEngine {
3474
4019
  }
3475
4020
  // 重置最后回复追踪
3476
4021
  lastReplyText = '';
4022
+ hasProjectedCurrentReplyText = false;
3477
4023
  this.eventBus.publish({
3478
4024
  type: 'tool:use',
3479
4025
  sessionId: session.id,
@@ -3570,14 +4116,14 @@ export class ResponseEngine {
3570
4116
  // 仅记录状态,最终 flush(true) 在流结束后统一执行
3571
4117
  if (event.type === 'complete') {
3572
4118
  const isAbort = event.terminalReason === 'aborted_streaming' || event.terminalReason === 'aborted_tools';
3573
- logger.info(`[ResponseEngine] ${isAbort ? 'task interrupted' : 'complete event'}: isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
4119
+ logger.info(`[ResponseEngine] ${isAbort ? 'task interrupted' : 'complete event'}: queryFinal=${event.queryFinal !== false} isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
3574
4120
  // 自动回填会话名称
3575
4121
  if (event.sessionTitle && shouldAutoFillSessionTitle(session.name, session.threadId)) {
3576
4122
  await this.sessionManager.renameSession(session.id, event.sessionTitle);
3577
4123
  logger.info(`[ResponseEngine] Auto-filled session name: ${event.sessionTitle}`);
3578
4124
  }
3579
4125
  // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
3580
- 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 };
4126
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, model: event.model, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall, modelCalls: event.modelCalls };
3581
4127
  if (!event.isError)
3582
4128
  madeProgress = true;
3583
4129
  // thought jsonl 写入已下沉到 aun.ts:sendThought 成功后,
@@ -3586,7 +4132,7 @@ export class ResponseEngine {
3586
4132
  // 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
3587
4133
  // 上下文过长的错误留给外层 isPromptTooLong 触发 auto-compact,不在此处输出
3588
4134
  const interruptReason = this.interruptedSessions.get(session.id);
3589
- const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
4135
+ const isUserInterrupt = isNonErrorInterruptReason(interruptReason);
3590
4136
  const isContextTooLong = event.terminalReason === 'prompt_too_long'
3591
4137
  || isContextTooLongText(event.errors?.join(' ') || '')
3592
4138
  || isContextTooLongText(lastReplyText);
@@ -3613,7 +4159,7 @@ export class ResponseEngine {
3613
4159
  }
3614
4160
  // 检测 proactive 标志位,设置 lastProactiveFlag 供模式切换提示使用
3615
4161
  // [迁移点5] 标志位检查:由 ProactiveMode.onComplete 实现
3616
- if (modeHooks?.mode?.onComplete && lastReplyText) {
4162
+ if (event.queryFinal !== false && modeHooks?.mode?.onComplete && lastReplyText) {
3617
4163
  await modeHooks.mode.onComplete({
3618
4164
  session,
3619
4165
  state: modeHooks.state,
@@ -3647,7 +4193,7 @@ export class ResponseEngine {
3647
4193
  logger.info(`[ResponseEngine] Auto-filled session name: ${event.sessionTitle}`);
3648
4194
  }
3649
4195
  // 记录完成状态
3650
- 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 };
4196
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, model: event.model, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall };
3651
4197
  if (event.subtype === 'success')
3652
4198
  madeProgress = true;
3653
4199
  }
@@ -3655,7 +4201,7 @@ export class ResponseEngine {
3655
4201
  catch (error) {
3656
4202
  // User interrupt (AbortError) is expected, log at info level
3657
4203
  const catchInterruptReason = this.interruptedSessions.get(session.id);
3658
- const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop' || catchInterruptReason === 'recalled';
4204
+ const catchIsUserInterrupt = isNonErrorInterruptReason(catchInterruptReason);
3659
4205
  if (catchInterruptReason === 'timeout') {
3660
4206
  logger.info('[ResponseEngine] Stream closed by timeout barrier');
3661
4207
  throw this.timeoutErrors.get(session.id) ?? new Error('SDK_TIMEOUT');