feat: Stabilize Company Suite & Self-Reflection logic, integrate new ADRs and bug records

This commit is contained in:
2026-05-14 16:05:28 +09:00
parent f521c3f557
commit 618b8d5b34
33 changed files with 2203 additions and 655 deletions
+41 -2
View File
@@ -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. */
+14 -14
View File
@@ -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;
+8 -6
View File
@@ -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 };
}
}
+386 -10
View File
@@ -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(),
+149 -32
View File
@@ -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
+20
View File
@@ -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,
+7 -5
View File
@@ -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})`);
}
}
+2 -2
View File
@@ -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}`;
+118
View File
@@ -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. */
+91
View File
@@ -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
View File
@@ -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