Files
metona-ai-desktop/electron/ipc/agent.ts
T
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

1076 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
import { OllamaAdapter } from '../harness/adapters/ollama.adapter';
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';
// v0.7.2 A5: 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整,
// 防止模型把残缺内容当作完整文件事实
const truncatedNote =
(att as { truncated?: boolean }).truncated === true
? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)'
: '';
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${truncatedNote} 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 {
// 提示注入检测(安全模块)
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
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,
);
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 LoopP2-10: 每会话独立引擎)
const engine = agentEngineManager.getEngine(sessionId);
const output = await engine.runStream(userMessage, 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
// 异步执行,不阻塞主流程返回;失败仅记录日志
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 };
}
},
);
// ===== 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: '余额查询失败(API Key 无效或网络错误)' };
}
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: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' };
}
if (!apiKey && provider !== 'ollama') {
return { success: false, error: 'API Key 未配置,无法获取模型列表' };
}
// 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op
if (!ctx.reloadAdapter()) {
return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' };
}
const adapter = agentEngineManager.getAdapter();
if (!adapter.listModels) {
return { success: false, error: '当前 Provider 不支持模型列表查询' };
}
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: '仅 Ollama Provider 支持模型下载' };
}
if (ollamaPullController) {
return { success: false, error: '已有模型下载任务进行中,请先取消' };
}
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: '没有进行中的下载任务' };
}
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 递归派生的孙任务)
for (const taskId of abortedTaskIds) {
confirmationHook.clearPending(taskId);
}
// TOOL 层:记录中断
auditService.log({
sessionId,
eventType: 'session_end',
actor: 'user',
target: 'session',
details: { reason: 'user_abort' },
outcome: 'denied',
});
return { success: true };
});
// ===== 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;
}
const subTraces = new Map<string, SubTraceState>();
const finishSubTrace = (
taskId: string,
reason: string,
durationMs: number,
iterations: number,
): void => {
sessionRecorder.stopRecording(taskId, {
totalIterations: iterations,
totalTokens: 0,
durationMs,
terminationReason: reason,
});
subTraces.delete(taskId);
subMeta.delete(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,
});
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,
});
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,
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,
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.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;
}
},
);
}