feat: ConnectAI structural hardening and retrieval precision improvements

This commit is contained in:
g1nation
2026-05-05 21:37:45 +09:00
parent c2f17cfb03
commit 466e9e4d5f
17 changed files with 424 additions and 160 deletions
+123 -72
View File
@@ -1,11 +1,14 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import { lockManager } from '../core/lock';
import { actionQueue } from '../core/queue';
import { logInfo, logError } from '../utils';
import { AgentDataValidator, PerformanceProfiler, CognitionAudit } from './diagnostics';
import { WikiFormatter } from './formatter';
import { ErrorType, RecoveryRule } from '../types/interfaces';
export { ErrorType, RecoveryRule };
// ─────────────────────────────────────────────
// 1. 에이전트 인터페이스 확장 (Interface Extensibility)
@@ -73,6 +76,7 @@ export class MissionState {
public readonly missionId: string;
public readonly startTime: number;
public readonly promptHash: string; // [Structural Fix] 정식 필드로 추가
public resilienceMetrics = {
fallbacks: 0,
retries: 0,
@@ -80,8 +84,9 @@ export class MissionState {
deduplications: 0
};
constructor(missionId: string) {
constructor(missionId: string, promptHash: string = '') {
this.missionId = missionId;
this.promptHash = promptHash; // 생성 시점에 즉시 할당
this.startTime = Date.now();
this._lastTransitionTime = this.startTime;
}
@@ -152,15 +157,24 @@ export class MissionState {
/**
* 저장된 미션 상태를 불러옵니다 (Resumption용).
* 프롬프트 해시를 비교하여 질문이 변경되었을 경우 무효화합니다.
*/
public static loadFromDisk(missionId: string): MissionState | null {
public static loadFromDisk(missionId: string, currentPrompt: string): MissionState | null {
try {
const workspacePath = process.env.ASTRA_TEST_ROOT || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
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);
// [Structural Fix] 프롬프트 무결성 검증
const currentPromptHash = createHash('sha256').update(currentPrompt).digest('hex').slice(0, 16);
if (data.promptHash && data.promptHash !== currentPromptHash) {
logInfo(`[MissionState] 프롬프트 변경 감지. 이전 상태를 폐기하고 새로 시작합니다.`);
return null;
}
const state = new MissionState(missionId, currentPromptHash);
state._stage = data.status;
state._results = data.results || {};
state.resilienceMetrics = data.resilienceMetrics || {
@@ -218,6 +232,7 @@ export class MissionState {
totalElapsedMs: this.getElapsedMs(),
failureReason: this._failureReason,
results: this._results,
promptHash: this.promptHash, // 정식 필드 참조
transitionCount: this._auditTrail.length,
transitions: this._auditTrail.map(e => ({
from: e.from,
@@ -232,39 +247,9 @@ export class MissionState {
}
// ─────────────────────────────────────────────
// 3. Error Recovery Matrix (오류 복구 매트릭스)
// 3. Error Recovery Logic (오류 복구 로직)
// ─────────────────────────────────────────────
/**
* 오류 유형 분류.
* 에이전트 간 통신 실패 시 발생하는 오류를 세 범주로 분류합니다.
*/
export enum ErrorType {
/** 네트워크 타임아웃, API 응답 지연 등 재시도로 복구 가능한 오류 */
TRANSIENT = 'TRANSIENT',
/** 프롬프트 구조 문제, 모델 명백한 실패 등 수동 개입이 필요한 오류 */
PERMANENT = 'PERMANENT',
/** 인증 실패 (API Key 만료 등) */
AUTH_FAILURE = 'AUTH_FAILURE',
/** 사용자가 의도적으로 작업을 취소한 경우 */
ABORT = 'ABORT'
}
/**
* Error Recovery Matrix의 단일 규칙 정의.
* 오류 유형별 대응 전략을 선언적으로 공식화합니다.
*/
export interface RecoveryRule {
type: ErrorType;
description: string;
maxRetries: number;
backoffBaseMs: number;
action: 'retry' | 'abort' | 'fail_with_message' | 'fallback';
userMessage: string;
/** 'fallback' 액션 시 실행할 대체 로직 (예: 캐시 반환) */
fallbackDescription?: string;
}
/**
* ┌─────────────────────────────────────────────────────────────────────┐
* │ Error Recovery Matrix (오류 복구 매트릭스) │
@@ -318,9 +303,13 @@ export class ErrorClassifier {
/Failed to fetch/i,
/503/, // Service Unavailable
/502/, // Bad Gateway
/500/, // Internal Server Error (일시적일 수 있음)
/429/, // Too Many Requests (Rate Limit)
/ENOTFOUND/i,
/socket hang up/i,
/out of memory/i, // GPU/System OOM
/oom/i,
/failed to allocate/i
];
/** Permanent Error로 분류되는 패턴 */
@@ -333,6 +322,10 @@ export class ErrorClassifier {
/invalid.*model/i,
/model.*not found/i,
/parse error/i,
/context.*length.*exceeded/i,
/max.*token.*limit/i,
/safety.*filter/i,
/blocked.*content/i
];
/**
@@ -392,28 +385,46 @@ export class CacheManager {
}
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);
return createHash('sha256').update(key).digest('hex');
}
public static get(prompt: string, context: string): string | null {
public static get(prompt: string, context: string, currentModel: string = 'unknown'): 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');
const filePath = path.join(this.getCacheDir(), `${key}.json`); // 확장자 .json으로 변경
if (!fs.existsSync(filePath)) return null;
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// 1. TTL 검증 (기본 7일)
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - data.createdAt > SEVEN_DAYS_MS) {
logInfo(`[CacheManager] 캐시 만료됨 (TTL 초과).`);
return null;
}
// 2. 모델 버전 검증
if (data.modelVersion !== currentModel && currentModel !== 'unknown') {
logInfo(`[CacheManager] 모델 버전 불일치 (${data.modelVersion} -> ${currentModel}). 캐시 스킵.`);
return null;
}
return data.result;
} catch (err) {
return null;
}
return null;
}
public static set(prompt: string, context: string, result: string): void {
public static set(prompt: string, context: string, result: string, modelVersion: string = 'unknown'): void {
const key = this.getHash(prompt + context);
const filePath = path.join(this.getCacheDir(), `${key}.cache`);
fs.writeFileSync(filePath, result, 'utf-8');
const filePath = path.join(this.getCacheDir(), `${key}.json`);
const cacheData = {
result,
createdAt: Date.now(),
modelVersion
};
fs.writeFileSync(filePath, JSON.stringify(cacheData, null, 2), 'utf-8');
}
}
@@ -453,10 +464,12 @@ export class AgentEngine {
options?: AgentExecuteOptions
): Promise<string> {
let state: MissionState;
const promptHash = createHash('sha256').update(prompt).digest('hex').slice(0, 16);
// 0. 상태 복원 시도 (Resumption) - 프롬프트 무결성 검사 포함
const existingState = MissionState.loadFromDisk(missionId, prompt);
state = (existingState && existingState.stage !== 'completed') ? existingState : new MissionState(missionId, promptHash);
// 0. 상태 복원 시도 (Resumption)
const existingState = MissionState.loadFromDisk(missionId);
state = (existingState && existingState.stage !== 'completed') ? existingState : new MissionState(missionId);
// 1. 명시적 락 획득 (Mutex)
const release = await lockManager.acquire(`mission_${missionId}`);
@@ -465,10 +478,12 @@ export class AgentEngine {
return await actionQueue.enqueue(async () => {
logInfo(`[AgentEngine] 미션 시작: ${missionId}`);
// [Blind Spot Check] 전역 중복 제거 (Global Deduplication)
const globalCache = CacheManager.get(prompt, 'global_final_report');
// [Structural Fix] 글로벌 캐시 키에 프롬프트 해시 결합 (네임스페이스 분리)
const globalCacheKey = `global_final_report_${promptHash}`;
const currentModel = (options?.config?.model as string) || 'unknown';
const globalCache = CacheManager.get(prompt, globalCacheKey, currentModel);
if (globalCache) {
logInfo(`[AgentEngine] [Deduplication] 최종 리포트 캐시를 발견했습니다. 실행을 스킵합니다.`);
logInfo(`[AgentEngine] [Deduplication] '${globalCacheKey}' 캐시를 발견했습니다.`);
this.transition(state, 'completed', '캐시된 결과 반환', onProgress);
return globalCache;
}
@@ -478,20 +493,25 @@ export class AgentEngine {
state, 'planner', '전략 수립 중...',
() => this.resilientExecute(state, this.planner, 'Planner', prompt, brainContext, signal, onProgress, {
...options,
context: brainContext, signal, config: { ...options?.config, role: 'planner' }
context: brainContext,
signal,
config: { ...options?.config, role: 'planner', isSamePrompt: true }
}),
prompt, brainContext, signal, onProgress
);
const plannerScore = this.validateResult(plan, 'Planner');
const researcherLevel: AbstractionLevel = plannerScore < 70 ? 'laser-focused' : 'balanced';
// [Structural Fix] 점수가 낮을수록 더 상세한 근거를 요구(comprehensive)하도록 로직 역전
const researcherLevel: AbstractionLevel = plannerScore < 70 ? 'comprehensive' : 'balanced';
// --- Phase 2: Researcher ---
const research = await this.executeStep(
state, 'researcher', '핵심 정보 수집 및 분석 중...',
() => this.resilientExecute(state, this.researcher, 'Researcher', plan, brainContext, signal, onProgress, {
...options,
context: brainContext, signal, config: { ...options?.config, role: 'researcher' },
context: brainContext,
signal,
config: { ...options?.config, role: 'researcher', isSamePrompt: true },
abstractionLevel: researcherLevel
}),
plan, brainContext, signal, onProgress
@@ -505,14 +525,17 @@ export class AgentEngine {
}
const researchScore = this.validateResult(research, 'Researcher');
const writerLevel: AbstractionLevel = researchScore < 65 ? 'laser-focused' : 'balanced';
// [Structural Fix] 점수가 낮을수록 더 상세한 근거를 요구(comprehensive)하도록 로직 역전
const writerLevel: AbstractionLevel = researchScore < 65 ? 'comprehensive' : 'balanced';
// --- Phase 4: Writer ---
const finalReport = await this.executeStep(
state, 'writer', '최종 리포트 작성 및 편집 중...',
() => this.resilientExecute(state, this.writer, 'Writer', research, prompt, signal, onProgress, {
...options,
context: brainContext, signal, config: { role: 'writer', allowFallback: true, ...options?.config },
context: brainContext,
signal,
config: { role: 'writer', allowFallback: true, isSamePrompt: true, ...options?.config },
priorResults: { plan, writerPrep, previousValidData: state.getResult('finalReport'), ...options?.priorResults },
abstractionLevel: writerLevel
}),
@@ -523,11 +546,16 @@ export class AgentEngine {
// --- Phase 5: Advice & Standardization ---
const proactiveAdvice = await this.generateProactiveAdvice(finalReport, prompt, brainContext, signal);
const enrichedReport = `${finalReport}\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\n${proactiveAdvice}`;
// [Structural Fix] 생성된 제안의 무결성 검증 (최소 길이 50자 이상일 때만 append)
const enrichedReport = proactiveAdvice && proactiveAdvice.length > 50
? `${finalReport}\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\n${proactiveAdvice}`
: finalReport;
const standardizedReport = WikiFormatter.format(enrichedReport, state);
// 최종 결과 전역 캐싱 (Deduplication)
CacheManager.set(prompt, 'global_final_report', standardizedReport);
// 최종 결과 전역 캐싱 (Deduplication - Secure Key with Model Awareness)
CacheManager.set(prompt, `global_final_report_${promptHash}`, standardizedReport, currentModel);
CognitionAudit.auditPolicyCompliance('MissionComplete', standardizedReport);
this.transition(state, 'completed', '미션 완료', onProgress);
@@ -664,6 +692,11 @@ export class AgentEngine {
case ErrorType.PERMANENT:
// 영구 오류 → 재시도 없이 즉시 중단, 사용자 메시지 첨부
logError(`[AgentEngine] [PERMANENT] ${agentName} 복구 불가: ${rule.userMessage}`);
// [Self-Correction] 에러 발생 직전의 데이터 상태를 짧게 진단 (안전한 audit 모드 사용)
const auditResult = AgentDataValidator.audit(agentName, input.substring(0, 500));
logInfo(`[AgentEngine] [Pre-Failure Audit] Risk: ${auditResult.conflictRisk} | Issues: ${auditResult.issues.join(', ') || 'None'}`);
const enrichedError = new Error(`[${agentName}] ${rule.userMessage} (원인: ${error.message})`);
(enrichedError as any).originalError = error;
(enrichedError as any).errorType = ErrorType.PERMANENT;
@@ -676,9 +709,19 @@ export class AgentEngine {
if (transientRule.action === 'fallback' || options?.config?.allowFallback) {
logInfo(`[AgentEngine] [FALLBACK] ${agentName} 재시도 소진. 대체 데이터(Cache/Stale) 경로를 가동합니다.`);
state.resilienceMetrics.fallbacks++;
const cached = CacheManager.get(input, context);
// 1. 캐시 시도 (현재 프롬프트/컨텍스트 및 모델에 종속됨)
const currentModel = (options?.config?.model as string) || 'unknown';
const cached = CacheManager.get(input, context, currentModel);
if (cached) return cached;
if (options?.priorResults?.['previousValidData']) return options.priorResults['previousValidData'];
// 2. [Structural Fix] 플래그 대신 입력/컨텍스트 해시를 직접 검증하여 Fallback 안전성 확보
const inputHash = createHash('sha256').update(input).digest('hex').slice(0, 16);
const contextHash = createHash('sha256').update(context || '').digest('hex').slice(0, 16);
if (options?.priorResults?.['previousValidData'] && (state.promptHash === inputHash || state.promptHash === contextHash)) {
return options.priorResults['previousValidData'];
}
}
logError(`[AgentEngine] [TRANSIENT] ${agentName} 최대 재시도 횟수(${transientRule.maxRetries}) 소진.`);
@@ -785,16 +828,24 @@ export class AgentEngine {
* 수행된 작업 결과를 분석하여 다음 단계의 의사결정 포크를 제안합니다.
*/
private async generateProactiveAdvice(report: string, originalPrompt: string, context: string, signal: AbortSignal): Promise<string> {
logInfo(`[AgentEngine] 선제적 제안 생성 중...`);
// 본 로직은 별도의 가벼운 추론 단계를 거치거나, WriterAgent의 기능을 확장하여 구현할 수 있습니다.
// 현재는 WriterAgent에게 한 번 더 질의하거나, 리포트에서 액션 아이템을 추출하는 구조로 시작합니다.
const advicePrompt = `다음 리포트를 읽고, 사용자가 다음에 내려야 할 '전략적 의사결정'이나 '실행 작업' 3가지를 제안해줘.
// [Structural Fix] 절단 없는 컨텍스트 전달 (LLM 상상력 제한)
const advicePrompt = `사용자의 원래 요청과 작성된 최종 리포트를 바탕으로,
사용자가 다음에 내려야 할 '전략적 의사결정'이나 '실행 작업' 3가지를 구체적으로 제안해주십시오.
존재하지 않는 사실을 지어내지 말고, 리포트에 명시된 근거만을 활용하십시오.
원래 요청: ${originalPrompt}
리포트 요약: ${report.substring(0, 1000)}...`;
리포트 내용:
${report}`;
try {
// WriterAgent를 Advisor 모드로 재활용
return await this.writer.execute(advicePrompt, context, signal, { config: { role: 'advisor' } });
// Advisor 전용 설정을 주입하여 역할 혼용 방지
return await this.writer.execute(advicePrompt, context, signal, {
config: {
role: 'advisor',
temperature: 0.1, // 창의성 억제, 사실성 강화
maxTokens: 500
}
});
} catch (err) {
return "다음 단계에 대한 자동 제안을 생성하지 못했습니다. 리포트의 결론 섹션을 참고해 주세요.";
}