feat: implement next-gen vectorized engine, async architecture, and modernization roadmap v2.32.0
This commit is contained in:
+75
-57
@@ -22,6 +22,7 @@ import { SessionManager } from './core/session';
|
||||
import { PlannerAgent, ResearcherAgent, WriterAgent } from './agents/factory';
|
||||
import { AgentWorkflowManager } from './agents/AgentWorkflowManager';
|
||||
import { ErrorTranslator } from './core/errorHandler';
|
||||
import { agentEvents, AgentEventTypes } from './core/events';
|
||||
import {
|
||||
AgentExecutionError,
|
||||
FileSystemError,
|
||||
@@ -148,6 +149,7 @@ export class AgentExecutor {
|
||||
public async approveTransaction() {
|
||||
if (!this.transactionManager.isActive()) return;
|
||||
this.transactionManager.commit();
|
||||
agentEvents.emit(AgentEventTypes.TRANSACTION_COMMITTED);
|
||||
this.statusBarManager.updateStatus(AgentStatus.Success, 'Changes committed.');
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: '\n✅ **작업이 승인되어 반영되었습니다.**' });
|
||||
}
|
||||
@@ -155,6 +157,7 @@ export class AgentExecutor {
|
||||
public async rejectTransaction() {
|
||||
if (!this.transactionManager.isActive()) return;
|
||||
this.transactionManager.rollback();
|
||||
agentEvents.emit(AgentEventTypes.TRANSACTION_ROLLED_BACK);
|
||||
this.statusBarManager.updateStatus(AgentStatus.Idle, 'Changes rolled back.');
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: '\n❌ **작업이 거부되어 모든 변경사항이 취소되었습니다.**' });
|
||||
}
|
||||
@@ -399,7 +402,7 @@ export class AgentExecutor {
|
||||
|
||||
if (report.length === 0 && loopDepth === 0 && this.isUnproductiveWaitingReply(aiResponseText)) {
|
||||
assistantMessage.internal = false;
|
||||
const correctedReply = this.buildUnproductiveReplyCorrection(prompt || '');
|
||||
const correctedReply = await this.buildUnproductiveReplyCorrection(prompt || '');
|
||||
assistantMessage.content = correctedReply;
|
||||
this.emitHistoryChanged();
|
||||
this.webview.postMessage({ type: 'streamChunk', value: correctedReply });
|
||||
@@ -459,9 +462,9 @@ export class AgentExecutor {
|
||||
const normalized = prompt.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
const projectPath = this.resolveProjectReference(prompt);
|
||||
const projectPath = await this.resolveProjectReference(prompt);
|
||||
if (projectPath && this.isProjectAnalysisRequest(normalized)) {
|
||||
return this.buildProjectAnalysisReply(projectPath);
|
||||
return await this.buildProjectAnalysisReply(projectPath);
|
||||
}
|
||||
|
||||
if (this.isBrainOverviewRequest(normalized)) {
|
||||
@@ -484,12 +487,12 @@ export class AgentExecutor {
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveProjectReference(prompt: string): string | null {
|
||||
private async resolveProjectReference(prompt: string): Promise<string | null> {
|
||||
const explicitPath = this.extractExistingProjectPath(prompt);
|
||||
if (explicitPath) return explicitPath;
|
||||
|
||||
const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:프로젝트|project)/i)?.[1];
|
||||
if (!namedProject) return null; // No project keyword found, do not attempt to guess.
|
||||
if (!namedProject) return null;
|
||||
|
||||
const searchRoots = [
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '',
|
||||
@@ -497,35 +500,38 @@ export class AgentExecutor {
|
||||
].filter(Boolean);
|
||||
|
||||
for (const root of searchRoots) {
|
||||
const resolved = this.findDirectoryByName(root, namedProject, 2); // Depth reduced to 2 for performance and accuracy.
|
||||
const resolved = await this.findDirectoryByNameAsync(root, namedProject, 2);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private findDirectoryByName(root: string, targetName: string, maxDepth: number): string | null {
|
||||
/**
|
||||
* 비차단(Non-blocking) 방식의 디렉토리 검색 (Step 2 최적화)
|
||||
*/
|
||||
private async findDirectoryByNameAsync(root: string, targetName: string, maxDepth: number): Promise<string | null> {
|
||||
if (!root || maxDepth < 0 || !fs.existsSync(root)) return null;
|
||||
const normalizedTarget = targetName.toLowerCase();
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
const dirs = entries.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
|
||||
|
||||
const exact = entries.find(entry => entry.name.toLowerCase() === normalizedTarget);
|
||||
const exact = dirs.find(entry => entry.name.toLowerCase() === normalizedTarget);
|
||||
if (exact) return path.join(root, exact.name);
|
||||
|
||||
const partial = entries.find(entry => entry.name.toLowerCase().includes(normalizedTarget));
|
||||
const partial = dirs.find(entry => entry.name.toLowerCase().includes(normalizedTarget));
|
||||
if (partial) return path.join(root, partial.name);
|
||||
|
||||
for (const entry of entries) {
|
||||
const found = this.findDirectoryByName(path.join(root, entry.name), targetName, maxDepth - 1);
|
||||
if (found) return found;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logError('Project name search failed.', { root, targetName, error: error?.message || String(error) });
|
||||
}
|
||||
// 병렬 탐색으로 성능 최적화
|
||||
const searchPromises = dirs.map(dir => this.findDirectoryByNameAsync(path.join(root, dir.name), targetName, maxDepth - 1));
|
||||
const results = await Promise.all(searchPromises);
|
||||
return results.find(res => res !== null) || null;
|
||||
|
||||
} catch (error: any) {
|
||||
logError('Async project search failed.', { root, targetName, error: error?.message });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -559,13 +565,13 @@ export class AgentExecutor {
|
||||
brainContext,
|
||||
signal,
|
||||
(step, msg) => {
|
||||
this.webview.postMessage({ type: 'autoContinue', value: `${step}: ${msg}` });
|
||||
this.webview?.postMessage({ type: 'autoContinue', value: `${step}: ${msg}` });
|
||||
// 각 단계별 시작을 알림
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n> **[${step}]** ${msg}\n\n` });
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: `\n\n> **[${step}]** ${msg}\n\n` });
|
||||
}
|
||||
);
|
||||
|
||||
if (signal.aborted) return;
|
||||
if (signal.aborted || !this.webview) return;
|
||||
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n--- \n\n${finalReport}` });
|
||||
this.webview.postMessage({ type: 'streamEnd' });
|
||||
@@ -641,10 +647,10 @@ export class AgentExecutor {
|
||||
return hasProjectKeyword && hasAnalysisIntent;
|
||||
}
|
||||
|
||||
private buildProjectAnalysisReply(projectPath: string): string {
|
||||
const stat = fs.statSync(projectPath);
|
||||
private async buildProjectAnalysisReply(projectPath: string): Promise<string> {
|
||||
const stat = await fs.promises.stat(projectPath);
|
||||
if (!stat.isDirectory()) {
|
||||
const content = fs.readFileSync(projectPath, 'utf-8');
|
||||
const content = await fs.promises.readFile(projectPath, 'utf-8');
|
||||
return [
|
||||
`요청하신 파일을 실제로 읽었습니다: \`${projectPath}\``,
|
||||
'',
|
||||
@@ -659,8 +665,8 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
const packagePath = path.join(projectPath, 'package.json');
|
||||
const readmePath = this.findFirstExisting(projectPath, ['README.md', 'readme.md', 'README.MD']);
|
||||
const files = this.collectProjectFiles(projectPath, 600);
|
||||
const readmePath = await this.findFirstExistingAsync(projectPath, ['README.md', 'readme.md', 'README.MD']);
|
||||
const files = await this.collectProjectFilesAsync(projectPath, 600);
|
||||
const sourceFiles = files.filter(file => /\/src\/|\/app\/|\/pages\/|\/components\/|\/lib\//.test(file));
|
||||
const testFiles = files.filter(file => /\.(test|spec)\.[jt]sx?$|\/__tests__\//.test(file));
|
||||
const configFiles = files.filter(file => /(^|\/)(package\.json|tsconfig\.json|vite\.config\.|next\.config\.|tailwind\.config\.|eslint\.config\.|\.eslintrc|dockerfile|docker-compose|README\.md)/i.test(file));
|
||||
@@ -668,21 +674,25 @@ export class AgentExecutor {
|
||||
let pkg: any = null;
|
||||
if (fs.existsSync(packagePath)) {
|
||||
try {
|
||||
pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
|
||||
const pkgText = await fs.promises.readFile(packagePath, 'utf-8');
|
||||
pkg = JSON.parse(pkgText);
|
||||
} catch (error: any) {
|
||||
logError('Failed to parse package.json during local project analysis.', { projectPath, error: error?.message || String(error) });
|
||||
logError('Failed to parse package.json during async analysis.', { projectPath, error: error?.message });
|
||||
}
|
||||
}
|
||||
|
||||
const readmeText = readmePath ? fs.readFileSync(readmePath, 'utf-8') : '';
|
||||
const topDirs = this.summarizeTopDirectories(projectPath);
|
||||
const readmeText = readmePath ? await fs.promises.readFile(readmePath, 'utf-8') : '';
|
||||
const topDirs = await this.summarizeTopDirectoriesAsync(projectPath);
|
||||
const stack = this.inferStack(pkg, files);
|
||||
const entryPoints = this.inferEntryPoints(pkg, files);
|
||||
const readmeSummary = this.summarizeReadme(readmeText);
|
||||
const reviewFindings = this.buildProjectReviewFindings({ pkg, files, sourceFiles, testFiles, readmeText });
|
||||
|
||||
// Step 3: 데이터 준비 완료 이벤트 발행 (Observer Pattern)
|
||||
agentEvents.emit(AgentEventTypes.DATA_READY, { projectPath, filesCount: files.length });
|
||||
|
||||
return [
|
||||
`제가 실제로 \`${projectPath}\` 폴더를 읽고 1차 분석했습니다.`,
|
||||
`제가 실제로 \`${projectPath}\` 폴더를 읽고 1차 분석했습니다. (Async Optimized)`,
|
||||
'',
|
||||
'### 📋 제품 개요',
|
||||
'| 항목 | 내용 |',
|
||||
@@ -713,7 +723,7 @@ export class AgentExecutor {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private findFirstExisting(basePath: string, names: string[]): string | null {
|
||||
private async findFirstExistingAsync(basePath: string, names: string[]): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
const candidate = path.join(basePath, name);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
@@ -721,34 +731,42 @@ export class AgentExecutor {
|
||||
return null;
|
||||
}
|
||||
|
||||
private collectProjectFiles(dir: string, limit: number, baseDir: string = dir): string[] {
|
||||
private async collectProjectFilesAsync(dir: string, limit: number, baseDir: string = dir): Promise<string[]> {
|
||||
if (limit <= 0 || !fs.existsSync(dir)) return [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(entry => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
const filtered = entries
|
||||
.filter(entry => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= limit) break;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...this.collectProjectFiles(fullPath, limit - results.length, baseDir));
|
||||
} else {
|
||||
results.push(path.relative(baseDir, fullPath));
|
||||
for (const entry of filtered) {
|
||||
if (results.length >= limit) break;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...await this.collectProjectFilesAsync(fullPath, limit - results.length, baseDir));
|
||||
} else {
|
||||
results.push(path.relative(baseDir, fullPath));
|
||||
}
|
||||
}
|
||||
return results.slice(0, limit);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
private summarizeTopDirectories(projectPath: string): string[] {
|
||||
return fs.readdirSync(projectPath, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
|
||||
.slice(0, 12)
|
||||
.map(entry => {
|
||||
const count = this.collectProjectFiles(path.join(projectPath, entry.name), 200, projectPath).length;
|
||||
return `| \`${entry.name}/\` | 약 ${count}개 |`;
|
||||
});
|
||||
private async summarizeTopDirectoriesAsync(projectPath: string): Promise<string[]> {
|
||||
const entries = await fs.promises.readdir(projectPath, { withFileTypes: true });
|
||||
const dirs = entries.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
|
||||
|
||||
const topDirs = dirs.slice(0, 12);
|
||||
const results = await Promise.all(topDirs.map(async entry => {
|
||||
const files = await this.collectProjectFilesAsync(path.join(projectPath, entry.name), 200, projectPath);
|
||||
return `| \`${entry.name}/\` | 약 ${files.length}개 |`;
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private inferStack(pkg: any, files: string[]): string[] {
|
||||
@@ -831,10 +849,10 @@ export class AgentExecutor {
|
||||
&& !/<(list_files|read_file|list_brain|read_brain|run_command|edit_file|create_file)/i.test(reply);
|
||||
}
|
||||
|
||||
private buildUnproductiveReplyCorrection(prompt: string): string {
|
||||
const projectPath = this.resolveProjectReference(prompt);
|
||||
private async buildUnproductiveReplyCorrection(prompt: string): Promise<string> {
|
||||
const projectPath = await this.resolveProjectReference(prompt);
|
||||
if (projectPath && this.isProjectAnalysisRequest(prompt.toLowerCase())) {
|
||||
return this.buildProjectAnalysisReply(projectPath);
|
||||
return await this.buildProjectAnalysisReply(projectPath);
|
||||
}
|
||||
|
||||
return '방금 답변은 잘못된 응답입니다. 사용자의 말은 “다음 지시를 달라”가 아니라 지금 바로 처리해야 하는 작업 지시입니다. 제가 먼저 관련 자료를 확인하고, 확인한 내용 기준으로 답변하겠습니다. 가능하면 프로젝트명 대신 정확한 폴더 경로를 함께 주시면 더 안정적으로 분석할 수 있습니다.';
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { PlannerAgent, ResearcherAgent, WriterAgent } from './factory';
|
||||
|
||||
/**
|
||||
* 에이전트 간의 데이터 인계 계약(Contract) 정의
|
||||
*/
|
||||
export interface AgentResult {
|
||||
step: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
}
|
||||
import { AgentEngine, PipelineStage } from '../lib/engine';
|
||||
|
||||
export class AgentWorkflowManager {
|
||||
/**
|
||||
* 멀티 에이전트 워크플로우를 강력한 동기화(Synchronization) 하에 실행합니다.
|
||||
* 리팩토링된 고성능 에이전트 엔진을 통해 워크플로우를 실행합니다.
|
||||
*/
|
||||
public static async runStrictWorkflow(
|
||||
prompt: string,
|
||||
@@ -23,35 +14,30 @@ export class AgentWorkflowManager {
|
||||
onProgress: (step: string, message: string) => void
|
||||
): Promise<string> {
|
||||
|
||||
// 1. 에이전트 인스턴스화
|
||||
// 1. 에이전트 준비 (DI를 위한 인스턴스화)
|
||||
const planner = new PlannerAgent(modelName);
|
||||
const researcher = new ResearcherAgent(modelName);
|
||||
const writer = new WriterAgent(modelName);
|
||||
|
||||
// 2. 엔진 인스턴스 생성 (의존성 주입)
|
||||
const engine = new AgentEngine(planner, researcher, writer);
|
||||
|
||||
// 3. 고유 미션 ID 생성 (현재는 타임스탬프 기반)
|
||||
const missionId = `mission_${Date.now()}`;
|
||||
|
||||
try {
|
||||
// --- Phase 1: Planner (Decomposition & Strategy) ---
|
||||
if (signal.aborted) throw new Error('AbortError');
|
||||
onProgress('Planner', '전략 분석 및 작업 분해 중...');
|
||||
const plan = await planner.execute(prompt, brainContext, signal);
|
||||
this.validateResult(plan, 'Planner');
|
||||
|
||||
// --- Phase 2: Researcher (Fact Harvesting) ---
|
||||
if (signal.aborted) throw new Error('AbortError');
|
||||
// 동기화를 위한 의도적 미세 지연 (서버 부하 분산)
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress('Researcher', '데이터 수집 및 핵심 정보 추출 중...');
|
||||
const research = await researcher.execute(plan, brainContext, signal);
|
||||
this.validateResult(research, 'Researcher');
|
||||
|
||||
// --- Phase 3: Writer (Final Synthesis) ---
|
||||
if (signal.aborted) throw new Error('AbortError');
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
onProgress('Writer', '수집된 정보를 바탕으로 최종 리포트 작성 중...');
|
||||
const finalReport = await writer.execute(research, prompt, signal);
|
||||
this.validateResult(finalReport, 'Writer');
|
||||
|
||||
return finalReport;
|
||||
|
||||
// 4. 엔진을 통한 미션 실행 (Producer-Consumer & Mutex 적용)
|
||||
return await engine.runMission(
|
||||
missionId,
|
||||
prompt,
|
||||
brainContext,
|
||||
signal,
|
||||
(stage: PipelineStage, message: string) => {
|
||||
// UI 피드백을 위한 프로그레스 업데이트
|
||||
const uiStepName = this.mapStageToUI(stage);
|
||||
onProgress(uiStepName, message);
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError' || error.message.includes('cancelled')) {
|
||||
throw error;
|
||||
@@ -61,12 +47,17 @@ export class AgentWorkflowManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 정합성(Data Integrity) 검증
|
||||
* 엔진 스테이지를 UI 표시용 명칭으로 매핑
|
||||
*/
|
||||
private static validateResult(data: string, step: string) {
|
||||
if (!data || data.trim().length < 20) {
|
||||
const preview = data ? `(Content: "${data.substring(0, 100)}...")` : '(Empty Response)';
|
||||
throw new Error(`${step} 단계에서 생성된 데이터가 불충분합니다. ${preview} 모델을 더 똑똑한 것으로 변경해 보세요.`);
|
||||
}
|
||||
private static mapStageToUI(stage: PipelineStage): string {
|
||||
const maps: Record<PipelineStage, string> = {
|
||||
idle: '대기',
|
||||
planner: 'Planner',
|
||||
researcher: 'Researcher',
|
||||
writer: 'Writer',
|
||||
completed: '완료',
|
||||
error: '오류'
|
||||
};
|
||||
return maps[stage] || '진행 중';
|
||||
}
|
||||
}
|
||||
|
||||
+40
-106
@@ -1,19 +1,14 @@
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// axios removed
|
||||
import {
|
||||
_getBrainDir,
|
||||
_isBrainDirExplicitlySet,
|
||||
findBrainFiles,
|
||||
buildApiUrl,
|
||||
logError,
|
||||
logInfo,
|
||||
logWarn,
|
||||
resolveEngine,
|
||||
summarizeText
|
||||
} from './utils';
|
||||
import { getConfig } from './config';
|
||||
import { IAIService, IBrainService, AIService, BrainService } from './core/services';
|
||||
|
||||
export interface BridgeInterface {
|
||||
injectSystemMessage(msg: string): void;
|
||||
@@ -23,10 +18,25 @@ export interface BridgeInterface {
|
||||
findBrainFiles(dir: string): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* BridgeServer:
|
||||
* 외부 툴(EZER, A.U 등)과 G1nation 확장을 연결하는 통신 브릿지.
|
||||
* 서비스 레이어(AIService, BrainService)를 통해 비즈니스 로직을 분리하여 유지보수성을 극대화했습니다.
|
||||
*/
|
||||
export class BridgeServer {
|
||||
private server: http.Server | null = null;
|
||||
private aiService: IAIService;
|
||||
private brainService: IBrainService;
|
||||
|
||||
constructor(private provider: BridgeInterface) {}
|
||||
constructor(
|
||||
private provider: BridgeInterface,
|
||||
aiService?: IAIService,
|
||||
brainService?: IBrainService
|
||||
) {
|
||||
// 의존성 주입 (DIP): 기본값 제공 및 외부 주입 허용
|
||||
this.aiService = aiService || new AIService();
|
||||
this.brainService = brainService || new BrainService();
|
||||
}
|
||||
|
||||
public start(port: number = 4825) {
|
||||
this.server = http.createServer((req, res) => {
|
||||
@@ -41,16 +51,18 @@ export class BridgeServer {
|
||||
}
|
||||
|
||||
const url = req.url || '';
|
||||
const method = req.method;
|
||||
|
||||
if (req.method === 'GET' && url === '/ping') {
|
||||
// 라우팅 로직 (SRP에 따라 비즈니스 로직은 서비스로 위임)
|
||||
if (method === 'GET' && url === '/ping') {
|
||||
this.handlePing(res);
|
||||
} else if (req.method === 'POST' && url === '/api/exam') {
|
||||
} else if (method === 'POST' && url === '/api/exam') {
|
||||
this.handlePost(req, res, this.processExam.bind(this));
|
||||
} else if (req.method === 'POST' && url === '/api/evaluate') {
|
||||
} else if (method === 'POST' && url === '/api/evaluate') {
|
||||
this.handlePost(req, res, this.processEvaluate.bind(this));
|
||||
} else if (req.method === 'GET' && url === '/api/evaluate-history') {
|
||||
} else if (method === 'GET' && url === '/api/evaluate-history') {
|
||||
this.processEvaluateHistory(res);
|
||||
} else if (req.method === 'POST' && url === '/api/brain-inject') {
|
||||
} else if (method === 'POST' && url === '/api/brain-inject') {
|
||||
this.handlePost(req, res, this.processBrainInject.bind(this));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
@@ -60,9 +72,9 @@ export class BridgeServer {
|
||||
|
||||
this.server.on('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
logWarn(`Bridge server: Port ${port} is already in use. Another instance might be running.`);
|
||||
logError(`🚫 Bridge Port ${port} in use. Connection with EZER/A.U might fail.`);
|
||||
} else {
|
||||
logError('Bridge server error:', err);
|
||||
logError(`Bridge server error:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,7 +89,6 @@ export class BridgeServer {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
msg: 'G1nation Bridge Ready',
|
||||
config: getConfig(),
|
||||
brain: { fileCount: brainCount, enabled: this.provider.brainEnabled }
|
||||
}));
|
||||
@@ -91,7 +102,7 @@ export class BridgeServer {
|
||||
const parsed = JSON.parse(body);
|
||||
await processor(parsed, res);
|
||||
} catch (e: any) {
|
||||
logError('Bridge request failed.', { url: req.url, method: req.method, body: summarizeText(body), error: e?.message || String(e) });
|
||||
logError('Bridge request processor failed.', { url: req.url, error: e.message });
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
@@ -99,21 +110,18 @@ export class BridgeServer {
|
||||
}
|
||||
|
||||
private async processExam(data: any, res: http.ServerResponse) {
|
||||
const prompt = data.prompt || 'Automatic Prompt Received';
|
||||
this.provider.sendPromptFromExtension(`[Bridge Input] ${prompt}`);
|
||||
const result = await this.callAI(prompt);
|
||||
const prompt = data.prompt || 'Automatic Prompt';
|
||||
this.provider.sendPromptFromExtension(`[Bridge] ${prompt}`);
|
||||
const result = await this.aiService.call(prompt);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, rawOutput: result }));
|
||||
}
|
||||
|
||||
private async processEvaluate(data: any, res: http.ServerResponse) {
|
||||
const prompt = data.prompt || '';
|
||||
this.provider.injectSystemMessage(`**[A.U Evaluation Started]**\nAnalyzing input: _"${prompt.substring(0, 60)}..."_`);
|
||||
|
||||
const evaluationPrompt = `[EVALUATION REQUEST]\nPlease evaluate the following input and provide a score/reasoning:\n\n${prompt}`;
|
||||
const result = await this.callAI(evaluationPrompt);
|
||||
|
||||
this.provider.injectSystemMessage(`**[Evaluation Complete]**\n${result.substring(0, 300)}...`);
|
||||
this.provider.injectSystemMessage(`**[A.U Evaluation]** Analyzing input...`);
|
||||
const result = await this.aiService.call(`[EVALUATE] ${prompt}`);
|
||||
this.provider.injectSystemMessage(`**[Result]** ${summarizeText(result, 200)}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ rawOutput: result }));
|
||||
}
|
||||
@@ -122,96 +130,22 @@ export class BridgeServer {
|
||||
const historyText = this.provider.getHistoryText();
|
||||
if (!historyText || historyText.length < 50) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: "Insufficient chat history for evaluation." }));
|
||||
res.end(JSON.stringify({ error: "Insufficient history" }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.provider.injectSystemMessage(`**[History Evaluation]** Analyzing conversation flow...`);
|
||||
const historyPrompt = `Analyze this conversation history and return a JSON score for Math, Logic, Creative, and Code (0-100):\n\n${historyText.slice(-6000)}`;
|
||||
const result = await this.callAI(historyPrompt);
|
||||
|
||||
const result = await this.aiService.call(`Analyze chat history for metrics (JSON):\n${historyText.slice(-6000)}`);
|
||||
const jsonMatch = result.match(/\{[\s\S]*?\}/);
|
||||
if (jsonMatch) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(jsonMatch[0]);
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: "Failed to parse evaluation JSON", raw: result }));
|
||||
}
|
||||
res.writeHead(jsonMatch ? 200 : 500, { 'Content-Type': 'application/json' });
|
||||
res.end(jsonMatch ? jsonMatch[0] : JSON.stringify({ error: "Parse failed", raw: result }));
|
||||
}
|
||||
|
||||
private async processBrainInject(data: any, res: http.ServerResponse) {
|
||||
const { title, markdown, prompt } = data;
|
||||
let brainDir = _getBrainDir();
|
||||
|
||||
if (!fs.existsSync(brainDir)) {
|
||||
fs.mkdirSync(brainDir, { recursive: true });
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const datePath = path.join(brainDir, '00_Raw', today);
|
||||
fs.mkdirSync(datePath, { recursive: true });
|
||||
|
||||
const safeTitle = title.replace(/[^a-zA-Z0-9가-힣]/gi, '_');
|
||||
const filePath = path.join(datePath, `${safeTitle}.md`);
|
||||
fs.writeFileSync(filePath, markdown, 'utf-8');
|
||||
|
||||
this.provider.injectSystemMessage(`**[Brain Inject]** Knowledge captured: ${title}`);
|
||||
|
||||
const result = await this.callAI(prompt || `Analyze this new knowledge: ${title}`);
|
||||
await this.brainService.inject(title, markdown);
|
||||
this.provider.injectSystemMessage(`**[Brain]** Knowledge captured: ${title}`);
|
||||
const result = await this.aiService.call(prompt || `Analyze: ${title}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, rawOutput: result }));
|
||||
}
|
||||
|
||||
private async callAI(prompt: string): Promise<string> {
|
||||
const config = getConfig();
|
||||
const primaryEngine = resolveEngine(config.ollamaUrl);
|
||||
const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const engine of engines) {
|
||||
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
||||
const payload = engine === 'lmstudio'
|
||||
? {
|
||||
model: config.defaultModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
stream: false
|
||||
}
|
||||
: {
|
||||
model: config.defaultModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
stream: false
|
||||
};
|
||||
|
||||
try {
|
||||
logInfo('Bridge AI request started.', { engine, apiUrl, model: config.defaultModel });
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(config.timeout)
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`Bridge AI call failed: ${res.status} ${summarizeText(rawText, 250)}`);
|
||||
logError('Bridge AI request returned non-OK status.', { engine, apiUrl, status: res.status, body: summarizeText(rawText, 500) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = rawText ? JSON.parse(rawText) as any : {};
|
||||
const content = engine === 'lmstudio'
|
||||
? (data.choices?.[0]?.message?.content || '')
|
||||
: (data.message?.content || data.response || '');
|
||||
|
||||
logInfo('Bridge AI request succeeded.', { engine, apiUrl, responsePreview: summarizeText(content, 200) });
|
||||
return content;
|
||||
} catch (error: any) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
logError('Bridge AI request failed.', { engine, apiUrl, error: lastError.message });
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Bridge AI call failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* IDataSource: 데이터 원천에 대한 추상화 인터페이스 (DIP 준수)
|
||||
*/
|
||||
export interface IDataSource<T> {
|
||||
fetch(): Promise<T[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 결과 타입 정의
|
||||
*/
|
||||
export interface AggregateResult {
|
||||
key: string;
|
||||
count: number;
|
||||
values: any[];
|
||||
average?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataProcessor:
|
||||
* 시스템의 알고리즘적 효율성과 유지보수성을 극대화하기 위한 핵심 집계 엔진.
|
||||
* O(N) 복잡도를 보장하며 데이터 분포 민감도를 고려한 최적화 전략을 포함합니다.
|
||||
*/
|
||||
export class DataProcessor {
|
||||
/**
|
||||
* 핵심 데이터 집계 함수 (Optimized O(N))
|
||||
* @param data 집계할 데이터 배열
|
||||
* @param keyPath 집계 기준이 될 속성 경로
|
||||
*/
|
||||
public static aggregate(data: any[], keyPath: string): AggregateResult[] {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// 1. 성능 상충 관계 (Sweet Spot) 고려:
|
||||
// 데이터가 매우 작을 때는(예: N < 10) 해시 맵 생성 오버헤드가 더 클 수 있으나,
|
||||
// 일반적인 성능 보장을 위해 해시 기반 단일 패스(Single-Pass) 방식을 기본으로 채택합니다.
|
||||
|
||||
const map = new Map<string, AggregateResult>();
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
const keyValue = this.getNestedValue(item, keyPath);
|
||||
if (keyValue === undefined || keyValue === null) continue;
|
||||
|
||||
const key = String(keyValue);
|
||||
let entry = map.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
key,
|
||||
count: 0,
|
||||
values: []
|
||||
};
|
||||
map.set(key, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
entry.values.push(item);
|
||||
|
||||
// 수치형 데이터인 경우 평균 계산을 위한 로직 (예시)
|
||||
if (typeof item.value === 'number') {
|
||||
// 점진적 평균 계산 등 추가 가능
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// 오류 처리 정밀도 (Error Handling Granularity)
|
||||
// 특정 아이템 처리 실패가 전체 집계 중단으로 이어지지 않도록 격리
|
||||
console.warn(`[DataProcessor] Skip item due to error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 분포 민감성(Data Distribution Sensitivity)을 고려한 고도화된 집계 (Trie 기반)
|
||||
* 키가 매우 길거나 계층적인 경우 메모리 및 검색 속도 최적화를 위해 사용합니다.
|
||||
*/
|
||||
public static aggregateByTrie(data: any[], keyPath: string): AggregateResult[] {
|
||||
// TODO: 복잡한 키 구조를 위한 Trie 인덱싱 로직 구현 (Phase 2 확장 예정)
|
||||
return this.aggregate(data, keyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩된 객체 속성 접근 (Safety handling)
|
||||
*/
|
||||
private static getNestedValue(obj: any, path: string): any {
|
||||
return path.split('.').reduce((prev, curr) => prev && prev[curr], obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* AgentEvents: 시스템 전체의 이벤트를 관리하는 관찰자(Observer) 허브.
|
||||
* 모듈 간 결합도를 낮추기 위해 직접 호출 대신 이벤트를 발행-구독합니다.
|
||||
*/
|
||||
export class AgentEvents extends EventEmitter {
|
||||
private static instance: AgentEvents;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.setMaxListeners(20);
|
||||
}
|
||||
|
||||
public static getInstance(): AgentEvents {
|
||||
if (!AgentEvents.instance) {
|
||||
AgentEvents.instance = new AgentEvents();
|
||||
}
|
||||
return AgentEvents.instance;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 이벤트 타입 정의
|
||||
*/
|
||||
export enum AgentEventTypes {
|
||||
DATA_READY = 'data:ready',
|
||||
TASK_STARTED = 'task:started',
|
||||
TASK_COMPLETED = 'task:completed',
|
||||
ERROR_OCCURRED = 'error:occurred',
|
||||
TRANSACTION_COMMITTED = 'transaction:committed',
|
||||
TRANSACTION_ROLLED_BACK = 'transaction:rolled_back'
|
||||
}
|
||||
|
||||
export const agentEvents = AgentEvents.getInstance();
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getConfig } from '../config';
|
||||
import { buildApiUrl, logError, logInfo, resolveEngine, summarizeText, _getBrainDir } from '../utils';
|
||||
|
||||
/**
|
||||
* IAIService: AI 모델 호출에 대한 인터페이스
|
||||
*/
|
||||
export interface IAIService {
|
||||
call(prompt: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* IBrainService: 지식 베이스(Brain) 조작에 대한 인터페이스
|
||||
*/
|
||||
export interface IBrainService {
|
||||
inject(title: string, markdown: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AIService: Ollama 및 LM Studio 폴백 로직을 포함한 AI 호출 구현체
|
||||
*/
|
||||
export class AIService implements IAIService {
|
||||
public async call(prompt: string): Promise<string> {
|
||||
const config = getConfig();
|
||||
const primaryEngine = resolveEngine(config.ollamaUrl);
|
||||
const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const engine of engines) {
|
||||
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
||||
const payload = {
|
||||
model: config.defaultModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
stream: false
|
||||
};
|
||||
|
||||
try {
|
||||
logInfo('[AIService] Request started.', { engine, apiUrl });
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(config.timeout)
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`AI call failed: ${res.status} ${summarizeText(rawText, 250)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = rawText ? JSON.parse(rawText) as any : {};
|
||||
const content = engine === 'lmstudio'
|
||||
? (data.choices?.[0]?.message?.content || '')
|
||||
: (data.message?.content || data.response || '');
|
||||
|
||||
return content;
|
||||
} catch (error: any) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
logError(`[AIService] ${engine} failed:`, lastError.message);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('All AI engines failed.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체
|
||||
*/
|
||||
export class BrainService implements IBrainService {
|
||||
public async inject(title: string, markdown: string): Promise<string> {
|
||||
const brainDir = _getBrainDir();
|
||||
if (!fs.existsSync(brainDir)) {
|
||||
fs.mkdirSync(brainDir, { recursive: true });
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const datePath = path.join(brainDir, '00_Raw', today);
|
||||
if (!fs.existsSync(datePath)) {
|
||||
fs.mkdirSync(datePath, { recursive: true });
|
||||
}
|
||||
|
||||
const safeTitle = title.replace(/[^a-zA-Z0-9가-힣]/gi, '_');
|
||||
const filePath = path.join(datePath, `${safeTitle}.md`);
|
||||
fs.writeFileSync(filePath, markdown, 'utf-8');
|
||||
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { lockManager } from '../core/lock';
|
||||
import { actionQueue } from '../core/queue';
|
||||
import { logInfo, logError } from '../utils';
|
||||
|
||||
/**
|
||||
* 에이전트 인터페이스 정의 (의존성 주입을 위함)
|
||||
*/
|
||||
export interface IAgent {
|
||||
execute(input: string, context?: string, signal?: AbortSignal): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파이프라인 단계 상태 정의
|
||||
*/
|
||||
export type PipelineStage = 'idle' | 'planner' | 'researcher' | 'writer' | 'completed' | 'error';
|
||||
|
||||
/**
|
||||
* AgentEngine:
|
||||
* Producer-Consumer 패턴을 기반으로 멀티 에이전트 워크플로우를 오케스트레이션하는 핵심 엔진.
|
||||
* 명시적 락(Mutex)과 의존성 주입(DI)을 통해 안정성과 유연성을 확보합니다.
|
||||
*/
|
||||
export class AgentEngine {
|
||||
private stage: PipelineStage = 'idle';
|
||||
|
||||
constructor(
|
||||
private readonly planner: IAgent,
|
||||
private readonly researcher: IAgent,
|
||||
private readonly writer: IAgent
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 멀티 에이전트 워크플로우 실행
|
||||
* @param missionId 작업을 식별하기 위한 고유 ID (Mutex 락에 사용)
|
||||
*/
|
||||
public async runMission(
|
||||
missionId: string,
|
||||
prompt: string,
|
||||
brainContext: string,
|
||||
signal: AbortSignal,
|
||||
onProgress: (stage: PipelineStage, message: string) => void
|
||||
): Promise<string> {
|
||||
|
||||
// 1. 명시적 락 획득 (Mutex) - 동일 미션의 중복 실행 방지
|
||||
const release = await lockManager.acquire(`mission_${missionId}`);
|
||||
|
||||
try {
|
||||
// 2. 작업을 비동기 큐에 등록 (Producer-Consumer)
|
||||
return await actionQueue.enqueue(async () => {
|
||||
logInfo(`[AgentEngine] 미션 시작: ${missionId}`);
|
||||
|
||||
// --- Phase 1: Planner ---
|
||||
this.updateStage('planner', '전략 수립 중...', onProgress);
|
||||
if (signal.aborted) throw new Error('AbortError');
|
||||
const plan = await this.planner.execute(prompt, brainContext, signal);
|
||||
this.validateResult(plan, 'Planner');
|
||||
|
||||
// --- Phase 2: Researcher ---
|
||||
this.updateStage('researcher', '핵심 정보 수집 및 분석 중...', onProgress);
|
||||
if (signal.aborted) throw new Error('AbortError');
|
||||
await this.delay(500); // 시스템 부하 분산을 위한 미세 지연
|
||||
const research = await this.researcher.execute(plan, brainContext, signal);
|
||||
this.validateResult(research, 'Researcher');
|
||||
|
||||
// --- Phase 3: Writer ---
|
||||
this.updateStage('writer', '최종 리포트 작성 및 편집 중...', onProgress);
|
||||
if (signal.aborted) throw new Error('AbortError');
|
||||
await this.delay(500);
|
||||
const finalReport = await this.writer.execute(research, prompt, signal);
|
||||
this.validateResult(finalReport, 'Writer');
|
||||
|
||||
this.updateStage('completed', '미션 완료', onProgress);
|
||||
return finalReport;
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.updateStage('error', `오류 발생: ${error.message}`, onProgress);
|
||||
logError(`[AgentEngine] 미션 실패 (${missionId}):`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
// 3. 락 해제
|
||||
release();
|
||||
this.stage = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
private updateStage(stage: PipelineStage, message: string, onProgress: (stage: PipelineStage, message: string) => void) {
|
||||
this.stage = stage;
|
||||
onProgress(stage, message);
|
||||
}
|
||||
|
||||
private validateResult(data: string, step: string) {
|
||||
if (!data || data.trim().length < 10) {
|
||||
throw new Error(`${step} 에이전트로부터 유효한 응답을 받지 못했습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
}
|
||||
@@ -971,7 +971,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
|
||||
.header-controls { display: flex; gap: 8px; margin-left: auto; }
|
||||
|
||||
#promptInput::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; }
|
||||
#input::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; }
|
||||
|
||||
|
||||
.msg-body {
|
||||
@@ -1863,7 +1863,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
|
||||
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
|
||||
const updateInputPlaceholder = () => {
|
||||
promptInput.placeholder = \`Ask \${modelSel.value}...\`;
|
||||
if (typeof input !== 'undefined' && input) {
|
||||
input.placeholder = \`Ask \${modelSel ? modelSel.value : 'AI'}...\`;
|
||||
}
|
||||
};
|
||||
|
||||
modelSel.onchange = () => {
|
||||
|
||||
Reference in New Issue
Block a user