evolclaw 2.1.2 → 2.3.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 +59 -30
- package/data/evolclaw.sample.json +15 -4
- package/dist/agents/claude-runner.js +685 -0
- package/dist/agents/codex-runner.js +315 -0
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +580 -10
- package/dist/channels/feishu.js +888 -135
- package/dist/channels/wechat.js +127 -21
- package/dist/cli.js +519 -136
- package/dist/config.js +277 -25
- package/dist/core/agent-loader.js +39 -0
- package/dist/core/channel-loader.js +67 -0
- package/dist/core/command-handler.js +1537 -392
- package/dist/core/event-bus.js +32 -0
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +216 -0
- package/dist/core/message/message-processor.js +1028 -0
- package/dist/core/message/message-queue.js +240 -0
- package/dist/core/message/stream-debouncer.js +122 -0
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
- package/dist/core/permission.js +259 -0
- package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
- package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/core/session/session-file-adapter.js +7 -0
- package/dist/core/session/session-file-health.js +45 -0
- package/dist/core/session/session-manager.js +1072 -0
- package/dist/index.js +402 -252
- package/dist/ipc.js +106 -0
- package/dist/paths.js +1 -0
- package/dist/types.js +3 -0
- package/dist/utils/{platform.js → cross-platform.js} +38 -1
- package/dist/utils/error-utils.js +130 -5
- package/dist/utils/init-channel.js +649 -0
- package/dist/utils/init.js +190 -53
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/utils/migrate-project.js +122 -0
- package/dist/utils/rich-content-renderer.js +228 -0
- package/dist/utils/stats-collector.js +102 -0
- package/package.json +4 -2
- package/dist/core/agent-runner.js +0 -348
- package/dist/core/message-processor.js +0 -604
- package/dist/core/message-queue.js +0 -116
- package/dist/core/message-stream.js +0 -59
- package/dist/core/session-manager.js +0 -664
- package/dist/index.js.bak +0 -340
- package/dist/utils/init-feishu.js +0 -261
- package/dist/utils/init-wechat.js +0 -170
- package/dist/utils/markdown-to-feishu.js +0 -94
- package/dist/utils/permission.js +0 -43
- package/dist/utils/session-file-health.js +0 -68
- /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { hasCompact } from '../../agents/claude-runner.js';
|
|
4
|
+
import { StreamFlusher } from './stream-flusher.js';
|
|
5
|
+
import { StreamIdleMonitor } from './stream-idle-monitor.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
|
|
8
|
+
import { summarizeToolInput } from '../permission.js';
|
|
9
|
+
import { getOwner } from '../../config.js';
|
|
10
|
+
/**
|
|
11
|
+
* 统一消息处理器
|
|
12
|
+
* 负责处理来自不同渠道的消息,协调事件流处理
|
|
13
|
+
*/
|
|
14
|
+
export class MessageProcessor {
|
|
15
|
+
sessionManager;
|
|
16
|
+
config;
|
|
17
|
+
messageCache;
|
|
18
|
+
eventBus;
|
|
19
|
+
commandHandler;
|
|
20
|
+
channels = new Map();
|
|
21
|
+
channelTypeMap = new Map(); // channelType → channelName(首个实例)
|
|
22
|
+
currentFlusher;
|
|
23
|
+
shouldSuppressActivities = false;
|
|
24
|
+
agentMap;
|
|
25
|
+
defaultAgentId;
|
|
26
|
+
interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
|
|
27
|
+
interactionRouter;
|
|
28
|
+
/** 按 agentId 获取 agent,回退到默认 */
|
|
29
|
+
getAgent(agentId) {
|
|
30
|
+
if (agentId && this.agentMap.has(agentId))
|
|
31
|
+
return this.agentMap.get(agentId);
|
|
32
|
+
return this.agentMap.get(this.defaultAgentId) || this.agentMap.values().next().value;
|
|
33
|
+
}
|
|
34
|
+
/** 获取可用 agent 列表 */
|
|
35
|
+
getAvailableAgents() {
|
|
36
|
+
return [...this.agentMap.keys()];
|
|
37
|
+
}
|
|
38
|
+
/** 判断是否为后台会话(仅主会话参与判断,话题会话独立) */
|
|
39
|
+
async isBackgroundSession(session, channel, channelId) {
|
|
40
|
+
if (session.threadId)
|
|
41
|
+
return false;
|
|
42
|
+
const active = await this.sessionManager.getActiveSession(channel, channelId);
|
|
43
|
+
return active ? session.id !== active.id : false;
|
|
44
|
+
}
|
|
45
|
+
constructor(agentRunnerOrMap, sessionManager, config, messageCache, eventBus, commandHandler, defaultAgentId) {
|
|
46
|
+
this.sessionManager = sessionManager;
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.messageCache = messageCache;
|
|
49
|
+
this.eventBus = eventBus;
|
|
50
|
+
this.commandHandler = commandHandler;
|
|
51
|
+
if (agentRunnerOrMap instanceof Map) {
|
|
52
|
+
this.agentMap = agentRunnerOrMap;
|
|
53
|
+
this.defaultAgentId = defaultAgentId || 'claude';
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// 向后兼容:单个 agentRunner
|
|
57
|
+
this.agentMap = new Map([[agentRunnerOrMap.name, agentRunnerOrMap]]);
|
|
58
|
+
this.defaultAgentId = agentRunnerOrMap.name;
|
|
59
|
+
}
|
|
60
|
+
// 监听中断事件,标记被中断的 session
|
|
61
|
+
this.eventBus.subscribe('message:interrupted', (event) => {
|
|
62
|
+
if ('sessionId' in event && event.sessionId) {
|
|
63
|
+
this.interruptedSessions.set(event.sessionId, event.reason || 'unknown');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
setInteractionRouter(router) {
|
|
68
|
+
this.interactionRouter = router;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 注册渠道适配器
|
|
72
|
+
*/
|
|
73
|
+
registerChannel(adapter, policy, options) {
|
|
74
|
+
this.channels.set(adapter.channelName, { adapter, options, policy });
|
|
75
|
+
// 维护 channelType → channelName 映射(首个实例优先)
|
|
76
|
+
const type = options?.channelType || adapter.channelName;
|
|
77
|
+
if (!this.channelTypeMap.has(type)) {
|
|
78
|
+
this.channelTypeMap.set(type, adapter.channelName);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 获取渠道适配器(支持实例名和 channelType)
|
|
83
|
+
*/
|
|
84
|
+
getAdapter(channelName) {
|
|
85
|
+
return this.resolveChannelInfo(channelName)?.adapter;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 获取渠道信息(含 policy,支持实例名和 channelType)
|
|
89
|
+
*/
|
|
90
|
+
getChannelInfo(channelName) {
|
|
91
|
+
return this.resolveChannelInfo(channelName);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 处理 compact 开始事件
|
|
95
|
+
*/
|
|
96
|
+
handleCompactStart(sessionId) {
|
|
97
|
+
if (sessionId) {
|
|
98
|
+
this.eventBus.publish({ type: 'agent:compact-start', sessionId });
|
|
99
|
+
}
|
|
100
|
+
if (this.currentFlusher && !this.shouldSuppressActivities) {
|
|
101
|
+
this.currentFlusher.addActivity('\u23f3 会话压缩中...');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 根据 channel 标识查找渠道信息
|
|
106
|
+
* 先按实例名精确匹配,再按 channelType 映射到实例名
|
|
107
|
+
*/
|
|
108
|
+
resolveChannelInfo(channel) {
|
|
109
|
+
// 1. 精确匹配实例名
|
|
110
|
+
let info = this.channels.get(channel);
|
|
111
|
+
if (info)
|
|
112
|
+
return info;
|
|
113
|
+
// 2. 按 channelType 查找(兼容按类型名路由)
|
|
114
|
+
const instanceName = this.channelTypeMap.get(channel);
|
|
115
|
+
if (instanceName)
|
|
116
|
+
info = this.channels.get(instanceName);
|
|
117
|
+
return info;
|
|
118
|
+
}
|
|
119
|
+
// 命令前缀列表(与 CommandHandler.quickCommandPrefixes 保持同步)
|
|
120
|
+
static COMMAND_PREFIXES = [
|
|
121
|
+
'/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart',
|
|
122
|
+
'/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork',
|
|
123
|
+
'/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/send', '/check',
|
|
124
|
+
'/p ', '/s ', '/name ',
|
|
125
|
+
];
|
|
126
|
+
/** 判断消息内容是否为已知命令 */
|
|
127
|
+
isKnownCommand(content) {
|
|
128
|
+
return content === '/p' || content === '/s' ||
|
|
129
|
+
MessageProcessor.COMMAND_PREFIXES.some(cmd => content.startsWith(cmd));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 处理消息(主入口)
|
|
133
|
+
*/
|
|
134
|
+
async processMessage(message) {
|
|
135
|
+
const idleMs = (this.config.idleMonitor?.timeout ?? 120) * 1000;
|
|
136
|
+
// 先解析会话,再优先用 session.metadata.channelName 精确定位实例级 adapter
|
|
137
|
+
// message.channel 现在存实例名(channelName),可直接用于精确路由
|
|
138
|
+
const { session, absoluteProjectPath } = await this.resolveSession(message);
|
|
139
|
+
const channelKey = session.metadata?.channelName || message.channel;
|
|
140
|
+
const channelInfo = this.resolveChannelInfo(channelKey);
|
|
141
|
+
if (!channelInfo) {
|
|
142
|
+
logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const { policy } = channelInfo;
|
|
146
|
+
const streamKey = session.id;
|
|
147
|
+
const chatType = message.chatType || 'private';
|
|
148
|
+
const identityRole = session.identity?.role || 'anonymous';
|
|
149
|
+
// 按 session.agentId 选择 agent 后端
|
|
150
|
+
const agent = this.getAgent(session.agentId);
|
|
151
|
+
const monitorEnabled = this.config.idleMonitor?.enabled !== false;
|
|
152
|
+
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
153
|
+
const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
|
|
154
|
+
// 计算是否抑制中间输出(工具活动 + 流式文本)
|
|
155
|
+
const shouldSuppress = () => {
|
|
156
|
+
return !policy.showMiddleResult(chatType, identityRole);
|
|
157
|
+
};
|
|
158
|
+
this.shouldSuppressActivities = shouldSuppress();
|
|
159
|
+
let monitor;
|
|
160
|
+
let monitorInterval;
|
|
161
|
+
let rejectFn;
|
|
162
|
+
const resetTimer = (eventType, toolName) => {
|
|
163
|
+
monitor?.recordEvent(eventType || 'unknown', toolName);
|
|
164
|
+
};
|
|
165
|
+
// Cache background status to avoid async call inside setInterval
|
|
166
|
+
const isBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
167
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
168
|
+
rejectFn = reject;
|
|
169
|
+
if (!monitorEnabled)
|
|
170
|
+
return;
|
|
171
|
+
monitor = new StreamIdleMonitor(idleMs);
|
|
172
|
+
monitorInterval = setInterval(() => {
|
|
173
|
+
// Drain all pending levels in one tick
|
|
174
|
+
let result = monitor.check();
|
|
175
|
+
while (result) {
|
|
176
|
+
if (result.action === 'kill') {
|
|
177
|
+
logger.warn(`[MessageProcessor] Idle monitor: kill after ${result.idleSec}s idle, stream: ${streamKey}`);
|
|
178
|
+
this.eventBus.publish({ type: 'agent:idle-timeout', sessionId: streamKey, idleSec: result.idleSec });
|
|
179
|
+
// 后台任务也需要中断(释放资源),但不发送通知
|
|
180
|
+
if (channelInfo && !isBackground) {
|
|
181
|
+
const msg = showIdleMonitor
|
|
182
|
+
? result.message
|
|
183
|
+
: `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
|
|
184
|
+
channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(session)).catch(e => {
|
|
185
|
+
logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
agent.interrupt(streamKey).catch(e => {
|
|
189
|
+
logger.debug(`[MessageProcessor] Interrupt failed (may already be cleaned up):`, e);
|
|
190
|
+
});
|
|
191
|
+
rejectFn(new Error('SDK_TIMEOUT'));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// notify or warn: send diagnostic message, task continues
|
|
196
|
+
logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
|
|
197
|
+
if (channelInfo && showIdleMonitor && !shouldSuppress()) {
|
|
198
|
+
if (!isBackground) {
|
|
199
|
+
channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(session)).catch(e => {
|
|
200
|
+
logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
result = monitor.check();
|
|
206
|
+
}
|
|
207
|
+
}, 30000);
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
await Promise.race([
|
|
211
|
+
this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress),
|
|
212
|
+
timeoutPromise
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// 超时错误:kill 级别已发送诊断信息,无需再发
|
|
217
|
+
// 非超时错误走通用处理
|
|
218
|
+
// 记录错误到健康状态(复用已有 session)
|
|
219
|
+
if (channelInfo) {
|
|
220
|
+
try {
|
|
221
|
+
const errorType = classifyError(error);
|
|
222
|
+
// 上下文过长是可恢复错误,不累计触发安全模式
|
|
223
|
+
if (errorType === ErrorType.CONTEXT_TOO_LONG) {
|
|
224
|
+
logger.info(`[MessageProcessor] Context too long error, skipping safe mode accumulation`);
|
|
225
|
+
// 认证错误(401 / Invalid API Key)不是会话问题,安全模式无法修复,不累计
|
|
226
|
+
}
|
|
227
|
+
else if (errorType === ErrorType.AUTH_ERROR) {
|
|
228
|
+
logger.info(`[MessageProcessor] Auth error (invalid API key), skipping safe mode accumulation`);
|
|
229
|
+
// API 错误(5xx / 算力池切换等)是平台暂时性问题,安全模式无法修复,不累计
|
|
230
|
+
}
|
|
231
|
+
else if (errorType === ErrorType.API_ERROR) {
|
|
232
|
+
logger.info(`[MessageProcessor] API error, skipping safe mode accumulation`);
|
|
233
|
+
}
|
|
234
|
+
else if (!policy.accumulateErrors(chatType, identityRole)) {
|
|
235
|
+
logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping safe mode accumulation`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
|
|
239
|
+
const newCount = await this.sessionManager.recordError(session.id, prefixed, error.message);
|
|
240
|
+
await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (statusError) {
|
|
244
|
+
logger.error('[MessageProcessor] Failed to update health status:', statusError);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
if (monitorInterval)
|
|
251
|
+
clearInterval(monitorInterval);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** 从 session 提取渠道预构建的回复上下文 */
|
|
255
|
+
getReplyContext(session) {
|
|
256
|
+
return session.metadata?.replyContext;
|
|
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
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
|
|
288
|
+
const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
|
|
289
|
+
const channelKey = session.metadata?.channelName || message.channel;
|
|
290
|
+
const channelInfo = this.resolveChannelInfo(channelKey);
|
|
291
|
+
if (!channelInfo) {
|
|
292
|
+
logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// 二次拦截:如果命令消息绕过 MessageBridge 的 handleCommand 泄漏到这里,
|
|
296
|
+
// 静默丢弃而不是发送给 Agent(命令已在 MessageBridge 层处理过)
|
|
297
|
+
const rawContent = message.content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
|
|
298
|
+
if (rawContent.startsWith('/') && this.isKnownCommand(rawContent)) {
|
|
299
|
+
logger.warn(`[MessageProcessor] Command leaked past MessageBridge, dropped: "${rawContent.substring(0, 40)}"`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const { adapter, options } = channelInfo;
|
|
303
|
+
const agent = this.getAgent(session.agentId);
|
|
304
|
+
const streamKey = session.id;
|
|
305
|
+
try {
|
|
306
|
+
const isBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
307
|
+
// 记录收到消息
|
|
308
|
+
logger.message({
|
|
309
|
+
msgId: messageId,
|
|
310
|
+
sessionId: session.id,
|
|
311
|
+
dir: 'inbound',
|
|
312
|
+
status: 'received'
|
|
313
|
+
});
|
|
314
|
+
this.eventBus.publish({
|
|
315
|
+
type: 'message:received',
|
|
316
|
+
sessionId: session.id,
|
|
317
|
+
channel: message.channel,
|
|
318
|
+
channelId: message.channelId,
|
|
319
|
+
content: message.content,
|
|
320
|
+
timestamp: Date.now()
|
|
321
|
+
});
|
|
322
|
+
const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
|
|
323
|
+
const modeInfo = isBackground ? ' [\u540e\u53f0]' : '';
|
|
324
|
+
logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
|
|
325
|
+
// 记录开始处理
|
|
326
|
+
this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
|
|
327
|
+
adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(session));
|
|
328
|
+
logger.message({
|
|
329
|
+
msgId: messageId,
|
|
330
|
+
sessionId: session.id,
|
|
331
|
+
dir: 'inbound',
|
|
332
|
+
status: 'processing'
|
|
333
|
+
});
|
|
334
|
+
const startTime = Date.now();
|
|
335
|
+
// 创建 StreamFlusher,传入文件标记模式用于自动过滤
|
|
336
|
+
// 使用动态判断,确保切换项目后不会继续输出
|
|
337
|
+
let firstReply = true;
|
|
338
|
+
const flusher = new StreamFlusher(async (text, isFinal, hasText) => {
|
|
339
|
+
const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
340
|
+
if (!isCurrentlyBackground) {
|
|
341
|
+
const opts = {};
|
|
342
|
+
if (isFinal)
|
|
343
|
+
opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
|
|
344
|
+
// 话题会话:使用 Channel 预构建的 replyContext(确保消息进入话题)
|
|
345
|
+
const replyCtx = session.metadata?.replyContext;
|
|
346
|
+
if (replyCtx) {
|
|
347
|
+
Object.assign(opts, replyCtx);
|
|
348
|
+
}
|
|
349
|
+
else if (firstReply && message.messageId) {
|
|
350
|
+
// 主会话:首条消息引用回复用户原消息(只在含真实文字时消费)
|
|
351
|
+
if (hasText) {
|
|
352
|
+
opts.replyToMessageId = message.messageId;
|
|
353
|
+
firstReply = false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await adapter.sendText(message.channelId, text, Object.keys(opts).length ? opts : undefined);
|
|
357
|
+
}
|
|
358
|
+
// 后台任务:静默,不发送输出
|
|
359
|
+
}, (options?.flushDelay ?? this.config.flushDelay ?? 3) * 1000, options?.fileMarkerPattern, this.config.debug?.flusherDiag);
|
|
360
|
+
// 保存当前 flusher,用于 compact 事件
|
|
361
|
+
this.currentFlusher = flusher;
|
|
362
|
+
// 调用 AgentRunner(含上下文过长自动 compact 重试)
|
|
363
|
+
// 设置权限审批的消息发送回调(指向当前渠道)
|
|
364
|
+
agent.setSendPrompt(async (text) => {
|
|
365
|
+
await adapter.sendText(message.channelId, text, this.getReplyContext(session));
|
|
366
|
+
});
|
|
367
|
+
// 设置权限审批的交互上下文(支持交互卡片)
|
|
368
|
+
agent.setPermissionContext?.({
|
|
369
|
+
adapter,
|
|
370
|
+
channelId: message.channelId,
|
|
371
|
+
replyContext: this.getReplyContext(session),
|
|
372
|
+
interactionRouter: this.interactionRouter,
|
|
373
|
+
});
|
|
374
|
+
// 设置 per-session 权限模式
|
|
375
|
+
agent.setMode(session.metadata?.permissionMode ?? 'bypass');
|
|
376
|
+
// 标记会话为处理中(实时持久化,重启后可恢复)
|
|
377
|
+
this.sessionManager.markProcessing(session.id);
|
|
378
|
+
// 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
|
|
379
|
+
const prevInterruptReason = this.interruptedSessions.get(session.id);
|
|
380
|
+
this.interruptedSessions.delete(session.id);
|
|
381
|
+
const effectivePrompt = prevInterruptReason === 'new_message' && session.agentSessionId
|
|
382
|
+
? `【新消息插入】\n\n${message.content}\n\n【请无视之前中断继续处理】`
|
|
383
|
+
: message.content;
|
|
384
|
+
let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
|
|
385
|
+
try {
|
|
386
|
+
// 动态构建运行时上下文提示
|
|
387
|
+
const contextParts = [];
|
|
388
|
+
const currentChannelType = options?.channelType || message.channel;
|
|
389
|
+
// 1. 当前环境信息
|
|
390
|
+
const peerLabel = session.identity?.role || 'unknown';
|
|
391
|
+
const peerName = message.peerName || session.metadata?.peerName;
|
|
392
|
+
const envParts = [
|
|
393
|
+
`会话通道: ${currentChannelType}`,
|
|
394
|
+
`当前项目: ${path.basename(absoluteProjectPath)}`,
|
|
395
|
+
];
|
|
396
|
+
if (session.name)
|
|
397
|
+
envParts.push(`会话名称: ${session.name}`);
|
|
398
|
+
envParts.push(`对端身份: ${peerLabel}`);
|
|
399
|
+
if (peerName)
|
|
400
|
+
envParts.push(`对端名称: ${peerName}`);
|
|
401
|
+
if (session.chatType)
|
|
402
|
+
envParts.push(`聊天类型: ${session.chatType}`);
|
|
403
|
+
if (session.agentId && session.agentId !== 'claude')
|
|
404
|
+
envParts.push(`当前Agent: ${session.agentId}`);
|
|
405
|
+
contextParts.push(`[当前环境] ${envParts.join(' | ')}`);
|
|
406
|
+
// 只读模式提示
|
|
407
|
+
if (session.metadata?.permissionMode === 'readonly') {
|
|
408
|
+
contextParts.push('[只读模式] 禁止修改项目文件。如需生成文件供用户下载,请写入 .evolclaw/tmp/ 目录后使用 [SEND_FILE:] 发送');
|
|
409
|
+
}
|
|
410
|
+
// 2. 文件发送能力(按 channelType 去重,提示词只展示第一级通道名)
|
|
411
|
+
const fileChannelTypes = new Set();
|
|
412
|
+
const currentCanSend = !!channelInfo.adapter.sendFile;
|
|
413
|
+
for (const [, info] of this.channels) {
|
|
414
|
+
if (info.adapter.sendFile) {
|
|
415
|
+
fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
|
|
419
|
+
if (currentCanSend || crossChannelTypes.length > 0) {
|
|
420
|
+
const hints = [];
|
|
421
|
+
if (currentCanSend)
|
|
422
|
+
hints.push(`[SEND_FILE:路径] 发送文件到当前通道`);
|
|
423
|
+
if (crossChannelTypes.length > 0)
|
|
424
|
+
hints.push(`[SEND_FILE:${crossChannelTypes[0]}:路径] 发送文件到指定通道(可用: ${crossChannelTypes.join('/')})`);
|
|
425
|
+
contextParts.push(hints.join(','));
|
|
426
|
+
}
|
|
427
|
+
// 3. 当前通道能力
|
|
428
|
+
const capParts = [];
|
|
429
|
+
if (options?.supportsImages)
|
|
430
|
+
capParts.push('图片输入');
|
|
431
|
+
if (channelInfo.adapter.sendImage)
|
|
432
|
+
capParts.push('图片输出');
|
|
433
|
+
if (channelInfo.adapter.sendFile)
|
|
434
|
+
capParts.push('文件发送');
|
|
435
|
+
if (capParts.length > 0) {
|
|
436
|
+
contextParts.push(`[通道能力] ${capParts.join('、')}`);
|
|
437
|
+
}
|
|
438
|
+
const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
|
|
439
|
+
// 可重试错误(403/429/5xx)指数退避重试,最多 3 次
|
|
440
|
+
const MAX_RETRIES = 3;
|
|
441
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
442
|
+
let streamRegistered = false;
|
|
443
|
+
try {
|
|
444
|
+
const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
|
|
445
|
+
agent.registerStream(streamKey, stream);
|
|
446
|
+
streamRegistered = true;
|
|
447
|
+
streamResult = await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress);
|
|
448
|
+
break; // 成功,跳出重试循环
|
|
449
|
+
}
|
|
450
|
+
catch (retryError) {
|
|
451
|
+
if (streamRegistered) {
|
|
452
|
+
agent.cleanupStream(streamKey);
|
|
453
|
+
}
|
|
454
|
+
if (attempt < MAX_RETRIES && isRetryableError(retryError)) {
|
|
455
|
+
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
|
|
456
|
+
logger.warn(`[MessageProcessor] Retryable error (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
|
|
457
|
+
flusher.addActivity(`⚠️ API 暂时不可用,${delay / 1000}秒后重试 (${attempt}/${MAX_RETRIES})...`);
|
|
458
|
+
await flusher.flush();
|
|
459
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
throw retryError; // 不可重试或已耗尽重试次数
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.agentSessionId && hasCompact(agent)) {
|
|
468
|
+
// 尝试 compact 压缩会话
|
|
469
|
+
flusher.addActivity('\u26a0\ufe0f 上下文过长,正在压缩会话...');
|
|
470
|
+
await flusher.flush();
|
|
471
|
+
const compacted = await agent.compact(session.id, session.agentSessionId, absoluteProjectPath);
|
|
472
|
+
if (compacted) {
|
|
473
|
+
// compact 成功,带 resume 重试(不重复原始消息,让 Agent 继续未完成的工作)
|
|
474
|
+
flusher.addActivity('\u2705 压缩完成,正在重试...');
|
|
475
|
+
const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, options?.systemPromptAppend, this.sessionManager);
|
|
476
|
+
agent.registerStream(streamKey, retryStream);
|
|
477
|
+
streamResult = await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
throw new Error('CONTEXT_COMPACT_FAILED');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// 处理文件标记 - 支持 [SEND_FILE:path] 和 [SEND_FILE:channel:path]
|
|
488
|
+
// 注意:始终扫描全部文本(含中间轮),因为文件标记可能出现在任意轮次
|
|
489
|
+
// suppressed 模式下 flusher 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
|
|
490
|
+
const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
|
|
491
|
+
const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
|
|
492
|
+
const flusherText = flusher.getFinalText();
|
|
493
|
+
const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
|
|
494
|
+
const fileMatches = [...fullText.matchAll(markerPattern)];
|
|
495
|
+
for (const match of fileMatches) {
|
|
496
|
+
// 兼容旧格式 (1组) 和新格式 (2组)
|
|
497
|
+
const hasChannelGroup = match.length >= 3;
|
|
498
|
+
const targetSpec = hasChannelGroup ? (match[1] ?? undefined) : undefined;
|
|
499
|
+
const filePath = (hasChannelGroup ? match[2] : match[1]).trim();
|
|
500
|
+
if (this.isPlaceholderPath(filePath)) {
|
|
501
|
+
logger.info(`[${adapter.channelName}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
// 解析目标:按实例名匹配,再按 channelType 映射
|
|
505
|
+
let targetInfo = targetSpec ? this.channels.get(targetSpec) : channelInfo;
|
|
506
|
+
let targetLabel = targetSpec || message.channel;
|
|
507
|
+
if (targetSpec && !targetInfo) {
|
|
508
|
+
// 按 channelType 查找首个匹配的实例
|
|
509
|
+
const instanceName = this.channelTypeMap.get(targetSpec);
|
|
510
|
+
if (instanceName)
|
|
511
|
+
targetInfo = this.channels.get(instanceName);
|
|
512
|
+
}
|
|
513
|
+
const currentChannelType = channelInfo.options?.channelType || adapter.channelName;
|
|
514
|
+
const isCrossChannel = targetSpec && targetSpec !== message.channel
|
|
515
|
+
&& targetSpec !== currentChannelType;
|
|
516
|
+
// 跨通道仅限 owner
|
|
517
|
+
if (isCrossChannel && session.identity?.role !== 'owner') {
|
|
518
|
+
await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(session));
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
|
|
522
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
523
|
+
logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
|
|
524
|
+
await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(session));
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
// 找目标 adapter
|
|
528
|
+
if (!targetInfo) {
|
|
529
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(session));
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (!targetInfo.adapter.sendFile) {
|
|
533
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(session));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// 找目标 channelId
|
|
537
|
+
let targetChannelId = message.channelId;
|
|
538
|
+
if (isCrossChannel) {
|
|
539
|
+
const targetAdapterName = targetInfo.adapter.channelName;
|
|
540
|
+
const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
|
|
541
|
+
const ownerPeerId = getOwner(this.config, targetAdapterName);
|
|
542
|
+
targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
|
|
543
|
+
if (!targetChannelId) {
|
|
544
|
+
await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
|
|
549
|
+
try {
|
|
550
|
+
await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(session));
|
|
551
|
+
this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
|
|
552
|
+
if (isCrossChannel) {
|
|
553
|
+
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(session));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
|
|
558
|
+
await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(session));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
|
|
562
|
+
// suppressed 模式:中间流式文本未推送,使用最后一轮回复(回退到全文)
|
|
563
|
+
// 非 suppressed 且无流式文本:同上
|
|
564
|
+
// 非 suppressed 且有流式文本:已经逐步推送过了,不重复添加
|
|
565
|
+
// 但如果 flusher 既未发送过内容也没有 pending 内容(如 text 事件全为空),仍需兜底
|
|
566
|
+
const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
|
|
567
|
+
if (finalReplyText) {
|
|
568
|
+
if (shouldSuppress()) {
|
|
569
|
+
flusher.addText(finalReplyText);
|
|
570
|
+
}
|
|
571
|
+
else if (!streamResult.hasReceivedText || (!flusher.hasSentContent() && !flusher.hasContent())) {
|
|
572
|
+
flusher.addText(finalReplyText);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
576
|
+
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
|
+
// 清理 activeStreams(正常完成)
|
|
586
|
+
agent.cleanupStream(streamKey);
|
|
587
|
+
// 清除处理中状态
|
|
588
|
+
this.sessionManager.clearProcessing(session.id);
|
|
589
|
+
// 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
|
|
590
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
591
|
+
if (streamResult.isError) {
|
|
592
|
+
// Agent 流正常结束但任务结果失败(权限被拒、max turns、工具链失败等)
|
|
593
|
+
const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
|
|
594
|
+
const rawSubtype = streamResult.subtype || 'agent_error';
|
|
595
|
+
const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
|
|
596
|
+
adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(session));
|
|
597
|
+
this.eventBus.publish({
|
|
598
|
+
type: 'message:error',
|
|
599
|
+
sessionId: session.id,
|
|
600
|
+
error: errorSummary,
|
|
601
|
+
errorType,
|
|
602
|
+
terminalReason: streamResult.terminalReason
|
|
603
|
+
});
|
|
604
|
+
// 仅系统级 subtype 累计安全模式(权限拒绝、max turns 等用户操作不累计)
|
|
605
|
+
if (isInfraError(rawSubtype, streamResult.terminalReason)) {
|
|
606
|
+
const chatType = message.chatType || 'private';
|
|
607
|
+
const identityRole = session.identity?.role || 'anonymous';
|
|
608
|
+
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
609
|
+
const { policy } = channelInfo;
|
|
610
|
+
if (policy.accumulateErrors(chatType, identityRole)) {
|
|
611
|
+
const newCount = await this.sessionManager.recordError(session.id, errorType, errorSummary);
|
|
612
|
+
await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
logger.message({
|
|
616
|
+
msgId: messageId,
|
|
617
|
+
sessionId: session.id,
|
|
618
|
+
dir: 'inbound',
|
|
619
|
+
status: 'failed',
|
|
620
|
+
error: errorSummary,
|
|
621
|
+
terminalReason: streamResult.terminalReason
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
// 真正的成功
|
|
626
|
+
adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
|
|
627
|
+
await this.sessionManager.recordSuccess(session.id);
|
|
628
|
+
this.eventBus.publish({
|
|
629
|
+
type: 'message:completed',
|
|
630
|
+
sessionId: session.id,
|
|
631
|
+
channel: message.channel,
|
|
632
|
+
channelId: message.channelId,
|
|
633
|
+
terminalReason: streamResult.terminalReason,
|
|
634
|
+
finalText: streamResult.lastReplyText || undefined,
|
|
635
|
+
durationMs: Date.now() - startTime,
|
|
636
|
+
timestamp: Date.now()
|
|
637
|
+
});
|
|
638
|
+
// 记录处理完成
|
|
639
|
+
logger.message({
|
|
640
|
+
msgId: messageId,
|
|
641
|
+
sessionId: session.id,
|
|
642
|
+
dir: 'inbound',
|
|
643
|
+
status: 'completed',
|
|
644
|
+
duration: Date.now() - startTime
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
const isFinallyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
648
|
+
if (isFinallyBackground) {
|
|
649
|
+
const projectName = path.basename(session.projectPath);
|
|
650
|
+
const count = this.messageCache.getCount(session.id);
|
|
651
|
+
await adapter.sendText(message.channelId, `[\u540e\u53f0-${projectName}] \u2713 任务完成 (${count}条消息已缓存)`);
|
|
652
|
+
}
|
|
653
|
+
// 记录发送响应
|
|
654
|
+
logger.message({
|
|
655
|
+
msgId: `${messageId}_reply`,
|
|
656
|
+
sessionId: session.id,
|
|
657
|
+
dir: 'outbound',
|
|
658
|
+
status: 'sent'
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
// 清理流和处理中状态(异常时也要清除)
|
|
663
|
+
agent.cleanupStream(streamKey);
|
|
664
|
+
try {
|
|
665
|
+
this.sessionManager.clearProcessing(session.id);
|
|
666
|
+
}
|
|
667
|
+
catch { }
|
|
668
|
+
// 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
|
|
669
|
+
// 区分超时 / 中断 / 错误
|
|
670
|
+
const errType = classifyError(error);
|
|
671
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
672
|
+
const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
|
|
673
|
+
const procStatus = errType === ErrorType.SDK_TIMEOUT ? 'timeout'
|
|
674
|
+
: errType === ErrorType.STREAM_ERROR ? 'interrupted'
|
|
675
|
+
: 'error';
|
|
676
|
+
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
|
|
677
|
+
if (!isUserInterrupt) {
|
|
678
|
+
try {
|
|
679
|
+
adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
|
|
680
|
+
}
|
|
681
|
+
catch { }
|
|
682
|
+
}
|
|
683
|
+
// 用户主动中断时降级日志;其余仍按 error 记录
|
|
684
|
+
if (isUserInterrupt) {
|
|
685
|
+
logger.info(`[${message.channel}] Interrupted by user (${interruptReason})`);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
logger.error(`[${message.channel}] Error:`, error);
|
|
689
|
+
}
|
|
690
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
691
|
+
const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
|
|
692
|
+
this.eventBus.publish({
|
|
693
|
+
type: 'message:error',
|
|
694
|
+
sessionId: session.id,
|
|
695
|
+
error: errorMsg,
|
|
696
|
+
errorType
|
|
697
|
+
});
|
|
698
|
+
// 记录处理失败
|
|
699
|
+
logger.message({
|
|
700
|
+
msgId: messageId,
|
|
701
|
+
sessionId: session.id,
|
|
702
|
+
dir: 'inbound',
|
|
703
|
+
status: 'failed',
|
|
704
|
+
error: error instanceof Error ? error.message : String(error)
|
|
705
|
+
});
|
|
706
|
+
if (error instanceof Error && !isUserInterrupt) {
|
|
707
|
+
logger.error(`[${message.channel}] Error stack:`, error.stack);
|
|
708
|
+
}
|
|
709
|
+
// 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
|
|
710
|
+
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
|
|
711
|
+
// processEventStream 已通过 flusher 发过错误时也跳过
|
|
712
|
+
if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
|
|
713
|
+
logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
|
|
714
|
+
}
|
|
715
|
+
else if (isUserInterrupt) {
|
|
716
|
+
logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
|
|
717
|
+
}
|
|
718
|
+
else if (error?._errorAlreadySent) {
|
|
719
|
+
logger.info(`[MessageProcessor] Error already sent via flusher, skip sending duplicate message`);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
const userMessage = getErrorMessage(error, undefined);
|
|
723
|
+
// 获取 session 用于话题回复(如果 resolveSession 已执行)
|
|
724
|
+
let sendOpts;
|
|
725
|
+
try {
|
|
726
|
+
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
|
|
727
|
+
sendOpts = this.getReplyContext(session);
|
|
728
|
+
}
|
|
729
|
+
catch { }
|
|
730
|
+
await adapter.sendText(message.channelId, userMessage, sendOpts);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* 解析会话和项目路径
|
|
736
|
+
*/
|
|
737
|
+
async resolveSession(message) {
|
|
738
|
+
// 话题会话:使用 Channel 预构建的 replyContext
|
|
739
|
+
const metadata = message.replyContext
|
|
740
|
+
? { replyContext: message.replyContext }
|
|
741
|
+
: undefined;
|
|
742
|
+
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata, undefined, message.peerId);
|
|
743
|
+
const absoluteProjectPath = path.isAbsolute(session.projectPath)
|
|
744
|
+
? session.projectPath
|
|
745
|
+
: path.resolve(process.cwd(), session.projectPath);
|
|
746
|
+
return { session, absoluteProjectPath };
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* 处理标准事件流(AgentEvent)
|
|
750
|
+
*
|
|
751
|
+
* 此方法只消费标准 AgentEvent 类型,不引用任何 SDK 特有事件。
|
|
752
|
+
* SDK 事件 → AgentEvent 的转换在 AgentRunner.transformStream() 中完成。
|
|
753
|
+
*/
|
|
754
|
+
async processEventStream(stream, session, flusher, resetTimer, shouldSuppress) {
|
|
755
|
+
let hasReceivedText = false;
|
|
756
|
+
let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
|
|
757
|
+
let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
|
|
758
|
+
// 追踪最后一轮 assistant 回复文本(tool_use 之后的纯文本)
|
|
759
|
+
let lastReplyText = '';
|
|
760
|
+
try {
|
|
761
|
+
for await (const event of stream) {
|
|
762
|
+
// 每收到事件重置空闲超时
|
|
763
|
+
const toolName = event.type === 'tool_use' ? event.name : undefined;
|
|
764
|
+
resetTimer(event.type, toolName);
|
|
765
|
+
// 记录所有事件类型
|
|
766
|
+
logger.info(`[MessageProcessor] Event: type=${event.type}`);
|
|
767
|
+
// session_id 已在 AgentRunner.transformStream 中处理,此处仅记录
|
|
768
|
+
if (event.type === 'session_id') {
|
|
769
|
+
logger.info(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
// session 状态变更(idle/running/requires_action)
|
|
773
|
+
if (event.type === 'state_changed') {
|
|
774
|
+
logger.info(`[MessageProcessor] Session state: ${event.state} for session: ${session.id}`);
|
|
775
|
+
this.eventBus.publish({ type: 'agent:state-changed', sessionId: session.id, state: event.state });
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// agent 状态通知(仅事件,不直出给用户)
|
|
779
|
+
if (event.type === 'status') {
|
|
780
|
+
logger.info(`[MessageProcessor] Agent status: ${event.subtype}: ${event.message}`);
|
|
781
|
+
this.eventBus.publish({
|
|
782
|
+
type: 'agent:status',
|
|
783
|
+
sessionId: session.id,
|
|
784
|
+
subtype: event.subtype,
|
|
785
|
+
message: event.message,
|
|
786
|
+
timestamp: Date.now()
|
|
787
|
+
});
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const isCurrentlyBackground = await this.isBackgroundSession(session, session.channel, session.channelId);
|
|
791
|
+
// === 前台任务:正常处理所有事件 ===
|
|
792
|
+
if (!isCurrentlyBackground) {
|
|
793
|
+
// 流式文本
|
|
794
|
+
if (event.type === 'text') {
|
|
795
|
+
hasReceivedText = true;
|
|
796
|
+
lastReplyText += event.text;
|
|
797
|
+
this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
|
|
798
|
+
if (!shouldSuppress()) {
|
|
799
|
+
flusher.addText(event.text);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// compact 完成
|
|
803
|
+
if (event.type === 'compact') {
|
|
804
|
+
this.eventBus.publish({ type: 'agent:compact-complete', sessionId: session.id, preTokens: event.preTokens });
|
|
805
|
+
if (!shouldSuppress()) {
|
|
806
|
+
flusher.addActivity(`\ud83d\udca1 会话压缩完成,继续执行...(压缩前 tokens: ${event.preTokens})`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// 子任务进度
|
|
810
|
+
if (event.type === 'task_progress') {
|
|
811
|
+
const tools = event.toolUses ?? 0;
|
|
812
|
+
const duration = event.durationMs ? `${Math.round(event.durationMs / 1000)}s` : '';
|
|
813
|
+
const stats = [tools > 0 ? `${tools}\u6b21\u5de5\u5177\u8c03\u7528` : '', duration].filter(Boolean).join(', ');
|
|
814
|
+
if (event.summary && !shouldSuppress()) {
|
|
815
|
+
flusher.addActivity(`\u23f3 \u5b50\u4efb\u52a1: ${event.summary}${stats ? ` (${stats})` : ''}`);
|
|
816
|
+
}
|
|
817
|
+
else if (stats && !shouldSuppress()) {
|
|
818
|
+
flusher.addActivity(`\u23f3 \u5b50\u4efb\u52a1\u8fdb\u884c\u4e2d: ${stats}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// 工具调用
|
|
822
|
+
if (event.type === 'tool_use') {
|
|
823
|
+
// 工具调用意味着当前文本是中间轮,重置最后回复追踪
|
|
824
|
+
lastReplyText = '';
|
|
825
|
+
this.eventBus.publish({
|
|
826
|
+
type: 'tool:use',
|
|
827
|
+
sessionId: session.id,
|
|
828
|
+
toolName: event.name,
|
|
829
|
+
input: event.input,
|
|
830
|
+
timestamp: Date.now()
|
|
831
|
+
});
|
|
832
|
+
if (!shouldSuppress()) {
|
|
833
|
+
const desc = summarizeToolInput(event.name, event.input || {});
|
|
834
|
+
flusher.addActivity(`\ud83d\udd27 ${event.name}${desc ? ': ' + desc : ''}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// 工具结果
|
|
838
|
+
if (event.type === 'tool_result') {
|
|
839
|
+
logger.debug(`[MessageProcessor] tool_result: name=${event.name}, is_error=${event.isError}`);
|
|
840
|
+
this.eventBus.publish({
|
|
841
|
+
type: 'tool:result',
|
|
842
|
+
sessionId: session.id,
|
|
843
|
+
toolName: event.name,
|
|
844
|
+
isError: event.isError,
|
|
845
|
+
content: event.result,
|
|
846
|
+
timestamp: Date.now()
|
|
847
|
+
});
|
|
848
|
+
if (event.isError && !shouldSuppress()) {
|
|
849
|
+
hasErrorResult = true;
|
|
850
|
+
let errorMsg = event.error || (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)) || '\u6267\u884c\u5931\u8d25';
|
|
851
|
+
// 移除 XML 风格的错误标签
|
|
852
|
+
errorMsg = errorMsg.replace(/<tool_use_error>(.*?)<\/tool_use_error>/gs, '$1');
|
|
853
|
+
flusher.addActivity(`\u26a0\ufe0f ${event.name || '\u5de5\u5177'}: ${errorMsg}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// 运行时错误(Codex: turn.failed / item error)
|
|
857
|
+
if (event.type === 'error') {
|
|
858
|
+
logger.warn(`[MessageProcessor] error event: ${event.errorType}: ${event.error}`);
|
|
859
|
+
if (!hasErrorResult && !shouldSuppress()) {
|
|
860
|
+
hasErrorResult = true;
|
|
861
|
+
flusher.addActivity(`\u274c ${event.error}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// 完成事件
|
|
865
|
+
// SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
|
|
866
|
+
// 仅记录状态,最终 flush(true) 在流结束后统一执行
|
|
867
|
+
if (event.type === 'complete') {
|
|
868
|
+
logger.debug(`[MessageProcessor] complete event: hasReceivedText=${hasReceivedText}, isError=${event.isError}, shouldSuppress=${shouldSuppress()}`);
|
|
869
|
+
// 自动回填会话名称
|
|
870
|
+
if (event.sessionTitle && session.name === '默认会话') {
|
|
871
|
+
await this.sessionManager.renameSession(session.id, event.sessionTitle);
|
|
872
|
+
logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
|
|
873
|
+
}
|
|
874
|
+
// 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
|
|
875
|
+
completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
|
|
876
|
+
// 失败且无前置错误输出:显示 errors 摘要
|
|
877
|
+
// 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
|
|
878
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
879
|
+
const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
|
|
880
|
+
if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt) {
|
|
881
|
+
const errorSummary = event.errors?.join('; ') || '\u4efb\u52a1\u6267\u884c\u5931\u8d25';
|
|
882
|
+
// 使用 terminalReason 提供更友好的错误提示
|
|
883
|
+
const userFriendlyMessage = event.terminalReason
|
|
884
|
+
? getErrorMessage(null, event.terminalReason)
|
|
885
|
+
: `\u274c ${errorSummary}`;
|
|
886
|
+
flusher.addActivity(userFriendlyMessage);
|
|
887
|
+
}
|
|
888
|
+
// 中间 complete:flush 掉已有 activities(不带 isFinal),让中间结果及时显示
|
|
889
|
+
// 最终文本留给流结束后的统一 flush(true)
|
|
890
|
+
if (flusher.hasContent()) {
|
|
891
|
+
await flusher.flushActivitiesOnly();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
// === 后台任务:追踪最后回复文本,但只处理 complete 事件 ===
|
|
897
|
+
if (event.type === 'text') {
|
|
898
|
+
lastReplyText += event.text;
|
|
899
|
+
}
|
|
900
|
+
else if (event.type === 'tool_use') {
|
|
901
|
+
lastReplyText = '';
|
|
902
|
+
}
|
|
903
|
+
if (event.type !== 'complete') {
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
// 自动回填会话名称
|
|
907
|
+
if (event.sessionTitle && session.name === '默认会话') {
|
|
908
|
+
await this.sessionManager.renameSession(session.id, event.sessionTitle);
|
|
909
|
+
logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
|
|
910
|
+
}
|
|
911
|
+
// 记录完成状态
|
|
912
|
+
completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
|
|
913
|
+
if (event.subtype === 'success') {
|
|
914
|
+
this.messageCache.addEvent(session.id, {
|
|
915
|
+
type: 'completed',
|
|
916
|
+
message: lastReplyText || event.result || '',
|
|
917
|
+
timestamp: Date.now(),
|
|
918
|
+
metadata: {
|
|
919
|
+
duration: event.durationMs,
|
|
920
|
+
cost: event.costUsd
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
// 后台任务完成也纳入统计
|
|
924
|
+
this.eventBus.publish({
|
|
925
|
+
type: 'message:completed',
|
|
926
|
+
sessionId: session.id,
|
|
927
|
+
channel: session.channel,
|
|
928
|
+
channelId: session.channelId,
|
|
929
|
+
finalText: lastReplyText || event.result || undefined,
|
|
930
|
+
durationMs: event.durationMs,
|
|
931
|
+
timestamp: Date.now()
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
else if (event.isError === true) {
|
|
935
|
+
const bgErrorType = prefixErrorType(ERROR_PREFIX.AGENT, event.subtype || 'agent_error');
|
|
936
|
+
this.messageCache.addEvent(session.id, {
|
|
937
|
+
type: 'error',
|
|
938
|
+
message: event.errors?.join('\n') || '\u672a\u77e5\u9519\u8bef',
|
|
939
|
+
timestamp: Date.now(),
|
|
940
|
+
metadata: {
|
|
941
|
+
errorType: event.subtype
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
// 后台任务失败也纳入统计
|
|
945
|
+
this.eventBus.publish({
|
|
946
|
+
type: 'message:error',
|
|
947
|
+
sessionId: session.id,
|
|
948
|
+
error: event.errors?.join('; ') || '\u672a\u77e5\u9519\u8bef',
|
|
949
|
+
errorType: bgErrorType
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
// User interrupt (AbortError) is expected, log at info level
|
|
956
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
957
|
+
logger.info('[MessageProcessor] Stream interrupted (AbortError)');
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
961
|
+
}
|
|
962
|
+
if (error instanceof Error && error.message.includes('process exited')) {
|
|
963
|
+
flusher.addActivity('\u274c Claude Code \u8fdb\u7a0b\u5f02\u5e38\u9000\u51fa\uff0c\u8bf7\u91cd\u8bd5');
|
|
964
|
+
}
|
|
965
|
+
// Flush any pending error activities before re-throwing,
|
|
966
|
+
// and mark the error so outer catch won't send a duplicate message
|
|
967
|
+
if (hasErrorResult || flusher.hasContent()) {
|
|
968
|
+
try {
|
|
969
|
+
await flusher.flush(true);
|
|
970
|
+
}
|
|
971
|
+
catch { }
|
|
972
|
+
if (error instanceof Error) {
|
|
973
|
+
error._errorAlreadySent = true;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
completeResult.hasReceivedText = hasReceivedText;
|
|
979
|
+
return completeResult;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* 解析文件路径,支持相对路径和绝对路径
|
|
983
|
+
* 优先在项目根目录查找,兜底尝试 .openclaw/workspace/
|
|
984
|
+
*/
|
|
985
|
+
resolveFilePath(filePath, projectPath) {
|
|
986
|
+
if (path.isAbsolute(filePath)) {
|
|
987
|
+
return filePath;
|
|
988
|
+
}
|
|
989
|
+
// 优先在项目根目录查找
|
|
990
|
+
const rootPath = path.join(projectPath, filePath);
|
|
991
|
+
if (fs.existsSync(rootPath)) {
|
|
992
|
+
return rootPath;
|
|
993
|
+
}
|
|
994
|
+
// 兜底:尝试 .openclaw/workspace/
|
|
995
|
+
const workspacePath = path.join(projectPath, '.openclaw', 'workspace', filePath);
|
|
996
|
+
if (fs.existsSync(workspacePath)) {
|
|
997
|
+
return workspacePath;
|
|
998
|
+
}
|
|
999
|
+
// 都找不到,返回项目根目录路径
|
|
1000
|
+
return rootPath;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* 判断文件路径是否为占位符/示例文本
|
|
1004
|
+
* 用于过滤大模型在说明文字中误写的 [SEND_FILE:...] 标记
|
|
1005
|
+
*/
|
|
1006
|
+
isPlaceholderPath(filePath) {
|
|
1007
|
+
if (!filePath)
|
|
1008
|
+
return true;
|
|
1009
|
+
// 精确占位符
|
|
1010
|
+
const exactPlaceholders = ['...', '\u2026', 'path', 'file', 'file_path', 'filepath',
|
|
1011
|
+
'\u8def\u5f84', '\u6587\u4ef6\u8def\u5f84', '\u6587\u4ef6', 'filename', 'xxx'];
|
|
1012
|
+
if (exactPlaceholders.includes(filePath.toLowerCase()))
|
|
1013
|
+
return true;
|
|
1014
|
+
// 示例路径前缀
|
|
1015
|
+
if (/^(\/path\/to\/|\.\/path\/to\/|example\/|\u793a\u4f8b|\/example)/i.test(filePath))
|
|
1016
|
+
return true;
|
|
1017
|
+
// 含模板变量
|
|
1018
|
+
if (/\$\{.+\}|\{\{.+\}\}|<.+>/.test(filePath))
|
|
1019
|
+
return true;
|
|
1020
|
+
// 纯标点/特殊字符(非路径字符)
|
|
1021
|
+
if (/^[.\s\u2026]+$/.test(filePath))
|
|
1022
|
+
return true;
|
|
1023
|
+
// 含正则/代码特殊字符(Agent 在说明中引用了代码或正则表达式)
|
|
1024
|
+
if (/[\\[\]{}*+?|^$]/.test(filePath))
|
|
1025
|
+
return true;
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
}
|