evolcore 0.0.12 → 0.0.14

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 (116) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/codex-managed-hook.mjs +4 -1
  3. package/bin/ec-safe-output.js +26 -7
  4. package/bin/install-codex-managed-hooks.mjs +201 -0
  5. package/dist/agents/claude-runner.js +105 -11
  6. package/dist/agents/codex-app-server-client.js +176 -7
  7. package/dist/agents/codex-runner.js +248 -84
  8. package/dist/agents/ecagent-runner.js +21 -2
  9. package/dist/agents/gemini-runner.js +9 -4
  10. package/dist/aun/aid/control-aid.js +36 -13
  11. package/dist/aun/aid/store.js +13 -7
  12. package/dist/aun/group-fs-download.js +56 -0
  13. package/dist/aun/group-identity.js +1 -2
  14. package/dist/aun/msg/group.js +11 -33
  15. package/dist/aun/msg/index.js +1 -2
  16. package/dist/aun/msg/managed-operation.js +176 -55
  17. package/dist/aun/msg/upload.js +16 -1
  18. package/dist/aun/outbox.js +31 -2
  19. package/dist/aun/rpc/client.js +66 -0
  20. package/dist/aun/rpc/index.js +1 -2
  21. package/dist/aun/storage/{upload.js → client.js} +11 -2
  22. package/dist/aun/storage/index.js +1 -2
  23. package/dist/channels/aun.js +514 -150
  24. package/dist/channels/daemon.js +2 -0
  25. package/dist/cli/agent.js +9 -96
  26. package/dist/cli/aun-commands.js +1 -1
  27. package/dist/cli/cli-argv.js +10 -0
  28. package/dist/cli/config.js +9 -17
  29. package/dist/cli/ctl-command.js +1 -1
  30. package/dist/cli/daemon-commands.js +39 -24
  31. package/dist/cli/fs-command.js +65 -19
  32. package/dist/cli/init.js +77 -8
  33. package/dist/cli/queue-command.js +42 -0
  34. package/dist/cli/restart-monitor.js +3 -14
  35. package/dist/cli/task-context.js +172 -0
  36. package/dist/cli/trigger-command.js +7 -0
  37. package/dist/config/access-policy.js +1 -2
  38. package/dist/config/builtin-roles.js +7 -0
  39. package/dist/config/config-manager.js +27 -3
  40. package/dist/config/contact-book-store.js +7 -2
  41. package/dist/config/peer-role-resolver.js +6 -2
  42. package/dist/config/role-migration-startup.js +53 -0
  43. package/dist/config/role-store.js +1 -1
  44. package/dist/core/auth/agent-delegation.js +1 -1
  45. package/dist/core/auth/auth-gateway.js +30 -1
  46. package/dist/core/auth/authorization-audit.js +47 -0
  47. package/dist/core/auth/operation-authorizer.js +1 -0
  48. package/dist/core/auth/operation-catalog.js +10 -1
  49. package/dist/core/bootstrap-messages.js +9 -2
  50. package/dist/core/bootstrap-service.js +8 -1
  51. package/dist/core/command/command-handler.js +201 -51
  52. package/dist/core/command/menu-catalog.js +4 -0
  53. package/dist/core/command/menu-handler.js +85 -18
  54. package/dist/core/command/menu-protocol.js +23 -2
  55. package/dist/core/command/slash-handler.js +58 -10
  56. package/dist/core/event-catalog.js +8 -0
  57. package/dist/core/handoff/runtime.js +12 -1
  58. package/dist/core/handoff/store.js +5 -1
  59. package/dist/core/interaction-router.js +10 -0
  60. package/dist/core/message/im-renderer.js +34 -1
  61. package/dist/core/message/message-bridge.js +68 -13
  62. package/dist/core/message/message-log.js +2 -0
  63. package/dist/core/message/message-queue.js +267 -39
  64. package/dist/core/message/message-utils.js +81 -4
  65. package/dist/core/message/response-engine.js +242 -48
  66. package/dist/core/message/stream-debouncer.js +10 -2
  67. package/dist/core/permission/approval-gateway.js +1 -0
  68. package/dist/core/permission/ec-command-parser.js +649 -18
  69. package/dist/core/permission/index.js +1 -1
  70. package/dist/core/permission/mode.js +15 -0
  71. package/dist/core/permission/readonly-shell-query.js +148 -8
  72. package/dist/core/permission/sandbox-runtime.js +21 -0
  73. package/dist/core/permission/tool-policy.js +266 -56
  74. package/dist/core/protected-paths.js +107 -2
  75. package/dist/core/runtime-lock.js +101 -0
  76. package/dist/core/session/session-manager.js +21 -2
  77. package/dist/core/session/session-mapper.js +20 -1
  78. package/dist/eck/detect.js +18 -1
  79. package/dist/eck/group-rules-sync.js +15 -44
  80. package/dist/index.js +191 -89
  81. package/dist/ipc.js +16 -3
  82. package/dist/paths.js +3 -0
  83. package/dist/response-system/engines/v1/proactive-flow.js +92 -8
  84. package/dist/response-system/modes/single-session/index.js +3 -0
  85. package/dist/trigger/feedback.js +64 -11
  86. package/dist/trigger/history.js +42 -7
  87. package/dist/trigger/manager.js +21 -1
  88. package/dist/trigger/parser.js +14 -2
  89. package/dist/trigger/scheduler.js +1 -0
  90. package/dist/trigger/validation.js +67 -6
  91. package/dist/utils/ecweb-supervisor.js +332 -0
  92. package/dist/utils/error-utils.js +17 -0
  93. package/dist/utils/logger.js +37 -4
  94. package/kits/docs/evolcore/trigger.md +17 -1
  95. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  96. package/kits/schemas/role-config.schema.1.json +1 -0
  97. package/kits/templates/roles/admin.json +7 -0
  98. package/kits/templates/roles/member.json +8 -1
  99. package/kits/templates/roles/visitor.json +2 -0
  100. package/package.json +1 -1
  101. package/dist/aun/msg/payload-type.js +0 -27
  102. package/dist/aun/rpc/caller.js +0 -42
  103. package/dist/aun/rpc/connection.js +0 -25
  104. package/dist/aun/storage/manage.js +0 -10
  105. package/dist/config/access-policy-domain.js +0 -18
  106. package/dist/config/config-batch-get.js +0 -11
  107. package/dist/config/owner-policy.js +0 -4
  108. package/dist/config/role-config-v4-startup.js +0 -32
  109. package/dist/config/role-config-v5-startup.js +0 -27
  110. package/dist/core/auth/trigger-authorization.js +0 -15
  111. package/dist/core/interaction-registration.js +0 -10
  112. package/dist/core/permission/execution-sandbox.js +0 -16
  113. package/dist/core/relation/peer-key.js +0 -1
  114. package/dist/core/session/session-key.js +0 -24
  115. package/dist/eck/baseagent-caps.js +0 -18
  116. package/dist/eck/rules-loader.js +0 -28
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
5
  import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForContextWindow, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken } from '../../agents/runner-types.js';
6
- import { buildTaskRuntimeEnv } from '../../cli/task-context.js';
6
+ import { buildTaskRuntimeEnv, ensureRuntimeLockDir, ensureSessionRuntimeDir } from '../../cli/task-context.js';
7
7
  import { IMRenderer } from './im-renderer.js';
8
8
  import { createSendFileMarkerPattern } from './file-markers.js';
9
9
  import { createTextInferenceProvider } from '../inference/text-inference.js';
@@ -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, opts.proactiveSelfAid);
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
  {
@@ -1965,6 +1992,7 @@ export class ResponseEngine {
1965
1992
  const renderer = new IMRenderer({
1966
1993
  adapter,
1967
1994
  envelope,
1995
+ agentAid: session.selfAID,
1968
1996
  flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
1969
1997
  suppressActivityItems: isProactive ? false : middleOutputMode !== 'all',
1970
1998
  suppressIntermediateText: isProactive ? false : middleOutputMode === 'none',
@@ -2007,7 +2035,7 @@ export class ResponseEngine {
2007
2035
  opts.metadata = withTaskMetadata(opts.metadata);
2008
2036
  if (payload.kind.startsWith('status.'))
2009
2037
  this.touchAgentActivity(channelKey);
2010
- const enrichedEnvelope = { ...envelope, replyContext: opts };
2038
+ const enrichedEnvelope = withEnvelopeReplyContext(envelope, opts);
2011
2039
  snapshot.pushOutbound(session.id, taskId, { kind: payload.kind, decision: 'sent' });
2012
2040
  await adapter.send(enrichedEnvelope, payload);
2013
2041
  },
@@ -2032,7 +2060,7 @@ export class ResponseEngine {
2032
2060
  const capturedReplyContext = taskReplyContext();
2033
2061
  // 设置权限审批的消息发送回调(指向当前渠道)
2034
2062
  const permissionPrompt = async (text) => {
2035
- await adapter.send({ ...envelope, replyContext: capturedReplyContext }, { kind: 'result.text', text, isFinal: false });
2063
+ await adapter.send(withEnvelopeReplyContext(envelope, capturedReplyContext), { kind: 'result.text', text, isFinal: false });
2036
2064
  };
2037
2065
  const authChatType = configChatType;
2038
2066
  const authConversationId = authChatType === 'group'
@@ -2046,15 +2074,16 @@ export class ResponseEngine {
2046
2074
  : undefined;
2047
2075
  const permissionAdapter = triggerOriginChannelInfo?.adapter ?? adapter;
2048
2076
  const permissionChannelId = authorizationIdentity?.actorId ?? capturedChannelId;
2049
- const permissionReplyContext = authorizationIdentity ? undefined : capturedReplyContext;
2077
+ const permissionReplyContext = authorizationIdentity
2078
+ ? { delivery: { chatType: 'private' } }
2079
+ : capturedReplyContext;
2050
2080
  let permissionTerminalFailure;
2051
2081
  const permissionPromptForOrigin = async (text) => {
2052
- await permissionAdapter.send({
2082
+ await permissionAdapter.send(withEnvelopeReplyContext({
2053
2083
  ...envelope,
2054
2084
  channel: authorizationIdentity?.originChannelKey ?? envelope.channel,
2055
2085
  channelId: permissionChannelId,
2056
- replyContext: permissionReplyContext,
2057
- }, { kind: 'result.text', text, isFinal: false });
2086
+ }, permissionReplyContext), { kind: 'result.text', text, isFinal: false });
2058
2087
  };
2059
2088
  agent.setSendPrompt(permissionPromptForOrigin);
2060
2089
  const owningAgentForAuth = owningAgentForTask;
@@ -2142,6 +2171,7 @@ export class ResponseEngine {
2142
2171
  ? (anomaly) => {
2143
2172
  recordTriggerExecutionAnomaly(triggerRunId, {
2144
2173
  ...anomaly,
2174
+ correlationId: anomaly.correlationId ?? anomaly.requestId,
2145
2175
  agentAid: anomaly.agentAid ?? session.selfAID ?? message.selfAID,
2146
2176
  sessionId: anomaly.sessionId ?? session.id,
2147
2177
  permissionMode: anomaly.permissionMode ?? effectivePermissionMode,
@@ -2200,7 +2230,7 @@ export class ResponseEngine {
2200
2230
  },
2201
2231
  },
2202
2232
  permissionStateChanged: async (permissionEvent) => {
2203
- const executionEnvelope = { ...envelope, replyContext: capturedReplyContext };
2233
+ const executionEnvelope = withEnvelopeReplyContext(envelope, capturedReplyContext);
2204
2234
  if (permissionEvent.state === 'waiting') {
2205
2235
  const payload = {
2206
2236
  kind: 'status.requires_action',
@@ -2214,12 +2244,11 @@ export class ResponseEngine {
2214
2244
  };
2215
2245
  await adapter.send(executionEnvelope, payload);
2216
2246
  if (permissionAdapter !== adapter || permissionChannelId !== capturedChannelId) {
2217
- await permissionAdapter.send({
2247
+ await permissionAdapter.send(withEnvelopeReplyContext({
2218
2248
  ...envelope,
2219
2249
  channel: authorizationIdentity?.originChannelKey ?? envelope.channel,
2220
2250
  channelId: permissionChannelId,
2221
- replyContext: permissionReplyContext,
2222
- }, payload);
2251
+ }, permissionReplyContext), payload);
2223
2252
  }
2224
2253
  return;
2225
2254
  }
@@ -2731,6 +2760,12 @@ export class ResponseEngine {
2731
2760
  this.handoffRuntime.completeTargetContext(selfAid, handoffId);
2732
2761
  }
2733
2762
  }
2763
+ // The runner may already own a stronger per-session directory (Codex
2764
+ // registers one before constructing its thread). Other runners use a
2765
+ // private child of the process-provided TMPDIR. Both paths are passed
2766
+ // explicitly so sandboxed helper commands never fall back to a shared
2767
+ // agent or project directory for transient state.
2768
+ const sessionRuntimeDir = agent.getManagedTempDir?.(session.id) ?? ensureSessionRuntimeDir(session.id);
2734
2769
  const taskRuntimeContext = {
2735
2770
  taskId,
2736
2771
  sessionId: session.id,
@@ -2744,6 +2779,8 @@ export class ResponseEngine {
2744
2779
  peerType: message.peerType || session.metadata?.peerType || undefined,
2745
2780
  peerRole,
2746
2781
  threadId: session.threadId || undefined,
2782
+ sessionRuntimeDir,
2783
+ runtimeLockDir: ensureRuntimeLockDir(sessionRuntimeDir),
2747
2784
  handoffIds: v2HandoffIds.length > 0 && v2HandoffDirection === 'target' ? v2HandoffIds : undefined,
2748
2785
  causation: taskCausation,
2749
2786
  };
@@ -2777,6 +2814,7 @@ export class ResponseEngine {
2777
2814
  ? (agent.resolveModelId(model) ?? model)
2778
2815
  : model));
2779
2816
  const retryAttemptTimeoutMs = this.retryAttemptTimeoutMs();
2817
+ const retryInputIsAtMostOnce = agent.retryInputSemantics === 'at_most_once';
2780
2818
  let runAttempt = 1;
2781
2819
  const recordRetryHealthError = async (retryError) => {
2782
2820
  if (!policy.accumulateErrors(chatType, identityRole))
@@ -2813,14 +2851,17 @@ export class ResponseEngine {
2813
2851
  const processAttempt = this.processEventStream(stream, session, agent, renderer, (eventType, toolName) => {
2814
2852
  resetTimer(eventType, toolName);
2815
2853
  attemptTimeout?.reset();
2816
- }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease);
2854
+ }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease, effectivePermissionMode, session.selfAID || message.selfAID);
2817
2855
  streamResult = attemptTimeout
2818
2856
  ? await Promise.race([processAttempt, attemptTimeout.promise])
2819
2857
  : await processAttempt;
2820
2858
  if (!streamResult.isError) {
2821
2859
  const protocolDecision = this.turnCoordinator.evaluateCommit(turnLease, this.buildTurnCommitEvidence(session, streamResult));
2822
2860
  const protocolReason = protocolDecision.ok ? undefined : protocolDecision.reason;
2823
- const safeProtocolReplay = isSafeProtocolReplayCandidate(streamResult, protocolReason);
2861
+ // Codex app-server turn/start appends its input before runQuery
2862
+ // resolves. Replaying here would create a second user turn.
2863
+ const safeProtocolReplay = !retryInputIsAtMostOnce
2864
+ && isSafeProtocolReplayCandidate(streamResult, protocolReason);
2824
2865
  const protocolRetry = retryScheduler.afterProtocolFailure(safeProtocolReplay ? protocolReason : undefined, safeProtocolReplay);
2825
2866
  if (protocolRetry.kind === 'protocol_replay') {
2826
2867
  protocolReplayAttempts = protocolRetry.attempt;
@@ -2865,6 +2906,16 @@ export class ResponseEngine {
2865
2906
  permissionError._errorAlreadySent = true;
2866
2907
  throw permissionError;
2867
2908
  }
2909
+ const retryWouldReplaySubmittedInput = retryAttemptTimedOut
2910
+ || isRetryableError(retryError)
2911
+ || classifyError(retryError) === ErrorType.MODEL_UNAVAILABLE;
2912
+ if (retryInputIsAtMostOnce && streamRegistered && retryWouldReplaySubmittedInput) {
2913
+ await recordRetryHealthError(retryError);
2914
+ markRetryInputAlreadySubmitted(retryError);
2915
+ logger.warn(`[ResponseEngine] Not replaying already-submitted input: agent=${agent.name} `
2916
+ + `session=${session.id} task=${taskId} attempt=${runAttempt}`);
2917
+ throw retryError;
2918
+ }
2868
2919
  if (classifyError(retryError) === ErrorType.MODEL_UNAVAILABLE && !hasExplicitTriggerModel) {
2869
2920
  if (probingPrimaryModel && previousFallbackState) {
2870
2921
  const resumedFallback = previousFallbackState.activeFallback;
@@ -2958,6 +3009,8 @@ export class ResponseEngine {
2958
3009
  resetTimer,
2959
3010
  shouldSuppress,
2960
3011
  proactive,
3012
+ proactiveSelfAid: session.selfAID || message.selfAID,
3013
+ permissionMode: effectivePermissionMode,
2961
3014
  turnLease,
2962
3015
  });
2963
3016
  }
@@ -2999,6 +3052,8 @@ export class ResponseEngine {
2999
3052
  resetTimer,
3000
3053
  shouldSuppress,
3001
3054
  proactive,
3055
+ proactiveSelfAid: session.selfAID || message.selfAID,
3056
+ permissionMode: effectivePermissionMode,
3002
3057
  turnLease,
3003
3058
  });
3004
3059
  // 重试后仍然 prompt_too_long:显示友好提示
@@ -3070,7 +3125,7 @@ export class ResponseEngine {
3070
3125
  return;
3071
3126
  const isCurrentlyBackground = this.isBackgroundSession(session, message.channel, message.channelId);
3072
3127
  if (!isCurrentlyBackground) {
3073
- await adapter.send({ ...envelope, replyContext: capturedReplyContext }, payload);
3128
+ await adapter.send(withEnvelopeReplyContext(envelope, capturedReplyContext), payload);
3074
3129
  if (payload.kind === 'result.text') {
3075
3130
  logger.info(`[ResponseEngine] afterProcess sent: task=${taskId} text="${payload.text.slice(0, 60)}"`);
3076
3131
  }
@@ -3158,7 +3213,10 @@ export class ResponseEngine {
3158
3213
  // 发送文件
3159
3214
  logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
3160
3215
  try {
3161
- await targetInfo.adapter.send(buildEnvelope({ taskId, channel: targetInfo.adapter.channelName, channelId: targetChannelId, agentName: agentNameForStats, replyContext: capturedReplyContext }), { kind: 'result.file', filePath: resolvedPath });
3216
+ const targetReplyContext = isCrossChannel
3217
+ ? { delivery: { chatType: 'private' } }
3218
+ : capturedReplyContext;
3219
+ await targetInfo.adapter.send(buildEnvelope({ taskId, channel: targetInfo.adapter.channelName, channelId: targetChannelId, agentName: agentNameForStats, replyContext: targetReplyContext }), { kind: 'result.file', filePath: resolvedPath });
3162
3220
  this.eventBus.publish({ type: 'runner:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
3163
3221
  sent++;
3164
3222
  if (isCrossChannel) {
@@ -3681,6 +3739,7 @@ export class ResponseEngine {
3681
3739
  // processEventStream 已通过 renderer 发过错误时也跳过
3682
3740
  const retryExhaustedCount = getRetryExhaustedCount(error);
3683
3741
  const modelFallbackExhaustedMessage = getModelFallbackExhaustedMessage(error);
3742
+ const retryInputAlreadySubmitted = wasRetryInputAlreadySubmitted(error);
3684
3743
  if (isUserInterrupt) {
3685
3744
  logger.info(`[ResponseEngine] User interrupt by new_message, skip sending error message`);
3686
3745
  }
@@ -3692,13 +3751,15 @@ export class ResponseEngine {
3692
3751
  const idleSec = getLastIdleSec?.() || 0;
3693
3752
  const userMessage = retryExhaustedCount
3694
3753
  ? formatRetryableErrorFinalMessage(error, retryExhaustedCount)
3695
- : modelFallbackExhaustedMessage
3696
- ? modelFallbackExhaustedMessage
3697
- : isTotalExecutionTimeout
3698
- ? `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`
3699
- : isTimeout
3700
- ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
3701
- : getErrorMessage(error, undefined);
3754
+ : retryInputAlreadySubmitted
3755
+ ? formatRetryInputAlreadySubmittedMessage(error)
3756
+ : modelFallbackExhaustedMessage
3757
+ ? modelFallbackExhaustedMessage
3758
+ : isTotalExecutionTimeout
3759
+ ? `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`
3760
+ : isTimeout
3761
+ ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
3762
+ : getErrorMessage(error, undefined);
3702
3763
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
3703
3764
  let sendOpts;
3704
3765
  try {
@@ -3715,14 +3776,14 @@ export class ResponseEngine {
3715
3776
  const errorPayload = {
3716
3777
  kind: 'result.error',
3717
3778
  text: userMessage,
3718
- reason: retryExhaustedCount ? 'retry_exhausted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
3779
+ reason: retryExhaustedCount ? 'retry_exhausted' : retryInputAlreadySubmitted ? 'input_already_submitted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
3719
3780
  };
3720
3781
  if (!isUserInterrupt && daemonTrigger && procStatus === 'error') {
3721
3782
  // DaemonChannel settles a trigger on the first terminal payload. Put
3722
3783
  // the complete failure metadata first so the audit retains tool
3723
3784
  // counts and context-recovery diagnostics even for thrown errors
3724
3785
  // (for example when compact itself fails).
3725
- await adapter.send({ ...envelope, replyContext: sendOpts }, {
3786
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), {
3726
3787
  kind: 'status.error',
3727
3788
  metadata: {
3728
3789
  errorType: errType,
@@ -3735,17 +3796,17 @@ export class ResponseEngine {
3735
3796
  contextRecoveryAttempts: streamResult.contextRecoveryAttempts ?? 0,
3736
3797
  },
3737
3798
  }).catch(() => { });
3738
- await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
3799
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), errorPayload);
3739
3800
  }
3740
3801
  else {
3741
- await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
3802
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), errorPayload);
3742
3803
  }
3743
3804
  if (!isUserInterrupt && daemonTrigger && procStatus !== 'error') {
3744
- await adapter.send({ ...envelope, replyContext: sendOpts }, statusPayload).catch(() => { });
3805
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), statusPayload).catch(() => { });
3745
3806
  }
3746
3807
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
3747
3808
  if (isProactive && adapter.capabilities?.thought) {
3748
- await adapter.send({ ...envelope, replyContext: sendOpts }, {
3809
+ await adapter.send(withEnvelopeReplyContext(envelope, sendOpts), {
3749
3810
  kind: 'activity.batch',
3750
3811
  items: [{
3751
3812
  kind: 'notice',
@@ -3973,7 +4034,7 @@ export class ResponseEngine {
3973
4034
  */
3974
4035
  async processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress, proactive,
3975
4036
  /** [迁移点4/5] 响应模式插件 + 状态,用于调 onToolUse/onComplete 钩子 */
3976
- modeHooks, causation, turnLease) {
4037
+ modeHooks, causation, turnLease, permissionMode, proactiveSelfAid) {
3977
4038
  // Per-session agent name for stats bucketing
3978
4039
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
3979
4040
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
@@ -3989,6 +4050,7 @@ export class ResponseEngine {
3989
4050
  let toolUseCount = 0;
3990
4051
  const openToolUseIds = new Set();
3991
4052
  const anonymousToolUseIds = new Map();
4053
+ const anonymousOpenIdByCorrelation = new Map();
3992
4054
  const finalizeResult = () => ({
3993
4055
  ...completeResult,
3994
4056
  hasReceivedText,
@@ -4004,7 +4066,28 @@ export class ResponseEngine {
4004
4066
  let lastReplyText = '';
4005
4067
  // callId → description 映射,用于 tool_result 回显描述
4006
4068
  const toolDescByCallId = new Map();
4069
+ const toolInputByCallId = new Map();
4070
+ const toolInputByCorrelationId = new Map();
4071
+ const anonymousToolInputsByName = new Map();
4007
4072
  const ctlQueueReadCallIds = new Set();
4073
+ const removePendingToolInput = (pending) => {
4074
+ for (const [callId, value] of toolInputByCallId) {
4075
+ if (value === pending)
4076
+ toolInputByCallId.delete(callId);
4077
+ }
4078
+ for (const [correlationId, value] of toolInputByCorrelationId) {
4079
+ if (value === pending)
4080
+ toolInputByCorrelationId.delete(correlationId);
4081
+ }
4082
+ const anonymous = anonymousToolInputsByName.get(pending.name);
4083
+ if (anonymous) {
4084
+ const remaining = anonymous.filter(value => value !== pending);
4085
+ if (remaining.length > 0)
4086
+ anonymousToolInputsByName.set(pending.name, remaining);
4087
+ else
4088
+ anonymousToolInputsByName.delete(pending.name);
4089
+ }
4090
+ };
4008
4091
  try {
4009
4092
  for await (let event of stream) {
4010
4093
  if (turnLease && !this.turnCoordinator.isCurrent(turnLease)) {
@@ -4036,16 +4119,44 @@ export class ResponseEngine {
4036
4119
  else {
4037
4120
  const syntheticId = `__anonymous_tool_${toolUseCount}`;
4038
4121
  openToolUseIds.add(syntheticId);
4122
+ if (event.correlationId)
4123
+ anonymousOpenIdByCorrelation.set(event.correlationId, syntheticId);
4039
4124
  const pending = anonymousToolUseIds.get(event.name) ?? [];
4040
4125
  pending.push(syntheticId);
4041
4126
  anonymousToolUseIds.set(event.name, pending);
4042
4127
  }
4043
4128
  }
4044
4129
  else if (event.type === 'tool_result') {
4045
- if (event.callId) {
4130
+ // A result carrying both identifiers may be stale or replayed. Do not
4131
+ // close the active call until both identifiers resolve to the same
4132
+ // pending tool invocation; otherwise a mismatched result can make the
4133
+ // protocol look complete even though the real call is still open.
4134
+ const hasBothResultIdentifiers = !!event.callId && !!event.correlationId;
4135
+ const byCallId = event.callId ? toolInputByCallId.get(event.callId) : undefined;
4136
+ const byCorrelationId = event.correlationId
4137
+ ? toolInputByCorrelationId.get(event.correlationId)
4138
+ : undefined;
4139
+ const identifiersMatch = !hasBothResultIdentifiers
4140
+ || (!!byCallId && !!byCorrelationId && byCallId === byCorrelationId);
4141
+ if (event.callId && identifiersMatch) {
4046
4142
  openToolUseIds.delete(event.callId);
4047
4143
  }
4048
- else {
4144
+ else if (!event.callId && event.correlationId && byCorrelationId) {
4145
+ const syntheticId = anonymousOpenIdByCorrelation.get(event.correlationId);
4146
+ if (syntheticId) {
4147
+ openToolUseIds.delete(syntheticId);
4148
+ anonymousOpenIdByCorrelation.delete(event.correlationId);
4149
+ }
4150
+ else {
4151
+ for (const [callId, pending] of toolInputByCallId) {
4152
+ if (pending === byCorrelationId) {
4153
+ openToolUseIds.delete(callId);
4154
+ break;
4155
+ }
4156
+ }
4157
+ }
4158
+ }
4159
+ else if (!event.callId) {
4049
4160
  const names = event.name ? [event.name] : [...anonymousToolUseIds.keys()];
4050
4161
  for (const name of names) {
4051
4162
  const pending = anonymousToolUseIds.get(name);
@@ -4059,6 +4170,11 @@ export class ResponseEngine {
4059
4170
  }
4060
4171
  }
4061
4172
  }
4173
+ else if (event.type === 'notice') {
4174
+ for (const callId of event.closedToolCallIds ?? []) {
4175
+ openToolUseIds.delete(callId);
4176
+ }
4177
+ }
4062
4178
  else if (event.type === 'complete') {
4063
4179
  assistantUuid = event.assistantUuid ?? assistantUuid;
4064
4180
  protocolIncompleteReason = event.protocolIncompleteReason ?? protocolIncompleteReason;
@@ -4295,14 +4411,33 @@ export class ResponseEngine {
4295
4411
  sessionId: session.id,
4296
4412
  toolName: event.name,
4297
4413
  input: event.input,
4414
+ ...(event.callId ? { callId: event.callId } : {}),
4415
+ ...(event.correlationId || event.callId ? { correlationId: event.correlationId ?? event.callId } : {}),
4416
+ ...(session.selfAID ? { agentAid: session.selfAID } : {}),
4417
+ ...(permissionMode ? { permissionMode } : {}),
4298
4418
  timestamp: Date.now(),
4299
4419
  causation,
4300
4420
  });
4421
+ const desc = summarizeToolInput(event.name, event.input || {});
4422
+ const pendingInput = { name: event.name, input: event.input || {} };
4423
+ if (event.callId)
4424
+ toolInputByCallId.set(event.callId, pendingInput);
4425
+ if (event.correlationId)
4426
+ toolInputByCorrelationId.set(event.correlationId, pendingInput);
4427
+ else if (event.callId) {
4428
+ // Renderer/event-log normalization uses callId as the fallback
4429
+ // correlation. Keep the same alias here so a result carrying both
4430
+ // fields with that fallback still matches the original call.
4431
+ toolInputByCorrelationId.set(event.callId, pendingInput);
4432
+ }
4433
+ if (!event.callId && !event.correlationId) {
4434
+ const pending = anonymousToolInputsByName.get(event.name) ?? [];
4435
+ pending.push(pendingInput);
4436
+ anonymousToolInputsByName.set(event.name, pending);
4437
+ }
4301
4438
  if (!shouldSuppress()) {
4302
- const desc = summarizeToolInput(event.name, event.input || {});
4303
- if (event.callId) {
4439
+ if (event.callId)
4304
4440
  toolDescByCallId.set(event.callId, desc);
4305
- }
4306
4441
  renderer.addToolCall(event.name, event.input, event.callId, desc, event.turn, event.outputTokens);
4307
4442
  }
4308
4443
  if (event.callId && isCtlQueueReadCommand(event.name, event.input || {})) {
@@ -4318,7 +4453,7 @@ export class ResponseEngine {
4318
4453
  toolInput: event.input || {},
4319
4454
  injectToModel: (text) => { agent.injectUserMessage?.(session.id, text); },
4320
4455
  getQueueLength: () => this.messageQueue?.getQueueLength(session.id) ?? 0,
4321
- isSendCommand: (toolName, toolInput) => isExactEvolcoreSendCommandForSession(toolName, toolInput, session.channelId, proactiveState?.chatType === 'group' ? 'group' : 'private', session.selfAID),
4456
+ isSendCommand: (toolName, toolInput) => isExactEvolcoreSendCommandForSession(toolName, toolInput, session.channelId, proactiveState?.chatType === 'group' ? 'group' : 'private', proactiveSelfAid || session.selfAID),
4322
4457
  logger,
4323
4458
  });
4324
4459
  }
@@ -4344,9 +4479,62 @@ export class ResponseEngine {
4344
4479
  toolName: event.name,
4345
4480
  isError: event.isError,
4346
4481
  agentName: agentNameForStats,
4482
+ ...(event.callId ? { callId: event.callId } : {}),
4483
+ ...(event.correlationId || event.callId ? { correlationId: event.correlationId ?? event.callId } : {}),
4484
+ ...(session.selfAID ? { agentAid: session.selfAID } : {}),
4485
+ ...(permissionMode ? { permissionMode } : {}),
4347
4486
  timestamp: Date.now(),
4348
4487
  causation,
4349
4488
  });
4489
+ // A proactive send gate advances only after the matching command
4490
+ // completed successfully and returned an explicit send receipt.
4491
+ let original;
4492
+ const byCallId = event.callId ? toolInputByCallId.get(event.callId) : undefined;
4493
+ const byCorrelationId = event.correlationId
4494
+ ? toolInputByCorrelationId.get(event.correlationId)
4495
+ : undefined;
4496
+ // Both identifiers must describe the same call. This avoids
4497
+ // accepting a stale/replayed correlation as the result of a new
4498
+ // call, or vice versa.
4499
+ const hasBothResultIdentifiers = !!event.callId && !!event.correlationId;
4500
+ if (hasBothResultIdentifiers
4501
+ && (!byCallId || !byCorrelationId || byCallId !== byCorrelationId)) {
4502
+ logger.warn(`[ResponseEngine] Ignoring mismatched tool result identifiers: session=${session.id}`);
4503
+ }
4504
+ else if (!hasBothResultIdentifiers) {
4505
+ original = byCallId ?? byCorrelationId;
4506
+ }
4507
+ else {
4508
+ original = byCallId;
4509
+ }
4510
+ if (!original && !event.callId && !event.correlationId) {
4511
+ const pending = anonymousToolInputsByName.get(event.name);
4512
+ // Without either identifier, FIFO is unsafe when same-named calls
4513
+ // overlap or complete out of order. Leave the gate unchanged until
4514
+ // a unique pending call can be matched.
4515
+ if (pending?.length === 1) {
4516
+ original = pending[0];
4517
+ anonymousToolInputsByName.delete(event.name);
4518
+ }
4519
+ }
4520
+ if (original)
4521
+ removePendingToolInput(original);
4522
+ if (original && modeHooks?.mode?.onToolResult) {
4523
+ const proactiveState = modeHooks.state.get('proactive');
4524
+ await modeHooks.mode.onToolResult({
4525
+ session,
4526
+ state: modeHooks.state,
4527
+ toolName: original.name,
4528
+ toolInput: original.input,
4529
+ result: event.result,
4530
+ isError: event.isError,
4531
+ error: event.error,
4532
+ injectToModel: (text) => { agent.injectUserMessage?.(session.id, text); },
4533
+ getQueueLength: () => this.messageQueue?.getQueueLength(session.id) ?? 0,
4534
+ isSendCommand: (toolName, toolInput) => isExactEvolcoreSendCommandForSession(toolName, toolInput, session.channelId, proactiveState?.chatType === 'group' ? 'group' : 'private', proactiveSelfAid || session.selfAID),
4535
+ logger,
4536
+ });
4537
+ }
4350
4538
  // 从 tool_use 阶段缓存的描述中回溯
4351
4539
  const cachedDesc = event.callId ? toolDescByCallId.get(event.callId) : undefined;
4352
4540
  if (event.isError && !shouldSuppress()) {
@@ -4360,6 +4548,12 @@ export class ResponseEngine {
4360
4548
  renderer.addToolResult(event.name || '\u5de5\u5177', true, event.result, undefined, event.callId, event.durationMs, cachedDesc);
4361
4549
  }
4362
4550
  }
4551
+ // 非终态运行时告警走 status notify,不进入 activity/thought 历史。
4552
+ if (event.type === 'notice' && event.severity === 'warn') {
4553
+ await renderer.sendWarningStatus(event.message, event.subtype, {
4554
+ ...(event.missingItems !== undefined && { missingItems: event.missingItems }),
4555
+ });
4556
+ }
4363
4557
  // 运行时错误(Codex: turn.failed / item error)
4364
4558
  if (event.type === 'error') {
4365
4559
  logger.warn(`[ResponseEngine] error event: ${event.errorType}: ${event.error}`);