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:
@@ -215,79 +215,177 @@ nerve sense schema <name> --json # JSON 格式输出
|
|||||||
workflows/<name>/
|
workflows/<name>/
|
||||||
index.ts # WorkflowDefinition — import roles,定义 moderator
|
index.ts # WorkflowDefinition — import roles,定义 moderator
|
||||||
roles/
|
roles/
|
||||||
analyst/
|
planner/
|
||||||
index.ts # role execute 函数
|
index.ts # role 构建函数
|
||||||
prompt.md # prompt 模板(可选)
|
prompt.ts # prompt 模板(纯函数,静态 import)
|
||||||
reporter/
|
coder/
|
||||||
index.ts
|
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
|
### WorkflowDefinition
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// workflows/alert/index.ts
|
// workflows/sense-generator/index.ts
|
||||||
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
||||||
import { analyst } from "./roles/analyst/index.js";
|
import { END } from "@uncaged/nerve-core";
|
||||||
import { reporter } from "./roles/reporter/index.js";
|
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 = {
|
const workflow: WorkflowDefinition<SenseMeta> = {
|
||||||
name: "alert",
|
name: "sense-generator",
|
||||||
roles: { analyst, reporter },
|
roles: {
|
||||||
|
planner: await buildPlannerRole(),
|
||||||
// Moderator: 纯路由函数,决定下一个 role
|
coder: await buildCoderRole(),
|
||||||
moderator: (context) => {
|
tester,
|
||||||
if (context.rounds === 0) return "analyst";
|
},
|
||||||
if (context.rounds === 1) return "reporter";
|
moderator(context) {
|
||||||
return "END";
|
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
|
Top-level `await` 是合法的 — daemon 通过 `await import(indexPath)` 加载 workflow 模块。
|
||||||
// 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: {} };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Role 可以附带 prompt 模板:
|
### Meta Schema 定义
|
||||||
|
|
||||||
|
Meta 类型和 Zod schema 集中在 `roles/types.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// workflows/alert/roles/reporter/index.ts
|
// roles/types.ts
|
||||||
import { readFileSync } from "node:fs";
|
import { z } from "zod";
|
||||||
import { join, dirname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
export type SenseMeta = {
|
||||||
const PROMPT = readFileSync(join(__dirname, "prompt.md"), "utf8");
|
planner: { senseName: string };
|
||||||
|
coder: { filesCreated: boolean };
|
||||||
export const reporter = {
|
tester: { passed: boolean; attempt: number };
|
||||||
async execute(start, messages) {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
await sendNotification(PROMPT.replace("{{content}}", lastMessage.content));
|
|
||||||
return { content: "已通知", meta: {} };
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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`)
|
### 公共工具(`@uncaged/nerve-workflow-utils`)
|
||||||
|
|
||||||
Role 中可使用以下公共工具:
|
|
||||||
|
|
||||||
| 工具 | 用途 |
|
| 工具 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `spawnSafe(cmd, args, opts)` | 安全启动子进程,`shell: false`,支持 timeout 和 dry-run,返回 `Result<SpawnResult>` |
|
| `createCursorRole(opts)` | Role factory:cursor-agent + llmExtract,返回 `Role<T>` |
|
||||||
| `nerveCommandEnv()` | 返回配置好 pnpm/nerve PATH 的环境变量,用于在 role 中调用 CLI |
|
| `createHermesRole(opts)` | Role factory:Hermes Agent + llmExtract,返回 `Role<T>` |
|
||||||
| `cursorAgent(prompt, opts)` | 调用 cursor-agent CLI(mode: `"plan"` / `"ask"` / `"default"`),返回 `Result<string>` |
|
| `createLlmRole(opts)` | Role factory:OpenAI chat + llmExtract,返回 `Role<T>` |
|
||||||
| `llmExtract(opts)` | 调用 OpenAI 兼容 API + Zod schema 提取结构化数据,返回 `Result<T>` |
|
| `createReActRole(opts)` | Role factory:ReAct 循环 + llmExtract,返回 `Role<T>` |
|
||||||
| `readNerveYaml(dir)` | 安全读取 nerve.yaml(防路径穿越),返回 `Result<string>` |
|
| `spawnSafe(cmd, args, opts)` | 安全启动子进程,`shell: false`,支持 timeout,返回 `Result<SpawnResult>` |
|
||||||
|
| `nerveCommandEnv()` | 返回配置好 pnpm/nerve PATH 的环境变量 |
|
||||||
| `isDryRun(startStep)` | 从 workflow StartStep 提取 dry-run 标志 |
|
| `isDryRun(startStep)` | 从 workflow StartStep 提取 dry-run 标志 |
|
||||||
|
|
||||||
### Workflow 进程模型
|
### Workflow 进程模型
|
||||||
|
|||||||
Reference in New Issue
Block a user