Files
connectai/src/features/company/ceoPlanner.ts
T

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 };
}