evolclaw 2.3.0 → 2.5.0
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.
- package/README.md +33 -14
- package/dist/agents/claude-runner.js +224 -23
- package/dist/agents/codex-runner.js +2 -8
- package/dist/agents/gemini-runner.js +1 -8
- package/dist/channels/aun.js +492 -82
- package/dist/channels/dingtalk.js +506 -0
- package/dist/channels/feishu.js +31 -231
- package/dist/channels/qqbot.js +391 -0
- package/dist/channels/wechat.js +36 -38
- package/dist/channels/wecom.js +549 -0
- package/dist/cli.js +170 -17
- package/dist/config.js +98 -2
- package/dist/core/command-handler.js +511 -146
- package/dist/core/message/message-bridge.js +10 -6
- package/dist/core/message/message-processor.js +176 -78
- package/dist/core/message/message-queue.js +48 -0
- package/dist/core/message/stream-flusher.js +2 -2
- package/dist/core/session/session-manager.js +25 -3
- package/dist/index.js +55 -21
- package/dist/ipc.js +14 -4
- package/dist/templates/skills.md +64 -0
- package/dist/utils/error-dict.js +63 -0
- package/dist/utils/error-utils.js +156 -56
- package/dist/utils/format.js +32 -0
- package/dist/utils/init-channel.js +734 -8
- package/dist/utils/init.js +33 -2
- package/dist/utils/media-cache.js +2 -0
- package/dist/utils/stats-collector.js +0 -8
- package/package.json +9 -3
|
@@ -78,7 +78,8 @@ export class MessageBridge {
|
|
|
78
78
|
// 3. session 解析(使用 Channel 层填充的 chatType)
|
|
79
79
|
const chatType = msg.chatType || 'private';
|
|
80
80
|
const metadata = {};
|
|
81
|
-
|
|
81
|
+
// 话题会话创建时写入 replyContext(用于 threadId 路由);主会话不写(避免群聊覆盖)
|
|
82
|
+
if (msg.threadId && msg.replyContext)
|
|
82
83
|
metadata.replyContext = msg.replyContext;
|
|
83
84
|
// 写入实例名(审计 + 精确出站路由)
|
|
84
85
|
metadata.channelName = channelName;
|
|
@@ -101,6 +102,7 @@ export class MessageBridge {
|
|
|
101
102
|
chatType,
|
|
102
103
|
images: msg.images, timestamp: Date.now(),
|
|
103
104
|
peerId: msg.peerId, peerName: msg.peerName,
|
|
105
|
+
peerType: msg.peerType,
|
|
104
106
|
messageId: msg.messageId,
|
|
105
107
|
mentions: msg.mentions, threadId: msg.threadId,
|
|
106
108
|
replyContext: msg.replyContext,
|
|
@@ -150,8 +152,7 @@ export class MessageBridge {
|
|
|
150
152
|
return false;
|
|
151
153
|
if (parsed.type === 'menu.query') {
|
|
152
154
|
const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
|
|
153
|
-
const
|
|
154
|
-
const items = this.cmdHandler.getMenuItems(isAdmin);
|
|
155
|
+
const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
|
|
155
156
|
const response = JSON.stringify({ type: 'menu.response', items });
|
|
156
157
|
if (adapter?.sendCustomPayload) {
|
|
157
158
|
adapter.sendCustomPayload(msg.channelId, response);
|
|
@@ -195,8 +196,8 @@ export class MessageBridge {
|
|
|
195
196
|
return true;
|
|
196
197
|
}
|
|
197
198
|
/**
|
|
198
|
-
* 撤回消息:先查 debounce 窗口,再查 message queue
|
|
199
|
-
* @returns true
|
|
199
|
+
* 撤回消息:先查 debounce 窗口,再查 message queue,最后查正在执行的任务。
|
|
200
|
+
* @returns true 如果找到并取消/中断
|
|
200
201
|
*/
|
|
201
202
|
cancel(messageId) {
|
|
202
203
|
// 阶段 1: debounce 窗口(尚未入队)
|
|
@@ -205,7 +206,10 @@ export class MessageBridge {
|
|
|
205
206
|
return true;
|
|
206
207
|
}
|
|
207
208
|
// 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
|
|
208
|
-
|
|
209
|
+
if (this.messageQueue.cancel(messageId))
|
|
210
|
+
return true;
|
|
211
|
+
// 阶段 3: 正在执行的任务 → 触发 interrupt
|
|
212
|
+
return this.messageQueue.cancelActive(messageId);
|
|
209
213
|
}
|
|
210
214
|
/** 清理资源 */
|
|
211
215
|
dispose() {
|
|
@@ -7,6 +7,7 @@ import { logger } from '../../utils/logger.js';
|
|
|
7
7
|
import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
|
|
8
8
|
import { summarizeToolInput } from '../permission.js';
|
|
9
9
|
import { getOwner } from '../../config.js';
|
|
10
|
+
import { getPackageRoot, resolveRoot } from '../../paths.js';
|
|
10
11
|
/**
|
|
11
12
|
* 统一消息处理器
|
|
12
13
|
* 负责处理来自不同渠道的消息,协调事件流处理
|
|
@@ -25,6 +26,9 @@ export class MessageProcessor {
|
|
|
25
26
|
defaultAgentId;
|
|
26
27
|
interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
|
|
27
28
|
interactionRouter;
|
|
29
|
+
messageQueue;
|
|
30
|
+
skillsHintDesc = undefined; // undefined=未加载, null=无模板, string=缓存描述
|
|
31
|
+
skillsEnsured = false; // 全局 SKILLS.md 是否已确保
|
|
28
32
|
/** 按 agentId 获取 agent,回退到默认 */
|
|
29
33
|
getAgent(agentId) {
|
|
30
34
|
if (agentId && this.agentMap.has(agentId))
|
|
@@ -67,6 +71,9 @@ export class MessageProcessor {
|
|
|
67
71
|
setInteractionRouter(router) {
|
|
68
72
|
this.interactionRouter = router;
|
|
69
73
|
}
|
|
74
|
+
setMessageQueue(queue) {
|
|
75
|
+
this.messageQueue = queue;
|
|
76
|
+
}
|
|
70
77
|
/**
|
|
71
78
|
* 注册渠道适配器
|
|
72
79
|
*/
|
|
@@ -149,7 +156,6 @@ export class MessageProcessor {
|
|
|
149
156
|
// 按 session.agentId 选择 agent 后端
|
|
150
157
|
const agent = this.getAgent(session.agentId);
|
|
151
158
|
const monitorEnabled = this.config.idleMonitor?.enabled !== false;
|
|
152
|
-
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
153
159
|
const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
|
|
154
160
|
// 计算是否抑制中间输出(工具活动 + 流式文本)
|
|
155
161
|
const shouldSuppress = () => {
|
|
@@ -181,7 +187,7 @@ export class MessageProcessor {
|
|
|
181
187
|
const msg = showIdleMonitor
|
|
182
188
|
? result.message
|
|
183
189
|
: `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
|
|
184
|
-
channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(
|
|
190
|
+
channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(message)).catch(e => {
|
|
185
191
|
logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
|
|
186
192
|
});
|
|
187
193
|
}
|
|
@@ -196,7 +202,7 @@ export class MessageProcessor {
|
|
|
196
202
|
logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
|
|
197
203
|
if (channelInfo && showIdleMonitor && !shouldSuppress()) {
|
|
198
204
|
if (!isBackground) {
|
|
199
|
-
channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(
|
|
205
|
+
channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(message)).catch(e => {
|
|
200
206
|
logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
|
|
201
207
|
});
|
|
202
208
|
}
|
|
@@ -219,25 +225,24 @@ export class MessageProcessor {
|
|
|
219
225
|
if (channelInfo) {
|
|
220
226
|
try {
|
|
221
227
|
const errorType = classifyError(error);
|
|
222
|
-
//
|
|
228
|
+
// 上下文过长是可恢复错误,不累计错误计数
|
|
223
229
|
if (errorType === ErrorType.CONTEXT_TOO_LONG) {
|
|
224
|
-
logger.info(`[MessageProcessor] Context too long error, skipping
|
|
225
|
-
// 认证错误(401 / Invalid API Key
|
|
230
|
+
logger.info(`[MessageProcessor] Context too long error, skipping error accumulation`);
|
|
231
|
+
// 认证错误(401 / Invalid API Key)不是会话问题,不累计
|
|
226
232
|
}
|
|
227
233
|
else if (errorType === ErrorType.AUTH_ERROR) {
|
|
228
|
-
logger.info(`[MessageProcessor] Auth error (invalid API key), skipping
|
|
229
|
-
// API 错误(5xx /
|
|
234
|
+
logger.info(`[MessageProcessor] Auth error (invalid API key), skipping error accumulation`);
|
|
235
|
+
// API 错误(5xx / 算力池切换等)是平台暂时性问题,不累计
|
|
230
236
|
}
|
|
231
237
|
else if (errorType === ErrorType.API_ERROR) {
|
|
232
|
-
logger.info(`[MessageProcessor] API error, skipping
|
|
238
|
+
logger.info(`[MessageProcessor] API error, skipping error accumulation`);
|
|
233
239
|
}
|
|
234
240
|
else if (!policy.accumulateErrors(chatType, identityRole)) {
|
|
235
|
-
logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping
|
|
241
|
+
logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping error accumulation`);
|
|
236
242
|
}
|
|
237
243
|
else {
|
|
238
244
|
const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
|
|
239
|
-
|
|
240
|
-
await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
|
|
245
|
+
await this.sessionManager.recordError(session.id, prefixed, error.message);
|
|
241
246
|
}
|
|
242
247
|
}
|
|
243
248
|
catch (statusError) {
|
|
@@ -251,39 +256,11 @@ export class MessageProcessor {
|
|
|
251
256
|
clearInterval(monitorInterval);
|
|
252
257
|
}
|
|
253
258
|
}
|
|
254
|
-
/**
|
|
255
|
-
getReplyContext(
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
|
|
260
|
-
*/
|
|
261
|
-
async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors) {
|
|
262
|
-
if (safeModeThreshold <= 0)
|
|
263
|
-
return;
|
|
264
|
-
const health = await this.sessionManager.getHealthStatus(session.id);
|
|
265
|
-
const sendOpts = this.getReplyContext(session);
|
|
266
|
-
const isThread = !!session.threadId;
|
|
267
|
-
if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
|
|
268
|
-
await this.sessionManager.setSafeMode(session.id, true);
|
|
269
|
-
logger.warn(`[MessageProcessor] Session ${session.id} entered safe mode after ${consecutiveErrors} errors`);
|
|
270
|
-
this.eventBus.publish({ type: 'session:safe-mode-entered', sessionId: session.id, consecutiveErrors });
|
|
271
|
-
const suggestions = isThread
|
|
272
|
-
? `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /clear - 清空会话历史\n3. /status - 查看详细状态`
|
|
273
|
-
: `1. /repair - 检查并修复会话(推荐,保留历史)\n2. /new [名称] - 创建新会话(清空历史)\n3. /status - 查看详细状态`;
|
|
274
|
-
await adapter.sendText(channelId, `\u26a0\ufe0f 安全模式已启用(连续 ${consecutiveErrors} 次异常)
|
|
275
|
-
|
|
276
|
-
当前限制:
|
|
277
|
-
- 无法记住之前的对话
|
|
278
|
-
- 每次提问需要提供完整上下文
|
|
279
|
-
|
|
280
|
-
建议操作:
|
|
281
|
-
${suggestions}`, sendOpts);
|
|
282
|
-
}
|
|
283
|
-
else if (safeModeThreshold >= 2 && consecutiveErrors === safeModeThreshold - 1) {
|
|
284
|
-
await adapter.sendText(channelId, `\u26a0\ufe0f 检测到异常(${consecutiveErrors}/${safeModeThreshold})\n\n如果问题持续,系统将自动进入安全模式。建议使用 /status 查看状态。`, sendOpts);
|
|
285
|
-
}
|
|
259
|
+
/** 获取回复上下文(跟着任务走) */
|
|
260
|
+
getReplyContext(message) {
|
|
261
|
+
return message.replyContext;
|
|
286
262
|
}
|
|
263
|
+
/** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
|
|
287
264
|
async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
|
|
288
265
|
const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
|
|
289
266
|
const channelKey = session.metadata?.channelName || message.channel;
|
|
@@ -324,7 +301,7 @@ ${suggestions}`, sendOpts);
|
|
|
324
301
|
logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
|
|
325
302
|
// 记录开始处理
|
|
326
303
|
this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
|
|
327
|
-
adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(
|
|
304
|
+
adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(message));
|
|
328
305
|
logger.message({
|
|
329
306
|
msgId: messageId,
|
|
330
307
|
sessionId: session.id,
|
|
@@ -341,8 +318,8 @@ ${suggestions}`, sendOpts);
|
|
|
341
318
|
const opts = {};
|
|
342
319
|
if (isFinal)
|
|
343
320
|
opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
|
|
344
|
-
//
|
|
345
|
-
const replyCtx =
|
|
321
|
+
// replyContext 跟着任务走:优先用当前 message 的,兜底用 session 的(话题会话创建时写入)
|
|
322
|
+
const replyCtx = this.getReplyContext(message);
|
|
346
323
|
if (replyCtx) {
|
|
347
324
|
Object.assign(opts, replyCtx);
|
|
348
325
|
}
|
|
@@ -362,17 +339,25 @@ ${suggestions}`, sendOpts);
|
|
|
362
339
|
// 调用 AgentRunner(含上下文过长自动 compact 重试)
|
|
363
340
|
// 设置权限审批的消息发送回调(指向当前渠道)
|
|
364
341
|
agent.setSendPrompt(async (text) => {
|
|
365
|
-
await adapter.sendText(message.channelId, text, this.getReplyContext(
|
|
342
|
+
await adapter.sendText(message.channelId, text, this.getReplyContext(message));
|
|
366
343
|
});
|
|
367
344
|
// 设置权限审批的交互上下文(支持交互卡片)
|
|
368
|
-
agent.setPermissionContext?.({
|
|
345
|
+
agent.setPermissionContext?.(session.id, {
|
|
369
346
|
adapter,
|
|
370
347
|
channelId: message.channelId,
|
|
371
|
-
replyContext: this.getReplyContext(
|
|
348
|
+
replyContext: this.getReplyContext(message),
|
|
372
349
|
interactionRouter: this.interactionRouter,
|
|
350
|
+
interceptNextMessage: this.messageQueue
|
|
351
|
+
? (sessionKey, handler) => this.messageQueue.interceptNext(sessionKey, handler)
|
|
352
|
+
: undefined,
|
|
353
|
+
cancelIntercept: this.messageQueue
|
|
354
|
+
? (sessionKey) => this.messageQueue.cancelIntercept(sessionKey)
|
|
355
|
+
: undefined,
|
|
373
356
|
});
|
|
374
|
-
// 设置 per-session
|
|
375
|
-
|
|
357
|
+
// 设置 per-session 权限模式(动态默认值:owner → bypass,admin → auto,guest → readonly)
|
|
358
|
+
const role = session.identity?.role;
|
|
359
|
+
const defaultPermMode = role === 'owner' ? 'bypass' : role === 'admin' ? 'auto' : 'readonly';
|
|
360
|
+
agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
|
|
376
361
|
// 标记会话为处理中(实时持久化,重启后可恢复)
|
|
377
362
|
this.sessionManager.markProcessing(session.id);
|
|
378
363
|
// 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
|
|
@@ -389,6 +374,7 @@ ${suggestions}`, sendOpts);
|
|
|
389
374
|
// 1. 当前环境信息
|
|
390
375
|
const peerLabel = session.identity?.role || 'unknown';
|
|
391
376
|
const peerName = message.peerName || session.metadata?.peerName;
|
|
377
|
+
const peerType = message.peerType;
|
|
392
378
|
const envParts = [
|
|
393
379
|
`会话通道: ${currentChannelType}`,
|
|
394
380
|
`当前项目: ${path.basename(absoluteProjectPath)}`,
|
|
@@ -398,6 +384,8 @@ ${suggestions}`, sendOpts);
|
|
|
398
384
|
envParts.push(`对端身份: ${peerLabel}`);
|
|
399
385
|
if (peerName)
|
|
400
386
|
envParts.push(`对端名称: ${peerName}`);
|
|
387
|
+
if (peerType && peerType !== 'unknown')
|
|
388
|
+
envParts.push(`对端类型: ${peerType}`);
|
|
401
389
|
if (session.chatType)
|
|
402
390
|
envParts.push(`聊天类型: ${session.chatType}`);
|
|
403
391
|
if (session.agentId && session.agentId !== 'claude')
|
|
@@ -435,6 +423,19 @@ ${suggestions}`, sendOpts);
|
|
|
435
423
|
if (capParts.length > 0) {
|
|
436
424
|
contextParts.push(`[通道能力] ${capParts.join('、')}`);
|
|
437
425
|
}
|
|
426
|
+
// 4. 群聊 @ 规则:告知 agent 应该 @ 谁,由 agent 自行在回复中添加
|
|
427
|
+
if (message.chatType === 'group' && message.peerId) {
|
|
428
|
+
contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
|
|
429
|
+
}
|
|
430
|
+
// 5. Agent ctl 自管理指令提示 + SKILLS.md 生成
|
|
431
|
+
if (!this.skillsEnsured) {
|
|
432
|
+
this.ensureSkillsFile();
|
|
433
|
+
this.skillsEnsured = true;
|
|
434
|
+
}
|
|
435
|
+
const skillsHint = this.getSkillsHint();
|
|
436
|
+
if (skillsHint) {
|
|
437
|
+
contextParts.push(`[EvolClaw 自管理] ${skillsHint}`);
|
|
438
|
+
}
|
|
438
439
|
const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
|
|
439
440
|
// 可重试错误(403/429/5xx)指数退避重试,最多 3 次
|
|
440
441
|
const MAX_RETRIES = 3;
|
|
@@ -515,22 +516,22 @@ ${suggestions}`, sendOpts);
|
|
|
515
516
|
&& targetSpec !== currentChannelType;
|
|
516
517
|
// 跨通道仅限 owner
|
|
517
518
|
if (isCrossChannel && session.identity?.role !== 'owner') {
|
|
518
|
-
await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(
|
|
519
|
+
await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(message));
|
|
519
520
|
continue;
|
|
520
521
|
}
|
|
521
522
|
const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
|
|
522
523
|
if (!fs.existsSync(resolvedPath)) {
|
|
523
524
|
logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
|
|
524
|
-
await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(
|
|
525
|
+
await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(message));
|
|
525
526
|
continue;
|
|
526
527
|
}
|
|
527
528
|
// 找目标 adapter
|
|
528
529
|
if (!targetInfo) {
|
|
529
|
-
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(
|
|
530
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(message));
|
|
530
531
|
continue;
|
|
531
532
|
}
|
|
532
533
|
if (!targetInfo.adapter.sendFile) {
|
|
533
|
-
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(
|
|
534
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(message));
|
|
534
535
|
continue;
|
|
535
536
|
}
|
|
536
537
|
// 找目标 channelId
|
|
@@ -541,21 +542,21 @@ ${suggestions}`, sendOpts);
|
|
|
541
542
|
const ownerPeerId = getOwner(this.config, targetAdapterName);
|
|
542
543
|
targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
|
|
543
544
|
if (!targetChannelId) {
|
|
544
|
-
await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(
|
|
545
|
+
await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
|
|
545
546
|
continue;
|
|
546
547
|
}
|
|
547
548
|
}
|
|
548
549
|
logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
|
|
549
550
|
try {
|
|
550
|
-
await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(
|
|
551
|
+
await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(message));
|
|
551
552
|
this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
|
|
552
553
|
if (isCrossChannel) {
|
|
553
|
-
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(
|
|
554
|
+
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(message));
|
|
554
555
|
}
|
|
555
556
|
}
|
|
556
557
|
catch (error) {
|
|
557
558
|
logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
|
|
558
|
-
await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(
|
|
559
|
+
await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(message));
|
|
559
560
|
}
|
|
560
561
|
}
|
|
561
562
|
// 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
|
|
@@ -574,14 +575,6 @@ ${suggestions}`, sendOpts);
|
|
|
574
575
|
}
|
|
575
576
|
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
576
577
|
await flusher.flush(true);
|
|
577
|
-
// 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
|
|
578
|
-
const healthStatus = await this.sessionManager.getHealthStatus(session.id);
|
|
579
|
-
if (healthStatus.safeMode) {
|
|
580
|
-
const hint = session.threadId
|
|
581
|
-
? '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
|
|
582
|
-
: '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
|
|
583
|
-
await adapter.sendText(message.channelId, hint, this.getReplyContext(session));
|
|
584
|
-
}
|
|
585
578
|
// 清理 activeStreams(正常完成)
|
|
586
579
|
agent.cleanupStream(streamKey);
|
|
587
580
|
// 清除处理中状态
|
|
@@ -593,7 +586,7 @@ ${suggestions}`, sendOpts);
|
|
|
593
586
|
const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
|
|
594
587
|
const rawSubtype = streamResult.subtype || 'agent_error';
|
|
595
588
|
const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
|
|
596
|
-
adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(
|
|
589
|
+
adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(message));
|
|
597
590
|
this.eventBus.publish({
|
|
598
591
|
type: 'message:error',
|
|
599
592
|
sessionId: session.id,
|
|
@@ -601,15 +594,13 @@ ${suggestions}`, sendOpts);
|
|
|
601
594
|
errorType,
|
|
602
595
|
terminalReason: streamResult.terminalReason
|
|
603
596
|
});
|
|
604
|
-
//
|
|
597
|
+
// 系统级 subtype 仍累计错误计数,供 /status 诊断使用
|
|
605
598
|
if (isInfraError(rawSubtype, streamResult.terminalReason)) {
|
|
606
599
|
const chatType = message.chatType || 'private';
|
|
607
600
|
const identityRole = session.identity?.role || 'anonymous';
|
|
608
|
-
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
609
601
|
const { policy } = channelInfo;
|
|
610
602
|
if (policy.accumulateErrors(chatType, identityRole)) {
|
|
611
|
-
|
|
612
|
-
await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount);
|
|
603
|
+
await this.sessionManager.recordError(session.id, errorType, errorSummary);
|
|
613
604
|
}
|
|
614
605
|
}
|
|
615
606
|
logger.message({
|
|
@@ -623,7 +614,7 @@ ${suggestions}`, sendOpts);
|
|
|
623
614
|
}
|
|
624
615
|
else {
|
|
625
616
|
// 真正的成功
|
|
626
|
-
adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(
|
|
617
|
+
adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(message));
|
|
627
618
|
await this.sessionManager.recordSuccess(session.id);
|
|
628
619
|
this.eventBus.publish({
|
|
629
620
|
type: 'message:completed',
|
|
@@ -676,7 +667,7 @@ ${suggestions}`, sendOpts);
|
|
|
676
667
|
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
|
|
677
668
|
if (!isUserInterrupt) {
|
|
678
669
|
try {
|
|
679
|
-
adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(
|
|
670
|
+
adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(message));
|
|
680
671
|
}
|
|
681
672
|
catch { }
|
|
682
673
|
}
|
|
@@ -724,7 +715,7 @@ ${suggestions}`, sendOpts);
|
|
|
724
715
|
let sendOpts;
|
|
725
716
|
try {
|
|
726
717
|
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
|
|
727
|
-
sendOpts = this.getReplyContext(
|
|
718
|
+
sendOpts = this.getReplyContext(message);
|
|
728
719
|
}
|
|
729
720
|
catch { }
|
|
730
721
|
await adapter.sendText(message.channelId, userMessage, sendOpts);
|
|
@@ -735,11 +726,12 @@ ${suggestions}`, sendOpts);
|
|
|
735
726
|
* 解析会话和项目路径
|
|
736
727
|
*/
|
|
737
728
|
async resolveSession(message) {
|
|
738
|
-
//
|
|
739
|
-
const metadata = message.replyContext
|
|
729
|
+
// 话题会话创建时写入 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
|
|
730
|
+
const metadata = (message.threadId && message.replyContext)
|
|
740
731
|
? { replyContext: message.replyContext }
|
|
741
732
|
: undefined;
|
|
742
733
|
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata, undefined, message.peerId);
|
|
734
|
+
// replyContext 不再写入 session.metadata(跟着 message 走,避免群聊多人覆盖)
|
|
743
735
|
const absoluteProjectPath = path.isAbsolute(session.projectPath)
|
|
744
736
|
? session.projectPath
|
|
745
737
|
: path.resolve(process.cwd(), session.projectPath);
|
|
@@ -953,9 +945,22 @@ ${suggestions}`, sendOpts);
|
|
|
953
945
|
}
|
|
954
946
|
catch (error) {
|
|
955
947
|
// User interrupt (AbortError) is expected, log at info level
|
|
948
|
+
const catchInterruptReason = this.interruptedSessions.get(session.id);
|
|
949
|
+
const catchIsUserInterrupt = catchInterruptReason === 'new_message' || catchInterruptReason === 'stop';
|
|
956
950
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
957
951
|
logger.info('[MessageProcessor] Stream interrupted (AbortError)');
|
|
958
952
|
}
|
|
953
|
+
else if (catchIsUserInterrupt) {
|
|
954
|
+
// SDK telemetry noise after user-initiated interrupt — not a real error
|
|
955
|
+
logger.debug('[MessageProcessor] Stream ended after user interrupt:', error?.message?.split('\n')[0]);
|
|
956
|
+
completeResult.isError = false;
|
|
957
|
+
completeResult.hasReceivedText = hasReceivedText;
|
|
958
|
+
return completeResult;
|
|
959
|
+
}
|
|
960
|
+
else if (isRetryableError(error)) {
|
|
961
|
+
// Retryable errors (network aborts, transient API failures) are noise at ERROR level
|
|
962
|
+
logger.warn('[MessageProcessor] Stream processing error (retryable):', error?.message?.split('\n')[0]);
|
|
963
|
+
}
|
|
959
964
|
else {
|
|
960
965
|
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
961
966
|
}
|
|
@@ -999,6 +1004,99 @@ ${suggestions}`, sendOpts);
|
|
|
999
1004
|
// 都找不到,返回项目根目录路径
|
|
1000
1005
|
return rootPath;
|
|
1001
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* 确保全局数据目录下有最新版本的 SKILLS.md
|
|
1009
|
+
* 目标:{EVOLCLAW_HOME}/data/SKILLS.md
|
|
1010
|
+
*/
|
|
1011
|
+
ensureSkillsFile() {
|
|
1012
|
+
try {
|
|
1013
|
+
const targetDir = path.join(resolveRoot(), 'data');
|
|
1014
|
+
const targetPath = path.join(targetDir, 'SKILLS.md');
|
|
1015
|
+
const templatePath = path.join(getPackageRoot(), 'src', 'templates', 'skills.md');
|
|
1016
|
+
// 模板不存在则跳过(构建环境可能没有 src/)
|
|
1017
|
+
if (!fs.existsSync(templatePath)) {
|
|
1018
|
+
// 尝试 dist/templates/skills.md
|
|
1019
|
+
const distTemplatePath = path.join(getPackageRoot(), 'dist', 'templates', 'skills.md');
|
|
1020
|
+
if (!fs.existsSync(distTemplatePath))
|
|
1021
|
+
return;
|
|
1022
|
+
this.copySkillsIfNeeded(distTemplatePath, targetDir, targetPath);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
this.copySkillsIfNeeded(templatePath, targetDir, targetPath);
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
// 静默失败,不影响正常消息处理
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
copySkillsIfNeeded(templatePath, targetDir, targetPath) {
|
|
1032
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
1033
|
+
const templateVersion = templateContent.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
|
|
1034
|
+
if (fs.existsSync(targetPath)) {
|
|
1035
|
+
const existing = fs.readFileSync(targetPath, 'utf-8');
|
|
1036
|
+
const existingVersion = existing.match(/^version:\s*(.+)$/m)?.[1]?.trim() || '0';
|
|
1037
|
+
if (this.compareSemver(existingVersion, templateVersion) >= 0)
|
|
1038
|
+
return; // 已是最新
|
|
1039
|
+
}
|
|
1040
|
+
if (!fs.existsSync(targetDir)) {
|
|
1041
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1042
|
+
}
|
|
1043
|
+
fs.writeFileSync(targetPath, templateContent, 'utf-8');
|
|
1044
|
+
}
|
|
1045
|
+
/** 简易 semver 比较:支持 "1", "1.0", "1.0.0" 等格式,返回 -1/0/1 */
|
|
1046
|
+
compareSemver(a, b) {
|
|
1047
|
+
const pa = a.split('.').map(Number);
|
|
1048
|
+
const pb = b.split('.').map(Number);
|
|
1049
|
+
const len = Math.max(pa.length, pb.length);
|
|
1050
|
+
for (let i = 0; i < len; i++) {
|
|
1051
|
+
const na = pa[i] || 0;
|
|
1052
|
+
const nb = pb[i] || 0;
|
|
1053
|
+
if (na !== nb)
|
|
1054
|
+
return na > nb ? 1 : -1;
|
|
1055
|
+
}
|
|
1056
|
+
return 0;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* 从模板 frontmatter 缓存提示(懒加载,整个进程只读一次模板文件)
|
|
1060
|
+
*/
|
|
1061
|
+
getSkillsHint() {
|
|
1062
|
+
if (this.skillsHintDesc === undefined) {
|
|
1063
|
+
this.skillsHintDesc = this.loadSkillsHint();
|
|
1064
|
+
}
|
|
1065
|
+
return this.skillsHintDesc;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* 从包模板源读取 frontmatter 并生成提示(仅执行一次)
|
|
1069
|
+
*/
|
|
1070
|
+
loadSkillsHint() {
|
|
1071
|
+
try {
|
|
1072
|
+
const candidates = [
|
|
1073
|
+
path.join(getPackageRoot(), 'src', 'templates', 'skills.md'),
|
|
1074
|
+
path.join(getPackageRoot(), 'dist', 'templates', 'skills.md'),
|
|
1075
|
+
];
|
|
1076
|
+
for (const templatePath of candidates) {
|
|
1077
|
+
if (!fs.existsSync(templatePath))
|
|
1078
|
+
continue;
|
|
1079
|
+
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
1080
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1081
|
+
if (!frontmatterMatch)
|
|
1082
|
+
continue;
|
|
1083
|
+
const fm = frontmatterMatch[1];
|
|
1084
|
+
const desc = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || 'EvolClaw 运行时管理指令';
|
|
1085
|
+
const trigger = fm.match(/^trigger:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
1086
|
+
const skillsPath = path.join(resolveRoot(), 'data', 'SKILLS.md');
|
|
1087
|
+
const parts = [
|
|
1088
|
+
`可通过 Bash 执行 \`evolclaw ctl <cmd>\` 管理运行时:${desc}`,
|
|
1089
|
+
trigger ? `触发时机:${trigger}` : '',
|
|
1090
|
+
`完整文档见 ${skillsPath}`,
|
|
1091
|
+
];
|
|
1092
|
+
return parts.filter(Boolean).join('\n');
|
|
1093
|
+
}
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1002
1100
|
/**
|
|
1003
1101
|
* 判断文件路径是否为占位符/示例文本
|
|
1004
1102
|
* 用于过滤大模型在说明文字中误写的 [SEND_FILE:...] 标记
|
|
@@ -8,10 +8,12 @@ export class MessageQueue {
|
|
|
8
8
|
currentSessionKey;
|
|
9
9
|
currentProjectPath;
|
|
10
10
|
currentAgentId;
|
|
11
|
+
activeMessageIds = new Set(); // 正在执行的消息 ID
|
|
11
12
|
interruptCallback;
|
|
12
13
|
eventBus;
|
|
13
14
|
recentMessageIds = new Set();
|
|
14
15
|
DEDUP_WINDOW = 60_000; // 1 分钟窗口
|
|
16
|
+
interceptors = new Map();
|
|
15
17
|
constructor(handler) {
|
|
16
18
|
this.handler = handler;
|
|
17
19
|
}
|
|
@@ -21,6 +23,19 @@ export class MessageQueue {
|
|
|
21
23
|
setEventBus(eventBus) {
|
|
22
24
|
this.eventBus = eventBus;
|
|
23
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* 注册一次性消息拦截器:下一条来自 sessionKey 的消息不入队、不触发 interrupt,
|
|
28
|
+
* 直接传给 handler。用于 AskUserQuestion 的"手动输入"场景。
|
|
29
|
+
*/
|
|
30
|
+
interceptNext(sessionKey, handler) {
|
|
31
|
+
this.interceptors.set(sessionKey, handler);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 取消拦截器(超时或卡片被其他方式回答时调用)
|
|
35
|
+
*/
|
|
36
|
+
cancelIntercept(sessionKey) {
|
|
37
|
+
this.interceptors.delete(sessionKey);
|
|
38
|
+
}
|
|
24
39
|
/**
|
|
25
40
|
* 检查消息是否应该处理(去重)
|
|
26
41
|
*/
|
|
@@ -53,6 +68,14 @@ export class MessageQueue {
|
|
|
53
68
|
if (!this.shouldProcess(message)) {
|
|
54
69
|
return Promise.resolve();
|
|
55
70
|
}
|
|
71
|
+
// 拦截器检查:AskUserQuestion 等场景的一次性消息拦截
|
|
72
|
+
const interceptor = this.interceptors.get(sessionKey);
|
|
73
|
+
if (interceptor) {
|
|
74
|
+
this.interceptors.delete(sessionKey);
|
|
75
|
+
logger.debug(`[Queue] Message intercepted for ${sessionKey}`);
|
|
76
|
+
interceptor(message);
|
|
77
|
+
return Promise.resolve();
|
|
78
|
+
}
|
|
56
79
|
const queueKey = this.getQueueKey(sessionKey, projectPath);
|
|
57
80
|
logger.debug(`[Queue] Enqueuing message for ${queueKey}`);
|
|
58
81
|
return new Promise((resolve, reject) => {
|
|
@@ -97,6 +120,7 @@ export class MessageQueue {
|
|
|
97
120
|
this.processing.delete(queueKey);
|
|
98
121
|
this.currentSessionKey = undefined;
|
|
99
122
|
this.currentProjectPath = undefined;
|
|
123
|
+
this.activeMessageIds.clear();
|
|
100
124
|
return;
|
|
101
125
|
}
|
|
102
126
|
// FIFO 贪心合并:弹出队首连续同 peerId 的消息
|
|
@@ -105,6 +129,12 @@ export class MessageQueue {
|
|
|
105
129
|
this.currentSessionKey = queueKey;
|
|
106
130
|
this.currentProjectPath = merged.projectPath;
|
|
107
131
|
this.currentAgentId = merged.message.agentId;
|
|
132
|
+
// 记录正在执行的 messageId(用于撤回中断)
|
|
133
|
+
this.activeMessageIds.clear();
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
if (item.message.messageId)
|
|
136
|
+
this.activeMessageIds.add(item.message.messageId);
|
|
137
|
+
}
|
|
108
138
|
const resolves = items.map(i => i.resolve);
|
|
109
139
|
const rejects = items.map(i => i.reject);
|
|
110
140
|
logger.debug(`[Queue] Processing ${items.length} message(s) from ${merged.message.channel}:${merged.message.channelId}`);
|
|
@@ -200,6 +230,24 @@ export class MessageQueue {
|
|
|
200
230
|
}
|
|
201
231
|
return false;
|
|
202
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* 撤回正在执行的消息:如果 messageId 正在处理中,触发 interrupt。
|
|
235
|
+
* @returns true 如果找到并触发了中断
|
|
236
|
+
*/
|
|
237
|
+
cancelActive(messageId) {
|
|
238
|
+
if (!this.activeMessageIds.has(messageId))
|
|
239
|
+
return false;
|
|
240
|
+
if (!this.currentSessionKey)
|
|
241
|
+
return false;
|
|
242
|
+
// 从 queueKey 提取 sessionKey
|
|
243
|
+
const sessionKey = this.currentSessionKey.split('::')[0];
|
|
244
|
+
logger.info(`[Queue] Recalled active message ${messageId}, interrupting session ${sessionKey}`);
|
|
245
|
+
this.eventBus?.publish({ type: 'message:interrupted', sessionId: sessionKey, reason: 'recalled' });
|
|
246
|
+
if (this.interruptCallback) {
|
|
247
|
+
this.interruptCallback(sessionKey, this.currentAgentId).catch(() => { });
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
203
251
|
/**
|
|
204
252
|
* 外部锁:快速命令(/compact, /clear)执行期间阻塞队列处理
|
|
205
253
|
* 返回 release 函数
|
|
@@ -161,13 +161,13 @@ export class StreamFlusher {
|
|
|
161
161
|
if (this.diagEnabled)
|
|
162
162
|
diag(this.instanceId, 'flushActivitiesOnly', { outputLen: output.length });
|
|
163
163
|
if (output) {
|
|
164
|
+
this.sentContent = true; // 同步标记,避免 timer flush 未 await 时的竞态
|
|
164
165
|
const text = output;
|
|
165
166
|
// chain 保持不断裂:单条失败不阻塞后续(catch → resolve)
|
|
166
167
|
this.sendChain = this.sendChain
|
|
167
168
|
.then(() => this.send(text, false, false))
|
|
168
169
|
.catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
|
|
169
170
|
await this.sendChain;
|
|
170
|
-
this.sentContent = true;
|
|
171
171
|
this.lastFlush = Date.now();
|
|
172
172
|
this.flushCount++;
|
|
173
173
|
}
|
|
@@ -207,6 +207,7 @@ export class StreamFlusher {
|
|
|
207
207
|
if (this.diagEnabled)
|
|
208
208
|
diag(this.instanceId, 'flush', { isFinal, outputLen: output.length, flushCount: this.flushCount, sinceLastFlush: Date.now() - this.lastFlush, preview: output.substring(0, 80) });
|
|
209
209
|
if (output) {
|
|
210
|
+
this.sentContent = true; // 同步标记,避免 timer flush 未 await 时的竞态
|
|
210
211
|
const text = output;
|
|
211
212
|
const final = isFinal;
|
|
212
213
|
const ht = hasText;
|
|
@@ -214,7 +215,6 @@ export class StreamFlusher {
|
|
|
214
215
|
.then(() => this.send(text, final, ht))
|
|
215
216
|
.catch(e => { logger.warn('[StreamFlusher] send failed:', e); });
|
|
216
217
|
await this.sendChain;
|
|
217
|
-
this.sentContent = true;
|
|
218
218
|
this.lastFlush = Date.now();
|
|
219
219
|
this.flushCount++;
|
|
220
220
|
}
|