/** * Sequential dispatcher for 1인 기업 모드. * * Drives one company "turn": * * user prompt * → CEO planner (JSON {brief, tasks}) * → for each task in plan: dispatch one specialist (sequentially) * - build specialist prompt (incl. peer context from earlier agents) * - call the AI service * - persist its output to disk * - append its output to the peer-context buffer for the next agent * → CEO reporter (synthesis markdown) * → persist `_report.md`, update agent memory + decisions * → emit `companyTurnUpdate` events to the webview at each phase * * Why sequential? The user runs Astra on a single GPU/CPU with limited RAM, * and parallel agents would force us to keep multiple models loaded * simultaneously. Sequential dispatch keeps "exactly one model resident at * a time" — the LM Studio lifecycle manager unloads the previous model and * loads the next when an agent has its own override. * * Why not use `AgentExecutor.handlePrompt` here? Because `handlePrompt` is * built for the *interactive* chat path: it owns the conversation history, * streaming UI, agent-mode injection, and a dozen other things we don't * want triggered by a company turn. The company dispatcher needs a clean * "one system + one user → one string back" primitive — `AIService.chat()` * fits that perfectly. Specialists can still emit action tags * (``, ``); we route their *raw* output through * the existing action-tag executor afterwards so file/command tools work * exactly as in chat. */ import * as vscode from 'vscode'; import { IAIService } from '../../core/services'; import { getActiveBrainProfile, logError, logInfo } from '../../utils'; import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever'; import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap'; import { mapWeightToBrainFileLimit, buildKnowledgeMixPolicy, } from '../../retrieval/knowledgeMix'; import { listActiveAgentsByCategory, modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix, } from './companyConfig'; import { runCeoPlanner } from './ceoPlanner'; import { runCeoReporter } from './ceoReporter'; import { buildSpecialistPrompt } from './promptBuilder'; import { appendAgentMemory, appendDecision, createSessionDir, newSessionTimestamp, readAgentMemory, readDecisions, writeAgentOutput, writeBrief, writeReport, writeSessionJson, } from './sessionStore'; import { markResumeStatus, readResumeState, resolveSessionDir, writeResumeState, } from './resumeStore'; import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport'; // ── Self-reflector + intent alignment 모듈 정적 import (옛 dynamic require 8회 통합) ── // 옛 코드는 매 stage 마다 `await import(...)` 로 모듈을 로드했음. 이유는 cyclic import // 회피로 짐작됐지만 실제로 selfReflector / intentAlignment 모듈 어느 것도 dispatcher 를 // import 하지 않아 안전하게 정적 promote 가능. 코드 흐름 명확해지고, 매 dispatch 마다 // require 호출 8회 → 0회 (모듈 캐시 자동). import { verifyResponse, formatIssuesForRetry } from '../selfReflector/selfReflectorVerifier'; import { verifyCreatedFiles } from '../selfReflector/selfReflectorExecution'; import { verifyHollow } from '../selfReflector/selfReflectorHollow'; import { formatContractForPrompt } from './intentAlignment'; import { getConfig as getDispatcherConfig } from '../../config'; import { AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult, } from './types'; /** Trim length applied when an agent's output is fed into the next agent. */ const PEER_OUTPUT_BUDGET = 1500; /** * Events emitted during a turn. The sidebar webview subscribes to render * progress (chips, headers, streamed agent replies). The shape is generic so * the same channel can carry CEO/agent/report messages without per-type * postMessage plumbing. */ /** * User's decision after a stage with `requiresApproval=true` finishes. * - 'approve' → proceed to next stage as-is * - 'revise' → re-run the same stage; comment is prepended to its instruction * - 'abort' → end the turn (same as hitting Stop) */ export type ApprovalDecision = | { kind: 'approve' } | { kind: 'revise'; comment: string } | { kind: 'abort' }; export type CompanyTurnEvent = | { phase: 'plan-start' } | { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string } | { phase: 'agent-start'; agentId: string; task: string; index: number; total: number } | { phase: 'agent-done'; agentId: string; output: AgentTurnOutput; index: number; total: number } /** * Pipeline-mode only: emitted when a stage's output matches the * configured `loopBackPattern` and the dispatcher jumps back to a * previous stage. The webview uses this to render "🔁 stage X → Y * (재시도 N차)" in the chat. */ | { phase: 'stage-loop'; from: string; to: string; iteration: number } /** * Manual approval gate. Emitted right before the dispatcher awaits the * user's decision. Webview surfaces 승인/수정요청/중단 buttons. */ | { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number } /** Resolved approval — purely informational for the chat log. */ | { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' } /** * 3-way 검수 사이클 시작 — 작업자 산출물 직후, 검수자/CEO 메타-판단을 * 돌리기 직전에 emit. webview는 stage 카드 안에 라운드 누적 영역을 연다. */ | { phase: 'review-start'; stageId: string; stageLabel: string; maxRounds: number; inspectorAgentId: string } /** * 한 검수 라운드 결과. inspectorVerdict + ceoVerdict + 각자 코멘트를 * 묶어 한 이벤트로. 라운드를 chat에서 한 줄씩 누적 표시 가능하다. */ | { phase: 'review-round'; stageId: string; round: number; inspectorAgentId: string; inspectorText: string; inspectorVerdict: 'pass' | 'revise' | 'unclear'; ceoText: string; ceoVerdict: 'pass' | 'revise' | 'abort' | 'unclear'; durationMs: number; } /** 검수 사이클 종료. final = 마지막 라운드 verdict. */ | { phase: 'review-end'; stageId: string; final: 'pass' | 'aborted' | 'maxed-out'; rounds: number } | { phase: 'report-start' } | { phase: 'report-done'; report: string; ok: boolean } /** * Emitted after the secretary attempts to mirror the CEO report to * Telegram. `ok=true` ⇒ delivered. `ok=false` ⇒ delivery failed (network * / API error). `ok=null` ⇒ no mirror was attempted because the user * hasn't opted in (no token, no chat id, or `telegram.enabled=false`). */ | { phase: 'telegram-mirror'; ok: boolean | null; reason?: string } | { phase: 'session-saved'; sessionDir: string } | { phase: 'aborted'; reason: string } // 일반 정보·경고·에러 메시지 — 진행 UI 와 별개로 사용자에게 전달할 텍스트. // 예: resume state 저장 실패, optional feature 미설치 안내 등. | { phase: 'log'; level: 'info' | 'warn' | 'error'; message: string }; export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void; export interface DispatcherDeps { context: vscode.ExtensionContext; ai: IAIService; /** Default model to fall back to when an agent has no override. */ defaultModel: string; /** * Global Knowledge Mix weight (0–100) — fallback when an agent has no * per-agent override. Mirrors `g1nation.knowledgeMix.secondBrainWeight`. */ globalKnowledgeMixWeight: number; /** * Baseline number of brain files to retrieve at weight=50 (balanced). * The actual count is `mapWeightToBrainFileLimit(weight, baseline)`. * Pass the same value the chat path uses (`config.memoryLongTermFiles`) * so company-mode behaviour stays in sync. */ brainFileBaseline?: number; /** * Apply ConnectAI's action-tag executor to the specialist's raw response. * Without this hook, agent outputs containing `` etc. would * be shown to the user as a *claim* of file creation but nothing would * actually land on disk. We thread it through deps (rather than * importing AgentExecutor directly) so the dispatcher stays free of * sidebar / agent.ts dependencies for unit testability. */ executeActionTags?: (text: string) => Promise; /** Per-call cancellation. The sidebar's Stop button flips this. */ signal?: AbortSignal; /** Optional event sink for the webview. Receives events synchronously. */ onEvent?: CompanyTurnEmitter; /** * Manual-approval bridge. When a pipeline stage has `requiresApproval`, * the dispatcher emits `phase: 'awaiting-approval'` and then *awaits* * this promise. The host (SidebarChatProvider) is responsible for: * 1. Storing a resolver tied to the emitted stageId * 2. Surfacing approval buttons in the webview chat * 3. Resolving the promise when the user clicks one of them * 4. Resolving with `{ kind: 'abort' }` if the turn is cancelled * (so the dispatcher doesn't hang forever) */ awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise; /** * 이번 turn 한정으로 활성 파이프라인을 *override*. 비어 있으면 평소대로 * `state.activePipelineId` 따른다. 의도 분류기의 `suggestedPipelineId` 또는 * 사용자 키워드(`[파이프라인:id]`) 검출 시 chatHandlers가 채워서 넘긴다. * 알 수 없는 id면 dispatcher가 silent fallback해서 legacy 동작 * (state.activePipelineId 또는 CEO planner)로 진행. */ pipelineIdOverride?: string; /** * Intent Alignment 단계에서 사용자와 합의된 Requirement Contract. 있으면 * CEO planner / specialist prompt / 검수자(inspector + CEO) prompt 전부에 * 같은 ground truth로 주입되어 에이전트들이 추측 대신 contract를 따른다. * 없으면 legacy 동작 — alignment 단계를 거치지 않았거나 사용자 모드가 * 'off'였던 경우. */ requirementContract?: RequirementContract; } /** * Run a single company turn. Returns a fully-populated `SessionResult` even * on partial failure (so callers can always render *something* in chat). * * When `seed` is supplied, this is a *resume* of a previously-aborted turn: * the saved sessionDir/plan/outputs are reused, and dispatch picks up at the * saved `nextIndex` instead of starting from scratch. The CEO planner is * skipped on resume — we trust the original plan to keep behaviour * deterministic. Pipeline mode also restores `latestByStage` / `iterations` * / `revisionNotes` so loop-backs and template tokens resolve as if the * turn never paused. */ export async function runCompanyTurn( userPrompt: string, deps: DispatcherDeps, seed?: CompanyResumeState | null, ): Promise { const startedAt = Date.now(); const state = readCompanyState(deps.context); const timestamp = seed?.timestamp ?? newSessionTimestamp(); const sessionDir = seed ? resolveSessionDir(deps.context, seed.timestamp) : createSessionDir(deps.context, timestamp); const startedAtIso = seed?.startedAt ?? new Date().toISOString(); const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ }); const isAborted = () => deps.signal?.aborted === true; // ── Resume state writer ── // 모든 의미 있는 시점(plan 확정 / 각 stage 직후 / abort)에 같은 파일을 덮어쓴다. // dispatch 중에 캡처해야 할 cursor·캐시들은 클로저로 넘기는 게 매번 // 인자를 6개씩 줄지어 보내는 것보다 깔끔하다. const persistResume = ( status: CompanyResumeState['status'], partial: { plan: CompanyTaskPlan; pipelineId: string | null; outputs: AgentTurnOutput[]; nextIndex: number; pipelineContext?: CompanyResumeState['pipelineContext']; abortReason?: string; }, ): void => { const result = writeResumeState(sessionDir, { version: 1, timestamp, userPrompt, pipelineId: partial.pipelineId, plan: partial.plan, agentOutputs: partial.outputs, nextIndex: partial.nextIndex, pipelineContext: partial.pipelineContext, status, abortReason: partial.abortReason, lastUpdatedAt: new Date().toISOString(), startedAt: startedAtIso, }); // 옛 코드는 write 실패해도 silent 로 logError 만 → 사용자는 *resume turn 손실* // 사실을 모름. 실패 시 emit 으로 webview 에 통보해 사용자가 즉시 인지. if (!result.ok) { emit({ phase: 'log', level: 'warn', message: `Resume 상태 저장 실패 (${status}): ${result.reason}. 이 turn 은 이어서 진행 못 할 수 있습니다.`, }); } }; const fail = (reason: string, ctx?: { plan: CompanyTaskPlan; pipelineId: string | null; outputs: AgentTurnOutput[]; nextIndex: number; pipelineContext?: CompanyResumeState['pipelineContext']; }): SessionResult => { emit({ phase: 'aborted', reason }); // abort 시점의 상태를 _resume.json에 영구화 — 이후 사용자가 "이어서 진행" // 누르면 이 파일에서 plan + 진행도를 복원해 nextIndex부터 재개 가능. if (ctx) { persistResume('aborted', { ...ctx, abortReason: reason }); } else if (seed) { // Resume 도중에 즉시 abort — 들어왔던 seed를 그대로 abort로 다시 마킹. markResumeStatus(sessionDir, 'aborted', reason); } return { timestamp, sessionDir, userPrompt, plan: ctx?.plan ?? seed?.plan ?? { brief: '', tasks: [] }, agentOutputs: ctx?.outputs ?? seed?.agentOutputs ?? [], report: '', totalDurationMs: Date.now() - startedAt, }; }; if (isAborted()) return fail('signal-aborted'); // ── Phase 1: plan (pipeline or legacy planner — skipped on resume) ── let pipeline: PipelineDef | null; let plan: CompanyTaskPlan; let plannerRaw = ''; let plannerParsed = true; if (seed) { // Resume path: reuse the original plan + pipeline binding verbatim. // If the user edited the pipeline definition in the meantime we still // re-resolve by id from the current state — that way deleted stages // wouldn't crash the dispatch (resolveActivePipeline returns null // gracefully). Worst case the dispatch finishes the leftover plan. pipeline = seed.pipelineId ? (state.pipelines?.[seed.pipelineId] ?? null) : null; plan = seed.plan; emit({ phase: 'plan-start' }); emit({ phase: 'plan-ready', plan, parsed: true, raw: '' }); } else { emit({ phase: 'plan-start' }); // deps.pipelineIdOverride가 들어왔으면 *이번 turn만* 그 파이프라인을 쓴다. // state.activePipelineId는 건드리지 않으므로 다음 라운드부턴 다시 사용자 // 설정 따른다. override id가 유효한 파이프라인을 못 가리키면 silent fallback. const overrideId = deps.pipelineIdOverride; pipeline = overrideId ? (state.pipelines?.[overrideId] ?? resolveActivePipeline(state)) : resolveActivePipeline(state); if (pipeline) { // Pipeline mode: the user has authored a fixed sequence of stages. // We still surface a `plan` for the report writer and the session // summary — derived directly from the pipeline definition. plan = { brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`, // stage.agentId가 비어 있는 경우(CEO 동적 선택) 직군 라벨을 placeholder로 // 표시 — plan은 사전 요약용이므로 실제 dispatch는 _runPipeline에서 결정. tasks: pipeline.stages.map((s) => ({ agent: s.agentId || (s.roleCategory ? `[직군:${s.roleCategory}]` : '[미정]'), task: s.label, })), }; } else { const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel); const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel, contractBlock: deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : undefined, signal: deps.signal, }); plan = plannerResult.plan; plannerRaw = plannerResult.raw; plannerParsed = plannerResult.parsed; } if (isAborted()) { return fail('aborted-after-plan', { plan, pipelineId: pipeline?.id ?? null, outputs: [], nextIndex: 0, }); } emit({ phase: 'plan-ready', plan, parsed: plannerParsed, raw: plannerRaw }); writeBrief(sessionDir, userPrompt, plan); } const pipelineId = pipeline?.id ?? null; // 초기 resume 상태(또는 resume 진행 시작 상태) 영속화. persistResume('in-progress', { plan, pipelineId, outputs: seed?.agentOutputs ?? [], nextIndex: seed?.nextIndex ?? 0, pipelineContext: seed?.pipelineContext, }); // ── Phase 2: sequential dispatch ── const outputs: AgentTurnOutput[] = seed?.agentOutputs ? [...seed.agentOutputs] : []; if (pipeline) { const runResult = await _runPipeline( pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit, seed && seed.pipelineId === pipeline.id ? { outputs, latestByStage: seed.pipelineContext?.latestByStage ?? {}, iterations: seed.pipelineContext?.iterations ?? {}, revisionNotes: seed.pipelineContext?.revisionNotes ?? {}, startIndex: seed.nextIndex, } : undefined, (commit) => { // 각 stage 직후 호출됨 — _runPipeline의 내부 cursor·캐시를 통째로 받아 // resume 파일을 갱신. 다음 stage가 시작되기 전에 디스크에 한 번 떨어짐. persistResume('in-progress', { plan, pipelineId, outputs: commit.outputs, nextIndex: commit.nextIndex, pipelineContext: { latestByStage: commit.latestByStage, iterations: commit.iterations, revisionNotes: commit.revisionNotes, }, }); }, ); if (runResult.aborted) { return fail(runResult.aborted, { plan, pipelineId, outputs: runResult.outputs, nextIndex: runResult.nextIndex ?? outputs.length, pipelineContext: runResult.pipelineContext, }); } // outputs는 이미 _runPipeline 내부에서 누적 — 새 객체 push 불필요. outputs.length = 0; outputs.push(...runResult.outputs); } else { const total = plan.tasks.length; const startIdx = seed?.nextIndex ?? 0; for (let i = startIdx; i < total; i++) { if (isAborted()) { return fail('aborted-mid-dispatch', { plan, pipelineId: null, outputs, nextIndex: i, }); } const task = plan.tasks[i]; emit({ phase: 'agent-start', agentId: task.agent, task: task.task, index: i, total }); const turn = await _dispatchOne(task.agent, task.task, outputs, state, deps); outputs.push(turn); writeAgentOutput(sessionDir, turn); appendAgentMemory( deps.context, task.agent, `[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`, ); emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total }); // 각 task 직후 resume cursor 영속화 — 다음 task 전에 abort/crash 나도 // 같은 task 중복 실행 없이 그 다음부터 이어진다. persistResume('in-progress', { plan, pipelineId: null, outputs, nextIndex: i + 1, }); } } // ── Phase 3: synthesis ── // 여기까지 왔다는 건 모든 stage/task가 끝난 상태. abort 시에도 outputs는 // 이미 disk에 떨어져 있으므로 resume cursor를 plan.tasks.length로 옮겨 // 다음 재개 시점에는 report 단계부터 시작하도록 한다 (= 사실상 완료에 가깝다). if (isAborted()) { return fail('aborted-before-report', { plan, pipelineId, outputs, nextIndex: plan.tasks.length, }); } emit({ phase: 'report-start' }); const reportModel = modelForAgent(state, 'ceo', deps.defaultModel); const reportResult = await runCeoReporter( deps.ai, plan, outputs, state, { model: reportModel, signal: deps.signal }, ); writeReport(sessionDir, reportResult.report); emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok }); // ── Phase 3.5: Secretary mirror to Telegram ── // Origin parity (Connect_origin extension.ts ~line 20620): after the CEO // synthesizes the round, the Secretary (영숙) ships a digest to the user's // Telegram chat. Strictly best-effort — failures never break the turn. let telegramOk: boolean | null = null; let telegramReason: string | undefined; try { const reporter = await buildTelegramReporter(deps.context); if (!reporter) { telegramReason = 'no-config'; // opt-in off, no token, or no chat id } else { const tgText = formatCompanyTelegramReport({ state, userPrompt, plan, outputs, report: reportResult.report, sessionTimestamp: timestamp, sessionDir, }); telegramOk = await reporter(tgText); if (!telegramOk) telegramReason = 'delivery-failed'; } } catch (e: any) { telegramOk = false; telegramReason = e?.message ?? String(e); logError('company.dispatcher: telegram mirror threw.', { error: telegramReason }); } emit({ phase: 'telegram-mirror', ok: telegramOk, reason: telegramReason }); // ── Phase 4: persist + side effects ── const result: SessionResult = { timestamp, sessionDir, userPrompt, plan, agentOutputs: outputs, report: reportResult.report, totalDurationMs: Date.now() - startedAt, }; writeSessionJson(sessionDir, result); // 자연 종료 — resume 파일은 'completed' 마킹으로 listResumable에서 자동 제외. // 파일은 삭제하지 않고 남겨 추후 감사/디버깅 용도로 활용 (수 KB 수준). markResumeStatus(sessionDir, 'completed'); // Heuristic: if the report mentions a 🚀 line, extract it as a decision. const decisionLine = reportResult.report.split(/\n/).find((l) => /^\d+\.\s+/.test(l.trim())); if (decisionLine) appendDecision(deps.context, decisionLine.trim()); emit({ phase: 'session-saved', sessionDir }); logInfo('company.dispatcher: turn complete.', { sessionDir, agents: outputs.length, ok: reportResult.ok, durationMs: result.totalDurationMs, }); return result; } /** * Resume a previously-aborted company turn from disk. * * sessionDir의 `_resume.json`을 읽어 plan + 진행 cursor + 캐시를 복원한 다음 * `runCompanyTurn`을 seed 옵션으로 호출. 같은 sessionDir을 재사용하므로 * markdown 산출물(`_brief.md`, `.md` 등)이 누적됨. * * @returns 복구 가능한 상태가 있으면 SessionResult, 아니면 null * (`_resume.json`이 없거나, 이미 'completed' 상태이거나, plan이 비어 있는 경우). */ export async function resumeCompanyTurn( timestamp: string, deps: DispatcherDeps, ): Promise { const sessionDir = resolveSessionDir(deps.context, timestamp); const saved = readResumeState(sessionDir); if (!saved) { logInfo('company.dispatcher: resume requested but no state found.', { timestamp }); return null; } if (saved.status === 'completed') { logInfo('company.dispatcher: resume requested for completed session — ignoring.', { timestamp }); return null; } if (!saved.plan || !Array.isArray(saved.plan.tasks) || saved.plan.tasks.length === 0) { logInfo('company.dispatcher: resume requested but plan is empty.', { timestamp }); return null; } logInfo('company.dispatcher: resuming turn.', { timestamp, pipelineId: saved.pipelineId, nextIndex: saved.nextIndex, totalTasks: saved.plan.tasks.length, }); return runCompanyTurn(saved.userPrompt, deps, saved); } /** * Dispatch one specialist. Wraps the AI call with try/catch so a single * agent's failure never aborts the whole turn — we record the error and * keep going so the user still gets the other agents' outputs. */ async function _dispatchOne( agentId: string, task: string, earlierOutputs: AgentTurnOutput[], state: ReturnType, deps: DispatcherDeps, /** Pipeline stage override — wins over the agent's own model override. */ stageModelOverride?: string, ): Promise { const startedAt = Date.now(); const def = resolveAgent(state, agentId); if (!def) { return { agentId, task, response: '', durationMs: 0, error: `Unknown agent id: ${agentId}`, }; } const memory = readAgentMemory(deps.context, agentId); const decisions = readDecisions(deps.context, 2000); // Google Calendar iCal 캐시 (선택 사항). 셋업 안 된 사용자는 빈 문자열 → 무시. // 매 dispatch 마다 디스크 read 1회 발생하지만 캐시는 KB 단위라 비용 무시 가능. let calendarContext = ''; try { const { readCalendarCache } = require('../calendar') as typeof import('../calendar'); calendarContext = readCalendarCache(deps.context) ?? ''; } catch { /* feature 미설치 / 캐시 없음 — silent skip */ } // Task tracker — _shared/tasks.md 의 active 항목 요약. 모든 agent 가 진척 상황을 // 한 눈에 볼 수 있도록. 비어있으면 빈 문자열 → 프롬프트에서 섹션 자체 생략. let tasksContext = ''; try { const { readTaskStore, summarizeActiveTasks } = require('../tasks') as typeof import('../tasks'); tasksContext = summarizeActiveTasks(readTaskStore(deps.context)); } catch { /* silent */ } const peerOutputs = earlierOutputs .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent .map((o) => { const peerDef = resolveAgent(state, o.agentId); const body = o.response.length > PEER_OUTPUT_BUDGET ? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)' : o.response; return { agentId: o.agentId, agentName: peerDef?.name ?? o.agentId, emoji: peerDef?.emoji ?? '🤖', content: body, }; }); // ── Second Brain RAG for this specialist ──────────────────────────────── // The non-company chat path uses `AgentExecutor.buildMemoryContext` to // pull RAG chunks before every LLM call. The dispatcher used to skip // that entirely, leaving company agents *blind* to the user's stored // knowledge — which made the Knowledge Mix slider effectively a no-op // for company turns. We now run a lightweight scoped retrieval here so // every dispatch sees the same brain the user expects, weighted by the // agent's own Knowledge Mix. const { weight: knowledgeWeight, source: knowledgeMixSource } = resolveCompanyKnowledgeMix(state, agentId, deps.globalKnowledgeMixWeight); const brainFileLimit = mapWeightToBrainFileLimit(knowledgeWeight, deps.brainFileBaseline ?? 6); let brainContext = ''; if (brainFileLimit > 0) { try { const brain = getActiveBrainProfile(); const brainRoot = brain?.localBrainPath || ''; if (brainRoot) { // Reuse the agent ↔ knowledge map: if the same agent name // appears there (free-form .md path or canonical id), we // honour its `knowledgeFolders` scope. Otherwise we search // the whole brain so a missing mapping doesn't starve the // dispatcher. const scope = resolveScopeForAgent(agentId, brainRoot); const retrieval = retrieveScoped(task, brainRoot, scope.folders, { maxResults: brainFileLimit, }); brainContext = buildContextBlock(retrieval); } } catch (e: any) { logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', { agentId, error: e?.message ?? String(e), }); } } const policyBlock = buildKnowledgeMixPolicy({ weight: knowledgeWeight, source: knowledgeMixSource, agent: def.name, }); const system = buildSpecialistPrompt({ agentId, state, agentMemory: memory, sharedDecisions: decisions, calendarContext, tasksContext, peerOutputs, brainContext, // injected as `[SECOND BRAIN CONTEXT]` block knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block // alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의 // system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지. contractBlock: deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : undefined, }); // 우선순위: stage > agent > global default. const model = (stageModelOverride && stageModelOverride.trim()) ? stageModelOverride.trim() : modelForAgent(state, agentId, deps.defaultModel); try { const result = await deps.ai.chat({ system, user: task, model, signal: deps.signal, }); let rawResponse = (result.content || '').trim(); // ── Self-Reflector Phase B — 외부 검증 + 1회 retry ── // 사용자가 selfReflector.externalVerification 켰을 때만 동작. 검증 LLM이 // 'fail' 내면 issue를 task에 prepend해서 같은 specialist 1회 더 호출. // 검증 자체가 실패하면(verifierError) 원본 응답을 그대로 보존하고 진행 — 안전망. let verifierIssues: string[] = []; let verifierSummary = ''; try { // 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음. const cfgRuntime = getDispatcherConfig(); if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) { const contractBlock = deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : undefined; const verdict = await verifyResponse(deps.ai, { task, response: rawResponse, agentName: def.name, model, contractBlock, }); verifierIssues = verdict.issues; verifierSummary = verdict.summary; logInfo('selfReflector.B: verdict.', { agentId, verdict: verdict.verdict, issuesCount: verdict.issues.length, }); if (verdict.verdict === 'fail' && verdict.issues.length > 0) { const retryTask = `${formatIssuesForRetry(verdict.issues)}\n\n[원래 지시]\n${task}`; try { const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal, }); const retried = (retryRes.content || '').trim(); if (retried) { rawResponse = retried; verifierSummary = `검증 fail → 1회 retry 적용 (${verdict.issues.length}개 지적 반영)`; } } catch (e: any) { logError('selfReflector.B: retry call failed; keeping original.', { agentId, error: e?.message ?? String(e), }); } } } } catch (e: any) { // Phase B 전체가 실패해도 dispatch 자체는 계속. logError('selfReflector.B: hook failed; continuing without verification.', { agentId, error: e?.message ?? String(e), }); } // Apply ConnectAI's action-tag executor so ``, // ``, ``, etc. emitted by the agent actually // hit disk / shell. The report (e.g. "✅ Created: foo.py") is // appended to the response so the user sees what really happened. let finalResponse = rawResponse || '_(empty response)_'; let actionReport: string[] | undefined; const hasTag = !!rawResponse && hasActionTag(rawResponse); if (rawResponse && deps.executeActionTags && hasTag) { try { const report = await deps.executeActionTags(rawResponse); actionReport = report; // ── Self-Reflector Phase C — 생성/편집된 파일 syntax 체크 ── // 사용자가 selfReflector.executionVerification 켰을 때만. 추가 // report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영. try { const cfgRuntime = getDispatcherConfig(); if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) { const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; if (projectRoot) { const extra = await verifyCreatedFiles(actionReport, projectRoot); if (extra.length > 0) { actionReport = [...actionReport, ...extra]; } } } } catch (e: any) { logError('selfReflector.C: hook failed; continuing without execution check.', { agentId, error: e?.message ?? String(e), }); } // ── Hollow Code Check (휴리스틱, LLM 콜 0) ── // Phase C(syntax)가 잡지 못하는 *빈 깡통* 패턴을 정규식으로 잡는다. // v2.2.203: Phase A(selfReflectorEnabled) 와 분리 — 독립 toggle // (hollowCheck.enabled, 기본 ON). action-tag 있는 모든 응답에 무조건 실행. // Retry 도 Phase B 와 분리 (hollowCheck.autoRetry, 기본 ON) — 다수 사용자가 // 자동 안전망 받도록. try { const cfgRuntime = getDispatcherConfig(); if (cfgRuntime.hollowCheckEnabled && actionReport.length > 0) { const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; if (projectRoot) { const hollowRes = verifyHollow(actionReport, projectRoot); if (hollowRes.hasHollow) { actionReport = [...actionReport, ...hollowRes.extraLines]; // hollowCheck.autoRetry 가 켜져 있고 아직 retry 안 했다면 hollow를 // 자동 재작업 트리거. (Phase B 와 무관 — 빈 깡통은 늘 잡아야) if (cfgRuntime.hollowCheckAutoRetry && verifierIssues.length === 0) { verifierIssues = hollowRes.hollowReasons.map((r) => `빈 깡통: ${r}`); verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`; // 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend. try { const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`; const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal }); const retried = (retryRes.content || '').trim(); if (retried) { // 재작업 결과로 본문 갱신 + action-tag 다시 실행. rawResponse = retried; if (deps.executeActionTags && hasActionTag(retried)) { const retryReport = await deps.executeActionTags(retried); actionReport = retryReport; // 재작업 결과도 hollow 한 번 더 검사. const reCheck = verifyHollow(retryReport, projectRoot); if (reCheck.hasHollow) { actionReport = [...actionReport, ...reCheck.extraLines]; verifierSummary = `재작업 후에도 hollow 일부 잔존 — 사용자 확인 필요`; } else { verifierSummary = `Hollow 감지 → 재작업으로 해결`; } } } } catch (e: any) { logError('selfReflector.hollow: retry call failed.', { agentId, error: e?.message ?? String(e), }); } } else if (!cfgRuntime.hollowCheckAutoRetry) { // auto-retry OFF — 사용자에게 경고만. verifierSummary = `⚠️ Hollow code 감지 — hollowCheck.autoRetry 켜면 자동 재시도`; } } } } } catch (e: any) { logError('selfReflector.hollow: check failed; continuing.', { agentId, error: e?.message ?? String(e), }); } if (actionReport.length > 0) { finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${actionReport.map((r) => `- ${r}`).join('\n')}`; } } catch (e: any) { // Surface the failure but keep the agent's text — partial // success is more useful than dropping the whole response. const err = e?.message ?? String(e); logError('company.dispatcher: action-tag execution failed.', { agentId, err }); finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`; } } else if (rawResponse && !hasTag && claimsFileCreation(rawResponse)) { // Hallucination guard: small models love to *narrate* file // creation ("foo.py를 생성했습니다 …") without emitting the // tag — so the user sees ✅ in chat but nothing // on disk. Catch the mismatch here and flag it loudly so the // CEO synthesis (which reads this response) and the user both // know nothing was actually written. const warning = '⚠️ **실제 파일이 생성되지 않았습니다.** Agent가 파일 생성을 텍스트로 설명했지만 ConnectAI 액션 태그(`` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.'; finalResponse = `${rawResponse}\n\n---\n${warning}`; logInfo('company.dispatcher: agent claimed creation without action tag.', { agentId }); } // `error: 'no-action-tag-but-claimed'` is *advisory* — we still let // the turn complete because some agents (Writer, Researcher) are // legitimately answer-only. But by flagging the agent output we // mark it as not-fully-successful so the CEO synthesis can read // the warning verbatim. const claimedButDidnt = rawResponse && !hasTag && claimsFileCreation(rawResponse); // 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지* // 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출. if (verifierSummary) { const issuesText = verifierIssues.length > 0 ? '\n' + verifierIssues.map((i) => ` - ${i}`).join('\n') : ''; finalResponse = `${finalResponse}\n\n---\n**🔬 외부 검증:** ${verifierSummary}${issuesText}`; } return { agentId, task, response: finalResponse, durationMs: Date.now() - startedAt, error: rawResponse ? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined) : 'empty-response', actionReport, }; } catch (e: any) { const err = e?.message ?? String(e); logError('company.dispatcher: agent dispatch failed.', { agentId, err }); return { agentId, task, response: `⚠️ 호출 실패: ${err}`, durationMs: Date.now() - startedAt, error: err, }; } } /** * Run an authored pipeline: each stage dispatches its agent with a templated * instruction. Stages can declare a `loopBackPattern` regex — when it * matches the stage's output, the dispatcher jumps back to `loopBackTo` (a * stage that must precede the current one). Iteration count is bounded by * `maxIterations` (default 3) to keep run-away loops from hanging the user. * * Returns `{ outputs, aborted }`: `aborted` is set only when the abort * signal flipped mid-run; the outer dispatcher then short-circuits. */ interface PipelineSeed { /** 이미 끝낸 stage들의 출력. 새로운 outputs 배열의 시드로 들어감. */ outputs: AgentTurnOutput[]; /** stage id → 최신 출력. `{{stage.}}` 치환에 사용. */ latestByStage: Record; /** stage id → loop-back 누적 횟수. */ iterations: Record; /** stage id → 사용자 수정요청 코멘트. */ revisionNotes: Record; /** 재개를 시작할 0-based stage index. */ startIndex: number; } /** * Resolve which agent should run a given stage *right now*. * * Priority order: * 1. `stage.agentId` is explicitly set → use that agent verbatim. The * user pinned this stage to a specific person; honour it. * 2. No agentId but `stage.roleCategory` → pull the active agents in * that category. If exactly one is active, use them (saves an LLM * call on the common case). If multiple, ask CEO via a single short * JSON-shaped LLM call which is best fit for this *specific task*. * 3. Neither — return null so the dispatcher can record an error and * skip the stage cleanly. (normalize already rejects this case but * we guard at runtime in case a stale state slipped through.) * * The LLM call is wrapped in try/catch with a `firstCandidate` fallback: * a bad classifier response should never block the pipeline, just degrade * to "first active agent in role". Caller decides whether to surface a * note about who CEO chose; we just return `{ agentId, source, reason? }`. */ async function _resolveStageAgent( stage: PipelineStage, taskText: string, state: CompanyState, deps: DispatcherDeps, ): Promise<{ agentId: string; source: 'pinned' | 'sole-candidate' | 'ceo-selected' | 'fallback-first'; reason?: string } | null> { if (stage.agentId && resolveAgent(state, stage.agentId)) { return { agentId: stage.agentId, source: 'pinned' }; } const cat = stage.roleCategory as AgentRoleCategory | undefined; if (!cat) return null; const candidates = listActiveAgentsByCategory(state)[cat] ?? []; if (candidates.length === 0) return null; if (candidates.length === 1) { return { agentId: candidates[0].id, source: 'sole-candidate' }; } // 다수 후보 → CEO에게 1회 LLM 콜로 결정. 시스템 프롬프트는 짧게, JSON만. const catLabel = ROLE_CATEGORY_LABELS[cat] ?? cat; const optionsBlock = candidates.map((c) => `- id: ${c.id} | 이름: ${c.name} ${c.emoji}`).join('\n'); const system = `당신은 1인 기업의 CEO입니다. 다음 task에 가장 적합한 *${catLabel}* 직군 구성원 한 명을 골라주세요.\n\n반드시 아래 JSON 한 줄만 출력. 다른 텍스트(설명, 펜스, 머리말) 일체 금지.\n{"agentId":"<선택한 id>","reason":"한 줄(40자 이내)"}`; const user = `[현재 stage] ${stage.label || stage.id}\n[task]\n${taskText.slice(0, 600)}\n\n[후보]\n${optionsBlock}\n\n위 후보 중 task에 가장 적합한 한 명을 id로 골라 JSON 응답:`; try { const result = await deps.ai.chat({ system, user, model: modelForAgent(state, 'ceo', deps.defaultModel), signal: deps.signal, }); const raw = (result.content || '').trim(); // 가벼운 파서 — 코드펜스 / 잡문 제거 후 첫 {…} 추출. const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); const stage1 = (fenced ? fenced[1] : raw).trim(); let picked: { agentId?: unknown; reason?: unknown } | null = null; try { picked = JSON.parse(stage1); } catch { const m = stage1.match(/\{[\s\S]*\}/); if (m) { try { picked = JSON.parse(m[0]); } catch { /* fall through */ } } } const aid = typeof picked?.agentId === 'string' ? picked.agentId.trim() : ''; if (aid && candidates.some((c) => c.id === aid)) { const reason = typeof picked?.reason === 'string' ? picked.reason.trim() : ''; return { agentId: aid, source: 'ceo-selected', reason }; } // 응답이 유효한 후보가 아님 → 첫 번째로 폴백. logInfo('dispatcher: CEO selection invalid; falling back to first candidate.', { stageId: stage.id, rawHead: raw.slice(0, 80), }); } catch (e: any) { logError('dispatcher: CEO selection call failed; falling back.', { stageId: stage.id, error: e?.message ?? String(e), }); } return { agentId: candidates[0].id, source: 'fallback-first' }; } // resolveInspector / parseInspectorVerdict / parseCeoVerdict / renderStageInstruction // / hasActionTag / claimsFileCreation // → `src/features/company/dispatcherHelpers.ts` import { resolveInspector, parseInspectorVerdict, parseCeoVerdict, renderStageInstruction, hasActionTag, claimsFileCreation, } from './dispatcherHelpers'; /** * 3-way 합의 검수 사이클. 작업자 산출물(latestOutput)을 받고: * 1. 검수자에게 보내 ✅/❌ 코멘트를 받음 * 2. CEO에게 (산출물 + 검수자 코멘트)를 보내 ✅/🔁/🛑 메타-판단을 받음 * 3. 검수자 ✅ + CEO ✅ → pass / 아니면 다음 라운드 / CEO 🛑 → 즉시 abort * 4. 최대 라운드 도달 시 maxed-out (강제 통과로 처리하되 webview에 경고) * * Revise verdict 시 작업자에게 *어떤 부분을 고쳐야 하는지* 검수자 코멘트가 * 그대로 전달돼야 하므로 revisionNotes 맵에 검수 코멘트를 채워 caller가 * 사용자 코멘트와 동일한 메커니즘으로 stage 재실행하게 한다. */ async function _runReviewCycle(args: { stage: PipelineStage; stageTaskText: string; latestOutput: AgentTurnOutput; state: CompanyState; deps: DispatcherDeps; emit: CompanyTurnEmitter; isAborted: () => boolean; }): Promise<{ verdict: 'pass' | 'revise' | 'abort' | 'maxed-out' | 'aborted'; revisionNote?: string; rounds: number; }> { const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args; const reviewWith = stage.reviewWith || ''; if (!reviewWith) return { verdict: 'pass', rounds: 0 }; const inspector = resolveInspector(reviewWith, state); if (!inspector) { // 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지 // 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할 // 수 있다. logInfo('reviewCycle: no inspector resolvable; skipping.', { stageId: stage.id, reviewWith }); return { verdict: 'pass', rounds: 0 }; } const maxRounds = Math.max(1, Math.min(10, stage.reviewMaxRounds ?? 3)); emit({ phase: 'review-start', stageId: stage.id, stageLabel: stage.label || stage.id, maxRounds, inspectorAgentId: inspector.agentId, }); let currentOutput = latestOutput; let lastInspectorText = ''; let lastInspectorVerdict: 'pass' | 'revise' | 'unclear' = 'unclear'; let lastCeoText = ''; let lastCeoVerdict: 'pass' | 'revise' | 'abort' | 'unclear' = 'unclear'; for (let round = 1; round <= maxRounds; round++) { if (isAborted()) { emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round - 1 }); return { verdict: 'aborted', rounds: round - 1 }; } const startedAt = Date.now(); // contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend — // 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다. const contractPrefix = deps.requirementContract ? formatContractForPrompt(deps.requirementContract) + '\n\n' : ''; // ── 1) 검수자 LLM 콜 ── const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.'; const inspectorUser = `[현재 stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1500)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 3000)}`; let inspectorText = ''; try { const res = await deps.ai.chat({ system: inspectorSystem, user: inspectorUser, model: modelForAgent(state, inspector.agentId, deps.defaultModel), signal: deps.signal, }); inspectorText = (res.content || '').trim(); } catch (e: any) { logError('reviewCycle: inspector call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) }); inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`; } lastInspectorText = inspectorText; lastInspectorVerdict = parseInspectorVerdict(inspectorText); if (isAborted()) { emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round }); return { verdict: 'aborted', rounds: round }; } // ── 2) CEO 메타-판단 ── const ceoSystem = contractPrefix + '당신은 회사 CEO입니다. 작업자 산출물 + 검수자 의견을 보고 *세 명이 모두 만족하는지* 메타-판단을 내립니다. 위 contract 기준에 부합하는지가 핵심.\n\n반드시 첫 줄을 다음 셋 중 하나로 시작:\n - ✅ 통과 — 산출물·검수가 contract criteria를 모두 충족.\n - 🔁 보완 — contract 기준 한 가지 이상 미흡. 작업자에게 줄 구체 지시 1~3줄.\n - 🛑 중단 — 라운드 더 돌아도 의미 없음. 사장님께 현 상태로 보고.'; const ceoUser = `[stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1000)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 2000)}\n\n[검수자 의견]\n${inspectorText.slice(0, 1500)}\n\n[지금 라운드: ${round}/${maxRounds}]`; let ceoText = ''; try { const res = await deps.ai.chat({ system: ceoSystem, user: ceoUser, model: modelForAgent(state, 'ceo', deps.defaultModel), signal: deps.signal, }); ceoText = (res.content || '').trim(); } catch (e: any) { logError('reviewCycle: CEO meta call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) }); ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완'; } lastCeoText = ceoText; lastCeoVerdict = parseCeoVerdict(ceoText); emit({ phase: 'review-round', stageId: stage.id, round, inspectorAgentId: inspector.agentId, inspectorText, inspectorVerdict: lastInspectorVerdict, ceoText, ceoVerdict: lastCeoVerdict, durationMs: Date.now() - startedAt, }); // ── 3) 합의 판정 ── // 검수자 ✅ + CEO ✅ → 통과. CEO 🛑 → 즉시 중단. 그 외 → 다음 라운드. // unclear는 안전한 쪽(revise)으로 폴백. if (lastInspectorVerdict === 'pass' && lastCeoVerdict === 'pass') { emit({ phase: 'review-end', stageId: stage.id, final: 'pass', rounds: round }); return { verdict: 'pass', rounds: round }; } if (lastCeoVerdict === 'abort') { emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round }); return { verdict: 'abort', rounds: round }; } // revise — 다음 라운드 진입 전 작업자에게 줄 코멘트 합성. const note = [ `[검수자 ${inspector.agentId}] ${inspectorText.slice(0, 600)}`, `[CEO 메타] ${ceoText.slice(0, 400)}`, ].join('\n\n'); // 마지막 라운드 직전이라면 더 이상 작업자를 부를 일 없음 — 그냥 maxed-out. if (round >= maxRounds) { emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: round }); return { verdict: 'maxed-out', revisionNote: note, rounds: round }; } // 작업자 재실행: caller가 stage를 다시 dispatch하도록 revisionNote 전달. // 그런데 사이클은 한 단위(검수+CEO)를 caller 밖에서 끝나야 하므로 여기서 // 직접 작업자 재실행 → 새 currentOutput 갱신. const reDispatchTask = `[검수 피드백 — ${round}라운드]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${stageTaskText}`; emit({ phase: 'agent-start', agentId: currentOutput.agentId, task: reDispatchTask, index: -1, total: maxRounds }); const reTurn = await _dispatchOne(currentOutput.agentId, reDispatchTask, [], state, deps, stage.modelOverride); emit({ phase: 'agent-done', agentId: currentOutput.agentId, output: reTurn, index: -1, total: maxRounds }); currentOutput = reTurn; } // 정상 흐름에선 위 break 조건 중 하나로 빠지지만 안전망으로: emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: maxRounds }); return { verdict: 'maxed-out', revisionNote: `[검수자 ${inspector.agentId}] ${lastInspectorText.slice(0, 600)}\n\n[CEO 메타] ${lastCeoText.slice(0, 400)}`, rounds: maxRounds, }; } /** _runPipeline이 매 stage 직후 호출하는 commit 콜백의 payload. */ export interface PipelineCommit { outputs: AgentTurnOutput[]; latestByStage: Record; iterations: Record; revisionNotes: Record; nextIndex: number; } async function _runPipeline( pipeline: PipelineDef, userPrompt: string, brief: string, sessionDir: string, timestamp: string, state: CompanyState, deps: DispatcherDeps, isAborted: () => boolean, emit: CompanyTurnEmitter, seed?: PipelineSeed, onStageCommit?: (commit: PipelineCommit) => void, ): Promise<{ outputs: AgentTurnOutput[]; aborted?: string; /** abort 시점의 stage cursor — runCompanyTurn이 resume 파일 영속화에 사용. */ nextIndex?: number; pipelineContext?: { latestByStage: Record; iterations: Record; revisionNotes: Record; }; }> { const outputs: AgentTurnOutput[] = seed?.outputs ? [...seed.outputs] : []; // Keep the latest output per stage id so `{{stage.}}` template // tokens always resolve to the most recent value across loop-backs. const latestByStage: Record = seed?.latestByStage ? { ...seed.latestByStage } : {}; const iterations: Record = seed?.iterations ? { ...seed.iterations } : {}; const total = pipeline.stages.length; // Per-stage extra instruction injected by user revision requests. Cleared // after the stage re-runs successfully so it doesn't pollute the rest of // the pipeline. const revisionNotes: Record = seed?.revisionNotes ? { ...seed.revisionNotes } : {}; let i = seed?.startIndex ?? 0; // stepIndex는 emit의 index 인자(UI 진행률) — 재개 시 이미 완료된 stage 수만큼 // 미리 진행시켜둬야 "참여 중인 stage 4/7" 같은 표시가 정확해진다. let stepIndex = seed?.outputs?.length ?? 0; /** Snapshot helper — 현재 cursor·캐시 묶음을 PipelineCommit으로 캡슐. */ const snapshot = (nextIdx: number): PipelineCommit => ({ outputs: [...outputs], latestByStage: { ...latestByStage }, iterations: { ...iterations }, revisionNotes: { ...revisionNotes }, nextIndex: nextIdx, }); const abortReturn = (reason: string) => ({ outputs, aborted: reason, nextIndex: i, pipelineContext: { latestByStage: { ...latestByStage }, iterations: { ...iterations }, revisionNotes: { ...revisionNotes }, }, }); while (i < pipeline.stages.length) { if (isAborted()) return abortReturn('aborted-mid-pipeline'); const stage = pipeline.stages[i]; const baseTask = renderStageInstruction(stage, userPrompt, brief, latestByStage); const note = revisionNotes[stage.id]; const task = note ? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}` : baseTask; // 동적 담당자 해결. stage.agentId가 박혀 있으면 그걸 쓰고, 비어 있으면 // CEO가 직군 후보 중에서 1회 LLM 콜로 적임자 선택. 모든 후보가 비활성/없음 // 이면 null — 그 경우 stage를 에러로 마킹하고 건너뛴다(파이프라인 hang 방지). const picked = await _resolveStageAgent(stage, task, state, deps); if (!picked) { const errOutput: AgentTurnOutput = { agentId: stage.agentId || `<${stage.roleCategory ?? 'unknown'}>`, task, response: `⚠️ 이 단계에 배정할 활성 에이전트가 없습니다 (직군: ${stage.roleCategory ?? '미지정'}). 관리 패널에서 해당 직군의 에이전트를 활성화하거나, stage에 직접 담당자를 지정하세요.`, durationMs: 0, error: 'no-active-agent-in-role', }; outputs.push(errOutput); latestByStage[stage.id] = errOutput; writeAgentOutput(sessionDir, errOutput); emit({ phase: 'agent-done', agentId: errOutput.agentId, output: errOutput, index: stepIndex, total }); stepIndex++; i++; continue; } const resolvedAgentId = picked.agentId; // CEO 선택 시 사용자에게 *왜 이 사람*인지 한 줄로 보여주기 위해 task 앞에 // 짧은 메타 한 줄을 prepend — 에이전트 시스템 프롬프트엔 영향 없고 chat // 카드 표시에만 쓰인다. let taskForChat = task; if (picked.source === 'ceo-selected' && picked.reason) { taskForChat = `[🧭 CEO 선임: ${picked.reason}]\n\n${task}`; } emit({ phase: 'agent-start', agentId: resolvedAgentId, task: taskForChat, index: stepIndex, total }); const turn = await _dispatchOne(resolvedAgentId, task, outputs, state, deps, stage.modelOverride); outputs.push(turn); latestByStage[stage.id] = turn; writeAgentOutput(sessionDir, turn); appendAgentMemory( deps.context, resolvedAgentId, `[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`, ); emit({ phase: 'agent-done', agentId: resolvedAgentId, output: turn, index: stepIndex, total }); stepIndex++; // Successful run consumed the revision note (if any) — clear it. if (!turn.error) delete revisionNotes[stage.id]; // ── 3-way 검수 사이클 ── // 작업자가 에러 없이 응답을 냈고, stage에 reviewWith가 설정돼 있으면 // 검수자 + CEO 메타-판단 사이클로 합의를 도출. 합의 실패 시: // - revise/maxed-out: 검수 코멘트를 revisionNote로 받아 stage 재실행 // (loop-back과 동일한 메커니즘 재활용) // - abort: 사용자에게 알리고 라운드 종료 if (stage.reviewWith && !turn.error) { const reviewResult = await _runReviewCycle({ stage, stageTaskText: task, latestOutput: turn, state, deps, emit, isAborted, }); if (reviewResult.verdict === 'aborted') { return abortReturn('aborted-during-review'); } if (reviewResult.verdict === 'abort') { return abortReturn('aborted-by-ceo-review'); } // revise / maxed-out — 모두 작업자에게 다시 보내 한 번 더 (loop-back). // 단, maxed-out은 사용자에게 "한계 도달, 마지막 결과로 진행"을 알려야 // 더 자연스러우므로 다음 stage로 그대로 진행 (revisionNote 무시). if (reviewResult.verdict === 'revise' && reviewResult.revisionNote) { revisionNotes[stage.id] = reviewResult.revisionNote; continue; // 같은 stage 재실행 — while(i)는 그대로 } // pass / maxed-out → 다음 단계로 진행 (revisionNotes 클리어는 위에서 이미) } // ── Manual approval gate ── // After agent-done emits, before loop-back / next stage advance, // give the user a chance to inspect and approve. We only fire the // gate when (a) the stage opted in, (b) we have an awaitApproval // bridge from the host, and (c) the stage didn't error out // (errored stages would loop forever waiting for "approval" of a // failure — the user should just hit Stop). if (stage.requiresApproval && deps.awaitApproval && !turn.error) { emit({ phase: 'awaiting-approval', stageId: stage.id, stageLabel: stage.label || stage.id, index: stepIndex - 1, total, }); let decision: ApprovalDecision; try { decision = await deps.awaitApproval({ stageId: stage.id, stageLabel: stage.label || stage.id }); } catch { // 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지. decision = { kind: 'abort' }; } if (isAborted()) return abortReturn('aborted-mid-approval'); emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind }); if (decision.kind === 'abort') { return abortReturn('aborted-by-user-at-approval'); } if (decision.kind === 'revise') { revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)'; // 같은 stage 재실행 — i를 그대로 두고 continue. // 수정요청 코멘트도 resume에 반영해야 재개 시 사용자 의도 보존. onStageCommit?.(snapshot(i)); continue; } // 'approve' → 아래 loop-back/다음 stage 진행 로직으로 자연히 fall-through. } // Loop-back evaluation. We only loop on *successful* responses with // non-empty body — an error or empty response would loop forever. if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) { const limit = stage.maxIterations ?? 3; const count = (iterations[stage.id] ?? 0) + 1; iterations[stage.id] = count; let re: RegExp | null = null; try { re = new RegExp(stage.loopBackPattern, 'i'); } catch { re = null; } if (re && re.test(turn.response) && count <= limit) { const targetIdx = pipeline.stages.findIndex((s) => s.id === stage.loopBackTo); if (targetIdx !== -1 && targetIdx < i) { emit({ phase: 'stage-loop', from: stage.id, to: stage.loopBackTo, iteration: count }); i = targetIdx; onStageCommit?.(snapshot(i)); continue; } } } i++; // 매 stage 자연 완료 직후 resume 파일을 갱신 — 다음 stage 시작 전에 디스크에 // 떨어지므로 그 사이에 abort/crash가 나도 정확히 그 다음 stage부터 재개된다. onStageCommit?.(snapshot(i)); } return { outputs }; }