feat: v2.2.74 → v2.2.82 — chunked writer + 코드 리뷰 패치 + /youtube 확장
주요 변경: [chunked writer 아키텍처 (v2.2.74~v2.2.75)] - 5-stage 다중 에이전트(planner/researcher/reflector/writer/synthesizer) 파이프라인 제거 → 단일 ChunkedWriter 의 outline → section[N] → polish 3-step 으로 교체. 본문 분석에서 추상화 손실 / 토큰 폭증 문제 해소 - 답변 길이 자동 분기: 짧은 prompt 는 fast-path direct 1회 호출, 본문 분석은 chunked. outline 빈 배열도 direct 폴백 [코드 리뷰 9개 항목 일괄 패치 (v2.2.76)] - /research polling hang 방어 (heartbeat + status 정규화 + 연속 실패 abort) - 회사 모드 dispatcher abort 신호를 AIService.chat 까지 전달 - bridgeFetch 에 onHeartbeat 콜백 도입 (slow endpoint 사용자 친화적) - dead code 정리: reflectionPersister.ts 제거 + enableReflection 등 좀비 config 키 - parseOutline 의 empty vs fallback reason 명시적 분리 - chatHandlers 의 회사 모드 케이스 ~325줄을 src/sidebar/companyHandlers.ts 로 분리 - Intent Alignment 라운드 한도 도달 시 smart 모드 자동 진행 - LM Studio doSwitch unload 실패 시 currentModel 정리 + load 강행 - retrieval informationDensity → queryCoverage 정합화 [/youtube 채널 지원 (v2.2.77~v2.2.82)] - 채널/플레이리스트 URL 자동 감지 + n:N 으로 영상 개수 지정 (최대 50) - 채널 루트 URL 에 /videos 탭 자동 append (yt-dlp enumeration 정상화) - 영상별 순차 처리 (queue 패턴) + i/N 진행 표시 + 마지막 통계 요약 - mode:info / mode:benchmark / mode:both 분석 모드 분기 - info: 영상 내용을 지식 카드로 추출 (튜토리얼·강의·뉴스용) - benchmark: 4-렌즈 대본 역기획서 (콘텐츠 제작 벤치마크용) - both: 둘 다 (기본) - bare keyword 도 허용: /youtube <url> n:1 info - bridge 에러 메시지 [object Object] 깨짐 수정 (구조화 에러 추출) - "패키지 없음" 등 환경 의존성 에러에 자동 가이드 첨부 [Astra: Setup Datacollect Dependencies 명령 추가 (v2.2.80)] - Python 자동 감지 + yt-dlp / youtube-transcript-api 자동 설치 - macOS PEP 668 환경 자동 폴백 (--user --break-system-packages) - /youtube 등에서 패키지 미설치 감지 시 "Install Now" 버튼 notification [테스트] - tests/agentEngine.test.ts 를 chunked flow 에 맞춰 전체 재작성 - tests/resilience_stress.test.ts Scenario B/D 를 role-aware mock 으로 갱신 - 399/399 통과 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+333
-192
@@ -4,12 +4,10 @@ import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { lockManager } from '../core/lock';
|
||||
import { actionQueue } from '../core/queue';
|
||||
import { logInfo, logError, getActiveBrainProfile } from '../utils';
|
||||
import { logInfo, logError } from '../utils';
|
||||
import { AgentDataValidator, PerformanceProfiler, CognitionAudit } from './diagnostics';
|
||||
import { WikiFormatter } from './formatter';
|
||||
import { ErrorType, RecoveryRule } from '../types/interfaces';
|
||||
import { getConfig } from '../config';
|
||||
import { persistReflectionAsLesson } from '../agents/reflectionPersister';
|
||||
export { ErrorType, RecoveryRule };
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -49,9 +47,19 @@ export interface IAgent {
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 파이프라인 단계 상태 정의
|
||||
* 파이프라인 단계 상태 정의.
|
||||
*
|
||||
* 예전엔 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' | 'planner' | 'researcher' | 'reflector' | 'writer' | 'synthesizer' | 'completed' | 'error';
|
||||
export type PipelineStage = 'idle' | 'outline' | 'section' | 'polish' | 'direct' | 'completed' | 'error';
|
||||
|
||||
/**
|
||||
* 감사(Audit) 이력에 기록되는 단일 상태 전환 엔트리.
|
||||
@@ -448,19 +456,28 @@ export class CacheManager {
|
||||
* - Error Recovery Matrix 기반의 Transient/Permanent 오류 자동 분류 및 복구
|
||||
*/
|
||||
export class AgentEngine {
|
||||
/** Outline LLM이 제안한 N을 강제로 1..MAX_SECTIONS 로 clamp 한다. */
|
||||
static readonly MAX_SECTIONS = 5;
|
||||
|
||||
/**
|
||||
* 단일 writer agent — 같은 모델이 outline / section / polish 역할을 번갈아
|
||||
* 수행한다. 역할 분기는 options.config.role 로 ChunkedWriter 내부에서 처리.
|
||||
*
|
||||
* 하위호환을 위해 추가 IAgent 인자(`_legacyAgents`)를 받지만 사용하지 않는다.
|
||||
* 기존 호출처에서 planner/researcher 등을 같이 넘겨도 컴파일은 통과.
|
||||
*/
|
||||
constructor(
|
||||
private readonly planner: IAgent,
|
||||
private readonly researcher: IAgent,
|
||||
private readonly writer: IAgent,
|
||||
// [Self-Reflection] Researcher와 Writer 사이에 주입되는 메타인지 노드. 미주입 시 기존 3단계 파이프라인을 그대로 유지.
|
||||
private readonly reflector?: IAgent,
|
||||
// [5-stage pipeline] Writer(=Drafter)가 만든 초안을 사용자용 최종 답변으로 다듬는 노드.
|
||||
// 미주입 시 Writer 출력이 그대로 최종 답변이 된다(기존 동작 유지).
|
||||
private readonly synthesizer?: IAgent
|
||||
..._legacyAgents: Array<IAgent | undefined>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 멀티 에이전트 워크플로우 실행 (Refactored: Atomic State Machine)
|
||||
* 단일 writer 기반 chunked 워크플로우 실행.
|
||||
*
|
||||
* outline → section[1..N] → polish
|
||||
*
|
||||
* Resilience layer(MissionState · ErrorClassifier · CacheManager)는 그대로
|
||||
* 재사용하되, persona별 agent 5개를 줄세우던 옛 phase 구조만 제거했다.
|
||||
*/
|
||||
public async runMission(
|
||||
missionId: string,
|
||||
@@ -495,155 +512,121 @@ export class AgentEngine {
|
||||
return globalCache;
|
||||
}
|
||||
|
||||
// --- Phase 1: Planner ---
|
||||
const plan = await this.executeStep(
|
||||
state, 'planner', '전략 수립 중...',
|
||||
() => this.resilientExecute(state, this.planner, 'Planner', prompt, brainContext, signal, onProgress, {
|
||||
...options,
|
||||
context: brainContext,
|
||||
signal,
|
||||
config: { ...options?.config, role: 'planner', isSamePrompt: true }
|
||||
}),
|
||||
prompt, brainContext, signal, onProgress
|
||||
);
|
||||
|
||||
const plannerScore = this.validateResult(plan, 'Planner');
|
||||
// [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', isSamePrompt: true },
|
||||
abstractionLevel: researcherLevel
|
||||
}),
|
||||
plan, brainContext, signal, onProgress
|
||||
);
|
||||
|
||||
// --- Phase 3: Context Preparation (Side Effect of Phase 2) ---
|
||||
let writerPrep = state.getResult('writerPrep');
|
||||
if (!writerPrep) {
|
||||
writerPrep = await this.prepareWriterContext(prompt, plan, brainContext);
|
||||
state.setResult('writerPrep', writerPrep);
|
||||
// --- 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',
|
||||
);
|
||||
}
|
||||
|
||||
const researchScore = this.validateResult(research, 'Researcher');
|
||||
// [Structural Fix] 점수가 낮을수록 더 상세한 근거를 요구(comprehensive)하도록 로직 역전
|
||||
const writerLevel: AbstractionLevel = researchScore < 65 ? 'comprehensive' : 'balanced';
|
||||
|
||||
// --- Phase 3.5: Reflector (Self-Reflection) ---
|
||||
// Reflector가 주입되어 있고 옵션에서 명시적으로 끄지 않은 경우에만 실행한다.
|
||||
// 실패해도 파이프라인을 막지 않는다(soft-fail): Reflector는 품질 보강이지 필수 게이트가 아님.
|
||||
let reflection = '';
|
||||
const reflectionDisabled = options?.config?.enableReflection === false;
|
||||
if (this.reflector && !reflectionDisabled) {
|
||||
try {
|
||||
reflection = await this.executeStep(
|
||||
state, 'reflector', '중간 산출물 자기검증 중...',
|
||||
() => this.resilientExecute(state, this.reflector!, 'Reflector', research, brainContext, signal, onProgress, {
|
||||
...options,
|
||||
context: brainContext,
|
||||
signal,
|
||||
config: { ...options?.config, role: 'reflector', isSamePrompt: true },
|
||||
priorResults: { plan, originalPrompt: prompt, ...options?.priorResults },
|
||||
abstractionLevel: 'balanced'
|
||||
}),
|
||||
// [Cache namespace] Writer와 동일한 (research, prompt) 페어를 쓰면 CacheManager가
|
||||
// Writer 호출 시 reflector 결과를 그대로 반환해버린다. 단계명을 prefix로 분리.
|
||||
`reflector::${research}`, prompt, signal, onProgress
|
||||
);
|
||||
} catch (reflErr: any) {
|
||||
// Reflector 실패는 치명적이지 않다. 감사 이력에만 남기고 빈 reflection으로 Writer를 진행시킨다.
|
||||
if (reflErr?.name === 'AbortError') throw reflErr;
|
||||
logError(`[AgentEngine] Reflector soft-fail — Writer 계속 진행: ${reflErr?.message || reflErr}`);
|
||||
reflection = '';
|
||||
}
|
||||
|
||||
// [Self-Reflection → Knowledge] Reflector가 의미 있는 critique을 내놓았으면
|
||||
// brain에 lesson 카드로 영속화한다. 다음 미션의 Planner/Researcher/Writer는
|
||||
// 기존 lesson retrieval 경로를 통해 이 카드를 자동으로 inject받는다.
|
||||
// 동일 패턴 재발 시 카드를 새로 만들지 않고 occurrences를 증가시키며 severity를
|
||||
// low→medium→high로 가중. fire-and-forget으로 미션 흐름을 막지 않는다.
|
||||
if (reflection && getConfig().autoLessonFromReflection !== false) {
|
||||
try {
|
||||
const brainDir = getActiveBrainProfile()?.localBrainPath;
|
||||
if (brainDir) {
|
||||
const result = persistReflectionAsLesson({
|
||||
reflection,
|
||||
originalPrompt: prompt,
|
||||
brainDir,
|
||||
});
|
||||
if (result) {
|
||||
logInfo(`[AgentEngine] Reflector critique → lesson (${result.bumped ? 'bumped' : 'new'}, severity=${result.severity}, occ=${result.occurrences}).`);
|
||||
}
|
||||
}
|
||||
} catch (persistErr: any) {
|
||||
// Lesson 영속화 실패는 미션 결과에 영향 없음 — 로그만 남기고 계속 진행.
|
||||
logError(`[AgentEngine] lesson 영속화 실패 (무시): ${persistErr?.message || persistErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 4: Writer ---
|
||||
const finalReport = await this.executeStep(
|
||||
state, 'writer', '최종 리포트 작성 및 편집 중...',
|
||||
() => this.resilientExecute(state, this.writer, 'Writer', research, prompt, signal, onProgress, {
|
||||
// --- Phase 1: Outline ---
|
||||
// 1번의 LLM 호출로 답변을 몇 개 섹션으로 쪼갤지 결정. JSON 배열 반환.
|
||||
const outlineRaw = await this.executeStep(
|
||||
state, 'outline', '답변 구조 잡는 중...',
|
||||
() => this.resilientExecute(state, this.writer, 'Outline', prompt, brainContext, signal, onProgress, {
|
||||
...options,
|
||||
context: brainContext,
|
||||
signal,
|
||||
config: { role: 'writer', allowFallback: true, isSamePrompt: true, ...options?.config },
|
||||
priorResults: { plan, writerPrep, reflection, previousValidData: state.getResult('finalReport'), ...options?.priorResults },
|
||||
abstractionLevel: writerLevel
|
||||
config: { ...options?.config, role: 'outline' },
|
||||
}),
|
||||
research, prompt, signal, onProgress
|
||||
`outline::${prompt}`, brainContext, signal, onProgress
|
||||
);
|
||||
|
||||
state.setResult('finalReport', finalReport);
|
||||
const outline = this.parseOutline(outlineRaw);
|
||||
const sections = outline.sections;
|
||||
|
||||
// --- Phase 4.5: Synthesizer (final polish) ---
|
||||
// Drafter(=Writer) 출력은 "초안"이다. Synthesizer가 주어졌으면 한 번 더 압축/매끄럽게 정리한다.
|
||||
// 입력이 작은 draft 뿐이라 컨텍스트가 가벼워, 작은 로컬 모델도 한 번에 처리할 수 있다.
|
||||
// 실패해도 미션을 막지 않고 Drafter 출력을 그대로 사용한다(soft-fail).
|
||||
let polishedReport = finalReport;
|
||||
if (this.synthesizer) {
|
||||
try {
|
||||
polishedReport = await this.executeStep(
|
||||
state, 'synthesizer', '최종 답변 다듬기 중...',
|
||||
() => this.resilientExecute(state, this.synthesizer!, 'Synthesizer', finalReport, prompt, signal, onProgress, {
|
||||
...options,
|
||||
context: brainContext,
|
||||
signal,
|
||||
config: { ...options?.config, role: 'synthesizer', isSamePrompt: true },
|
||||
priorResults: { plan, reflection, originalPrompt: prompt, ...options?.priorResults },
|
||||
abstractionLevel: 'balanced'
|
||||
}),
|
||||
`synthesizer::${finalReport}`, prompt, signal, onProgress
|
||||
);
|
||||
if (!polishedReport || polishedReport.trim().length < 24) {
|
||||
// 합성기가 빈/잘린 결과를 내면 안전하게 초안 사용.
|
||||
logError('[AgentEngine] Synthesizer returned empty/tiny output — using Drafter output.');
|
||||
polishedReport = finalReport;
|
||||
}
|
||||
} catch (synthErr: any) {
|
||||
if (synthErr?.name === 'AbortError') throw synthErr;
|
||||
logError(`[AgentEngine] Synthesizer soft-fail — using Drafter output: ${synthErr?.message || synthErr}`);
|
||||
polishedReport = finalReport;
|
||||
}
|
||||
// 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',
|
||||
);
|
||||
}
|
||||
|
||||
// --- Phase 5: Advice & Standardization ---
|
||||
const proactiveAdvice = await this.generateProactiveAdvice(polishedReport, prompt, brainContext, signal);
|
||||
|
||||
// [Structural Fix] 생성된 제안의 무결성 검증 (최소 길이 50자 이상일 때만 append)
|
||||
const enrichedReport = proactiveAdvice && proactiveAdvice.length > 50
|
||||
? `${polishedReport}\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\n${proactiveAdvice}`
|
||||
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;
|
||||
|
||||
const standardizedReport = WikiFormatter.format(enrichedReport, state);
|
||||
// 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);
|
||||
@@ -851,18 +834,209 @@ export class AgentEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer가 사용할 초기 컨텍스트를 사전에 구성합니다.
|
||||
* Researcher와 병렬로 실행되어 Phase 3 진입 시 즉시 활용 가능합니다.
|
||||
* Fast-path 휴리스틱: prompt 가 "쪼갤 필요 없는 단순 케이스" 인지 즉시 판정.
|
||||
* 명백할 때만 true — 애매한 중간 길이는 false 로 반환해 outline LLM 이 판정하게 위임.
|
||||
*
|
||||
* 단순 기준:
|
||||
* - 길이 < 200자
|
||||
* - 본문 첨부 신호 없음 (코드 펜스, 긴 빈줄, --- 구분선)
|
||||
* - 분석/리서치 키워드 없음 (분석/리서치/조사/보고서/심층/설계/기획/꼼꼼히/상세히)
|
||||
*/
|
||||
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');
|
||||
public static isObviouslySimple(prompt: string): boolean {
|
||||
if (!prompt) return false;
|
||||
const trimmed = prompt.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (trimmed.length >= 200) return false;
|
||||
|
||||
logInfo(`[AgentEngine] [WriterPrep] 초기 컨텍스트 준비 완료 (${contextSummary.length} chars)`);
|
||||
return contextSummary;
|
||||
// 본문 첨부 신호: 코드 펜스 / 긴 빈줄 / 마크다운 구분선 / 인용 다수.
|
||||
const hasAttachment = /```|\n\n\n|^---$|^> .*\n> /m.test(trimmed);
|
||||
if (hasAttachment) return false;
|
||||
|
||||
// 분석/구조화 키워드.
|
||||
const heavyKeyword = /(분석|리서치|조사|보고서|심층|상세히|꼼꼼히|기획|설계|아키텍처|리뷰|review|analyz|research|deep\s*analysis|strategy|proposal|보고|요약해서\s*정리)/i;
|
||||
if (heavyKeyword.test(trimmed)) 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): {
|
||||
sections: Array<{ heading: string; scope: string }>;
|
||||
reason: 'ok' | 'empty' | 'fallback';
|
||||
} {
|
||||
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, AgentEngine.MAX_SECTIONS) };
|
||||
} 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 {
|
||||
@@ -871,12 +1045,6 @@ export class AgentEngine {
|
||||
return clean.length > length ? clean.substring(0, length) + '...' : clean;
|
||||
}
|
||||
|
||||
private validateResult(data: string, step: string): number {
|
||||
// Error Recovery Matrix: Permanent 오류 발생을 방지하기 위한 선제적 핸드오프 검증
|
||||
const validation = AgentDataValidator.validateHandoff(step, data);
|
||||
return validation.score;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Astra v4.0] 맥락 증폭 로직
|
||||
* 지식 관리 정책 v4.0을 LLM 지시사항으로 변환하여 주입합니다.
|
||||
@@ -910,31 +1078,4 @@ export class AgentEngine {
|
||||
return `${context}\n${policyDirectives}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Astra v4.0] 선제적 제안 생성
|
||||
* 수행된 작업 결과를 분석하여 다음 단계의 의사결정 포크를 제안합니다.
|
||||
*/
|
||||
private async generateProactiveAdvice(report: string, originalPrompt: string, context: string, signal: AbortSignal): Promise<string> {
|
||||
// [Structural Fix] 절단 없는 컨텍스트 전달 (LLM 상상력 제한)
|
||||
const advicePrompt = `사용자의 원래 요청과 작성된 최종 리포트를 바탕으로,
|
||||
사용자가 다음에 내려야 할 '전략적 의사결정'이나 '실행 작업' 3가지를 구체적으로 제안해주십시오.
|
||||
존재하지 않는 사실을 지어내지 말고, 리포트에 명시된 근거만을 활용하십시오.
|
||||
|
||||
원래 요청: ${originalPrompt}
|
||||
리포트 내용:
|
||||
${report}`;
|
||||
|
||||
try {
|
||||
// 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