feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复: - API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容) - 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级) - error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计) - abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止) - run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化) - .env 真实生效(dotenv 回退加载,应用内配置优先) P1 工程基础: - ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线) - 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层) - test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行) - SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end) - Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知) - MCP 真就绪(等待全部连接完成再广播 tools:ready) - SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态) - CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移) P2 架构升级: - handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播) - AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号) - TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent - 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染) - 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI) - Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入 P3 能力扩展: - OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens) - Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机) - 设置页/Onboarding 六 Provider 全链路接入
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* 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 } from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||||
import log from 'electron-log';
|
||||
|
||||
/** 单会话的 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 去重)
|
||||
}
|
||||
|
||||
export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const {
|
||||
agentEngineManager, sessionRecorder, configService, sessionService,
|
||||
workspaceService, contextBuilder, auditService, memoryManager,
|
||||
promptInjectionDefender, outputValidator, memoryConsolidator,
|
||||
sessionSummaryService, orchestrator, confirmationHook,
|
||||
} = ctx;
|
||||
|
||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||
const throttleStates = new Map<string, ThrottleState>();
|
||||
const iterationTraces = new Map<string, IterationTrace>();
|
||||
|
||||
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;
|
||||
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:
|
||||
cleanupSessionState(sessionId);
|
||||
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;
|
||||
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,
|
||||
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,
|
||||
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: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${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: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 常驻监听: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: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`,
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 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('无效的会话 ID', sessionId ?? '');
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||||
sendErrorEvent('无效的消息格式', sessionId);
|
||||
return { success: false, error: 'Invalid message format' };
|
||||
}
|
||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||
|
||||
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
|
||||
if (!ctx.reloadAdapter()) {
|
||||
const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
|
||||
log.error('[AGENT]', errorMsg);
|
||||
sendErrorEvent(errorMsg, sessionId);
|
||||
sessionRecorder.stopRecording(sessionId, { totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
// TRACE 层:开始录制 / TOOL 层:记录会话开始
|
||||
sessionRecorder.startRecording(sessionId);
|
||||
auditService.logSessionStart(sessionId);
|
||||
|
||||
// 保存用户消息到数据库
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: userMessage.content,
|
||||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||
});
|
||||
|
||||
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||||
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||||
|
||||
// 从工作空间文件构建 System Prompt
|
||||
const workspaceFiles = workspaceService.getFiles();
|
||||
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
|
||||
|
||||
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
|
||||
if (contextBuilder.isUsingFallbackRole()) {
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
|
||||
});
|
||||
}
|
||||
|
||||
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
||||
try {
|
||||
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||||
if (memories.length > 0) {
|
||||
const memorySection = memories.map((m, i) =>
|
||||
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
|
||||
).join('\n');
|
||||
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
|
||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
|
||||
: memoryBlock;
|
||||
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||
}
|
||||
|
||||
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
|
||||
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||
const attachmentList = attachments.map((att, i) => {
|
||||
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
||||
const note = att.type === 'image'
|
||||
? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
|
||||
: att.type === 'text'
|
||||
? 'content already inlined in the user message, do NOT search in workspace or read it again'
|
||||
: 'uploaded directly by user, do NOT search in workspace';
|
||||
return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
|
||||
}).join('\n');
|
||||
|
||||
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`;
|
||||
|
||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
||||
: attachmentBlock;
|
||||
|
||||
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// TRACE 层:记录上下文构建
|
||||
sessionRecorder.recordContextBuilt(sessionId, {
|
||||
tokenCount: estimateMessagesTokens(history),
|
||||
usageRatio: 0,
|
||||
});
|
||||
|
||||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
||||
const engine = agentEngineManager.getEngine(sessionId);
|
||||
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
|
||||
|
||||
// 输出验证(不阻塞响应,仅记录警告)
|
||||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
||||
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}`);
|
||||
} catch (err) {
|
||||
log.error('[OutputValidator] Validation failed:', err);
|
||||
}
|
||||
|
||||
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
|
||||
for (const step of output.iterations) {
|
||||
if (!step.thought) 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;
|
||||
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
|
||||
// 异步执行,不阻塞主流程返回;失败仅记录日志
|
||||
memoryConsolidator
|
||||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
||||
.then((result) => {
|
||||
if (result.appended > 0) {
|
||||
log.info(`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`);
|
||||
broadcast('toast:show', {
|
||||
type: 'info',
|
||||
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('[AGENT] Memory consolidation failed:', err);
|
||||
});
|
||||
|
||||
// TOOL 层:记录会话结束 / TRACE 层:停止录制
|
||||
auditService.logSessionEnd({
|
||||
sessionId,
|
||||
totalIterations: output.iterations.length,
|
||||
totalTokens: output.totalTokenUsage.totalTokens,
|
||||
durationMs: output.durationMs,
|
||||
terminationReason: output.terminationReason,
|
||||
});
|
||||
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 层:停止录制
|
||||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 中断会话 =====
|
||||
|
||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||
log.info('[AGENT] Abort:', sessionId);
|
||||
// P2-10: 联动中断该会话派生的所有 SubAgent(消除"会话停了子任务还在跑")
|
||||
orchestrator.abortByParent(sessionId);
|
||||
agentEngineManager.abort(sessionId);
|
||||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
||||
await agentEngineManager.waitForAbort(sessionId);
|
||||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||||
confirmationHook.clearPending();
|
||||
|
||||
// TOOL 层:记录中断
|
||||
auditService.log({
|
||||
sessionId,
|
||||
eventType: 'session_end',
|
||||
actor: 'user',
|
||||
target: 'session',
|
||||
details: { reason: 'user_abort' },
|
||||
outcome: 'denied',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user