diff --git a/src/lib/engine.ts b/src/lib/engine.ts index c830d59..68b560b 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -5,6 +5,7 @@ import { lockManager } from '../core/lock'; import { actionQueue } from '../core/queue'; import { logInfo, logError } from '../utils'; import { AgentDataValidator, PerformanceProfiler } from './diagnostics'; +import { WikiFormatter } from './formatter'; // ───────────────────────────────────────────── // 1. 에이전트 인터페이스 확장 (Interface Extensibility) @@ -63,6 +64,7 @@ export class MissionState { private _stage: PipelineStage = 'idle'; private _auditTrail: AuditEntry[] = []; private _lastTransitionTime: number = Date.now(); + private _results: Record = {}; private _failureReason?: string; public readonly missionId: string; @@ -127,6 +129,39 @@ export class MissionState { this.saveToDisk(); } + /** + * 중간 결과물을 저장합니다 (Resumption용). + */ + public setResult(key: string, value: string): void { + this._results[key] = value; + this.saveToDisk(); + } + + public getResult(key: string): string | undefined { + return this._results[key]; + } + + /** + * 저장된 미션 상태를 불러옵니다 (Resumption용). + */ + public static loadFromDisk(missionId: string): MissionState | null { + try { + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (!workspacePath) return null; + const filePath = path.join(workspacePath, '.astra', 'missions', `${missionId}.json`); + if (!fs.existsSync(filePath)) return null; + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const state = new MissionState(missionId); + state._stage = data.status; + state._results = data.results || {}; + return state; + } catch (err) { + return null; + } + } + + /** * 전체 미션의 경과 시간을 반환합니다. @@ -168,6 +203,7 @@ export class MissionState { startTime: new Date(this.startTime).toISOString(), totalElapsedMs: this.getElapsedMs(), failureReason: this._failureReason, + results: this._results, transitionCount: this._auditTrail.length, transitions: this._auditTrail.map(e => ({ from: e.from, @@ -322,6 +358,47 @@ export class ErrorClassifier { } } +/** + * CacheManager: 중복 수집 방지 (Deduplication)를 위한 캐시 레이어. + * 동일한 프롬프트와 컨텍스트에 대해 중복된 LLM 호출을 방지합니다. + */ +export class CacheManager { + private static getCacheDir(): string { + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const cacheDir = path.join(workspacePath, '.astra', 'cache'); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + return cacheDir; + } + + private static getHash(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + const char = key.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } + return hash.toString(36); + } + + public static get(prompt: string, context: string): string | null { + const key = this.getHash(prompt + context); + const filePath = path.join(this.getCacheDir(), `${key}.cache`); + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath, 'utf-8'); + } + return null; + } + + public static set(prompt: string, context: string, result: string): void { + const key = this.getHash(prompt + context); + const filePath = path.join(this.getCacheDir(), `${key}.cache`); + fs.writeFileSync(filePath, result, 'utf-8'); + } +} + + // ───────────────────────────────────────────── // 4. AgentEngine 본체 // ───────────────────────────────────────────── @@ -359,8 +436,14 @@ export class AgentEngine { onProgress: (stage: PipelineStage, message: string) => void ): Promise { - // 상태 객체 초기화 - this.state = new MissionState(missionId); + // 0. 상태 복원 시도 (Resumption) + const existingState = MissionState.loadFromDisk(missionId); + if (existingState && existingState.stage !== 'completed') { + logInfo(`[AgentEngine] 기존 미션 발견. '${existingState.stage}' 단계부터 재개합니다.`); + this.state = existingState; + } else { + this.state = new MissionState(missionId); + } // 1. 명시적 락 획득 (Mutex) - 동일 미션의 중복 실행 방지 const release = await lockManager.acquire(`mission_${missionId}`); @@ -371,30 +454,49 @@ export class AgentEngine { logInfo(`[AgentEngine] 미션 시작: ${missionId}`); // --- Phase 1: Planner --- - this.transition('planner', '전략 수립 중...', onProgress); - this.checkAbort(signal); - logInfo(`[AgentEngine] [Planner] Input Prompt: ${this.summarizeLog(prompt, 50)}`); - const plan = await this.resilientExecute( - this.planner, 'Planner', prompt, brainContext, signal, onProgress, - { context: brainContext, signal, config: { role: 'planner' } } - ); + let plan = this.state!.getResult('plan'); + if (!plan) { + this.transition('planner', '전략 수립 중...', onProgress); + this.checkAbort(signal); + + // Deduplication: 동일 프롬프트 캐시 확인 + const cachedPlan = CacheManager.get(prompt, brainContext); + if (cachedPlan) { + logInfo(`[AgentEngine] [Deduplication] 기존 Planner 캐시를 사용합니다.`); + plan = cachedPlan; + } else { + plan = await this.resilientExecute( + this.planner, 'Planner', prompt, brainContext, signal, onProgress, + { context: brainContext, signal, config: { role: 'planner' } } + ); + CacheManager.set(prompt, brainContext, plan); + } + this.state!.setResult('plan', plan); + } this.validateResult(plan, 'Planner'); - logInfo(`[AgentEngine] [Planner] Output: ${this.summarizeLog(plan, 100)}`); - // --- Phase 2 & 3: Parallel Prep + Sequential Execution --- - this.transition('researcher', '핵심 정보 수집 및 분석 중...', onProgress); - this.checkAbort(signal); - logInfo(`[AgentEngine] [Researcher] BrainContext Size: ${brainContext?.length || 0} chars`); + // --- Phase 2 & 3: Researcher --- + let research = this.state!.getResult('research'); + let writerPrep = this.state!.getResult('writerPrep'); - const [research, writerPrep] = await Promise.all([ - this.resilientExecute( - this.researcher, 'Researcher', plan, brainContext, signal, onProgress, - { context: brainContext, signal, config: { role: 'researcher' } } - ), - this.prepareWriterContext(prompt, plan, brainContext) - ]); + if (!research || !writerPrep) { + this.transition('researcher', '핵심 정보 수집 및 분석 중...', onProgress); + this.checkAbort(signal); + + const [res, prep] = await Promise.all([ + research || this.resilientExecute( + this.researcher, 'Researcher', plan, brainContext, signal, onProgress, + { context: brainContext, signal, config: { role: 'researcher' } } + ), + writerPrep || this.prepareWriterContext(prompt, plan, brainContext) + ]); + + research = res; + writerPrep = prep; + this.state!.setResult('research', research); + this.state!.setResult('writerPrep', writerPrep); + } this.validateResult(research, 'Researcher'); - logInfo(`[AgentEngine] [Researcher] Output: ${this.summarizeLog(research, 100)}`); // --- Phase 3: Writer --- this.transition('writer', '최종 리포트 작성 및 편집 중...', onProgress); @@ -404,12 +506,16 @@ export class AgentEngine { { context: brainContext, signal, config: { role: 'writer' }, priorResults: { plan, writerPrep } } ); this.validateResult(finalReport, 'Writer'); - logInfo(`[AgentEngine] [Writer] Output: ${this.summarizeLog(finalReport, 100)}`); + + // 3. 지식 저장 포맷 표준화 (Standardization: Astra 피드백) + const standardizedReport = WikiFormatter.format(finalReport, missionId); this.transition('completed', '미션 완료', onProgress); logInfo(`[AgentEngine] 미션 완료: ${missionId} (총 ${this.state!.getElapsedMs()}ms)`); - return finalReport; + return standardizedReport; }); + + } catch (error: any) { const { type, rule } = ErrorClassifier.classify(error); const stageName = (this.state?.stage || 'unknown').toUpperCase(); diff --git a/src/lib/formatter.ts b/src/lib/formatter.ts new file mode 100644 index 0000000..be18b25 --- /dev/null +++ b/src/lib/formatter.ts @@ -0,0 +1,37 @@ +import * as vscode from 'vscode'; + +export class WikiFormatter { + /** + * 최종 에이전트 출력물을 P-Reinforce v3.0 표준 포맷으로 변환합니다. + */ + public static format(content: string, missionId: string): string { + const now = new Date().toISOString(); + + // 1. Frontmatter가 없는 경우 주입 + let formatted = content; + if (!content.trim().startsWith('---')) { + const frontmatter = [ + '---', + `id: ${missionId}`, + `date: ${now}`, + 'type: knowledge_artifact', + 'standard: P-Reinforce v3.0', + 'tags: [automated, connect_ai, brain_sync]', + '---', + '', + '' + ].join('\n'); + formatted = frontmatter + content; + } + + // 2. 필수 헤더 보정 (예: Brief Summary가 없는 경우 상단에 자동 생성 시도) + if (!formatted.includes('## 📌 Brief Summary')) { + // 간단한 요약 추출 시도 (첫 문장 등) + const summary = content.split('\n').find(l => l.trim().length > 10) || '요약이 생성되지 않았습니다.'; + const summarySection = `## 📌 Brief Summary\n${summary.substring(0, 200)}...\n\n`; + formatted = formatted.replace(/---\n\n/, `---\n\n${summarySection}`); + } + + return formatted; + } +}