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:
@@ -1,11 +1,18 @@
|
||||
import { PlannerAgent, ResearcherAgent, ReflectorAgent, WriterAgent, SynthesizerAgent } from './factory';
|
||||
import { ChunkedWriter } from './factory';
|
||||
import { AgentEngine, PipelineStage, AgentExecuteOptions } from '../lib/engine';
|
||||
import { getConfig } from '../config';
|
||||
|
||||
/**
|
||||
* Multi-agent 워크플로우를 외부에 노출하는 얇은 매니저.
|
||||
*
|
||||
* 예전엔 planner / researcher / reflector / writer / synthesizer 5개 persona 를
|
||||
* 줄세웠지만, 각 hop 마다 컨텍스트가 누적되고 원본 본문이 추상화로 손실돼
|
||||
* 사용자가 본문 분석을 요청해도 "분석 방법론" 만 만들어내는 사고가 있었음.
|
||||
*
|
||||
* 현재 흐름: 단일 ChunkedWriter 가 outline → section[N] → polish 세 역할을
|
||||
* 같은 모델에서 번갈아 수행 → 각 호출은 작고(컨텍스트 폭증 없음), 본문은
|
||||
* 매 호출에 직접 전달돼 손실 없음.
|
||||
*/
|
||||
export class AgentWorkflowManager {
|
||||
/**
|
||||
* 리팩토링된 고성능 에이전트 엔진을 통해 워크플로우를 실행합니다.
|
||||
*/
|
||||
public static async runStrictWorkflow(
|
||||
prompt: string,
|
||||
modelName: string,
|
||||
@@ -13,21 +20,12 @@ export class AgentWorkflowManager {
|
||||
signal: AbortSignal,
|
||||
onProgress: (step: string, message: string) => void
|
||||
): Promise<string> {
|
||||
const planner = new PlannerAgent(modelName);
|
||||
const researcher = new ResearcherAgent(modelName);
|
||||
const writer = new WriterAgent(modelName);
|
||||
// [Self-Reflection] 설정으로 비활성화하지 않은 경우에만 Reflector를 주입.
|
||||
const cfg = getConfig();
|
||||
const enableReflection = cfg.enableReflection !== false;
|
||||
const reflector = enableReflection ? new ReflectorAgent(modelName) : undefined;
|
||||
// [5-stage pipeline] 최종 합성 단계. 설정으로 끄지 않은 한 항상 주입.
|
||||
const enableSynth = cfg.workflowSynthesizerEnabled !== false;
|
||||
const synthesizer = enableSynth ? new SynthesizerAgent(modelName) : undefined;
|
||||
const engine = new AgentEngine(planner, researcher, writer, reflector, synthesizer);
|
||||
const writer = new ChunkedWriter(modelName);
|
||||
const engine = new AgentEngine(writer);
|
||||
const missionId = `mission_${Date.now()}`;
|
||||
|
||||
const runOptions: AgentExecuteOptions = {
|
||||
config: { enableReflection }
|
||||
config: { model: modelName },
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -50,16 +48,14 @@ export class AgentWorkflowManager {
|
||||
}
|
||||
|
||||
private static mapStageToUI(stage: PipelineStage): string {
|
||||
// 사용자가 보는 라벨은 한국어 + 단계 번호로 통일. 5단계 파이프라인이 명확하게 드러나도록.
|
||||
const maps: Record<PipelineStage, string> = {
|
||||
idle: '대기',
|
||||
planner: '① 계획',
|
||||
researcher: '② 자료 수집',
|
||||
reflector: '③ 자기 검증',
|
||||
writer: '④ 초안 작성',
|
||||
synthesizer: '⑤ 최종 정리',
|
||||
outline: '① 구조 잡기',
|
||||
section: '② 본문 작성',
|
||||
polish: '③ 최종 다듬기',
|
||||
direct: '⚡ 즉답',
|
||||
completed: '완료',
|
||||
error: '오류'
|
||||
error: '오류',
|
||||
};
|
||||
return maps[stage] || '진행 중';
|
||||
}
|
||||
|
||||
+136
-162
@@ -63,7 +63,7 @@ export abstract class BaseAgent {
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
|
||||
|
||||
// 강력한 응답 추출 (Multi-path parsing)
|
||||
let content = '';
|
||||
if (data.message?.content) content = data.message.content;
|
||||
@@ -71,7 +71,7 @@ export abstract class BaseAgent {
|
||||
else if (data.choices?.[0]?.text) content = data.choices[0].text;
|
||||
else if (data.response) content = data.response;
|
||||
else if (typeof data === 'string') content = data;
|
||||
|
||||
|
||||
return content || '';
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -99,175 +99,149 @@ function anySignal(signals: AbortSignal[]): AbortSignal {
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export class PlannerAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Master Strategist & Planner].
|
||||
Your sole purpose is to transform vague requests into flawless, high-resolution execution blueprints.
|
||||
- THINKING PROCESS: You must analyze the request from multiple angles (technical, logical, structural).
|
||||
- OUTPUT RULE: You MUST output a structured <blueprint> using Markdown.
|
||||
- COMPONENTS: Each blueprint must have [Objective], [Core Challenges], [Data Requirements], and [Step-by-Step Research Tasks].
|
||||
- CONSTRAINT: Do not be vague. Use professional terminology. If the request is too simple, expand it with relevant technical considerations.`;
|
||||
|
||||
async execute(input: string, brainContext?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
|
||||
const wrappedInput = `### SYSTEM INSTRUCTION: GENERATE EXECUTION BLUEPRINT
|
||||
1. Target Goal: ${input}
|
||||
2. Available Knowledge Base & Policy: ${brainContext}
|
||||
3. Mission: Create a comprehensive research roadmap.`;
|
||||
return this.callLLM(this.persona, wrappedInput, signal);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResearcherAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Senior Technical Researcher].
|
||||
Your mission is to extract, filter, and synthesize critical data based on a strategic blueprint.
|
||||
- DATA INTEGRITY: Only provide high-quality, verified-style information.
|
||||
- FORMAT: Use [Key Facts], [Technical Deep-Dive], and [Summary of Knowledge] sections.
|
||||
- CRITICAL THINKING: Identify gaps in the plan and provide extra insights to fill those gaps.
|
||||
- NO FLUFF: Be concise but extremely dense with information.`;
|
||||
|
||||
async execute(input: string, brainContext?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
|
||||
const wrappedInput = `### SYSTEM INSTRUCTION: DATA HARVESTING
|
||||
1. Blueprint to Follow: ${input}
|
||||
2. Contextual Constraints & Policy: ${brainContext}
|
||||
3. Mission: Provide a dense summary of facts and technical insights.`;
|
||||
return this.callLLM(this.persona, wrappedInput, signal);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriterAgent extends BaseAgent {
|
||||
// [5-stage pipeline] Writer는 이제 "Drafter" 역할: 빠르게 1차 초안만 생성한다.
|
||||
// 최종 다듬기/요약/critique 반영은 후속 SynthesizerAgent가 담당하므로,
|
||||
// 작은 모델이 한 번에 모든 것을 끝내려 컨텍스트를 폭주시키는 일이 없도록 한다.
|
||||
private readonly persona = `You are the [Section Drafter].
|
||||
Your goal is to produce a STRUCTURED FIRST DRAFT that the downstream Synthesizer will polish.
|
||||
- SCOPE: Cover each major topic from the research as its own section. Each section starts with a short plain-text label on its own line (e.g. "잘된 점", "부족한 점") — NO "#", "##", "**", "__", ">" markers. Use "- " for bullets, never "* ".
|
||||
- DENSITY: Pack facts; skip flowery prose, executive summaries, and closing remarks (the Synthesizer adds those).
|
||||
- TONE: Plain, factual, developer-readable Korean.
|
||||
- BREVITY: Keep each section tight — better to leave the Synthesizer something to merge than to run out of tokens mid-section.
|
||||
- SELF-CORRECTION: When a [REFLECTION CRITIQUE] block is provided, address each listed gap inline in the relevant section. Do not silently ignore the critique.
|
||||
- LANGUAGE: KOREAN.`;
|
||||
|
||||
async execute(input: string, originalRequest?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
|
||||
// [Astra v4.0] Advisor 모드 처리
|
||||
if (options?.config?.role === 'advisor') {
|
||||
const advisorPersona = `You are the [Strategic Proactive Advisor].
|
||||
Analyze the provided report and suggest 3 high-impact next actions for the user.
|
||||
- Focus on decision forks, risk mitigation, or immediate implementation steps.
|
||||
- Be extremely concrete and actionable.
|
||||
- Respond in KOREAN.`;
|
||||
return this.callLLM(advisorPersona, input, signal);
|
||||
}
|
||||
|
||||
// Fix 3: Trim input if it's too long (Basic Context Diet)
|
||||
const trimmedData = input.length > 8000 ? input.substring(0, 8000) + '... [Data Trimmed for Performance]' : input;
|
||||
|
||||
const policy = options?.context || '';
|
||||
const reflection = options?.priorResults?.reflection;
|
||||
// Reflector 결과가 있으면 별도 블록으로 주입. 길이 4000자 cap (Writer 입력 비대화 방지).
|
||||
const reflectionBlock = reflection && reflection.trim().length > 0
|
||||
? `\n5. [REFLECTION CRITIQUE — must be addressed]:\n${reflection.length > 4000 ? reflection.substring(0, 4000) + '... [Critique Trimmed]' : reflection}`
|
||||
: '';
|
||||
|
||||
const wrappedInput = `### SYSTEM INSTRUCTION: SECTIONED DRAFT
|
||||
1. Gathered Research Data: ${trimmedData}
|
||||
2. User's Original Objective: ${originalRequest}
|
||||
3. Applied Knowledge & Filtering Policy: ${policy}
|
||||
4. Mission: Produce a STRUCTURED FIRST DRAFT in KOREAN — section per topic, factual bullets allowed.
|
||||
Do NOT add a final executive summary or closing remarks; the Synthesizer will handle those.${reflectionBlock}`;
|
||||
return this.callLLM(this.persona, wrappedInput, signal);
|
||||
}
|
||||
/**
|
||||
* Section outline shape produced by ChunkedWriter in the 'outline' role.
|
||||
* Tokens are kept minimal — heading is what the section is about, scope tells
|
||||
* the next call what facts to keep inside that section so adjacent sections
|
||||
* don't duplicate content.
|
||||
*/
|
||||
export interface SectionOutline {
|
||||
heading: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* [5-stage pipeline] SynthesizerAgent
|
||||
* Drafter가 작성한 1차 초안을 받아 최종 사용자 답변으로 다듬는다.
|
||||
* - 입력이 "이미 정리된 draft" 라서 컨텍스트가 작다 → 작은 로컬 모델도 한 번에 처리 가능.
|
||||
* - 역할은 (a) 도입 한 줄 (b) 섹션 흐름 정리 (c) 결론/제안 한 단락. 새로운 사실을 만들지 않는다.
|
||||
* - Reflector critique이 함께 전달되면, 그 항목들이 답변에 정말 반영되었는지 한 번 더 점검한다.
|
||||
* ChunkedWriter — single-agent replacement for the old 5-stage pipeline.
|
||||
*
|
||||
* Why this exists: the old pipeline (planner → researcher → reflector → writer
|
||||
* → synthesizer) was different *personas* in series, which (a) burned tokens
|
||||
* by repeating context at every hop and (b) drifted away from the user's
|
||||
* actual request because intermediate agents only saw earlier agents'
|
||||
* abstractions — never the original message. The user's intent was simpler:
|
||||
* **split the *answer* into chunks so each LLM call stays under the token
|
||||
* cap, then join.** That's what this class does.
|
||||
*
|
||||
* Flow inside `AgentEngine.runMission`:
|
||||
* 1. role='outline' → 1 LLM call returns a JSON list of section outlines
|
||||
* (N = 1..MAX, the model decides based on expected
|
||||
* output length).
|
||||
* 2. role='section' → N LLM calls, one per outline entry, each given the
|
||||
* original prompt + this section's scope + already-
|
||||
* written sections (truncated) so it can avoid
|
||||
* repeating earlier content.
|
||||
* 3. role='polish' → 1 LLM call takes the joined draft and produces a
|
||||
* final clean copy (fixes typos, removes
|
||||
* hallucinations / unsupported claims, smooths flow).
|
||||
*
|
||||
* Every role uses the *same* model — no persona mismatch, no agent-to-agent
|
||||
* abstraction loss. The only thing that changes is the per-call system
|
||||
* prompt picked here based on `options.config.role`.
|
||||
*/
|
||||
export class SynthesizerAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Final Editor & Synthesizer].
|
||||
You receive a structured FIRST DRAFT (already broken into sections) plus the user's original request and (optionally) a reflection critique.
|
||||
Your only job is to produce the FINAL user-facing answer.
|
||||
export class ChunkedWriter extends BaseAgent {
|
||||
/** Hard cap on section count regardless of what the outline model returns. */
|
||||
static readonly MAX_SECTIONS = 5;
|
||||
|
||||
[OUTPUT FORMAT — 7 hard rules — these override every other formatting habit]
|
||||
R1. CONCLUSION FIRST. The very first sentence is the conclusion / verdict / recommendation. No greeting, no "분석해보겠습니다", no scene-setting paragraph, no "핵심 요약" label line on top. Just the conclusion as sentence 1. A reader who stops after sentence 1 must know what you decided.
|
||||
R2. AT MOST 3 SECTIONS. Total. A section = a label line + body, or a clearly separated numbered group. If the answer fits without sections, use none. Three is the ceiling, not a target.
|
||||
R3. NO REPETITION. Each sentence carries new information. If you said it in the conclusion, do NOT restate it in a later section.
|
||||
R4. BOLD ≤ 3 INSTANCES. Across the entire answer, use bold at most 3 times — reserve it for truly load-bearing words (file name, verdict word, hard number). Most answers should have zero.
|
||||
R5. JUDGE WITHOUT ASKING. If a defensible decision is reachable from the draft + original request, deliver it and act. Do NOT ask permission, do NOT bounce the question back.
|
||||
R6. ASK ONE QUESTION ONLY WHEN: (a) the path forks into two materially different directions and user intent is unknown, OR (b) the next step is irreversible (delete, force-push, drop table, overwrite uncommitted work, send external message). One plain sentence on its own line at the end. No "핵심 확인 질문" label, no "질문 의도", no follow-ups.
|
||||
R7. GUESS-AND-ACT WITH STATED ASSUMPTION. If a detail is missing but a reasonable guess exists, guess and act, declaring the assumption in one line prefixed "가정:".
|
||||
private readonly outlinePersona = `You are a concise editor planning the structure of a Korean answer.
|
||||
Decide how many sections the answer needs (0..${ChunkedWriter.MAX_SECTIONS}). Pick the *smallest* number that still covers the user's request well — a short factual question should be 0-1 section, a meaty analysis 3-5.
|
||||
|
||||
[PLAIN TEXT]
|
||||
- NEVER emit "#", "##", "###", "__", "> " markers. Section labels are plain text on their own line.
|
||||
- Bullets: "- " only. No "* " / "• ".
|
||||
- No tables. No HTML.
|
||||
- Inline code with backticks is OK (e.g. \`src/agent.ts\`). Triple-backtick code blocks only for actual code.
|
||||
Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\`. No prose, no fences, no leading text.
|
||||
- 🟢 **빈 배열 \`[]\`** = "쪼갤 필요 없음". 사용자 질문이 간단해서 단일 LLM 호출로 즉답이 더 빠르고 자연스러울 때 (예: 단순 사실 질문, 짧은 코드 한 줄, 정의 묻기). 시스템이 이걸 받으면 outline·section 단계 건너뛰고 1회 직답으로 처리한다.
|
||||
- heading: a short Korean section label (≤ 24 chars). For 1-section answers, set heading to "본문".
|
||||
- scope: one Korean sentence describing exactly what facts/points belong inside this section so adjacent sections don't overlap.
|
||||
|
||||
[CONTENT]
|
||||
- Preserve every factual claim from the draft. Do NOT invent new facts, do NOT add hidden reasoning, do NOT write meta-commentary.
|
||||
- DO NOT EMIT: <think>, <analysis>, <|channel|> markers, "Thinking Process:", planning notes, or any hidden reasoning.
|
||||
- If a [REFLECTION CRITIQUE] is provided, verify each item is addressed. If something is missing, say so explicitly rather than fabricating coverage.
|
||||
- LANGUAGE: KOREAN. Tone: direct, technical, developer-friendly.`;
|
||||
판단 기준:
|
||||
- 답변이 한 단락 (대략 3~5문장) 이내로 완결 가능 → \`[]\`
|
||||
- 본문 분석·여러 측면 비교·구조화된 보고서가 필요 → N개 섹션
|
||||
|
||||
async execute(input: string, originalRequest?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
|
||||
const draft = input.length > 12000 ? input.substring(0, 12000) + '... [Draft Trimmed]' : input;
|
||||
const reflection = options?.priorResults?.reflection;
|
||||
const reflectionBlock = reflection && reflection.trim().length > 0
|
||||
? `\n4. [REFLECTION CRITIQUE — verify the draft addresses each item]:\n${reflection.length > 3000 ? reflection.substring(0, 3000) + '... [Critique Trimmed]' : reflection}`
|
||||
If the user attached source content (article/code/log) the sections must cover *that content*, not analysis methodology.`;
|
||||
|
||||
private readonly sectionPersona = `You are writing ONE section of a longer Korean answer. You will be given:
|
||||
- the user's original request (possibly with attached content),
|
||||
- this section's heading + scope,
|
||||
- the full outline (for context only — DO NOT write other sections),
|
||||
- already-written previous sections (so you can avoid repeating them).
|
||||
|
||||
Rules:
|
||||
- Stay strictly inside this section's scope. Do NOT cover other outline entries.
|
||||
- Korean, plain markdown (no top-level "#" — the heading will be added by the joiner).
|
||||
- Pack facts. Avoid filler / executive summaries / closing remarks (the polish pass adds those).
|
||||
- If the user attached source content, cite from it; do not invent facts.
|
||||
- Do NOT output the heading itself — only the body of this section.`;
|
||||
|
||||
private readonly polishPersona = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
|
||||
|
||||
Job:
|
||||
1. Fix typos, broken markdown, inconsistent terminology.
|
||||
2. Remove unsupported claims / hallucinations: if a sentence asserts a fact that isn't grounded in the user's request (or the earlier sections themselves), delete it. Better to be short than wrong.
|
||||
3. Smooth section transitions and remove duplicated information across sections.
|
||||
4. Open with the conclusion / key takeaway in the first sentence (no "분석해보겠습니다", no preamble).
|
||||
5. Preserve every factually grounded claim from the draft. Don't invent new facts.
|
||||
|
||||
Output rules:
|
||||
- Korean. Plain markdown. Section labels as plain text on their own line — no "#", "##".
|
||||
- Bullets with "- " only. No tables, no HTML, no triple-bar separators.
|
||||
- If the draft already has a sensible structure, keep it; only refactor when sections clearly overlap or contradict.
|
||||
- DO NOT emit hidden reasoning (<think>, "Thinking:", etc.).`;
|
||||
|
||||
/**
|
||||
* Single-pass 직답 persona. 짧은 질문·정의 묻기·간단한 사실 확인처럼
|
||||
* 쪼갤 필요 없는 입력을 1회 호출로 끝낸다. outline → section → polish 의
|
||||
* 3회 LLM 호출을 통째로 우회 → 작은 모델로 즉답 가능.
|
||||
*/
|
||||
private readonly directPersona = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
|
||||
|
||||
Rules:
|
||||
- 첫 문장이 결론 / 직답이다. "분석해보겠습니다" "좋은 질문입니다" 같은 서문 금지.
|
||||
- Korean. Plain markdown — "#", "##" 같은 헤더 금지, "- " bullet 만 허용. No tables, no HTML.
|
||||
- 짧은 질문엔 짧은 답. 한 문장으로 충분하면 한 문장.
|
||||
- 사용자가 본문(코드·기사·로그)을 첨부했으면 그 본문에서 인용. 본문에 없는 사실 지어내지 말 것.
|
||||
- 추론 과정·"Thinking:"·<think> 노출 금지.`;
|
||||
|
||||
async execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
|
||||
const role = (options?.config?.role as string | undefined) || 'section';
|
||||
switch (role) {
|
||||
case 'outline':
|
||||
return this.callLLM(this.outlinePersona, this.buildOutlinePrompt(input, context), signal);
|
||||
case 'polish':
|
||||
return this.callLLM(this.polishPersona, this.buildPolishPrompt(input, options), signal);
|
||||
case 'direct':
|
||||
return this.callLLM(this.directPersona, this.buildDirectPrompt(input, context), signal);
|
||||
case 'section':
|
||||
default:
|
||||
return this.callLLM(this.sectionPersona, this.buildSectionPrompt(input, context, options), signal);
|
||||
}
|
||||
}
|
||||
|
||||
private buildOutlinePrompt(userRequest: string, brainContext?: string): string {
|
||||
const ctx = brainContext && brainContext.trim().length > 0
|
||||
? `\n\n[보조 지식 컨텍스트 — 답변에 직접 인용하기보단 분할 결정에만 참고]\n${brainContext.substring(0, 1200)}`
|
||||
: '';
|
||||
return `[사용자 요청 — 본문이 포함돼 있다면 그게 1차 자료입니다]\n${userRequest}${ctx}\n\n위 요청에 대한 답변을 ${ChunkedWriter.MAX_SECTIONS}개 이내의 섹션으로 어떻게 나눌지 JSON 배열로만 출력하세요.`;
|
||||
}
|
||||
|
||||
const wrappedInput = `### SYSTEM INSTRUCTION: FINAL SYNTHESIS
|
||||
1. User's Original Request: ${originalRequest || '(unavailable)'}
|
||||
2. Structured Draft (from Drafter — your input to polish):
|
||||
${draft}
|
||||
3. Mission: Produce the FINAL user-facing answer in KOREAN. Do not restart from scratch — polish, smooth, and conclude.${reflectionBlock}`;
|
||||
return this.callLLM(this.persona, wrappedInput, signal);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReflectorAgent extends BaseAgent {
|
||||
private readonly persona = `You are the [Internal Critic & Self-Reflection Officer].
|
||||
Your sole role is META-COGNITION: stress-test the plan and the research output BEFORE the Writer commits to a final report.
|
||||
- POSTURE: Skeptical, rigorous, blunt. You are looking for what is WRONG, not what is right.
|
||||
- DO NOT: rewrite the report, add new content, or speculate beyond the evidence provided.
|
||||
- DO: surface gaps, unsupported claims, contradictions, drift from the original objective, and missing perspectives.
|
||||
- OUTPUT STRICTLY in this Markdown shape (Korean):
|
||||
## 🧭 Alignment with Objective
|
||||
- <원래 요청 대비 일치/이탈 평가>
|
||||
## 🕳 Gaps & Missing Evidence
|
||||
- <plan에는 있지만 research가 다루지 않은 항목>
|
||||
## ⚖️ Contradictions / Conflicts
|
||||
- <research 내부 또는 brain context와의 모순; 없으면 "발견되지 않음">
|
||||
## 🚨 Unsupported / Weak Claims
|
||||
- <근거가 빈약하거나 일반화된 진술>
|
||||
## ✅ Guidance for Writer
|
||||
- <Writer가 최종 리포트에서 반드시 보정해야 할 3~5개 구체 지시>
|
||||
- CONSTRAINT: 최대 500단어. 새 지식을 만들지 말고, 제공된 자료에서만 판단할 것.`;
|
||||
|
||||
async execute(input: string, _context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
|
||||
const plan = options?.priorResults?.plan || '(plan unavailable)';
|
||||
const research = input;
|
||||
const originalPrompt = options?.priorResults?.originalPrompt || '(original prompt unavailable)';
|
||||
const brainContext = options?.context || '';
|
||||
|
||||
// Reflector 는 중간 단계이므로 비대한 입력을 방지하기 위해 각 섹션을 cap.
|
||||
const cap = (s: string, n: number) => s.length > n ? s.substring(0, n) + '... [trimmed]' : s;
|
||||
|
||||
const wrappedInput = `### SYSTEM INSTRUCTION: SELF-REFLECTION PASS
|
||||
1. Original User Objective:
|
||||
${cap(originalPrompt, 1500)}
|
||||
|
||||
2. Planner Blueprint:
|
||||
${cap(plan, 3000)}
|
||||
|
||||
3. Researcher Output (to be critiqued):
|
||||
${cap(research, 5000)}
|
||||
|
||||
4. Knowledge / Brain Context (for cross-check only — do not invent beyond this):
|
||||
${cap(brainContext, 2000)}
|
||||
|
||||
5. Mission: Run a single rigorous reflection pass and output the structured critique exactly as specified by your persona.`;
|
||||
return this.callLLM(this.persona, wrappedInput, signal);
|
||||
private buildSectionPrompt(input: string, brainContext?: string, options?: AgentExecuteOptions): string {
|
||||
const prior = options?.priorResults ?? {};
|
||||
const heading = prior.sectionHeading ?? '본문';
|
||||
const scope = prior.sectionScope ?? '사용자 요청 전체';
|
||||
const outlineJoined = prior.outlineSummary ?? '';
|
||||
const prev = prior.prevSectionsTrimmed ?? '';
|
||||
const originalPrompt = prior.originalPrompt ?? input;
|
||||
const ctx = brainContext && brainContext.trim().length > 0
|
||||
? `\n\n[보조 지식 컨텍스트]\n${brainContext.substring(0, 2000)}`
|
||||
: '';
|
||||
return `[사용자 원본 요청]\n${originalPrompt}\n\n[이 섹션 정보]\nheading: ${heading}\nscope: ${scope}\n\n[전체 outline — 다른 섹션은 다루지 마세요]\n${outlineJoined}\n\n[이미 작성된 섹션들 — 중복 금지]\n${prev || '(없음 — 첫 섹션)'}${ctx}\n\n위 scope만 다루는 섹션 본문을 작성하세요. heading 줄은 출력하지 말고 본문만.`;
|
||||
}
|
||||
|
||||
private buildPolishPrompt(draft: string, options?: AgentExecuteOptions): string {
|
||||
const prior = options?.priorResults ?? {};
|
||||
const originalPrompt = prior.originalPrompt ?? '(원본 요청 없음)';
|
||||
return `[사용자 원본 요청]\n${originalPrompt}\n\n[섹션별 초안 — 이것을 다듬어 최종 답변으로]\n${draft}\n\n위 초안을 사용자에게 보낼 최종본으로 다듬으세요. 새 사실 추가 금지, 근거 없는 주장은 제거.`;
|
||||
}
|
||||
|
||||
private buildDirectPrompt(userRequest: string, brainContext?: string): string {
|
||||
const ctx = brainContext && brainContext.trim().length > 0
|
||||
? `\n\n[보조 지식 컨텍스트 — 필요할 때만 인용]\n${brainContext.substring(0, 2000)}`
|
||||
: '';
|
||||
return `[사용자 요청]\n${userRequest}${ctx}\n\n위 요청에 대한 최종 답변을 1회로 끝내세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Reflection → Lesson persistence
|
||||
*
|
||||
* Take the Reflector agent's structured critique and persist any substantive
|
||||
* findings as a `lesson` card in `<brainDir>/lessons/auto-reflector/`. The
|
||||
* existing brain retrieval pipeline (see `retrieval/brainIndex.ts` +
|
||||
* `retrieval/lessonHelpers.ts`) then automatically boosts these cards and
|
||||
* injects them as an `[⚠ ACTIVE LESSONS — verify these BEFORE finalizing your
|
||||
* answer]` block in *future* missions' Planner/Researcher/Writer context, so a
|
||||
* critique caught in mission N becomes a guardrail in mission N+1.
|
||||
*
|
||||
* Recurrence handling: if a similarly-titled auto-reflector lesson already
|
||||
* exists, we bump `occurrences:` and escalate `severity` (low→medium→high)
|
||||
* instead of producing a duplicate card. Same pattern reappearing 3+ times
|
||||
* surfaces as severity:high, which the lesson retrieval/scoring layer
|
||||
* propagates as a stronger guardrail.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logInfo, logError } from '../utils';
|
||||
import {
|
||||
lessonSlug,
|
||||
parseLessonFrontmatter,
|
||||
bumpLessonOccurrences,
|
||||
normalizeLessonTitle,
|
||||
} from '../retrieval/lessonHelpers';
|
||||
|
||||
interface ReflectionSections {
|
||||
alignment: string;
|
||||
gaps: string;
|
||||
contradictions: string;
|
||||
unsupported: string;
|
||||
guidance: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the body of a `## …<keyword>…` section out of the Reflector's markdown. Line-scan rather
|
||||
* than a multi-line regex so it survives emoji headers and trailing whitespace without
|
||||
* leaning on JS-unsupported regex features.
|
||||
*/
|
||||
function extractSection(text: string, headerKeyword: string): string {
|
||||
if (!text) return '';
|
||||
const kw = headerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const headerRe = new RegExp(`^##[^\\n]*${kw}[^\\n]*$`, 'i');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const buf: string[] = [];
|
||||
let inSection = false;
|
||||
for (const line of lines) {
|
||||
if (headerRe.test(line)) { inSection = true; continue; }
|
||||
if (inSection && /^##\s/.test(line)) break;
|
||||
if (inSection) buf.push(line);
|
||||
}
|
||||
return buf.join('\n').trim();
|
||||
}
|
||||
|
||||
function parseReflection(text: string): ReflectionSections {
|
||||
return {
|
||||
alignment: extractSection(text, 'Alignment'),
|
||||
gaps: extractSection(text, 'Gaps'),
|
||||
contradictions: extractSection(text, 'Contradictions'),
|
||||
unsupported: extractSection(text, 'Unsupported'),
|
||||
guidance: extractSection(text, 'Guidance'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Trivial" = the Reflector explicitly said nothing was found, or the section is too short to be
|
||||
* meaningful. We don't want to spam the brain with `발견되지 않음` cards.
|
||||
*/
|
||||
function isTrivial(section: string): boolean {
|
||||
if (!section) return true;
|
||||
const stripped = section.replace(/[-*•·\s\n]+/g, '').toLowerCase();
|
||||
if (!stripped) return true;
|
||||
if (/^(없음|발견되지않음|해당없음|na|nothing|none|n\/a)$/.test(stripped)) return true;
|
||||
if (stripped.length < 12) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSubstantiveContent(sections: ReflectionSections): boolean {
|
||||
return !isTrivial(sections.gaps)
|
||||
|| !isTrivial(sections.contradictions)
|
||||
|| !isTrivial(sections.unsupported)
|
||||
|| !isTrivial(sections.guidance);
|
||||
}
|
||||
|
||||
/** Pick a short (≤80 char) title from the first actionable bullet across the substantive sections. */
|
||||
function deriveTitle(sections: ReflectionSections): string {
|
||||
const order = [sections.guidance, sections.gaps, sections.unsupported, sections.contradictions];
|
||||
for (const sec of order) {
|
||||
if (isTrivial(sec)) continue;
|
||||
const firstBullet = sec.split('\n').find((l) => /^\s*[-*•]/.test(l));
|
||||
const raw = (firstBullet || sec.split('\n')[0] || '').replace(/^\s*[-*•]\s*/, '').trim();
|
||||
if (raw.length >= 10) {
|
||||
return raw.length > 80 ? raw.slice(0, 77) + '…' : raw;
|
||||
}
|
||||
}
|
||||
return 'Reflector finding (auto)';
|
||||
}
|
||||
|
||||
function severityFor(occurrences: number): 'low' | 'medium' | 'high' {
|
||||
if (occurrences >= 3) return 'high';
|
||||
if (occurrences >= 2) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function buildLessonCard(params: {
|
||||
title: string;
|
||||
today: string;
|
||||
situation: string;
|
||||
sections: ReflectionSections;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}): string {
|
||||
const { title, today, situation, sections, severity } = params;
|
||||
const safeTitle = title.replace(/\n/g, ' ').trim();
|
||||
|
||||
const riskParts = [
|
||||
isTrivial(sections.gaps) ? '' : `**Gaps**\n${sections.gaps.trim()}`,
|
||||
isTrivial(sections.unsupported) ? '' : `**Unsupported claims**\n${sections.unsupported.trim()}`,
|
||||
isTrivial(sections.contradictions) ? '' : `**Contradictions**\n${sections.contradictions.trim()}`,
|
||||
].filter(Boolean);
|
||||
const risk = riskParts.length ? riskParts.join('\n\n') : '<critique 본문 비어 있음>';
|
||||
|
||||
const rootCause = isTrivial(sections.alignment)
|
||||
? '<원본 요청 대비 이탈/근본 원인이 critique에 명시되지 않음 — 회고 시 보강>'
|
||||
: sections.alignment.trim();
|
||||
|
||||
const fix = isTrivial(sections.guidance)
|
||||
? '<Reflector가 Writer 보정 지시(Guidance)를 비워뒀음 — 다음 미션 시 수동 보강 권장>'
|
||||
: sections.guidance.trim();
|
||||
|
||||
const bullets = isTrivial(sections.guidance)
|
||||
? []
|
||||
: sections.guidance
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^[-*•]/.test(l))
|
||||
.map((l) => l.replace(/^[-*•]\s*/, ''))
|
||||
.filter((l) => l.length > 0)
|
||||
.slice(0, 6);
|
||||
const checklistBlock = (bullets.length
|
||||
? bullets
|
||||
: ['<다음 유사 mission 전에 위 Gaps / Unsupported 항목을 사전 점검>']
|
||||
).map((c) => `- ${c}`).join('\n');
|
||||
|
||||
return [
|
||||
'---',
|
||||
'type: lesson',
|
||||
`title: ${safeTitle}`,
|
||||
'applies-to: []',
|
||||
`severity: ${severity}`,
|
||||
'source: auto-reflector',
|
||||
'occurrences: 1',
|
||||
`last-seen: ${today}`,
|
||||
'---',
|
||||
'',
|
||||
`# Lesson: ${safeTitle}`,
|
||||
'',
|
||||
'## Situation',
|
||||
situation,
|
||||
'',
|
||||
'## Mistake / Risk',
|
||||
risk,
|
||||
'',
|
||||
'## Root Cause',
|
||||
rootCause,
|
||||
'',
|
||||
'## Fix',
|
||||
fix,
|
||||
'',
|
||||
'## Prevention Checklist',
|
||||
checklistBlock,
|
||||
'',
|
||||
'## Applies To',
|
||||
'- auto-reflector',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap title-overlap match against existing auto-reflector cards. Exact-normalized-title hit wins
|
||||
* outright; otherwise the best ≥60% term-overlap candidate is returned (or none).
|
||||
*/
|
||||
function findExistingLesson(autoDir: string, newTitle: string): { filePath: string; content: string } | undefined {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(autoDir).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const newNorm = normalizeLessonTitle(newTitle);
|
||||
if (!newNorm) return undefined;
|
||||
const tokenize = (norm: string) => new Set(norm.match(/[a-z0-9]{3,}|[가-힣]{2,}/g) || []);
|
||||
const newTerms = tokenize(newNorm);
|
||||
|
||||
let best: { filePath: string; content: string; score: number } | undefined;
|
||||
for (const f of entries) {
|
||||
const filePath = path.join(autoDir, f);
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fm = parseLessonFrontmatter(content);
|
||||
const existingTitle = (fm.title || '').trim();
|
||||
const existingNorm = normalizeLessonTitle(existingTitle);
|
||||
if (!existingNorm) continue;
|
||||
if (existingNorm === newNorm) return { filePath, content };
|
||||
if (newTerms.size === 0) continue;
|
||||
const existingTerms = tokenize(existingNorm);
|
||||
let overlap = 0;
|
||||
for (const t of newTerms) if (existingTerms.has(t)) overlap++;
|
||||
const score = overlap / newTerms.size;
|
||||
if (score >= 0.6 && (!best || score > best.score)) {
|
||||
best = { filePath, content, score };
|
||||
}
|
||||
}
|
||||
return best ? { filePath: best.filePath, content: best.content } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace (or insert) the `severity:` line in a lesson's frontmatter. Returns content unchanged if
|
||||
* there is no frontmatter block — caller is responsible for not calling on free-form notes.
|
||||
*/
|
||||
function setSeverityInFrontmatter(content: string, severity: 'low' | 'medium' | 'high'): string {
|
||||
if (!/^?---\s*\n/.test(content)) return content;
|
||||
const end = content.indexOf('\n---', 4);
|
||||
if (end < 0) return content;
|
||||
let block = content.slice(0, end);
|
||||
const rest = content.slice(end);
|
||||
if (/^\s*severity\s*:/m.test(block)) {
|
||||
block = block.replace(/^(\s*severity\s*:\s*).*$/m, `$1${severity}`);
|
||||
} else {
|
||||
block += `\nseverity: ${severity}`;
|
||||
}
|
||||
return block + rest;
|
||||
}
|
||||
|
||||
export interface PersistResult {
|
||||
/** Absolute path of the file written or bumped. */
|
||||
filePath: string;
|
||||
/** True if an existing lesson was updated (occurrences++); false for a new card. */
|
||||
bumped: boolean;
|
||||
/** Current occurrences value after the operation. */
|
||||
occurrences: number;
|
||||
/** Current severity after the operation. */
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the Reflector's critique as a lesson card. Returns `undefined` when nothing was written
|
||||
* (no brain path, critique trivial, IO failure — all soft-fail by design; never throws).
|
||||
*/
|
||||
export function persistReflectionAsLesson(params: {
|
||||
reflection: string;
|
||||
originalPrompt: string;
|
||||
brainDir: string;
|
||||
}): PersistResult | undefined {
|
||||
const { reflection, originalPrompt, brainDir } = params;
|
||||
if (!reflection || !brainDir || !path.isAbsolute(brainDir)) return undefined;
|
||||
|
||||
try {
|
||||
const sections = parseReflection(reflection);
|
||||
if (!hasSubstantiveContent(sections)) {
|
||||
logInfo('[reflectionPersister] critique is trivial — skipping lesson dump.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const title = deriveTitle(sections);
|
||||
const autoDir = path.join(brainDir, 'lessons', 'auto-reflector');
|
||||
try {
|
||||
fs.mkdirSync(autoDir, { recursive: true });
|
||||
} catch {
|
||||
// Fall through; the write below will surface the real failure.
|
||||
}
|
||||
|
||||
const existing = findExistingLesson(autoDir, title);
|
||||
if (existing) {
|
||||
let bumped = bumpLessonOccurrences(existing.content, today);
|
||||
const newOcc = parseLessonFrontmatter(bumped).occurrences ?? 1;
|
||||
const sev = severityFor(newOcc);
|
||||
bumped = setSeverityInFrontmatter(bumped, sev);
|
||||
fs.writeFileSync(existing.filePath, bumped, 'utf8');
|
||||
logInfo(`[reflectionPersister] bumped existing lesson (occ=${newOcc}, severity=${sev}): ${existing.filePath}`);
|
||||
return { filePath: existing.filePath, bumped: true, occurrences: newOcc, severity: sev };
|
||||
}
|
||||
|
||||
const situation = (originalPrompt || '').slice(0, 400).replace(/\s+/g, ' ').trim()
|
||||
|| '<original prompt unavailable>';
|
||||
const card = buildLessonCard({ title, today, situation, sections, severity: 'low' });
|
||||
|
||||
let filePath = path.join(autoDir, `${today}-${lessonSlug(title)}.md`);
|
||||
let n = 2;
|
||||
while (fs.existsSync(filePath)) {
|
||||
filePath = path.join(autoDir, `${today}-${lessonSlug(title)}-${n++}.md`);
|
||||
}
|
||||
fs.writeFileSync(filePath, card, 'utf8');
|
||||
logInfo(`[reflectionPersister] new lesson saved: ${filePath}`);
|
||||
return { filePath, bumped: false, occurrences: 1, severity: 'low' };
|
||||
} catch (e: any) {
|
||||
logError('[reflectionPersister] failed to persist lesson.', { error: e?.message ?? String(e) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user