evolcore 0.0.12 → 0.0.13

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 (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/ec-safe-output.js +26 -7
  3. package/dist/agents/claude-runner.js +53 -7
  4. package/dist/agents/codex-app-server-client.js +53 -5
  5. package/dist/agents/codex-runner.js +104 -59
  6. package/dist/agents/ecagent-runner.js +4 -1
  7. package/dist/aun/aid/control-aid.js +36 -13
  8. package/dist/aun/aid/store.js +13 -7
  9. package/dist/aun/group-fs-download.js +56 -0
  10. package/dist/aun/group-identity.js +1 -2
  11. package/dist/aun/msg/group.js +11 -33
  12. package/dist/aun/msg/index.js +1 -2
  13. package/dist/aun/msg/managed-operation.js +116 -55
  14. package/dist/aun/msg/upload.js +16 -1
  15. package/dist/aun/outbox.js +31 -2
  16. package/dist/aun/rpc/client.js +66 -0
  17. package/dist/aun/rpc/index.js +1 -2
  18. package/dist/aun/storage/{upload.js → client.js} +11 -2
  19. package/dist/aun/storage/index.js +1 -2
  20. package/dist/channels/aun.js +371 -136
  21. package/dist/cli/agent.js +9 -96
  22. package/dist/cli/cli-argv.js +10 -0
  23. package/dist/cli/config.js +9 -17
  24. package/dist/cli/ctl-command.js +1 -1
  25. package/dist/cli/daemon-commands.js +39 -24
  26. package/dist/cli/fs-command.js +19 -10
  27. package/dist/cli/init.js +77 -8
  28. package/dist/cli/queue-command.js +42 -0
  29. package/dist/cli/restart-monitor.js +3 -14
  30. package/dist/cli/trigger-command.js +7 -0
  31. package/dist/config/access-policy.js +1 -2
  32. package/dist/config/builtin-roles.js +5 -0
  33. package/dist/config/config-manager.js +21 -1
  34. package/dist/config/peer-role-resolver.js +6 -2
  35. package/dist/config/role-migration-startup.js +53 -0
  36. package/dist/config/role-store.js +1 -1
  37. package/dist/core/auth/agent-delegation.js +1 -1
  38. package/dist/core/auth/auth-gateway.js +29 -1
  39. package/dist/core/auth/authorization-audit.js +15 -0
  40. package/dist/core/auth/operation-authorizer.js +1 -0
  41. package/dist/core/auth/operation-catalog.js +9 -0
  42. package/dist/core/bootstrap-messages.js +9 -2
  43. package/dist/core/bootstrap-service.js +1 -0
  44. package/dist/core/command/command-handler.js +198 -51
  45. package/dist/core/command/menu-catalog.js +4 -0
  46. package/dist/core/command/menu-handler.js +85 -18
  47. package/dist/core/command/menu-protocol.js +23 -2
  48. package/dist/core/command/slash-handler.js +58 -10
  49. package/dist/core/event-catalog.js +6 -0
  50. package/dist/core/handoff/runtime.js +12 -1
  51. package/dist/core/handoff/store.js +5 -1
  52. package/dist/core/interaction-router.js +10 -0
  53. package/dist/core/message/im-renderer.js +19 -0
  54. package/dist/core/message/message-bridge.js +63 -11
  55. package/dist/core/message/message-log.js +2 -0
  56. package/dist/core/message/message-queue.js +267 -39
  57. package/dist/core/message/message-utils.js +81 -4
  58. package/dist/core/message/response-engine.js +107 -41
  59. package/dist/core/message/stream-debouncer.js +10 -2
  60. package/dist/core/permission/approval-gateway.js +1 -0
  61. package/dist/core/permission/ec-command-parser.js +93 -14
  62. package/dist/core/permission/index.js +1 -1
  63. package/dist/core/permission/mode.js +15 -0
  64. package/dist/core/permission/readonly-shell-query.js +148 -8
  65. package/dist/core/permission/sandbox-runtime.js +21 -0
  66. package/dist/core/permission/tool-policy.js +249 -27
  67. package/dist/core/protected-paths.js +107 -2
  68. package/dist/core/session/session-manager.js +21 -2
  69. package/dist/core/session/session-mapper.js +20 -1
  70. package/dist/eck/detect.js +18 -1
  71. package/dist/eck/group-rules-sync.js +15 -44
  72. package/dist/index.js +161 -86
  73. package/dist/ipc.js +16 -3
  74. package/dist/paths.js +3 -0
  75. package/dist/trigger/feedback.js +64 -11
  76. package/dist/trigger/manager.js +21 -1
  77. package/dist/trigger/parser.js +14 -2
  78. package/dist/trigger/scheduler.js +1 -0
  79. package/dist/trigger/validation.js +67 -6
  80. package/dist/utils/ecweb-supervisor.js +332 -0
  81. package/dist/utils/error-utils.js +10 -0
  82. package/kits/docs/evolcore/trigger.md +17 -1
  83. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  84. package/kits/schemas/role-config.schema.1.json +1 -0
  85. package/kits/templates/roles/admin.json +5 -0
  86. package/kits/templates/roles/member.json +7 -1
  87. package/kits/templates/roles/visitor.json +2 -0
  88. package/package.json +1 -1
  89. package/dist/aun/msg/payload-type.js +0 -27
  90. package/dist/aun/rpc/caller.js +0 -42
  91. package/dist/aun/rpc/connection.js +0 -25
  92. package/dist/aun/storage/manage.js +0 -10
  93. package/dist/config/access-policy-domain.js +0 -18
  94. package/dist/config/config-batch-get.js +0 -11
  95. package/dist/config/owner-policy.js +0 -4
  96. package/dist/config/role-config-v4-startup.js +0 -32
  97. package/dist/config/role-config-v5-startup.js +0 -27
  98. package/dist/core/auth/trigger-authorization.js +0 -15
  99. package/dist/core/interaction-registration.js +0 -10
  100. package/dist/core/permission/execution-sandbox.js +0 -16
  101. package/dist/core/relation/peer-key.js +0 -1
  102. package/dist/core/session/session-key.js +0 -24
  103. package/dist/eck/baseagent-caps.js +0 -18
  104. package/dist/eck/rules-loader.js +0 -28
@@ -9,6 +9,10 @@ import { renderCommandCardAsText, renderActionAsText } from '../interaction-rout
9
9
  */
10
10
  export function buildEnvelope(opts) {
11
11
  const taskId = opts.taskId ?? `interaction-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
12
+ const delivery = opts.delivery ?? opts.replyContext?.delivery;
13
+ const replyContext = opts.replyContext
14
+ ? { ...opts.replyContext, ...(delivery ? { delivery } : {}) }
15
+ : undefined;
12
16
  return {
13
17
  taskId,
14
18
  operationId: opts.operationId ?? taskId,
@@ -17,11 +21,86 @@ export function buildEnvelope(opts) {
17
21
  channelId: opts.channelId,
18
22
  agentName: opts.agentName ?? '<unknown>',
19
23
  chatmode: opts.chatmode ?? 'interactive',
20
- replyContext: opts.replyContext,
24
+ delivery,
25
+ replyContext,
21
26
  causation: opts.causation,
22
27
  timestamp: opts.timestamp ?? Date.now(),
23
28
  };
24
29
  }
30
+ /**
31
+ * Replace an envelope's reply context while keeping its transport route
32
+ * authoritative at the top level. A per-send context may intentionally move
33
+ * a reply to another target (for example owner approval in a private chat).
34
+ */
35
+ export function withEnvelopeReplyContext(envelope, replyContext) {
36
+ const delivery = replyContext?.delivery ?? envelope.delivery;
37
+ return {
38
+ ...envelope,
39
+ ...(delivery ? { delivery } : {}),
40
+ ...(replyContext
41
+ ? {
42
+ replyContext: {
43
+ ...replyContext,
44
+ ...(delivery ? { delivery } : {}),
45
+ },
46
+ }
47
+ : {}),
48
+ };
49
+ }
50
+ /** Runtime validation for routes persisted in envelopes, sessions, and outbox entries. */
51
+ export function isDeliveryTarget(value) {
52
+ if (!value || typeof value !== 'object' || Array.isArray(value))
53
+ return false;
54
+ const route = value;
55
+ if (route.chatType === 'private')
56
+ return true;
57
+ return route.chatType === 'group'
58
+ && typeof route.groupId === 'string'
59
+ && route.groupId.trim().length > 0;
60
+ }
61
+ /** Validate a route and ensure group routes address the enclosing channel. */
62
+ export function isDeliveryTargetForChannel(value, channelId) {
63
+ return isDeliveryTarget(value)
64
+ && (value.chatType === 'private' || value.groupId === channelId);
65
+ }
66
+ /** Compare normalized private/group delivery routes. */
67
+ export function sameDeliveryTarget(left, right) {
68
+ return left.chatType === right.chatType
69
+ && (left.chatType === 'private'
70
+ || (right.chatType === 'group' && left.groupId === right.groupId));
71
+ }
72
+ /** Resolve an outbound route from trusted, persisted session state. */
73
+ export function deliveryTargetFromSession(session) {
74
+ const channelType = session.channelType || session.channel?.split('#', 1)[0];
75
+ const strictAun = channelType?.toLowerCase() === 'aun';
76
+ if (session.chatType === 'group') {
77
+ const groupId = session.metadata?.groupId;
78
+ if (strictAun && (!groupId || groupId !== session.channelId)) {
79
+ const code = groupId ? 'AUN_OUTBOUND_ROUTE_MISMATCH' : 'AUN_OUTBOUND_ROUTE_REQUIRED';
80
+ throw Object.assign(new Error(`AUN group session has no valid persisted groupId for channelId=${session.channelId}`), { code });
81
+ }
82
+ return { chatType: 'group', groupId: groupId || session.channelId };
83
+ }
84
+ if (session.chatType === 'private')
85
+ return { chatType: 'private' };
86
+ if (strictAun) {
87
+ throw Object.assign(new Error(`AUN session has no persisted chatType for channelId=${session.channelId}`), {
88
+ code: 'AUN_OUTBOUND_ROUTE_REQUIRED',
89
+ });
90
+ }
91
+ return { chatType: 'private' };
92
+ }
93
+ /** Preserve channel-specific reply fields while filling the session route. */
94
+ export function replyContextFromSession(session, context) {
95
+ return {
96
+ ...(session.metadata?.replyContext ?? {}),
97
+ ...(context ?? {}),
98
+ // Session chatType/groupId is the persisted routing authority. The nested
99
+ // reply context can outlive a route correction, so only an explicit
100
+ // per-send context may override it.
101
+ delivery: context?.delivery ?? deliveryTargetFromSession(session),
102
+ };
103
+ }
25
104
  /**
26
105
  * 交互请求的默认回退文本
27
106
  * 当调用方未显式提供时使用,根据交互类型选择合适的渲染器。
@@ -60,9 +139,7 @@ export async function sendInteractionPayload(adapter, envelope, interaction, fal
60
139
  fallbackText: text || undefined,
61
140
  };
62
141
  try {
63
- const enriched = replyCtx
64
- ? { ...envelope, replyContext: replyCtx }
65
- : envelope;
142
+ const enriched = replyCtx ? withEnvelopeReplyContext(envelope, replyCtx) : envelope;
66
143
  const receipt = await adapter.send(enriched, payload);
67
144
  if (receipt?.status === 'suppressed')
68
145
  return false;
@@ -22,7 +22,7 @@ import { createRootCausation, deriveCausation, normalizeCausation } from '../cau
22
22
  import { recordCausationLink, recordCausationSpan } from '../causation/audit.js';
23
23
  import { normalizeBaseagent } from '../../agents/baseagent.js';
24
24
  import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
25
- import { buildEnvelope } from './message-utils.js';
25
+ import { buildEnvelope, replyContextFromSession, withEnvelopeReplyContext } from './message-utils.js';
26
26
  import { isSystemOrServicePeer, resolveChatMode } from './peer-mode.js';
27
27
  import { AGENT_DELEGATION_TOKEN_ENV } from '../auth/agent-delegation.js';
28
28
  import { hasTrustedPrincipal } from '../auth/authenticated-actor.js';
@@ -136,6 +136,7 @@ const RETRY_EXHAUSTED_MARK = Symbol('evolcore.retryExhausted');
136
136
  const RETRY_MADE_PROGRESS_MARK = Symbol('evolcore.retryMadeProgress');
137
137
  const RETRY_HEALTH_RECORDED_MARK = Symbol('evolcore.retryHealthRecorded');
138
138
  const MODEL_FALLBACK_EXHAUSTED_MARK = Symbol('evolcore.modelFallbackExhausted');
139
+ const RETRY_INPUT_ALREADY_SUBMITTED_MARK = Symbol('evolcore.retryInputAlreadySubmitted');
139
140
  function markRetryExhausted(error, retries) {
140
141
  if (error && typeof error === 'object') {
141
142
  error[RETRY_EXHAUSTED_MARK] = retries;
@@ -176,6 +177,21 @@ function getModelFallbackExhaustedMessage(error) {
176
177
  : undefined;
177
178
  return typeof message === 'string' ? message : undefined;
178
179
  }
180
+ function markRetryInputAlreadySubmitted(error) {
181
+ if (error && typeof error === 'object') {
182
+ error[RETRY_INPUT_ALREADY_SUBMITTED_MARK] = true;
183
+ // A terminal delivery guard, rather than any buffered activity, owns the
184
+ // final user-visible explanation.
185
+ delete error._errorAlreadySent;
186
+ }
187
+ }
188
+ function wasRetryInputAlreadySubmitted(error) {
189
+ return !!(error && typeof error === 'object' && error[RETRY_INPUT_ALREADY_SUBMITTED_MARK] === true);
190
+ }
191
+ function formatRetryInputAlreadySubmittedMessage(error) {
192
+ const reason = getErrorMessage(error, undefined, false) || 'Codex 暂时不可用';
193
+ return `⚠️ Codex 已接收本次消息,但本轮未能确认完成。为避免在会话中重复写入同一消息,系统未自动重试。请检查会话状态后重新发送。\n原因:${reason}`;
194
+ }
179
195
  function isNonErrorInterruptReason(reason) {
180
196
  return reason === 'new_message'
181
197
  || reason === 'stop'
@@ -492,7 +508,7 @@ export class ResponseEngine {
492
508
  return;
493
509
  const available = error.availableBaseagents.length ? error.availableBaseagents.join(', ') : '(none)';
494
510
  const text = `❌ 当前会话绑定的 baseagent 不可用: ${error.baseagent}\nAgent: ${error.evolagentName}\n可用: ${available}\n请使用 /baseagent 切换到可用后端。`;
495
- const sessionReplyContext = { ...(replyContext ?? {}), sessionId: session.id };
511
+ const sessionReplyContext = { ...replyContextFromSession(session, replyContext), sessionId: session.id };
496
512
  await channelInfo.adapter.send(buildEnvelope({
497
513
  taskId: `system-${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`,
498
514
  sessionId: session.id,
@@ -655,7 +671,7 @@ export class ResponseEngine {
655
671
  agentName: agent.name,
656
672
  chatmode: this.resolveEffectiveChatmodeForSession(session, channelKey, session.channelId),
657
673
  replyContext: {
658
- ...(session.metadata?.replyContext ?? {}),
674
+ ...replyContextFromSession(session),
659
675
  sessionId: session.id,
660
676
  threadId: session.threadId || session.metadata?.replyContext?.threadId,
661
677
  },
@@ -937,7 +953,7 @@ export class ResponseEngine {
937
953
  }
938
954
  agent.registerStream(streamKey, retryStream);
939
955
  return await this.processEventStream(retryStream, session, agent, renderer, resetTimer, shouldSuppress, proactive, undefined, // 重试分支不调插件钩子
940
- undefined, turnLease);
956
+ undefined, turnLease, opts.permissionMode);
941
957
  }
942
958
  // Dropping the whole session hides the root cause and can replay side
943
959
  // effects in unattended trigger tasks. Surface an actionable failure
@@ -1199,7 +1215,7 @@ export class ResponseEngine {
1199
1215
  logger.warn(`[ResponseEngine] Trigger execution identity missing: trigger=${message.triggerMeta?.triggerId ?? '<none>'}`);
1200
1216
  const channelInfo = this.resolveChannelInfo(accessChannelKey);
1201
1217
  if (channelInfo) {
1202
- await channelInfo.adapter.send({
1218
+ await channelInfo.adapter.send(buildEnvelope({
1203
1219
  taskId: `trigger-identity-missing-${Date.now()}`,
1204
1220
  sessionId: session.id,
1205
1221
  channel: accessChannelKey,
@@ -1208,7 +1224,7 @@ export class ResponseEngine {
1208
1224
  chatmode: 'interactive',
1209
1225
  replyContext: message.replyContext,
1210
1226
  timestamp: Date.now(),
1211
- }, {
1227
+ }), {
1212
1228
  kind: 'system.error',
1213
1229
  text: 'Trigger 创建者身份缺失,已拒绝执行',
1214
1230
  subtype: 'trigger_identity_missing',
@@ -1230,7 +1246,7 @@ export class ResponseEngine {
1230
1246
  const channelInfo = this.resolveChannelInfo(channelKey);
1231
1247
  if (channelInfo) {
1232
1248
  try {
1233
- await channelInfo.adapter.send({
1249
+ await channelInfo.adapter.send(buildEnvelope({
1234
1250
  taskId: `access-denied-${Date.now()}`,
1235
1251
  sessionId: session.id,
1236
1252
  channel: channelKey,
@@ -1239,7 +1255,7 @@ export class ResponseEngine {
1239
1255
  chatmode: 'interactive',
1240
1256
  replyContext: message.replyContext,
1241
1257
  timestamp: Date.now(),
1242
- }, {
1258
+ }), {
1243
1259
  kind: 'system.error',
1244
1260
  text: '暂无权限访问本 agent,请联系 agent 管理员授权访问',
1245
1261
  subtype: 'access_denied',
@@ -1497,7 +1513,12 @@ export class ResponseEngine {
1497
1513
  }
1498
1514
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
1499
1515
  async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, outputState = { middleOutputMode: 'all' }, timeoutControl) {
1500
- const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
1516
+ // The model must see the time at which the adapter accepted the input.
1517
+ // Runner start is recorded separately so queue delay never rewrites input
1518
+ // chronology after a historical replay or a long backlog.
1519
+ const inboundTimestamp = message.receivedAt ?? message.enqueuedAt ?? message.timestamp ?? Date.now();
1520
+ const runnerStartedAt = Date.now();
1521
+ const messageId = `${message.channel}_${message.channelId}_${inboundTimestamp}`;
1501
1522
  const channelKey = this.messageChannelKey(message, session);
1502
1523
  const channelInfo = this.resolveChannelInfo(channelKey);
1503
1524
  const taskAgentAid = message.selfAID || session.selfAID;
@@ -1724,7 +1745,7 @@ export class ResponseEngine {
1724
1745
  const taskReplyContext = () => {
1725
1746
  const base = this.getReplyContext(message);
1726
1747
  return {
1727
- ...(base ?? {}),
1748
+ ...replyContextFromSession(session, base),
1728
1749
  sessionId: session.id,
1729
1750
  threadId: session.threadId || base?.threadId,
1730
1751
  metadata: withTaskMetadata(base?.metadata),
@@ -1844,7 +1865,7 @@ export class ResponseEngine {
1844
1865
  // Kept outside the task body so a thrown terminal error can preserve the
1845
1866
  // work already performed in its trigger audit metadata.
1846
1867
  let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
1847
- let startTime = Date.now();
1868
+ let startTime = runnerStartedAt;
1848
1869
  let protocolReplayAttempts = 0;
1849
1870
  try {
1850
1871
  const isBackground = this.isBackgroundSession(session, message.channel, message.channelId);
@@ -1853,7 +1874,10 @@ export class ResponseEngine {
1853
1874
  msgId: messageId,
1854
1875
  sessionId: session.id,
1855
1876
  dir: 'inbound',
1856
- status: 'received'
1877
+ status: 'received',
1878
+ receivedAt: message.receivedAt ?? inboundTimestamp,
1879
+ enqueuedAt: message.enqueuedAt,
1880
+ processingStartedAt: runnerStartedAt,
1857
1881
  });
1858
1882
  this.eventBus.publish({
1859
1883
  type: 'message:received',
@@ -1862,7 +1886,10 @@ export class ResponseEngine {
1862
1886
  channelId: message.channelId,
1863
1887
  content: message.content,
1864
1888
  agentName: agentNameForStats,
1865
- timestamp: Date.now()
1889
+ timestamp: inboundTimestamp,
1890
+ receivedAt: message.receivedAt ?? inboundTimestamp,
1891
+ enqueuedAt: message.enqueuedAt,
1892
+ processingStartedAt: runnerStartedAt,
1866
1893
  });
1867
1894
  // ── 硬上限检查:超限直接返回提示,不调模型 ──
1868
1895
  {
@@ -2007,7 +2034,7 @@ export class ResponseEngine {
2007
2034
  opts.metadata = withTaskMetadata(opts.metadata);
2008
2035
  if (payload.kind.startsWith('status.'))
2009
2036
  this.touchAgentActivity(channelKey);
2010
- const enrichedEnvelope = { ...envelope, replyContext: opts };
2037
+ const enrichedEnvelope = withEnvelopeReplyContext(envelope, opts);
2011
2038
  snapshot.pushOutbound(session.id, taskId, { kind: payload.kind, decision: 'sent' });
2012
2039
  await adapter.send(enrichedEnvelope, payload);
2013
2040
  },
@@ -2032,7 +2059,7 @@ export class ResponseEngine {
2032
2059
  const capturedReplyContext = taskReplyContext();
2033
2060
  // 设置权限审批的消息发送回调(指向当前渠道)
2034
2061
  const permissionPrompt = async (text) => {
2035
- await adapter.send({ ...envelope, replyContext: capturedReplyContext }, { kind: 'result.text', text, isFinal: false });
2062
+ await adapter.send(withEnvelopeReplyContext(envelope, capturedReplyContext), { kind: 'result.text', text, isFinal: false });
2036
2063
  };
2037
2064
  const authChatType = configChatType;
2038
2065
  const authConversationId = authChatType === 'group'
@@ -2046,15 +2073,16 @@ export class ResponseEngine {
2046
2073
  : undefined;
2047
2074
  const permissionAdapter = triggerOriginChannelInfo?.adapter ?? adapter;
2048
2075
  const permissionChannelId = authorizationIdentity?.actorId ?? capturedChannelId;
2049
- const permissionReplyContext = authorizationIdentity ? undefined : capturedReplyContext;
2076
+ const permissionReplyContext = authorizationIdentity
2077
+ ? { delivery: { chatType: 'private' } }
2078
+ : capturedReplyContext;
2050
2079
  let permissionTerminalFailure;
2051
2080
  const permissionPromptForOrigin = async (text) => {
2052
- await permissionAdapter.send({
2081
+ await permissionAdapter.send(withEnvelopeReplyContext({
2053
2082
  ...envelope,
2054
2083
  channel: authorizationIdentity?.originChannelKey ?? envelope.channel,
2055
2084
  channelId: permissionChannelId,
2056
- replyContext: permissionReplyContext,
2057
- }, { kind: 'result.text', text, isFinal: false });
2085
+ }, permissionReplyContext), { kind: 'result.text', text, isFinal: false });
2058
2086
  };
2059
2087
  agent.setSendPrompt(permissionPromptForOrigin);
2060
2088
  const owningAgentForAuth = owningAgentForTask;
@@ -2200,7 +2228,7 @@ export class ResponseEngine {
2200
2228
  },
2201
2229
  },
2202
2230
  permissionStateChanged: async (permissionEvent) => {
2203
- const executionEnvelope = { ...envelope, replyContext: capturedReplyContext };
2231
+ const executionEnvelope = withEnvelopeReplyContext(envelope, capturedReplyContext);
2204
2232
  if (permissionEvent.state === 'waiting') {
2205
2233
  const payload = {
2206
2234
  kind: 'status.requires_action',
@@ -2214,12 +2242,11 @@ export class ResponseEngine {
2214
2242
  };
2215
2243
  await adapter.send(executionEnvelope, payload);
2216
2244
  if (permissionAdapter !== adapter || permissionChannelId !== capturedChannelId) {
2217
- await permissionAdapter.send({
2245
+ await permissionAdapter.send(withEnvelopeReplyContext({
2218
2246
  ...envelope,
2219
2247
  channel: authorizationIdentity?.originChannelKey ?? envelope.channel,
2220
2248
  channelId: permissionChannelId,
2221
- replyContext: permissionReplyContext,
2222
- }, payload);
2249
+ }, permissionReplyContext), payload);
2223
2250
  }
2224
2251
  return;
2225
2252
  }
@@ -2777,6 +2804,7 @@ export class ResponseEngine {
2777
2804
  ? (agent.resolveModelId(model) ?? model)
2778
2805
  : model));
2779
2806
  const retryAttemptTimeoutMs = this.retryAttemptTimeoutMs();
2807
+ const retryInputIsAtMostOnce = agent.retryInputSemantics === 'at_most_once';
2780
2808
  let runAttempt = 1;
2781
2809
  const recordRetryHealthError = async (retryError) => {
2782
2810
  if (!policy.accumulateErrors(chatType, identityRole))
@@ -2813,14 +2841,17 @@ export class ResponseEngine {
2813
2841
  const processAttempt = this.processEventStream(stream, session, agent, renderer, (eventType, toolName) => {
2814
2842
  resetTimer(eventType, toolName);
2815
2843
  attemptTimeout?.reset();
2816
- }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease);
2844
+ }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease, effectivePermissionMode);
2817
2845
  streamResult = attemptTimeout
2818
2846
  ? await Promise.race([processAttempt, attemptTimeout.promise])
2819
2847
  : await processAttempt;
2820
2848
  if (!streamResult.isError) {
2821
2849
  const protocolDecision = this.turnCoordinator.evaluateCommit(turnLease, this.buildTurnCommitEvidence(session, streamResult));
2822
2850
  const protocolReason = protocolDecision.ok ? undefined : protocolDecision.reason;
2823
- const safeProtocolReplay = isSafeProtocolReplayCandidate(streamResult, protocolReason);
2851
+ // Codex app-server turn/start appends its input before runQuery
2852
+ // resolves. Replaying here would create a second user turn.
2853
+ const safeProtocolReplay = !retryInputIsAtMostOnce
2854
+ && isSafeProtocolReplayCandidate(streamResult, protocolReason);
2824
2855
  const protocolRetry = retryScheduler.afterProtocolFailure(safeProtocolReplay ? protocolReason : undefined, safeProtocolReplay);
2825
2856
  if (protocolRetry.kind === 'protocol_replay') {
2826
2857
  protocolReplayAttempts = protocolRetry.attempt;
@@ -2865,6 +2896,16 @@ export class ResponseEngine {
2865
2896
  permissionError._errorAlreadySent = true;
2866
2897
  throw permissionError;
2867
2898
  }
2899
+ const retryWouldReplaySubmittedInput = retryAttemptTimedOut
2900
+ || isRetryableError(retryError)
2901
+ || classifyError(retryError) === ErrorType.MODEL_UNAVAILABLE;
2902
+ if (retryInputIsAtMostOnce && streamRegistered && retryWouldReplaySubmittedInput) {
2903
+ await recordRetryHealthError(retryError);
2904
+ markRetryInputAlreadySubmitted(retryError);
2905
+ logger.warn(`[ResponseEngine] Not replaying already-submitted input: agent=${agent.name} `
2906
+ + `session=${session.id} task=${taskId} attempt=${runAttempt}`);
2907
+ throw retryError;
2908
+ }
2868
2909
  if (classifyError(retryError) === ErrorType.MODEL_UNAVAILABLE && !hasExplicitTriggerModel) {
2869
2910
  if (probingPrimaryModel && previousFallbackState) {
2870
2911
  const resumedFallback = previousFallbackState.activeFallback;
@@ -2958,6 +2999,7 @@ export class ResponseEngine {
2958
2999
  resetTimer,
2959
3000
  shouldSuppress,
2960
3001
  proactive,
3002
+ permissionMode: effectivePermissionMode,
2961
3003
  turnLease,
2962
3004
  });
2963
3005
  }
@@ -2999,6 +3041,7 @@ export class ResponseEngine {
2999
3041
  resetTimer,
3000
3042
  shouldSuppress,
3001
3043
  proactive,
3044
+ permissionMode: effectivePermissionMode,
3002
3045
  turnLease,
3003
3046
  });
3004
3047
  // 重试后仍然 prompt_too_long:显示友好提示
@@ -3070,7 +3113,7 @@ export class ResponseEngine {
3070
3113
  return;
3071
3114
  const isCurrentlyBackground = this.isBackgroundSession(session, message.channel, message.channelId);
3072
3115
  if (!isCurrentlyBackground) {
3073
- await adapter.send({ ...envelope, replyContext: capturedReplyContext }, payload);
3116
+ await adapter.send(withEnvelopeReplyContext(envelope, capturedReplyContext), payload);
3074
3117
  if (payload.kind === 'result.text') {
3075
3118
  logger.info(`[ResponseEngine] afterProcess sent: task=${taskId} text="${payload.text.slice(0, 60)}"`);
3076
3119
  }
@@ -3158,7 +3201,10 @@ export class ResponseEngine {
3158
3201
  // 发送文件
3159
3202
  logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
3160
3203
  try {
3161
- await targetInfo.adapter.send(buildEnvelope({ taskId, channel: targetInfo.adapter.channelName, channelId: targetChannelId, agentName: agentNameForStats, replyContext: capturedReplyContext }), { kind: 'result.file', filePath: resolvedPath });
3204
+ const targetReplyContext = isCrossChannel
3205
+ ? { delivery: { chatType: 'private' } }
3206
+ : capturedReplyContext;
3207
+ await targetInfo.adapter.send(buildEnvelope({ taskId, channel: targetInfo.adapter.channelName, channelId: targetChannelId, agentName: agentNameForStats, replyContext: targetReplyContext }), { kind: 'result.file', filePath: resolvedPath });
3162
3208
  this.eventBus.publish({ type: 'runner:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
3163
3209
  sent++;
3164
3210
  if (isCrossChannel) {
@@ -3681,6 +3727,7 @@ export class ResponseEngine {
3681
3727
  // processEventStream 已通过 renderer 发过错误时也跳过
3682
3728
  const retryExhaustedCount = getRetryExhaustedCount(error);
3683
3729
  const modelFallbackExhaustedMessage = getModelFallbackExhaustedMessage(error);
3730
+ const retryInputAlreadySubmitted = wasRetryInputAlreadySubmitted(error);
3684
3731
  if (isUserInterrupt) {
3685
3732
  logger.info(`[ResponseEngine] User interrupt by new_message, skip sending error message`);
3686
3733
  }
@@ -3692,13 +3739,15 @@ export class ResponseEngine {
3692
3739
  const idleSec = getLastIdleSec?.() || 0;
3693
3740
  const userMessage = retryExhaustedCount
3694
3741
  ? formatRetryableErrorFinalMessage(error, retryExhaustedCount)
3695
- : modelFallbackExhaustedMessage
3696
- ? modelFallbackExhaustedMessage
3697
- : isTotalExecutionTimeout
3698
- ? `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`
3699
- : isTimeout
3700
- ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
3701
- : getErrorMessage(error, undefined);
3742
+ : retryInputAlreadySubmitted
3743
+ ? formatRetryInputAlreadySubmittedMessage(error)
3744
+ : modelFallbackExhaustedMessage
3745
+ ? modelFallbackExhaustedMessage
3746
+ : isTotalExecutionTimeout
3747
+ ? `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`
3748
+ : isTimeout
3749
+ ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
3750
+ : getErrorMessage(error, undefined);
3702
3751
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
3703
3752
  let sendOpts;
3704
3753
  try {
@@ -3715,14 +3764,14 @@ export class ResponseEngine {
3715
3764
  const errorPayload = {
3716
3765
  kind: 'result.error',
3717
3766
  text: userMessage,
3718
- reason: retryExhaustedCount ? 'retry_exhausted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
3767
+ reason: retryExhaustedCount ? 'retry_exhausted' : retryInputAlreadySubmitted ? 'input_already_submitted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
3719
3768
  };
3720
3769
  if (!isUserInterrupt && daemonTrigger && procStatus === 'error') {
3721
3770
  // DaemonChannel settles a trigger on the first terminal payload. Put
3722
3771
  // the complete failure metadata first so the audit retains tool
3723
3772
  // counts and context-recovery diagnostics even for thrown errors
3724
3773
  // (for example when compact itself fails).
3725
- await adapter.send({ ...envelope, replyContext: sendOpts }, {
3774
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), {
3726
3775
  kind: 'status.error',
3727
3776
  metadata: {
3728
3777
  errorType: errType,
@@ -3735,17 +3784,17 @@ export class ResponseEngine {
3735
3784
  contextRecoveryAttempts: streamResult.contextRecoveryAttempts ?? 0,
3736
3785
  },
3737
3786
  }).catch(() => { });
3738
- await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
3787
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), errorPayload);
3739
3788
  }
3740
3789
  else {
3741
- await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
3790
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), errorPayload);
3742
3791
  }
3743
3792
  if (!isUserInterrupt && daemonTrigger && procStatus !== 'error') {
3744
- await adapter.send({ ...envelope, replyContext: sendOpts }, statusPayload).catch(() => { });
3793
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), statusPayload).catch(() => { });
3745
3794
  }
3746
3795
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
3747
3796
  if (isProactive && adapter.capabilities?.thought) {
3748
- await adapter.send({ ...envelope, replyContext: sendOpts }, {
3797
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), {
3749
3798
  kind: 'activity.batch',
3750
3799
  items: [{
3751
3800
  kind: 'notice',
@@ -3973,7 +4022,7 @@ export class ResponseEngine {
3973
4022
  */
3974
4023
  async processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress, proactive,
3975
4024
  /** [迁移点4/5] 响应模式插件 + 状态,用于调 onToolUse/onComplete 钩子 */
3976
- modeHooks, causation, turnLease) {
4025
+ modeHooks, causation, turnLease, permissionMode) {
3977
4026
  // Per-session agent name for stats bucketing
3978
4027
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
3979
4028
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
@@ -4059,6 +4108,11 @@ export class ResponseEngine {
4059
4108
  }
4060
4109
  }
4061
4110
  }
4111
+ else if (event.type === 'notice') {
4112
+ for (const callId of event.closedToolCallIds ?? []) {
4113
+ openToolUseIds.delete(callId);
4114
+ }
4115
+ }
4062
4116
  else if (event.type === 'complete') {
4063
4117
  assistantUuid = event.assistantUuid ?? assistantUuid;
4064
4118
  protocolIncompleteReason = event.protocolIncompleteReason ?? protocolIncompleteReason;
@@ -4295,6 +4349,9 @@ export class ResponseEngine {
4295
4349
  sessionId: session.id,
4296
4350
  toolName: event.name,
4297
4351
  input: event.input,
4352
+ ...(event.callId ? { callId: event.callId } : {}),
4353
+ ...(session.selfAID ? { agentAid: session.selfAID } : {}),
4354
+ ...(permissionMode ? { permissionMode } : {}),
4298
4355
  timestamp: Date.now(),
4299
4356
  causation,
4300
4357
  });
@@ -4344,6 +4401,9 @@ export class ResponseEngine {
4344
4401
  toolName: event.name,
4345
4402
  isError: event.isError,
4346
4403
  agentName: agentNameForStats,
4404
+ ...(event.callId ? { callId: event.callId } : {}),
4405
+ ...(session.selfAID ? { agentAid: session.selfAID } : {}),
4406
+ ...(permissionMode ? { permissionMode } : {}),
4347
4407
  timestamp: Date.now(),
4348
4408
  causation,
4349
4409
  });
@@ -4360,6 +4420,12 @@ export class ResponseEngine {
4360
4420
  renderer.addToolResult(event.name || '\u5de5\u5177', true, event.result, undefined, event.callId, event.durationMs, cachedDesc);
4361
4421
  }
4362
4422
  }
4423
+ // 非终态运行时告警走 status notify,不进入 activity/thought 历史。
4424
+ if (event.type === 'notice' && event.severity === 'warn') {
4425
+ await renderer.sendWarningStatus(event.message, event.subtype, {
4426
+ ...(event.missingItems !== undefined && { missingItems: event.missingItems }),
4427
+ });
4428
+ }
4363
4429
  // 运行时错误(Codex: turn.failed / item error)
4364
4430
  if (event.type === 'error') {
4365
4431
  logger.warn(`[ResponseEngine] error event: ${event.errorType}: ${event.error}`);
@@ -56,9 +56,10 @@ export class StreamDebouncer {
56
56
  * 如果窗口只剩这一条,整个窗口取消。
57
57
  * @returns true 如果找到并移除
58
58
  */
59
- cancel(messageId) {
59
+ cancel(messageId, selfAID) {
60
60
  for (const [key, win] of this.pending) {
61
- const idx = win.entries.findIndex(e => e.messageId === messageId);
61
+ const idx = win.entries.findIndex(e => e.messageId === messageId
62
+ && (selfAID === undefined || e.rest.selfAID === selfAID));
62
63
  if (idx === -1)
63
64
  continue;
64
65
  // resolve 被撤回的那条(静默完成,不报错)
@@ -89,8 +90,13 @@ export class StreamDebouncer {
89
90
  const contents = [];
90
91
  const sourceMessageIds = [];
91
92
  const seenSourceMessageIds = new Set();
93
+ let receivedAt;
92
94
  for (const e of entries) {
93
95
  contents.push(e.content);
96
+ const entryReceivedAt = e.rest.receivedAt ?? e.rest.timestamp;
97
+ if (entryReceivedAt !== undefined) {
98
+ receivedAt = receivedAt === undefined ? entryReceivedAt : Math.min(receivedAt, entryReceivedAt);
99
+ }
94
100
  if (e.images)
95
101
  allImages.push(...e.images);
96
102
  if (e.mentions)
@@ -119,6 +125,8 @@ export class StreamDebouncer {
119
125
  replyContext: last.replyContext,
120
126
  messageId: latestMessageId,
121
127
  sourceMessageIds: sourceMessageIds.length > 0 ? sourceMessageIds : undefined,
128
+ receivedAt,
129
+ timestamp: receivedAt ?? last.rest.timestamp,
122
130
  };
123
131
  const resolves = entries.map(e => e.resolve);
124
132
  const rejects = entries.map(e => e.reject);
@@ -566,6 +566,7 @@ export class PermissionGateway {
566
566
  return decisionPromise;
567
567
  }
568
568
  const ownerReplyContext = {
569
+ delivery: { chatType: 'private' },
569
570
  metadata: {
570
571
  source: 'handoff',
571
572
  chatmode: 'interactive',