feat: Stabilize Company Suite & Self-Reflection logic, integrate new ADRs and bug records
This commit is contained in:
@@ -27,6 +27,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#F8FAFC',
|
||||
specialty: '오케스트레이션, 작업 분해, 종합 판단, 다음 액션 결정',
|
||||
tagline: '회사 전체 의사결정과 작업 분배를 맡습니다',
|
||||
roleCategory: 'ceo',
|
||||
alwaysOn: true,
|
||||
},
|
||||
youtube: {
|
||||
@@ -37,6 +38,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#FF4444',
|
||||
specialty: '유튜브 채널 운영, 영상 기획서(제목·후크·구조), 트렌드 분석, 썸네일 브리프, 업로드 메타데이터, 시청자 유지율 전략',
|
||||
tagline: '유튜브 채널 기획·운영 전반을 책임집니다',
|
||||
roleCategory: 'planner',
|
||||
persona: '데이터 중심·솔직·자신감 있는 톤. 결론을 먼저 말한 뒤 데이터 근거로 뒷받침. 추측보다 숫자. 가끔 직설적이지만 따뜻함은 잃지 않음. 이모지는 자제하되 "🔥"·"📊"·"🎯" 같은 핵심 강조용은 OK.',
|
||||
},
|
||||
instagram: {
|
||||
@@ -47,6 +49,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#E1306C',
|
||||
specialty: '인스타그램 릴스/피드 콘셉트, 캡션, 해시태그 전략, 게시 시간, 스토리, 팔로워 인게이지먼트',
|
||||
tagline: '인스타 콘텐츠 기획과 인게이지먼트를 끌어올립니다',
|
||||
roleCategory: 'planner',
|
||||
},
|
||||
designer: {
|
||||
id: 'designer',
|
||||
@@ -56,6 +59,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#A78BFA',
|
||||
specialty: '브랜드 디자인 브리프(컬러·타이포·레퍼런스), 썸네일 컨셉 3안, 비주얼 시스템, 디자인 가이드',
|
||||
tagline: '브랜드와 시각 자산 디자인을 담당합니다',
|
||||
roleCategory: 'designer',
|
||||
},
|
||||
developer: {
|
||||
id: 'developer',
|
||||
@@ -65,6 +69,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#22D3EE',
|
||||
specialty: '코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트/봇, 데이터 파이프라인, git 워크플로, 자기 검증 루프',
|
||||
tagline: '읽고·생각하고·짜고·검증한다 — 시니어 엔지니어',
|
||||
roleCategory: 'developer',
|
||||
persona: '시니어 풀스택 엔지니어. 코드 한 줄도 그냥 안 넘김. "왜?·어떻게?·이게 깨지나?" 늘 묻고 검증. 친근하지만 프로페셔널 톤. "확인 후 진행할게요"·"테스트 통과 확인했어요" 같은 책임감 있는 표현. 이모지는 💻·⚙️·🔧·✅·🐛 정도만.',
|
||||
},
|
||||
business: {
|
||||
@@ -75,6 +80,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#F5C518',
|
||||
specialty: '수익화 모델, 가격 전략, 시장·경쟁 분석, ROI/KPI 설계, 비즈니스 의사결정',
|
||||
tagline: '수익화·가격·전략 의사결정을 같이 봅니다',
|
||||
roleCategory: 'inspector',
|
||||
},
|
||||
secretary: {
|
||||
id: 'secretary',
|
||||
@@ -84,6 +90,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#84CC16',
|
||||
specialty: '일정·할 일 관리, 다른 에이전트 작업 요약·보고, 데일리 브리핑, 알림',
|
||||
tagline: '일정·할 일·연락을 챙기고 소통을 정리합니다',
|
||||
roleCategory: 'support',
|
||||
persona: '친근하고 정중한 톤. 짧고 정리된 문장. 이모지 적당히 (😊·📅·✅ 정도). 보고할 땐 한눈에 보이게 불릿 포인트 + 핵심만.',
|
||||
},
|
||||
editor: {
|
||||
@@ -94,6 +101,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#F472B6',
|
||||
specialty: '영상 BGM 기획, 사운드 디자인, 영상-음악 매칭, 자막·타이틀 동기화 가이드',
|
||||
tagline: '영상의 톤에 맞는 사운드 방향을 잡습니다',
|
||||
roleCategory: 'designer',
|
||||
persona: '음악·사운드 감각이 좋고 영상의 톤을 한 마디로 잡아냄. "이 영상은 [장르/분위기]가 어울릴 것 같아요" 식으로 제안. BPM·키·길이를 정확히 표기. 데이터 중심이지만 창작자 감수성도 있음. 이모지는 🎵·🎼·🎚 정도만.',
|
||||
},
|
||||
writer: {
|
||||
@@ -104,6 +112,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#FBBF24',
|
||||
specialty: '카피라이팅, 영상 스크립트 초안, 인스타 캡션, 블로그 글, 메일 톤앤매너, 후크 작성',
|
||||
tagline: '카피·스크립트·후크를 글로 풀어냅니다',
|
||||
roleCategory: 'planner',
|
||||
},
|
||||
researcher: {
|
||||
id: 'researcher',
|
||||
@@ -113,6 +122,32 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
color: '#60A5FA',
|
||||
specialty: '트렌드 리서치, 경쟁사 분석, 데이터 수집·요약, 인용 자료 정리, 사실 확인',
|
||||
tagline: '트렌드와 데이터를 모아 사실 확인까지 끝냅니다',
|
||||
roleCategory: 'researcher',
|
||||
},
|
||||
// ── 신규 직군 에이전트 ──
|
||||
// QA·Inspector 직군이 없으면 사용자가 "기획 → 개발 → QA" 파이프라인을
|
||||
// 처음부터 만들 수 없어서 onboarding이 막힌다. 코드로 같이 동봉.
|
||||
qa: {
|
||||
id: 'qa',
|
||||
name: '재훈',
|
||||
role: 'QA 엔지니어',
|
||||
emoji: '🧪',
|
||||
color: '#10B981',
|
||||
specialty: '기능 테스트 시나리오 작성, 버그 재현·기록, 회귀 테스트, 엣지 케이스 발굴, 통과/실패 명확히 보고',
|
||||
tagline: '기능 검증과 버그 발굴을 담당합니다',
|
||||
roleCategory: 'qa',
|
||||
persona: '꼼꼼하고 의심 많은 톤. "정상 동작합니다" 같은 모호한 표현 대신 "케이스 A: ✅ / 케이스 B: ❌ (재현 방법: ...)" 식의 검증 가능한 결론. 버그가 있으면 반드시 "❌ 버그 발견:"으로 시작 — loop-back regex가 잡을 수 있게.',
|
||||
},
|
||||
inspector: {
|
||||
id: 'inspector',
|
||||
name: '민지',
|
||||
role: '기획·산출물 감리',
|
||||
emoji: '🔎',
|
||||
color: '#EF4444',
|
||||
specialty: '기획서 검토, 요구사항 대비 산출물 정합성 확인, 누락된 케이스 지적, 최종 승인 또는 재작업 요청',
|
||||
tagline: '기획 의도와 산출물의 일치 여부를 감리합니다',
|
||||
roleCategory: 'inspector',
|
||||
persona: '깐깐하지만 건설적인 톤. 무엇이 좋고 무엇이 부족한지 명확히 구분. 결론을 "✅ 승인" 또는 "❌ 재작업 필요: ..."로 명시 — loop-back regex가 잡을 수 있게. 사장님(사용자)이 시간 낭비 안 하게 핵심만.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,14 +155,18 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
export const COMPANY_AGENT_ORDER: string[] = [
|
||||
'ceo', 'youtube', 'instagram', 'designer', 'developer',
|
||||
'business', 'secretary', 'editor', 'writer', 'researcher',
|
||||
'qa', 'inspector',
|
||||
];
|
||||
|
||||
/** Specialists only (everything except the CEO). */
|
||||
export const COMPANY_SPECIALIST_IDS: string[] = COMPANY_AGENT_ORDER.filter((id) => id !== 'ceo');
|
||||
|
||||
/** Default activation set used when a user first opens the company panel. */
|
||||
/** Default activation set used when a user first opens the company panel.
|
||||
* 포함 기준: 13단계 풀 프로덕트 파이프라인을 그대로 돌릴 수 있는 최소 직군
|
||||
* 세트. business는 inspector 직군이지만 너무 많이 끼면 작은 모델이
|
||||
* 헷갈리므로 빼고, 진짜 감리 역할인 inspector(민지)를 넣는다. */
|
||||
export const DEFAULT_ACTIVE_AGENTS: string[] = [
|
||||
'ceo', 'developer', 'writer', 'researcher', 'designer', 'business',
|
||||
'ceo', 'writer', 'researcher', 'designer', 'developer', 'qa', 'inspector',
|
||||
];
|
||||
|
||||
/** Lookup helper. Returns `undefined` for unknown ids instead of throwing. */
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
*/
|
||||
import { IAIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { COMPANY_AGENTS } from './agents';
|
||||
import { isAgentActive } from './companyConfig';
|
||||
import { isAgentActive, listAllAgents, resolveAgent } from './companyConfig';
|
||||
import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets';
|
||||
import { buildPlannerSystemPrompt } from './promptBuilder';
|
||||
import { CompanyState, CompanyTaskPlan } from './types';
|
||||
@@ -35,25 +34,25 @@ export interface PlannerResult {
|
||||
const EMPTY_PLAN: CompanyTaskPlan = { brief: '', tasks: [] };
|
||||
|
||||
/**
|
||||
* Map Korean agent nicknames + likely typos to canonical ids. Built once
|
||||
* from the static AGENTS map so it stays in sync with renames.
|
||||
* Build a nickname → id map from a state snapshot. Built-ins + custom
|
||||
* agents are merged so a user-added agent's name can match the planner's
|
||||
* output too. Rebuilt per normalize call (cheap — <50 entries).
|
||||
*/
|
||||
const NAME_TO_ID: Record<string, string> = (() => {
|
||||
function _buildNameMap(state: CompanyState): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, def] of Object.entries(COMPANY_AGENTS)) {
|
||||
out[id.toLowerCase()] = id;
|
||||
out[def.name.toLowerCase()] = id;
|
||||
// Also catch the role keyword (e.g. "designer", "writer")
|
||||
for (const def of listAllAgents(state)) {
|
||||
out[def.id.toLowerCase()] = def.id;
|
||||
out[def.name.toLowerCase()] = def.id;
|
||||
const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase();
|
||||
if (roleHead && !out[roleHead]) out[roleHead] = id;
|
||||
if (roleHead && !out[roleHead]) out[roleHead] = def.id;
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
}
|
||||
|
||||
function _canonicalAgentId(raw: unknown): string | null {
|
||||
function _canonicalAgentId(raw: unknown, state: CompanyState, nameMap: Record<string, string>): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const key = raw.trim().toLowerCase();
|
||||
return NAME_TO_ID[key] ?? (COMPANY_AGENTS[key] ? key : null);
|
||||
return nameMap[key] ?? (resolveAgent(state, key) ? key : null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,8 +148,9 @@ function _extractFirstBalancedObject(s: string): string | null {
|
||||
export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan {
|
||||
const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] };
|
||||
const dropped: string[] = [];
|
||||
const nameMap = _buildNameMap(state);
|
||||
for (const t of plan.tasks) {
|
||||
const canonical = _canonicalAgentId(t.agent);
|
||||
const canonical = _canonicalAgentId(t.agent, state, nameMap);
|
||||
if (!canonical) {
|
||||
dropped.push(`unknown:${t.agent}`);
|
||||
continue;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { IAIService } from '../../core/services';
|
||||
import { logError } from '../../utils';
|
||||
import { getCompanyAgent } from './agents';
|
||||
import { resolveAgent } from './companyConfig';
|
||||
import { applyPromptVars, CEO_REPORT_PROMPT } from './promptAssets';
|
||||
import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ReportResult {
|
||||
function _buildReportUserMessage(
|
||||
plan: CompanyTaskPlan,
|
||||
outputs: AgentTurnOutput[],
|
||||
state: CompanyState,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
if (plan.brief) {
|
||||
@@ -47,7 +48,7 @@ function _buildReportUserMessage(
|
||||
lines.push('_(no agent dispatched this turn — produce a brief acknowledgement instead)_');
|
||||
} else {
|
||||
for (const out of outputs) {
|
||||
const def = getCompanyAgent(out.agentId);
|
||||
const def = resolveAgent(state, out.agentId);
|
||||
const head = def ? `### ${def.emoji} ${def.name}` : `### ${out.agentId}`;
|
||||
lines.push('');
|
||||
lines.push(head);
|
||||
@@ -69,13 +70,14 @@ function _buildReportUserMessage(
|
||||
export function buildFallbackReport(
|
||||
plan: CompanyTaskPlan,
|
||||
outputs: AgentTurnOutput[],
|
||||
state: CompanyState,
|
||||
): string {
|
||||
const parts: string[] = ['## ✅ 완료된 작업'];
|
||||
if (outputs.length === 0) {
|
||||
parts.push('- _(no agents ran this turn)_');
|
||||
} else {
|
||||
for (const out of outputs) {
|
||||
const def = getCompanyAgent(out.agentId);
|
||||
const def = resolveAgent(state, out.agentId);
|
||||
const head = def ? `**${def.emoji} ${def.name}**` : `**${out.agentId}**`;
|
||||
const firstLine = (out.response.split(/\n/).find((l) => l.trim()) || out.task).trim();
|
||||
parts.push(`- ${head} — ${firstLine.slice(0, 120)}`);
|
||||
@@ -100,7 +102,7 @@ export async function runCeoReporter(
|
||||
options: { model?: string; timeoutMs?: number } = {},
|
||||
): Promise<ReportResult> {
|
||||
const system = applyPromptVars(CEO_REPORT_PROMPT, { company: state.companyName });
|
||||
const user = _buildReportUserMessage(plan, outputs);
|
||||
const user = _buildReportUserMessage(plan, outputs, state);
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system,
|
||||
@@ -110,11 +112,11 @@ export async function runCeoReporter(
|
||||
});
|
||||
const text = (result.content || '').trim();
|
||||
if (!text) {
|
||||
return { report: buildFallbackReport(plan, outputs), ok: false };
|
||||
return { report: buildFallbackReport(plan, outputs, state), ok: false };
|
||||
}
|
||||
return { report: text, ok: true };
|
||||
} catch (e: any) {
|
||||
logError('ceoReporter: AI call failed.', { error: e?.message ?? String(e) });
|
||||
return { report: buildFallbackReport(plan, outputs), ok: false };
|
||||
return { report: buildFallbackReport(plan, outputs, state), ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,65 @@
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { COMPANY_AGENTS, DEFAULT_ACTIVE_AGENTS, getCompanyAgent } from './agents';
|
||||
import { AgentPromptOverride, CompanyState, COMPANY_STATE_KEY } from './types';
|
||||
import {
|
||||
AgentPromptOverride, AgentRoleCategory, CompanyAgentDef, CompanyState, COMPANY_STATE_KEY,
|
||||
PipelineDef, PipelineStage, ROLE_CATEGORY_ORDER,
|
||||
} from './types';
|
||||
|
||||
const VALID_ROLE_CATEGORIES = new Set<AgentRoleCategory>(ROLE_CATEGORY_ORDER);
|
||||
|
||||
function _coerceRoleCategory(raw: unknown, fallback: AgentRoleCategory): AgentRoleCategory {
|
||||
if (typeof raw === 'string' && VALID_ROLE_CATEGORIES.has(raw as AgentRoleCategory)) {
|
||||
return raw as AgentRoleCategory;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation for a user-supplied agent id. Lowercase ASCII, digits, hyphens,
|
||||
* underscores. Built-in ids cannot be reused (would shadow code). Empty /
|
||||
* invalid input returns an error string for the UI to surface.
|
||||
*/
|
||||
export function validateCustomAgentId(id: string): { ok: true } | { ok: false; reason: string } {
|
||||
const trimmed = (id || '').trim();
|
||||
if (!trimmed) return { ok: false, reason: 'id가 비어 있습니다.' };
|
||||
if (!/^[a-z][a-z0-9_-]{1,40}$/.test(trimmed)) {
|
||||
return { ok: false, reason: 'id는 소문자/숫자/-/_ 만 허용 (2~41자, 소문자 시작).' };
|
||||
}
|
||||
if (COMPANY_AGENTS[trimmed]) {
|
||||
return { ok: false, reason: `'${trimmed}'은 기본 에이전트 id와 겹칩니다.` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function _normalizeCustomAgentDef(raw: unknown): CompanyAgentDef | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const id = typeof r.id === 'string' ? r.id.trim() : '';
|
||||
const name = typeof r.name === 'string' ? r.name.trim() : '';
|
||||
const role = typeof r.role === 'string' ? r.role.trim() : '';
|
||||
if (!id || !name || !role) return null;
|
||||
if (validateCustomAgentId(id).ok !== true) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
emoji: typeof r.emoji === 'string' && r.emoji.trim() ? r.emoji.trim() : '🤖',
|
||||
color: typeof r.color === 'string' && /^#?[0-9a-fA-F]{3,8}$/.test(r.color.trim())
|
||||
? (r.color.trim().startsWith('#') ? r.color.trim() : '#' + r.color.trim())
|
||||
: '#94A3B8',
|
||||
specialty: typeof r.specialty === 'string' ? r.specialty.trim() : '',
|
||||
tagline: typeof r.tagline === 'string' ? r.tagline.trim() : '',
|
||||
persona: typeof r.persona === 'string' && r.persona.trim() ? r.persona.trim() : undefined,
|
||||
// CEO 직군은 빌트인 전용. 사용자가 ceo로 만들려 해도 'support'로 fallback.
|
||||
roleCategory: (() => {
|
||||
const rc = _coerceRoleCategory(r.roleCategory, 'support');
|
||||
return rc === 'ceo' ? 'support' : rc;
|
||||
})(),
|
||||
// `alwaysOn` is reserved for built-ins (CEO). Custom agents never set it.
|
||||
alwaysOn: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default state for a brand-new user. CEO is always on. */
|
||||
function _defaultState(): CompanyState {
|
||||
@@ -31,9 +89,75 @@ function _defaultState(): CompanyState {
|
||||
modelOverrides: {},
|
||||
promptOverrides: {},
|
||||
knowledgeMixOverrides: {},
|
||||
customAgents: {},
|
||||
roleCategoryOverrides: {},
|
||||
pipelines: {},
|
||||
activePipelineId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation for a pipeline / stage id. Same alphabet rules as agent ids
|
||||
* so the UI feels consistent. Empty / invalid input returns an error
|
||||
* string for the UI to surface.
|
||||
*/
|
||||
function _validId(id: string): boolean {
|
||||
return /^[a-z][a-z0-9_-]{1,40}$/.test((id || '').trim());
|
||||
}
|
||||
|
||||
function _normalizeStage(raw: unknown): PipelineStage | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const id = typeof r.id === 'string' ? r.id.trim() : '';
|
||||
const agentId = typeof r.agentId === 'string' ? r.agentId.trim() : '';
|
||||
const label = typeof r.label === 'string' && r.label.trim() ? r.label.trim() : id;
|
||||
if (!_validId(id) || !agentId) return null;
|
||||
const out: PipelineStage = {
|
||||
id, label, agentId,
|
||||
instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
|
||||
};
|
||||
if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) {
|
||||
out.loopBackPattern = r.loopBackPattern.trim();
|
||||
}
|
||||
if (typeof r.loopBackTo === 'string' && _validId(r.loopBackTo)) {
|
||||
out.loopBackTo = r.loopBackTo.trim();
|
||||
}
|
||||
if (typeof r.maxIterations === 'number' && Number.isFinite(r.maxIterations)) {
|
||||
out.maxIterations = Math.max(1, Math.min(10, Math.round(r.maxIterations)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _normalizePipeline(raw: unknown): PipelineDef | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const id = typeof r.id === 'string' ? r.id.trim() : '';
|
||||
const name = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : id;
|
||||
if (!_validId(id)) return null;
|
||||
const rawStages = Array.isArray(r.stages) ? r.stages : [];
|
||||
const stages: PipelineStage[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of rawStages) {
|
||||
const ns = _normalizeStage(s);
|
||||
if (!ns) continue;
|
||||
if (seen.has(ns.id)) continue; // stage ids must be unique within pipeline
|
||||
seen.add(ns.id);
|
||||
stages.push(ns);
|
||||
}
|
||||
// Drop loopBackTo references that point to a non-existent / later stage.
|
||||
for (let i = 0; i < stages.length; i++) {
|
||||
const s = stages[i];
|
||||
if (s.loopBackTo) {
|
||||
const earlierIdx = stages.findIndex((x) => x.id === s.loopBackTo);
|
||||
if (earlierIdx === -1 || earlierIdx >= i) {
|
||||
delete s.loopBackTo;
|
||||
delete s.loopBackPattern;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { id, name, stages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a state value loaded from globalState. Guards against schema
|
||||
* drift (e.g. unknown agent ids that no longer exist, missing fields).
|
||||
@@ -45,8 +169,19 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
|
||||
const companyName = typeof raw.companyName === 'string' && raw.companyName.trim()
|
||||
? raw.companyName.trim()
|
||||
: def.companyName;
|
||||
// Custom agents come first so the merged-id check below is one-pass.
|
||||
const customAgents: Record<string, CompanyAgentDef> = {};
|
||||
if (raw.customAgents && typeof raw.customAgents === 'object') {
|
||||
for (const [id, ad] of Object.entries(raw.customAgents as Record<string, unknown>)) {
|
||||
const def = _normalizeCustomAgentDef(ad);
|
||||
// Force id consistency: the record key wins over a mismatched .id
|
||||
if (def && def.id === id) customAgents[id] = def;
|
||||
}
|
||||
}
|
||||
const knownId = (id: string): boolean =>
|
||||
!!getCompanyAgent(id) || Object.prototype.hasOwnProperty.call(customAgents, id);
|
||||
const validIds = Array.isArray(raw.activeAgentIds)
|
||||
? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && !!getCompanyAgent(id))
|
||||
? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && knownId(id))
|
||||
: def.activeAgentIds;
|
||||
// CEO is *implicitly* always active — keep it out of the persisted list
|
||||
// so we never accidentally drop it, but the public reader re-includes it.
|
||||
@@ -54,7 +189,7 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
|
||||
const overrides: Record<string, string> = {};
|
||||
if (raw.modelOverrides && typeof raw.modelOverrides === 'object') {
|
||||
for (const [k, v] of Object.entries(raw.modelOverrides)) {
|
||||
if (typeof v === 'string' && v.trim() && getCompanyAgent(k)) {
|
||||
if (typeof v === 'string' && v.trim() && knownId(k)) {
|
||||
overrides[k] = v.trim();
|
||||
}
|
||||
}
|
||||
@@ -65,7 +200,7 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
|
||||
const promptOverrides: Record<string, AgentPromptOverride> = {};
|
||||
if (raw.promptOverrides && typeof raw.promptOverrides === 'object') {
|
||||
for (const [agentId, v] of Object.entries(raw.promptOverrides as Record<string, unknown>)) {
|
||||
if (!getCompanyAgent(agentId) || !v || typeof v !== 'object') continue;
|
||||
if (!knownId(agentId) || !v || typeof v !== 'object') continue;
|
||||
const ov = v as Record<string, unknown>;
|
||||
const cleaned: AgentPromptOverride = {};
|
||||
if (typeof ov.persona === 'string' && ov.persona.trim()) cleaned.persona = ov.persona.trim();
|
||||
@@ -82,19 +217,48 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
|
||||
const knowledgeMixOverrides: Record<string, number> = {};
|
||||
if (raw.knowledgeMixOverrides && typeof raw.knowledgeMixOverrides === 'object') {
|
||||
for (const [agentId, v] of Object.entries(raw.knowledgeMixOverrides as Record<string, unknown>)) {
|
||||
if (!getCompanyAgent(agentId)) continue;
|
||||
if (!knownId(agentId)) continue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
const w = Math.max(0, Math.min(100, Math.round(v)));
|
||||
knowledgeMixOverrides[agentId] = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 직군 override — 사용자가 빌트인을 reclassify할 수 있게.
|
||||
// 'ceo'로 설정하려는 시도는 무시 (지휘자 직군은 빌트인 CEO 전용).
|
||||
const roleCategoryOverrides: Record<string, AgentRoleCategory> = {};
|
||||
if (raw.roleCategoryOverrides && typeof raw.roleCategoryOverrides === 'object') {
|
||||
for (const [agentId, v] of Object.entries(raw.roleCategoryOverrides as Record<string, unknown>)) {
|
||||
if (!knownId(agentId) || agentId === 'ceo') continue;
|
||||
if (typeof v !== 'string' || !VALID_ROLE_CATEGORIES.has(v as AgentRoleCategory)) continue;
|
||||
if (v === 'ceo') continue;
|
||||
roleCategoryOverrides[agentId] = v as AgentRoleCategory;
|
||||
}
|
||||
}
|
||||
// Pipelines — drop malformed entries; stage agent ids that don't resolve
|
||||
// are kept (the dispatcher will surface a per-stage error) so the user
|
||||
// can fix them in the editor instead of losing their pipeline silently.
|
||||
const pipelines: Record<string, PipelineDef> = {};
|
||||
if (raw.pipelines && typeof raw.pipelines === 'object') {
|
||||
for (const [pid, p] of Object.entries(raw.pipelines as Record<string, unknown>)) {
|
||||
const np = _normalizePipeline(p);
|
||||
if (np && np.id === pid) pipelines[pid] = np;
|
||||
}
|
||||
}
|
||||
const activePipelineId = typeof raw.activePipelineId === 'string'
|
||||
&& Object.prototype.hasOwnProperty.call(pipelines, raw.activePipelineId)
|
||||
? raw.activePipelineId
|
||||
: null;
|
||||
return {
|
||||
enabled, companyName,
|
||||
activeAgentIds: withoutCeo,
|
||||
modelOverrides: overrides,
|
||||
promptOverrides,
|
||||
knowledgeMixOverrides,
|
||||
customAgents,
|
||||
roleCategoryOverrides,
|
||||
pipelines,
|
||||
activePipelineId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,8 +400,220 @@ export async function setAgentKnowledgeMix(
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Custom agent CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add or replace a user-defined agent. Returns either the new state on
|
||||
* success, or an error object so the UI can surface validation failures
|
||||
* (id collision with built-ins, malformed id, etc.). Accepts `unknown`
|
||||
* because the payload comes off the postMessage channel — `_normalizeCustomAgentDef`
|
||||
* is responsible for shape validation; this wrapper only adds the id-collision
|
||||
* guard and persists the merged state.
|
||||
*/
|
||||
export async function addCustomAgent(
|
||||
context: vscode.ExtensionContext,
|
||||
raw: unknown,
|
||||
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
|
||||
const def = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
|
||||
const idCheck = validateCustomAgentId(typeof def.id === 'string' ? def.id : '');
|
||||
if (idCheck.ok !== true) return { ok: false, reason: idCheck.reason };
|
||||
const normalized = _normalizeCustomAgentDef(def);
|
||||
if (!normalized) return { ok: false, reason: '에이전트 정의가 유효하지 않습니다 (id · 이름 · 역할 필수).' };
|
||||
const cur = readCompanyState(context);
|
||||
const customAgents = { ...(cur.customAgents ?? {}), [normalized.id]: normalized };
|
||||
const next: CompanyState = { ...cur, customAgents };
|
||||
await writeCompanyState(context, next);
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user-defined agent and its overrides. Built-in agents cannot be
|
||||
* removed — returns `ok:false` instead. The active-agent list is also
|
||||
* cleaned so the removed id doesn't linger as a dangling reference.
|
||||
*/
|
||||
export async function removeCustomAgent(
|
||||
context: vscode.ExtensionContext,
|
||||
agentId: string,
|
||||
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
|
||||
if (COMPANY_AGENTS[agentId]) {
|
||||
return { ok: false, reason: '기본 에이전트는 삭제할 수 없습니다.' };
|
||||
}
|
||||
const cur = readCompanyState(context);
|
||||
if (!cur.customAgents || !cur.customAgents[agentId]) {
|
||||
return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
|
||||
}
|
||||
const { [agentId]: _gone, ...customAgents } = cur.customAgents;
|
||||
const { [agentId]: _m, ...modelOverrides } = cur.modelOverrides;
|
||||
const { [agentId]: _p, ...promptOverrides } = cur.promptOverrides;
|
||||
const { [agentId]: _k, ...knowledgeMixOverrides } = cur.knowledgeMixOverrides;
|
||||
const { [agentId]: _r, ...roleCategoryOverrides } = (cur.roleCategoryOverrides ?? {});
|
||||
const activeAgentIds = cur.activeAgentIds.filter((id) => id !== agentId);
|
||||
const next: CompanyState = {
|
||||
...cur, customAgents, modelOverrides, promptOverrides, knowledgeMixOverrides,
|
||||
roleCategoryOverrides, activeAgentIds,
|
||||
};
|
||||
await writeCompanyState(context, next);
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set / clear a per-agent 직군 override. Pass `null` to revert the agent
|
||||
* to its def's own roleCategory. The CEO can't be reclassified (always 'ceo').
|
||||
*/
|
||||
export async function setAgentRoleCategory(
|
||||
context: vscode.ExtensionContext,
|
||||
agentId: string,
|
||||
category: AgentRoleCategory | null,
|
||||
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
|
||||
if (agentId === 'ceo') return { ok: false, reason: 'CEO 직군은 변경할 수 없습니다.' };
|
||||
const cur = readCompanyState(context);
|
||||
if (!getCompanyAgent(agentId) && !cur.customAgents?.[agentId]) {
|
||||
return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
|
||||
}
|
||||
const overrides = { ...(cur.roleCategoryOverrides ?? {}) };
|
||||
if (category === null || category === undefined) {
|
||||
delete overrides[agentId];
|
||||
} else {
|
||||
if (!VALID_ROLE_CATEGORIES.has(category) || category === 'ceo') {
|
||||
return { ok: false, reason: `'${category}'는 유효한 직군이 아닙니다.` };
|
||||
}
|
||||
overrides[agentId] = category;
|
||||
}
|
||||
const next: CompanyState = { ...cur, roleCategoryOverrides: overrides };
|
||||
await writeCompanyState(context, next);
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
// ── Pipeline CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Save a pipeline (insert or replace). Returns the new state on success,
|
||||
* or an error reason if the payload normalizes to nothing valid.
|
||||
*/
|
||||
export async function upsertPipeline(
|
||||
context: vscode.ExtensionContext,
|
||||
raw: unknown,
|
||||
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
|
||||
const def = _normalizePipeline(raw);
|
||||
if (!def) return { ok: false, reason: '파이프라인 정의가 유효하지 않습니다 (id 필수, 소문자 시작).' };
|
||||
const cur = readCompanyState(context);
|
||||
const pipelines = { ...(cur.pipelines ?? {}), [def.id]: def };
|
||||
const next: CompanyState = { ...cur, pipelines };
|
||||
await writeCompanyState(context, next);
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pipeline. Also clears `activePipelineId` if it was pointing at
|
||||
* the deleted pipeline — without that, the dispatcher would silently fall
|
||||
* back to the legacy planner path, which is confusing.
|
||||
*/
|
||||
export async function deletePipeline(
|
||||
context: vscode.ExtensionContext,
|
||||
pipelineId: string,
|
||||
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
|
||||
const cur = readCompanyState(context);
|
||||
if (!cur.pipelines || !cur.pipelines[pipelineId]) {
|
||||
return { ok: false, reason: `'${pipelineId}' 파이프라인을 찾을 수 없습니다.` };
|
||||
}
|
||||
const { [pipelineId]: _gone, ...pipelines } = cur.pipelines;
|
||||
const activePipelineId = cur.activePipelineId === pipelineId ? null : (cur.activePipelineId ?? null);
|
||||
const next: CompanyState = { ...cur, pipelines, activePipelineId };
|
||||
await writeCompanyState(context, next);
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active pipeline. Pass `null` to revert to the legacy CEO-planner
|
||||
* path. Unknown ids are rejected so the dispatcher can trust `state.activePipelineId`.
|
||||
*/
|
||||
export async function setActivePipeline(
|
||||
context: vscode.ExtensionContext,
|
||||
pipelineId: string | null,
|
||||
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
|
||||
const cur = readCompanyState(context);
|
||||
if (pipelineId !== null && pipelineId !== '' && !(cur.pipelines ?? {})[pipelineId]) {
|
||||
return { ok: false, reason: `'${pipelineId}' 파이프라인을 찾을 수 없습니다.` };
|
||||
}
|
||||
const next: CompanyState = { ...cur, activePipelineId: pipelineId || null };
|
||||
await writeCompanyState(context, next);
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
// ── Derived helpers (no I/O) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the currently-active pipeline definition, or `null` when none is
|
||||
* selected (legacy planner path).
|
||||
*/
|
||||
export function resolveActivePipeline(state: CompanyState): PipelineDef | null {
|
||||
const id = state.activePipelineId;
|
||||
if (!id) return null;
|
||||
return state.pipelines?.[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* State-aware agent lookup with 직군 override applied. Built-ins first, then
|
||||
* custom agents; the agent's effective `roleCategory` always reflects the
|
||||
* user's override (if any). Returns `undefined` when neither table has the id.
|
||||
*/
|
||||
export function resolveAgent(state: CompanyState, agentId: string): CompanyAgentDef | undefined {
|
||||
const base = getCompanyAgent(agentId) ?? state.customAgents?.[agentId];
|
||||
if (!base) return undefined;
|
||||
const ov = state.roleCategoryOverrides?.[agentId];
|
||||
if (ov && VALID_ROLE_CATEGORIES.has(ov) && ov !== base.roleCategory && agentId !== 'ceo') {
|
||||
return { ...base, roleCategory: ov };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective 직군 for an agent (override > def). Falls back to
|
||||
* 'support' when the agent is unknown so caller code doesn't have to handle
|
||||
* undefined.
|
||||
*/
|
||||
export function resolveAgentRoleCategory(state: CompanyState, agentId: string): AgentRoleCategory {
|
||||
const def = resolveAgent(state, agentId);
|
||||
return def?.roleCategory ?? 'support';
|
||||
}
|
||||
|
||||
/**
|
||||
* All agent definitions visible to the user this turn — built-ins followed
|
||||
* by custom agents in insertion order, with 직군 overrides applied. Used by
|
||||
* the CEO planner (to build the dispatch menu) and the manage UI (to render
|
||||
* cards).
|
||||
*/
|
||||
export function listAllAgents(state: CompanyState): CompanyAgentDef[] {
|
||||
const out: CompanyAgentDef[] = [];
|
||||
for (const def of Object.values(COMPANY_AGENTS)) {
|
||||
out.push(resolveAgent(state, def.id) ?? def);
|
||||
}
|
||||
if (state.customAgents) {
|
||||
for (const def of Object.values(state.customAgents)) {
|
||||
out.push(resolveAgent(state, def.id) ?? def);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* All *active* agents grouped by 직군. The pipeline editor uses this to
|
||||
* populate its "직군 → 담당" cascading dropdown. Inactive agents are filtered
|
||||
* because dispatching to a disabled agent would be a no-op anyway.
|
||||
*/
|
||||
export function listActiveAgentsByCategory(state: CompanyState): Record<AgentRoleCategory, CompanyAgentDef[]> {
|
||||
const buckets: Record<AgentRoleCategory, CompanyAgentDef[]> = {
|
||||
ceo: [], planner: [], researcher: [], designer: [],
|
||||
developer: [], qa: [], inspector: [], support: [],
|
||||
};
|
||||
for (const def of listAllAgents(state)) {
|
||||
const active = def.id === 'ceo' || state.activeAgentIds.includes(def.id);
|
||||
if (!active) continue;
|
||||
buckets[def.roleCategory].push(def);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full set of agent ids that should be available to the CEO
|
||||
* planner on this turn. CEO is always included regardless of `activeAgentIds`.
|
||||
@@ -245,7 +621,7 @@ export async function setAgentKnowledgeMix(
|
||||
export function activeAgentIds(state: CompanyState): string[] {
|
||||
const set = new Set<string>(['ceo']);
|
||||
for (const id of state.activeAgentIds) {
|
||||
if (getCompanyAgent(id)) set.add(id);
|
||||
if (resolveAgent(state, id)) set.add(id);
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
@@ -304,9 +680,9 @@ export function resolveCompanyKnowledgeMix(
|
||||
|
||||
/**
|
||||
* Resolve the *effective* prompt fields for an agent — merge the static
|
||||
* default from `agents.ts` with any user-saved override. Returns plain
|
||||
* strings so the prompt builder doesn't have to worry about which source
|
||||
* each field came from.
|
||||
* default from `agents.ts` (or the custom agent def, for user-added agents)
|
||||
* with any user-saved override. Returns plain strings so the prompt builder
|
||||
* doesn't have to worry about which source each field came from.
|
||||
*/
|
||||
export function resolveAgentPrompt(state: CompanyState, agentId: string): {
|
||||
persona: string;
|
||||
@@ -315,7 +691,7 @@ export function resolveAgentPrompt(state: CompanyState, agentId: string): {
|
||||
/** Whether *any* field is currently overridden — useful for UI hints. */
|
||||
hasOverride: boolean;
|
||||
} {
|
||||
const def = getCompanyAgent(agentId);
|
||||
const def = resolveAgent(state, agentId);
|
||||
const ov = state.promptOverrides?.[agentId];
|
||||
return {
|
||||
persona: (ov?.persona ?? def?.persona ?? '').toString(),
|
||||
|
||||
@@ -39,8 +39,9 @@ import {
|
||||
mapWeightToBrainFileLimit,
|
||||
buildKnowledgeMixPolicy,
|
||||
} from '../../retrieval/knowledgeMix';
|
||||
import { getCompanyAgent } from './agents';
|
||||
import { modelForAgent, readCompanyState, resolveCompanyKnowledgeMix } from './companyConfig';
|
||||
import {
|
||||
modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
|
||||
} from './companyConfig';
|
||||
import { runCeoPlanner } from './ceoPlanner';
|
||||
import { runCeoReporter } from './ceoReporter';
|
||||
import { buildSpecialistPrompt } from './promptBuilder';
|
||||
@@ -57,7 +58,7 @@ import {
|
||||
writeSessionJson,
|
||||
} from './sessionStore';
|
||||
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
|
||||
import { AgentTurnOutput, CompanyState, CompanyTaskPlan, SessionResult } from './types';
|
||||
import { AgentTurnOutput, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types';
|
||||
|
||||
/** Trim length applied when an agent's output is fed into the next agent. */
|
||||
const PEER_OUTPUT_BUDGET = 1500;
|
||||
@@ -73,6 +74,13 @@ export type CompanyTurnEvent =
|
||||
| { 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 }
|
||||
| { phase: 'report-start' }
|
||||
| { phase: 'report-done'; report: string; ok: boolean }
|
||||
/**
|
||||
@@ -147,38 +155,59 @@ export async function runCompanyTurn(
|
||||
};
|
||||
if (isAborted()) return fail('signal-aborted');
|
||||
|
||||
// ── Phase 1: planner ──
|
||||
// ── Phase 1: plan (pipeline or legacy planner) ──
|
||||
emit({ phase: 'plan-start' });
|
||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
|
||||
const pipeline = resolveActivePipeline(state);
|
||||
let plan: CompanyTaskPlan;
|
||||
let plannerRaw = '';
|
||||
let plannerParsed = false;
|
||||
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)}`,
|
||||
tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
|
||||
};
|
||||
plannerParsed = true;
|
||||
} else {
|
||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
|
||||
plan = plannerResult.plan;
|
||||
plannerRaw = plannerResult.raw;
|
||||
plannerParsed = plannerResult.parsed;
|
||||
}
|
||||
if (isAborted()) return fail('aborted-after-plan');
|
||||
emit({
|
||||
phase: 'plan-ready',
|
||||
plan: plannerResult.plan,
|
||||
parsed: plannerResult.parsed,
|
||||
raw: plannerResult.raw,
|
||||
plan,
|
||||
parsed: plannerParsed,
|
||||
raw: plannerRaw,
|
||||
});
|
||||
writeBrief(sessionDir, userPrompt, plannerResult.plan);
|
||||
writeBrief(sessionDir, userPrompt, 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 });
|
||||
if (pipeline) {
|
||||
const runResult = await _runPipeline(pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit);
|
||||
if (runResult.aborted) return fail(runResult.aborted);
|
||||
outputs.push(...runResult.outputs);
|
||||
} else {
|
||||
const total = plan.tasks.length;
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (isAborted()) return fail('aborted-mid-dispatch');
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: synthesis ──
|
||||
@@ -187,7 +216,7 @@ export async function runCompanyTurn(
|
||||
const reportModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const reportResult = await runCeoReporter(
|
||||
deps.ai,
|
||||
plannerResult.plan,
|
||||
plan,
|
||||
outputs,
|
||||
state,
|
||||
{ model: reportModel },
|
||||
@@ -209,7 +238,7 @@ export async function runCompanyTurn(
|
||||
const tgText = formatCompanyTelegramReport({
|
||||
state,
|
||||
userPrompt,
|
||||
plan: plannerResult.plan,
|
||||
plan,
|
||||
outputs,
|
||||
report: reportResult.report,
|
||||
sessionTimestamp: timestamp,
|
||||
@@ -229,7 +258,7 @@ export async function runCompanyTurn(
|
||||
const result: SessionResult = {
|
||||
timestamp, sessionDir,
|
||||
userPrompt,
|
||||
plan: plannerResult.plan,
|
||||
plan,
|
||||
agentOutputs: outputs,
|
||||
report: reportResult.report,
|
||||
totalDurationMs: Date.now() - startedAt,
|
||||
@@ -260,7 +289,7 @@ async function _dispatchOne(
|
||||
deps: DispatcherDeps,
|
||||
): Promise<AgentTurnOutput> {
|
||||
const startedAt = Date.now();
|
||||
const def = getCompanyAgent(agentId);
|
||||
const def = resolveAgent(state, agentId);
|
||||
if (!def) {
|
||||
return {
|
||||
agentId, task, response: '', durationMs: 0,
|
||||
@@ -272,7 +301,7 @@ async function _dispatchOne(
|
||||
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 peerDef = resolveAgent(state, o.agentId);
|
||||
const body = o.response.length > PEER_OUTPUT_BUDGET
|
||||
? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)'
|
||||
: o.response;
|
||||
@@ -399,6 +428,94 @@ async function _dispatchOne(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function _runPipeline(
|
||||
pipeline: PipelineDef,
|
||||
userPrompt: string,
|
||||
brief: string,
|
||||
sessionDir: string,
|
||||
timestamp: string,
|
||||
state: CompanyState,
|
||||
deps: DispatcherDeps,
|
||||
isAborted: () => boolean,
|
||||
emit: CompanyTurnEmitter,
|
||||
): Promise<{ outputs: AgentTurnOutput[]; aborted?: string }> {
|
||||
const outputs: AgentTurnOutput[] = [];
|
||||
// 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> = {};
|
||||
const iterations: Record<string, number> = {};
|
||||
const total = pipeline.stages.length;
|
||||
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);
|
||||
emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
|
||||
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps);
|
||||
outputs.push(turn);
|
||||
latestByStage[stage.id] = turn;
|
||||
writeAgentOutput(sessionDir, turn);
|
||||
appendAgentMemory(
|
||||
deps.context, stage.agentId,
|
||||
`[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
|
||||
);
|
||||
emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total });
|
||||
stepIndex++;
|
||||
// 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;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return { outputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute template tokens in a stage's instruction. Falls back to the
|
||||
* raw user prompt when the template is empty so the user doesn't have to
|
||||
* fill every stage with a long template just to forward the original ask.
|
||||
*/
|
||||
function _renderStageInstruction(
|
||||
stage: PipelineStage,
|
||||
userPrompt: string,
|
||||
brief: string,
|
||||
latestByStage: Record<string, AgentTurnOutput>,
|
||||
): string {
|
||||
const tpl = (stage.instructionTemplate || '').trim();
|
||||
if (!tpl) return userPrompt;
|
||||
return tpl
|
||||
.replace(/\{\{\s*userPrompt\s*\}\}/g, userPrompt)
|
||||
.replace(/\{\{\s*brief\s*\}\}/g, brief)
|
||||
.replace(/\{\{\s*stage\.([a-zA-Z0-9_-]+)\s*\}\}/g, (_m, sid) => {
|
||||
const o = latestByStage[sid];
|
||||
return o?.response ?? `[stage:${sid} 아직 실행되지 않음]`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pre-check so we don't fire up the action-tag executor for every
|
||||
* specialist response — only the ones that actually contain a recognised
|
||||
|
||||
@@ -22,6 +22,18 @@ export {
|
||||
setAgentModelOverride,
|
||||
setAgentPromptOverride,
|
||||
setAgentKnowledgeMix,
|
||||
addCustomAgent,
|
||||
removeCustomAgent,
|
||||
validateCustomAgentId,
|
||||
setAgentRoleCategory,
|
||||
upsertPipeline,
|
||||
deletePipeline,
|
||||
setActivePipeline,
|
||||
resolveActivePipeline,
|
||||
resolveAgent,
|
||||
resolveAgentRoleCategory,
|
||||
listAllAgents,
|
||||
listActiveAgentsByCategory,
|
||||
resolveAgentPrompt,
|
||||
resolveCompanyKnowledgeMix,
|
||||
activeAgentIds,
|
||||
@@ -30,6 +42,14 @@ export {
|
||||
summarizeForChip,
|
||||
} from './companyConfig';
|
||||
|
||||
export type {
|
||||
AgentRoleCategory,
|
||||
PipelineDef,
|
||||
PipelineStage,
|
||||
} from './types';
|
||||
|
||||
export { ROLE_CATEGORY_LABELS, ROLE_CATEGORY_ORDER } from './types';
|
||||
|
||||
export type {
|
||||
CompanyAgentDef,
|
||||
CompanyState,
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* every task. Each call is pure (no I/O of its own — the caller fetches
|
||||
* memory/decisions and passes them in), which keeps it trivial to test.
|
||||
*/
|
||||
import { COMPANY_AGENTS, getCompanyAgent } from './agents';
|
||||
import { resolveAgentPrompt } from './companyConfig';
|
||||
import { COMPANY_AGENTS } from './agents';
|
||||
import { listAllAgents, resolveAgent, resolveAgentPrompt } from './companyConfig';
|
||||
import { CompanyState } from './types';
|
||||
|
||||
export interface SpecialistPromptInputs {
|
||||
@@ -57,7 +57,7 @@ export interface SpecialistPromptInputs {
|
||||
* dense paragraphs. Order matters: identity first, then rules, then context.
|
||||
*/
|
||||
export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
||||
const agent = getCompanyAgent(inputs.agentId);
|
||||
const agent = resolveAgent(inputs.state, inputs.agentId);
|
||||
if (!agent) {
|
||||
// Defensive fallback — should never happen because the dispatcher
|
||||
// filters tasks against the active agent set before calling us.
|
||||
@@ -188,13 +188,15 @@ export function buildPlannerSystemPrompt(
|
||||
): string {
|
||||
const active = new Set<string>(state.activeAgentIds);
|
||||
active.add('ceo');
|
||||
const inactive = Object.keys(COMPANY_AGENTS).filter((id) => !active.has(id));
|
||||
// Built-ins + custom agents — both can be deactivated by the user.
|
||||
const allIds = listAllAgents(state).map((a) => a.id);
|
||||
const inactive = allIds.filter((id) => !active.has(id));
|
||||
const tail: string[] = [];
|
||||
if (inactive.length > 0) {
|
||||
tail.push('');
|
||||
tail.push('현재 비활성화된 에이전트 (절대 dispatch 금지):');
|
||||
for (const id of inactive) {
|
||||
const def = COMPANY_AGENTS[id];
|
||||
const def = resolveAgent(state, id) ?? COMPANY_AGENTS[id];
|
||||
tail.push(`- ${id} (${def?.name ?? id})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import { appendTelegramMessage } from '../../integrations/telegram/conversationHistory';
|
||||
import { COMPANY_AGENTS } from './agents';
|
||||
import { resolveAgent } from './companyConfig';
|
||||
import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
|
||||
|
||||
/** Same key the rest of the extension uses. Defined locally so this module is dependency-free. */
|
||||
@@ -130,7 +130,7 @@ export function formatCompanyTelegramReport(opts: {
|
||||
const brief = opts.plan.brief ? `\n\n*브리프:* ${opts.plan.brief}` : '';
|
||||
const agentsLine = opts.plan.tasks.length > 0
|
||||
? '\n\n*완료한 에이전트:*\n' + opts.plan.tasks.map((t) => {
|
||||
const def = COMPANY_AGENTS[t.agent];
|
||||
const def = resolveAgent(opts.state, t.agent);
|
||||
const ranOk = opts.outputs.find((o) => o.agentId === t.agent && !o.error);
|
||||
const mark = ranOk ? '✅' : '⚠️';
|
||||
return `• ${mark} ${def?.emoji ?? ''} ${def?.name ?? t.agent}`;
|
||||
|
||||
@@ -9,6 +9,50 @@
|
||||
* model-constrained machine without RAM thrash.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 직군(role category). Drives the pipeline editor's "직군 → 담당 에이전트"
|
||||
* cascading dropdown and the CEO planner's "no 개발 before 기획" guard rail.
|
||||
*
|
||||
* Why a fixed enum instead of free-text categories: a small LLM (gemma e2b
|
||||
* etc.) can't reliably *cluster* free-text specialties at plan time, but it
|
||||
* can absolutely reason about "planner first, developer second" when the
|
||||
* planner prompt enumerates these slugs. The UI also needs a stable set so
|
||||
* the dropdown is short and the icons are pickable.
|
||||
*
|
||||
* ceo — orchestrator (only the built-in CEO)
|
||||
* planner — 기획 (요구 정리·기획서·방향성 정의)
|
||||
* researcher — 시장·트렌드·사실 확인 리서치
|
||||
* designer — UI/UX·비주얼·사운드·콘텐츠 디자인
|
||||
* developer — 코드 작성·API·자동화·배포 스크립트
|
||||
* qa — 테스트·버그 발굴·회귀 확인
|
||||
* inspector — 기획 의도 대비 산출물 감리 / 최종 승인
|
||||
* support — 비즈니스·운영·비서 (직접 산출물 X, 보조)
|
||||
*/
|
||||
export type AgentRoleCategory =
|
||||
| 'ceo'
|
||||
| 'planner'
|
||||
| 'researcher'
|
||||
| 'designer'
|
||||
| 'developer'
|
||||
| 'qa'
|
||||
| 'inspector'
|
||||
| 'support';
|
||||
|
||||
export const ROLE_CATEGORY_LABELS: Record<AgentRoleCategory, string> = {
|
||||
ceo: 'CEO',
|
||||
planner: '기획',
|
||||
researcher: '리서치',
|
||||
designer: '디자인',
|
||||
developer: '개발',
|
||||
qa: 'QA',
|
||||
inspector: '감리',
|
||||
support: '지원',
|
||||
};
|
||||
|
||||
export const ROLE_CATEGORY_ORDER: AgentRoleCategory[] = [
|
||||
'ceo', 'planner', 'researcher', 'designer', 'developer', 'qa', 'inspector', 'support',
|
||||
];
|
||||
|
||||
/** Static description of a company agent. Loaded from `agents.ts`. */
|
||||
export interface CompanyAgentDef {
|
||||
/** Stable identifier used in JSON plans, file names, config keys. */
|
||||
@@ -27,6 +71,14 @@ export interface CompanyAgentDef {
|
||||
tagline: string;
|
||||
/** Optional voice / personality directive injected into the system prompt. */
|
||||
persona?: string;
|
||||
/**
|
||||
* 직군 — drives pipeline editor cascading dropdowns and CEO planner
|
||||
* sequencing rules. Built-ins ship with a fixed category; users may
|
||||
* override via state.roleCategoryOverrides (custom agents pick at
|
||||
* create time and can re-edit later). Falls back to 'support' when
|
||||
* a legacy state entry is missing this field.
|
||||
*/
|
||||
roleCategory: AgentRoleCategory;
|
||||
/**
|
||||
* When true, this agent can't be toggled off in the UI. CEO uses this so
|
||||
* it's always available as the orchestrator.
|
||||
@@ -88,6 +140,72 @@ export interface CompanyState {
|
||||
* because their job is to cite recorded knowledge.
|
||||
*/
|
||||
knowledgeMixOverrides: Record<string, number>;
|
||||
/**
|
||||
* User-added agents. Merged with the built-in `COMPANY_AGENTS` roster at
|
||||
* read time — the built-ins are immutable code; everything in here is
|
||||
* user-defined and editable. Keys are agent ids (lowercase, kebab-case),
|
||||
* which must NOT collide with built-in ids.
|
||||
*/
|
||||
customAgents?: Record<string, CompanyAgentDef>;
|
||||
/**
|
||||
* Per-agent 직군 override. Lets the user re-classify a built-in agent
|
||||
* without forking the code (e.g. promote Writer from `planner` to
|
||||
* `inspector` if they want that workflow). Missing key → use the
|
||||
* agent def's own `roleCategory`.
|
||||
*/
|
||||
roleCategoryOverrides?: Record<string, AgentRoleCategory>;
|
||||
/**
|
||||
* User-defined work pipelines. When `activePipelineId` is set to a key
|
||||
* here, the dispatcher runs the pipeline's stages in order instead of
|
||||
* letting the CEO planner emit ad-hoc tasks. Empty / unset → legacy
|
||||
* behaviour (CEO planner → linear dispatch).
|
||||
*/
|
||||
pipelines?: Record<string, PipelineDef>;
|
||||
/**
|
||||
* Which pipeline drives this turn. `null` / undefined → legacy CEO
|
||||
* planner path. Must reference a key in `pipelines` or it is ignored.
|
||||
*/
|
||||
activePipelineId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One step of a custom pipeline. Each stage runs a specific agent with a
|
||||
* specific instruction template (supports `{{userPrompt}}` and stage-output
|
||||
* variables like `{{stage.plan}}`). The stage can optionally loop back to
|
||||
* a previous stage when its output matches a regex — the canonical use
|
||||
* case is "QA finds a bug → go back to dev".
|
||||
*/
|
||||
export interface PipelineStage {
|
||||
/** Stable id within the pipeline. Used for `loopBackTo` references. */
|
||||
id: string;
|
||||
/** Human label shown in the chat phase header and the editor. */
|
||||
label: string;
|
||||
/** Which agent runs this stage. Must resolve via `resolveAgent`. */
|
||||
agentId: string;
|
||||
/**
|
||||
* Instruction template. Tokens substituted before dispatch:
|
||||
* - `{{userPrompt}}` — what the user typed
|
||||
* - `{{brief}}` — CEO brief from the kickoff stage
|
||||
* - `{{stage.<id>}}` — full output of a previous stage
|
||||
* Empty → fall back to `{{userPrompt}}`.
|
||||
*/
|
||||
instructionTemplate: string;
|
||||
/**
|
||||
* Regex (string form, case-insensitive) applied to this stage's output.
|
||||
* On match the dispatcher jumps back to `loopBackTo`. Iterations counted
|
||||
* — see `maxIterations`.
|
||||
*/
|
||||
loopBackPattern?: string;
|
||||
/** Stage id to jump to on `loopBackPattern` match. Must precede this stage. */
|
||||
loopBackTo?: string;
|
||||
/** Safety cap on how many times this stage can loop back. Default 3. */
|
||||
maxIterations?: number;
|
||||
}
|
||||
|
||||
export interface PipelineDef {
|
||||
id: string;
|
||||
name: string;
|
||||
stages: PipelineStage[];
|
||||
}
|
||||
|
||||
/** Output of the CEO planner LLM call after JSON parsing. */
|
||||
|
||||
@@ -75,6 +75,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
await provider._sendBrainStatus();
|
||||
return true;
|
||||
case 'stopGeneration':
|
||||
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
|
||||
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
|
||||
provider.abortCompanyTurn();
|
||||
provider._agent.stop();
|
||||
return true;
|
||||
case 'loadSession':
|
||||
@@ -203,6 +206,21 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'setCompanyAgentRoleCategory': {
|
||||
// Override an agent's 직군. Empty / null payload value reverts to
|
||||
// the def's own roleCategory. CEO is rejected by the backend.
|
||||
const { setAgentRoleCategory } = await import('../features/company');
|
||||
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
||||
if (!agentId) return true;
|
||||
const cat = (typeof data.value === 'string' && data.value.trim()) ? data.value.trim() : null;
|
||||
const result = await setAgentRoleCategory(provider._context, agentId, cat as any);
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'setCompanyAgentRoleCategoryResult',
|
||||
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
|
||||
});
|
||||
if (result.ok) await provider._sendCompanyAgents();
|
||||
return true;
|
||||
}
|
||||
case 'setCompanyAgentKnowledgeMix': {
|
||||
// Per-agent Knowledge Mix override. `null`/missing value falls
|
||||
// back to the global slider. The dispatcher reads this on the
|
||||
@@ -238,6 +256,79 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
await provider._sendCompanyAgents();
|
||||
return true;
|
||||
}
|
||||
case 'addCompanyAgent': {
|
||||
// User-defined agent. Payload: { def: CompanyAgentDef }. Returns
|
||||
// an `addCompanyAgentResult` so the UI overlay can keep its form
|
||||
// open + show an error when validation fails (id collision etc.).
|
||||
const { addCustomAgent } = await import('../features/company');
|
||||
const def = data.def;
|
||||
const result = await addCustomAgent(provider._context, def ?? {});
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'addCompanyAgentResult',
|
||||
value: result.ok
|
||||
? { ok: true, agentId: def?.id }
|
||||
: { ok: false, reason: result.reason },
|
||||
});
|
||||
if (result.ok) {
|
||||
await provider._sendCompanyStatus();
|
||||
await provider._sendCompanyAgents();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'deleteCompanyAgent': {
|
||||
// Drop a user-defined agent. Built-ins refuse — backend enforces.
|
||||
const { removeCustomAgent } = await import('../features/company');
|
||||
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
|
||||
if (!agentId) return true;
|
||||
const result = await removeCustomAgent(provider._context, agentId);
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'deleteCompanyAgentResult',
|
||||
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
|
||||
});
|
||||
if (result.ok) {
|
||||
await provider._sendCompanyStatus();
|
||||
await provider._sendCompanyAgents();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'getCompanyPipelines':
|
||||
await provider._sendCompanyPipelines();
|
||||
return true;
|
||||
case 'upsertCompanyPipeline': {
|
||||
const { upsertPipeline } = await import('../features/company');
|
||||
const result = await upsertPipeline(provider._context, data.def ?? {});
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'upsertCompanyPipelineResult',
|
||||
value: result.ok ? { ok: true } : { ok: false, reason: result.reason },
|
||||
});
|
||||
if (result.ok) await provider._sendCompanyPipelines();
|
||||
return true;
|
||||
}
|
||||
case 'deleteCompanyPipeline': {
|
||||
const { deletePipeline } = await import('../features/company');
|
||||
const pid = typeof data.pipelineId === 'string' ? data.pipelineId : '';
|
||||
if (!pid) return true;
|
||||
const result = await deletePipeline(provider._context, pid);
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'deleteCompanyPipelineResult',
|
||||
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
|
||||
});
|
||||
if (result.ok) await provider._sendCompanyPipelines();
|
||||
return true;
|
||||
}
|
||||
case 'setActiveCompanyPipeline': {
|
||||
const { setActivePipeline } = await import('../features/company');
|
||||
const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim()
|
||||
? data.pipelineId.trim()
|
||||
: null;
|
||||
const result = await setActivePipeline(provider._context, pid);
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'setActiveCompanyPipelineResult',
|
||||
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
|
||||
});
|
||||
if (result.ok) await provider._sendCompanyPipelines();
|
||||
return true;
|
||||
}
|
||||
case 'proactiveTrigger':
|
||||
await provider._handleProactiveSuggestion(data.context);
|
||||
return true;
|
||||
|
||||
+99
-19
@@ -42,6 +42,9 @@ import {
|
||||
CompanyTurnEvent,
|
||||
COMPANY_AGENTS,
|
||||
COMPANY_AGENT_ORDER,
|
||||
ROLE_CATEGORY_LABELS,
|
||||
ROLE_CATEGORY_ORDER,
|
||||
resolveAgent,
|
||||
} from './features/company';
|
||||
import { AIService } from './core/services';
|
||||
|
||||
@@ -91,6 +94,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
|
||||
static readonly MODELS_CACHE_TTL_MS = 30000;
|
||||
|
||||
/**
|
||||
* AbortController for the currently-running 1인 기업 turn. Cleared when
|
||||
* the turn ends (success or fail). The webview's Stop button routes
|
||||
* through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
|
||||
* — the dispatcher's `signal` then short-circuits between phases.
|
||||
*/
|
||||
private _companyAbort?: AbortController;
|
||||
|
||||
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
|
||||
private _archWatcher?: vscode.FileSystemWatcher;
|
||||
/** Debounce timer for the architecture watcher. */
|
||||
@@ -1453,6 +1464,49 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
return readCompanyState(this._context).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the currently-running 1인 기업 turn if any. Returns true when an
|
||||
* abort was actually fired (so the chat handler can skip `agent.stop()`
|
||||
* — the company path never touches AgentExecutor). The dispatcher will
|
||||
* see `signal.aborted` at its next phase boundary and emit
|
||||
* `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
|
||||
* `streamEnd` so the UI unlocks.
|
||||
*/
|
||||
abortCompanyTurn(): boolean {
|
||||
if (!this._companyAbort) return false;
|
||||
this._companyAbort.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the full pipeline catalogue + active id to the webview so the
|
||||
* editor overlay can render the cards. Pipelines are user-defined
|
||||
* (no built-ins) so an empty list is the default for new users.
|
||||
*/
|
||||
async _sendCompanyPipelines(): Promise<void> {
|
||||
if (!this._view) return;
|
||||
const state = readCompanyState(this._context);
|
||||
// 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
|
||||
// cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
|
||||
const { listActiveAgentsByCategory } = await import('./features/company');
|
||||
const byCategory = listActiveAgentsByCategory(state);
|
||||
// CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
|
||||
const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
|
||||
for (const [cat, defs] of Object.entries(byCategory)) {
|
||||
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
|
||||
}
|
||||
this._view.webview.postMessage({
|
||||
type: 'companyPipelines',
|
||||
value: {
|
||||
pipelines: state.pipelines ?? {},
|
||||
activePipelineId: state.activePipelineId ?? null,
|
||||
roleCategoryLabels: ROLE_CATEGORY_LABELS,
|
||||
roleCategoryOrder: ROLE_CATEGORY_ORDER,
|
||||
activeAgentsByCategory: slimByCategory,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Send the chip state (active flag + agent count + name) to the webview. */
|
||||
async _sendCompanyStatus(): Promise<void> {
|
||||
if (!this._view) return;
|
||||
@@ -1480,44 +1534,63 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
const state = readCompanyState(this._context);
|
||||
const cfg = getConfig();
|
||||
const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
|
||||
const agents = COMPANY_AGENT_ORDER.map((id) => {
|
||||
const def = COMPANY_AGENTS[id];
|
||||
// Built-ins first (insertion order from agents.ts), then user-added
|
||||
// customs in their own order. `custom: true` lets the UI render a
|
||||
// delete button only for user-added entries.
|
||||
const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id]);
|
||||
const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
|
||||
const orderedIds = [...builtinIds, ...customIds];
|
||||
const renderEntry = (id: string) => {
|
||||
const builtin = COMPANY_AGENTS[id];
|
||||
const custom = state.customAgents?.[id];
|
||||
const baseDef = builtin ?? custom;
|
||||
if (!baseDef) return null;
|
||||
// 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
|
||||
// 보이려면 override 결과를 보내야 한다.
|
||||
const effective = resolveAgent(state, id) ?? baseDef;
|
||||
const isCustom = !builtin;
|
||||
const override = state.promptOverrides[id] || {};
|
||||
const kmOverride = state.knowledgeMixOverrides[id];
|
||||
const hasKmOverride = typeof kmOverride === 'number';
|
||||
const roleOverride = state.roleCategoryOverrides?.[id];
|
||||
return {
|
||||
id,
|
||||
name: def.name,
|
||||
role: def.role,
|
||||
emoji: def.emoji,
|
||||
color: def.color,
|
||||
alwaysOn: !!def.alwaysOn,
|
||||
name: effective.name,
|
||||
role: effective.role,
|
||||
emoji: effective.emoji,
|
||||
color: effective.color,
|
||||
alwaysOn: !!effective.alwaysOn,
|
||||
custom: isCustom,
|
||||
active: id === 'ceo' || state.activeAgentIds.includes(id),
|
||||
modelOverride: state.modelOverrides[id] || '',
|
||||
// Defaults — never change at runtime.
|
||||
defaultTagline: def.tagline,
|
||||
defaultSpecialty: def.specialty,
|
||||
defaultPersona: def.persona || '',
|
||||
// Current effective values (default + override merged).
|
||||
tagline: override.tagline || def.tagline,
|
||||
specialty: override.specialty || def.specialty,
|
||||
persona: override.persona || def.persona || '',
|
||||
// Per-field override flags for the UI.
|
||||
defaultTagline: baseDef.tagline,
|
||||
defaultSpecialty: baseDef.specialty,
|
||||
defaultPersona: baseDef.persona || '',
|
||||
tagline: override.tagline || baseDef.tagline,
|
||||
specialty: override.specialty || baseDef.specialty,
|
||||
persona: override.persona || baseDef.persona || '',
|
||||
personaOverridden: !!override.persona,
|
||||
specialtyOverridden: !!override.specialty,
|
||||
taglineOverridden: !!override.tagline,
|
||||
// Knowledge Mix — null when using global default, number otherwise.
|
||||
// 직군: effective(override 반영) + def 기본값 + override 플래그
|
||||
roleCategory: effective.roleCategory,
|
||||
defaultRoleCategory: baseDef.roleCategory,
|
||||
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
|
||||
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
|
||||
// What the dispatcher *will actually use* this turn (for hint UI).
|
||||
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
|
||||
};
|
||||
});
|
||||
};
|
||||
const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
|
||||
this._view.webview.postMessage({
|
||||
type: 'companyAgents',
|
||||
value: {
|
||||
companyName: state.companyName,
|
||||
globalKnowledgeMixWeight: globalWeight,
|
||||
agents,
|
||||
// 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
|
||||
// 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
|
||||
roleCategoryLabels: ROLE_CATEGORY_LABELS,
|
||||
roleCategoryOrder: ROLE_CATEGORY_ORDER,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1534,6 +1607,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
const emit = (event: CompanyTurnEvent) => {
|
||||
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
|
||||
};
|
||||
// Fresh AbortController per turn — the Stop button routes through
|
||||
// `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
|
||||
// `signal.aborted` between phases and short-circuits cleanly.
|
||||
const abort = new AbortController();
|
||||
this._companyAbort = abort;
|
||||
try {
|
||||
await runCompanyTurn(userPrompt, {
|
||||
context: this._context,
|
||||
@@ -1550,6 +1628,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
// hit disk. Without this, agents would *claim* to create
|
||||
// files while nothing happened — the exact bug we just fixed.
|
||||
executeActionTags: (text) => this._agent.executeActionTagsOnText(text),
|
||||
signal: abort.signal,
|
||||
onEvent: emit,
|
||||
});
|
||||
} catch (e: any) {
|
||||
@@ -1559,6 +1638,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
|
||||
});
|
||||
} finally {
|
||||
if (this._companyAbort === abort) this._companyAbort = undefined;
|
||||
// The webview's send button is locked into the "generating" state
|
||||
// when the user submits; it only unlocks on `streamEnd`. The
|
||||
// normal chat path posts that from inside AgentExecutor, but
|
||||
|
||||
Reference in New Issue
Block a user