feat: ConnectAI structural hardening and retrieval precision improvements
This commit is contained in:
+123
-72
@@ -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 "다음 단계에 대한 자동 제안을 생성하지 못했습니다. 리포트의 결론 섹션을 참고해 주세요.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user