import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { Role, RoleResult } from "@uncaged/nerve-core"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; import { plannerPrompt } from "./prompt.js"; const roleSchema = z .object({ name: z.string().default(""), goal: z.string().default(""), io: z.string().default(""), }) .default({ name: "", goal: "", io: "" }); export const plannerMetaSchema = z.object({ userPrompt: z.string().default(""), workflowName: z .string() .default("") .describe("kebab-case workflow name under workflows/, e.g. issue-fixer"), roles: z.array(roleSchema).default([]), flowTransitions: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), validationLoopsDesign: z.preprocess( (v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default(""), ), externalDeps: z.preprocess( (v) => (Array.isArray(v) ? v.join(", ") : v), z.string().default(""), ), dataFlow: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), planMarkdown: z.preprocess( (v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default(""), ), }); export type PlannerMeta = z.infer; export type BuildPlannerDeps = { provider: LlmProvider; nerveRoot: string; workflowsDir: string; }; function getNerveYaml(nerveRoot: string): string { const result = readNerveYaml({ nerveRoot }); return result.ok ? result.value : "# nerve.yaml unavailable"; } function getSenseGeneratorReference(workflowsDir: string): string { const p = join(workflowsDir, "sense-generator", "index.ts"); if (!existsSync(p)) { return "(missing workflows/sense-generator/index.ts)"; } return readFileSync(p, "utf-8"); } export function buildPlannerRole({ provider, nerveRoot, workflowsDir, }: BuildPlannerDeps): Role { return async (start, _messages) => { const dry = isDryRun(start); const userPrompt = start.content; const messages = plannerPrompt({ nerveAgentContext, userPrompt, nerveRoot, workflowsDir, senseGeneratorReference: getSenseGeneratorReference(workflowsDir), nerveYaml: getNerveYaml(nerveRoot), }); const extracted = await llmExtract({ text: messages.map((m) => m.content).join("\n"), schema: plannerMetaSchema, provider, dryRun: dry, }); const emptyMeta: PlannerMeta = { userPrompt, workflowName: "", roles: [], flowTransitions: "", validationLoopsDesign: "", externalDeps: "", dataFlow: "", planMarkdown: "", }; if (!extracted.ok) { return { content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`, meta: emptyMeta, } satisfies RoleResult; } const value = extracted.value; const planMarkdown = value.planMarkdown.length > 0 ? value.planMarkdown : [ `# Workflow Plan`, `- workflowName: ${value.workflowName}`, ``, `## Roles`, ...value.roles.map((r) => `- ${r.name}: ${r.goal} (${r.io})`), ``, `## Flow Transitions`, value.flowTransitions, ``, `## Validation Loops`, value.validationLoopsDesign, ``, `## External Dependencies`, value.externalDeps, ``, `## Data Flow`, value.dataFlow, ].join("\n"); return { content: planMarkdown, meta: { userPrompt, workflowName: value.workflowName, roles: value.roles, flowTransitions: value.flowTransitions, validationLoopsDesign: value.validationLoopsDesign, externalDeps: value.externalDeps, dataFlow: value.dataFlow, planMarkdown, }, } satisfies RoleResult; }; }