Files
metona-ai-desktop/electron/ipc/agent.ts
T
thzxx ebe45482b0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s
feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息
  前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 +
  cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token
  成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter
  同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点
  时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用
  + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo
  .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数,
  未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控
  [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键)

P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构
  resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup
  钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式
  退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接,
  替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用
  pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为
  可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin;
  ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent
  终结三处接线, 根治 rememberedDecisions 泄漏)

P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→
  Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs
  死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收
  管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP
  stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个
  run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页
  手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 +
  审计链一键校验)

P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用
  同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时
  刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机,
  teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页
  显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐
  docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/
  OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐)

测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context
  分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control
  四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向
  解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、
  trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机
  (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动
  (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试
  前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与
  并发去重的检查顺序竞态(去重先于幂等)

版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect)

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过
  (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
2026-08-30 09:44:43 +08:00

1122 lines
43 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';
// 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';
/** 构建 "时区名 (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';
}
}
/** 单会话的 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,
titleGenerator,
} = ctx;
// ===== 常驻事件管道:text_delta 按会话节流(F8 =====
const throttleStates = new Map<string, ThrottleState>();
const iterationTraces = new Map<string, IterationTrace>();
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
const lastConsolidationBySession = new Map<string, number>();
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 人格',
});
}
// 检索与用户消息相关的记忆 + 附件元信息 → 构建用户消息上下文前置块。
// v0.7.3 P1-1 根治: 记忆注入与附件提示此前追加进 systemPrompt.dynamicReminders
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
// LLM 语义等价),system prompt 保持跨 run 字节级稳定。
let userContextPrefix = '';
try {
const memories = 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 已用原始内容)、前端展示、记忆固化、
// 注入检测均使用原始干净内容,互不污染。
const engineUserMessage: MetonaMessage = {
...userMessage,
content: withUserContextPrefix(userContextPrefix, userMessage.content),
};
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: 每会话独立引擎)
// 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) {
lastConsolidationBySession.set(sessionId, Date.now());
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);
});
} else {
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
}
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
if (output.terminationReason === 'completed') {
titleGenerator
.maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer)
.then((title) => {
if (title) {
// 广播重命名结果,前端 Sidebar 实时刷新标题
broadcast('config:changed', { key: `session.title.${sessionId}`, value: title });
}
})
.catch(() => {
/* 静默 —— 标题失败已有 debug 日志 */
});
}
// 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 递归派生的孙任务)
// v0.7.3 P2-3: 被中止的 SubAgent 是会话终态 —— 用 forgetSession 连决策记忆一并清理
for (const taskId of abortedTaskIds) {
confirmationHook.forgetSession(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);
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
confirmationHook.forgetSession(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;
}
},
);
}