feat: Implement Pipeline Templates for Company Suite and refine orchestration logic

This commit is contained in:
2026-05-14 17:36:15 +09:00
parent 618b8d5b34
commit 75d7e6b83a
19 changed files with 1181 additions and 50 deletions
+83 -3
View File
@@ -69,6 +69,17 @@ const PEER_OUTPUT_BUDGET = 1500;
* 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 }
@@ -81,6 +92,13 @@ export type CompanyTurnEvent =
* (재시도 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' }
| { phase: 'report-start' }
| { phase: 'report-done'; report: string; ok: boolean }
/**
@@ -125,6 +143,17 @@ export interface DispatcherDeps {
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>;
}
/**
@@ -287,6 +316,8 @@ async function _dispatchOne(
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);
@@ -360,7 +391,10 @@ async function _dispatchOne(
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
});
const model = modelForAgent(state, agentId, deps.defaultModel);
// 우선순위: stage > agent > global default.
const model = (stageModelOverride && stageModelOverride.trim())
? stageModelOverride.trim()
: modelForAgent(state, agentId, deps.defaultModel);
try {
const result = await deps.ai.chat({
@@ -455,14 +489,22 @@ async function _runPipeline(
const latestByStage: Record<string, AgentTurnOutput> = {};
const iterations: Record<string, number> = {};
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> = {};
let i = 0;
let stepIndex = 0;
while (i < pipeline.stages.length) {
if (isAborted()) return { outputs, aborted: 'aborted-mid-pipeline' };
const stage = pipeline.stages[i];
const task = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const note = revisionNotes[stage.id];
const task = note
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
: baseTask;
emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps);
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride);
outputs.push(turn);
latestByStage[stage.id] = turn;
writeAgentOutput(sessionDir, turn);
@@ -472,6 +514,44 @@ async function _runPipeline(
);
emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total });
stepIndex++;
// Successful run consumed the revision note (if any) — clear it.
if (!turn.error) delete revisionNotes[stage.id];
// ── 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 { outputs, aborted: 'aborted-mid-approval' };
emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind });
if (decision.kind === 'abort') {
return { outputs, aborted: 'aborted-by-user-at-approval' };
}
if (decision.kind === 'revise') {
revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)';
// 같은 stage 재실행 — i를 그대로 두고 continue.
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()) {