docs(nerve-dev): update workflow section with role factories, meta routing principle, prompt.ts pattern

- Role factory templates (createCursorRole, createHermesRole, createLlmRole, createReActRole)
- Meta = moderator routing only, not data bus between roles
- prompt.ts pure functions instead of readFileSync + prompt.md
- Updated workflow-utils API table
- Real sense-generator example throughout

小橘 🍊(NEKO Team)
This commit is contained in:
2026-04-28 03:41:47 +00:00
parent 7ee7c4503a
commit facb25a959
+147 -49
View File
@@ -215,79 +215,177 @@ nerve sense schema <name> --json # JSON 格式输出
workflows/<name>/
index.ts # WorkflowDefinition — import roles,定义 moderator
roles/
analyst/
index.ts # role execute 函数
prompt.md # prompt 模板(可选
reporter/
planner/
index.ts # role 构建函数
prompt.ts # prompt 模板(纯函数,静态 import
coder/
index.ts
prompt.ts
tester/
index.ts # 手写 role(纯 CLI 逻辑,不用 factory)
shared.ts # 共享 helpers(provider、工具函数)
types.ts # SenseMeta 类型 + Zod schemas
```
每个 role 是一个独立目录,包含 `index.ts`execute 函数)和可选的 prompt 模板、配置等资源文件
每个 role 是一个独立目录,包含 `index.ts`role 构建函数)和 `prompt.ts`prompt 模板
### 设计原则:Meta 只服务 Moderator 路由
**Meta 不是数据总线。** Role 之间不通过 meta 传递数据。
- `content` 写入 thread,下游 role 通过 `nerve thread <threadId>` 自己读上下文
- `meta` 只包含 moderator 做路由决策需要的最小信息
```typescript
// ✅ Good — meta 只给 moderator 用
type SenseMeta = {
planner: { senseName: string }; // moderator 不需要 plan 全文
coder: { filesCreated: boolean }; // moderator 只需知道成功没
tester: { passed: boolean; attempt: number };
};
// ❌ Bad — meta 当数据总线,role 间紧耦合
type SenseMeta = {
planner: { plan: string; senseName: string; userInput: string };
coder: { senseName: string; files: Record<string, boolean>; cursorOutput: string };
};
```
### Role Factory 模板(`@uncaged/nerve-workflow-utils`)
优先使用 Role factory 创建 agent 类 role,不手写 `cursorAgent()` + `llmExtract()` 调用链。
```typescript
// roles/planner/index.ts
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { resolveDashScopeProvider, NERVE_ROOT } from "../shared.js";
import { plannerMetaSchema } from "../types.js";
import type { SenseMeta } from "../types.js";
import { plannerPrompt } from "./prompt.js";
export async function buildPlannerRole() {
const provider = await resolveDashScopeProvider();
if (provider === null) {
throw new Error("Missing DASHSCOPE_API_KEY or DASHSCOPE_BASE_URL");
}
return createCursorRole<SenseMeta["planner"]>({
cwd: NERVE_ROOT,
mode: "ask",
prompt: async (threadId) => plannerPrompt({ threadId, ... }),
extract: { provider, schema: plannerMetaSchema },
});
}
```
可用 factory:
| Factory | 用途 |
|---------|------|
| `createCursorRole(opts)` | 调 cursor-agent CLI,适合代码生成/review |
| `createHermesRole(opts)` | 调 Hermes Agent CLI,适合通用任务 |
| `createLlmRole(opts)` | 调 OpenAI 兼容 API,适合纯文本生成 |
| `createReActRole(opts)` | ReAct 循环(思考→工具→观察),适合需要工具调用的任务 |
所有 factory 内部流程统一:`agent 执行 → content → llmExtract(content, schema) → RoleResult<Meta>`
不适合用 factory 的场景:纯 CLI 逻辑(如跑测试、检查文件、git 操作)—— 这类 role 手写更清晰。
### Prompt 模板
Prompt 放在独立的 `prompt.ts` 文件,导出纯函数:
```typescript
// roles/planner/prompt.ts
export function plannerPrompt(vars: {
threadId: string;
senseExamples: string;
nerveYaml: string;
}): string {
return `You are planning a new Nerve sense.
Read the workflow thread: \`nerve thread ${vars.threadId}\`
...`;
}
```
```typescript
// roles/planner/index.ts — 使用
import { plannerPrompt } from "./prompt.js";
prompt: async (threadId) => plannerPrompt({ threadId, senseExamples, nerveYaml }),
```
**不要用 `readFileSync` 读 prompt.md** — 静态 import 确保 bundler 能打包,类型安全,无运行时文件依赖。
### WorkflowDefinition
```typescript
// workflows/alert/index.ts
// workflows/sense-generator/index.ts
import type { WorkflowDefinition } from "@uncaged/nerve-core";
import { analyst } from "./roles/analyst/index.js";
import { reporter } from "./roles/reporter/index.js";
import { END } from "@uncaged/nerve-core";
import { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js";
import { tester } from "./roles/tester/index.js";
import type { SenseMeta } from "./roles/types.js";
export const definition: WorkflowDefinition = {
name: "alert",
roles: { analyst, reporter },
// Moderator: 纯路由函数,决定下一个 role
moderator: (context) => {
if (context.rounds === 0) return "analyst";
if (context.rounds === 1) return "reporter";
return "END";
const workflow: WorkflowDefinition<SenseMeta> = {
name: "sense-generator",
roles: {
planner: await buildPlannerRole(),
coder: await buildCoderRole(),
tester,
},
moderator(context) {
if (context.steps.length === 0) return "planner";
const last = context.steps[context.steps.length - 1];
if (last.role === "planner") return "coder";
if (last.role === "coder") return "tester";
if (last.role === "tester") {
if (last.meta.passed) return END;
return last.meta.attempt < 3 ? "coder" : END;
}
return END;
},
};
export default workflow;
```
```typescript
// workflows/alert/roles/analyst/index.ts
export const analyst = {
async execute(start, messages) {
// start: { prompt, maxRounds, dryRun }
// messages: 历史消息数组
const analysis = await callLLM(start.prompt);
return { content: analysis, meta: {} };
},
};
```
Top-level `await` 是合法的 — daemon 通过 `await import(indexPath)` 加载 workflow 模块。
Role 可以附带 prompt 模板:
### Meta Schema 定义
Meta 类型和 Zod schema 集中在 `roles/types.ts`
```typescript
// workflows/alert/roles/reporter/index.ts
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
// roles/types.ts
import { z } from "zod";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROMPT = readFileSync(join(__dirname, "prompt.md"), "utf8");
export const reporter = {
async execute(start, messages) {
const lastMessage = messages[messages.length - 1];
await sendNotification(PROMPT.replace("{{content}}", lastMessage.content));
return { content: "已通知", meta: {} };
},
export type SenseMeta = {
planner: { senseName: string };
coder: { filesCreated: boolean };
tester: { passed: boolean; attempt: number };
};
export const plannerMetaSchema = z.object({
senseName: z.string().describe("kebab-case sense name from the plan"),
});
export const coderMetaSchema = z.object({
filesCreated: z.boolean().describe("true if the sense files were created"),
});
```
### 公共工具(`@uncaged/nerve-workflow-utils`)
Role 中可使用以下公共工具:
| 工具 | 用途 |
|------|------|
| `spawnSafe(cmd, args, opts)` | 安全启动子进程,`shell: false`,支持 timeout 和 dry-run,返回 `Result<SpawnResult>` |
| `nerveCommandEnv()` | 返回配置好 pnpm/nerve PATH 的环境变量,用于在 role 中调用 CLI |
| `cursorAgent(prompt, opts)` | 调用 cursor-agent CLI(mode: `"plan"` / `"ask"` / `"default"`),返回 `Result<string>` |
| `llmExtract(opts)` | 调用 OpenAI 兼容 API + Zod schema 提取结构化数据,返回 `Result<T>` |
| `readNerveYaml(dir)` | 安全读取 nerve.yaml(防路径穿越),返回 `Result<string>` |
| `createCursorRole(opts)` | Role factory:cursor-agent + llmExtract,返回 `Role<T>` |
| `createHermesRole(opts)` | Role factory:Hermes Agent + llmExtract,返回 `Role<T>` |
| `createLlmRole(opts)` | Role factory:OpenAI chat + llmExtract,返回 `Role<T>` |
| `createReActRole(opts)` | Role factory:ReAct 循环 + llmExtract,返回 `Role<T>` |
| `spawnSafe(cmd, args, opts)` | 安全启动子进程,`shell: false`,支持 timeout,返回 `Result<SpawnResult>` |
| `nerveCommandEnv()` | 返回配置好 pnpm/nerve PATH 的环境变量 |
| `isDryRun(startStep)` | 从 workflow StartStep 提取 dry-run 标志 |
### Workflow 进程模型