diff --git a/workflows/sense-generator/index.ts b/workflows/sense-generator/index.ts index 4bc4a69..b31976e 100644 --- a/workflows/sense-generator/index.ts +++ b/workflows/sense-generator/index.ts @@ -7,8 +7,7 @@ import type { import { END } from "@uncaged/nerve-core"; import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { - cursorAgent, - llmExtract, + createCursorRole, nerveAgentContext, readNerveYaml, spawnSafe, @@ -20,6 +19,7 @@ import { z } from "zod"; const HOME = process.env.HOME ?? "/home/azureuser"; const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const SENSES_DIR = join(NERVE_ROOT, "senses"); +const AGENT_TIMEOUT_MS = 3_600_000; function getNerveYaml(): string { const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); @@ -67,7 +67,9 @@ function formatSpawnFailure(error: SpawnError): string { * Run the same checks the workflow used to ask Hermes to perform, but locally. * Hermes chat often returns UI prose instead of shell output, which caused false failures. */ -async function runSenseSmokeTest(senseName: string): Promise<{ ok: boolean; log: string; reason: string }> { +async function runSenseSmokeTest( + senseName: string, +): Promise<{ ok: boolean; log: string; reason: string }> { const logParts: string[] = []; const runNerve = async (args: string[]): Promise<{ ok: true; out: string } | { ok: false; err: string }> => { @@ -170,43 +172,69 @@ function buildSenseExamples(): string { return examples.join("\n\n---\n\n"); } +function getSenseNameFromThread(messages: WorkflowMessage[]): string { + const p = messages.find((m) => m.role === "planner"); + if (p === undefined || typeof p.meta !== "object" || p.meta === null) { + return ""; + } + return String((p.meta as { senseName: string }).senseName); +} + +function getPlanFromThread(messages: WorkflowMessage[]): string { + const p = messages.find((m) => m.role === "planner"); + return p !== undefined ? p.content : ""; +} + type SenseMeta = { - planner: { plan: string; senseName: string; userInput: string }; - coder: { senseName: string; files: Record; cursorOutput: string }; - tester: { passed: boolean; senseName: string; reason: string; attempt: number }; + planner: { senseName: string }; + coder: { filesCreated: boolean }; + tester: { passed: boolean; attempt: number }; }; -const senseMetaSchema = z +const plannerMetaSchema = z .object({ - name: z.string().describe("kebab-case sense name, e.g. 'disk-usage'"), - description: z.string().describe("One-line description of what this sense monitors"), + senseName: z + .string() + .describe("kebab-case sense name from the plan, e.g. 'disk-usage'"), }) - .describe("Extract the sense name and a one-line description from the plan"); + .describe("Extract the kebab-case sense name from the plan text"); -const workflow: WorkflowDefinition = { - name: "sense-generator", +const coderMetaSchema = z + .object({ + filesCreated: z + .boolean() + .describe("true if index.js, schema.ts, migrations/0001_init.sql exist and nerve.yaml was updated"), + }) + .describe("Whether the agent completed all file work for the sense"); - roles: { - async planner( - start: StartStep, - _messages: WorkflowMessage[], - ): Promise> { - const userInput = start.content; +async function runPlanner( + start: StartStep, + _messages: WorkflowMessage[], +): Promise> { + const userInput = start.content; + const provider = await resolveDashScopeProvider(); + if (provider === null) { + return { + content: + "Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + + "and optionally DASHSCOPE_MODEL.", + meta: { senseName: "" }, + }; + } - const provider = await resolveDashScopeProvider(); - if (provider === null) { - return { - content: - "Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + - "and optionally DASHSCOPE_MODEL.", - meta: { plan: "", senseName: "", userInput }, - }; - } - - const planPrompt = `You are planning a new Nerve sense. + const role = createCursorRole({ + cwd: NERVE_ROOT, + mode: "ask", + timeoutMs: AGENT_TIMEOUT_MS, + prompt: async (threadId) => { + return `You are planning a new Nerve sense. ${nerveAgentContext} +**Context:** Read this workflow run for background before you plan. From a shell in \`${NERVE_ROOT}\`, run: +\`nerve thread show ${threadId} --budget 50000\` +Use the thread transcript (prior user messages and rounds) when deciding the sense. + User request: ${userInput} Pick a good kebab-case name for this sense. @@ -239,50 +267,45 @@ ${getNerveYaml()} \`\`\` Output ONLY the plan in markdown. Be precise and implementation-ready.`; - - const planResult = await cursorAgent({ - prompt: planPrompt, - mode: "ask", - cwd: NERVE_ROOT, - env: null, - timeoutMs: null, - }); - if (!planResult.ok) { - return { - content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`, - meta: { plan: "", senseName: "", userInput }, - }; - } - const plan = planResult.value; - - const extracted = await llmExtract({ - text: plan, - schema: senseMetaSchema, - provider, - }); - if (!extracted.ok) { - return { - content: `${plan}\n\n[llmExtract error] ${JSON.stringify(extracted.error)}`, - meta: { plan, senseName: "", userInput }, - }; - } - - return { - content: plan, - meta: { plan, senseName: extracted.value.name, userInput }, - }; }, + extract: { provider, schema: plannerMetaSchema }, + }); - async coder( - _start: StartStep, - messages: WorkflowMessage[], - ): Promise> { - const last = messages[messages.length - 1]; - const { plan, senseName } = last.meta as { plan: string; senseName: string }; + try { + return await role(start, _messages); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { content: message, meta: { senseName: "" } }; + } +} - const codePrompt = `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/. +async function runCoder( + _start: StartStep, + messages: WorkflowMessage[], +): Promise> { + const plan = getPlanFromThread(messages); + const senseName = getSenseNameFromThread(messages); + const provider = await resolveDashScopeProvider(); + if (provider === null) { + return { + content: + "Cannot run coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + + "and optionally DASHSCOPE_MODEL.", + meta: { filesCreated: false }, + }; + } -Here is the plan: + const role = createCursorRole({ + cwd: NERVE_ROOT, + mode: "default", + timeoutMs: AGENT_TIMEOUT_MS, + prompt: async (threadId) => { + return `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/. + +**Context:** Read this workflow run for background before you edit files. From a shell in \`${NERVE_ROOT}\`, run: +\`nerve thread show ${threadId} --budget 50000\` + +Here is the plan (from the planner step): ${plan} @@ -311,77 +334,74 @@ IMPORTANT RULES: - nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\` - Use the interval specified in the plan for the reflex -Create all files now.`; - - const agentResult = await cursorAgent({ - prompt: codePrompt, - mode: "default", - cwd: NERVE_ROOT, - env: null, - timeoutMs: null, - }); - if (!agentResult.ok) { - const resultText = `cursor-agent failed: ${formatSpawnFailure(agentResult.error)}`; - return { - content: resultText, - meta: { - senseName, - files: { index: false, schema: false, migration: false }, - cursorOutput: resultText, - }, - }; - } - const result = agentResult.value; - - const senseDir = join(SENSES_DIR, senseName); - const files = { - index: existsSync(join(senseDir, "index.js")), - schema: existsSync(join(senseDir, "schema.ts")), - migration: existsSync(join(senseDir, "migrations", "0001_init.sql")), - }; - - return { - content: result, - meta: { senseName, files, cursorOutput: result }, - }; +Create all files now. End with a clear statement of whether all files and updates were created successfully, or what is still missing.`; }, + extract: { provider, schema: coderMetaSchema }, + }); - async tester( - _start: StartStep, - messages: WorkflowMessage[], - ): Promise> { - const last = messages[messages.length - 1]; - const { senseName, files } = last.meta as { senseName: string; files: Record }; + try { + return await role(_start, messages); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { content: message, meta: { filesCreated: false } }; + } +} - const attempt = messages.filter((m) => m.role === "tester").length + 1; +async function runTester( + _start: StartStep, + messages: WorkflowMessage[], +): Promise> { + const senseName = getSenseNameFromThread(messages); + const senseDir = join(SENSES_DIR, senseName); - const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); - if (missing.length > 0) { - return { - content: `FAIL — missing files: ${missing.join(", ")}`, - meta: { passed: false, senseName, reason: `Missing files: ${missing.join(", ")}`, attempt }, - }; - } + const files = { + index: existsSync(join(senseDir, "index.js")), + schema: existsSync(join(senseDir, "schema.ts")), + migration: existsSync(join(senseDir, "migrations", "0001_init.sql")), + }; - const smoke = await runSenseSmokeTest(senseName); + const attempt = messages.filter((m) => m.role === "tester").length + 1; - if (smoke.ok) { - return { - content: `PASS — ${smoke.reason}`, - meta: { passed: true, senseName, reason: smoke.reason, attempt }, - }; - } + if (!senseName) { + return { + content: "FAIL — no senseName from planner meta", + meta: { passed: false, attempt }, + }; + } - return { - content: `FAIL — ${smoke.reason}`, - meta: { - passed: false, - senseName, - reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, - attempt, - }, - }; + const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k); + if (missing.length > 0) { + return { + content: `FAIL — missing files: ${missing.join(", ")}`, + meta: { passed: false, attempt }, + }; + } + + const smoke = await runSenseSmokeTest(senseName); + + if (smoke.ok) { + return { + content: `PASS — ${smoke.reason}`, + meta: { passed: true, attempt }, + }; + } + + return { + content: `FAIL — ${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`, + meta: { + passed: false, + attempt, }, + }; +} + +const workflow: WorkflowDefinition = { + name: "sense-generator", + + roles: { + planner: runPlanner, + coder: runCoder, + tester: runTester, }, moderator(context) {