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 { END } from "@uncaged/nerve-core";
|
||||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||||
import {
|
import {
|
||||||
cursorAgent,
|
createCursorRole,
|
||||||
llmExtract,
|
|
||||||
nerveAgentContext,
|
nerveAgentContext,
|
||||||
readNerveYaml,
|
readNerveYaml,
|
||||||
spawnSafe,
|
spawnSafe,
|
||||||
@ -20,6 +19,7 @@ import { z } from "zod";
|
|||||||
const HOME = process.env.HOME ?? "/home/azureuser";
|
const HOME = process.env.HOME ?? "/home/azureuser";
|
||||||
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
const NERVE_ROOT = join(HOME, ".uncaged-nerve");
|
||||||
const SENSES_DIR = join(NERVE_ROOT, "senses");
|
const SENSES_DIR = join(NERVE_ROOT, "senses");
|
||||||
|
const AGENT_TIMEOUT_MS = 3_600_000;
|
||||||
|
|
||||||
function getNerveYaml(): string {
|
function getNerveYaml(): string {
|
||||||
const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
|
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.
|
* 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.
|
* 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 logParts: string[] = [];
|
||||||
|
|
||||||
const runNerve = async (args: string[]): Promise<{ ok: true; out: string } | { ok: false; err: 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");
|
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 = {
|
type SenseMeta = {
|
||||||
planner: { plan: string; senseName: string; userInput: string };
|
planner: { senseName: string };
|
||||||
coder: { senseName: string; files: Record<string, boolean>; cursorOutput: string };
|
coder: { filesCreated: boolean };
|
||||||
tester: { passed: boolean; senseName: string; reason: string; attempt: number };
|
tester: { passed: boolean; attempt: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
const senseMetaSchema = z
|
const plannerMetaSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().describe("kebab-case sense name, e.g. 'disk-usage'"),
|
senseName: z
|
||||||
description: z.string().describe("One-line description of what this sense monitors"),
|
.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> = {
|
const coderMetaSchema = z
|
||||||
name: "sense-generator",
|
.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 function runPlanner(
|
||||||
async planner(
|
|
||||||
start: StartStep,
|
start: StartStep,
|
||||||
_messages: WorkflowMessage[],
|
_messages: WorkflowMessage[],
|
||||||
): Promise<RoleResult<SenseMeta["planner"]>> {
|
): Promise<RoleResult<SenseMeta["planner"]>> {
|
||||||
const userInput = start.content;
|
const userInput = start.content;
|
||||||
|
|
||||||
const provider = await resolveDashScopeProvider();
|
const provider = await resolveDashScopeProvider();
|
||||||
if (provider === null) {
|
if (provider === null) {
|
||||||
return {
|
return {
|
||||||
content:
|
content:
|
||||||
"Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " +
|
"Cannot run planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " +
|
||||||
"and optionally DASHSCOPE_MODEL.",
|
"and optionally DASHSCOPE_MODEL.",
|
||||||
meta: { plan: "", senseName: "", userInput },
|
meta: { senseName: "" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
${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}
|
User request: ${userInput}
|
||||||
Pick a good kebab-case name for this sense.
|
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.`;
|
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(
|
try {
|
||||||
|
return await role(start, _messages);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return { content: message, meta: { senseName: "" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCoder(
|
||||||
_start: StartStep,
|
_start: StartStep,
|
||||||
messages: WorkflowMessage[],
|
messages: WorkflowMessage[],
|
||||||
): Promise<RoleResult<SenseMeta["coder"]>> {
|
): Promise<RoleResult<SenseMeta["coder"]>> {
|
||||||
const last = messages[messages.length - 1];
|
const plan = getPlanFromThread(messages);
|
||||||
const { plan, senseName } = last.meta as { plan: string; senseName: string };
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const codePrompt = `You are implementing a new Nerve sense called "${senseName}" in the directory ${SENSES_DIR}/${senseName}/.
|
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}/.
|
||||||
|
|
||||||
Here is the plan:
|
**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}
|
${plan}
|
||||||
|
|
||||||
@ -311,55 +334,46 @@ IMPORTANT RULES:
|
|||||||
- nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\`
|
- nerve.yaml: add under \`senses:\` and add a reflex under \`reflexes:\`
|
||||||
- Use the interval specified in the plan for the reflex
|
- Use the interval specified in the plan for the reflex
|
||||||
|
|
||||||
Create all files now.`;
|
Create all files now. End with a clear statement of whether all files and updates were created successfully, or what is still missing.`;
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
};
|
extract: { provider, schema: coderMetaSchema },
|
||||||
}
|
});
|
||||||
const result = agentResult.value;
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await role(_start, messages);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return { content: message, meta: { filesCreated: false } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTester(
|
||||||
|
_start: StartStep,
|
||||||
|
messages: WorkflowMessage[],
|
||||||
|
): Promise<RoleResult<SenseMeta["tester"]>> {
|
||||||
|
const senseName = getSenseNameFromThread(messages);
|
||||||
const senseDir = join(SENSES_DIR, senseName);
|
const senseDir = join(SENSES_DIR, senseName);
|
||||||
|
|
||||||
const files = {
|
const files = {
|
||||||
index: existsSync(join(senseDir, "index.js")),
|
index: existsSync(join(senseDir, "index.js")),
|
||||||
schema: existsSync(join(senseDir, "schema.ts")),
|
schema: existsSync(join(senseDir, "schema.ts")),
|
||||||
migration: existsSync(join(senseDir, "migrations", "0001_init.sql")),
|
migration: existsSync(join(senseDir, "migrations", "0001_init.sql")),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
content: result,
|
|
||||||
meta: { senseName, files, cursorOutput: result },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
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> };
|
|
||||||
|
|
||||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||||
|
|
||||||
|
if (!senseName) {
|
||||||
|
return {
|
||||||
|
content: "FAIL — no senseName from planner meta",
|
||||||
|
meta: { passed: false, attempt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k);
|
const missing = Object.entries(files).filter(([, v]) => !v).map(([k]) => k);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
return {
|
return {
|
||||||
content: `FAIL — missing files: ${missing.join(", ")}`,
|
content: `FAIL — missing files: ${missing.join(", ")}`,
|
||||||
meta: { passed: false, senseName, reason: `Missing files: ${missing.join(", ")}`, attempt },
|
meta: { passed: false, attempt },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,20 +382,26 @@ Create all files now.`;
|
|||||||
if (smoke.ok) {
|
if (smoke.ok) {
|
||||||
return {
|
return {
|
||||||
content: `PASS — ${smoke.reason}`,
|
content: `PASS — ${smoke.reason}`,
|
||||||
meta: { passed: true, senseName, reason: smoke.reason, attempt },
|
meta: { passed: true, attempt },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: `FAIL — ${smoke.reason}`,
|
content: `FAIL — ${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`,
|
||||||
meta: {
|
meta: {
|
||||||
passed: false,
|
passed: false,
|
||||||
senseName,
|
|
||||||
reason: `${smoke.reason}\n\n--- smoke log ---\n${smoke.log}`,
|
|
||||||
attempt,
|
attempt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
|
const workflow: WorkflowDefinition<SenseMeta> = {
|
||||||
|
name: "sense-generator",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
planner: runPlanner,
|
||||||
|
coder: runCoder,
|
||||||
|
tester: runTester,
|
||||||
},
|
},
|
||||||
|
|
||||||
moderator(context) {
|
moderator(context) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user