/** * 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 { logError, logInfo } from '../../utils'; import { getCompanyAgent } from './agents'; import { modelForAgent, readCompanyState } 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 { AgentTurnOutput, CompanyTaskPlan, 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. */ 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 } | { phase: 'report-start' } | { phase: 'report-done'; report: string; ok: boolean } | { phase: 'session-saved'; sessionDir: string } | { phase: 'aborted'; reason: 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; /** Per-call cancellation. The sidebar's Stop button flips this. */ signal?: AbortSignal; /** Optional event sink for the webview. Receives events synchronously. */ onEvent?: CompanyTurnEmitter; } /** * Run a single company turn. Returns a fully-populated `SessionResult` even * on partial failure (so callers can always render *something* in chat). */ export async function runCompanyTurn( userPrompt: string, deps: DispatcherDeps, ): Promise { const startedAt = Date.now(); const state = readCompanyState(deps.context); const timestamp = newSessionTimestamp(); const sessionDir = createSessionDir(deps.context, timestamp); const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ }); const isAborted = () => deps.signal?.aborted === true; const fail = (reason: string): SessionResult => { emit({ phase: 'aborted', reason }); return { timestamp, sessionDir, userPrompt, plan: { brief: '', tasks: [] }, agentOutputs: [], report: '', totalDurationMs: Date.now() - startedAt, }; }; if (isAborted()) return fail('signal-aborted'); // ── Phase 1: planner ── emit({ phase: 'plan-start' }); const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel); const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel }); if (isAborted()) return fail('aborted-after-plan'); emit({ phase: 'plan-ready', plan: plannerResult.plan, parsed: plannerResult.parsed, raw: plannerResult.raw, }); writeBrief(sessionDir, userPrompt, plannerResult.plan); // ── Phase 2: sequential dispatch ── const outputs: AgentTurnOutput[] = []; const total = plannerResult.plan.tasks.length; for (let i = 0; i < total; i++) { if (isAborted()) return fail('aborted-mid-dispatch'); const task = plannerResult.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); // Best-effort: append a one-line memory entry so the agent "remembers" // having done this task. Verbose successes are summarized in the CEO // report — memory is just the breadcrumb trail. appendAgentMemory( deps.context, task.agent, `[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`, ); emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total }); } // ── Phase 3: synthesis ── if (isAborted()) return fail('aborted-before-report'); emit({ phase: 'report-start' }); const reportModel = modelForAgent(state, 'ceo', deps.defaultModel); const reportResult = await runCeoReporter( deps.ai, plannerResult.plan, outputs, state, { model: reportModel }, ); writeReport(sessionDir, reportResult.report); emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok }); // ── Phase 4: persist + side effects ── const result: SessionResult = { timestamp, sessionDir, userPrompt, plan: plannerResult.plan, agentOutputs: outputs, report: reportResult.report, totalDurationMs: Date.now() - startedAt, }; writeSessionJson(sessionDir, result); // 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; } /** * 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, ): Promise { const startedAt = Date.now(); const def = getCompanyAgent(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); const peerOutputs = earlierOutputs .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent .map((o) => { const peerDef = getCompanyAgent(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, }; }); const system = buildSpecialistPrompt({ agentId, state, agentMemory: memory, sharedDecisions: decisions, peerOutputs, }); const model = modelForAgent(state, agentId, deps.defaultModel); try { const result = await deps.ai.chat({ system, user: task, model, }); const response = (result.content || '').trim(); return { agentId, task, response: response || '_(empty response)_', durationMs: Date.now() - startedAt, error: response ? undefined : 'empty-response', }; } 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, }; } }