evolcore 0.0.39 → 0.0.40

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 (102) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +31 -20
  3. package/bin/ec-safe-output.js +14 -2
  4. package/dist/agents/baseagent-availability.js +1 -0
  5. package/dist/agents/claude-runner.js +32 -46
  6. package/dist/agents/codex-runner.js +51 -27
  7. package/dist/agents/ecagent-runner.js +18 -7
  8. package/dist/agents/gemini-runner.js +1 -2
  9. package/dist/agents/runner-types.js +28 -6
  10. package/dist/aun/aid/client.js +1 -1
  11. package/dist/aun/aid/control-aid.js +23 -98
  12. package/dist/aun/aid/ownership-operation.js +1 -3
  13. package/dist/aun/group/invite-service.js +6 -0
  14. package/dist/aun/msg/p2p.js +8 -12
  15. package/dist/channels/aun.js +66 -48
  16. package/dist/channels/dingtalk.js +8 -2
  17. package/dist/channels/feishu.js +6 -1
  18. package/dist/channels/wecom.js +7 -3
  19. package/dist/cli/agent-create.js +23 -4
  20. package/dist/cli/aid-bind-command.js +4 -1
  21. package/dist/cli/daemon-commands.js +242 -17
  22. package/dist/cli/index.js +25 -13
  23. package/dist/cli/memory-command.js +33 -14
  24. package/dist/cli/restart-monitor.js +35 -13
  25. package/dist/cli/task-context.js +1 -0
  26. package/dist/config/config-field-policy.js +1 -0
  27. package/dist/contracts/ecweb-service-protocol.js +44 -0
  28. package/dist/core/agent-runtime.js +6 -0
  29. package/dist/core/auth/agent-delegation.js +7 -4
  30. package/dist/core/auth/auth-gateway.js +3 -1
  31. package/dist/core/auth/authorization-audit.js +5 -1
  32. package/dist/core/auth/debug-context.js +5 -1
  33. package/dist/core/auth/operation-catalog.js +16 -0
  34. package/dist/core/command/command-handler.js +7 -1
  35. package/dist/core/command/menu-catalog.js +48 -12
  36. package/dist/core/command/menu-handler.js +72 -4
  37. package/dist/core/command/menu-protocol.js +20 -1
  38. package/dist/core/command/slash-gate.js +17 -3
  39. package/dist/core/command/slash-handler.js +90 -7
  40. package/dist/core/event-catalog.js +2 -0
  41. package/dist/core/interaction-router.js +2 -1
  42. package/dist/core/ipc-registration.js +26 -7
  43. package/dist/core/memory/batch-meta.js +2 -0
  44. package/dist/core/memory/consolidation-context.js +42 -51
  45. package/dist/core/memory/consolidation-ops-records.js +39 -7
  46. package/dist/core/memory/consolidation-trigger.js +71 -64
  47. package/dist/core/memory/consolidation.js +226 -20
  48. package/dist/core/memory/memory-writer.js +160 -34
  49. package/dist/core/memory/relation-idle-monitor.js +287 -0
  50. package/dist/core/memory/relation-sweep.js +206 -0
  51. package/dist/core/message/aun-ipc-sender.js +5 -0
  52. package/dist/core/message/message-bridge.js +64 -55
  53. package/dist/core/message/message-log.js +17 -0
  54. package/dist/core/message/message-queue.js +6 -0
  55. package/dist/core/message/outbound.js +70 -0
  56. package/dist/core/message/peer-mode.js +7 -2
  57. package/dist/core/message/response-engine.js +341 -73
  58. package/dist/core/ownership/owner-resolver.js +384 -178
  59. package/dist/core/permission/ec-command-parser.js +19 -3
  60. package/dist/core/permission/tool-policy.js +7 -7
  61. package/dist/core/role/runtime-policy.js +64 -0
  62. package/dist/core/session/session-manager.js +25 -5
  63. package/dist/core/system-session/memory.js +3 -18
  64. package/dist/ecweb/basic-settings-service.js +709 -0
  65. package/dist/ecweb/providers-service.js +172 -0
  66. package/dist/index.js +92 -4
  67. package/dist/ipc.js +78 -11
  68. package/dist/stats/db.js +33 -0
  69. package/dist/stats/index.js +2 -2
  70. package/dist/stats/query.js +246 -0
  71. package/dist/stats/writer.js +41 -1
  72. package/dist/trigger/application-service.js +12 -0
  73. package/dist/trigger/authorization.js +2 -2
  74. package/dist/trigger/history.js +36 -0
  75. package/dist/trigger/scheduler.js +3 -0
  76. package/dist/utils/ecweb-supervisor.js +31 -10
  77. package/dist/utils/error-utils.js +8 -4
  78. package/dist/utils/evolcore-version.js +3 -2
  79. package/dist/utils/host-info.js +52 -0
  80. package/ecagent/dist/harness/agent-harness.js +4 -1
  81. package/kits/docs/evolcore/INDEX.md +1 -1
  82. package/kits/docs/evolcore/agent.md +1 -1
  83. package/kits/docs/evolcore/aid.md +29 -12
  84. package/kits/docs/evolcore/config.md +9 -7
  85. package/kits/docs/evolcore/memory-architecture.md +3 -4
  86. package/kits/docs/evolcore/memory-consolidation.md +28 -7
  87. package/kits/docs/evolcore/memory-tags.md +1 -1
  88. package/kits/docs/evolcore/memory.md +1 -1
  89. package/kits/docs/evolcore/menu-client.md +1 -1
  90. package/kits/docs/evolcore/menu-protocol.md +6 -0
  91. package/kits/docs/evolcore/msg.md +1 -1
  92. package/kits/eck_manifest.json +3 -3
  93. package/kits/rules/04-relation.md +3 -2
  94. package/kits/schemas/agent-config.schema.13.json +5 -1
  95. package/kits/templates/memory-consolidation/generalize.md +32 -4
  96. package/kits/templates/memory-consolidation/notes.md +2 -2
  97. package/kits/templates/memory-consolidation/promote.md +10 -2
  98. package/kits/templates/system-fragments/baseagent.md +5 -0
  99. package/kits/templates/system-fragments/memory.md +12 -3
  100. package/kits/templates/system-fragments/session.md +1 -1
  101. package/kits/{rules/07-memory-and-thread.md → templates/system-fragments/thread.md} +17 -80
  102. package/package.json +3 -3
@@ -47,7 +47,7 @@ export { buildEnvelope, sendInteractionPayload } from './message-utils.js';
47
47
  import { constrainResolvedModelForRole, isModelAllowedForRoleBaseagent } from '../model/model-permission.js';
48
48
  import { eligibleFallbackModels } from '../model/model-fallback.js';
49
49
  import { RetryScheduler } from './retry-scheduler.js';
50
- import { constrainRuntimePermissionMode, resolveRuntimeStringField } from '../role/runtime-policy.js';
50
+ import { assertSessionFullAccessAuthorization, constrainRuntimePermissionMode, resolveRuntimeStringField } from '../role/runtime-policy.js';
51
51
  import { resolveEffective, resolveEffectiveFieldWithSource } from '../../config/config-manager.js';
52
52
  import { resolveAgentLifecycle } from '../../config/lifecycle.js';
53
53
  import { authorizationConfigRevision, checkRoleAccess, getFirstStaticAgentOwner, listStaticAgentAdmins, listStaticAgentOwners, resolvePeerRoleDetail, roleToSessionIdentity, } from '../../config/peer-role-resolver.js';
@@ -84,6 +84,7 @@ export class AsyncQuestionService {
84
84
  deps;
85
85
  registered = new Map();
86
86
  deliveries = new Map();
87
+ blockingWaiters = new Map();
87
88
  constructor(deps) {
88
89
  this.deps = deps;
89
90
  }
@@ -115,10 +116,11 @@ export class AsyncQuestionService {
115
116
  const value = JSON.parse(fs.readFileSync(this.file(selfAid, id), 'utf8'));
116
117
  if (value.version !== 1 || value.selfAid !== selfAid || value.id !== id
117
118
  || !value.sessionId || !value.providerThreadId || !value.itemId
118
- || !value.origin?.peerId || !value.channelKey || !Number.isFinite(value.expiresAt))
119
+ || !value.origin?.peerId || !value.channelKey
120
+ || (value.expiresAt !== undefined && !Number.isFinite(value.expiresAt)))
119
121
  return undefined;
120
122
  parseAsyncUserQuestions([value.question]);
121
- return value;
123
+ return { ...value, blocking: value.blocking === true };
122
124
  }
123
125
  catch (error) {
124
126
  if (error.code !== 'ENOENT')
@@ -126,6 +128,25 @@ export class AsyncQuestionService {
126
128
  return undefined;
127
129
  }
128
130
  }
131
+ waitForBlocking(id) {
132
+ return new Promise((resolve, reject) => {
133
+ this.blockingWaiters.set(id, { resolve, reject });
134
+ });
135
+ }
136
+ resolveBlocking(id) {
137
+ const waiter = this.blockingWaiters.get(id);
138
+ if (!waiter)
139
+ return;
140
+ this.blockingWaiters.delete(id);
141
+ waiter.resolve();
142
+ }
143
+ rejectBlocking(id, reason) {
144
+ const waiter = this.blockingWaiters.get(id);
145
+ if (!waiter)
146
+ return;
147
+ this.blockingWaiters.delete(id);
148
+ waiter.reject(new Error(`用户交互已结束(${reason})`));
149
+ }
129
150
  async present(session, request, message, adapter, envelope) {
130
151
  if (!session.selfAID || !message.peerId)
131
152
  throw new Error('Async question requires an authenticated route');
@@ -158,13 +179,19 @@ export class AsyncQuestionService {
158
179
  threadId: sessionTopicThreadId(session), selfAID: session.selfAID,
159
180
  },
160
181
  envelope: { ...envelope, sessionId: session.id, replyContext, delivery: replyContext.delivery },
161
- channelKey: adapter.channelKey, createdAt: Date.now(), expiresAt: Date.now() + QUESTION_TTL_MS,
182
+ channelKey: adapter.channelKey,
183
+ createdAt: Date.now(),
184
+ ...(question.blocking === true ? {} : { expiresAt: Date.now() + QUESTION_TTL_MS }),
185
+ blocking: question.blocking !== false,
162
186
  status: 'pending',
163
187
  };
164
188
  if (!existing)
165
189
  this.save(record);
166
190
  records.push(record);
167
191
  }
192
+ const blockingWaits = records
193
+ .filter(record => record.blocking)
194
+ .map(record => ({ record, promise: this.waitForBlocking(record.id) }));
168
195
  // Persist the complete batch before dispatch; one failed card must not lose the other questions.
169
196
  const errors = [];
170
197
  for (const record of records) {
@@ -173,10 +200,14 @@ export class AsyncQuestionService {
173
200
  }
174
201
  catch (error) {
175
202
  errors.push(error);
203
+ this.rejectBlocking(record.id, '卡片投递失败');
176
204
  }
177
205
  }
178
- if (errors.length)
206
+ if (errors.length) {
207
+ await Promise.allSettled(blockingWaits.map(wait => wait.promise));
179
208
  throw new AggregateError(errors, 'Async question delivery failed; pending questions retained');
209
+ }
210
+ await Promise.all(blockingWaits.map(wait => wait.promise));
180
211
  }
181
212
  async restore(adapter, selfAid) {
182
213
  let names;
@@ -197,7 +228,7 @@ export class AsyncQuestionService {
197
228
  continue;
198
229
  if (this.registered.get(record.id) === adapter)
199
230
  continue;
200
- if (record.expiresAt <= Date.now()) {
231
+ if (record.expiresAt !== undefined && record.expiresAt <= Date.now()) {
201
232
  fs.unlinkSync(this.file(selfAid, record.id));
202
233
  continue;
203
234
  }
@@ -257,18 +288,24 @@ export class AsyncQuestionService {
257
288
  }
258
289
  }
259
290
  async deliverOnce(record, adapter) {
260
- if (record.expiresAt <= Date.now() || !(await this.validSession(record))) {
291
+ if ((record.expiresAt !== undefined && record.expiresAt <= Date.now()) || !(await this.validSession(record))) {
261
292
  record.status = 'cancelled';
262
293
  record.reason = 'expired_or_binding_changed';
263
294
  this.save(record);
295
+ this.rejectBlocking(record.id, record.reason);
264
296
  return;
265
297
  }
266
298
  const options = record.question.options ?? [];
267
299
  const interaction = {
268
300
  type: 'interaction', id: record.id, sessionId: record.sessionId, channelId: record.origin.channelId,
269
- initiatorId: record.origin.peerId, blocking: false, expiresAt: record.expiresAt,
301
+ initiatorId: record.origin.peerId,
302
+ ...(record.blocking ? { blocking: true } : { blocking: false }),
303
+ ...(record.expiresAt !== undefined ? { expiresAt: record.expiresAt } : {}),
270
304
  kind: {
271
305
  kind: 'action', title: '请补充信息', body: record.question.title, bodyFormat: 'plain',
306
+ ...(record.question.recommendedOption
307
+ ? { recommendedOption: `opt-${options.indexOf(record.question.recommendedOption)}` }
308
+ : {}),
272
309
  buttons: options.map((label, index) => ({ key: `opt-${index}`, label })),
273
310
  allowCustomInput: true,
274
311
  },
@@ -282,13 +319,24 @@ export class AsyncQuestionService {
282
319
  this.registered.set(record.id, adapter);
283
320
  this.deps.router.register(record.id, record.sessionId, async (action, values) => {
284
321
  const current = this.read(record.selfAid, record.id);
285
- if (!current || current.status !== 'pending' || current.expiresAt <= Date.now()) {
322
+ if (!current || current.status !== 'pending'
323
+ || (current.expiresAt !== undefined && current.expiresAt <= Date.now())) {
286
324
  await this.deps.router.cancel(record.id, 'expired_or_resolved');
287
325
  return false;
288
326
  }
289
327
  const answer = this.answerText(current.question, action, values);
290
- if (!answer)
328
+ if (!answer) {
329
+ if (action === 'cancel' || action === 'dismiss' || values?.behavior === 'dismiss') {
330
+ current.status = 'cancelled';
331
+ current.reason = 'user_cancelled';
332
+ this.save(current);
333
+ this.registered.delete(record.id);
334
+ this.rejectBlocking(record.id, current.reason);
335
+ await adapter.invalidateInteraction?.(record.id, current.reason);
336
+ return true;
337
+ }
291
338
  return false;
339
+ }
292
340
  const session = await this.validSession(current);
293
341
  if (!session) {
294
342
  await this.deps.router.cancel(record.id, 'binding_or_authorization_changed');
@@ -305,6 +353,13 @@ export class AsyncQuestionService {
305
353
  current.status = 'answered';
306
354
  this.save(current);
307
355
  this.registered.delete(record.id);
356
+ this.resolveBlocking(record.id);
357
+ try {
358
+ await adapter.invalidateInteraction?.(record.id, 'answered');
359
+ }
360
+ catch (error) {
361
+ logger.debug(`[AsyncQuestions] card invalidation after answer failed: request=${record.id} error=${String(error)}`);
362
+ }
308
363
  logger.info(`[AsyncQuestions] answer accepted: session=${record.sessionId} request=${record.id}`);
309
364
  return true;
310
365
  }
@@ -312,12 +367,16 @@ export class AsyncQuestionService {
312
367
  current.status = 'uncertain';
313
368
  current.reason = 'submission_failed';
314
369
  this.save(current);
370
+ this.rejectBlocking(record.id, '答案提交失败');
315
371
  logger.warn(`[AsyncQuestions] answer submission failed: request=${record.id} error=${String(error)}`);
316
372
  throw error;
317
373
  }
318
374
  }, {
319
375
  initiatorId: record.origin.peerId, initiatorChannelKey: adapter.channelKey,
320
- blocking: false, retainOnRejected: true, fallbackCommand: 'ask', timeoutMs: record.expiresAt - Date.now(),
376
+ blocking: record.blocking,
377
+ retainOnRejected: true,
378
+ fallbackCommand: 'ask',
379
+ ...(record.expiresAt !== undefined ? { timeoutMs: Math.max(1, record.expiresAt - Date.now()) } : {}),
321
380
  onCancel: async (reason) => {
322
381
  this.registered.delete(record.id);
323
382
  if (reason === 'daemon_restart')
@@ -328,6 +387,7 @@ export class AsyncQuestionService {
328
387
  current.reason = reason;
329
388
  this.save(current);
330
389
  }
390
+ this.rejectBlocking(record.id, reason);
331
391
  await adapter.invalidateInteraction?.(record.id, reason);
332
392
  },
333
393
  });
@@ -432,6 +492,7 @@ const TOOL_INPUT_DIAGNOSTIC_MAX_LENGTH = 96_000;
432
492
  const TOOL_INPUT_DIAGNOSTIC_MAX_FINDINGS = 3;
433
493
  const TOOL_INPUT_DIAGNOSTIC_MAX_SHELL_DEPTH = 4;
434
494
  const TOOL_INPUT_DIAGNOSTIC_TIMEOUT_MS = 1_000;
495
+ const PROACTIVE_CIRCUIT_BREAK_DRAIN_TIMEOUT_MS = 2_000;
435
496
  function isToolResultFailure(event) {
436
497
  return event.isError === true
437
498
  || event.executionState === 'failed'
@@ -871,12 +932,13 @@ class RetryAttemptTimeoutError extends Error {
871
932
  function createRetryAttemptTimeout(timeoutMs) {
872
933
  let timer;
873
934
  let settled = false;
935
+ let paused = false;
874
936
  let rejectTimeout;
875
937
  const promise = new Promise((_, reject) => {
876
938
  rejectTimeout = reject;
877
939
  });
878
940
  const arm = () => {
879
- if (settled)
941
+ if (settled || paused)
880
942
  return;
881
943
  if (timer)
882
944
  clearTimeout(timer);
@@ -891,8 +953,23 @@ function createRetryAttemptTimeout(timeoutMs) {
891
953
  return {
892
954
  promise,
893
955
  reset: arm,
956
+ pause: () => {
957
+ if (settled || paused)
958
+ return;
959
+ paused = true;
960
+ if (timer)
961
+ clearTimeout(timer);
962
+ timer = undefined;
963
+ },
964
+ resume: () => {
965
+ if (settled || !paused)
966
+ return;
967
+ paused = false;
968
+ arm();
969
+ },
894
970
  clear: () => {
895
971
  settled = true;
972
+ paused = false;
896
973
  if (timer)
897
974
  clearTimeout(timer);
898
975
  timer = undefined;
@@ -979,7 +1056,8 @@ function isNonErrorInterruptReason(reason) {
979
1056
  return reason === 'new_message'
980
1057
  || reason === 'stop'
981
1058
  || reason === 'recalled'
982
- || reason === 'daemon_restart';
1059
+ || reason === 'daemon_restart'
1060
+ || reason === 'proactive_circuit_break';
983
1061
  }
984
1062
  function isExpectedTerminalInterrupt(reason) {
985
1063
  return isAbortTerminalReason(reason);
@@ -1109,7 +1187,7 @@ function getStreamRawErrorText(result, fallback) {
1109
1187
  fullTextLooksLikeDiagnostic ? fullText : undefined,
1110
1188
  fallbackLooksLikeDiagnostic ? fallback : undefined,
1111
1189
  ];
1112
- return candidates.find((part) => typeof part === 'string' && part.trim().length > 0)?.trim();
1190
+ return candidates.find((part) => typeof part === 'string' && part.trim().length > 0);
1113
1191
  }
1114
1192
  function getStreamErrorMessage(result, includeEmoji = false) {
1115
1193
  const raw = getStreamErrorText(result);
@@ -1366,7 +1444,13 @@ export class ResponseEngine {
1366
1444
  agentName: error.evolagentName,
1367
1445
  chatmode: this.resolveEffectiveChatmodeForSession(session, channelKey, channelId),
1368
1446
  replyContext: sessionReplyContext,
1369
- }), { kind: 'system.error', text, subtype: 'baseagent_unavailable', recoverable: true });
1447
+ }), {
1448
+ kind: 'system.error',
1449
+ text,
1450
+ raw: error.message,
1451
+ subtype: 'baseagent_unavailable',
1452
+ recoverable: true,
1453
+ });
1370
1454
  }
1371
1455
  resolveEffectiveChatmodeForSession(session, channelKey, channelId) {
1372
1456
  const peerType = session.metadata?.peerType;
@@ -2333,6 +2417,22 @@ export class ResponseEngine {
2333
2417
  && !!message.bgSessionMeta?.runId;
2334
2418
  }
2335
2419
  bgSessionTerminalMessages = new WeakSet();
2420
+ /**
2421
+ * 后台会话本次运行的模型:解析出请求模型时写 requestedModel,runner 回报结果后写
2422
+ * actualModel,三个 bg-session 终态事件发布时读出。按入站 message 归属,随消息回收,
2423
+ * 不落盘、不跨会话;只服务终态事件,其它路径不要读。
2424
+ */
2425
+ bgSessionModels = new WeakMap();
2426
+ recordBgSessionModels(message, models) {
2427
+ if (message.source !== 'bg-session')
2428
+ return;
2429
+ const current = this.bgSessionModels.get(message) ?? {};
2430
+ this.bgSessionModels.set(message, {
2431
+ ...current,
2432
+ ...(models.requestedModel ? { requestedModel: models.requestedModel } : {}),
2433
+ ...(models.actualModel ? { actualModel: models.actualModel } : {}),
2434
+ });
2435
+ }
2336
2436
  claimBgSessionTerminal(message) {
2337
2437
  const meta = message.bgSessionMeta;
2338
2438
  if (message.source !== 'bg-session' || !meta?.runId || this.bgSessionTerminalMessages.has(message)) {
@@ -2352,6 +2452,7 @@ export class ResponseEngine {
2352
2452
  runId,
2353
2453
  sessionId: meta.boundSessionId ?? '',
2354
2454
  messageId: opts.messageId ?? message.messageId ?? runId,
2455
+ ...this.bgSessionModels.get(message),
2355
2456
  error,
2356
2457
  targetChannel: message.channel,
2357
2458
  targetChannelId: message.channelId,
@@ -2369,6 +2470,7 @@ export class ResponseEngine {
2369
2470
  runId,
2370
2471
  sessionId: meta.boundSessionId ?? '',
2371
2472
  messageId: message.messageId ?? runId,
2473
+ ...this.bgSessionModels.get(message),
2372
2474
  reason,
2373
2475
  targetChannel: message.channel,
2374
2476
  targetChannelId: message.channelId,
@@ -2387,6 +2489,7 @@ export class ResponseEngine {
2387
2489
  sessionId: meta.boundSessionId ?? '',
2388
2490
  messageId,
2389
2491
  durationMs,
2492
+ ...this.bgSessionModels.get(message),
2390
2493
  targetChannel: message.channel,
2391
2494
  targetChannelId: message.channelId,
2392
2495
  causation: causation ?? message.causation,
@@ -2429,7 +2532,7 @@ export class ResponseEngine {
2429
2532
  * them immediately before every Runner invocation, including retries.
2430
2533
  */
2431
2534
  async assertCurrentFullAccessExecutionAuthorization(authorization, agent) {
2432
- await refreshOwnershipForAuthorization(authorization.agentAid);
2535
+ await refreshOwnershipForAuthorization(authorization.agentAid, { allowTransient: true });
2433
2536
  if (authorization.permissionMode !== 'fullaccess'
2434
2537
  || authorization.processRole !== 'fullaccess-run'
2435
2538
  || authorization.dataScope !== 'daemon'
@@ -2444,6 +2547,12 @@ export class ResponseEngine {
2444
2547
  if (authorization.source === 'agent-policy') {
2445
2548
  assertAgentPolicyAuthorization(authorization);
2446
2549
  }
2550
+ else if (authorization.source === 'session-policy') {
2551
+ const currentSession = this.sessionManager.getSessionById
2552
+ ? await this.sessionManager.getSessionById(authorization.sessionId ?? '')
2553
+ : undefined;
2554
+ assertSessionFullAccessAuthorization(authorization, currentSession);
2555
+ }
2447
2556
  else if (authorization.source !== 'fullaccess-command' && authorization.source !== 'trigger') {
2448
2557
  throw new Error('fullaccess execution authorization has an unsupported source');
2449
2558
  }
@@ -2686,7 +2795,7 @@ export class ResponseEngine {
2686
2795
  * 处理消息(主入口)
2687
2796
  */
2688
2797
  async processMessage(message) {
2689
- const fallbackFullAccessAudit = this.resolveFullAccessAuditCandidate(message);
2798
+ const fallbackFullAccessAudit = await this.resolveFullAccessAuditCandidate(message);
2690
2799
  let outerFailureReason;
2691
2800
  try {
2692
2801
  await this.processMessageWithMonitors(message);
@@ -2707,7 +2816,9 @@ export class ResponseEngine {
2707
2816
  event: fallbackFullAccessAudit.authorization.source === 'trigger'
2708
2817
  ? 'fullaccess.trigger.execution.ended'
2709
2818
  : fallbackFullAccessAudit.authorization.source === 'agent-policy'
2710
- ? 'fullaccess.agent-policy.execution.ended' : 'fullaccess.execution.ended',
2819
+ ? 'fullaccess.agent-policy.execution.ended'
2820
+ : fallbackFullAccessAudit.authorization.source === 'session-policy'
2821
+ ? 'fullaccess.session-policy.execution.ended' : 'fullaccess.execution.ended',
2711
2822
  source: fallbackFullAccessAudit.authorization.source,
2712
2823
  actorId: fallbackFullAccessAudit.authorization.authorizedBy,
2713
2824
  processRole: fallbackFullAccessAudit.processRole,
@@ -2774,7 +2885,10 @@ export class ResponseEngine {
2774
2885
  const accessAdapter = accessChannelInfo?.adapter;
2775
2886
  const accessAdapterSelfAid = typeof accessAdapter?._selfAid === 'function' ? accessAdapter._selfAid() : undefined;
2776
2887
  const selfAidForAccess = accessAdapterSelfAid || message.selfAID || session.selfAID || undefined;
2777
- await refreshOwnershipForAuthorization(selfAidForAccess, { maxAgeMs: OWNERSHIP_CACHE_MAX_AGE_MS });
2888
+ await refreshOwnershipForAuthorization(selfAidForAccess, {
2889
+ maxAgeMs: OWNERSHIP_CACHE_MAX_AGE_MS,
2890
+ allowTransient: true,
2891
+ });
2778
2892
  if (message.handoffDelivery?.direction === 'target'
2779
2893
  && this.handoffRuntime
2780
2894
  && selfAidForAccess) {
@@ -2823,9 +2937,10 @@ export class ResponseEngine {
2823
2937
  // ── 角色访问控制检查:读取该用户角色的 allowAccess 配置,false 则拦截并回复权限不足 ──
2824
2938
  const userRole = session.identity?.role || 'none';
2825
2939
  const isInternalHandoff = message.source === 'handoff';
2826
- const fullAccessAuditCandidate = this.resolveFullAccessAuditCandidate(message);
2940
+ const fullAccessAuditCandidate = await this.resolveFullAccessAuditCandidate(message);
2827
2941
  const isCurrentFullAccessCommand = (fullAccessAuditCandidate?.authorization.source === 'fullaccess-command'
2828
- || fullAccessAuditCandidate?.authorization.source === 'agent-policy')
2942
+ || fullAccessAuditCandidate?.authorization.source === 'agent-policy'
2943
+ || fullAccessAuditCandidate?.authorization.source === 'session-policy')
2829
2944
  && fullAccessAuditCandidate.processRole === 'fullaccess-run';
2830
2945
  const isCurrentFullAccessTrigger = fullAccessAuditCandidate?.authorization.source === 'trigger'
2831
2946
  && fullAccessAuditCandidate.processRole === 'fullaccess-run';
@@ -2976,6 +3091,35 @@ export class ResponseEngine {
2976
3091
  activityRecorder?.call(channelInfo.adapter, triggerAttemptId);
2977
3092
  }
2978
3093
  };
3094
+ let totalExecutionDeadline;
3095
+ let totalExecutionPausedRemaining;
3096
+ const pauseTotalExecutionTimer = () => {
3097
+ if (totalExecutionTimer === undefined || totalExecutionDeadline === undefined)
3098
+ return;
3099
+ totalExecutionPausedRemaining = Math.max(0, totalExecutionDeadline - Date.now());
3100
+ clearTimeout(totalExecutionTimer);
3101
+ totalExecutionTimer = undefined;
3102
+ };
3103
+ const resumeTotalExecutionTimer = () => {
3104
+ if (totalExecutionTimer !== undefined || totalExecutionPausedRemaining === undefined)
3105
+ return;
3106
+ const remaining = totalExecutionPausedRemaining;
3107
+ totalExecutionPausedRemaining = undefined;
3108
+ totalExecutionDeadline = Date.now() + remaining;
3109
+ totalExecutionTimer = setTimeout(() => {
3110
+ logger.warn(`[ResponseEngine] Total execution timeout after ${totalExecutionMs}ms, stream: ${streamKey}`);
3111
+ rejectAfterInterruptBarrier(new Error('TOTAL_EXECUTION_TIMEOUT'));
3112
+ }, remaining);
3113
+ totalExecutionTimer.unref?.();
3114
+ };
3115
+ timeoutControl.pauseForInteraction = () => {
3116
+ monitor?.pause();
3117
+ pauseTotalExecutionTimer();
3118
+ };
3119
+ timeoutControl.resumeForInteraction = () => {
3120
+ monitor?.resume();
3121
+ resumeTotalExecutionTimer();
3122
+ };
2979
3123
  // Cache background status to avoid async call inside setInterval
2980
3124
  const isBackground = this.isBackgroundSession(session, message.channel, message.channelId);
2981
3125
  const timeoutPromise = new Promise((_, reject) => {
@@ -3028,6 +3172,7 @@ export class ResponseEngine {
3028
3172
  const totalExecutionPromise = totalExecutionMs === undefined
3029
3173
  ? undefined
3030
3174
  : new Promise((_, reject) => {
3175
+ totalExecutionDeadline = Date.now() + totalExecutionMs;
3031
3176
  totalExecutionTimer = setTimeout(() => {
3032
3177
  logger.warn(`[ResponseEngine] Total execution timeout after ${totalExecutionMs}ms, stream: ${streamKey}`);
3033
3178
  rejectAfterInterruptBarrier(new Error('TOTAL_EXECUTION_TIMEOUT'));
@@ -3095,12 +3240,14 @@ export class ResponseEngine {
3095
3240
  this.activeTimeoutHandlers.delete(session.id);
3096
3241
  }
3097
3242
  }
3098
- resolveFullAccessAuditCandidate(message) {
3243
+ async resolveFullAccessAuditCandidate(message) {
3099
3244
  const commandAuthorization = message.executionPermissionOverride;
3100
3245
  if (commandAuthorization?.permissionMode === 'fullaccess'
3101
3246
  && commandAuthorization.processRole === 'fullaccess-run'
3102
3247
  && commandAuthorization.dataScope === 'daemon'
3103
- && (commandAuthorization.source === 'fullaccess-command' || commandAuthorization.source === 'agent-policy')
3248
+ && (commandAuthorization.source === 'fullaccess-command'
3249
+ || commandAuthorization.source === 'agent-policy'
3250
+ || commandAuthorization.source === 'session-policy')
3104
3251
  && typeof commandAuthorization.authorizedBy === 'string'
3105
3252
  && commandAuthorization.authorizedBy.length > 0) {
3106
3253
  let currentOwner = false;
@@ -3108,6 +3255,12 @@ export class ResponseEngine {
3108
3255
  currentOwner = new Set(getDaemonOwners()).has(commandAuthorization.authorizedBy);
3109
3256
  if (commandAuthorization.source === 'agent-policy')
3110
3257
  assertAgentPolicyAuthorization(commandAuthorization);
3258
+ if (commandAuthorization.source === 'session-policy') {
3259
+ const session = this.sessionManager.getSessionById
3260
+ ? await this.sessionManager.getSessionById(commandAuthorization.sessionId ?? '')
3261
+ : undefined;
3262
+ assertSessionFullAccessAuthorization(commandAuthorization, session);
3263
+ }
3111
3264
  }
3112
3265
  catch {
3113
3266
  currentOwner = false;
@@ -3575,17 +3728,23 @@ export class ResponseEngine {
3575
3728
  if (!timeoutControl.claimTerminal())
3576
3729
  return;
3577
3730
  const recoverable = timeoutControl.recoverable === true;
3731
+ const rawErrorText = getErrorRawText(timeoutError);
3578
3732
  const isTotalExecutionTimeout = timeoutError.message === 'TOTAL_EXECUTION_TIMEOUT';
3579
3733
  const totalExecutionMs = timeoutError instanceof ExecutionTimeoutError ? timeoutError.timeoutMs : this.totalExecutionLimitMs();
3580
3734
  const statusPayload = timeoutError instanceof OrphanedTargetSessionError ? {
3581
3735
  kind: 'status.error',
3582
- metadata: { errorType: timeoutError.reason, message: '任务执行状态丢失,已结束本次执行。' },
3736
+ metadata: {
3737
+ errorType: timeoutError.reason,
3738
+ message: '任务执行状态丢失,已结束本次执行。',
3739
+ ...(rawErrorText ? { raw: rawErrorText } : {}),
3740
+ },
3583
3741
  } : {
3584
3742
  kind: 'status.timeout',
3585
3743
  metadata: isTotalExecutionTimeout
3586
3744
  ? { ...(totalExecutionMs === undefined ? {} : { totalExecutionMs }),
3587
- ...(timeoutError instanceof ExecutionTimeoutError ? { reason: timeoutError.reason } : {}) }
3588
- : { idleSec: getLastIdleSec?.() || undefined },
3745
+ ...(timeoutError instanceof ExecutionTimeoutError ? { reason: timeoutError.reason } : {}),
3746
+ ...(rawErrorText ? { raw: rawErrorText } : {}) }
3747
+ : { idleSec: getLastIdleSec?.() || undefined, ...(rawErrorText ? { raw: rawErrorText } : {}) },
3589
3748
  };
3590
3749
  const idleSec = getLastIdleSec?.() || 0;
3591
3750
  const userMessage = timeoutError instanceof OrphanedTargetSessionError
@@ -3612,7 +3771,7 @@ export class ResponseEngine {
3612
3771
  kind: 'result.error',
3613
3772
  text: userMessage,
3614
3773
  recoverable,
3615
- ...(getErrorRawText(timeoutError) ? { raw: getErrorRawText(timeoutError) } : {}),
3774
+ ...(rawErrorText ? { raw: rawErrorText } : {}),
3616
3775
  subtype: normalizeCoreErrorSubtype(timeoutError, undefined, isTotalExecutionTimeout ? 'timeout' : 'timeout'),
3617
3776
  reason: isExecutionFailure(timeoutError)
3618
3777
  ? normalizeCoreErrorReason(timeoutError.reason)
@@ -3674,6 +3833,46 @@ export class ResponseEngine {
3674
3833
  let fullAccessExecutionFailed = false;
3675
3834
  let fullAccessExecutionFailureReason;
3676
3835
  let activePermissionContext;
3836
+ let proactiveCircuitBreakRequested = false;
3837
+ let proactiveCircuitBreakInterruptPromise;
3838
+ let proactiveCircuitBreakTimer;
3839
+ const interruptProactiveCircuitBreak = () => {
3840
+ if (proactiveCircuitBreakInterruptPromise)
3841
+ return proactiveCircuitBreakInterruptPromise;
3842
+ if (!proactiveCircuitBreakRequested
3843
+ || this.interruptedSessions.get(session.id) !== 'proactive_circuit_break'
3844
+ || (turnLease && !this.turnCoordinator.isCurrent(turnLease)))
3845
+ return Promise.resolve();
3846
+ this.interruptedSessions.set(session.id, 'proactive_circuit_break');
3847
+ if (proactiveCircuitBreakTimer) {
3848
+ clearTimeout(proactiveCircuitBreakTimer);
3849
+ proactiveCircuitBreakTimer = undefined;
3850
+ }
3851
+ const interruptPromise = (async () => {
3852
+ await agent.interrupt(session.id).catch(error => {
3853
+ logger.debug(`[ResponseEngine] circuit break interrupt failed: session=${session.id} error=${error instanceof Error ? error.message : String(error)}`);
3854
+ });
3855
+ })();
3856
+ proactiveCircuitBreakInterruptPromise = interruptPromise;
3857
+ return interruptPromise;
3858
+ };
3859
+ const requestProactiveCircuitBreak = () => {
3860
+ if (proactiveCircuitBreakRequested)
3861
+ return;
3862
+ proactiveCircuitBreakRequested = true;
3863
+ this.interruptedSessions.set(session.id, 'proactive_circuit_break');
3864
+ void adapter.send(withEnvelopeReplyContext(envelope, taskReplyContext()), {
3865
+ kind: 'result.text',
3866
+ text: '⚠️ 任务执行长时间不反馈,已被自动中断。请重新发言继续。',
3867
+ isFinal: true,
3868
+ }).catch(error => {
3869
+ logger.warn(`[ResponseEngine] circuit break channel send failed: session=${session.id} error=${error instanceof Error ? error.message : String(error)}`);
3870
+ });
3871
+ proactiveCircuitBreakTimer = setTimeout(() => {
3872
+ void interruptProactiveCircuitBreak();
3873
+ }, PROACTIVE_CIRCUIT_BREAK_DRAIN_TIMEOUT_MS);
3874
+ proactiveCircuitBreakTimer.unref?.();
3875
+ };
3677
3876
  try {
3678
3877
  const isBackground = this.isBackgroundSession(session, message.channel, message.channelId);
3679
3878
  // 记录收到消息
@@ -4127,6 +4326,15 @@ export class ResponseEngine {
4127
4326
  ? systemSessionModule.runnerLaunch(normalizeBaseagent(agent.name).canonical, systemSessionContext).sandbox
4128
4327
  : undefined;
4129
4328
  const pureSessionPolicyHook = systemSessionMarker ? undefined : modePolicyHook;
4329
+ const purePreToolUsePolicyHook = pureSessionPolicyHook
4330
+ ? (toolName, toolInput) => proactiveCircuitBreakRequested
4331
+ ? {
4332
+ block: true,
4333
+ policyCode: 'proactive_circuit_break',
4334
+ reason: '任务已触发主动模式熔断,工具调用已停止',
4335
+ }
4336
+ : pureSessionPolicyHook(toolName, toolInput)
4337
+ : undefined;
4130
4338
  // 路径授权(仅用户会话):身份就是角色名;运行时授予(ec ctl path grant)按 sessionId 再并一层。
4131
4339
  const pathGrantIdentity = systemSessionMarker ? undefined : (peerRole || undefined);
4132
4340
  const grantedReadRoots = (() => {
@@ -4168,7 +4376,8 @@ export class ResponseEngine {
4168
4376
  permissionMode: effectivePermissionMode,
4169
4377
  recordExecutionAnomaly,
4170
4378
  pauseController: taskPauseController,
4171
- preToolUsePolicyHook: pureSessionPolicyHook,
4379
+ preToolUsePolicyHook: purePreToolUsePolicyHook,
4380
+ onProactiveCircuitBreak: requestProactiveCircuitBreak,
4172
4381
  systemSessionCheck,
4173
4382
  ...(systemSessionModule && systemSessionContext
4174
4383
  ? {
@@ -4278,6 +4487,13 @@ export class ResponseEngine {
4278
4487
  if (!pluginHook)
4279
4488
  return undefined;
4280
4489
  return (toolName, toolInput, meta) => {
4490
+ if (proactiveCircuitBreakRequested) {
4491
+ return {
4492
+ block: true,
4493
+ policyCode: 'proactive_circuit_break',
4494
+ reason: '任务已触发主动模式熔断,工具调用已停止',
4495
+ };
4496
+ }
4281
4497
  const stBefore = modeState.get('proactive');
4282
4498
  const wasFirstPending = stBefore && !stBefore.firstToolDone;
4283
4499
  const result = pluginHook(toolName, toolInput, meta);
@@ -4287,19 +4503,9 @@ export class ResponseEngine {
4287
4503
  if (result?.block) {
4288
4504
  // 拦截事件(首工具非表态 / 工具进展汇报未完成)
4289
4505
  snapshot.set(session.id, taskId, { policyHook: { triggered: true, blocked: true, toolName } });
4290
- // 问题1/2修复:熔断码表示连续违规已超阈值,停止注入提醒,直接中断任务。
4291
- // F5修复:向渠道发一条人类可读的错误说明,避免静默中断。
4506
+ // 熔断后拒绝新的工具调用,当前调用收尾后再中断流。
4292
4507
  if (result.policyCode === 'proactive_circuit_break') {
4293
- void adapter.send(withEnvelopeReplyContext(envelope, capturedReplyContext), {
4294
- kind: 'result.text',
4295
- text: '⚠️ 任务执行遇到循环障碍,已自动中断。请重新描述你的需求。',
4296
- isFinal: true,
4297
- }).catch(err => {
4298
- logger.warn(`[ResponseEngine] circuit break channel send failed: session=${session.id} error=${err instanceof Error ? err.message : String(err)}`);
4299
- });
4300
- void agent.interrupt(session.id).catch(error => {
4301
- logger.debug(`[ResponseEngine] circuit break interrupt failed: session=${session.id} error=${error instanceof Error ? error.message : String(error)}`);
4302
- });
4508
+ requestProactiveCircuitBreak();
4303
4509
  return result;
4304
4510
  }
4305
4511
  const errorMsg = `⚠️ proactive 模式违规:${result.reason ?? '请先发送必要说明'}。请重新执行正确的命令。`;
@@ -4390,7 +4596,7 @@ export class ResponseEngine {
4390
4596
  if (commandFullAccess.permissionMode !== 'fullaccess'
4391
4597
  || commandFullAccess.processRole !== 'fullaccess-run'
4392
4598
  || commandFullAccess.dataScope !== 'daemon'
4393
- || (commandFullAccess.source !== 'fullaccess-command' && commandFullAccess.source !== 'agent-policy')
4599
+ || (commandFullAccess.source !== 'fullaccess-command' && commandFullAccess.source !== 'agent-policy' && commandFullAccess.source !== 'session-policy')
4394
4600
  || !commandFullAccess.authorizedBy) {
4395
4601
  throw new Error('fullaccess command authorization is missing or malformed');
4396
4602
  }
@@ -4403,6 +4609,10 @@ export class ResponseEngine {
4403
4609
  || authenticatedActor.operatorId !== commandFullAccess.policyActor?.operatorId)) {
4404
4610
  throw new Error('Agent fullaccess policy authorization is missing its Agent identity');
4405
4611
  }
4612
+ if (commandFullAccess.source === 'session-policy'
4613
+ && (commandFullAccess.sessionId !== session.id || configChatType !== 'private')) {
4614
+ throw new Error('Session fullaccess policy authorization is missing its EC session identity');
4615
+ }
4406
4616
  fullAccessAuthorization = commandFullAccess;
4407
4617
  }
4408
4618
  else {
@@ -4413,7 +4623,7 @@ export class ResponseEngine {
4413
4623
  || !message.triggerMeta.authorizedBy) {
4414
4624
  throw new Error('fullaccess Trigger override lacks trusted scheduler identity or approval provenance');
4415
4625
  }
4416
- await refreshOwnershipForAuthorization(selfAid);
4626
+ await refreshOwnershipForAuthorization(selfAid, { allowTransient: true });
4417
4627
  const currentOwners = new Set(getDaemonOwners());
4418
4628
  if (!currentOwners.has(message.triggerMeta.authorizedBy)) {
4419
4629
  throw new Error('fullaccess Trigger approval is no longer owned');
@@ -4554,6 +4764,8 @@ export class ResponseEngine {
4554
4764
  }
4555
4765
  agentModel = (typeof agent.getModel === 'function') ? agent.getModel() : undefined;
4556
4766
  primaryModel = modelOverride?.model || agentModel;
4767
+ // runner 还没跑起来就终止时,终态事件至少带上解析出的模型
4768
+ this.recordBgSessionModels(message, { requestedModel: primaryModel });
4557
4769
  if (!hasExplicitTriggerModel && primaryModel) {
4558
4770
  const fallbackResolution = await eligibleFallbackModels({
4559
4771
  selfAid: selfAid || undefined,
@@ -5095,7 +5307,11 @@ export class ResponseEngine {
5095
5307
  chatType: configChatType,
5096
5308
  selfAid,
5097
5309
  peerId: configActorId || undefined,
5098
- peerKey,
5310
+ // 系统会话(如记忆整理)的消息 peerId 是后台伪对端 `bg:<kind>`,推出来的 peerKey 不是
5311
+ // 它要操作的关系;目标关系由发起方写在 session.metadata.peerKey,与 system-session
5312
+ // 模块的读写边界同源。ec memory 写盘读的是这里,必须用它。
5313
+ // 局部 peerKey(delegation grant / stats / 响应模式)保持伪对端不变,二者此处有意不一致。
5314
+ peerKey: systemSessionContext?.peerKey ?? peerKey,
5099
5315
  peerName: peerName || undefined,
5100
5316
  peerType: message.peerType || session.metadata?.peerType || undefined,
5101
5317
  // 系统会话没有角色
@@ -5183,7 +5399,9 @@ export class ResponseEngine {
5183
5399
  event: fullAccessAuthorization.source === 'trigger'
5184
5400
  ? 'fullaccess.trigger.execution.started'
5185
5401
  : fullAccessAuthorization.source === 'agent-policy'
5186
- ? 'fullaccess.agent-policy.execution.started' : 'fullaccess.execution.started',
5402
+ ? 'fullaccess.agent-policy.execution.started'
5403
+ : fullAccessAuthorization.source === 'session-policy'
5404
+ ? 'fullaccess.session-policy.execution.started' : 'fullaccess.execution.started',
5187
5405
  source: fullAccessAuthorization.source,
5188
5406
  actorId: fullAccessAuthorization.authorizedBy,
5189
5407
  processRole: fullAccessAuditProcessRole,
@@ -5234,6 +5452,8 @@ export class ResponseEngine {
5234
5452
  this.activeRunnerModels.set(session.id, debugRunnerModel);
5235
5453
  else
5236
5454
  this.activeRunnerModels.delete(session.id);
5455
+ // 每次尝试前覆盖:备用模型切换后,请求模型以最后一次尝试为准
5456
+ this.recordBgSessionModels(message, { requestedModel: debugRunnerModel });
5237
5457
  const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, runAgentSessionId, runnerImages, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
5238
5458
  // The turn may be superseded while the runner is still creating its
5239
5459
  // backend query. Replay the interrupt after runQuery resolves, when
@@ -5279,28 +5499,51 @@ export class ResponseEngine {
5279
5499
  } : undefined, taskCausation, turnLease, effectivePermissionMode, selfAid, async (request) => {
5280
5500
  if (!this.asyncQuestions)
5281
5501
  throw new Error('Async question service is unavailable');
5502
+ const blocking = request.questions.some(question => question.blocking === true);
5282
5503
  if (permissionContext.approvalInteractionPolicy === 'deny') {
5283
5504
  logger.info(`[AsyncQuestions] no interactive recipient for unattended task: session=${session.id}`);
5505
+ if (blocking)
5506
+ throw new Error('阻塞式用户问题无法在无人值守任务中继续');
5284
5507
  return;
5285
5508
  }
5286
- await renderer.flush(false);
5509
+ if (blocking) {
5510
+ attemptTimeout?.pause();
5511
+ timeoutControl?.pauseForInteraction?.();
5512
+ }
5287
5513
  try {
5514
+ if (blocking)
5515
+ await agent.interrupt(session.id);
5516
+ await renderer.flush(false);
5288
5517
  await this.asyncQuestions.present(session, request, message, adapter, envelope);
5289
5518
  }
5290
5519
  catch (error) {
5291
- // Card dispatch is non-blocking business input. Keep consuming the running turn;
5292
- // pending records can be delivered after reconnect/restart.
5520
+ if (blocking)
5521
+ throw error;
5522
+ // Non-blocking business input can be restored after reconnect/restart.
5293
5523
  logger.warn(`[AsyncQuestions] presentation failed: session=${session.id} error=${String(error)}`);
5294
5524
  }
5525
+ finally {
5526
+ if (blocking) {
5527
+ attemptTimeout?.resume();
5528
+ timeoutControl?.resumeForInteraction?.();
5529
+ }
5530
+ }
5531
+ }, async () => {
5532
+ if (proactiveCircuitBreakRequested)
5533
+ await interruptProactiveCircuitBreak();
5295
5534
  });
5296
5535
  streamResult = attemptTimeout
5297
5536
  ? await Promise.race([processAttempt, attemptTimeout.promise])
5298
5537
  : await processAttempt;
5538
+ // runner 回报的实际模型;之后无论成功、失败还是被打断,终态事件都能带出
5539
+ this.recordBgSessionModels(message, {
5540
+ actualModel: streamResult.lastModelCall?.model || streamResult.model,
5541
+ });
5299
5542
  // Activation can complete either before runQuery returns (Codex)
5300
5543
  // or while its event stream is consumed (Claude/Gemini/Ecagent).
5301
5544
  // Always reconcile from strict latest before any logical retry.
5302
5545
  await refreshTopicBindingForLogicalRetry();
5303
- if (!streamResult.isError) {
5546
+ if (!streamResult.isError && !isExpectedTerminalInterrupt(streamResult.terminalReason)) {
5304
5547
  const protocolDecision = this.turnCoordinator.evaluateCommit(turnLease, this.buildTurnCommitEvidence(session, streamResult));
5305
5548
  const protocolReason = protocolDecision.ok ? undefined : protocolDecision.reason;
5306
5549
  // Codex app-server turn/start appends its input before runQuery
@@ -5518,7 +5761,7 @@ export class ResponseEngine {
5518
5761
  // 上下文过长但无法 auto-compact(无 session ID 或 agent 不支持),显示友好提示
5519
5762
  await this.emitOperationalNotice(renderer, getContextTooLongHint(agent), 'warn', 'context-too-long');
5520
5763
  }
5521
- if (!streamResult.isError) {
5764
+ if (!streamResult.isError && !isExpectedTerminalInterrupt(streamResult.terminalReason)) {
5522
5765
  const commitEvidence = this.buildTurnCommitEvidence(session, streamResult);
5523
5766
  const commitDecision = this.turnCoordinator.evaluateCommit(turnLease, commitEvidence);
5524
5767
  if (!commitDecision.ok && commitDecision.reason === 'stale_generation') {
@@ -5715,6 +5958,7 @@ export class ResponseEngine {
5715
5958
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, 'stale_turn', taskCausation);
5716
5959
  return;
5717
5960
  }
5961
+ const terminalInterrupt = isExpectedTerminalInterrupt(streamResult.terminalReason);
5718
5962
  const streamRecoverable = !permissionTerminalFailure
5719
5963
  && !isExpectedTerminalInterrupt(streamResult.terminalReason)
5720
5964
  && isRetryableError(new Error(getStreamErrorText(streamResult)));
@@ -5757,7 +6001,7 @@ export class ResponseEngine {
5757
6001
  this.activeTaskRuntimeContexts.delete(session.id);
5758
6002
  this.activeRunnerModels.delete(session.id); // F1修复
5759
6003
  }
5760
- this.agentDelegationRegistry?.revokeTask(session.id, taskId, streamResult.isError ? 'failed' : 'completed');
6004
+ this.agentDelegationRegistry?.revokeTask(session.id, taskId, terminalInterrupt ? 'interrupted' : streamResult.isError ? 'failed' : 'completed');
5761
6005
  logger.info(`[ResponseEngine] session ${session.id} processing cleared task=${taskId}`);
5762
6006
  import('../../utils/msg-diag.js').then(m => m.diagLog('task_done', { aid: session.selfAID ?? '', msgId: message.messageId ?? '', taskId, text: message.content ?? '' })).catch(() => { });
5763
6007
  // 降级模型回复末尾追加标记(代码层硬注入,不依赖模型输出)
@@ -5800,7 +6044,6 @@ export class ResponseEngine {
5800
6044
  throw tagged;
5801
6045
  }
5802
6046
  }
5803
- const terminalInterrupt = isExpectedTerminalInterrupt(streamResult.terminalReason);
5804
6047
  if (!streamResult.isError && !terminalInterrupt) {
5805
6048
  const commitDecision = await this.turnCoordinator.commit(session, turnLease, this.buildTurnCommitEvidence(session, streamResult));
5806
6049
  if (!commitDecision.ok) {
@@ -5828,8 +6071,10 @@ export class ResponseEngine {
5828
6071
  const interruptReason = this.interruptedSessions.get(session.id);
5829
6072
  if (streamResult.isError || terminalInterrupt) {
5830
6073
  // Agent 流正常结束但任务结果失败(权限被拒、max turns、工具链失败等)
5831
- const errorSummary = streamResult.errors?.join('; ')
5832
- || (terminalInterrupt ? '用户交互未确认,任务已暂停' : '任务执行失败');
6074
+ const errorSummary = streamResult.subtype === 'proactive_circuit_break'
6075
+ ? '主动模式门禁触发熔断,任务已中断'
6076
+ : streamResult.errors?.join('; ')
6077
+ || (terminalInterrupt ? '用户交互未确认,任务已暂停' : '任务执行失败');
5833
6078
  const userErrorSummary = streamResult.subtype === 'protocol_incomplete'
5834
6079
  ? errorSummary
5835
6080
  : streamHitContextLimit(streamResult)
@@ -5852,6 +6097,7 @@ export class ResponseEngine {
5852
6097
  errorType: rawSubtype,
5853
6098
  recoverable: streamRecoverable,
5854
6099
  message: userErrorSummary,
6100
+ ...(streamRawErrorText ? { raw: streamRawErrorText } : {}),
5855
6101
  durationMs: Date.now() - startTime,
5856
6102
  numTurns: streamResult.numTurns,
5857
6103
  toolCallCount: streamResult.toolUseCount ?? 0,
@@ -6162,6 +6408,7 @@ export class ResponseEngine {
6162
6408
  finalText: streamResult.lastReplyText || undefined,
6163
6409
  durationMs: Date.now() - startTime,
6164
6410
  agentName: agentNameForStats,
6411
+ agentAid: runnerSelfAid,
6165
6412
  numTurns: streamResult.numTurns,
6166
6413
  encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
6167
6414
  timestamp: Date.now(),
@@ -6180,7 +6427,7 @@ export class ResponseEngine {
6180
6427
  // 所有 message.send 成功后统一写入 messages.jsonl,此处不再重复写入。
6181
6428
  }
6182
6429
  const isFinallyBackground = this.isBackgroundSession(session, message.channel, message.channelId);
6183
- if (isFinallyBackground && !streamResult.isError) {
6430
+ if (isFinallyBackground && !streamResult.isError && !terminalInterrupt) {
6184
6431
  if (!this.turnCoordinator.canPublish(turnLease))
6185
6432
  return;
6186
6433
  this.messageCache.addEvent(session.id, {
@@ -6272,20 +6519,26 @@ export class ResponseEngine {
6272
6519
  : failureReason, recoverable);
6273
6520
  }
6274
6521
  const totalExecutionMs = error instanceof ExecutionTimeoutError ? error.timeoutMs : this.totalExecutionLimitMs();
6522
+ const rawErrorText = getErrorRawText(error);
6275
6523
  const procStatus = errType === ErrorType.SDK_TIMEOUT || isTotalExecutionTimeout ? 'timeout'
6276
6524
  : errType === ErrorType.STREAM_ERROR ? 'interrupted'
6277
6525
  : 'error';
6278
6526
  const daemonTrigger = this.isTrustedDaemonTrigger(message);
6279
6527
  const statusPayload = error instanceof OrphanedTargetSessionError
6280
- ? { kind: 'status.error', metadata: { errorType: error.reason, message: '任务执行状态丢失,已结束本次执行。' } }
6528
+ ? { kind: 'status.error', metadata: {
6529
+ errorType: error.reason,
6530
+ message: '任务执行状态丢失,已结束本次执行。',
6531
+ ...(rawErrorText ? { raw: rawErrorText } : {}),
6532
+ } }
6281
6533
  : procStatus === 'timeout'
6282
6534
  ? { kind: 'status.timeout', metadata: isTotalExecutionTimeout
6283
6535
  ? { ...(totalExecutionMs === undefined ? {} : { totalExecutionMs }),
6284
- ...(error instanceof ExecutionTimeoutError ? { reason: error.reason } : {}) }
6285
- : { idleSec: getLastIdleSec?.() || undefined } }
6536
+ ...(error instanceof ExecutionTimeoutError ? { reason: error.reason } : {}),
6537
+ ...(rawErrorText ? { raw: rawErrorText } : {}) }
6538
+ : { idleSec: getLastIdleSec?.() || undefined, ...(rawErrorText ? { raw: rawErrorText } : {}) } }
6286
6539
  : procStatus === 'interrupted'
6287
6540
  ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
6288
- : { kind: 'status.error' };
6541
+ : { kind: 'status.error', metadata: rawErrorText ? { raw: rawErrorText } : undefined };
6289
6542
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
6290
6543
  if (!isUserInterrupt && ownsTimeoutTerminal && !daemonTrigger) {
6291
6544
  await adapter.send(envelope, statusPayload).catch(() => { });
@@ -6404,13 +6657,15 @@ export class ResponseEngine {
6404
6657
  : modelFallbackExhaustedMessage
6405
6658
  ? ErrorType.MODEL_UNAVAILABLE
6406
6659
  : normalizeCoreErrorSubtype(error, undefined, isTotalExecutionTimeout || isTimeout ? 'timeout' : undefined);
6407
- const rawErrorText = getErrorRawText(error);
6660
+ const distinctRawErrorText = rawErrorText && !userMessage.includes(rawErrorText)
6661
+ ? rawErrorText
6662
+ : undefined;
6408
6663
  const errorPayload = {
6409
6664
  kind: 'result.error',
6410
6665
  text: userMessage,
6411
6666
  recoverable,
6412
6667
  subtype: errorSubtype,
6413
- ...(rawErrorText ? { raw: rawErrorText } : {}),
6668
+ ...(distinctRawErrorText ? { raw: distinctRawErrorText } : {}),
6414
6669
  ...(errorReason ? { reason: errorReason } : {}),
6415
6670
  };
6416
6671
  if (!isUserInterrupt && daemonTrigger && procStatus === 'error') {
@@ -6423,6 +6678,7 @@ export class ResponseEngine {
6423
6678
  metadata: {
6424
6679
  errorType: errType,
6425
6680
  message: userMessage,
6681
+ ...(rawErrorText ? { raw: rawErrorText } : {}),
6426
6682
  recoverable,
6427
6683
  durationMs: Date.now() - startTime,
6428
6684
  numTurns: streamResult.numTurns,
@@ -6457,6 +6713,8 @@ export class ResponseEngine {
6457
6713
  }
6458
6714
  }
6459
6715
  finally {
6716
+ if (proactiveCircuitBreakTimer)
6717
+ clearTimeout(proactiveCircuitBreakTimer);
6460
6718
  if (taskActivityStore) {
6461
6719
  try {
6462
6720
  taskActivityStore.updateStatus(taskId, taskActivityStatus);
@@ -6497,7 +6755,9 @@ export class ResponseEngine {
6497
6755
  event: fullAccessAuthorization.source === 'trigger'
6498
6756
  ? 'fullaccess.trigger.execution.ended'
6499
6757
  : fullAccessAuthorization.source === 'agent-policy'
6500
- ? 'fullaccess.agent-policy.execution.ended' : 'fullaccess.execution.ended',
6758
+ ? 'fullaccess.agent-policy.execution.ended'
6759
+ : fullAccessAuthorization.source === 'session-policy'
6760
+ ? 'fullaccess.session-policy.execution.ended' : 'fullaccess.execution.ended',
6501
6761
  source: fullAccessAuthorization.source,
6502
6762
  actorId: fullAccessAuthorization.authorizedBy,
6503
6763
  processRole: fullAccessAuditProcessRole,
@@ -6777,7 +7037,7 @@ export class ResponseEngine {
6777
7037
  */
6778
7038
  async processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress, proactive,
6779
7039
  /** [迁移点4/5] 响应模式插件 + 状态,用于调 onToolUse/onComplete 钩子 */
6780
- modeHooks, causation, turnLease, permissionMode, proactiveSelfAid, onAsyncQuestion) {
7040
+ modeHooks, causation, turnLease, permissionMode, proactiveSelfAid, onAsyncQuestion, onToolCallsSettled) {
6781
7041
  // Per-session agent name for stats bucketing
6782
7042
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
6783
7043
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
@@ -6799,17 +7059,23 @@ export class ResponseEngine {
6799
7059
  const openToolUseIds = new Set();
6800
7060
  const anonymousToolUseIds = new Map();
6801
7061
  const anonymousOpenIdByCorrelation = new Map();
6802
- const finalizeResult = () => ({
6803
- ...completeResult,
6804
- hasReceivedText,
6805
- hasProjectedCurrentReplyText,
6806
- inputAccepted,
6807
- assistantUuid,
6808
- protocolIncompleteReason,
6809
- textCount,
6810
- toolUseCount,
6811
- openToolUseIds: [...openToolUseIds],
6812
- });
7062
+ const finalizeResult = () => {
7063
+ const circuitBreakInterrupted = this.interruptedSessions.get(session.id) === 'proactive_circuit_break';
7064
+ return {
7065
+ ...completeResult,
7066
+ ...(circuitBreakInterrupted
7067
+ ? { isError: false, subtype: 'proactive_circuit_break', terminalReason: 'aborted_tools', errors: undefined }
7068
+ : {}),
7069
+ hasReceivedText,
7070
+ hasProjectedCurrentReplyText,
7071
+ inputAccepted,
7072
+ assistantUuid,
7073
+ protocolIncompleteReason: circuitBreakInterrupted ? undefined : protocolIncompleteReason,
7074
+ textCount,
7075
+ toolUseCount,
7076
+ openToolUseIds: [...openToolUseIds],
7077
+ };
7078
+ };
6813
7079
  // 追踪最后一轮 assistant 回复文本(tool_use 之后的纯文本)
6814
7080
  let lastReplyText = '';
6815
7081
  // 上游合成错误帧的原文(runner 标记 synthetic 的 text)。与正文通道分开存放:
@@ -7451,6 +7717,8 @@ export class ResponseEngine {
7451
7717
  else if (!toolResultFailed && !shouldSuppress()) {
7452
7718
  renderer.addToolResult(event.name || '\u5de5\u5177', true, event.result, undefined, event.callId, event.durationMs, cachedDesc, event, event.assistantUuid);
7453
7719
  }
7720
+ if (openToolUseIds.size === 0)
7721
+ await onToolCallsSettled?.();
7454
7722
  }
7455
7723
  // 非终态运行时告警走 status notify,不进入 activity/thought 历史。
7456
7724
  if (event.type === 'notice' && event.severity === 'warn') {