evolclaw 3.1.11 → 3.2.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.
@@ -300,6 +300,9 @@ export class EvolAgentRegistry {
300
300
  }
301
301
  // swap config 后再起新 channel —— startChannel hook 需要看到新 config
302
302
  oldAgent.swapConfig(raw, merged);
303
+ // 热重载也刷新身份层缓存(persona / working 等 fileCache 'agent-files:<aid>' 组),
304
+ // 使 personal 文件改动经 reload 即时生效,不必重启。
305
+ oldAgent.invalidatePersonaCache();
303
306
  for (const ch of toAdd) {
304
307
  await hooks.startChannel(oldAgent, ch);
305
308
  addedSuccessfully.push(ch);
@@ -1,9 +1,9 @@
1
1
  import path from 'path';
2
- import fs from 'fs';
3
2
  import { logger } from '../utils/logger.js';
4
3
  import { saveAgent } from '../config-store.js';
5
4
  import { formatChannelKey, tryParseChannelKey } from './channel-loader.js';
6
5
  import { agentPersonalDir } from '../paths.js';
6
+ import { fileCache } from './cache/file-cache.js';
7
7
  /**
8
8
  * EvolAgent —— 一个 self-agent 的运行时表示。
9
9
  *
@@ -213,40 +213,45 @@ export class EvolAgent {
213
213
  this.merged.dispatch = value;
214
214
  this.persist();
215
215
  }
216
+ /** 读取观察者模式开关(默认 false)。 */
217
+ getObservable() {
218
+ return this.merged.observable === true;
219
+ }
220
+ /** 设置观察者模式开关:开启后入站/出站消息各转发一份给 owners[]。 */
221
+ setObservable(value) {
222
+ if (value)
223
+ this.rawAgent.observable = true;
224
+ else
225
+ delete this.rawAgent.observable;
226
+ this.merged.observable = value;
227
+ this.persist();
228
+ }
216
229
  // ── Personal layer ────────────────────────────────────────────────────
217
- _personaCache = undefined;
230
+ /** agent 身份层文件在 fileCache 的组名(带 aid,避免 reload 单个 agent 误失效他人)。 */
231
+ agentFilesGroup() {
232
+ return `agent-files:${this.aid}`;
233
+ }
218
234
  /**
219
- * 读取 personal/persona.md 内容(缓存,首次调用时从磁盘读)。
220
- * 文件不存在返回 null。
235
+ * 读取 personal/persona.md 内容。走 fileCache(mtime 门控):persona 没有任何
236
+ * 写入命令、由 agent 自己带外改写,与 working memory 同样改了即应生效,故每次读
237
+ * stat 比对、变了自动重读。文件不存在返回 null。
221
238
  */
222
239
  getPersona() {
223
- if (this._personaCache !== undefined)
224
- return this._personaCache;
225
240
  const personaPath = path.join(agentPersonalDir(this.aid), 'persona.md');
226
- try {
227
- this._personaCache = fs.readFileSync(personaPath, 'utf-8').trim() || null;
228
- }
229
- catch {
230
- this._personaCache = null;
231
- }
232
- return this._personaCache;
241
+ return fileCache.get(personaPath, (raw) => (raw === null ? null : (raw.trim() || null)), { policy: 'mtime', group: this.agentFilesGroup() });
233
242
  }
234
243
  /**
235
- * 读取 personal/memory/working.md 内容(不缓存,每次会话开始时读)。
244
+ * 读取 personal/memory/working.md 内容。走 fileCache(mtime 门控):
245
+ * agent 在对话中改写 working memory、不触发 reload,故每次读 stat 比对、
246
+ * 变了自动重读,既即时反映又避免无谓重读。
236
247
  */
237
248
  getWorkingMemory() {
238
249
  const workingPath = path.join(agentPersonalDir(this.aid), 'memory', 'working.md');
239
- try {
240
- const content = fs.readFileSync(workingPath, 'utf-8').trim();
241
- return content || null;
242
- }
243
- catch {
244
- return null;
245
- }
250
+ return fileCache.get(workingPath, (raw) => (raw === null ? null : (raw.trim() || null)), { policy: 'mtime', group: this.agentFilesGroup() });
246
251
  }
247
- /** 清除 persona 缓存(reload 后重新读取) */
252
+ /** 清除本 agent 身份层缓存(reload 后重新读取)。只失效自己的文件组,不波及他人。 */
248
253
  invalidatePersonaCache() {
249
- this._personaCache = undefined;
254
+ fileCache.invalidateGroup(this.agentFilesGroup());
250
255
  }
251
256
  // ── Context(喂给 message-processor / command-handler) ──────────────
252
257
  getContext(_channelKey, chatType, globalChatmode) {
@@ -0,0 +1,153 @@
1
+ import { agentCreateNonInteractive, agentDelete, agentEnable, agentDisable, agentList, agentShow, agentSet, } from '../../cli/agent.js';
2
+ import { logger } from '../../utils/logger.js';
3
+ import { resolvePaths } from '../../paths.js';
4
+ import path from 'path';
5
+ import { CreateStatusWriter, readCreateStatus } from './create-status.js';
6
+ /** 把 cli/agent.ts 的 error 字符串映射为结构化错误码 */
7
+ function classifyError(error) {
8
+ if (/already exists/i.test(error))
9
+ return 'CONFLICT';
10
+ if (/not found/i.test(error))
11
+ return 'NOT_FOUND';
12
+ if (/invalid|must be|required|缺少/i.test(error))
13
+ return 'INVALID_ARGS';
14
+ return 'INTERNAL';
15
+ }
16
+ /** 后台异步:实际创建 agent + 落 model/chatmode,全程写构建进度(D3)。
17
+ * 失败仅写日志 + create-status,不回传(受理即返回)。
18
+ * agentSet key 对照 cli/agent.ts 的 setNestedValue:
19
+ * model → 'models.default'(ModelsBlock.default);chatmode → 'chatmode'(ChatmodeBlock 对象)。 */
20
+ async function runCreateInBackground(opts) {
21
+ const agentDir = path.join(resolvePaths().agentsDir, opts.aid);
22
+ const w = new CreateStatusWriter(agentDir, opts.aid);
23
+ let curPhase = 'validating'; // 跟踪当前环节,供 catch 兜底时标注正确 phase
24
+ try {
25
+ // onPhase 把 agentCreateNonInteractive 内部环节(0-3、5)映射到进度文件
26
+ const res = await agentCreateNonInteractive({
27
+ aid: opts.aid, name: opts.name, baseagent: opts.baseagent,
28
+ project: opts.project, owner: opts.owner,
29
+ onPhase: (phase, state, detail) => {
30
+ if (state === 'begin') {
31
+ curPhase = phase;
32
+ w.begin(phase);
33
+ }
34
+ else if (state === 'done')
35
+ w.done(phase, detail);
36
+ else if (state === 'warn')
37
+ w.warn(phase, detail);
38
+ else if (state === 'failed')
39
+ w.finishFailed(phase, detail ?? 'failed');
40
+ },
41
+ });
42
+ if (!('ok' in res) || res.ok !== true) {
43
+ // 硬失败:onPhase('failed') 已写终态;这里仅兜底日志(防回调未覆盖的 return 路径)
44
+ const err = res.error;
45
+ logger.warn(`[agent-control] create ${opts.aid} failed: ${err}`);
46
+ return;
47
+ }
48
+ // 环节 4:applying_config(model/chatmode,agentCreateNonInteractive 之外)
49
+ if (opts.model || opts.chatmode) {
50
+ curPhase = 'applying_config';
51
+ w.begin('applying_config');
52
+ let warned;
53
+ if (opts.model) {
54
+ const r = await agentSet(opts.aid, 'models.default', opts.model);
55
+ if (!('ok' in r) || !r.ok)
56
+ warned = `model: ${r.error}`;
57
+ }
58
+ if (opts.chatmode) {
59
+ const r = await agentSet(opts.aid, 'chatmode', JSON.stringify(opts.chatmode));
60
+ if (!('ok' in r) || !r.ok)
61
+ warned = `${warned ? warned + '; ' : ''}chatmode: ${r.error}`;
62
+ }
63
+ if (warned) {
64
+ logger.warn(`[agent-control] applying_config ${opts.aid}: ${warned}`);
65
+ w.warn('applying_config', warned);
66
+ }
67
+ else
68
+ w.done('applying_config');
69
+ }
70
+ w.finishReady();
71
+ logger.info(`[agent-control] create ${opts.aid} ready`);
72
+ }
73
+ catch (e) {
74
+ const msg = e?.message || String(e);
75
+ logger.warn(`[agent-control] create ${opts.aid} threw at ${curPhase}: ${msg}`);
76
+ w.finishFailed(curPhase, msg); // 兜底终态,标注真实失败环节
77
+ }
78
+ }
79
+ /** name=agent 的 menu.action 执行。peerId 自动填为新 agent 的 owner。
80
+ * create 受理即返回(D3);delete/enable/disable 同步等结果。
81
+ * 调用方负责传入已兜底的 args.project(见 command-handler 装配)。 */
82
+ export async function execAgentAction(action, args, peerId) {
83
+ const a = args ?? {};
84
+ if (action === 'create') {
85
+ if (!peerId)
86
+ return { error: '缺少发起者 AID(无法绑定 owner)', code: 'INVALID_ARGS' };
87
+ if (!a.aid || !a.name || !a.baseagent) {
88
+ return { error: '缺少必填参数:aid / name / baseagent', code: 'INVALID_ARGS' };
89
+ }
90
+ if (!a.project || typeof a.project !== 'string') {
91
+ return { error: 'project 缺失且无法兜底(需 defaults.projects.rootPath/defaultPath)', code: 'INVALID_ARGS' };
92
+ }
93
+ // D3: 受理即返回,重副作用转后台
94
+ // 受理即返回;后台 promise fire-and-forget。runCreateInBackground 内部有 try/catch,
95
+ // 但 CreateStatusWriter 构造(mkdir/写盘)在 try 之前,故再加一层兜底防未处理拒绝。
96
+ void runCreateInBackground({
97
+ aid: a.aid, name: a.name, baseagent: a.baseagent,
98
+ project: a.project, owner: peerId,
99
+ model: a.model, chatmode: a.chatmode,
100
+ }).catch(e => logger.error(`[agent-control] runCreateInBackground unhandled ${a.aid}: ${e?.message || e}`));
101
+ return { data: { accepted: true, aid: a.aid } };
102
+ }
103
+ if (action === 'delete') {
104
+ if (!a.aid)
105
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
106
+ const res = await agentDelete(a.aid, false);
107
+ if (!('ok' in res) || res.ok !== true)
108
+ return { error: res.error, code: classifyError(res.error) };
109
+ return { data: { aid: res.aid, purged: res.purged } };
110
+ }
111
+ if (action === 'enable' || action === 'disable') {
112
+ if (!a.aid)
113
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
114
+ const res = action === 'enable' ? await agentEnable(a.aid) : await agentDisable(a.aid);
115
+ if (!('ok' in res) || res.ok !== true)
116
+ return { error: res.error, code: classifyError(res.error) };
117
+ return { data: { aid: res.aid, enabled: res.enabled, reloaded: res.reloaded } };
118
+ }
119
+ return { error: `不支持的 action: ${action}`, code: 'INVALID_ARGS' };
120
+ }
121
+ /** project 兜底:显式值 > rootPath 合成 > defaultPath > undefined */
122
+ export function resolveProjectPath(explicit, aid, defaults) {
123
+ if (explicit && explicit.trim())
124
+ return explicit;
125
+ const root = defaults?.projects?.rootPath;
126
+ if (root)
127
+ return path.join(root, aid.split('.')[0]);
128
+ return defaults?.projects?.defaultPath;
129
+ }
130
+ /** name=agent 的 menu.query:查单个 agent 详情,附构建进度(D3)。 */
131
+ export async function execAgentQuery(args) {
132
+ const aid = args?.aid;
133
+ if (!aid)
134
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
135
+ const res = await agentShow(aid);
136
+ if (!('ok' in res) || res.ok !== true)
137
+ return { error: res.error, code: classifyError(res.error) };
138
+ // 叠加构建进度(create 受理后、ready 前可见;ready 后文件仍在,可反映软失败 warn)
139
+ const agentDir = path.join(resolvePaths().agentsDir, aid);
140
+ const progress = readCreateStatus(agentDir);
141
+ return { data: progress ? { ...res, createProgress: progress } : res };
142
+ }
143
+ /** name=agent 的 menu.options:列出 agent(enabled 默认 / all) */
144
+ export async function execAgentOptions(args) {
145
+ const scope = args?.options === 'all' ? 'all' : 'enabled';
146
+ const res = await agentList();
147
+ if (!('ok' in res) || res.ok !== true)
148
+ return { error: res.error, code: classifyError(res.error) };
149
+ const agents = scope === 'all'
150
+ ? res.agents
151
+ : res.agents.filter((x) => x.status !== 'disabled');
152
+ return { data: { agents, scope } };
153
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ const FILE = 'create-status.json';
4
+ export function readCreateStatus(agentDir) {
5
+ try {
6
+ const raw = fs.readFileSync(path.join(agentDir, FILE), 'utf-8');
7
+ return JSON.parse(raw);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ /** 删除构建进度文件(agent 删除时清理)。非 purge 删除只移除 config.json,
14
+ * 故需显式清理本文件,避免目录残留陈旧进度。 */
15
+ export function removeCreateStatus(agentDir) {
16
+ try {
17
+ fs.rmSync(path.join(agentDir, FILE), { force: true });
18
+ }
19
+ catch { /* ignore */ }
20
+ }
21
+ /** 构建进度写入器。每次状态变更原子落盘(写临时文件 + rename)。 */
22
+ export class CreateStatusWriter {
23
+ agentDir;
24
+ status;
25
+ constructor(agentDir, aid) {
26
+ this.agentDir = agentDir;
27
+ const now = Date.now();
28
+ this.status = { aid, status: 'in_progress', currentPhase: null, steps: [], error: null, startedAt: now, updatedAt: now };
29
+ fs.mkdirSync(agentDir, { recursive: true });
30
+ this.flush();
31
+ }
32
+ begin(phase) {
33
+ this.status.currentPhase = phase;
34
+ this.status.steps.push({ phase, state: 'in_progress', ts: Date.now() });
35
+ this.flush();
36
+ }
37
+ done(phase, detail) { this.mark(phase, 'done', detail); }
38
+ warn(phase, detail) { this.mark(phase, 'warn', detail); }
39
+ finishReady() { this.status.status = 'ready'; this.status.currentPhase = null; this.flush(); }
40
+ finishFailed(phase, error) {
41
+ this.mark(phase, 'failed', error);
42
+ this.status.status = 'failed';
43
+ this.status.error = error;
44
+ this.status.currentPhase = null;
45
+ this.flush();
46
+ }
47
+ mark(phase, state, detail) {
48
+ const step = [...this.status.steps].reverse().find(s => s.phase === phase);
49
+ if (step) {
50
+ step.state = state;
51
+ if (detail)
52
+ step.detail = detail;
53
+ step.ts = Date.now();
54
+ }
55
+ else {
56
+ this.status.steps.push({ phase, state, detail, ts: Date.now() });
57
+ }
58
+ this.flush();
59
+ }
60
+ flush() {
61
+ this.status.updatedAt = Date.now();
62
+ const file = path.join(this.agentDir, FILE);
63
+ const tmp = `${file}.tmp`;
64
+ fs.writeFileSync(tmp, JSON.stringify(this.status, null, 2));
65
+ fs.renameSync(tmp, file);
66
+ }
67
+ }
@@ -151,7 +151,7 @@ export class MessageBridge {
151
151
  sameNetwork: msg.sameNetwork,
152
152
  sameEgressIp: msg.sameEgressIp,
153
153
  messageId: msg.messageId,
154
- mentions: msg.mentions, threadId: msg.threadId,
154
+ mentions: msg.mentions, mentionAids: msg.mentionAids, threadId: msg.threadId,
155
155
  replyContext: msg.replyContext,
156
156
  };
157
157
  // 5.5 写入消息记录(入方向)
@@ -218,6 +218,8 @@ export class MessageBridge {
218
218
  activity: '/activity',
219
219
  system: '/system',
220
220
  cli: '/cli',
221
+ agent: '/agent',
222
+ trigger: '/trigger',
221
223
  };
222
224
  resolveCmd(name, cmd) {
223
225
  if (cmd)
@@ -276,7 +278,7 @@ export class MessageBridge {
276
278
  const { id, name, cmd } = req;
277
279
  try {
278
280
  const resolvedCmd = this.resolveCmd(name, cmd);
279
- const result = await this.cmdHandler.execMenuQuery(resolvedCmd, channel, msg.channelId, msg.peerId);
281
+ const result = await this.cmdHandler.execMenuQuery(resolvedCmd, channel, msg.channelId, msg.peerId, req.args);
280
282
  if ('error' in result)
281
283
  throw { code: result.code || 'EXEC_FAILED', message: result.error };
282
284
  await this.sendMenuResponse(adapter, channel, msg.channelId, { type: 'menu.response', id, name, data: result.data }, sendReply);
@@ -292,7 +294,7 @@ export class MessageBridge {
292
294
  const { id, name, cmd } = req;
293
295
  try {
294
296
  const resolvedCmd = this.resolveCmd(name, cmd);
295
- const data = await this.cmdHandler.getSubMenuItems(resolvedCmd, channel, msg.channelId, msg.peerId) ?? [];
297
+ const data = await this.cmdHandler.getSubMenuItems(resolvedCmd, channel, msg.channelId, msg.peerId, req.args) ?? [];
296
298
  await this.sendMenuResponse(adapter, channel, msg.channelId, { type: 'menu.response', id, name, data }, sendReply);
297
299
  }
298
300
  catch (err) {
@@ -295,16 +295,9 @@ export class MessageProcessor {
295
295
  const streamKey = session.id;
296
296
  const chatType = message.chatType || 'private';
297
297
  const identityRole = session.identity?.role || 'anonymous';
298
- const agentNameForMonitor = this.agentRegistry?.resolveByChannel(channelKey)?.name ?? '<unknown>';
299
- // Resolve agent context from registry (Phase 2 foundation)
300
- const agentContext = this.getAgentContext(channelKey, chatType);
301
- if (agentContext) {
302
- logger.debug(`[MessageProcessor] Agent context resolved: ${agentContext.name} (${agentContext.baseagent})`);
303
- }
304
- // 按 session.agentId 选择 agent 后端
305
- const agent = this.getAgent(channelKey, session.agentId);
306
298
  const monitorEnabled = this.globalSettings.idleMonitor?.enabled !== false;
307
- const showIdleMonitor = policy.showIdleMonitor(chatType, identityRole);
299
+ // session.agentId 选择 agent 后端(idle-kill 路径需要 interrupt)
300
+ const agent = this.getAgent(channelKey, session.agentId);
308
301
  // 计算是否抑制中间输出(工具活动 + 流式文本)
309
302
  const shouldSuppress = () => {
310
303
  return !policy.showMiddleResult(chatType, identityRole);
@@ -313,6 +306,7 @@ export class MessageProcessor {
313
306
  let monitor;
314
307
  let monitorInterval;
315
308
  let rejectFn;
309
+ let lastIdleSec = 0;
316
310
  const resetTimer = (eventType, toolName) => {
317
311
  monitor?.recordEvent(eventType || 'unknown', toolName);
318
312
  };
@@ -329,17 +323,9 @@ export class MessageProcessor {
329
323
  let result = monitor.check();
330
324
  while (result) {
331
325
  if (result.action === 'kill') {
326
+ lastIdleSec = result.idleSec;
332
327
  logger.warn(`[MessageProcessor] Idle monitor: kill after ${result.idleSec}s idle, stream: ${streamKey}`);
333
328
  this.eventBus.publish({ type: 'runner:idle-timeout', sessionId: streamKey, idleSec: result.idleSec });
334
- // 后台任务也需要中断(释放资源),但不发送通知
335
- if (channelInfo && !isBackground) {
336
- const msg = showIdleMonitor
337
- ? result.message
338
- : `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
339
- channelInfo.adapter.send(buildEnvelope({ channel: channelInfo.adapter.channelName, channelId: message.channelId, agentName: agentNameForMonitor }), { kind: 'system.notice', text: msg, subtype: 'health' }).catch(e => {
340
- logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
341
- });
342
- }
343
329
  logger.info(`[MessageProcessor] agent.interrupt invoked (idle-kill) stream=${streamKey}`);
344
330
  agent.interrupt(streamKey).catch(e => {
345
331
  logger.debug(`[MessageProcessor] Interrupt failed (may already be cleaned up):`, e);
@@ -348,15 +334,16 @@ export class MessageProcessor {
348
334
  return;
349
335
  }
350
336
  else {
351
- // notify or warn: send diagnostic message, task continues
337
+ // notify or warn: publish event, task continues
352
338
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
353
- if (channelInfo && showIdleMonitor && !shouldSuppress()) {
354
- if (!isBackground) {
355
- channelInfo.adapter.send(buildEnvelope({ channel: channelInfo.adapter.channelName, channelId: message.channelId, agentName: agentNameForMonitor }), { kind: 'system.notice', text: result.message, subtype: 'health' }).catch(e => {
356
- logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
357
- });
358
- }
359
- }
339
+ this.eventBus.publish({
340
+ type: result.action === 'notify' ? 'runner:idle-notify' : 'runner:idle-warn',
341
+ sessionId: streamKey,
342
+ idleSec: result.idleSec,
343
+ totalEvents: result.state.totalEvents,
344
+ totalToolCalls: result.state.totalToolCalls,
345
+ lastToolName: result.state.lastToolName,
346
+ });
360
347
  }
361
348
  result = monitor.check();
362
349
  }
@@ -364,7 +351,7 @@ export class MessageProcessor {
364
351
  });
365
352
  try {
366
353
  await Promise.race([
367
- this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress),
354
+ this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec),
368
355
  timeoutPromise
369
356
  ]);
370
357
  }
@@ -412,7 +399,7 @@ export class MessageProcessor {
412
399
  return message.replyContext;
413
400
  }
414
401
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
415
- async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
402
+ async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec) {
416
403
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
417
404
  const channelKey = session.metadata?.channelKey || message.channel;
418
405
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -684,6 +671,11 @@ export class MessageProcessor {
684
671
  sameNetwork: message.sameNetwork ?? false,
685
672
  sameEgressIp: message.sameEgressIp ?? false,
686
673
  groupId: session.metadata?.groupId || undefined,
674
+ groupName: session.metadata?.groupName || undefined,
675
+ // 信封展示用:有群名则「名<ID>」,否则纯 ID。规避模板引擎无 not/else 的限制。
676
+ groupLabel: session.metadata?.groupId
677
+ ? (session.metadata?.groupName ? `${session.metadata.groupName}<${session.metadata.groupId}>` : session.metadata.groupId)
678
+ : undefined,
687
679
  chatType: session.chatType || null,
688
680
  channel: currentChannelType || null,
689
681
  venueUid: undefined,
@@ -735,6 +727,7 @@ export class MessageProcessor {
735
727
  sameDevice: message.sameDevice, sameNetwork: message.sameNetwork, sameEgressIp: message.sameEgressIp,
736
728
  content: message.content, timestamp: message.timestamp,
737
729
  images: message.images,
730
+ mentionAids: message.mentionAids,
738
731
  }];
739
732
  renderResult = renderMessageBody(renderItems, kitCtx.vars, session.id);
740
733
  if (renderResult.body.trim())
@@ -1100,7 +1093,7 @@ export class MessageProcessor {
1100
1093
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
1101
1094
  if (!isUserInterrupt) {
1102
1095
  const statusPayload = procStatus === 'timeout'
1103
- ? { kind: 'status.timeout' }
1096
+ ? { kind: 'status.timeout', metadata: { idleSec: getLastIdleSec?.() || undefined } }
1104
1097
  : procStatus === 'interrupted'
1105
1098
  ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
1106
1099
  : { kind: 'status.error' };
@@ -1133,20 +1126,22 @@ export class MessageProcessor {
1133
1126
  if (error instanceof Error && !isUserInterrupt) {
1134
1127
  logger.error(`[${message.channel}] Error stack:`, error.stack);
1135
1128
  }
1136
- // 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
1129
+ // 发送用户友好的错误消息
1137
1130
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
1138
1131
  // processEventStream 已通过 renderer 发过错误时也跳过
1139
- if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
1140
- logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
1141
- }
1142
- else if (isUserInterrupt) {
1132
+ const isTimeout = error instanceof Error && error.message === 'SDK_TIMEOUT';
1133
+ if (isUserInterrupt) {
1143
1134
  logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
1144
1135
  }
1145
1136
  else if (error?._errorAlreadySent) {
1146
1137
  logger.info(`[MessageProcessor] Error already sent via renderer, skip sending duplicate message`);
1147
1138
  }
1148
1139
  else {
1149
- const userMessage = getErrorMessage(error, undefined);
1140
+ // SDK_TIMEOUT:status.timeout 已发结构化状态,此处再补一条用户可见的错误文本(result.error
1141
+ const idleSec = getLastIdleSec?.() || 0;
1142
+ const userMessage = isTimeout
1143
+ ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
1144
+ : getErrorMessage(error, undefined);
1150
1145
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
1151
1146
  let sendOpts;
1152
1147
  try {
@@ -1159,7 +1154,10 @@ export class MessageProcessor {
1159
1154
  ...(sendOpts ?? {}),
1160
1155
  metadata: { ...(sendOpts?.metadata ?? {}), taskId, chatmode },
1161
1156
  };
1162
- await adapter.send({ ...envelope, replyContext: sendOpts }, { kind: 'result.text', text: userMessage, isFinal: true });
1157
+ const errorPayload = isTimeout
1158
+ ? { kind: 'result.error', text: userMessage, reason: 'timeout' }
1159
+ : { kind: 'result.text', text: userMessage, isFinal: true };
1160
+ await adapter.send({ ...envelope, replyContext: sendOpts }, errorPayload);
1163
1161
  // Proactive 可观测:catch 块的基础设施错误也透传为 thought,保证按 task_id 聚合完整
1164
1162
  }
1165
1163
  }
@@ -1196,6 +1194,16 @@ export class MessageProcessor {
1196
1194
  session.sessionMode = 'proactive';
1197
1195
  await this.sessionManager.updateSession(session.id, { sessionMode: 'proactive' });
1198
1196
  }
1197
+ // 群名解析:群会话首次取群显示名(group.get),缓存到 metadata,供信封渲染。
1198
+ // 渠道私有方法 getGroupName 自带进程缓存 + 容错;取不到不阻塞(groupName 保持空,模板回退 groupId)。
1199
+ if (message.chatType === 'group' && session.metadata?.groupId && !session.metadata.groupName) {
1200
+ const adapter = this.resolveChannelInfo(message.channel)?.adapter;
1201
+ const groupName = await adapter?.getGroupName?.(session.metadata.groupId).catch(() => undefined);
1202
+ if (groupName) {
1203
+ session.metadata.groupName = groupName;
1204
+ await this.sessionManager.updateSession(session.id, { metadata: session.metadata });
1205
+ }
1206
+ }
1199
1207
  // 兜底纠正2:旧 session 创建时没传 peerType(建为 interactive),后续非 human 消息进来时升级为 proactive。
1200
1208
  // 新建场景已由 getOrCreateSession 内部 resolveDefaultSessionMode 处理,这里只兜底历史会话。
1201
1209
  if (message.peerType && message.peerType !== 'human' && message.peerType !== 'unknown' && session.sessionMode !== 'proactive') {
@@ -40,16 +40,22 @@ export class MessageQueue {
40
40
  }
41
41
  /**
42
42
  * 检查消息是否应该处理(去重)
43
+ *
44
+ * 去重 key = `${sessionKey}:${messageId}`,而非裸 messageId。
45
+ * MessageQueue 是进程级单例,被所有 evolagent 共享。AUN 群广播时同一条群消息
46
+ * 会投递给群里每个 evolagent,它们 messageId 相同但 session 不同,必须各处理一次。
47
+ * 裸 messageId 去重会让先入队的 agent 吞掉其他 agent 的消息。
43
48
  */
44
- shouldProcess(message) {
49
+ shouldProcess(sessionKey, message) {
45
50
  if (!message.messageId)
46
51
  return true; // 无 ID 的消息不去重
47
- if (this.recentMessageIds.has(message.messageId)) {
48
- logger.debug(`[Queue] Duplicate message ${message.messageId}, skipping`);
52
+ const dedupKey = `${sessionKey}:${message.messageId}`;
53
+ if (this.recentMessageIds.has(dedupKey)) {
54
+ logger.debug(`[Queue] Duplicate message ${dedupKey}, skipping`);
49
55
  return false;
50
56
  }
51
- this.recentMessageIds.add(message.messageId);
52
- setTimeout(() => this.recentMessageIds.delete(message.messageId), this.DEDUP_WINDOW);
57
+ this.recentMessageIds.add(dedupKey);
58
+ setTimeout(() => this.recentMessageIds.delete(dedupKey), this.DEDUP_WINDOW);
53
59
  return true;
54
60
  }
55
61
  /**
@@ -67,7 +73,7 @@ export class MessageQueue {
67
73
  }
68
74
  async enqueue(sessionKey, message, projectPath, options) {
69
75
  // 消息去重检查
70
- if (!this.shouldProcess(message)) {
76
+ if (!this.shouldProcess(sessionKey, message)) {
71
77
  return Promise.resolve();
72
78
  }
73
79
  // 拦截器检查:AskUserQuestion 等场景的一次性消息拦截
@@ -212,6 +218,7 @@ export class MessageQueue {
212
218
  sameDevice: m.sameDevice, sameNetwork: m.sameNetwork, sameEgressIp: m.sameEgressIp,
213
219
  content: m.content, timestamp: m.timestamp,
214
220
  images: m.images && m.images.length > 0 ? m.images : undefined,
221
+ mentionAids: m.mentionAids && m.mentionAids.length > 0 ? m.mentionAids : undefined,
215
222
  });
216
223
  }
217
224
  }
@@ -16,9 +16,42 @@
16
16
  */
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
- import { agentRelationsDir } from '../../paths.js';
19
+ import { agentRelationsDir, agentConfig as agentConfigPath, resolvePaths } from '../../paths.js';
20
20
  import { loadDefaults, saveDefaultsSafe, loadAgent, saveAgent } from '../../config-store.js';
21
21
  import { formatPeerKey, parsePeerKey } from '../relation/peer-key.js';
22
+ import { fileCache } from '../cache/file-cache.js';
23
+ // ── mtime 门控缓存(统一走 FileCache)─────────────────────────────────────
24
+ //
25
+ // resolveEffectiveModel 每条消息按 关系>agent>全局 解析,原本每次都
26
+ // loadAgent()/loadDefaults()/读 preferences.json —— 一条消息最多读盘 5 次。
27
+ //
28
+ // model-scope 被 CLI 子进程与 daemon 共用,CLI 改文件后 daemon 无失效通知,
29
+ // 故靠 mtime 门控:每次只 statSync 比对 mtime,未变用缓存,变了才真正重读 +
30
+ // 重解析。跨进程天然正确(文件 mtime 变即感知),改配置即时生效不变;
31
+ // statSync 远比 read+JSON.parse 便宜。CLI 进程的 fileCache 是独立空实例、随进程
32
+ // 退出,等同直读最新盘值,安全。
33
+ //
34
+ // config/defaults 与 relation-prefs 三者统一走 daemon 单例 FileCache(消除原先
35
+ // 第二套 makeMtimeCache),读取计数一并进监控。config/defaults 的实际读盘 + 解析
36
+ // 仍委托原 loadAgent/loadDefaults(保留 atomicRead 崩溃恢复 + expandEnvRefs/校验),
37
+ // 故传 noopRead 让 FileCache 只做 mtime 门控、不重复读盘(loader 忽略 raw)。
38
+ /** FileCache 的 read 钩子占位:config/defaults 的真实读盘在 loader 里(loadAgent/
39
+ * loadDefaults,含崩溃恢复),此处返回 null 避免 FileCache 再读一遍。 */
40
+ const noopRead = () => null;
41
+ // agent config.json:mtime 门控,per-agent 分组(config:<aid>)便于 per-agent 监控视图。
42
+ const loadAgentCached = (self) => fileCache.get(agentConfigPath(self), () => loadAgent(self), { policy: 'mtime', group: `config:${self}`, read: noopRead });
43
+ // defaults.json:单文件,mtime 门控。
44
+ const loadDefaultsCached = () => fileCache.get(resolvePaths().defaultsConfig, () => loadDefaults(), { policy: 'mtime', group: 'config', read: noopRead });
45
+ // 关系级 preferences.json —— 走统一 FileCache(mtime 门控,带外改 + 不 reload)。
46
+ const readPrefsCached = (file) => fileCache.get(file, (raw) => (raw === null ? null : safeParsePrefs(raw)), { policy: 'mtime', group: 'relation-prefs' });
47
+ function safeParsePrefs(raw) {
48
+ try {
49
+ return JSON.parse(raw);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
22
55
  export class ModelScopeError extends Error {
23
56
  code;
24
57
  constructor(code, message) {
@@ -76,11 +109,11 @@ export function determineScope(sel) {
76
109
  export function activeBaseagent(self) {
77
110
  try {
78
111
  if (self) {
79
- const cfg = loadAgent(self);
112
+ const cfg = loadAgentCached(self);
80
113
  if (cfg?.active_baseagent)
81
114
  return cfg.active_baseagent;
82
115
  }
83
- const d = loadDefaults();
116
+ const d = loadDefaultsCached();
84
117
  if (d?.active_baseagent)
85
118
  return d.active_baseagent;
86
119
  }
@@ -114,17 +147,17 @@ function writeJsonAtomic(file, data) {
114
147
  export function readScope(scope, sel, ba) {
115
148
  switch (scope) {
116
149
  case 'global': {
117
- const block = (loadDefaults()?.baseagents || {});
150
+ const block = (loadDefaultsCached()?.baseagents || {});
118
151
  const c = block[ba] || {};
119
152
  return { model: c.model, effort: c[effortField(ba)] };
120
153
  }
121
154
  case 'agent': {
122
- const cfg = sel.self ? loadAgent(sel.self) : null;
155
+ const cfg = sel.self ? loadAgentCached(sel.self) : null;
123
156
  const c = (cfg?.baseagents || {})[ba] || {};
124
157
  return { model: c.model, effort: c[effortField(ba)] };
125
158
  }
126
159
  case 'relation': {
127
- const p = readJsonSafe(relationPrefsPath(sel.self, sel.peerKey));
160
+ const p = readPrefsCached(relationPrefsPath(sel.self, sel.peerKey));
128
161
  return { model: p?.model, effort: p?.effort };
129
162
  }
130
163
  }