feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证 - 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁) - 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync - 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies 安全加固: - PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额) - ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道) - Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面 - web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级) 缺陷修复(测试驱动发现): - mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试 - 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢 - 百度复合类名重复收录:class="result c-container" 被双重匹配 测试补齐(113 → 194 用例): - 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器 - 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批 体验升级: - OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡) - SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护) - MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销) - MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* IPC Agent Handlers — sendMessage 编排链路测试(v0.4.1 测试补齐)
|
||||
*
|
||||
* 覆盖 sendMessage 的主编排逻辑:
|
||||
* 1. 参数校验(无效 sessionId / userMessage → ERROR+DONE 流事件,防止前端 isStreaming 卡死)
|
||||
* 2. Adapter 加载失败中止
|
||||
* 3. Prompt 注入阻断(riskScore >= 7)
|
||||
* 4. 成功路径(消息持久化 / 审计 / 记忆固化 / 摘要评估 / Token 统计)
|
||||
* 5. 引擎异常路径(审计错误 + ERROR 流事件)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// ===== Mock electron(ipcMain) =====
|
||||
const ipcMainHandleMock = vi.fn();
|
||||
const ipcMainOnMock = vi.fn();
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (...args: unknown[]) => ipcMainHandleMock(...args),
|
||||
on: (...args: unknown[]) => ipcMainOnMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// ===== Mock broadcast(ipc/context) =====
|
||||
const broadcastMock = vi.fn();
|
||||
vi.mock('../context', () => ({
|
||||
broadcast: (...args: unknown[]) => broadcastMock(...args),
|
||||
}));
|
||||
|
||||
import { registerAgentHandlers } from '../agent';
|
||||
import type { IPCContext } from '../context';
|
||||
import type { MetonaMessage } from '../../harness/types';
|
||||
|
||||
// ===== Mock 依赖工厂 =====
|
||||
|
||||
function makeEngineMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
runStream: vi.fn().mockResolvedValue({
|
||||
finalAnswer: '这是最终回答',
|
||||
terminationReason: 'completed',
|
||||
iterations: [
|
||||
{
|
||||
iteration: 1,
|
||||
state: 'OBSERVING',
|
||||
startedAt: 1,
|
||||
completedAt: 2,
|
||||
thought: {
|
||||
id: 'thought-1',
|
||||
content: '本轮思考文本',
|
||||
reasoningContent: '推理过程',
|
||||
timestamp: 1,
|
||||
iteration: 1,
|
||||
},
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
},
|
||||
],
|
||||
totalTokenUsage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
|
||||
durationMs: 1234,
|
||||
metadata: {},
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
const engine = makeEngineMock();
|
||||
const engineManager = new EventEmitter() as EventEmitter & {
|
||||
getEngine: Mock;
|
||||
abort: Mock;
|
||||
waitForAbort: Mock;
|
||||
};
|
||||
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
|
||||
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
|
||||
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
|
||||
.fn()
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const ctx = {
|
||||
agentEngineManager: engineManager,
|
||||
sessionRecorder: {
|
||||
startRecording: vi.fn(),
|
||||
stopRecording: vi.fn(),
|
||||
recordContextBuilt: vi.fn(),
|
||||
recordToolCall: vi.fn(),
|
||||
recordToolResult: vi.fn(),
|
||||
recordLLMResponse: vi.fn(),
|
||||
recordIterationStart: vi.fn(),
|
||||
recordIterationEnd: vi.fn(),
|
||||
recordLLMRequest: vi.fn(),
|
||||
},
|
||||
configService: { get: vi.fn(() => '') },
|
||||
sessionService: {
|
||||
saveMessage: vi.fn(),
|
||||
getMessages: vi.fn(() => []),
|
||||
updateTokenUsage: vi.fn(),
|
||||
},
|
||||
workspaceService: {
|
||||
getFiles: vi.fn(() => ({ soul: '# Metona', memory: '# Memory' })),
|
||||
getPath: vi.fn(() => '/workspace'),
|
||||
updateMemoryTimestamp: vi.fn(),
|
||||
},
|
||||
contextBuilder: {
|
||||
buildSystemPrompt: vi.fn(() => ({
|
||||
roleDefinition: 'role',
|
||||
outputConstraints: 'constraints',
|
||||
safetyGuidelines: 'safety',
|
||||
dynamicReminders: 'reminders',
|
||||
})),
|
||||
isUsingFallbackRole: vi.fn(() => false),
|
||||
},
|
||||
auditService: {
|
||||
logSessionStart: vi.fn(),
|
||||
logSessionEnd: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
memoryManager: { search: vi.fn(() => []) },
|
||||
promptInjectionDefender: {
|
||||
detect: vi.fn(() => ({
|
||||
isInjection: false,
|
||||
riskScore: 0,
|
||||
findings: [],
|
||||
recommendation: 'PASS: ok',
|
||||
})),
|
||||
},
|
||||
outputValidator: {
|
||||
validate: vi.fn().mockResolvedValue({ valid: true, issues: [], score: 1 }),
|
||||
},
|
||||
memoryConsolidator: {
|
||||
consolidate: vi.fn().mockResolvedValue({ appended: 0, entries: [], skipped: 0 }),
|
||||
isRunning: vi.fn(() => false),
|
||||
},
|
||||
sessionSummaryService: {
|
||||
buildHistoryMessages: vi.fn(() => []),
|
||||
maybeSummarize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
orchestrator: { abortByParent: vi.fn() },
|
||||
confirmationHook: { clearPending: vi.fn() },
|
||||
reloadAdapter: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
return { ctx: ctx as unknown as IPCContext, engine, engineManager, ctxRaw: ctx };
|
||||
}
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => Promise<unknown> {
|
||||
const call = ipcMainHandleMock.mock.calls.find(([ch]) => ch === channel);
|
||||
if (!call) throw new Error(`IPC handler not registered: ${channel}`);
|
||||
return call[1] as (...args: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const VALID_MESSAGE: MetonaMessage = {
|
||||
role: 'user',
|
||||
content: '你好,请帮我分析这个项目',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ipcMainHandleMock.mockClear();
|
||||
ipcMainOnMock.mockClear();
|
||||
broadcastMock.mockClear();
|
||||
});
|
||||
|
||||
describe('agent:sendMessage — 参数校验', () => {
|
||||
it('无效 sessionId 拒绝并发送 ERROR + DONE 流事件(防止前端 isStreaming 卡死)', async () => {
|
||||
const { ctx } = makeCtx();
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
const result = await handler(null, VALID_MESSAGE, '');
|
||||
expect(result).toEqual({ success: false, error: 'Invalid sessionId' });
|
||||
|
||||
// ERROR + DONE 两个流事件都应广播
|
||||
const eventTypes = broadcastMock.mock.calls.map(([, ev]) => (ev as { type: string }).type);
|
||||
expect(eventTypes).toContain('error');
|
||||
expect(eventTypes).toContain('done');
|
||||
});
|
||||
|
||||
it('无效 userMessage(非对象 / content 非字符串)拒绝', async () => {
|
||||
const { ctx } = makeCtx();
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
const result = await handler(null, { content: 123 }, 'sess_1');
|
||||
expect(result).toEqual({ success: false, error: 'Invalid message format' });
|
||||
expect(broadcastMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent:sendMessage — 前置检查', () => {
|
||||
it('Adapter 加载失败时中止并停止录制', async () => {
|
||||
const { ctx, ctxRaw } = makeCtx({ reloadAdapter: vi.fn(() => false) });
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
const result = await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
expect((result as { success: boolean }).success).toBe(false);
|
||||
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
|
||||
// 不应调用引擎
|
||||
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('注入风险 riskScore >= 7 时阻断消息', async () => {
|
||||
const { ctx, ctxRaw } = makeCtx({
|
||||
promptInjectionDefender: {
|
||||
detect: vi.fn(() => ({
|
||||
isInjection: true,
|
||||
riskScore: 8,
|
||||
findings: [{ pattern: 'x', matched: 'ignore previous instructions', severity: 'high' }],
|
||||
recommendation: 'BLOCK: High-risk injection detected',
|
||||
})),
|
||||
},
|
||||
});
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
const result = await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
expect((result as { success: boolean }).success).toBe(false);
|
||||
expect((result as { error: string }).error).toContain('blocked by prompt injection defense');
|
||||
// 用户消息不保存(在注入检测前已保存?—— 现实现:先保存再检测,验证已保存)
|
||||
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: 'user', sessionId: 'sess_1' }),
|
||||
);
|
||||
// 引擎不启动
|
||||
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SOUL.md 缺失降级时发送 toast 提示', async () => {
|
||||
const { ctx } = makeCtx({
|
||||
contextBuilder: {
|
||||
buildSystemPrompt: vi.fn(() => ({
|
||||
roleDefinition: 'fallback',
|
||||
outputConstraints: 'c',
|
||||
safetyGuidelines: 's',
|
||||
})),
|
||||
isUsingFallbackRole: vi.fn(() => true),
|
||||
},
|
||||
});
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
const toastCall = broadcastMock.mock.calls.find(([ch]) => ch === 'toast:show');
|
||||
expect(toastCall).toBeDefined();
|
||||
expect((toastCall![1] as { message: string }).message).toContain('SOUL.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent:sendMessage — 成功路径', () => {
|
||||
it('完整编排:保存消息 → 运行引擎 → 持久化 assistant 消息 → 审计 → 异步固化', async () => {
|
||||
const { ctx, ctxRaw, engine } = makeCtx();
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
const result = await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
// 1. 用户消息保存
|
||||
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: VALID_MESSAGE.content,
|
||||
sessionId: 'sess_1',
|
||||
}),
|
||||
);
|
||||
// 2. 引擎启动(每会话引擎)
|
||||
expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1');
|
||||
expect(engine.runStream).toHaveBeenCalledWith(
|
||||
VALID_MESSAGE,
|
||||
'sess_1',
|
||||
[],
|
||||
expect.objectContaining({ roleDefinition: 'role' }),
|
||||
);
|
||||
// 3. assistant 消息保存(含思考内容)
|
||||
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '本轮思考文本',
|
||||
reasoningContent: '推理过程',
|
||||
}),
|
||||
);
|
||||
// 4. Token 统计更新
|
||||
expect(ctxRaw.sessionService.updateTokenUsage).toHaveBeenCalledWith('sess_1', 150);
|
||||
// 5. MEMORY.md 时间戳更新
|
||||
expect(ctxRaw.workspaceService.updateMemoryTimestamp).toHaveBeenCalled();
|
||||
// 6. 审计 + 录制结束
|
||||
expect(ctxRaw.auditService.logSessionEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'sess_1', terminationReason: 'completed' }),
|
||||
);
|
||||
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
|
||||
// 7. 输出验证执行
|
||||
expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith('这是最终回答', expect.anything());
|
||||
// 8. 摘要评估(异步触发)
|
||||
await vi.waitFor(() =>
|
||||
expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'),
|
||||
);
|
||||
// 9. 记忆固化(异步触发)
|
||||
await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('注入相关记忆到 System Prompt 动态区', async () => {
|
||||
const { ctx, ctxRaw } = makeCtx({
|
||||
memoryManager: {
|
||||
search: vi.fn(() => [
|
||||
{
|
||||
id: 'm1',
|
||||
type: 'semantic',
|
||||
content: '用户偏好深色主题',
|
||||
importance: 0.9,
|
||||
createdAt: Date.now(),
|
||||
score: 0.8,
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
expect(ctxRaw.memoryManager.search).toHaveBeenCalled();
|
||||
// 引擎收到的 systemPrompt 应包含记忆块
|
||||
const prompt = engine_runStreamPrompt(ctxRaw);
|
||||
expect(prompt.dynamicReminders).toContain('用户偏好深色主题');
|
||||
});
|
||||
|
||||
it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => {
|
||||
const { ctx, ctxRaw } = makeCtx({
|
||||
outputValidator: {
|
||||
validate: vi.fn().mockResolvedValue({
|
||||
valid: false,
|
||||
score: 0.7,
|
||||
issues: [
|
||||
{ severity: 'warning', type: 'hallucination', message: 'Path not found in context' },
|
||||
{ severity: 'info', type: 'format', message: 'noise' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
const validationCall = broadcastMock.mock.calls.find(
|
||||
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'validation',
|
||||
);
|
||||
expect(validationCall).toBeDefined();
|
||||
const payload = (validationCall![1] as { validation: { issues: unknown[] } }).validation;
|
||||
// info 级噪声不推送
|
||||
expect(payload.issues).toHaveLength(1);
|
||||
expect(ctxRaw.outputValidator.validate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent:sendMessage — 异常路径', () => {
|
||||
it('引擎抛错时返回失败并记录审计错误', async () => {
|
||||
const engine = makeEngineMock({
|
||||
runStream: vi.fn().mockRejectedValue(new Error('LLM connection failed')),
|
||||
});
|
||||
const engineManager = new EventEmitter() as EventEmitter & {
|
||||
getEngine: Mock;
|
||||
abort: Mock;
|
||||
waitForAbort: Mock;
|
||||
};
|
||||
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
|
||||
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
|
||||
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
|
||||
.fn()
|
||||
.mockResolvedValue(true);
|
||||
const { ctx, ctxRaw } = makeCtx({
|
||||
agentEngineManager: engineManager,
|
||||
});
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:sendMessage');
|
||||
|
||||
const result = await handler(null, VALID_MESSAGE, 'sess_1');
|
||||
expect((result as { success: boolean }).success).toBe(false);
|
||||
expect((result as { error: string }).error).toBe('LLM connection failed');
|
||||
// 审计记录错误
|
||||
expect(ctxRaw.auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'sess_1', eventType: 'error', outcome: 'error' }),
|
||||
);
|
||||
// ERROR 流事件广播
|
||||
const errorCall = broadcastMock.mock.calls.find(
|
||||
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error',
|
||||
);
|
||||
expect(errorCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent:abortSession — 中断编排', () => {
|
||||
it('联动 SubAgent 中断 + 引擎中断 + 清理确认', async () => {
|
||||
const { ctx, ctxRaw } = makeCtx();
|
||||
registerAgentHandlers(ctx);
|
||||
const handler = getHandler('agent:abortSession');
|
||||
|
||||
const result = await handler(null, 'sess_1');
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(ctxRaw.orchestrator.abortByParent).toHaveBeenCalledWith('sess_1');
|
||||
expect(ctxRaw.confirmationHook.clearPending).toHaveBeenCalled();
|
||||
expect(ctxRaw.auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventType: 'session_end', outcome: 'denied' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 从 runStream 调用参数中提取 systemPrompt */
|
||||
function engine_runStreamPrompt(ctxRaw: Record<string, unknown>): {
|
||||
roleDefinition: string;
|
||||
dynamicReminders?: string;
|
||||
} {
|
||||
const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as {
|
||||
runStream: Mock;
|
||||
};
|
||||
return engine.runStream.mock.calls[0][3];
|
||||
}
|
||||
+462
-345
@@ -22,7 +22,10 @@ import log from 'electron-log';
|
||||
/** 单会话的 text_delta 节流状态 */
|
||||
interface ThrottleState {
|
||||
buffer: string;
|
||||
lastEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null;
|
||||
lastEventMeta: Pick<
|
||||
MetonaStreamEvent,
|
||||
'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'
|
||||
> | null;
|
||||
flushTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
@@ -37,10 +40,20 @@ interface IterationTrace {
|
||||
|
||||
export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
const {
|
||||
agentEngineManager, sessionRecorder, configService, sessionService,
|
||||
workspaceService, contextBuilder, auditService, memoryManager,
|
||||
promptInjectionDefender, outputValidator, memoryConsolidator,
|
||||
sessionSummaryService, orchestrator, confirmationHook,
|
||||
agentEngineManager,
|
||||
sessionRecorder,
|
||||
configService,
|
||||
sessionService,
|
||||
workspaceService,
|
||||
contextBuilder,
|
||||
auditService,
|
||||
memoryManager,
|
||||
promptInjectionDefender,
|
||||
outputValidator,
|
||||
memoryConsolidator,
|
||||
sessionSummaryService,
|
||||
orchestrator,
|
||||
confirmationHook,
|
||||
} = ctx;
|
||||
|
||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||
@@ -88,7 +101,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
|
||||
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
|
||||
const st = throttleStates.get(sessionId) ?? {
|
||||
buffer: '', lastEventMeta: null, flushTimer: null,
|
||||
buffer: '',
|
||||
lastEventMeta: null,
|
||||
flushTimer: null,
|
||||
};
|
||||
throttleStates.set(sessionId, st);
|
||||
if (st.buffer === '') {
|
||||
@@ -101,7 +116,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
runId: event.runId,
|
||||
};
|
||||
} else if (st.lastEventMeta) {
|
||||
st.lastEventMeta = { ...st.lastEventMeta, seq: event.seq, timestamp: event.timestamp, runId: event.runId };
|
||||
st.lastEventMeta = {
|
||||
...st.lastEventMeta,
|
||||
seq: event.seq,
|
||||
timestamp: event.timestamp,
|
||||
runId: event.runId,
|
||||
};
|
||||
}
|
||||
st.buffer += event.delta;
|
||||
if (st.flushTimer === null) {
|
||||
@@ -133,9 +153,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
break;
|
||||
case MetonaStreamEventType.TOOL_RESULT:
|
||||
if (event.toolResult) {
|
||||
const resultPreview = typeof event.toolResult.result === 'string'
|
||||
? event.toolResult.result
|
||||
: JSON.stringify(event.toolResult.result);
|
||||
const resultPreview =
|
||||
typeof event.toolResult.result === 'string'
|
||||
? event.toolResult.result
|
||||
: JSON.stringify(event.toolResult.result);
|
||||
sessionRecorder.recordToolResult({
|
||||
sessionId,
|
||||
iteration: event.iteration,
|
||||
@@ -167,21 +188,67 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
|
||||
// ===== 常驻监听:状态变化(广播 + 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);
|
||||
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);
|
||||
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) {
|
||||
// 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,
|
||||
@@ -190,76 +257,48 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
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,
|
||||
});
|
||||
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
|
||||
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
|
||||
if (stateValue === 'TERMINATED') {
|
||||
if (trace) {
|
||||
sessionRecorder.recordIterationEnd(sessionId, {
|
||||
iteration: trace.iteration,
|
||||
durationMs: Date.now() - trace.startedAt,
|
||||
});
|
||||
}
|
||||
cleanupSessionState(sessionId);
|
||||
}
|
||||
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(),
|
||||
});
|
||||
});
|
||||
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 }) => {
|
||||
@@ -271,300 +310,378 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
});
|
||||
|
||||
// ===== 常驻监听: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 请求失败)`,
|
||||
});
|
||||
});
|
||||
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 },
|
||||
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 });
|
||||
};
|
||||
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`);
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||
sendErrorEvent('无效的会话 ID', sessionId ?? '');
|
||||
return { success: false, error: 'Invalid sessionId' };
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||
}
|
||||
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));
|
||||
|
||||
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
|
||||
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||
const attachmentList = attachments.map((att, i) => {
|
||||
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
||||
const note = att.type === 'image'
|
||||
? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
|
||||
: att.type === 'text'
|
||||
? 'content already inlined in the user message, do NOT search in workspace or read it again'
|
||||
: 'uploaded directly by user, do NOT search in workspace';
|
||||
return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
|
||||
}).join('\n');
|
||||
|
||||
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`;
|
||||
|
||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
||||
: attachmentBlock;
|
||||
|
||||
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
const injectionResult = promptInjectionDefender.detect(userMessage.content);
|
||||
if (injectionResult.riskScore >= 7) {
|
||||
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
|
||||
sendErrorEvent(`Message blocked by prompt injection defense: ${injectionResult.recommendation}`, sessionId);
|
||||
// 发送消息前确保 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',
|
||||
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);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
// TRACE 层:记录上下文构建
|
||||
sessionRecorder.recordContextBuilt(sessionId, {
|
||||
tokenCount: estimateMessagesTokens(history),
|
||||
usageRatio: 0,
|
||||
// TRACE 层:开始录制 / TOOL 层:记录会话开始
|
||||
sessionRecorder.startRecording(sessionId);
|
||||
auditService.logSessionStart(sessionId);
|
||||
|
||||
// 保存用户消息到数据库
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: userMessage.content,
|
||||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||
});
|
||||
|
||||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
||||
const engine = agentEngineManager.getEngine(sessionId);
|
||||
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
|
||||
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
|
||||
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
|
||||
|
||||
// 输出验证(不阻塞响应,仅记录警告)
|
||||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
||||
try {
|
||||
const toolResults = output.iterations
|
||||
.flatMap((step) => step.toolResults ?? [])
|
||||
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
|
||||
const context = [...history, { role: 'user', content: userMessage.content }]
|
||||
.map((m) => `${m.role}: ${m.content}`).join('\n');
|
||||
const validation = await outputValidator.validate(output.finalAnswer, {
|
||||
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
||||
context,
|
||||
// 从工作空间文件构建 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 人格',
|
||||
});
|
||||
if (!validation.valid || validation.issues.length > 0) {
|
||||
log.warn('[OutputValidator] Validation issues:', validation.issues);
|
||||
}
|
||||
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
|
||||
} catch (err) {
|
||||
log.error('[OutputValidator] Validation failed:', err);
|
||||
}
|
||||
|
||||
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
|
||||
for (const step of output.iterations) {
|
||||
if (!step.thought) continue;
|
||||
// 检索与用户消息相关的记忆,注入到 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);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
// 附件提示注入:用户直接上传的文件/图片,避免 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');
|
||||
|
||||
// 只有当有内容、思考内容或工具调用时才保存
|
||||
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
|
||||
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
|
||||
const assistantContent = (toolCallsWithResults?.length && !step.thought.content)
|
||||
? null
|
||||
: step.thought.content;
|
||||
sessionService.saveMessage({
|
||||
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`;
|
||||
|
||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
||||
: attachmentBlock;
|
||||
|
||||
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
const injectionResult = promptInjectionDefender.detect(userMessage.content);
|
||||
if (injectionResult.riskScore >= 7) {
|
||||
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
|
||||
sendErrorEvent(
|
||||
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
reasoningContent: step.thought.reasoningContent || undefined,
|
||||
toolCalls: toolCallsWithResults,
|
||||
iteration: step.iteration,
|
||||
);
|
||||
sessionRecorder.stopRecording(sessionId, {
|
||||
totalIterations: 0,
|
||||
totalTokens: 0,
|
||||
durationMs: 0,
|
||||
terminationReason: 'error',
|
||||
});
|
||||
return { success: false, error: 'Message blocked by prompt injection defense' };
|
||||
}
|
||||
if (injectionResult.riskScore >= 4) {
|
||||
log.warn(
|
||||
'[PromptInjectionDefender] Suspicious patterns detected:',
|
||||
injectionResult.findings,
|
||||
);
|
||||
}
|
||||
|
||||
// v0.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);
|
||||
// TRACE 层:记录上下文构建
|
||||
sessionRecorder.recordContextBuilt(sessionId, {
|
||||
tokenCount: estimateMessagesTokens(history),
|
||||
usageRatio: 0,
|
||||
});
|
||||
|
||||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
||||
const engine = agentEngineManager.getEngine(sessionId);
|
||||
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
|
||||
|
||||
// 输出验证(不阻塞响应,仅记录警告)
|
||||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
||||
// 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 级为噪声)
|
||||
const visibleIssues = validation.issues
|
||||
.filter((i) => 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 消息到数据库(含思考内容和工具调用)
|
||||
for (const step of output.iterations) {
|
||||
if (!step.thought) continue;
|
||||
|
||||
const toolCallsWithResults = step.toolCalls?.map((tc) => {
|
||||
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
status: result?.success ? ('success' as const) : ('error' as const),
|
||||
result: result?.result,
|
||||
durationMs: result?.durationMs,
|
||||
error: result?.error,
|
||||
};
|
||||
});
|
||||
|
||||
// 只有当有内容、思考内容或工具调用时才保存
|
||||
if (
|
||||
step.thought.content ||
|
||||
step.thought.reasoningContent ||
|
||||
toolCallsWithResults?.length
|
||||
) {
|
||||
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
|
||||
const assistantContent =
|
||||
toolCallsWithResults?.length && !step.thought.content ? null : step.thought.content;
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: result.error ?? resultContent,
|
||||
toolResult: result,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
reasoningContent: step.thought.reasoningContent || undefined,
|
||||
toolCalls: toolCallsWithResults,
|
||||
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`,
|
||||
});
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('[AGENT] Memory consolidation failed:', err);
|
||||
}
|
||||
|
||||
// 更新 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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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`,
|
||||
);
|
||||
|
||||
log.info(`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.error('[AGENT] Error:', error);
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
// 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',
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// 发送错误事件到 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 };
|
||||
}
|
||||
});
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ===== 中断会话 =====
|
||||
|
||||
|
||||
+59
-40
@@ -13,46 +13,65 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
return mcpManager.getServerStates();
|
||||
});
|
||||
|
||||
ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => {
|
||||
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { success: false, error: 'Invalid config' };
|
||||
}
|
||||
if (typeof config.name !== 'string' || !config.name.trim()) {
|
||||
return { success: false, error: 'Server name is required' };
|
||||
}
|
||||
// M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言)
|
||||
if (config.transport !== 'stdio' && config.transport !== 'sse') {
|
||||
return { success: false, error: `Invalid transport: ${config.transport}. Must be 'stdio' or 'sse'` };
|
||||
}
|
||||
// stdio 类型必须有 command
|
||||
if (config.transport === 'stdio' && (typeof config.command !== 'string' || !config.command.trim())) {
|
||||
return { success: false, error: 'command is required for stdio transport' };
|
||||
}
|
||||
// sse 类型必须有合法 url
|
||||
if (config.transport === 'sse') {
|
||||
if (typeof config.url !== 'string' || !config.url.trim()) {
|
||||
return { success: false, error: 'url is required for sse transport' };
|
||||
ipcMain.handle(
|
||||
'mcp:addServer',
|
||||
async (
|
||||
_event,
|
||||
config: { name: string; transport: string; command?: string; args?: string[]; url?: string },
|
||||
) => {
|
||||
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { success: false, error: 'Invalid config' };
|
||||
}
|
||||
try { new URL(config.url); } catch {
|
||||
return { success: false, error: 'Invalid url format' };
|
||||
if (typeof config.name !== 'string' || !config.name.trim()) {
|
||||
return { success: false, error: 'Server name is required' };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mcpManager.addServer({
|
||||
name: config.name,
|
||||
transport: config.transport, // 已校验,无需断言
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
url: config.url,
|
||||
enabled: true,
|
||||
});
|
||||
log.info(`MCP server added: ${config.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
}
|
||||
});
|
||||
// M-5 修复: transport 运行时校验(替代 as 断言)
|
||||
// v0.4.1: 新增 'streamable-http' 传输方式
|
||||
if (
|
||||
config.transport !== 'stdio' &&
|
||||
config.transport !== 'sse' &&
|
||||
config.transport !== 'streamable-http'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid transport: ${config.transport}. Must be 'stdio', 'sse', or 'streamable-http'`,
|
||||
};
|
||||
}
|
||||
// stdio 类型必须有 command
|
||||
if (
|
||||
config.transport === 'stdio' &&
|
||||
(typeof config.command !== 'string' || !config.command.trim())
|
||||
) {
|
||||
return { success: false, error: 'command is required for stdio transport' };
|
||||
}
|
||||
// sse / streamable-http 类型必须有合法 url
|
||||
if (config.transport === 'sse' || config.transport === 'streamable-http') {
|
||||
if (typeof config.url !== 'string' || !config.url.trim()) {
|
||||
return { success: false, error: `url is required for ${config.transport} transport` };
|
||||
}
|
||||
try {
|
||||
new URL(config.url);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid url format' };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mcpManager.addServer({
|
||||
name: config.name,
|
||||
transport: config.transport, // 已校验,无需断言
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
url: config.url,
|
||||
enabled: true,
|
||||
});
|
||||
log.info(`MCP server added: ${config.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
|
||||
// M-38 修复: name 校验
|
||||
@@ -63,7 +82,7 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
await mcpManager.removeServer(name);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,7 +98,7 @@ export function registerMCPHandlers(ctx: IPCContext): void {
|
||||
await mcpManager.toggleServer(name, enabled);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+31
-4
@@ -57,7 +57,12 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
|
||||
return;
|
||||
}
|
||||
const req = data as { toolCallId?: unknown; approved?: unknown; remember?: unknown; autoExecute?: unknown };
|
||||
const req = data as {
|
||||
toolCallId?: unknown;
|
||||
approved?: unknown;
|
||||
remember?: unknown;
|
||||
autoExecute?: unknown;
|
||||
};
|
||||
if (typeof req.toolCallId !== 'string' || !req.toolCallId) {
|
||||
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
|
||||
return;
|
||||
@@ -69,7 +74,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
||||
const remember = typeof req.remember === 'boolean' ? req.remember : false;
|
||||
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
|
||||
confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute);
|
||||
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
|
||||
log.info(
|
||||
`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批) =====
|
||||
@@ -86,7 +93,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
||||
};
|
||||
// 严格校验 toolCallIds 数组
|
||||
if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) {
|
||||
log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array');
|
||||
log.warn(
|
||||
'[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 每个元素必须是字符串
|
||||
@@ -108,7 +117,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
||||
remember,
|
||||
autoExecute,
|
||||
);
|
||||
log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
|
||||
log.info(
|
||||
`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ===== v0.3.2: 拉取当前所有 pending 确认 =====
|
||||
@@ -116,6 +127,22 @@ export function registerToolHandlers(ctx: IPCContext): void {
|
||||
return { success: true, data: confirmationHook.getPendingConfirmations() };
|
||||
});
|
||||
|
||||
// ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) =====
|
||||
ipcMain.handle('tool:getRememberedDenials', async () => {
|
||||
return { success: true, data: confirmationHook.getRememberedDenials() };
|
||||
});
|
||||
|
||||
ipcMain.handle('tool:resetRememberedDenial', async (_event, toolName: unknown) => {
|
||||
if (typeof toolName !== 'string' || !toolName) {
|
||||
return { success: false, error: 'Invalid toolName' };
|
||||
}
|
||||
const reset = confirmationHook.resetRememberedDenial(toolName);
|
||||
if (reset) {
|
||||
log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
|
||||
}
|
||||
return { success: reset };
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 持久化自动执行设置 =====
|
||||
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
|
||||
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
|
||||
|
||||
Reference in New Issue
Block a user