/** * CEO planner — turns a user prompt into a `CompanyTaskPlan`. * * Lifecycle of one planner call: * 1. Build the planner system prompt (template + active-agent list). * 2. Hit the AI service with the user prompt as the user message. * 3. Parse the response through a 4-stage JSON pipeline that tolerates * ```json fences, leading thoughts, truncated outputs, and minor key * misspellings. Smaller local models violate "no extra text" rules * *constantly*, so a permissive parser is required. * 4. Normalize agent ids: accept Korean nicknames (`레오` → `youtube`, * `코다리` → `developer`) and filter out tasks for inactive agents. * * The function never throws — it always returns a `CompanyTaskPlan`. If * everything fails we surface an empty plan with a brief that explains what * happened, and the dispatcher treats that as "nothing to dispatch, just * relay the chat-style reply". */ import { IAIService } from '../../core/services'; import { logError, logInfo } from '../../utils'; import { COMPANY_AGENTS } from './agents'; import { isAgentActive } from './companyConfig'; import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets'; import { buildPlannerSystemPrompt } from './promptBuilder'; import { CompanyState, CompanyTaskPlan } from './types'; export interface PlannerResult { plan: CompanyTaskPlan; /** True iff JSON parsing succeeded — false means we fell back to empty. */ parsed: boolean; /** Raw LLM output (kept for the chat / debug log). */ raw: string; } 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. */ const NAME_TO_ID: Record = (() => { const out: Record = {}; 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") const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase(); if (roleHead && !out[roleHead]) out[roleHead] = id; } return out; })(); function _canonicalAgentId(raw: unknown): string | null { if (typeof raw !== 'string') return null; const key = raw.trim().toLowerCase(); return NAME_TO_ID[key] ?? (COMPANY_AGENTS[key] ? key : null); } /** * 4-stage JSON extractor — same idea as Connect_origin's planner but built * fresh here so we don't carry over its 21K-line file. Each stage is a fall- * through: we keep trying until something gives us a parseable object. */ function _parsePlanJson(raw: string): CompanyTaskPlan | null { if (!raw || !raw.trim()) return null; // Stage 1 — strip ```json … ``` fence + leading "okay let me think" prose. const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); const stage1 = (fenced ? fenced[1] : raw).trim(); // Stage 2 — direct JSON.parse. try { const obj = JSON.parse(stage1); const plan = _coercePlan(obj); if (plan) return plan; } catch { /* fall through */ } // Stage 3 — find the first balanced `{ … }` and parse just that. Smaller // models love to prepend explanations or append trailing notes. const balanced = _extractFirstBalancedObject(stage1); if (balanced) { try { const obj = JSON.parse(balanced); const plan = _coercePlan(obj); if (plan) return plan; } catch { /* fall through */ } } // Stage 4 — regex recovery. If JSON is truncated mid-task we still try // to pull `brief` + any complete `{agent, task}` pairs from the text. const briefMatch = stage1.match(/"brief"\s*:\s*"([\s\S]*?)"/); const brief = briefMatch ? briefMatch[1] : ''; const tasks: CompanyTaskPlan['tasks'] = []; const taskRe = /\{\s*"agent"\s*:\s*"([^"]+)"\s*,\s*"task"\s*:\s*"([\s\S]*?)"\s*\}/g; let m: RegExpExecArray | null; while ((m = taskRe.exec(stage1))) { tasks.push({ agent: m[1].trim(), task: m[2].trim() }); } if (brief || tasks.length > 0) return { brief: brief.trim(), tasks }; return null; } function _coercePlan(obj: unknown): CompanyTaskPlan | null { if (!obj || typeof obj !== 'object') return null; const o = obj as Record; const brief = typeof o.brief === 'string' ? o.brief : ''; const rawTasks = Array.isArray(o.tasks) ? o.tasks : []; const tasks: CompanyTaskPlan['tasks'] = []; for (const t of rawTasks) { if (!t || typeof t !== 'object') continue; const tt = t as Record; if (typeof tt.agent === 'string' && typeof tt.task === 'string') { tasks.push({ agent: tt.agent.trim(), task: tt.task.trim() }); } } return { brief: brief.trim(), tasks }; } /** Find the first complete `{ … }` block respecting brace nesting. */ function _extractFirstBalancedObject(s: string): string | null { const start = s.indexOf('{'); if (start === -1) return null; let depth = 0; let inString = false; let escape = false; for (let i = start; i < s.length; i++) { const ch = s[i]; if (inString) { if (escape) escape = false; else if (ch === '\\') escape = true; else if (ch === '"') inString = false; continue; } if (ch === '"') { inString = true; continue; } if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) return s.slice(start, i + 1); } } return null; } /** * Filter + normalize a freshly-parsed plan against the current company * state. Tasks targeting unknown / inactive agents are dropped, and Korean * nicknames are rewritten to canonical ids. */ export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan { const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] }; const dropped: string[] = []; for (const t of plan.tasks) { const canonical = _canonicalAgentId(t.agent); if (!canonical) { dropped.push(`unknown:${t.agent}`); continue; } if (canonical === 'ceo') { // CEO is the orchestrator — it never receives a task in `tasks` // (the report phase calls it separately). Drop silently. dropped.push('ceo:self-dispatch'); continue; } if (!isAgentActive(state, canonical)) { dropped.push(`inactive:${canonical}`); continue; } out.tasks.push({ agent: canonical, task: t.task }); } if (dropped.length > 0) { logInfo('ceoPlanner: dropped tasks during normalization.', { dropped }); } return out; } /** * Run the CEO planner end-to-end. Never throws. The caller decides what to * do with `{ parsed: false, plan: { tasks: [] } }` — usually we surface the * raw text as a casual CEO reply. */ export async function runCeoPlanner( ai: IAIService, userPrompt: string, state: CompanyState, options: { model?: string; timeoutMs?: number } = {}, ): Promise { const system = buildPlannerSystemPrompt( applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }), state, ); let raw = ''; try { const result = await ai.chat({ system, user: userPrompt, model: options.model, timeoutMs: options.timeoutMs, }); raw = result.content || ''; } catch (e: any) { logError('ceoPlanner: AI call failed.', { error: e?.message ?? String(e) }); return { plan: EMPTY_PLAN, parsed: false, raw: '' }; } const parsed = _parsePlanJson(raw); if (!parsed) { // No JSON found — treat as a casual chat reply. The dispatcher's // empty-plan branch will surface `raw` as the CEO's spoken response. return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw }; } const plan = normalizePlan(parsed, state); logInfo('ceoPlanner: parsed plan.', { briefChars: plan.brief.length, taskCount: plan.tasks.length, agents: plan.tasks.map((t) => t.agent), }); return { plan, parsed: true, raw }; }