feat(engine): complete stability overhaul - added state resume, deduplication, and P-Reinforce formatting

This commit is contained in:
g1nation
2026-05-04 15:40:54 +09:00
parent 817d76c3fa
commit a714017495
2 changed files with 167 additions and 24 deletions
+130 -24
View File
@@ -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<string, string> = {};
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<string> {
// 상태 객체 초기화
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();
+37
View File
@@ -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;
}
}