refactor(sense-generator): use createCursorRole and slim SenseMeta
Replace hand-written planner and coder with createCursorRole from nerve-workflow-utils. Prompts instruct reading the Nerve thread via nerve thread show. Extract uses resolveDashScopeProvider. SenseMeta keeps routing-only fields; tester remains hand-written with filesystem and smoke checks. Made-with: Cursor
This commit is contained in:
parent
c5ea790447
commit
8ff6003a75
@ -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<string, boolean>; 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<SenseMeta> = {
|
||||
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<RoleResult<SenseMeta["planner"]>> {
|
||||
const userInput = start.content;
|
||||
async function runPlanner(
|
||||
start: StartStep,
|
||||
_messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<SenseMeta["planner"]>> {
|
||||
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<SenseMeta["planner"]>({
|
||||
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<RoleResult<SenseMeta["coder"]>> {
|
||||
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<RoleResult<SenseMeta["coder"]>> {
|
||||
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<SenseMeta["coder"]>({
|
||||
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<RoleResult<SenseMeta["tester"]>> {
|
||||
const last = messages[messages.length - 1];
|
||||
const { senseName, files } = last.meta as { senseName: string; files: Record<string, boolean> };
|
||||
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<RoleResult<SenseMeta["tester"]>> {
|
||||
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<SenseMeta> = {
|
||||
name: "sense-generator",
|
||||
|
||||
roles: {
|
||||
planner: runPlanner,
|
||||
coder: runCoder,
|
||||
tester: runTester,
|
||||
},
|
||||
|
||||
moderator(context) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user