feat: v2.62.0 - Astra Autonomous Loop (AAL) foundation & enhanced file analysis

This commit is contained in:
g1nation
2026-05-04 12:58:43 +09:00
parent 445d530b63
commit 215c5f9457
23 changed files with 2964 additions and 62 deletions
+77 -10
View File
@@ -793,7 +793,7 @@ export class AgentExecutor {
return '';
}
const candidates = this.extractLocalProjectPaths(prompt);
const candidates = this.extractLocalProjectPaths(prompt, rootPath);
if (candidates.length === 0) {
return '';
}
@@ -803,6 +803,11 @@ export class AgentExecutor {
'[LOCAL PROJECT PATH PREFLIGHT]',
`Local project intent: ${intent}`,
this.buildLocalProjectIntentGuidance(intent),
'[CRITICAL DIRECTIVE] The file contents below have already been read from the local filesystem. You MUST use them directly in your analysis.',
'DO NOT ask the user to provide, upload, paste, or share the file contents. They are already included below.',
'DO NOT say "파일 내용을 보여주세요", "코드를 공유해 주세요", or "파일을 제공해 주세요". The files have been pre-loaded.',
'If access succeeded, proceed IMMEDIATELY with analysis. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context before asking for uploads.',
'If access failed, explain the concrete failure. If access succeeded, proceed with code review from the scanned files.',
'If access succeeded and priority file previews are present, do not say that code was not provided.',
@@ -812,7 +817,7 @@ export class AgentExecutor {
'If intent is thinking, act as a project thinking partner and give a clear verdict grounded in the inspected files.'
];
for (const candidate of candidates.slice(0, 2)) {
for (const candidate of candidates.slice(0, 5)) {
sections.push(this.inspectLocalProjectPath(candidate, rootPath));
}
@@ -1004,8 +1009,27 @@ export class AgentExecutor {
}
private shouldPreflightLocalProjectPath(prompt: string): boolean {
return /(검토|리뷰|분석|확인|봐줘|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|설계|아키텍처|구조|방향|의견|생각|판단|어떤\s*거?\s*같|어때|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve|architecture|design|structure|opinion|think|judge)/i.test(prompt)
&& /\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i.test(prompt);
const hasActionKeyword = /(검토|리뷰|분석|확인|봐줘|읽어|열어|파일|내용|코드|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|설계|아키텍처|구조|방향|의견|생각|판단|어떤\s*거?\s*같|어때|순서대로|보면|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve|architecture|design|structure|opinion|think|judge|read|open|file|content|code)/i.test(prompt);
const hasLocalPath = this.containsLocalFilePath(prompt);
return hasActionKeyword && hasLocalPath;
}
/**
* 프롬프트에 로컬 파일/디렉토리 경로가 포함되어 있는지 감지합니다.
* 절대 경로: /Volumes/, /Users/, /home/, ~/
* 상대 경로: src/..., lib/..., components/..., tests/... 등 + 파일 확장자
*/
private containsLocalFilePath(prompt: string): boolean {
// 절대 경로
if (/(?:\/Volumes\/|\/Users\/|\/home\/|~\/)[^\s`"'<>]+/i.test(prompt)) {
return true;
}
// 상대 경로 패턴: 디렉토리/파일명.확장자 형태 (src/lib/engine.ts, components/App.tsx 등)
if (/(?:^|[\s,])(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)\//i.test(prompt)
&& /\.[a-z]{1,6}(?:[\s,;)\]]|$)/i.test(prompt)) {
return true;
}
return false;
}
private isProjectKnowledgeCreationRequest(prompt: string): boolean {
@@ -1017,7 +1041,7 @@ export class AgentExecutor {
}
private classifyLocalProjectIntent(prompt: string): LocalProjectIntent {
if (!/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i.test(prompt)) {
if (!this.containsLocalFilePath(prompt)) {
return 'general';
}
@@ -1323,9 +1347,46 @@ export class AgentExecutor {
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || null;
}
private extractLocalProjectPaths(prompt: string): string[] {
const matches = prompt.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/gi) || [];
return Array.from(new Set(matches.map((value) => value.replace(/[),.;\]]+$/g, ''))));
private extractLocalProjectPaths(prompt: string, rootPath?: string): string[] {
const results: string[] = [];
// 1. 절대 경로 감지: /Volumes/, /Users/, /home/, ~/
const absMatches = prompt.match(/(?:\/Volumes\/|\/Users\/|\/home\/|~\/)[^\s`"'<>]+/gi) || [];
for (const m of absMatches) {
results.push(m.replace(/[),.;\]]+$/g, ''));
}
// 2. 상대 경로 감지: src/lib/engine.ts, components/App.tsx 등
const relMatches = prompt.match(/(?:^|[\s,])(?:(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)\/[^\s`"'<>]+\.[a-z]{1,6})/gi) || [];
for (const m of relMatches) {
const cleaned = m.trim().replace(/^,\s*/, '').replace(/[),.;\]]+$/g, '');
if (rootPath) {
// 상대 경로를 워크스페이스 기준 절대 경로로 변환
const absPath = path.resolve(rootPath, cleaned);
if (fs.existsSync(absPath)) {
results.push(absPath);
} else {
// 프로젝트 루트 하위 프로젝트들에서도 검색
const subProjects = ['ConnectAI', 'Datacollector_MAC', 'Agent', 'skybound'];
let found = false;
for (const sub of subProjects) {
const subPath = path.resolve(rootPath, sub, cleaned);
if (fs.existsSync(subPath)) {
results.push(subPath);
found = true;
break;
}
}
if (!found) {
results.push(absPath); // fallback: 원래 경로 그대로
}
}
} else {
results.push(cleaned);
}
}
return Array.from(new Set(results));
}
private inspectLocalProjectPath(targetPath: string, rootPath: string): string {
@@ -1342,11 +1403,17 @@ export class AgentExecutor {
const stat = fs.statSync(absPath);
if (!stat.isDirectory()) {
const content = fs.readFileSync(absPath, 'utf8');
const fileName = path.basename(absPath);
const ext = path.extname(absPath).toLowerCase();
// 코드/문서 파일은 더 많은 내용을 제공하여 정밀한 분석이 가능하도록 함
const isCodeOrDoc = ['.ts', '.js', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.md', '.json', '.yaml', '.yml', '.toml', '.css', '.html', '.sql', '.sh', '.zsh', '.env', '.xml', '.swift', '.kt'].includes(ext);
const previewLimit = isCodeOrDoc ? 8000 : 2000;
return [
`Path: ${targetPath}`,
'Access: succeeded',
'Type: file',
`Preview:\n${summarizeText(content, 1200)}`
`Type: file (${fileName})`,
`Size: ${content.length} characters`,
`Full content (${content.length <= previewLimit ? 'complete' : `first ${previewLimit} chars`}):\n\`\`\`${ext.slice(1)}\n${summarizeText(content, previewLimit)}\n\`\`\``
].join('\n');
}
+2 -1
View File
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import { getConfig } from '../config';
import { AgentExecuteOptions } from '../lib/engine';
export abstract class BaseAgent {
constructor(protected readonly modelName: string) {}
@@ -76,7 +77,7 @@ export abstract class BaseAgent {
throw lastError;
}
abstract execute(input: string, context?: string, signal?: AbortSignal): Promise<string>;
abstract execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
}
// Helper to combine signals (since AbortSignal.any is not always available in older Node)
+88
View File
@@ -0,0 +1,88 @@
/**
* ============================================================
* Astra Path Resolver (경로 해결기)
*
* Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다.
* 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토리를 해결하여,
* 사용자 프로젝트 루트가 아닌 ConnectAI 패키지 내부에 데이터를 저장합니다.
*
* 이 모듈은 AAL(Astra Autonomous Loop) 프로토콜의 기반이 됩니다.
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
let _extensionRootPath: string | null = null;
/**
* 확장 프로그램 활성화 시 1회 호출하여 extension root를 설정합니다.
* extension.ts의 activate()에서 호출되어야 합니다.
*/
export function initAstraPathResolver(context: vscode.ExtensionContext): void {
_extensionRootPath = context.extensionUri.fsPath;
}
/**
* .astra 데이터 디렉토리의 절대 경로를 반환합니다.
* 디렉토리가 없으면 자동 생성합니다.
*
* @returns ConnectAI/.astra/ 의 절대 경로
*/
export function getAstraDataDir(): string {
const root = _extensionRootPath ?? _fallbackExtensionRoot();
const astraDir = path.join(root, '.astra');
if (!fs.existsSync(astraDir)) {
fs.mkdirSync(astraDir, { recursive: true });
}
return astraDir;
}
/**
* .astra 내부의 특정 파일 경로를 반환합니다.
*
* @param filename - 파일 이름 (예: 'project_memory.json', 'tasks.json')
* @returns 파일의 절대 경로
*/
export function getAstraFilePath(filename: string): string {
return path.join(getAstraDataDir(), filename);
}
/**
* .astra 내부의 프로젝트별 서브디렉토리 경로를 반환합니다.
* 프로젝트별 메모리 분리가 필요한 경우 사용합니다.
*
* @param projectId - 프로젝트 식별자 (hash 또는 이름)
* @returns 프로젝트별 .astra 서브디렉토리 경로
*/
export function getAstraProjectDir(projectId: string): string {
const projDir = path.join(getAstraDataDir(), 'projects', projectId);
if (!fs.existsSync(projDir)) {
fs.mkdirSync(projDir, { recursive: true });
}
return projDir;
}
/**
* AAL(Autonomous Loop) 태스크 파일의 경로를 반환합니다.
*/
export function getAstraTaskFilePath(): string {
return getAstraFilePath('tasks.json');
}
/**
* AAL 프로토콜 설정 파일 경로를 반환합니다.
*/
export function getAstraProtocolPath(): string {
return getAstraFilePath('protocol.json');
}
/**
* extensionUri가 아직 설정되지 않은 경우의 fallback.
* __dirname 기반으로 ConnectAI 루트를 추정합니다.
*/
function _fallbackExtensionRoot(): string {
// esbuild로 번들된 out/extension.js → 상위 디렉토리가 ConnectAI 루트
return path.resolve(__dirname, '..');
}
+18 -6
View File
@@ -2,11 +2,17 @@ import { logInfo, logError } from '../utils';
/**
* ActionQueueManager: Manages large-scale tasks by processing them
* sequentially to prevent resource exhaustion and I/O bottlenecks.
* with a concurrency limit to prevent resource exhaustion and I/O bottlenecks
* while maintaining high throughput under maximum load.
*/
export class ActionQueueManager {
private queue: (() => Promise<void>)[] = [];
private isProcessing: boolean = false;
private activeCount: number = 0;
private readonly concurrencyLimit: number;
constructor(concurrencyLimit: number = 3) {
this.concurrencyLimit = concurrencyLimit;
}
/**
* Adds a task to the queue.
@@ -26,28 +32,34 @@ export class ActionQueueManager {
}
private async processNext() {
if (this.isProcessing || this.queue.length === 0) return;
if (this.activeCount >= this.concurrencyLimit || this.queue.length === 0) return;
this.isProcessing = true;
this.activeCount++;
const task = this.queue.shift();
if (task) {
try {
// Add a micro-delay to allow system breathing room between heavy I/O
await new Promise(r => setTimeout(r, 50));
await new Promise(r => setTimeout(r, 10));
await task();
} catch (error) {
logError('Task in queue failed:', error);
} finally {
this.isProcessing = false;
this.activeCount--;
this.processNext();
}
} else {
this.activeCount--;
}
}
public getPendingCount(): number {
return this.queue.length;
}
public getActiveCount(): number {
return this.activeCount;
}
}
export const actionQueue = new ActionQueueManager();
+4
View File
@@ -16,12 +16,16 @@ import { AgentExecutor } from './agent';
import { BridgeServer } from './bridge';
import { SidebarChatProvider } from './sidebarProvider';
import { HealthCheckMonitor } from './core/health';
import { initAstraPathResolver } from './core/astraPath';
/**
* Astra Extension Entry Point
*/
export async function activate(context: vscode.ExtensionContext) {
logInfo('Astra activating...');
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
initAstraPathResolver(context);
// Start Environment Health Monitoring
HealthCheckMonitor.runAllChecks();
+18 -3
View File
@@ -95,7 +95,15 @@ export function buildSecondBrainTrace(userQuery: string, brainRoot: string, opti
const slotSelections = knowledgeSlots.map((slot) => {
const slotTerms = tokenize(slot.retrievalQuery);
const slotCandidates = files
.map((file) => scoreFile(file, brainRoot, slotTerms, queryIntent, targetProject))
.map((file) => {
const doc = scoreFile(file, brainRoot, slotTerms, queryIntent, targetProject);
// 슬롯 ID와 문서 디렉토리명 매칭 보너스 (e.g. ontology 슬롯 → Ontology/ 디렉토리)
const dirName = path.dirname(doc.path).toLowerCase();
if (dirName.includes(slot.id.toLowerCase())) {
doc.score = Number((doc.score + 0.5).toFixed(2));
}
return doc;
})
.filter((doc) => doc.score >= 0.25)
.sort((a, b) => b.score - a.score);
const materialCandidates = slotCandidates.filter((doc) => doc.knowledgeRole !== 'routing-hint');
@@ -581,7 +589,9 @@ function scoreFile(file: string, brainRoot: string, terms: string[], intent: Sec
score += projectRelevanceScore(relative, lower, targetProject, documentProject);
}
const expandedTerms = expandQuery(terms);
const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title, content, lastModified: Date.now() }])[0];
// 디렉토리 경로를 title에 포함하여 카테고리 키워드 매칭 향상 (e.g. Ontology/ → 'ontology' 토큰)
const titleWithPath = `${relative.replace(/[\\/]/g, ' ')} ${title}`;
const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title: titleWithPath, content, lastModified: Date.now() }])[0];
score += scoredTfIdf.score;
@@ -595,7 +605,8 @@ function scoreFile(file: string, brainRoot: string, terms: string[], intent: Sec
title,
path: relative,
absolutePath: file,
score: Number((Math.max(score, 0) / Math.max(expandedTerms.length, 1)).toFixed(2)),
// sqrt 정규화: 동의어 확장으로 분모가 과도하게 커지는 것을 방지
score: Number((Math.max(score, 0) / Math.max(Math.sqrt(expandedTerms.length), 1)).toFixed(2)),
excerpt: summarizeText(finalExcerpt, 420),
sourceType,
knowledgeRole,
@@ -679,6 +690,10 @@ function pathPriority(relativePath: string, intent: SecondBrainQueryIntent): num
if (/adr-\d+|decision|설계|원칙|principle|mvp|dependency|schema|documentation/i.test(normalized)) {
score += 1.5;
}
// 지식 카테고리 디렉토리 보너스 (knowledge slot 매칭 지원)
if (/(^|[\\/])(strategy|ontology|writing|technical|evidence|insight|information|domain)([\\/]|$)/i.test(normalized)) {
score += 1.5;
}
if (/(^|[\\/])(00_raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(normalized)) {
score -= 4;
}
+469 -26
View File
@@ -3,25 +3,311 @@ import { lockManager } from '../core/lock';
import { actionQueue } from '../core/queue';
import { logInfo, logError } from '../utils';
// ─────────────────────────────────────────────
// 1. 에이전트 인터페이스 확장 (Interface Extensibility)
// ─────────────────────────────────────────────
/**
* 에이전트 인터페이스 정의 (의존성 주입을 위함)
* 에이전트 실행 시 전달되는 확장 옵션 객체.
* 향후 에이전트별로 고유한 설정(temperature, maxTokens 등)을
* IAgent 시그니처를 변경하지 않고 유연하게 주입할 수 있습니다.
*/
export interface AgentExecuteOptions {
/** 에이전트 실행의 추가 컨텍스트 문자열 */
context?: string;
/** 실행 중단 시그널 */
signal?: AbortSignal;
/** 에이전트별 커스텀 설정 (temperature, maxTokens 등) */
config?: Record<string, unknown>;
/** 이전 단계의 중간 결과물 (병렬 파이프라인용) */
priorResults?: Record<string, string>;
}
/**
* 에이전트 인터페이스 정의 (의존성 주입을 위함).
* execute()는 기존 시그니처를 유지하면서, 확장 옵션도 수용합니다.
*/
export interface IAgent {
execute(input: string, context?: string, signal?: AbortSignal): Promise<string>;
execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
}
// ─────────────────────────────────────────────
// 2. 상태 관리의 명시적 분리 (Explicit State Management)
// ─────────────────────────────────────────────
/**
* 파이프라인 단계 상태 정의
*/
export type PipelineStage = 'idle' | 'planner' | 'researcher' | 'writer' | 'completed' | 'error';
/**
* 감사(Audit) 이력에 기록되는 단일 상태 전환 엔트리.
*/
export interface AuditEntry {
from: PipelineStage;
to: PipelineStage;
message: string;
timestamp: number;
durationFromPrev?: number;
}
/**
* MissionState: 엔진의 내부 상태를 캡슐화하는 독립 객체.
* 상태 전환의 모든 이력(Audit Trail)을 자동으로 기록하며,
* 외부 모니터링 시스템과 연동하여 투명한 파이프라인 추적을 가능하게 합니다.
*/
export class MissionState {
private _stage: PipelineStage = 'idle';
private _auditTrail: AuditEntry[] = [];
private _lastTransitionTime: number = Date.now();
public readonly missionId: string;
public readonly startTime: number;
constructor(missionId: string) {
this.missionId = missionId;
this.startTime = Date.now();
this._lastTransitionTime = this.startTime;
}
get stage(): PipelineStage {
return this._stage;
}
get auditTrail(): ReadonlyArray<AuditEntry> {
return this._auditTrail;
}
/**
* 상태를 전환하고, 감사 이력에 자동으로 기록합니다.
*/
public transition(to: PipelineStage, message: string): void {
const now = Date.now();
const entry: AuditEntry = {
from: this._stage,
to,
message,
timestamp: now,
durationFromPrev: now - this._lastTransitionTime
};
this._auditTrail.push(entry);
this._stage = to;
this._lastTransitionTime = now;
logInfo(`[MissionState] ${this.missionId}: ${entry.from}${entry.to} (${entry.durationFromPrev}ms) — ${message}`);
}
/**
* 전체 미션의 경과 시간을 반환합니다.
*/
public getElapsedMs(): number {
return Date.now() - this.startTime;
}
/**
* 감사 이력을 요약 문자열로 반환합니다 (디버깅/모니터링 용).
*/
public summarizeAudit(): string {
return this._auditTrail
.map(e => `[${e.from}${e.to}] ${e.durationFromPrev}ms: ${e.message}`)
.join('\n');
}
/**
* 감사 이력을 구조화된 JSON 포맷으로 출력합니다.
* 외부 모니터링 시스템(ELK Stack, Prometheus, Loki 등)과 연동 시
* 파싱 없이 바로 인제스트할 수 있는 표준 로그 포맷입니다.
*
* 출력 예시:
* ```json
* {
* "missionId": "mission_1714...",
* "status": "completed",
* "totalElapsedMs": 12450,
* "transitions": [
* { "from": "idle", "to": "planner", "durationMs": 0, "message": "...", "ts": "..." }
* ]
* }
* ```
*/
public toStructuredLog(): object {
return {
missionId: this.missionId,
status: this._stage,
startTime: new Date(this.startTime).toISOString(),
totalElapsedMs: this.getElapsedMs(),
transitionCount: this._auditTrail.length,
transitions: this._auditTrail.map(e => ({
from: e.from,
to: e.to,
durationMs: e.durationFromPrev,
message: e.message,
ts: new Date(e.timestamp).toISOString()
}))
};
}
}
// ─────────────────────────────────────────────
// 3. Error Recovery Matrix (오류 복구 매트릭스)
// ─────────────────────────────────────────────
/**
* 오류 유형 분류.
* 에이전트 간 통신 실패 시 발생하는 오류를 세 범주로 분류합니다.
*/
export enum ErrorType {
/** 네트워크 타임아웃, API 응답 지연 등 재시도로 복구 가능한 오류 */
TRANSIENT = 'TRANSIENT',
/** 프롬프트 구조 문제, 모델 명백한 실패 등 수동 개입이 필요한 오류 */
PERMANENT = 'PERMANENT',
/** 사용자가 의도적으로 작업을 취소한 경우 */
ABORT = 'ABORT'
}
/**
* Error Recovery Matrix의 단일 규칙 정의.
* 오류 유형별 대응 전략을 선언적으로 공식화합니다.
*/
export interface RecoveryRule {
type: ErrorType;
description: string;
maxRetries: number;
backoffBaseMs: number;
action: 'retry' | 'abort' | 'fail_with_message';
userMessage: string;
}
/**
* ┌─────────────────────────────────────────────────────────────────────┐
* │ Error Recovery Matrix (오류 복구 매트릭스) │
* ├──────────────┬──────────┬──────────┬─────────────────────────────────┤
* │ Error Type │ Retries │ Backoff │ Action │
* ├──────────────┼──────────┼──────────┼─────────────────────────────────┤
* │ TRANSIENT │ 3 │ 1000ms │ Exponential Backoff 자동 재시도 │
* │ PERMANENT │ 0 │ N/A │ 즉시 중단 + 사용자 안내 메시지 │
* │ ABORT │ 0 │ N/A │ 조용한 종료 (Graceful Exit) │
* └──────────────┴──────────┴──────────┴─────────────────────────────────┘
*/
export const ERROR_RECOVERY_MATRIX: ReadonlyArray<RecoveryRule> = [
{
type: ErrorType.TRANSIENT,
description: '네트워크 타임아웃, 일시적 API 지연, 연결 거부 등',
maxRetries: 3,
backoffBaseMs: 1000,
action: 'retry',
userMessage: '일시적인 연결 문제가 감지되었습니다. 자동으로 재시도 중입니다...'
},
{
type: ErrorType.PERMANENT,
description: '모델 응답 형식 오류, 프롬프트 구조 문제, 인증 실패 등',
maxRetries: 0,
backoffBaseMs: 0,
action: 'fail_with_message',
userMessage: '모델 응답에 근본적인 문제가 발생했습니다. 모델 설정을 확인하거나 다른 모델로 변경해 주세요.'
},
{
type: ErrorType.ABORT,
description: '사용자의 의도적 작업 취소',
maxRetries: 0,
backoffBaseMs: 0,
action: 'abort',
userMessage: '작업이 취소되었습니다.'
}
];
/**
* ErrorClassifier: 에러 객체를 분석하여 유형을 자동 판별합니다.
*/
export class ErrorClassifier {
/** Transient Error로 분류되는 패턴 */
private static readonly TRANSIENT_PATTERNS: RegExp[] = [
/timeout/i,
/ECONNREFUSED/i,
/ECONNRESET/i,
/ETIMEDOUT/i,
/network/i,
/fetch failed/i,
/Failed to fetch/i,
/503/, // Service Unavailable
/502/, // Bad Gateway
/429/, // Too Many Requests (Rate Limit)
/ENOTFOUND/i,
/socket hang up/i,
];
/** Permanent Error로 분류되는 패턴 */
private static readonly PERMANENT_PATTERNS: RegExp[] = [
/401/, // Unauthorized
/403/, // Forbidden
/404/, // Not Found (잘못된 모델명 등)
/유효한 응답을 받지 못했습니다/,
/Ollama URL이 설정되지 않았습니다/,
/invalid.*model/i,
/model.*not found/i,
/parse error/i,
];
/**
* 에러를 분류하고 해당하는 복구 규칙을 반환합니다.
*/
public static classify(error: any): { type: ErrorType; rule: RecoveryRule } {
// 1. Abort 확인
if (error.name === 'AbortError' || error.message === 'AbortError') {
return {
type: ErrorType.ABORT,
rule: ERROR_RECOVERY_MATRIX.find(r => r.type === ErrorType.ABORT)!
};
}
const message = error.message || String(error);
// 2. Permanent Error 확인 (우선 순위 높음)
for (const pattern of this.PERMANENT_PATTERNS) {
if (pattern.test(message)) {
return {
type: ErrorType.PERMANENT,
rule: ERROR_RECOVERY_MATRIX.find(r => r.type === ErrorType.PERMANENT)!
};
}
}
// 3. Transient Error 확인
for (const pattern of this.TRANSIENT_PATTERNS) {
if (pattern.test(message)) {
return {
type: ErrorType.TRANSIENT,
rule: ERROR_RECOVERY_MATRIX.find(r => r.type === ErrorType.TRANSIENT)!
};
}
}
// 4. 분류 불가 → 안전하게 Permanent로 처리 (보수적 접근)
return {
type: ErrorType.PERMANENT,
rule: ERROR_RECOVERY_MATRIX.find(r => r.type === ErrorType.PERMANENT)!
};
}
}
// ─────────────────────────────────────────────
// 4. AgentEngine 본체
// ─────────────────────────────────────────────
/**
* AgentEngine:
* Producer-Consumer 패턴을 기반으로 멀티 에이전트 워크플로우를 오케스트레이션하는 핵심 엔진.
* 명시적 락(Mutex) 의존성 주입(DI)을 통해 안정성과 유연성을 확보합니다.
* 명시적 락(Mutex), 의존성 주입(DI), 독립 상태 객체(MissionState),
* Error Recovery Matrix를 통해 안정성, 유연성, 투명성, 복원력을 동시에 확보합니다.
*
* 아키텍처 특징:
* - IAgent 인터페이스의 옵션 확장으로 에이전트별 커스텀 설정 지원
* - MissionState를 통한 감사(Audit) 이력 자동 기록
* - 병렬 준비 단계(Parallel Prep)를 통한 비동기 흐름 정교화
* - Error Recovery Matrix 기반의 Transient/Permanent 오류 자동 분류 및 복구
*/
export class AgentEngine {
private stage: PipelineStage = 'idle';
private state: MissionState | null = null;
constructor(
private readonly planner: IAgent,
@@ -41,6 +327,9 @@ export class AgentEngine {
onProgress: (stage: PipelineStage, message: string) => void
): Promise<string> {
// 상태 객체 초기화
this.state = new MissionState(missionId);
// 1. 명시적 락 획득 (Mutex) - 동일 미션의 중복 실행 방지
const release = await lockManager.acquire(`mission_${missionId}`);
@@ -50,51 +339,205 @@ export class AgentEngine {
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.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' } }
);
this.validateResult(plan, 'Planner');
logInfo(`[AgentEngine] [Planner] Output: ${this.summarizeLog(plan, 100)}`);
// --- 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);
// --- Phase 2 & 3: Parallel Prep + Sequential Execution ---
this.transition('researcher', '핵심 정보 수집 및 분석 중...', onProgress);
this.checkAbort(signal);
logInfo(`[AgentEngine] [Researcher] BrainContext Size: ${brainContext?.length || 0} chars`);
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)
]);
this.validateResult(research, 'Researcher');
logInfo(`[AgentEngine] [Researcher] Output: ${this.summarizeLog(research, 100)}`);
// --- 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.transition('writer', '최종 리포트 작성 및 편집 중...', onProgress);
this.checkAbort(signal);
const finalReport = await this.resilientExecute(
this.writer, 'Writer', research, prompt, signal, onProgress,
{ context: brainContext, signal, config: { role: 'writer' }, priorResults: { plan, writerPrep } }
);
this.validateResult(finalReport, 'Writer');
logInfo(`[AgentEngine] [Writer] Output: ${this.summarizeLog(finalReport, 100)}`);
this.updateStage('completed', '미션 완료', onProgress);
this.transition('completed', '미션 완료', onProgress);
logInfo(`[AgentEngine] 미션 완료: ${missionId} (총 ${this.state!.getElapsedMs()}ms)`);
return finalReport;
});
} catch (error: any) {
this.updateStage('error', `오류 발생: ${error.message}`, onProgress);
logError(`[AgentEngine] 미션 실패 (${missionId}):`, error);
const { type, rule } = ErrorClassifier.classify(error);
const stageName = (this.state?.stage || 'unknown').toUpperCase();
this.transition('error', `오류 발생: ${error.message}`, onProgress);
// Error Recovery Matrix 기반 세분화된 로깅
switch (type) {
case ErrorType.ABORT:
logInfo(`[AgentEngine] [ABORT] 미션 취소됨 (${missionId}) at ${stageName} stage.`);
break;
case ErrorType.TRANSIENT:
logError(`[AgentEngine] [TRANSIENT] 재시도 소진 후 실패 (${missionId}) at ${stageName} stage — ${rule.description}:`, error);
break;
case ErrorType.PERMANENT:
logError(`[AgentEngine] [PERMANENT] 복구 불가 오류 (${missionId}) at ${stageName} stage — ${rule.userMessage}:`, error);
break;
}
// 감사 이력 덤프 (디버깅용)
if (this.state) {
logError(`[AgentEngine] Audit Trail for ${missionId}:\n${this.state.summarizeAudit()}`);
}
throw error;
} finally {
// 3. 락 해제
release();
this.stage = 'idle';
if (this.state) {
this.state = null;
}
}
}
private updateStage(stage: PipelineStage, message: string, onProgress: (stage: PipelineStage, message: string) => void) {
this.stage = stage;
/**
* 현재 미션의 상태 객체를 외부에서 읽을 수 있도록 노출합니다.
* 외부 모니터링 시스템 연동에 활용됩니다.
*/
public getMissionState(): MissionState | null {
return this.state;
}
// ─── Resilience Layer ───
/**
* Error Recovery Matrix 기반의 탄력적 에이전트 실행.
*
* - Transient Error: 지수 백오프(Exponential Backoff)를 적용하여 최대 N회 자동 재시도.
* - Permanent Error: 즉시 중단하고 명확한 사용자 메시지를 첨부하여 예외를 전파.
* - Abort: 조용하게 예외를 전파 (Graceful Exit).
*/
private async resilientExecute(
agent: IAgent,
agentName: string,
input: string,
context: string,
signal: AbortSignal,
onProgress: (stage: PipelineStage, message: string) => void,
options?: AgentExecuteOptions
): Promise<string> {
const transientRule = ERROR_RECOVERY_MATRIX.find(r => r.type === ErrorType.TRANSIENT)!;
let lastError: any;
for (let attempt = 0; attempt <= transientRule.maxRetries; attempt++) {
try {
// 재시도 시 사용자에게 진행 상황 알림
if (attempt > 0) {
const backoffMs = transientRule.backoffBaseMs * Math.pow(2, attempt - 1);
logInfo(`[AgentEngine] [RETRY] ${agentName} 재시도 ${attempt}/${transientRule.maxRetries} (${backoffMs}ms 후)`);
onProgress(this.state?.stage || 'error', `${agentName} 재시도 중... (${attempt}/${transientRule.maxRetries})`);
await new Promise(r => setTimeout(r, backoffMs));
this.checkAbort(signal);
}
return await agent.execute(input, context, signal, options);
} catch (error: any) {
lastError = error;
const { type, rule } = ErrorClassifier.classify(error);
switch (type) {
case ErrorType.ABORT:
// 사용자 취소 → 재시도 없이 즉시 전파
logInfo(`[AgentEngine] [ABORT] ${agentName} 실행 취소됨.`);
throw error;
case ErrorType.PERMANENT:
// 영구 오류 → 재시도 없이 즉시 중단, 사용자 메시지 첨부
logError(`[AgentEngine] [PERMANENT] ${agentName} 복구 불가: ${rule.userMessage}`);
const enrichedError = new Error(`[${agentName}] ${rule.userMessage} (원인: ${error.message})`);
(enrichedError as any).originalError = error;
(enrichedError as any).errorType = ErrorType.PERMANENT;
throw enrichedError;
case ErrorType.TRANSIENT:
// 일시적 오류 → 재시도 가능 여부 확인
if (attempt >= transientRule.maxRetries) {
logError(`[AgentEngine] [TRANSIENT] ${agentName} 최대 재시도 횟수(${transientRule.maxRetries}) 소진.`);
const exhaustedError = new Error(
`[${agentName}] 일시적 연결 오류가 지속됩니다. ` +
`${transientRule.maxRetries}회 재시도 후에도 복구되지 않았습니다. ` +
`네트워크 연결 및 모델 서버 상태를 확인해 주세요. (원인: ${error.message})`
);
(exhaustedError as any).originalError = error;
(exhaustedError as any).errorType = ErrorType.TRANSIENT;
throw exhaustedError;
}
logInfo(`[AgentEngine] [TRANSIENT] ${agentName}에서 일시적 오류 감지: ${error.message}`);
break; // continue to next attempt
}
}
}
// 이론적으로 도달 불가하지만 안전장치
throw lastError;
}
// ─── Private Helpers ───
/**
* MissionState를 통한 상태 전환 + 외부 콜백 호출.
*/
private transition(stage: PipelineStage, message: string, onProgress: (stage: PipelineStage, message: string) => void) {
if (this.state) {
this.state.transition(stage, message);
}
onProgress(stage, message);
}
/**
* AbortSignal 확인을 일관되게 처리합니다.
*/
private checkAbort(signal: AbortSignal): void {
if (signal.aborted) {
throw new Error('AbortError');
}
}
/**
* Writer가 사용할 초기 컨텍스트를 사전에 구성합니다.
* Researcher와 병렬로 실행되어 Phase 3 진입 시 즉시 활용 가능합니다.
*/
private async prepareWriterContext(prompt: string, plan: string, brainContext: string): Promise<string> {
const contextSummary = [
`[Original Prompt] ${prompt.substring(0, 200)}`,
`[Plan Summary] ${plan.substring(0, 300)}`,
`[Brain Context Available] ${brainContext ? 'Yes' : 'No'} (${brainContext?.length || 0} chars)`
].join('\n');
logInfo(`[AgentEngine] [WriterPrep] 초기 컨텍스트 준비 완료 (${contextSummary.length} chars)`);
return contextSummary;
}
private summarizeLog(data: string | undefined, length: number = 100): string {
if (!data) return 'empty';
const clean = data.replace(/\n/g, ' ').trim();
return clean.length > length ? clean.substring(0, length) + '...' : clean;
}
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));
}
}
+6 -6
View File
@@ -3,8 +3,9 @@
* Project Memory (프로젝트 기억)
*
* 프로젝트별 요구사항, 코드 구조, 아키텍처 결정, 버그 기록 등을
* 프로젝트 로컬에 저장하고 관리합니다.
* 저장 위치: {projectRoot}/.astra/project_memory.json
* Astra 확장 프로그램 내부에 저장하고 관리합니다.
* 저장 위치: {ConnectAI}/.astra/project_memory.json
* (기존: {projectRoot}/.astra/ → 변경됨)
* ============================================================
*/
@@ -12,6 +13,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { ProjectMemoryStore, ArchitectureDecision, BugRecord, MemoryContextResult } from './types';
import { getAstraDataDir } from '../core/astraPath';
export class ProjectMemory {
private store: ProjectMemoryStore;
@@ -19,10 +21,8 @@ export class ProjectMemory {
private dirty = false;
constructor(projectRoot: string) {
const astraDir = path.join(projectRoot, '.astra');
if (!fs.existsSync(astraDir)) {
fs.mkdirSync(astraDir, { recursive: true });
}
// .astra 디렉토리를 ConnectAI 내부에서 해결 (사용자 프로젝트 루트에 생성하지 않음)
const astraDir = getAstraDataDir();
this.filePath = path.join(astraDir, 'project_memory.json');
this.store = this.load(projectRoot);
}
+4 -3
View File
@@ -222,9 +222,10 @@ Never use placeholder values like optional/subdir or filename.md. If the user as
Operational rules:
1. Same language as the user.
2. File paths can be relative to the workspace or absolute paths under /Volumes/Data/project/Antigravity.
3. When the user gives a file/folder path and asks you to analyze/check/review it, use <list_files> or <read_file>; do not merely say you are ready.
4. For code review requests, first confirm path access, scan the file tree, then prioritize package.json, src, docs, README, and config files before giving findings.
5. Keep persona light. Do not introduce yourself unless the user greets you or asks who you are.`;
3. When the user gives a file/folder path and asks you to analyze/check/review it, use <list_files> or <read_file> to access it IMMEDIATELY. Never say "show me the file", "provide the code", "파일 내용을 보여주세요", or "코드를 공유해 주세요". You have filesystem access — use it.
4. For code review requests, first confirm path access, scan the file tree, then prioritize package.json, src, docs, README, and config files before giving findings. Do NOT pause between steps to ask "진행할까요?" or "시작할까요?". Execute the full analysis in one continuous response.
5. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path, that IS the confirmation. Start the analysis immediately. Do not restate the plan and wait for a second confirmation.
6. Keep persona light. Do not introduce yourself unless the user greets you or asks who you are.`;
export function getSystemPrompt(): string {
return BASE_SYSTEM_PROMPT;