Files
metona-ai-desktop/electron/ipc/agent.ts
T
thzxx a7090214b1
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m20s
CI / 产物编译验证 (push) Successful in 10m5s
fix: v0.6.2 修复工具调用不稳定与会话停止 — 纯 tool_calls 轮丢失 assistant 消息导致 API 400
【根因(main.log 实证)】
19:04 / 19:05 / 19:06 三次会话终止均为同一报错:
  DeepSeek 400 "Messages with role 'tool' must be a response to a preceding
  message with 'tool_calls'"

缺陷链:engine 主循环仅在 step.thought 存在(该轮有文本或思考内容)时才
将 assistant 消息加入请求历史。当模型发起纯工具调用(零文本零思考 —
DeepSeek 高频行为)时:
  - assistant(tool_calls) 消息不进 messages
  - 但 tool 结果消息照常 push
  → 下一轮请求出现孤立 tool 消息 → 协议 400(不可重试)→ 会话 ERROR 终止
"不稳定" = 模型每轮是否附带文本是概率性行为:带文本正常,纯调用必崩。
DB 持久化侧同源缺陷(if (!step.thought) continue)导致这些步骤的
assistant 与 tool 结果全部不落库 — 重启后工具上下文丢失,模型重复调用。

【修复】
- engine.ts: 有 toolCalls 的轮次必 push assistant(content=null,C-6 规范)
- agent.ts: 持久化条件同步修复(无 thought 但有 toolCalls 的步骤落库)
- 回归测试: 纯 tool_calls 轮后第二次请求中 tool 消息前必须是带
  tool_calls 的 assistant(请求契约断言,engine-toolchain.test.ts)

【纵深防御 — 孤立 tool 消息过滤】
- openai-format.ts(DeepSeek/Agnes/MiMo/OpenAI 四家共享): 构建请求时
  按 tool_call_id 配对过滤孤立 tool 消息(任何来源的历史污染不再 400 死锁)
- anthropic.adapter.ts: tool_use/tool_result 同策略配对过滤
- 单测 ×6: 正常配对保留 / 孤立丢弃 / id 不匹配丢弃 / 多轮配对 /
  includeImages 原位转换 / 非 vision 静默丢弃

【多模态索引对齐收敛】
4 家 adapter 的 images 处理循环原按未过滤的 nonSystemMsgs[i-1] 对齐索引,
孤立 tool 过滤引入后会错位 — 统一收进 buildOpenAICompatibleMessages
(includeImages 参数,基于 sanitized 序列原位转换),4 家 adapter 删除
各自的索引对齐循环(DeepSeek vision 判断 / OpenAI 推理模型拒绝保留在 adapter)。

【终止原因可见化】
MAX_ITERATIONS / TIMEOUT 终止此前无任何提示(用户感知"会话直接停止")—
前端 DONE 事件非 completed 终止原因显示为 system 消息。

【v0.6.1 回归缓解】
web_fetch timeoutMs 120s → 240s:浏览器回退串行化后并发 3 个排队最坏
~127.5s,旧值让排队末位抓取被工具超时杀掉(表现为抓取不稳定)。

【验证】
lint 0/0;typecheck 双工程 0 错误;test:electron 259/259(+7);
electron-vite build 成功
2026-08-22 19:34:16 +08:00

982 lines
36 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 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 {
// 提示注入检测(安全模块)
// 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 };
}
});
// ===== 中断会话 =====
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;
}
},
);
}