feat: Implement Pipeline Templates for Company Suite and refine orchestration logic
This commit is contained in:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user