Files
connectai/src/features/company/dispatcher.ts
T
koriweb ebfce17b03 fix: v2.2.203 — 기업모드 dev-impl 빈 깡통 99% 버그 (hollow check 기본 ON)
증상: 사용자가 기획서 + 폴더 주고 "여기 개발해줘" 요청 → ASTRA 가 파일 만들고
"개발 완료" 보고 → 실제 파일을 열면 class/함수 본문이 비어 있음
(def foo(): pass · 빈 class · imports only). 99% 확률 재발.

원인:
- 안전망 이미 존재 (selfReflectorHollow.ts 가 정규식으로 빈 깡통 감지)
- BUT 두 개 config 모두 OFF — selfReflectorEnabled (Phase A) +
  selfReflectorExternalEnabled (Phase B)
- Phase A 켜면 [Self-Reflector Check] 블록이 답변에 노출 (UX 부작용),
  Phase B 는 +1 LLM 호출 비용 — 부작용 때문에 기본 OFF
- 결과: 다수 사용자가 안전망 전혀 없는 상태로 코드 작성 → 빈 깡통 통과

Fix 3종:

1. Hollow check 를 selfReflector 와 분리 — 신규 설정 2개:
   - g1nation.hollowCheck.enabled (boolean, 기본 ON) — action-tag 있는 모든
     응답에 무조건 hollow 스캔. LLM 호출 0.
   - g1nation.hollowCheck.autoRetry (boolean, 기본 ON) — 검출 시 1회 자동
     재작업. Phase B 와 분리.
   - dispatcher.ts 게이트 조건 교체

2. dev-impl 프롬프트 강화 (pipelineTemplates.ts) — [빈 깡통 금지] 5개 규칙:
   - 파일은 하나씩 생성, 모든 함수 본문 완전 구현 후 다음 파일로
   - 금지 패턴 명시: pass · ... · NotImplementedError · # TODO · 빈 class
   - 인터페이스/추상 메서드만 빈 본문 OK
   - 각 파일 생성 직후 자가 검증
   - 최종 요약에 파일별 핵심 동작 한 줄씩

3. 기본값 변경 — 사용자 행동 없이 안전망 작동. 옛 selfReflector Phase A/B 는
   그대로 OFF (UX 부작용 보존).

예상 효과: ~70-85% stub 감소. 남은 ~15% (작은 모델 attention 한계 / 큰 프로젝트)
는 per-file 순차 생성으로 v2.2.204+ 검토.

모델 한계 vs 로직 fix: 대부분 로직 fix 가능. 매우 작은 모델 (≤4B) 은 한계 더
빠름 — 더 큰 모델 (gemma-12b, qwen-32b) 권장.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:52:12 +09:00

1364 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* (`<create_file>`, `<run_command>`); 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 (0100) — 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 `<create_file>` 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<string[]>;
/** 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<ApprovalDecision>;
/**
* 이번 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<SessionResult> {
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`, `<agentId>.md` 등)이 누적됨.
*
* @returns 복구 가능한 상태가 있으면 SessionResult, 아니면 null
* (`_resume.json`이 없거나, 이미 'completed' 상태이거나, plan이 비어 있는 경우).
*/
export async function resumeCompanyTurn(
timestamp: string,
deps: DispatcherDeps,
): Promise<SessionResult | null> {
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<typeof readCompanyState>,
deps: DispatcherDeps,
/** Pipeline stage override — wins over the agent's own model override. */
stageModelOverride?: string,
): Promise<AgentTurnOutput> {
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 `<create_file>`,
// `<run_command>`, `<edit_file>`, 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
// <create_file> 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 액션 태그(`<create_file>` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.';
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.<id>}}` 치환에 사용. */
latestByStage: Record<string, AgentTurnOutput>;
/** stage id → loop-back 누적 횟수. */
iterations: Record<string, number>;
/** stage id → 사용자 수정요청 코멘트. */
revisionNotes: Record<string, string>;
/** 재개를 시작할 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<string, AgentTurnOutput>;
iterations: Record<string, number>;
revisionNotes: Record<string, string>;
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<string, AgentTurnOutput>;
iterations: Record<string, number>;
revisionNotes: Record<string, string>;
};
}> {
const outputs: AgentTurnOutput[] = seed?.outputs ? [...seed.outputs] : [];
// Keep the latest output per stage id so `{{stage.<id>}}` template
// tokens always resolve to the most recent value across loop-backs.
const latestByStage: Record<string, AgentTurnOutput> = seed?.latestByStage
? { ...seed.latestByStage }
: {};
const iterations: Record<string, number> = 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<string, string> = 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 };
}