0a97324f1b
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1115 lines
51 KiB
TypeScript
1115 lines
51 KiB
TypeScript
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)
|
|
// ─────────────────────────────────────────────
|
|
|
|
export type AbstractionLevel = 'laser-focused' | 'balanced' | 'comprehensive';
|
|
|
|
/**
|
|
* 에이전트 실행 시 전달되는 확장 옵션 객체.
|
|
* 향후 에이전트별로 고유한 설정(temperature, maxTokens 등)을
|
|
* IAgent 시그니처를 변경하지 않고 유연하게 주입할 수 있습니다.
|
|
*/
|
|
export interface AgentExecuteOptions {
|
|
/** 에이전트 실행의 추가 컨텍스트 문자열 */
|
|
context?: string;
|
|
/** 실행 중단 시그널 */
|
|
signal?: AbortSignal;
|
|
/** 에이전트별 커스텀 설정 (temperature, maxTokens 등) */
|
|
config?: Record<string, unknown>;
|
|
/** 이전 단계의 중간 결과물 (병렬 파이프라인용) */
|
|
priorResults?: Record<string, string | undefined>;
|
|
/** 에이전트 간 정보 전달 시의 추상화/필터링 수준 */
|
|
abstractionLevel?: AbstractionLevel;
|
|
}
|
|
|
|
/**
|
|
* 에이전트 인터페이스 정의 (의존성 주입을 위함).
|
|
* execute()는 기존 시그니처를 유지하면서, 확장 옵션도 수용합니다.
|
|
*/
|
|
export interface IAgent {
|
|
execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 2. 상태 관리의 명시적 분리 (Explicit State Management)
|
|
// ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* 파이프라인 단계 상태 정의.
|
|
*
|
|
* 예전엔 planner/researcher/reflector/writer/synthesizer 5개 persona를 줄세웠는데,
|
|
* 매 hop마다 컨텍스트를 다시 싣고 추상화가 누적돼 원본 본문을 잃었다. 사용자의
|
|
* 본래 의도는 "*답변*을 chunk로 나눠 토큰 압박 회피"였으므로, 이제 stage는
|
|
* 단일 writer가 거치는 3-step (outline → section → polish) 만 남는다. `section`
|
|
* stage는 outline에서 정해진 N번 반복 transition된다.
|
|
*
|
|
* `direct` 는 single-pass 경로 — outline·section·polish 를 모두 건너뛰고 1회
|
|
* LLM 호출로 즉답하는 빠른 경로. 짧은 질문이나 outline 이 "쪼갤 필요 없음"
|
|
* (빈 배열) 으로 판정한 경우에 사용.
|
|
*/
|
|
export type PipelineStage = 'idle' | 'outline' | 'section' | 'polish' | 'direct' | '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();
|
|
private _results: Record<string, string> = {};
|
|
private _failureReason?: string;
|
|
|
|
public readonly missionId: string;
|
|
public readonly startTime: number;
|
|
public readonly promptHash: string; // [Structural Fix] 정식 필드로 추가
|
|
public resilienceMetrics = {
|
|
fallbacks: 0,
|
|
retries: 0,
|
|
maxConflictScore: 0,
|
|
deduplications: 0
|
|
};
|
|
|
|
constructor(missionId: string, promptHash: string = '') {
|
|
this.missionId = missionId;
|
|
this.promptHash = promptHash; // 생성 시점에 즉시 할당
|
|
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}`);
|
|
this.saveToDisk();
|
|
}
|
|
|
|
/**
|
|
* 진행 상태를 디스크에 영구적으로 기록합니다 (State Save).
|
|
* 크래시 발생 시 어디까지 진행되었는지 파악하는 기초 데이터가 됩니다.
|
|
*/
|
|
private saveToDisk(): void {
|
|
try {
|
|
const workspacePath = process.env.ASTRA_TEST_ROOT || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
|
|
|
|
const astraDir = path.join(workspacePath, '.astra', 'missions');
|
|
if (!fs.existsSync(astraDir)) {
|
|
fs.mkdirSync(astraDir, { recursive: true });
|
|
}
|
|
const filePath = path.join(astraDir, `${this.missionId}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(this.toStructuredLog(), null, 2), 'utf-8');
|
|
} catch (err) {
|
|
logError(`[MissionState] Failed to save state to disk for ${this.missionId}`, err);
|
|
}
|
|
}
|
|
|
|
public setFailureReason(reason: string): void {
|
|
this._failureReason = reason;
|
|
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, 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'));
|
|
|
|
// [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 || {
|
|
fallbacks: 0,
|
|
retries: 0,
|
|
maxConflictScore: 0,
|
|
deduplications: 0
|
|
};
|
|
return state;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 전체 미션의 경과 시간을 반환합니다.
|
|
*/
|
|
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(),
|
|
failureReason: this._failureReason,
|
|
results: this._results,
|
|
promptHash: this.promptHash, // 정식 필드 참조
|
|
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()
|
|
})),
|
|
resilienceMetrics: this.resilienceMetrics
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 3. Error Recovery Logic (오류 복구 로직)
|
|
// ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* ┌─────────────────────────────────────────────────────────────────────┐
|
|
* │ 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
|
|
/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로 분류되는 패턴 */
|
|
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,
|
|
/context.*length.*exceeded/i,
|
|
/max.*token.*limit/i,
|
|
/safety.*filter/i,
|
|
/blocked.*content/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)!
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CacheManager: 중복 수집 방지 (Deduplication)를 위한 캐시 레이어.
|
|
* 동일한 프롬프트와 컨텍스트에 대해 중복된 LLM 호출을 방지합니다.
|
|
*/
|
|
export class CacheManager {
|
|
private static getCacheDir(): string {
|
|
const workspacePath = process.env.ASTRA_TEST_ROOT || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
|
|
const cacheDir = path.join(workspacePath, '.astra', 'cache');
|
|
if (!fs.existsSync(cacheDir)) {
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
}
|
|
return cacheDir;
|
|
}
|
|
|
|
private static getHash(key: string): string {
|
|
return createHash('sha256').update(key).digest('hex');
|
|
}
|
|
|
|
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}.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;
|
|
}
|
|
}
|
|
|
|
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}.json`);
|
|
const cacheData = {
|
|
result,
|
|
createdAt: Date.now(),
|
|
modelVersion
|
|
};
|
|
fs.writeFileSync(filePath, JSON.stringify(cacheData, null, 2), 'utf-8');
|
|
}
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 4. AgentEngine 본체
|
|
// ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* AgentEngine:
|
|
* Producer-Consumer 패턴을 기반으로 멀티 에이전트 워크플로우를 오케스트레이션하는 핵심 엔진.
|
|
* 명시적 락(Mutex), 의존성 주입(DI), 독립 상태 객체(MissionState),
|
|
* Error Recovery Matrix를 통해 안정성, 유연성, 투명성, 복원력을 동시에 확보합니다.
|
|
*
|
|
* 아키텍처 특징:
|
|
* - IAgent 인터페이스의 옵션 확장으로 에이전트별 커스텀 설정 지원
|
|
* - MissionState를 통한 감사(Audit) 이력 자동 기록
|
|
* - 병렬 준비 단계(Parallel Prep)를 통한 비동기 흐름 정교화
|
|
* - Error Recovery Matrix 기반의 Transient/Permanent 오류 자동 분류 및 복구
|
|
*/
|
|
export class AgentEngine {
|
|
/**
|
|
* Hard ceiling — *사용자 config 가 어떤 값이든 절대 넘을 수 없다*. 안전망.
|
|
* 실제 사용 상한은 `getConfig().chunkedMaxSections` (default 3). 사용자가
|
|
* Astra Settings 에서 1~10 사이 조정.
|
|
*
|
|
* factory.ts ChunkedWriter.MAX_SECTIONS_HARD_CEILING 와 일치.
|
|
*/
|
|
static readonly MAX_SECTIONS_HARD_CEILING = 10;
|
|
|
|
/**
|
|
* 단일 writer agent — 같은 모델이 outline / section / polish 역할을 번갈아
|
|
* 수행한다. 역할 분기는 options.config.role 로 ChunkedWriter 내부에서 처리.
|
|
*
|
|
* 하위호환을 위해 추가 IAgent 인자(`_legacyAgents`)를 받지만 사용하지 않는다.
|
|
* 기존 호출처에서 planner/researcher 등을 같이 넘겨도 컴파일은 통과.
|
|
*/
|
|
constructor(
|
|
private readonly writer: IAgent,
|
|
..._legacyAgents: Array<IAgent | undefined>
|
|
) {}
|
|
|
|
/**
|
|
* 단일 writer 기반 chunked 워크플로우 실행.
|
|
*
|
|
* outline → section[1..N] → polish
|
|
*
|
|
* Resilience layer(MissionState · ErrorClassifier · CacheManager)는 그대로
|
|
* 재사용하되, persona별 agent 5개를 줄세우던 옛 phase 구조만 제거했다.
|
|
*/
|
|
public async runMission(
|
|
missionId: string,
|
|
prompt: string,
|
|
brainContext: string,
|
|
signal: AbortSignal,
|
|
onProgress: (stage: PipelineStage, message: string) => void,
|
|
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);
|
|
|
|
|
|
// 1. 명시적 락 획득 (Mutex)
|
|
const release = await lockManager.acquire(`mission_${missionId}`);
|
|
|
|
try {
|
|
return await actionQueue.enqueue(async () => {
|
|
logInfo(`[AgentEngine] 미션 시작: ${missionId}`);
|
|
|
|
// [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] '${globalCacheKey}' 캐시를 발견했습니다.`);
|
|
this.transition(state, 'completed', '캐시된 결과 반환', onProgress);
|
|
return globalCache;
|
|
}
|
|
|
|
// --- Fast-path: 명백히 짧은 단일 답변 케이스 ---
|
|
// outline LLM 콜 자체를 우회 → 1회 호출로 즉답. 본문 첨부도 없고
|
|
// 분석/리서치 키워드도 없고 길이도 짧을 때만 발동. 애매하면 outline 으로
|
|
// 위임해서 LLM 이 판정하게 둔다.
|
|
if (AgentEngine.isObviouslySimple(prompt)) {
|
|
logInfo(`[AgentEngine] fast-path 단일 호출 (prompt ${prompt.length}자)`);
|
|
return await this.runSinglePass(
|
|
state, prompt, brainContext, signal, onProgress, options,
|
|
promptHash, currentModel, 'fast-path',
|
|
);
|
|
}
|
|
|
|
// --- Phase 1: Outline ---
|
|
// 1번의 LLM 호출로 답변을 몇 개 섹션으로 쪼갤지 결정. JSON 배열 반환.
|
|
// 사용자 config 의 chunkedMaxSections 를 outline persona 에 전달 — outline
|
|
// LLM 이 그 상한을 지키도록 prompt 에 박힘. parseOutline 의 cap 도 같은
|
|
// 값 사용해서 LLM 이 룰 어겨도 강제로 자름.
|
|
const cfgMaxSections = (() => {
|
|
try {
|
|
const { getConfig } = require('../config') as typeof import('../config');
|
|
const v = getConfig().chunkedMaxSections;
|
|
return Math.max(1, Math.min(AgentEngine.MAX_SECTIONS_HARD_CEILING, v ?? 3));
|
|
} catch { return 3; } // 안전 fallback
|
|
})();
|
|
const outlineRaw = await this.executeStep(
|
|
state, 'outline', '답변 구조 잡는 중...',
|
|
() => this.resilientExecute(state, this.writer, 'Outline', prompt, brainContext, signal, onProgress, {
|
|
...options,
|
|
context: brainContext,
|
|
signal,
|
|
config: { ...options?.config, role: 'outline', maxSections: cfgMaxSections },
|
|
}),
|
|
`outline::${prompt}`, brainContext, signal, onProgress
|
|
);
|
|
|
|
const outline = this.parseOutline(outlineRaw, cfgMaxSections);
|
|
const sections = outline.sections;
|
|
|
|
// outline 이 빈 배열(`reason === 'empty'`)을 반환했다면 LLM 이
|
|
// *명시적으로* "쪼갤 필요 없음" 으로 판정한 것 → section / polish 단계
|
|
// 건너뛰고 single-pass 직답. 이미 outline 1회는 썼지만 chunked 전체(2+N회)
|
|
// 보단 빠르고, 무엇보다 사용자 의도(짧은 답)에 부합.
|
|
//
|
|
// `reason === 'fallback'` 은 LLM 응답이 깨져서 우리가 임의로 1-section
|
|
// 으로 폴백한 케이스 — 이 경우엔 절대 single-pass 로 가지 말고 chunked
|
|
// 본문 1섹션 + polish 로 진행 (옛 버전에선 둘이 구분 안 돼서 우발적 전환).
|
|
if (outline.reason === 'empty') {
|
|
logInfo(`[AgentEngine] outline 이 명시적으로 단순 답변 판정 → direct 경로 폴백.`);
|
|
return await this.runSinglePass(
|
|
state, prompt, brainContext, signal, onProgress, options,
|
|
promptHash, currentModel, 'outline-fallback',
|
|
);
|
|
}
|
|
|
|
const outlineSummary = sections.map((s, i) => `${i + 1}. ${s.heading} — ${s.scope}`).join('\n');
|
|
|
|
// --- Phase 2: Sections (N회 반복) ---
|
|
// 각 섹션은 *동일* writer + 같은 모델이지만 role='section' 으로 분기.
|
|
// 본인 scope 만 다루고 prevSections 를 받아 중복을 피한다.
|
|
const sectionTexts: string[] = [];
|
|
for (let i = 0; i < sections.length; i++) {
|
|
this.checkAbort(signal);
|
|
const stageLabel = sections.length === 1
|
|
? '본문 작성 중...'
|
|
: `섹션 ${i + 1}/${sections.length}: "${sections[i].heading}" 작성 중...`;
|
|
// executeStep 의 Resumption 키는 stage 문자열 단일 — section 은 N번 반복하므로
|
|
// 캐시키만 idx 로 분리하고 state.results 에는 누적 join 결과를 별도로 저장한다.
|
|
const sectionText = await this.runSectionStep(
|
|
state, i, sections.length, stageLabel,
|
|
async () => this.resilientExecute(state, this.writer, `Section${i + 1}`, '', brainContext, signal, onProgress, {
|
|
...options,
|
|
context: brainContext,
|
|
signal,
|
|
config: { ...options?.config, role: 'section', allowFallback: true },
|
|
priorResults: {
|
|
originalPrompt: prompt,
|
|
sectionHeading: sections[i].heading,
|
|
sectionScope: sections[i].scope,
|
|
outlineSummary,
|
|
prevSectionsTrimmed: this.trimPrevSections(sectionTexts, sections),
|
|
previousValidData: state.getResult(`section_${i}`),
|
|
...options?.priorResults,
|
|
},
|
|
}),
|
|
prompt, brainContext, signal, onProgress
|
|
);
|
|
sectionTexts.push(sectionText);
|
|
}
|
|
|
|
// 섹션을 합쳐 polish 입력 draft 를 만든다. heading 줄을 같이 박아서
|
|
// polish 모델이 구조를 인지할 수 있게.
|
|
const joinedDraft = sections
|
|
.map((s, i) => `${s.heading}\n${sectionTexts[i] ?? ''}`)
|
|
.join('\n\n');
|
|
|
|
// --- Phase 3: Polish ---
|
|
// 1번의 LLM 호출로 오타·할루시네이션·중복 제거 + 첫 문장 결론으로 정렬.
|
|
const polishedReport = await this.executeStep(
|
|
state, 'polish', '최종 다듬기 중...',
|
|
() => this.resilientExecute(state, this.writer, 'Polish', joinedDraft, brainContext, signal, onProgress, {
|
|
...options,
|
|
context: brainContext,
|
|
signal,
|
|
config: { ...options?.config, role: 'polish', allowFallback: true },
|
|
priorResults: {
|
|
originalPrompt: prompt,
|
|
previousValidData: joinedDraft,
|
|
...options?.priorResults,
|
|
},
|
|
}),
|
|
`polish::${joinedDraft}`, prompt, signal, onProgress
|
|
);
|
|
|
|
// Polish 결과가 비정상적으로 짧으면(빈 응답 등) join 본을 fallback.
|
|
const safeReport = (!polishedReport || polishedReport.trim().length < 24)
|
|
? joinedDraft
|
|
: polishedReport;
|
|
|
|
// WikiFormatter는 *지식 아카이브 생성*용 포맷(P-Reinforce v3.0 frontmatter +
|
|
// Reliability Audit 표 등)이라 일반 채팅 답변에 강제 적용하면 메타 노이즈만 늘어남.
|
|
// 명시적으로 옵션이 켜진 경우(예: datacollect 위키 합성 경로)에만 wrap.
|
|
const wantsWikiFormat = options?.config?.formatAsKnowledgeArtifact === true;
|
|
const standardizedReport = wantsWikiFormat
|
|
? WikiFormatter.format(safeReport, state)
|
|
: safeReport;
|
|
|
|
// 최종 결과 전역 캐싱 (Deduplication - Secure Key with Model Awareness)
|
|
CacheManager.set(prompt, `global_final_report_${promptHash}`, standardizedReport, currentModel);
|
|
|
|
CognitionAudit.auditPolicyCompliance('MissionComplete', standardizedReport);
|
|
this.transition(state, 'completed', '미션 완료', onProgress);
|
|
return standardizedReport;
|
|
});
|
|
} catch (error: any) {
|
|
this.handleMissionFailure(missionId, state!, error, onProgress);
|
|
throw error;
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* [Atomic Step Execution]
|
|
* 개별 작업 단계를 캡슐화하여 상태 확인, 캐시 체크, 실행, 검증을 일관되게 처리합니다.
|
|
*/
|
|
private async executeStep(
|
|
state: MissionState,
|
|
stage: PipelineStage,
|
|
progressMessage: string,
|
|
action: () => Promise<string>,
|
|
cacheKeyPrompt: string,
|
|
cacheKeyContext: string,
|
|
signal: AbortSignal,
|
|
onProgress: (stage: PipelineStage, message: string) => void
|
|
): Promise<string> {
|
|
// 1. 기존 결과 확인 (Resumption)
|
|
let result = state.getResult(stage);
|
|
if (result) {
|
|
logInfo(`[AgentEngine] [Resumption] '${stage}' 단계 결과가 이미 존재합니다.`);
|
|
return result;
|
|
}
|
|
|
|
this.transition(state, stage, progressMessage, onProgress);
|
|
this.checkAbort(signal);
|
|
|
|
// 2. 캐시 확인 (Deduplication)
|
|
const cached = CacheManager.get(cacheKeyPrompt, cacheKeyContext);
|
|
if (cached) {
|
|
logInfo(`[AgentEngine] [Deduplication] '${stage}' 단계 캐시를 사용합니다.`);
|
|
state.resilienceMetrics.deduplications++;
|
|
result = cached;
|
|
} else {
|
|
// 3. 실행
|
|
result = await action();
|
|
CacheManager.set(cacheKeyPrompt, cacheKeyContext, result);
|
|
}
|
|
|
|
// 4. 저장
|
|
state.setResult(stage, result);
|
|
return result;
|
|
}
|
|
|
|
private handleMissionFailure(missionId: string, state: MissionState, error: any, onProgress: (stage: PipelineStage, message: string) => void) {
|
|
const { type, rule } = ErrorClassifier.classify(error);
|
|
const stageName = (state.stage || 'unknown').toUpperCase();
|
|
|
|
this.transition(state, 'error', `오류 발생: ${error.message}`, onProgress);
|
|
state.setFailureReason(`[${type}] ${rule.description} - 세부원인: ${error.message}`);
|
|
|
|
logError(`[AgentEngine] [${type}] ${missionId} 실패 at ${stageName} stage: ${error.message}`);
|
|
logError(`[AgentEngine] Audit Trail:\n${state.summarizeAudit()}`);
|
|
}
|
|
|
|
/**
|
|
* @deprecated 이제 미션 상태는 로컬 스코프에서 관리됩니다.
|
|
*/
|
|
public getMissionState(): MissionState | null {
|
|
return null;
|
|
}
|
|
|
|
|
|
// ─── Resilience Layer ───
|
|
|
|
/**
|
|
* Error Recovery Matrix 기반의 탄력적 에이전트 실행.
|
|
*
|
|
* - Transient Error: 지수 백오프(Exponential Backoff)를 적용하여 최대 N회 자동 재시도.
|
|
* - Permanent Error: 즉시 중단하고 명확한 사용자 메시지를 첨부하여 예외를 전파.
|
|
* - Abort: 조용하게 예외를 전파 (Graceful Exit).
|
|
*/
|
|
private async resilientExecute(
|
|
state: MissionState,
|
|
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++) {
|
|
if (attempt > 0) state.resilienceMetrics.retries++;
|
|
try {
|
|
// 재시도 시 사용자에게 진행 상황 알림
|
|
if (attempt > 0) {
|
|
const backoffMs = transientRule.backoffBaseMs * Math.pow(2, attempt - 1);
|
|
logInfo(`[AgentEngine] [RETRY] ${agentName} 재시도 ${attempt}/${transientRule.maxRetries} (${backoffMs}ms 후)`);
|
|
onProgress(state.stage, `${agentName} 재시도 중... (${attempt}/${transientRule.maxRetries})`);
|
|
await new Promise(r => setTimeout(r, backoffMs));
|
|
this.checkAbort(signal);
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
|
|
// [Astra v4.0] 맥락 증폭 (Context Amplification)
|
|
// 에이전트 실행 직전, 지식 신뢰도 및 충돌 처리 정책을 주입합니다.
|
|
const amplifiedContext = this.amplifyContext(context, options);
|
|
|
|
const result = await agent.execute(input, amplifiedContext, signal, options);
|
|
const durationMs = Date.now() - startTime;
|
|
|
|
// [Reliability Check] 충돌 위험도 추적
|
|
const validation = AgentDataValidator.validateHandoff(agentName, result);
|
|
state.resilienceMetrics.maxConflictScore = Math.max(state.resilienceMetrics.maxConflictScore, validation.conflictRisk);
|
|
|
|
PerformanceProfiler.logLLMLatency(agentName, durationMs, result.length);
|
|
|
|
return result;
|
|
} 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}`);
|
|
|
|
// [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;
|
|
throw enrichedError;
|
|
|
|
case ErrorType.TRANSIENT:
|
|
// 일시적 오류 → 재시도 가능 여부 확인
|
|
if (attempt >= transientRule.maxRetries) {
|
|
// [Intelligent Resilience] 재시도 실패 시 대체 경로(Fallback) 시도
|
|
if (transientRule.action === 'fallback' || options?.config?.allowFallback) {
|
|
logInfo(`[AgentEngine] [FALLBACK] ${agentName} 재시도 소진. 대체 데이터(Cache/Stale) 경로를 가동합니다.`);
|
|
state.resilienceMetrics.fallbacks++;
|
|
|
|
// 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'];
|
|
}
|
|
}
|
|
|
|
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(state: MissionState, stage: PipelineStage, message: string, onProgress: (stage: PipelineStage, message: string) => void) {
|
|
state.transition(stage, message);
|
|
onProgress(stage, message);
|
|
}
|
|
|
|
/**
|
|
* AbortSignal 확인을 일관되게 처리합니다.
|
|
*/
|
|
private checkAbort(signal: AbortSignal): void {
|
|
if (signal.aborted) {
|
|
throw new Error('AbortError');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fast-path 휴리스틱: prompt 가 "쪼갤 필요 없는 단순 케이스" 인지 즉시 판정.
|
|
* 명백할 때만 true — 애매한 중간 길이는 false 로 반환해 outline LLM 이 판정하게 위임.
|
|
*
|
|
* 단순 기준 (v2 — 키워드와 길이 *결합* 로 완화):
|
|
* - 길이 ≥ 400자 → 무조건 chunked (긴 입력은 분할 가치 있음)
|
|
* - 본문 첨부 신호 있음 → 무조건 chunked
|
|
* - 분석/리서치 키워드 *있고* 길이 ≥ 80자 → chunked
|
|
* - 분석/리서치 키워드 있어도 *80자 미만* → fast-path (예: "이 함수 분석해줘", "리뷰 요청")
|
|
* - 키워드 없고 길이 < 400자 → fast-path
|
|
*
|
|
* 이전 v1 은 키워드 1개만 있어도 200자 미만이면 무조건 chunked → "5줄 코드 리뷰해줘"
|
|
* 같은 짧은 케이스도 7회 LLM 호출했음. v2 는 *키워드 + 길이* 결합으로 진짜 무거운
|
|
* 케이스만 chunked.
|
|
*/
|
|
public static isObviouslySimple(prompt: string): boolean {
|
|
if (!prompt) return false;
|
|
const trimmed = prompt.trim();
|
|
if (trimmed.length === 0) return false;
|
|
|
|
// 본문 첨부 신호: 코드 펜스 / 긴 빈줄 / 마크다운 구분선 / 인용 다수.
|
|
const hasAttachment = /```|\n\n\n|^---$|^> .*\n> /m.test(trimmed);
|
|
if (hasAttachment) return false;
|
|
|
|
// 매우 긴 입력은 키워드 무관하게 chunked.
|
|
if (trimmed.length >= 400) return false;
|
|
|
|
// 분석/구조화 키워드.
|
|
const heavyKeyword = /(분석|리서치|조사|보고서|심층|상세히|꼼꼼히|기획|설계|아키텍처|리뷰|review|analyz|research|deep\s*analysis|strategy|proposal|보고|요약해서\s*정리)/i;
|
|
const hasKeyword = heavyKeyword.test(trimmed);
|
|
|
|
// 키워드 있고 입력이 길면(≥80자) chunked. 짧으면 (예: "이거 분석해줘") fast-path.
|
|
if (hasKeyword && trimmed.length >= 80) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Single-pass 경로: outline·section·polish 단계를 모두 건너뛰고 1회 LLM 호출로
|
|
* 즉답. fast-path 와 outline 빈배열 폴백 양쪽에서 공유.
|
|
*
|
|
* stage 전환은 'direct' 한 번만 발생 — audit trail 에서 fast-path / chunked 를
|
|
* 구분 가능.
|
|
*/
|
|
private async runSinglePass(
|
|
state: MissionState,
|
|
prompt: string,
|
|
brainContext: string,
|
|
signal: AbortSignal,
|
|
onProgress: (stage: PipelineStage, message: string) => void,
|
|
options: AgentExecuteOptions | undefined,
|
|
promptHash: string,
|
|
currentModel: string,
|
|
reason: 'fast-path' | 'outline-fallback',
|
|
): Promise<string> {
|
|
const stageMessage = reason === 'fast-path'
|
|
? '답변 작성 중... (단일 호출 fast-path)'
|
|
: '답변 작성 중... (outline 단일 답변 판정)';
|
|
|
|
const directAnswer = await this.executeStep(
|
|
state, 'direct', stageMessage,
|
|
() => this.resilientExecute(state, this.writer, 'Direct', prompt, brainContext, signal, onProgress, {
|
|
...options,
|
|
context: brainContext,
|
|
signal,
|
|
config: { ...options?.config, role: 'direct', allowFallback: true },
|
|
priorResults: {
|
|
originalPrompt: prompt,
|
|
previousValidData: prompt,
|
|
...options?.priorResults,
|
|
},
|
|
}),
|
|
`direct::${prompt}`, brainContext, signal, onProgress,
|
|
);
|
|
|
|
const wantsWikiFormat = options?.config?.formatAsKnowledgeArtifact === true;
|
|
const finalReport = wantsWikiFormat
|
|
? WikiFormatter.format(directAnswer, state)
|
|
: directAnswer;
|
|
|
|
CacheManager.set(prompt, `global_final_report_${promptHash}`, finalReport, currentModel);
|
|
CognitionAudit.auditPolicyCompliance('MissionComplete', finalReport);
|
|
this.transition(state, 'completed', '미션 완료', onProgress);
|
|
return finalReport;
|
|
}
|
|
|
|
/**
|
|
* Outline 호출 결과(JSON 문자열 기대)를 SectionOutline 배열로 파싱.
|
|
* 작은 모델이 코드펜스로 감싸거나 앞뒤에 prose를 흘리는 경우가 많아 3-stage
|
|
* tolerant parse: (1) raw, (2) fenced 안쪽, (3) 첫 [..] balanced 추출.
|
|
*
|
|
* 반환의 reason 값으로 호출자가 분기:
|
|
* - 'empty' — LLM 이 빈 배열 `[]` 로 "쪼갤 필요 없음" 명시. direct 폴백 발동.
|
|
* - 'ok' — N>=1 섹션 정상 파싱. chunked 진행.
|
|
* - 'fallback' — 응답이 비었거나 JSON 깨짐. 단일 "본문" 섹션으로 chunked 1회만 진행
|
|
* (옛 버전엔 길이로만 구분이 안 돼서 empty 와 fallback 이 혼동돼
|
|
* parse 실패가 우발적 single-pass 전환을 일으켰음).
|
|
*/
|
|
private parseOutline(raw: string, cap?: number): {
|
|
sections: Array<{ heading: string; scope: string }>;
|
|
reason: 'ok' | 'empty' | 'fallback';
|
|
} {
|
|
// cap 미지정 시 hard ceiling 으로 안전 보호. 정상 호출 경로에선 호출자가 사용자
|
|
// config 값 (chunkedMaxSections) 을 전달함.
|
|
const effectiveCap = Math.max(1, Math.min(
|
|
AgentEngine.MAX_SECTIONS_HARD_CEILING,
|
|
cap ?? AgentEngine.MAX_SECTIONS_HARD_CEILING,
|
|
));
|
|
const fallbackSections = [{ heading: '본문', scope: '사용자 요청 전체를 다루는 단일 섹션' }];
|
|
if (!raw || !raw.trim()) {
|
|
return { sections: fallbackSections, reason: 'fallback' };
|
|
}
|
|
|
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
const stage1 = (fenced ? fenced[1] : raw).trim();
|
|
|
|
// null = parse 자체 실패 / 형식 깨짐. [] = LLM 의 명시적 "쪼갤 필요 없음".
|
|
// 둘은 의미가 다르므로 호출자 측 분기가 가능하도록 union 으로 반환.
|
|
type ParseOk =
|
|
| { kind: 'empty' }
|
|
| { kind: 'sections'; list: Array<{ heading: string; scope: string }> };
|
|
const tryParse = (s: string): ParseOk | null => {
|
|
try {
|
|
const obj = JSON.parse(s);
|
|
if (!Array.isArray(obj)) return null;
|
|
if (obj.length === 0) return { kind: 'empty' };
|
|
const cleaned = obj
|
|
.map((o: any) => ({
|
|
heading: typeof o?.heading === 'string' ? o.heading.trim() : '',
|
|
scope: typeof o?.scope === 'string' ? o.scope.trim() : '',
|
|
}))
|
|
.filter((o) => o.heading.length > 0);
|
|
if (cleaned.length === 0) return null;
|
|
return { kind: 'sections', list: cleaned.slice(0, effectiveCap) };
|
|
} catch { return null; }
|
|
};
|
|
|
|
const direct = tryParse(stage1);
|
|
if (direct) {
|
|
return direct.kind === 'empty'
|
|
? { sections: [], reason: 'empty' }
|
|
: { sections: direct.list, reason: 'ok' };
|
|
}
|
|
|
|
// 첫 [...] balanced 추출
|
|
const start = stage1.indexOf('[');
|
|
const end = stage1.lastIndexOf(']');
|
|
if (start !== -1 && end > start) {
|
|
const balanced = tryParse(stage1.slice(start, end + 1));
|
|
if (balanced) {
|
|
return balanced.kind === 'empty'
|
|
? { sections: [], reason: 'empty' }
|
|
: { sections: balanced.list, reason: 'ok' };
|
|
}
|
|
}
|
|
|
|
logError('[AgentEngine] outline parse 실패 — 단일 본문 섹션 fallback (single-pass 전환 안 함).');
|
|
return { sections: fallbackSections, reason: 'fallback' };
|
|
}
|
|
|
|
/**
|
|
* 이전 섹션 본문을 polish 직전에 모델에 다시 넘길 때 쓸 짧은 요약 블록.
|
|
* 각 섹션을 300자 정도로 trim 해서 LLM이 "어디까지 적었나" 만 인지하게 한다.
|
|
* 통째로 다시 넣으면 토큰이 누적해서 의미가 없음.
|
|
*/
|
|
private trimPrevSections(
|
|
prev: string[],
|
|
sections: Array<{ heading: string; scope: string }>,
|
|
): string {
|
|
if (prev.length === 0) return '';
|
|
const TRIM = 300;
|
|
return prev
|
|
.map((text, i) => {
|
|
const heading = sections[i]?.heading ?? `섹션 ${i + 1}`;
|
|
const compact = (text || '').replace(/\s+/g, ' ').trim();
|
|
const clipped = compact.length > TRIM ? compact.slice(0, TRIM) + '...' : compact;
|
|
return `[${heading}] ${clipped}`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
/**
|
|
* `section` stage 1회 실행. `executeStep` 은 stage 키 1개를 캐싱키로 쓰므로
|
|
* N번 반복되는 section 에 그대로 못 쓰고 idx 별 key 로 분리해야 한다. 동작은
|
|
* executeStep 과 동일 (transition · abort · cache · resume · save).
|
|
*/
|
|
private async runSectionStep(
|
|
state: MissionState,
|
|
idx: number,
|
|
total: number,
|
|
progressMessage: string,
|
|
action: () => Promise<string>,
|
|
cacheKeyPrompt: string,
|
|
cacheKeyContext: string,
|
|
signal: AbortSignal,
|
|
onProgress: (stage: PipelineStage, message: string) => void,
|
|
): Promise<string> {
|
|
const resumeKey = `section_${idx}`;
|
|
const existing = state.getResult(resumeKey);
|
|
if (existing) {
|
|
logInfo(`[AgentEngine] [Resumption] section ${idx + 1}/${total} 결과가 이미 존재합니다.`);
|
|
return existing;
|
|
}
|
|
|
|
this.transition(state, 'section', progressMessage, onProgress);
|
|
this.checkAbort(signal);
|
|
|
|
const cacheKey = `section_${idx}::${cacheKeyPrompt}`;
|
|
const cached = CacheManager.get(cacheKey, cacheKeyContext);
|
|
let result: string;
|
|
if (cached) {
|
|
logInfo(`[AgentEngine] [Deduplication] section ${idx + 1}/${total} 캐시 히트.`);
|
|
state.resilienceMetrics.deduplications++;
|
|
result = cached;
|
|
} else {
|
|
result = await action();
|
|
CacheManager.set(cacheKey, cacheKeyContext, result);
|
|
}
|
|
|
|
state.setResult(resumeKey, result);
|
|
return result;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* [Astra v4.0] 맥락 증폭 로직
|
|
* 지식 관리 정책 v4.0을 LLM 지시사항으로 변환하여 주입합니다.
|
|
*/
|
|
private amplifyContext(context: string, options?: AgentExecuteOptions): string {
|
|
const level = options?.abstractionLevel || 'balanced';
|
|
|
|
const levelDirectives: Record<AbstractionLevel, string[]> = {
|
|
'laser-focused': [
|
|
"- [INTENSITY: HIGH] 불필요한 배경 설명과 중간 과정을 생략하고, 즉각적인 결론과 실행 가능한 To-Do 위주로 압축하십시오.",
|
|
"- [QUALITY] 정보의 양보다 '추론 기여 밀도'를 극한으로 높여 핵심 위주로 서술하십시오."
|
|
],
|
|
'balanced': [
|
|
"- [INTENSITY: MEDIUM] 핵심 논리를 중심으로 설명하되, 판단 근거가 되는 주요 데이터는 포함하십시오.",
|
|
"- [QUALITY] 지식의 양보다 '추론 기여 밀도'를 중시하여 서술하십시오."
|
|
],
|
|
'comprehensive': [
|
|
"- [INTENSITY: LOW] 전체 맥락을 파악할 수 있도록 배경 지식과 세부 데이터를 충분히 포함하십시오.",
|
|
"- [QUALITY] 포괄적인 분석을 위해 가용한 모든 데이터를 구조화하여 제공하십시오."
|
|
]
|
|
};
|
|
|
|
const policyDirectives = [
|
|
"\n### 🏛️ Knowledge Management Policy v4.1 (Filtering Applied)",
|
|
`- [ABSTRACTION LEVEL] 현재 에이전트 간 인터페이스 정책은 '${level}' 모드입니다.`,
|
|
...levelDirectives[level],
|
|
"- [CREDIBILITY] 정보 출처가 의도적으로 작성된 글인 경우 Medium 이상의 신뢰도를 부여하고 우선적으로 인용하십시오.",
|
|
"- [CONFLICT] 상충되는 지식 발견 시 스스로 판단하지 말고 반드시 [CONFLICT WARNING] 플래그와 함께 두 관점을 모두 보고하십시오."
|
|
].join('\n');
|
|
|
|
return `${context}\n${policyDirectives}`;
|
|
}
|
|
|
|
}
|