refactor: 移除多余依赖,统一 MUI 为唯一 UI 库
- 移除 radix-ui、clsx、tailwind-merge、class-variance-authority、react-router-dom - 前后端全面适配 MUI 组件,清理残留 Tailwind 工具类引用 - 优化 Agent Loop 引擎、IPC 处理器、Provider Adapter 等后端模块 - 新增 Agent 网络工具通用设计文档 v2
This commit is contained in:
@@ -125,7 +125,6 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
}
|
||||
for (const img of origMsg.images) {
|
||||
const isDataUrl = img.url.startsWith('data:');
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: img.url },
|
||||
|
||||
@@ -30,7 +30,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.listModels?.();
|
||||
await this.listModels();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -58,6 +58,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) {
|
||||
return {
|
||||
code: MetonaErrorCode.NETWORK_ERROR,
|
||||
message: error.message,
|
||||
provider: this.provider,
|
||||
retryable: true,
|
||||
retryAfterMs: 3000,
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('401') || msg.includes('unauthorized')) {
|
||||
return {
|
||||
code: MetonaErrorCode.AUTH_INVALID,
|
||||
|
||||
@@ -66,6 +66,7 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
@@ -124,6 +125,8 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
// 工具调用(Ollama 在最后一个 chunk 中整块返回)
|
||||
if (chunk.message?.tool_calls) {
|
||||
for (const tc of chunk.message.tool_calls) {
|
||||
const args = tc.function?.arguments;
|
||||
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : (args ?? {});
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
requestId: request.meta.requestId,
|
||||
@@ -134,7 +137,7 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
toolCall: {
|
||||
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: tc.function?.name ?? '',
|
||||
args: tc.function?.arguments ?? {},
|
||||
args: parsedArgs,
|
||||
iteration: request.meta.iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
@@ -441,10 +444,17 @@ export class OllamaAdapter extends BaseAdapter {
|
||||
reasoningContent: message?.thinking as string | undefined,
|
||||
toolCalls: toolCalls?.map((tc, i) => {
|
||||
const fn = tc.function as Record<string, unknown>;
|
||||
const rawArgs = fn?.arguments;
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record<string, unknown>) ?? {};
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
return {
|
||||
id: `tc_${Date.now()}_${i}`,
|
||||
name: (fn?.name as string) ?? '',
|
||||
args: (fn?.arguments as Record<string, unknown>) ?? {},
|
||||
args,
|
||||
iteration: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function* parseSSEStream(
|
||||
|
||||
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
|
||||
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
|
||||
if (finishReason === 'tool_calls' || finishReason === 'stop') {
|
||||
if (finishReason === 'tool_calls') {
|
||||
for (const [index, buf] of toolCallsBuffer) {
|
||||
try {
|
||||
yield {
|
||||
|
||||
@@ -44,6 +44,9 @@ const DEFAULT_CONFIG: AgentLoopConfig = {
|
||||
contextWindow: 128_000,
|
||||
retryCount: 3,
|
||||
temperature: 0.0,
|
||||
maxTokens: 63488,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'high',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -157,11 +160,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
messages,
|
||||
tools: this.tools.length > 0 ? this.tools : undefined,
|
||||
params: {
|
||||
maxTokens: 63488,
|
||||
maxTokens: this.config.maxTokens,
|
||||
temperature: this.config.temperature,
|
||||
stream: true,
|
||||
thinkingEnabled: true,
|
||||
thinkingEffort: 'high',
|
||||
thinkingEnabled: this.config.thinkingEnabled,
|
||||
thinkingEffort: this.config.thinkingEffort,
|
||||
contextLength: this.config.contextLength,
|
||||
},
|
||||
};
|
||||
@@ -254,9 +257,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
let tokenUsage: TokenUsage | undefined;
|
||||
|
||||
// 流式接收响应
|
||||
for await (const event of this.adapter.chatStream(request)) {
|
||||
for await (const event of this.chatStreamWithRetry(request)) {
|
||||
if (this.aborted) break;
|
||||
|
||||
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
|
||||
if (event.type === MetonaStreamEventType.DONE) continue;
|
||||
|
||||
// 转发流式事件到渲染进程
|
||||
this.emit('streamEvent', event);
|
||||
|
||||
@@ -299,9 +305,6 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
break;
|
||||
|
||||
case MetonaStreamEventType.DONE:
|
||||
break;
|
||||
|
||||
case MetonaStreamEventType.ERROR:
|
||||
if (event.error) {
|
||||
throw new Error(event.error.message);
|
||||
@@ -313,18 +316,21 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
|
||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||
step.toolCalls = [];
|
||||
for (const [index, buf] of toolCallsBuffer) {
|
||||
for (const [, buf] of toolCallsBuffer) {
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
step.toolCalls.push({
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
|
||||
iteration: this.currentIteration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
|
||||
} catch {
|
||||
// JSON 解析失败,跳过
|
||||
// JSON 解析失败,使用空参数(LLM 仍请求了该工具调用)
|
||||
args = {};
|
||||
}
|
||||
step.toolCalls.push({
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args,
|
||||
iteration: this.currentIteration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +367,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// 转发工具结果到渲染进程
|
||||
this.emit('streamEvent', {
|
||||
type: 'tool_result',
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
@@ -444,10 +450,37 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的流式调用
|
||||
*
|
||||
* 如果 adapter 抛出错误,在 retryCount 次数内重试,每次等待 1 秒。
|
||||
*/
|
||||
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
|
||||
try {
|
||||
yield* this.adapter.chatStream(request);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < this.config.retryCount) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async transitionTo(state: AgentLoopState): Promise<void> {
|
||||
const previous = this.currentState;
|
||||
this.currentState = state;
|
||||
this.emit('stateChange', { previous, current: state });
|
||||
this.emit('stateChange', {
|
||||
previous,
|
||||
current: state,
|
||||
state,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
});
|
||||
}
|
||||
|
||||
private accumulateTokens(usage: TokenUsage): void {
|
||||
@@ -461,6 +494,16 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
answer?: string,
|
||||
error?: Error,
|
||||
): AgentLoopOutput {
|
||||
// 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId: this.currentRequestId,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
this.currentState = AgentLoopState.TERMINATED;
|
||||
return {
|
||||
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 用于 Agent Loop 引擎内部的状态管理和迭代记录。
|
||||
*/
|
||||
|
||||
import type { MetonaToolCall, MetonaToolResult } from '../types';
|
||||
|
||||
// ===== Agent Loop 状态机 =====
|
||||
|
||||
export enum AgentLoopState {
|
||||
@@ -41,8 +43,8 @@ export interface IterationStep {
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
thought?: Thought;
|
||||
toolCalls?: import('@shared/index').MetonaToolCall[];
|
||||
toolResults?: import('@shared/index').MetonaToolResult[];
|
||||
toolCalls?: MetonaToolCall[];
|
||||
toolResults?: MetonaToolResult[];
|
||||
tokenUsage?: TokenUsage;
|
||||
}
|
||||
|
||||
@@ -63,6 +65,12 @@ export interface AgentLoopConfig {
|
||||
contextWindow: number;
|
||||
retryCount: number;
|
||||
temperature: number;
|
||||
/** 最大生成 token 数(默认 63488) */
|
||||
maxTokens: number;
|
||||
/** 是否启用思考模式(默认 true) */
|
||||
thinkingEnabled: boolean;
|
||||
/** 思考强度(默认 'high') */
|
||||
thinkingEffort: 'low' | 'medium' | 'high' | 'max';
|
||||
/** Ollama 上下文窗口大小(num_ctx),其他 Provider 忽略 */
|
||||
contextLength?: number;
|
||||
}
|
||||
|
||||
@@ -41,13 +41,19 @@ export class MemoryTriggerHook implements PostToolHook {
|
||||
async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void> {
|
||||
if (this.memorableTools.includes(toolCall.name) && result.success) {
|
||||
const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
||||
this.memoryManager.store({
|
||||
type: 'episodic',
|
||||
content: `Tool ${toolCall.name} returned: ${content.slice(0, 500)}`,
|
||||
source: 'tool_result',
|
||||
sessionId,
|
||||
importance: 0.6,
|
||||
});
|
||||
try {
|
||||
this.memoryManager.store({
|
||||
type: 'episodic',
|
||||
content: `Tool ${toolCall.name} returned: ${content.slice(0, 500)}`,
|
||||
source: 'tool_result',
|
||||
sessionId,
|
||||
importance: 0.6,
|
||||
});
|
||||
} catch (error) {
|
||||
// 记忆存储失败不应影响工具执行结果
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[MemoryTriggerHook] Failed to store episodic memory:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +219,8 @@ Use tools when needed to gather information or perform actions. Think step by st
|
||||
let skipMeta = true;
|
||||
|
||||
for (const line of lines) {
|
||||
// 跳过元数据注释行(以 # 开头的非标题行)
|
||||
if (skipMeta && line.startsWith('#') && !line.startsWith('## ')) {
|
||||
// 跳过元数据注释行(以 # 开头且不跟另一个 # 的行,即只跳过一级标题)
|
||||
if (skipMeta && line.match(/^#[^#]/)) {
|
||||
continue;
|
||||
}
|
||||
skipMeta = false;
|
||||
|
||||
@@ -22,12 +22,12 @@ export interface PermissionPolicy {
|
||||
}
|
||||
|
||||
export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
||||
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//] },
|
||||
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//, /C:\\Windows\\/i, /C:\\System32\\/i] },
|
||||
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
|
||||
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
|
||||
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
|
||||
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
|
||||
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//], requireConfirmation: true, maxFrequency: 5 },
|
||||
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
|
||||
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
|
||||
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 3 },
|
||||
{ toolName: 'web_extract', requiredLevel: PermissionLevel.READ },
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { IMetonaTool, ToolRegistryEntry, ToolExecutionContext } from '../ty
|
||||
|
||||
export class ToolRegistry {
|
||||
private tools = new Map<string, ToolRegistryEntry>();
|
||||
private disabledTools = new Set<string>();
|
||||
|
||||
/** 注册内置工具 */
|
||||
registerBuiltin(tool: IMetonaTool): void {
|
||||
@@ -45,16 +46,27 @@ export class ToolRegistry {
|
||||
/** 获取工具 */
|
||||
get(name: string): IMetonaTool | undefined {
|
||||
const entry = this.tools.get(name);
|
||||
return entry?.enabled ? entry.tool : undefined;
|
||||
if (!entry?.enabled) return undefined;
|
||||
if (this.disabledTools.has(name)) return undefined;
|
||||
return entry.tool;
|
||||
}
|
||||
|
||||
/** 列出所有已启用工具的定义 */
|
||||
listTools(): MetonaToolDef[] {
|
||||
return Array.from(this.tools.values())
|
||||
.filter((e) => e.enabled)
|
||||
.filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name))
|
||||
.map((e) => e.tool.definition);
|
||||
}
|
||||
|
||||
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
|
||||
setToolEnabled(name: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this.disabledTools.delete(name);
|
||||
} else {
|
||||
this.disabledTools.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行工具 */
|
||||
async execute(
|
||||
toolCall: MetonaToolCall,
|
||||
@@ -99,6 +111,6 @@ export class ToolRegistry {
|
||||
|
||||
/** 获取工具数量 */
|
||||
get size(): number {
|
||||
return Array.from(this.tools.values()).filter((e) => e.enabled).length;
|
||||
return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export enum MetonaStreamEventType {
|
||||
REASONING_DELTA = 'reasoning_delta',
|
||||
TOOL_CALL_DELTA = 'tool_call_delta',
|
||||
TOOL_CALL_COMPLETE = 'tool_call_complete',
|
||||
TOOL_RESULT = 'tool_result',
|
||||
THINKING_START = 'thinking_start',
|
||||
THINKING_END = 'thinking_end',
|
||||
ERROR = 'error',
|
||||
@@ -116,6 +117,9 @@ export interface MetonaStreamEvent {
|
||||
/** TOOL_CALL_COMPLETE(拼接完成后的完整调用) */
|
||||
toolCall?: MetonaToolCall;
|
||||
|
||||
/** TOOL_RESULT */
|
||||
toolResult?: MetonaToolResult;
|
||||
|
||||
/** USAGE */
|
||||
usage?: MetonaTokenUsage;
|
||||
|
||||
|
||||
+93
-13
@@ -20,6 +20,8 @@ import type { AuditService } from '../services/audit.service';
|
||||
import type { SessionRecorder } from '../services/session-recorder.service';
|
||||
import type { MemoryManager } from '../harness/memory/manager';
|
||||
import type { MCPManager } from '../services/mcp-manager.service';
|
||||
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
|
||||
import type { OutputValidator } from '../harness/verification/output-validator';
|
||||
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import type { MetonaError } from '../harness/types';
|
||||
@@ -35,12 +37,14 @@ export function registerAllIPCHandlers(
|
||||
workspaceService: WorkspaceService,
|
||||
contextBuilder: ContextBuilder,
|
||||
agentLoop: AgentLoopEngine,
|
||||
_toolRegistry: ToolRegistry,
|
||||
toolRegistry: ToolRegistry,
|
||||
auditService: AuditService,
|
||||
sessionRecorder: SessionRecorder,
|
||||
memoryManager: MemoryManager,
|
||||
mcpManager: MCPManager,
|
||||
reloadAdapter: () => void,
|
||||
promptInjectionDefender: PromptInjectionDefender,
|
||||
outputValidator: OutputValidator,
|
||||
): void {
|
||||
|
||||
// ===== Agent 交互 =====
|
||||
@@ -55,11 +59,12 @@ export function registerAllIPCHandlers(
|
||||
auditService.logSessionStart(sessionId);
|
||||
|
||||
// 保存用户消息到数据库
|
||||
// IPC boundary: frontend sends attachments not defined in the MetonaMessage type
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: userMessage.content,
|
||||
attachments: (userMessage as any).attachments,
|
||||
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
|
||||
});
|
||||
|
||||
// 加载历史消息
|
||||
@@ -71,6 +76,8 @@ export function registerAllIPCHandlers(
|
||||
role: m.role as MetonaMessage['role'],
|
||||
content: m.content,
|
||||
reasoningContent: m.reasoningContent,
|
||||
toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
|
||||
toolResult: m.toolResult as MetonaMessage['toolResult'],
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
|
||||
@@ -98,6 +105,40 @@ export function registerAllIPCHandlers(
|
||||
agentLoop.on('stateChange', onStateChange);
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
const injectionResult = promptInjectionDefender.detect(userMessage.content);
|
||||
if (injectionResult.riskScore >= 7) {
|
||||
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
const metonaError: MetonaError = {
|
||||
code: MetonaErrorCode.UNKNOWN,
|
||||
message: `Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
|
||||
retryable: false,
|
||||
};
|
||||
const errorEvent: MetonaStreamEvent = {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: '',
|
||||
sessionId,
|
||||
iteration: 0,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
error: metonaError,
|
||||
};
|
||||
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||||
}
|
||||
// TRACE 层:停止录制
|
||||
sessionRecorder.stopRecording({
|
||||
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({
|
||||
tokenCount: history.reduce((sum, m) => sum + Math.ceil(m.content.length / 2), 0),
|
||||
@@ -107,12 +148,47 @@ export function registerAllIPCHandlers(
|
||||
// 启动 Agent Loop
|
||||
const output = await agentLoop.runStream(userMessage, sessionId, history, systemPrompt);
|
||||
|
||||
// 保存 assistant 回复到数据库
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: output.finalAnswer,
|
||||
});
|
||||
// 输出验证(不阻塞响应,仅记录警告)
|
||||
try {
|
||||
const validation = await outputValidator.validate(output.finalAnswer);
|
||||
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;
|
||||
|
||||
// 将 MetonaToolCall + MetonaToolResult 转换为前端 ToolCallInfo 格式
|
||||
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) {
|
||||
sessionService.saveMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: step.thought.content,
|
||||
reasoningContent: step.thought.reasoningContent || undefined,
|
||||
toolCalls: toolCallsWithResults,
|
||||
iteration: step.iteration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 Token 统计
|
||||
if (output.totalTokenUsage.totalTokens > 0) {
|
||||
@@ -284,8 +360,8 @@ export function registerAllIPCHandlers(
|
||||
try {
|
||||
await mcpManager.removeServer(name);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -293,8 +369,8 @@ export function registerAllIPCHandlers(
|
||||
try {
|
||||
await mcpManager.toggleServer(name, enabled);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -363,7 +439,7 @@ export function registerAllIPCHandlers(
|
||||
// ===== 工具管理 =====
|
||||
|
||||
ipcMain.handle('tools:list', async () => {
|
||||
return _toolRegistry.listTools().map((t) => ({
|
||||
return toolRegistry.listTools().map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
@@ -407,8 +483,10 @@ export function registerAllIPCHandlers(
|
||||
ipcMain.handle('data:clearSessions', async () => {
|
||||
try {
|
||||
const db = sessionService.getDB();
|
||||
db.exec('BEGIN');
|
||||
db.exec('DELETE FROM messages');
|
||||
db.exec('DELETE FROM sessions');
|
||||
db.exec('COMMIT');
|
||||
log.info('[DATA] All sessions cleared');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -433,6 +511,7 @@ export function registerAllIPCHandlers(
|
||||
try {
|
||||
// 审计日志是 INSERT-ONLY,需要先禁用触发器
|
||||
const db = sessionService.getDB();
|
||||
db.exec('BEGIN');
|
||||
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
|
||||
db.exec('DELETE FROM audit_logs');
|
||||
db.exec(`
|
||||
@@ -441,6 +520,7 @@ export function registerAllIPCHandlers(
|
||||
SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY. Deletion is not allowed.');
|
||||
END
|
||||
`);
|
||||
db.exec('COMMIT');
|
||||
log.info('[DATA] Audit logs cleared');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
+15
-3
@@ -46,6 +46,7 @@ import { PolicyEngine } from './harness/sandbox/permissions';
|
||||
import { SandboxManager } from './harness/sandbox/sandbox';
|
||||
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
||||
import { OutputValidator } from './harness/verification/output-validator';
|
||||
import { UpdateService } from './services/update.service';
|
||||
|
||||
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
|
||||
log.transports.file.level = 'info';
|
||||
@@ -98,6 +99,10 @@ async function initialize(): Promise<void> {
|
||||
log.warn('LLM not configured. Please set provider, baseURL, and model in Settings.');
|
||||
}
|
||||
|
||||
if (!apiKey && provider !== 'ollama') {
|
||||
throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`);
|
||||
}
|
||||
|
||||
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model };
|
||||
switch (provider) {
|
||||
case 'agnes': return new AgnesAdapter(adapterConfig);
|
||||
@@ -150,6 +155,7 @@ async function initialize(): Promise<void> {
|
||||
];
|
||||
|
||||
// ===== Agent Loop =====
|
||||
// TODO: Re-read agent config on each runStream call or when config changes
|
||||
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
|
||||
const agentMaxIter = configService.get<number>('agent.maxIterations');
|
||||
const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
|
||||
@@ -187,13 +193,14 @@ async function initialize(): Promise<void> {
|
||||
windowManager.registerGlobalShortcuts();
|
||||
|
||||
// ===== Agent 状态同步到托盘 =====
|
||||
agentLoop.on('stateChange', (data: { previous: string; current: string }) => {
|
||||
agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => {
|
||||
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
||||
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
|
||||
EXECUTING: 'executing', OBSERVING: 'executing', REFLECTING: 'thinking',
|
||||
COMPRESSING: 'thinking', TERMINATED: 'idle',
|
||||
};
|
||||
trayManager?.setStatus(statusMap[data.current] ?? 'idle');
|
||||
const stateValue = (data.current || data.state) ?? '';
|
||||
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
|
||||
});
|
||||
|
||||
// ===== Agent 完成时发送系统通知 =====
|
||||
@@ -210,8 +217,13 @@ async function initialize(): Promise<void> {
|
||||
mainWindow, sessionService, configService, workspaceService,
|
||||
contextBuilder, agentLoop, toolRegistry, auditService,
|
||||
sessionRecorder, memoryManager, mcpManager, createAdapter,
|
||||
promptDefender, outputValidator,
|
||||
);
|
||||
|
||||
// TODO: Initialize UpdateService for auto-update functionality
|
||||
// const updateService = new UpdateService();
|
||||
// updateService.initialize(mainWindow);
|
||||
|
||||
// ===== 应用生命周期 =====
|
||||
app.on('window-all-closed', () => {
|
||||
// macOS: 保持应用运行(托盘模式)
|
||||
@@ -232,7 +244,7 @@ async function initialize(): Promise<void> {
|
||||
windowManager?.unregisterGlobalShortcuts();
|
||||
trayManager?.destroy();
|
||||
windowManager?.closeAll();
|
||||
try { await mcpManager.shutdown(); } catch {}
|
||||
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
|
||||
if (databaseService) { databaseService.close(); databaseService = null; }
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export class ConfigService {
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
const db = this.getDB();
|
||||
const jsonValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const jsonValue = JSON.stringify(value);
|
||||
|
||||
// 查询已有 category,避免 INSERT OR REPLACE 覆盖为默认值
|
||||
const existing = db.prepare('SELECT category FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
|
||||
|
||||
@@ -227,16 +227,24 @@ export class DatabaseService {
|
||||
try {
|
||||
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
|
||||
log.info('[DB] Migration: added attachments column to messages');
|
||||
} catch {
|
||||
// 列已存在,忽略
|
||||
} catch (error) {
|
||||
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('duplicate column')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移 2: audit_logs 表添加 iteration 列
|
||||
try {
|
||||
db.exec('ALTER TABLE audit_logs ADD COLUMN iteration INTEGER');
|
||||
log.info('[DB] Migration: added iteration column to audit_logs');
|
||||
} catch {
|
||||
// 列已存在,忽略
|
||||
} catch (error) {
|
||||
// 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('duplicate column')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export class SessionService {
|
||||
ORDER BY pinned DESC, updated_at DESC
|
||||
`).all(archived ? 1 : 0) as SessionRow[];
|
||||
|
||||
return rows.map(this.toSessionInfo);
|
||||
return rows.map((row) => this.toSessionInfo(row));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,7 +183,7 @@ export class SessionService {
|
||||
ORDER BY created_at ASC
|
||||
`).all(sessionId) as MessageRow[];
|
||||
|
||||
return rows.map(this.toMessageInfo);
|
||||
return rows.map((row) => this.toMessageInfo(row));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,11 +316,22 @@ export class SessionService {
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
reasoningContent: row.reasoning_content ?? undefined,
|
||||
toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined,
|
||||
toolResult: row.tool_result ? JSON.parse(row.tool_result) : undefined,
|
||||
attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
|
||||
toolCalls: row.tool_calls ? this.safeJsonParse(row.tool_calls) as unknown[] : undefined,
|
||||
toolResult: row.tool_result ? this.safeJsonParse(row.tool_result) : undefined,
|
||||
attachments: row.attachments ? this.safeJsonParse(row.attachments) as unknown[] : undefined,
|
||||
iteration: row.iteration ?? undefined,
|
||||
timestamp: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全 JSON 解析:解析失败时返回 undefined 而非抛出异常
|
||||
*/
|
||||
private safeJsonParse(json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ const AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const;
|
||||
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
|
||||
#
|
||||
# 格式版本: 1.0
|
||||
# 创建时间: ${new Date().toISOString()}
|
||||
# 最后更新: ${new Date().toISOString()}
|
||||
# 创建时间: __CREATED_AT__
|
||||
# 最后更新: __UPDATED_AT__
|
||||
# 工作空间: __WORKSPACE_PATH__
|
||||
#
|
||||
# 此文件由 Metona Agent 自动维护,用户可手动编辑。
|
||||
@@ -287,7 +287,11 @@ export class WorkspaceService {
|
||||
|
||||
switch (fileName) {
|
||||
case 'MEMORY.md': {
|
||||
const content = MEMORY_TEMPLATE.replace('__WORKSPACE_PATH__', this.workspacePath);
|
||||
const now = new Date().toISOString();
|
||||
const content = MEMORY_TEMPLATE
|
||||
.replace('__WORKSPACE_PATH__', this.workspacePath)
|
||||
.replace('__CREATED_AT__', now)
|
||||
.replace('__UPDATED_AT__', now);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class HealthChecker {
|
||||
async check(): Promise<HealthReport> {
|
||||
const checks: HealthCheck[] = [];
|
||||
checks.push(await this.checkDatabase());
|
||||
checks.push(await this.checkDiskSpace());
|
||||
checks.push(await this.checkFreeMemory());
|
||||
checks.push(await this.checkMemoryUsage());
|
||||
const allHealthy = checks.every((c) => c.healthy);
|
||||
return { healthy: allHealthy, checks, timestamp: new Date() };
|
||||
@@ -66,28 +66,28 @@ export class HealthChecker {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDiskSpace(): Promise<HealthCheck> {
|
||||
private async checkFreeMemory(): Promise<HealthCheck> {
|
||||
try {
|
||||
const userDataPath = app.getPath('userData');
|
||||
if (!existsSync(userDataPath)) {
|
||||
return { name: 'disk_space', healthy: false, error: 'User data path not accessible' };
|
||||
return { name: 'free_memory', healthy: false, error: 'User data path not accessible' };
|
||||
}
|
||||
// 检查实际可用磁盘空间(Windows/Linux 返回字节数)
|
||||
// 检查实际可用内存(Windows/Linux 返回字节数)
|
||||
const { freemem } = await import('os');
|
||||
const freeBytes = freemem();
|
||||
const freeMB = freeBytes / (1024 * 1024);
|
||||
// 少于 200MB 视为不健康
|
||||
if (freeMB < 200) {
|
||||
return {
|
||||
name: 'disk_space',
|
||||
name: 'free_memory',
|
||||
healthy: false,
|
||||
error: `Free memory critically low: ${freeMB.toFixed(0)}MB available`,
|
||||
};
|
||||
}
|
||||
return { name: 'disk_space', healthy: true };
|
||||
return { name: 'free_memory', healthy: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
name: 'disk_space',
|
||||
name: 'free_memory',
|
||||
healthy: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user