220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
/**
|
|
* 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<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")
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<PlannerResult> {
|
|
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 };
|
|
}
|