1348 lines
54 KiB
TypeScript
1348 lines
54 KiB
TypeScript
/**
|
||
* IPC Agent Handlers — Agent 交互域(P2-9 从 handlers.ts 拆分)
|
||
*
|
||
* 职责:
|
||
* 1. agent:sendMessage — 消息发送编排(历史加载/记忆注入/注入检测/runStream/持久化)
|
||
* 2. agent:abortSession — 中断会话(联动 SubAgent)
|
||
* 3. 常驻引擎事件管道 — 流式转发(按会话节流)、状态广播、TRACE 录制(P1-6 补全 9 种事件)、
|
||
* 压缩/死循环/Provider 切换通知
|
||
*
|
||
* P2-10: 事件监听从"每消息 attach/detach"改为常驻管道(按 sessionId 隔离状态),
|
||
* 支持多会话并发流式与多窗口广播。
|
||
*/
|
||
|
||
import { ipcMain } from 'electron';
|
||
import type { IPCContext } from './context';
|
||
import { broadcast } from './context';
|
||
import type {
|
||
MetonaMessage,
|
||
MetonaStreamEvent,
|
||
MetonaError,
|
||
MetonaSystemPrompt,
|
||
} from '../harness/types';
|
||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
|
||
import { OllamaAdapter } from '../harness/adapters/ollama.adapter';
|
||
// v0.7.3 P1-1: 用户上下文前置块(动态内容出 system,保 prompt cache 前缀稳定)
|
||
import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompts/user-context';
|
||
// v0.7.3 P1-5: 记忆固化触发决策(纯函数)
|
||
import { shouldConsolidate } from '../harness/memory/consolidation-policy';
|
||
import log from 'electron-log';
|
||
// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动)
|
||
import { mt } from '../utils/main-locale';
|
||
|
||
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
|
||
function buildTimezoneLabel(): string {
|
||
try {
|
||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
||
const offset = -new Date().getTimezoneOffset() / 60;
|
||
const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
|
||
return `${tz} (${offsetStr})`;
|
||
} catch {
|
||
return 'UTC';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* v0.8.2 P2-4: 会话元数据实时刷新广播(专用 session:updated 通道)。
|
||
* 消费方:Sidebar(title / messageCount / updatedAt 实时刷新)。
|
||
* 语义清理:标题生成不再伪装成 config:changed(合成 key session.title.<id>)。
|
||
*/
|
||
function broadcastSessionUpdated(
|
||
sessionId: string,
|
||
patch: { title?: string; messageCount?: number; updatedAt?: number },
|
||
): void {
|
||
broadcast('session:updated', { sessionId, ...patch });
|
||
}
|
||
|
||
/** 单会话的 text_delta 节流状态 */
|
||
interface ThrottleState {
|
||
buffer: string;
|
||
lastEventMeta: Pick<
|
||
MetonaStreamEvent,
|
||
'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'
|
||
> | null;
|
||
flushTimer: ReturnType<typeof setTimeout> | null;
|
||
}
|
||
|
||
/** 单会话的迭代录制状态(TRACE 层) */
|
||
interface IterationTrace {
|
||
iteration: number;
|
||
startedAt: number;
|
||
text: string;
|
||
usage?: { input: number; output: number; total: number };
|
||
responded: boolean; // 本轮 llm_response 是否已记录(PARSING 与下一轮 THINKING 去重)
|
||
/**
|
||
* v0.8.0 P0-1: 本轮 Provider 原生停止原因(DONE 事件携带的 finishReason)。
|
||
* v0.7.4 及之前 recordLLMResponse 硬编码 'stop',length 截断在 TRACE 文件中
|
||
* 不可归因;现记录真值,缺省回退 'stop'。
|
||
*/
|
||
finishReason?: string;
|
||
}
|
||
|
||
// v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除
|
||
// 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留
|
||
import {
|
||
appendReplay,
|
||
resetReplayBuffer,
|
||
markReplayTerminated,
|
||
getReplayBufferData,
|
||
} from './replay-buffer';
|
||
|
||
export function registerAgentHandlers(ctx: IPCContext): void {
|
||
const {
|
||
agentEngineManager,
|
||
sessionRecorder,
|
||
configService,
|
||
sessionService,
|
||
workspaceService,
|
||
contextBuilder,
|
||
auditService,
|
||
memoryManager,
|
||
promptInjectionDefender,
|
||
outputValidator,
|
||
memoryConsolidator,
|
||
sessionSummaryService,
|
||
orchestrator,
|
||
confirmationHook,
|
||
titleGenerator,
|
||
} = ctx;
|
||
|
||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||
const throttleStates = new Map<string, ThrottleState>();
|
||
const iterationTraces = new Map<string, IterationTrace>();
|
||
|
||
// v0.8.1 review: MEMORY.md 体积告警去重(每会话一次)
|
||
const memoryOversizeWarned = new Set<string>();
|
||
|
||
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
|
||
// v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。
|
||
// 容量 200:超过后淘汰最旧的(固化频率窗口 10 分钟,200 个会话远超实际并发上限)。
|
||
const lastConsolidationBySession = new Map<string, number>();
|
||
const MAX_CONSOLIDATION_TRACKED = 200;
|
||
const touchConsolidation = (sessionId: string): number => {
|
||
const now = Date.now();
|
||
lastConsolidationBySession.set(sessionId, now);
|
||
if (lastConsolidationBySession.size > MAX_CONSOLIDATION_TRACKED) {
|
||
// 淘汰最旧(Map 迭代顺序 = 插入顺序)
|
||
const oldestKey = lastConsolidationBySession.keys().next().value as string | undefined;
|
||
if (oldestKey !== undefined) lastConsolidationBySession.delete(oldestKey);
|
||
}
|
||
return now;
|
||
};
|
||
|
||
const flushThrottle = (sessionId: string): void => {
|
||
const st = throttleStates.get(sessionId);
|
||
if (!st) return;
|
||
st.flushTimer = null;
|
||
if (!st.buffer || !st.lastEventMeta) {
|
||
st.buffer = '';
|
||
st.lastEventMeta = null;
|
||
return;
|
||
}
|
||
// 构建合并的 text_delta 事件,保留最后一个 delta 的元数据
|
||
const mergedEvent: MetonaStreamEvent = {
|
||
...st.lastEventMeta,
|
||
type: MetonaStreamEventType.TEXT_DELTA,
|
||
delta: st.buffer,
|
||
seq: st.lastEventMeta.seq,
|
||
timestamp: Date.now(),
|
||
};
|
||
broadcast('agent:streamEvent', mergedEvent);
|
||
st.buffer = '';
|
||
st.lastEventMeta = null;
|
||
};
|
||
|
||
const cleanupSessionState = (sessionId: string): void => {
|
||
const st = throttleStates.get(sessionId);
|
||
if (st?.flushTimer) {
|
||
clearTimeout(st.flushTimer);
|
||
flushThrottle(sessionId);
|
||
}
|
||
throttleStates.delete(sessionId);
|
||
iterationTraces.delete(sessionId);
|
||
};
|
||
|
||
// ===== 常驻监听:流式事件(节流转发 + TRACE 录制) =====
|
||
agentEngineManager.on('streamEvent', (event: MetonaStreamEvent) => {
|
||
if (!event.sessionId) return;
|
||
const sessionId = event.sessionId;
|
||
// v0.8.0 P1-1a: 全量事件进入回放缓冲(含 text_delta —— 必须在节流分支
|
||
// return 之前入队,否则切回会话时已缓冲的正文增量缺失)
|
||
appendReplay(sessionId, { channel: 'streamEvent', payload: event, ts: Date.now() });
|
||
const trace = iterationTraces.get(sessionId);
|
||
|
||
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
|
||
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
|
||
const st = throttleStates.get(sessionId) ?? {
|
||
buffer: '',
|
||
lastEventMeta: null,
|
||
flushTimer: null,
|
||
};
|
||
throttleStates.set(sessionId, st);
|
||
if (st.buffer === '') {
|
||
st.lastEventMeta = {
|
||
requestId: event.requestId,
|
||
sessionId: event.sessionId,
|
||
iteration: event.iteration,
|
||
seq: event.seq,
|
||
timestamp: event.timestamp,
|
||
runId: event.runId,
|
||
};
|
||
} else if (st.lastEventMeta) {
|
||
st.lastEventMeta = {
|
||
...st.lastEventMeta,
|
||
seq: event.seq,
|
||
timestamp: event.timestamp,
|
||
runId: event.runId,
|
||
};
|
||
}
|
||
st.buffer += event.delta;
|
||
if (st.flushTimer === null) {
|
||
st.flushTimer = setTimeout(() => flushThrottle(sessionId), 32);
|
||
}
|
||
// TRACE: 累积本轮 LLM 文本(供 llm_response 记录)
|
||
if (trace) trace.text += event.delta;
|
||
return;
|
||
}
|
||
|
||
// 非 text_delta 事件:先 flush 缓冲区,再立即转发(保证事件顺序)
|
||
const st = throttleStates.get(sessionId);
|
||
if (st?.flushTimer) {
|
||
clearTimeout(st.flushTimer);
|
||
flushThrottle(sessionId);
|
||
}
|
||
|
||
// TRACE: 工具调用/结果/usage 录制(P1-6 补全)
|
||
switch (event.type) {
|
||
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
|
||
if (event.toolCall) {
|
||
sessionRecorder.recordToolCall({
|
||
sessionId,
|
||
iteration: event.iteration,
|
||
toolName: event.toolCall.name,
|
||
args: event.toolCall.args,
|
||
});
|
||
}
|
||
break;
|
||
case MetonaStreamEventType.TOOL_RESULT:
|
||
if (event.toolResult) {
|
||
const resultPreview =
|
||
typeof event.toolResult.result === 'string'
|
||
? event.toolResult.result
|
||
: JSON.stringify(event.toolResult.result);
|
||
sessionRecorder.recordToolResult({
|
||
sessionId,
|
||
iteration: event.iteration,
|
||
toolName: event.toolResult.toolName,
|
||
success: event.toolResult.success,
|
||
durationMs: event.toolResult.durationMs ?? 0,
|
||
resultPreview,
|
||
error: event.toolResult.error,
|
||
});
|
||
}
|
||
break;
|
||
case MetonaStreamEventType.USAGE:
|
||
if (trace && event.usage) {
|
||
trace.usage = {
|
||
input: event.usage.inputTokens ?? 0,
|
||
output: event.usage.outputTokens ?? 0,
|
||
total: event.usage.totalTokens ?? 0,
|
||
};
|
||
}
|
||
break;
|
||
case MetonaStreamEventType.DONE:
|
||
// v0.7.4 P1-6: DONE 不再执行 cleanupSessionState —— 引擎的 finish() 顺序是
|
||
// 先发 DONE 再发 TERMINATED stateChange,而 TERMINATED 分支(stateChange 监听)
|
||
// 需要读取 iterationTraces 补记最终迭代的 iteration_end/llm_response。
|
||
// 旧实现在 DONE 时删除 iterationTraces → 最终迭代的 TRACE 完整性缺失。
|
||
// 现仅在 TERMINATED 分支统一收尾(flush throttle buffer + 删 iterationTraces)。
|
||
// 注意:此处仍需转发 DONE 事件到前端(下方 broadcast 统一执行)。
|
||
// v0.8.0 P0-1: 捕获 Provider 原生停止原因(recordLLMResponse 记录真值)
|
||
if (trace && event.finishReason) trace.finishReason = event.finishReason;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
broadcast('agent:streamEvent', event);
|
||
});
|
||
|
||
// ===== 常驻监听:状态变化(广播 + TRACE 迭代录制) =====
|
||
agentEngineManager.on(
|
||
'stateChange',
|
||
(data: {
|
||
previous?: string;
|
||
current?: string;
|
||
state?: string;
|
||
sessionId?: string;
|
||
iteration?: number;
|
||
runId?: string;
|
||
}) => {
|
||
if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`);
|
||
broadcast('agent:stateChange', data);
|
||
|
||
const sessionId = data.sessionId;
|
||
// v0.8.0 P1-1a: stateChange 事件进入回放缓冲。INIT(run 启动)清空上一 run
|
||
// 的缓冲;TERMINATED 置终态标记(保留缓冲供"完成后切回"回放最终内容)。
|
||
if (sessionId) {
|
||
const stateRaw = data.state ?? data.current ?? '';
|
||
if (stateRaw === 'INIT') {
|
||
resetReplayBuffer(sessionId);
|
||
} else {
|
||
appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() });
|
||
if (stateRaw === 'TERMINATED') {
|
||
markReplayTerminated(sessionId);
|
||
}
|
||
}
|
||
}
|
||
if (!sessionId || data.iteration == null) return;
|
||
const stateValue = data.state ?? data.current ?? '';
|
||
const trace = iterationTraces.get(sessionId);
|
||
|
||
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
||
if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) {
|
||
if (trace && !trace.responded) {
|
||
sessionRecorder.recordLLMResponse({
|
||
sessionId,
|
||
iteration: trace.iteration,
|
||
content: trace.text,
|
||
// v0.8.0 P0-1: 记录真实停止原因(旧实现硬编码 'stop',length 截断不可归因)
|
||
finishReason: trace.finishReason ?? 'stop',
|
||
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
|
||
});
|
||
}
|
||
if (trace) {
|
||
sessionRecorder.recordIterationEnd(sessionId, {
|
||
iteration: trace.iteration,
|
||
durationMs: Date.now() - trace.startedAt,
|
||
});
|
||
}
|
||
iterationTraces.set(sessionId, {
|
||
iteration: data.iteration,
|
||
startedAt: Date.now(),
|
||
text: '',
|
||
responded: false,
|
||
});
|
||
sessionRecorder.recordIterationStart(sessionId, data.iteration);
|
||
const provider = configService.get<string>('llm.provider') ?? '';
|
||
const model = configService.get<string>('llm.model') ?? '';
|
||
sessionRecorder.recordLLMRequest({
|
||
sessionId,
|
||
iteration: data.iteration,
|
||
provider,
|
||
model,
|
||
messageCount: data.iteration + 1,
|
||
});
|
||
}
|
||
|
||
// PARSING → 本轮流式结束,记录 llm_response
|
||
if (
|
||
stateValue === 'PARSING' &&
|
||
trace &&
|
||
trace.iteration === data.iteration &&
|
||
!trace.responded
|
||
) {
|
||
trace.responded = true;
|
||
sessionRecorder.recordLLMResponse({
|
||
sessionId,
|
||
iteration: trace.iteration,
|
||
content: trace.text,
|
||
// v0.8.0 P0-1: 记录真实停止原因(旧实现硬编码 'stop',length 截断不可归因)
|
||
finishReason: trace.finishReason ?? 'stop',
|
||
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
|
||
});
|
||
}
|
||
|
||
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
|
||
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
|
||
if (stateValue === 'TERMINATED') {
|
||
if (trace) {
|
||
sessionRecorder.recordIterationEnd(sessionId, {
|
||
iteration: trace.iteration,
|
||
durationMs: Date.now() - trace.startedAt,
|
||
});
|
||
}
|
||
cleanupSessionState(sessionId);
|
||
}
|
||
},
|
||
);
|
||
|
||
// ===== 常驻监听:上下文压缩(toast + streamEvent 通知) =====
|
||
agentEngineManager.on(
|
||
'compressed',
|
||
(data: {
|
||
sessionId?: string;
|
||
iteration?: number;
|
||
originalTokens?: number;
|
||
compressedTokens?: number;
|
||
}) => {
|
||
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
|
||
// toast 通知用户压缩已发生
|
||
broadcast('toast:show', {
|
||
type: 'info',
|
||
message: mt('agent.toast.compressed', {
|
||
original: data.originalTokens ?? '?',
|
||
compressed: data.compressedTokens ?? '?',
|
||
saved: savedTokens,
|
||
}),
|
||
});
|
||
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
|
||
broadcast('agent:streamEvent', {
|
||
type: 'compressed',
|
||
sessionId: data.sessionId ?? '',
|
||
iteration: data.iteration ?? 0,
|
||
originalTokens: data.originalTokens,
|
||
compressedTokens: data.compressedTokens,
|
||
savedTokens,
|
||
timestamp: Date.now(),
|
||
});
|
||
},
|
||
);
|
||
|
||
// ===== 常驻监听:死循环检测(toast 警告) =====
|
||
agentEngineManager.on('deadLoop', (data: { iteration?: number; sessionId?: string }) => {
|
||
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
|
||
broadcast('toast:show', {
|
||
type: 'warning',
|
||
message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }),
|
||
});
|
||
});
|
||
|
||
// ===== 常驻监听:Provider 故障转移(P1,通知前端 + toast) =====
|
||
agentEngineManager.on(
|
||
'providerSwitched',
|
||
(data: { from?: string; to?: string; reason?: string; sessionId?: string }) => {
|
||
broadcast('agent:providerSwitched', {
|
||
from: data.from,
|
||
to: data.to,
|
||
reason: data.reason ?? 'failover',
|
||
sessionId: data.sessionId ?? '',
|
||
});
|
||
broadcast('toast:show', {
|
||
type: 'warning',
|
||
message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }),
|
||
});
|
||
},
|
||
);
|
||
|
||
// ===== Agent 消息发送 =====
|
||
|
||
ipcMain.handle(
|
||
'agent:sendMessage',
|
||
async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
||
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
||
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
|
||
const sendErrorEvent = (message: string, sid: string): void => {
|
||
const errorEvent: MetonaStreamEvent = {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: '',
|
||
sessionId: sid,
|
||
iteration: 0,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false },
|
||
};
|
||
broadcast('agent:streamEvent', errorEvent);
|
||
broadcast('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
|
||
};
|
||
|
||
if (!sessionId || typeof sessionId !== 'string') {
|
||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||
sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? '');
|
||
return { success: false, error: 'Invalid sessionId' };
|
||
}
|
||
if (
|
||
!userMessage ||
|
||
typeof userMessage !== 'object' ||
|
||
typeof userMessage.content !== 'string'
|
||
) {
|
||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||
sendErrorEvent(mt('agent.error.invalidMessage'), sessionId);
|
||
return { success: false, error: 'Invalid message format' };
|
||
}
|
||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||
|
||
// v0.7.4 P1-3: 同会话并发防重入 —— 同一 sessionId 的第二个 invoke 在第一个 run
|
||
// 未结束时到达,会让同一引擎被并行 runStream(工具副作用并发执行 / 内部 run-lock
|
||
// 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并
|
||
// 广播明确错误事件,前端 isStreaming 正常收尾。
|
||
if (agentEngineManager.isRunning(sessionId)) {
|
||
const busyMsg = mt('agent.error.sessionBusy');
|
||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`);
|
||
sendErrorEvent(busyMsg, sessionId);
|
||
return { success: false, error: busyMsg };
|
||
}
|
||
|
||
// v0.7.4 P1-4: 会话存在性预检 —— sessionId 指向已删除会话时,后续 saveMessage
|
||
// 会因 FK 约束(foreign_keys=ON)抛错,而旧实现该调用在 try 块之外,invoke 直接
|
||
// reject:不发 ERROR/DONE 事件、不 stopRecording,前端 isStreaming 永久卡死。
|
||
// 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。
|
||
const sessionExists = sessionService.getSession(sessionId) != null;
|
||
if (!sessionExists) {
|
||
const missingMsg = mt('agent.error.sessionMissing');
|
||
log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`);
|
||
sendErrorEvent(missingMsg, sessionId);
|
||
await sessionRecorder.stopRecording(sessionId, {
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
return { success: false, error: missingMsg };
|
||
}
|
||
|
||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||
if (!ctx.reloadAdapter()) {
|
||
const errorMsg = mt('agent.error.adapterLoadFailed');
|
||
log.error('[AGENT]', errorMsg);
|
||
sendErrorEvent(errorMsg, sessionId);
|
||
await sessionRecorder.stopRecording(sessionId, {
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
return { success: false, error: errorMsg };
|
||
}
|
||
|
||
// TRACE 层:开始录制 / TOOL 层:记录会话开始
|
||
sessionRecorder.startRecording(sessionId);
|
||
auditService.logSessionStart(sessionId);
|
||
|
||
// v0.7.4 P1-4: 以下数据准备(保存用户消息/加载历史/构建 System Prompt/记忆检索)
|
||
// 全部移入 try 块 —— 旧实现这些调用在 try 之外,DB 或文件系统抛错时 invoke 直接
|
||
// reject(不发 ERROR/DONE 事件、不 stopRecording,前端 isStreaming 永久卡死)。
|
||
// 统一由下方 catch 收尾:ERROR + DONE 双事件 + 审计 + 录制终止。
|
||
let history: MetonaMessage[] = [];
|
||
let systemPrompt: MetonaSystemPrompt | null = null;
|
||
let userContextPrefix = '';
|
||
let engineUserMessage: MetonaMessage;
|
||
|
||
try {
|
||
// 提示注入检测(安全模块)
|
||
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
|
||
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
|
||
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
|
||
//
|
||
// v0.8.2 P1-4 根治: 检测提前到用户消息落库**之前** —— 旧顺序先 saveMessage
|
||
// 后 detect,riskScore≥7 阻断时恶意内容已持久化进会话历史(被阻断的消息
|
||
// 仍可在历史中读到、参与后续上下文)。检测只依赖消息内容本身,无需
|
||
// systemPrompt,前置无任何依赖障碍。
|
||
const injectionEnabled =
|
||
configService.get<boolean>('security.promptInjectionDefense') !== false;
|
||
if (injectionEnabled) {
|
||
const injectionResult = promptInjectionDefender.detect(userMessage.content);
|
||
if (injectionResult.riskScore >= 7) {
|
||
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
|
||
sendErrorEvent(
|
||
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
|
||
sessionId,
|
||
);
|
||
await sessionRecorder.stopRecording(sessionId, {
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
return { success: false, error: 'Message blocked by prompt injection defense' };
|
||
}
|
||
if (injectionResult.riskScore >= 4) {
|
||
log.warn(
|
||
'[PromptInjectionDefender] Suspicious patterns detected:',
|
||
injectionResult.findings,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 保存用户消息到数据库
|
||
// v0.7.4 回归修复: 透传前端消息 id(ChatMessage.id 由 genMsgId 生成)——
|
||
// 否则 DB 用 msg_<nanoid> 生成不同 id,用户对刚发送消息"仅保存"
|
||
// (updateMessageContent 按 id 匹配)会 0 行更新失败。
|
||
// v0.8.2 P1-4: 位于注入检测之后 —— 被阻断的内容不进入会话历史
|
||
sessionService.saveMessage({
|
||
sessionId,
|
||
role: 'user',
|
||
content: userMessage.content,
|
||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||
id: (userMessage as MetonaMessage & { id?: string }).id,
|
||
});
|
||
|
||
// v0.8.2 P2-4: 用户消息落库后即时广播(Sidebar 的条数/时间同步刷新)
|
||
{
|
||
const updatedSession = sessionService.getSession(sessionId);
|
||
broadcastSessionUpdated(sessionId, {
|
||
messageCount: updatedSession?.messageCount,
|
||
updatedAt: updatedSession?.updatedAt,
|
||
});
|
||
}
|
||
|
||
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||
history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||
|
||
// 从工作空间文件构建 System Prompt
|
||
const workspaceFiles = workspaceService.getFiles();
|
||
systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
|
||
|
||
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||
if (contextBuilder.isUsingFallbackRole()) {
|
||
broadcast('toast:show', {
|
||
type: 'info',
|
||
message: mt('agent.soul.fallbackToast'),
|
||
});
|
||
}
|
||
|
||
// 检索与用户消息相关的记忆 + 附件元信息 → 构建用户消息上下文前置块。
|
||
// v0.7.3 P1-1 根治: 记忆注入与附件提示此前追加进 systemPrompt.dynamicReminders,
|
||
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
|
||
// (LLM 语义等价),system prompt 保持跨 run 字节级稳定。
|
||
try {
|
||
// v0.8.1: search 升级 async(向量混合检索)
|
||
const memories = await memoryManager.search(userMessage.content, {
|
||
topK: 5,
|
||
minImportance: 0.3,
|
||
});
|
||
const attachments = (
|
||
userMessage as MetonaMessage & {
|
||
attachments?: Array<{ name: string; type: string; truncated?: boolean }>;
|
||
}
|
||
).attachments;
|
||
userContextPrefix = buildUserContextPrefix({
|
||
now: Date.now(),
|
||
memories,
|
||
attachments: Array.isArray(attachments) ? attachments : [],
|
||
timezoneLabel: buildTimezoneLabel(),
|
||
});
|
||
if (memories.length > 0) {
|
||
log.debug(`[AGENT] Injected ${memories.length} memories into user context prefix`);
|
||
}
|
||
} catch (err) {
|
||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||
// 记忆检索失败时前置块退化为仅含日期时间(附件提示随之丢失可接受——
|
||
// 主进程附件提示是辅助语义,附件内容本体仍在消息中)
|
||
userContextPrefix = buildUserContextPrefix({
|
||
now: Date.now(),
|
||
memories: [],
|
||
attachments: [],
|
||
timezoneLabel: buildTimezoneLabel(),
|
||
});
|
||
}
|
||
|
||
// 构建发送给引擎的用户消息副本 —— 前置块只存在于该副本:
|
||
// DB 持久化(上方 saveMessage 已用原始内容)、前端展示、记忆固化、
|
||
// 注入检测均使用原始干净内容,互不污染。
|
||
engineUserMessage = {
|
||
...userMessage,
|
||
content: withUserContextPrefix(userContextPrefix, userMessage.content),
|
||
};
|
||
} catch (err) {
|
||
log.error('[AGENT] Failed to prepare message context:', err);
|
||
const prepErr = (err as Error).message;
|
||
broadcast('agent:streamEvent', {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: '',
|
||
sessionId,
|
||
iteration: 0,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: { code: MetonaErrorCode.UNKNOWN, message: prepErr, retryable: false },
|
||
} satisfies MetonaStreamEvent);
|
||
await sessionRecorder.stopRecording(sessionId, {
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
return { success: false, error: prepErr };
|
||
}
|
||
|
||
// 提示注入检测已前移至数据准备 try 块的开头(v0.8.2 P1-4:先检测后落库)
|
||
|
||
try {
|
||
// TRACE 层:记录上下文构建
|
||
sessionRecorder.recordContextBuilt(sessionId, {
|
||
tokenCount: estimateMessagesTokens(history),
|
||
usageRatio: 0,
|
||
});
|
||
|
||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
||
// v0.7.3 P1-1: 传入带上下文前置块的消息副本 —— 原始 userMessage 保持干净
|
||
const engine = agentEngineManager.getEngine(sessionId);
|
||
const output = await engine.runStream(engineUserMessage, sessionId, history, systemPrompt);
|
||
|
||
// 输出验证(不阻塞响应,仅记录警告)
|
||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
||
// v0.4.1: warning 及以上级别的 issue 通过 VALIDATION 流事件推送前端展示(此前仅写日志,用户不可感知)
|
||
try {
|
||
const toolResults = output.iterations
|
||
.flatMap((step) => step.toolResults ?? [])
|
||
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
|
||
const context = [...history, { role: 'user', content: userMessage.content }]
|
||
.map((m) => `${m.role}: ${m.content}`)
|
||
.join('\n');
|
||
const validation = await outputValidator.validate(output.finalAnswer, {
|
||
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
||
context,
|
||
});
|
||
if (!validation.valid || validation.issues.length > 0) {
|
||
log.warn('[OutputValidator] Validation issues:', validation.issues);
|
||
}
|
||
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
|
||
|
||
// v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
|
||
// 类型谓词收窄 severity,确保与 MetonaValidationPayload.issues 的类型一致
|
||
const visibleIssues = validation.issues
|
||
.filter(
|
||
(i): i is typeof i & { severity: 'warning' | 'error' } =>
|
||
i.severity === 'warning' || i.severity === 'error',
|
||
)
|
||
.slice(0, 5);
|
||
if (visibleIssues.length > 0) {
|
||
broadcast('agent:streamEvent', {
|
||
type: MetonaStreamEventType.VALIDATION,
|
||
requestId: '',
|
||
sessionId,
|
||
iteration: output.iterations.length,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
validation: {
|
||
score: validation.score,
|
||
issues: visibleIssues.map((i) => ({
|
||
severity: i.severity,
|
||
type: i.type,
|
||
message: i.message,
|
||
})),
|
||
},
|
||
} satisfies MetonaStreamEvent);
|
||
}
|
||
} catch (err) {
|
||
log.error('[OutputValidator] Validation failed:', err);
|
||
}
|
||
|
||
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
|
||
// 崩溃修复(会话停止根因的持久化侧): 原 `if (!step.thought) continue;` 把
|
||
// 纯工具调用轮(零文本零思考)整个跳过 — assistant 与 tool 结果都不落库。
|
||
// 后果:① 重启后历史缺失工具上下文(模型"忘记"做过什么,重复调用 →
|
||
// 表现为工具调用不稳定);② 与 engine 运行时缺陷同源。现改为:
|
||
// 有 thought 或有 toolCalls 的步骤都保存。
|
||
for (const step of output.iterations) {
|
||
if (!step.thought && !(step.toolCalls && step.toolCalls.length > 0)) continue;
|
||
|
||
const toolCallsWithResults = step.toolCalls?.map((tc) => {
|
||
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
|
||
return {
|
||
id: tc.id,
|
||
name: tc.name,
|
||
args: tc.args,
|
||
status: result?.success ? ('success' as const) : ('error' as const),
|
||
result: result?.result,
|
||
durationMs: result?.durationMs,
|
||
error: result?.error,
|
||
};
|
||
});
|
||
|
||
// 只有当有内容、思考内容或工具调用时才保存
|
||
if (
|
||
step.thought?.content ||
|
||
step.thought?.reasoningContent ||
|
||
toolCallsWithResults?.length
|
||
) {
|
||
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
|
||
const assistantContent =
|
||
toolCallsWithResults?.length && !step.thought?.content
|
||
? null
|
||
: (step.thought?.content ?? null);
|
||
sessionService.saveMessage({
|
||
sessionId,
|
||
role: 'assistant',
|
||
content: assistantContent,
|
||
reasoningContent: step.thought?.reasoningContent || undefined,
|
||
toolCalls: toolCallsWithResults,
|
||
iteration: step.iteration,
|
||
});
|
||
}
|
||
|
||
// v0.3.0 修复: 保存 tool 结果消息到数据库
|
||
// OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
|
||
if (step.toolResults) {
|
||
for (const result of step.toolResults) {
|
||
const resultContent =
|
||
typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
||
sessionService.saveMessage({
|
||
sessionId,
|
||
role: 'tool',
|
||
content: result.error ?? resultContent,
|
||
toolResult: result,
|
||
iteration: step.iteration,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新 Token 统计
|
||
if (output.totalTokenUsage.totalTokens > 0) {
|
||
sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens);
|
||
}
|
||
|
||
// 更新 MEMORY.md 时间戳
|
||
workspaceService.updateMemoryTimestamp();
|
||
|
||
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
|
||
// v0.7.3 P1-5 节流: 此前每次 run 无条件发起固化 LLM 请求,短寒暄同样触发。
|
||
// 现按 consolidation-policy 决策(总开关 + 内容门控 + 会话级频率窗口)触发;
|
||
// 异步执行不阻塞主流程返回;失败仅记录日志。
|
||
const lastConsolidationAt = lastConsolidationBySession.get(sessionId) ?? 0;
|
||
const hadSuccessfulToolCall = output.iterations.some((step) =>
|
||
(step.toolResults ?? []).some((r) => r.success),
|
||
);
|
||
const decision = shouldConsolidate({
|
||
enabled: configService.get<boolean>('memory.consolidationEnabled') !== false,
|
||
answerChars: output.finalAnswer?.length ?? 0,
|
||
minChars: configService.get<number>('memory.consolidationMinChars') ?? 200,
|
||
hadSuccessfulToolCall,
|
||
lastConsolidationAt,
|
||
now: Date.now(),
|
||
intervalMs: configService.get<number>('memory.consolidationIntervalMs') ?? 600_000,
|
||
});
|
||
if (decision.consolidate) {
|
||
memoryConsolidator
|
||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
||
.then((result) => {
|
||
if (result.appended > 0) {
|
||
touchConsolidation(sessionId);
|
||
log.info(
|
||
`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`,
|
||
);
|
||
broadcast('toast:show', {
|
||
type: 'info',
|
||
message: mt('agent.toast.consolidated', { count: result.appended }),
|
||
});
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
log.warn('[AGENT] Memory consolidation failed:', err);
|
||
});
|
||
} else {
|
||
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
|
||
}
|
||
|
||
// v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后
|
||
// 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警
|
||
// 一次,避免连续消息刷屏。
|
||
const memorySize = (workspaceService.getFiles().memory ?? '').length;
|
||
if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) {
|
||
memoryOversizeWarned.add(sessionId);
|
||
broadcast('toast:show', {
|
||
type: 'warning',
|
||
message: mt('agent.toast.memoryOversize'),
|
||
});
|
||
}
|
||
|
||
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
|
||
// v0.8.2 P2-4: 标题广播改走专用 session:updated 事件 —— 此前伪装成
|
||
// config:changed(合成 key session.title.<id>),语义混用、渲染层需特判。
|
||
if (output.terminationReason === 'completed') {
|
||
titleGenerator
|
||
.maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer)
|
||
.then((title) => {
|
||
if (title) {
|
||
// 广播重命名结果,前端 Sidebar 实时刷新标题
|
||
broadcastSessionUpdated(sessionId, { title });
|
||
}
|
||
})
|
||
.catch(() => {
|
||
/* 静默 —— 标题失败已有 debug 日志 */
|
||
});
|
||
}
|
||
|
||
// v0.8.2 P2-4: 会话元数据实时刷新 —— 此前 Sidebar 仅挂载时 list() 一次,
|
||
// 流式过程中 messageCount/updatedAt 停留在旧值直到重启。run 收尾统一
|
||
// 广播 session:updated(含 DB 最新 messageCount/updatedAt)。
|
||
{
|
||
const updatedSession = sessionService.getSession(sessionId);
|
||
broadcastSessionUpdated(sessionId, {
|
||
messageCount: updatedSession?.messageCount,
|
||
updatedAt: updatedSession?.updatedAt,
|
||
});
|
||
}
|
||
|
||
// TOOL 层:记录会话结束 / TRACE 层:停止录制
|
||
auditService.logSessionEnd({
|
||
sessionId,
|
||
totalIterations: output.iterations.length,
|
||
totalTokens: output.totalTokenUsage.totalTokens,
|
||
durationMs: output.durationMs,
|
||
terminationReason: output.terminationReason,
|
||
});
|
||
await sessionRecorder.stopRecording(sessionId, {
|
||
totalIterations: output.iterations.length,
|
||
totalTokens: output.totalTokenUsage.totalTokens,
|
||
durationMs: output.durationMs,
|
||
terminationReason: output.terminationReason,
|
||
});
|
||
|
||
// P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录)
|
||
sessionSummaryService.maybeSummarize(sessionId).catch((err) => {
|
||
log.warn('[AGENT] Session summary generation failed:', err);
|
||
});
|
||
|
||
log.info(
|
||
`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`,
|
||
);
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
log.error('[AGENT] Error:', error);
|
||
|
||
// TOOL 层:记录错误
|
||
auditService.log({
|
||
sessionId,
|
||
eventType: 'error',
|
||
actor: 'agent',
|
||
target: 'agent_loop',
|
||
details: { error: (error as Error).message },
|
||
outcome: 'error',
|
||
});
|
||
|
||
// TRACE 层:停止录制
|
||
await sessionRecorder.stopRecording(sessionId, {
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'error',
|
||
});
|
||
|
||
// 发送错误事件到 UI
|
||
const metonaError: MetonaError = {
|
||
code: MetonaErrorCode.UNKNOWN,
|
||
message: (error as Error).message,
|
||
retryable: false,
|
||
};
|
||
broadcast('agent:streamEvent', {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: '',
|
||
sessionId,
|
||
iteration: 0,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: metonaError,
|
||
} satisfies MetonaStreamEvent);
|
||
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
},
|
||
);
|
||
|
||
// ===== v0.5.0: DeepSeek 余额查询(复用 DeepSeekAdapter.getBalance,原为死代码) =====
|
||
ipcMain.handle('llm:getBalance', async () => {
|
||
try {
|
||
const adapter = agentEngineManager.getAdapter();
|
||
if (!(adapter instanceof DeepSeekAdapter)) {
|
||
return {
|
||
success: false,
|
||
error: 'Balance query is only supported for the DeepSeek provider',
|
||
};
|
||
}
|
||
const balance = await adapter.getBalance();
|
||
if (!balance) {
|
||
return { success: false, error: mt('llm.balance.queryFailed') };
|
||
}
|
||
return { success: true, data: balance };
|
||
} catch (error) {
|
||
log.warn('[AGENT] getBalance failed:', (error as Error).message);
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
// ===== v0.7.2 P3-9: 动态模型列表(六家 adapter 的 listModels 首次获得 IPC 消费者)=====
|
||
// 此前 DeepSeek/OpenAI/Ollama 均实现了实时模型发现(/models、/api/tags),
|
||
// 但 preload/IPC 层无任何通道 —— 设置页模型输入只能靠用户手填。
|
||
// 契约:列表基于"已保存"的 LLM 配置(与引擎实际使用的 adapter 同源);
|
||
// 配置不完整时显式失败,绝不返回兜底 adapter 的误导性静态列表。
|
||
ipcMain.handle('llm:listModels', async () => {
|
||
try {
|
||
const provider = configService.get<string>('llm.provider') ?? '';
|
||
const model = configService.get<string>('llm.model') ?? '';
|
||
const apiKey = configService.get<string>('llm.apiKey') || '';
|
||
if (!provider || !model) {
|
||
return { success: false, error: mt('llm.listModels.notConfigured') };
|
||
}
|
||
if (!apiKey && provider !== 'ollama') {
|
||
return { success: false, error: mt('llm.listModels.noApiKey') };
|
||
}
|
||
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op)
|
||
if (!ctx.reloadAdapter()) {
|
||
return { success: false, error: mt('llm.listModels.configInvalid') };
|
||
}
|
||
const adapter = agentEngineManager.getAdapter();
|
||
if (!adapter.listModels) {
|
||
return { success: false, error: mt('llm.listModels.unsupported') };
|
||
}
|
||
const models = await adapter.listModels();
|
||
return { success: true, data: models };
|
||
} catch (error) {
|
||
log.warn('[AGENT] listModels failed:', (error as Error).message);
|
||
return { success: false, error: (error as Error).message };
|
||
}
|
||
});
|
||
|
||
// ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 IPC/UI)=====
|
||
// v0.6.4 P4-1 已实现 pull 的进度回调与外部取消信号,但主进程侧零消费者。
|
||
// 契约:同一时刻仅允许一个下载任务(全局 AbortController);
|
||
// 进度经 llm:ollamaPullProgress 广播({ model, status, completed?, total? }),
|
||
// 结束(成功/失败/取消)统一广播 llm:ollamaPullEnded 供前端复位 UI。
|
||
let ollamaPullController: AbortController | null = null;
|
||
|
||
ipcMain.handle('llm:ollamaPull', async (_event, modelName: unknown) => {
|
||
if (typeof modelName !== 'string' || !modelName.trim()) {
|
||
return { success: false, error: 'Invalid model name' };
|
||
}
|
||
// 模型名仅进入 JSON body(不经 shell),仍做字符白名单防转义边界
|
||
// 合法形态如 qwen3:8b / llama3.1:70b-instruct-q4_K_M / user/model
|
||
if (!/^[A-Za-z0-9._:/-]+$/.test(modelName.trim())) {
|
||
return { success: false, error: `Invalid model name: ${modelName.slice(0, 60)}` };
|
||
}
|
||
const adapter = agentEngineManager.getAdapter();
|
||
if (!(adapter instanceof OllamaAdapter)) {
|
||
return { success: false, error: mt('llm.pull.ollamaOnly') };
|
||
}
|
||
if (ollamaPullController) {
|
||
return { success: false, error: mt('llm.pull.inProgress') };
|
||
}
|
||
const controller = new AbortController();
|
||
ollamaPullController = controller;
|
||
const trimmed = modelName.trim();
|
||
try {
|
||
await adapter.pullModel(
|
||
trimmed,
|
||
(progress) => {
|
||
broadcast('llm:ollamaPullProgress', { model: trimmed, ...progress });
|
||
},
|
||
controller.signal,
|
||
);
|
||
return { success: true };
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: (error as Error).message,
|
||
aborted: controller.signal.aborted,
|
||
};
|
||
} finally {
|
||
ollamaPullController = null;
|
||
broadcast('llm:ollamaPullEnded', { model: trimmed });
|
||
}
|
||
});
|
||
|
||
ipcMain.handle('llm:ollamaPullCancel', async () => {
|
||
if (!ollamaPullController) {
|
||
return { success: false, error: mt('llm.pull.none') };
|
||
}
|
||
ollamaPullController.abort();
|
||
return { success: true };
|
||
});
|
||
|
||
// ===== 中断会话 =====
|
||
|
||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||
log.info('[AGENT] Abort:', sessionId);
|
||
// P2-10: 联动中断该会话派生的所有 SubAgent(消除"会话停了子任务还在跑")
|
||
// v0.5.1: 记录被中止的 taskId — SubAgent 的 pending 确认以 taskId 为 sessionId,
|
||
// 需一并清理,否则中止后孤儿工具在用户补批残留弹框时会真实执行副作用
|
||
const abortedTaskIds = orchestrator.abortByParent(sessionId);
|
||
agentEngineManager.abort(sessionId);
|
||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
||
await agentEngineManager.waitForAbort(sessionId);
|
||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
||
confirmationHook.clearPending(sessionId);
|
||
// v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务)
|
||
// v0.7.3 P2-3: 被中止的 SubAgent 是会话终态 —— 用 forgetSession 连决策记忆一并清理
|
||
// v0.7.4 P3-5: 被中止 SubAgent 的 TRACE 状态(subTraces/subMeta)一并清理 ——
|
||
// 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目
|
||
for (const taskId of abortedTaskIds) {
|
||
confirmationHook.forgetSession(taskId);
|
||
// v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理
|
||
memoryManager.clearWorkingMemory(taskId);
|
||
subTraces.delete(taskId);
|
||
subMeta.delete(taskId);
|
||
// 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止)
|
||
await sessionRecorder.stopRecording(taskId, {
|
||
totalIterations: 0,
|
||
totalTokens: 0,
|
||
durationMs: 0,
|
||
terminationReason: 'user_interrupt',
|
||
});
|
||
}
|
||
|
||
// TOOL 层:记录中断
|
||
auditService.log({
|
||
sessionId,
|
||
eventType: 'session_end',
|
||
actor: 'user',
|
||
target: 'session',
|
||
details: { reason: 'user_abort' },
|
||
outcome: 'denied',
|
||
});
|
||
|
||
return { success: true };
|
||
});
|
||
|
||
// ===== v0.8.0 P1-1a: 后台会话流回放(切回会话时恢复运行内容) =====
|
||
// 渲染层 setCurrentSession 检测到目标会话在 sessionRunStates 中运行(或刚结束)
|
||
// 时调用本通道:返回回放缓冲中按序保存的 streamEvent + stateChange 事件,
|
||
// 渲染层经事件总线灌入 useAgentStream 管线重建消息/Trace 状态,实时事件随后
|
||
// 无缝衔接(runId 对齐由缓冲记录的 runId 提供)。
|
||
ipcMain.handle('agent:getReplayState', (_event, sessionId: unknown) => {
|
||
if (typeof sessionId !== 'string' || !sessionId) {
|
||
return { success: false, error: 'Invalid sessionId' };
|
||
}
|
||
const data = getReplayBufferData(sessionId);
|
||
return {
|
||
success: true,
|
||
data: {
|
||
isRunning: agentEngineManager.isRunning(sessionId),
|
||
runId: data.runId,
|
||
truncated: data.truncated,
|
||
events: data.events,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ===== v0.5.0: SubAgent 可观测性 =====
|
||
// 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区)
|
||
// 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流
|
||
// 此前 orchestrator 的 6 个事件全项目零消费者,SubAgent 执行过程对 UI 与 TRACE 完全不可见
|
||
|
||
/** SubAgent 元信息(description/depth,供完成/失败事件广播时补全载荷) */
|
||
const subMeta = new Map<string, { description: string; depth: number }>();
|
||
/** SubAgent 迭代录制状态(THINKING 驱动新迭代,text 累积供 llm_response) */
|
||
interface SubTraceState {
|
||
iteration: number;
|
||
startedAt: number;
|
||
text: string;
|
||
usage: { input: number; output: number; total: number };
|
||
responded: boolean;
|
||
/** v0.8.0 P0-1: 本轮 Provider 原生停止原因(DONE 事件携带) */
|
||
finishReason?: string;
|
||
}
|
||
const subTraces = new Map<string, SubTraceState>();
|
||
|
||
const finishSubTrace = async (
|
||
taskId: string,
|
||
reason: string,
|
||
durationMs: number,
|
||
iterations: number,
|
||
): Promise<void> => {
|
||
await sessionRecorder.stopRecording(taskId, {
|
||
totalIterations: iterations,
|
||
totalTokens: 0,
|
||
durationMs,
|
||
terminationReason: reason,
|
||
});
|
||
subTraces.delete(taskId);
|
||
subMeta.delete(taskId);
|
||
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
|
||
confirmationHook.forgetSession(taskId);
|
||
// v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入)
|
||
memoryManager.clearWorkingMemory(taskId);
|
||
};
|
||
|
||
orchestrator.on(
|
||
'taskDelegated',
|
||
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
|
||
broadcast('subagent:event', {
|
||
taskId: d.taskId,
|
||
parentSessionId: d.parentSessionId,
|
||
description: d.description,
|
||
status: 'delegated',
|
||
depth: d.depth,
|
||
});
|
||
subMeta.set(d.taskId, { description: d.description, depth: d.depth });
|
||
sessionRecorder.startRecording(d.taskId);
|
||
},
|
||
);
|
||
|
||
orchestrator.on(
|
||
'taskStarted',
|
||
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
|
||
broadcast('subagent:event', {
|
||
taskId: d.taskId,
|
||
parentSessionId: d.parentSessionId,
|
||
description: d.description,
|
||
status: 'running',
|
||
depth: d.depth,
|
||
});
|
||
},
|
||
);
|
||
|
||
orchestrator.on(
|
||
'taskCompleted',
|
||
(r: {
|
||
taskId: string;
|
||
parentSessionId: string;
|
||
success: boolean;
|
||
durationMs: number;
|
||
iterations: number;
|
||
}) => {
|
||
const meta = subMeta.get(r.taskId);
|
||
broadcast('subagent:event', {
|
||
taskId: r.taskId,
|
||
parentSessionId: r.parentSessionId,
|
||
description: meta?.description ?? '',
|
||
status: r.success ? 'completed' : 'error',
|
||
depth: meta?.depth ?? 1,
|
||
durationMs: r.durationMs,
|
||
iterations: r.iterations,
|
||
});
|
||
void finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
|
||
},
|
||
);
|
||
|
||
orchestrator.on(
|
||
'taskError',
|
||
(d: { taskId: string; parentSessionId: string; description: string; error: string }) => {
|
||
const meta = subMeta.get(d.taskId);
|
||
broadcast('subagent:event', {
|
||
taskId: d.taskId,
|
||
parentSessionId: d.parentSessionId,
|
||
description: d.description ?? meta?.description ?? '',
|
||
status: 'error',
|
||
depth: meta?.depth ?? 1,
|
||
error: d.error,
|
||
});
|
||
void finishSubTrace(d.taskId, 'error', 0, 0);
|
||
},
|
||
);
|
||
|
||
// SubEngine 状态事件 → TRACE 迭代录制(与主管道相同的事件形状,sessionId = taskId)
|
||
orchestrator.on(
|
||
'subStateChange',
|
||
({
|
||
taskId,
|
||
data,
|
||
}: {
|
||
taskId: string;
|
||
data: { state?: string; current?: string; iteration?: number };
|
||
}) => {
|
||
const st = subTraces.get(taskId);
|
||
const stateValue = data.state ?? data.current ?? '';
|
||
|
||
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
||
if (
|
||
stateValue === 'THINKING' &&
|
||
data.iteration != null &&
|
||
(!st || st.iteration !== data.iteration)
|
||
) {
|
||
if (st && !st.responded) {
|
||
sessionRecorder.recordLLMResponse({
|
||
sessionId: taskId,
|
||
iteration: st.iteration,
|
||
content: st.text,
|
||
// v0.8.0 P0-1: 记录真实停止原因(与主管道同口径)
|
||
finishReason: st.finishReason ?? 'stop',
|
||
tokenUsage: st.usage,
|
||
});
|
||
sessionRecorder.recordIterationEnd(taskId, {
|
||
iteration: st.iteration,
|
||
durationMs: Date.now() - st.startedAt,
|
||
});
|
||
}
|
||
subTraces.set(taskId, {
|
||
iteration: data.iteration,
|
||
startedAt: Date.now(),
|
||
text: '',
|
||
usage: { input: 0, output: 0, total: 0 },
|
||
responded: false,
|
||
});
|
||
sessionRecorder.recordIterationStart(taskId, data.iteration);
|
||
const provider = configService.get<string>('llm.provider') ?? '';
|
||
const model = configService.get<string>('llm.model') ?? '';
|
||
sessionRecorder.recordLLMRequest({
|
||
sessionId: taskId,
|
||
iteration: data.iteration,
|
||
provider,
|
||
model,
|
||
messageCount: data.iteration + 1,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// TERMINATED → 补记最终迭代(正常流程只在下一轮 THINKING 补记,最终轮无后续)
|
||
if (stateValue === 'TERMINATED' && st) {
|
||
if (!st.responded) {
|
||
sessionRecorder.recordLLMResponse({
|
||
sessionId: taskId,
|
||
iteration: st.iteration,
|
||
content: st.text,
|
||
// v0.8.0 P0-1: 记录真实停止原因(与主管道同口径)
|
||
finishReason: st.finishReason ?? 'stop',
|
||
tokenUsage: st.usage,
|
||
});
|
||
}
|
||
sessionRecorder.recordIterationEnd(taskId, {
|
||
iteration: st.iteration,
|
||
durationMs: Date.now() - st.startedAt,
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
// SubEngine 流事件 → TRACE 内容录制(文本累积 + 工具调用/结果)
|
||
orchestrator.on(
|
||
'subStreamEvent',
|
||
({ taskId, event }: { taskId: string; event: MetonaStreamEvent }) => {
|
||
const st = subTraces.get(taskId);
|
||
if (!st) return;
|
||
|
||
switch (event.type) {
|
||
case MetonaStreamEventType.TEXT_DELTA:
|
||
if (event.delta) st.text += event.delta;
|
||
break;
|
||
case MetonaStreamEventType.USAGE:
|
||
if (event.usage) {
|
||
st.usage = {
|
||
input: event.usage.inputTokens ?? 0,
|
||
output: event.usage.outputTokens ?? 0,
|
||
total: event.usage.totalTokens ?? 0,
|
||
};
|
||
}
|
||
break;
|
||
case MetonaStreamEventType.DONE:
|
||
// v0.8.0 P0-1: 捕获 Provider 原生停止原因(SubAgent TRACE 真值)
|
||
if (event.finishReason) st.finishReason = event.finishReason;
|
||
break;
|
||
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
|
||
if (event.toolCall) {
|
||
sessionRecorder.recordToolCall({
|
||
sessionId: taskId,
|
||
iteration: event.iteration,
|
||
toolName: event.toolCall.name,
|
||
args: event.toolCall.args,
|
||
});
|
||
}
|
||
break;
|
||
case MetonaStreamEventType.TOOL_RESULT:
|
||
if (event.toolResult) {
|
||
const resultPreview =
|
||
typeof event.toolResult.result === 'string'
|
||
? event.toolResult.result
|
||
: JSON.stringify(event.toolResult.result);
|
||
sessionRecorder.recordToolResult({
|
||
sessionId: taskId,
|
||
iteration: event.iteration,
|
||
toolName: event.toolResult.toolName,
|
||
success: event.toolResult.success,
|
||
durationMs: event.toolResult.durationMs ?? 0,
|
||
resultPreview,
|
||
error: event.toolResult.error,
|
||
});
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
},
|
||
);
|
||
}
|