release: v2.0.3 - AI 1-Person Company Engine & Business Intelligence
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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 { 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<SessionResult> {
|
||||
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<typeof readCompanyState>,
|
||||
deps: DispatcherDeps,
|
||||
): Promise<AgentTurnOutput> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user