@mzzsfy/dsh-rs-workflow 0.2.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -49
- package/cordis.patch.yml +8 -17
- package/lib/board.mjs +355 -0
- package/lib/driver/approve.mjs +126 -0
- package/lib/driver/control.mjs +46 -0
- package/lib/driver/index.mjs +454 -0
- package/lib/driver/prompts.mjs +102 -0
- package/lib/driver/runner.mjs +173 -0
- package/lib/driver/scheduler.mjs +159 -0
- package/lib/index.js +30 -213
- package/lib/orchestrator.mjs +428 -0
- package/lib/planner-gate.mjs +195 -0
- package/lib/release.mjs +183 -0
- package/lib/settings-schema.mjs +73 -0
- package/lib/spec.mjs +161 -0
- package/lib/storage.mjs +35 -0
- package/lib/store.mjs +255 -0
- package/lib/template-tool.mjs +161 -0
- package/lib/template.mjs +341 -0
- package/package.json +8 -6
- package/src/client.js +1269 -0
- package/lib/preset-sync.mjs +0 -227
- package/preset/rs-workflow/agent.cordis.yml +0 -196
- package/preset/rs-workflow/preset.yml +0 -5
- package/preset/rs-workflow/skills/rs-workflow/SKILL.md +0 -110
- package/preset/rs-workflow/skills/rs-workflow/references/engine.js +0 -1446
- package/preset/rs-workflow/skills/rs-workflow/references/templates.md +0 -104
- package/preset/rs-workflow/skills/rs-workflow/slots.json5 +0 -53
package/lib/release.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// release — 流程模板创建/移除/自清理(v5:模板 id 由组合名承载,orchestrator 行 templateId 锚定改写)
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { validateTemplate } from './template.mjs'
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
const AGENT_YAML = 'agent.cordis.yml'
|
|
10
|
+
const PRESET_YML = 'preset.yml'
|
|
11
|
+
const FLOW_FILE = 'flow.json'
|
|
12
|
+
const MARKER_NAME = '.dsh-rs-workflow-source.json'
|
|
13
|
+
const PACKAGE_NAME = '@mzzsfy/dsh-rs-workflow'
|
|
14
|
+
const MARKER_KIND_FLOW = 'flow'
|
|
15
|
+
const PRESET_SKELETON = join(PKG_ROOT, 'preset', 'rs-workflow', AGENT_YAML)
|
|
16
|
+
const USER_PRESET_DIR = '.agent-presets'
|
|
17
|
+
const FLOW_PRESET_PREFIX = 'rs-'
|
|
18
|
+
const STAGING_PREFIX = '.rs-workflow-staging-'
|
|
19
|
+
const OLD_PREFIX = '.rs-workflow-old-'
|
|
20
|
+
const ORPHAN_PREFIX = '.rs-workflow-orphan-'
|
|
21
|
+
// 骨架唯一源中的 orchestrator 行 templateId 锚定;创建产物一律改写为组合承载的模板 id(恰一处)
|
|
22
|
+
const TEMPLATE_ANCHOR_MAIN = 'TPL_ANCHOR'
|
|
23
|
+
const FLOW_ID_RE = /^[a-z][a-z0-9-]*$/
|
|
24
|
+
const DESC_MAX_CHARS = 200
|
|
25
|
+
const DESC_FALLBACK = '流程工作流模板'
|
|
26
|
+
const PRESET_NAME_PREFIX = '若水·'
|
|
27
|
+
let PKG_VERSION = 'unknown'
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version
|
|
30
|
+
if (typeof parsed === 'string' && parsed.length > 0) PKG_VERSION = parsed
|
|
31
|
+
} catch { /* 读包失败归 unknown */ }
|
|
32
|
+
|
|
33
|
+
const nonEmpty = (v) => (typeof v === 'string' && v.trim().length > 0 ? v.trim() : null)
|
|
34
|
+
|
|
35
|
+
// 预设根解析:参数 > env 根 > env home > 默认家目录,统一拼 .agent-presets
|
|
36
|
+
function resolveHome(explicit) {
|
|
37
|
+
const home = nonEmpty(explicit) ?? nonEmpty(process.env.DSH_RS_WORKFLOW_PRESET_ROOT) ?? nonEmpty(process.env.DSH_HOME) ?? join(homedir(), '.dsh')
|
|
38
|
+
return resolve(home)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function presetRoot(dshHome) {
|
|
42
|
+
return join(resolveHome(dshHome), USER_PRESET_DIR)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function flowPresetDest(flowId, dshHome) {
|
|
46
|
+
return join(presetRoot(dshHome), FLOW_PRESET_PREFIX + flowId)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 读 marker;缺失/损坏同义返回 null(无法证明归属)
|
|
50
|
+
function readMarker(dir) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(join(dir, MARKER_NAME), 'utf8'))
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ownedByUs = (marker) => marker !== null && marker.package === PACKAGE_NAME
|
|
59
|
+
|
|
60
|
+
const markerJson = () => JSON.stringify({ package: PACKAGE_NAME, kind: MARKER_KIND_FLOW, version: PKG_VERSION }, null, 2) + '\n'
|
|
61
|
+
|
|
62
|
+
// 创建骨架:包内唯一源逐字保留,仅改写 orchestrator 行 templateId 锚定(必须恰一处)
|
|
63
|
+
function releaseAgentYaml(flowId) {
|
|
64
|
+
const parts = readFileSync(PRESET_SKELETON, 'utf8').split(TEMPLATE_ANCHOR_MAIN)
|
|
65
|
+
if (parts.length !== 2) throw new Error(`创建骨架 templateId 锚定串异常(期望恰一处): ${PRESET_SKELETON}`)
|
|
66
|
+
return parts.join(flowId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// preset.yml 由骨架元数据 + 模板条目生成(组合名已含模板语义,描述取模板条目)
|
|
70
|
+
function presetYaml(entry, flowId) {
|
|
71
|
+
const desc = String(entry.description ?? '').replace(/\s*\n\s*/g, ' ').slice(0, DESC_MAX_CHARS) || DESC_FALLBACK
|
|
72
|
+
// name 用双引号纯量防 YAML 结构字符(: 与换行)破坏生成物
|
|
73
|
+
const name = `${PRESET_NAME_PREFIX}${entry.label || flowId}`.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\s*\n\s*/g, ' ')
|
|
74
|
+
return `name: "${name}"\ndescription: >-\n ${desc}\n`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 硬崩溃残留的 staging/备份目录清理(前缀本包独占,直接删安全;orphan 前缀永不自动删)
|
|
78
|
+
function cleanStaleStaging(parentDir) {
|
|
79
|
+
let entries
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(parentDir)
|
|
82
|
+
} catch {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
for (const name of entries) {
|
|
86
|
+
if (name.startsWith(STAGING_PREFIX) || name.startsWith(OLD_PREFIX)) {
|
|
87
|
+
rmSync(join(parentDir, name), { recursive: true, force: true })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 原子换入:staging 组装 → 旧目录 rename 备份 → 新目录入位 → 删备份;
|
|
93
|
+
// 入位失败还原,还原也失败改 orphan 前缀保留待人工处置,绝不静默销毁
|
|
94
|
+
function atomicReplace(dest, existed, build) {
|
|
95
|
+
const parent = dirname(dest)
|
|
96
|
+
mkdirSync(parent, { recursive: true })
|
|
97
|
+
const stagingRoot = mkdtempSync(join(parent, STAGING_PREFIX))
|
|
98
|
+
const out = join(stagingRoot, 'out')
|
|
99
|
+
const backup = join(parent, OLD_PREFIX + Date.now())
|
|
100
|
+
let orphan = null
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(out, { recursive: true })
|
|
103
|
+
build(out)
|
|
104
|
+
const hasDest = existsSync(dest)
|
|
105
|
+
if (hasDest) renameSync(dest, backup)
|
|
106
|
+
try {
|
|
107
|
+
renameSync(out, dest)
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (hasDest) {
|
|
110
|
+
try {
|
|
111
|
+
renameSync(backup, dest)
|
|
112
|
+
} catch {
|
|
113
|
+
orphan = join(parent, ORPHAN_PREFIX + Date.now())
|
|
114
|
+
try {
|
|
115
|
+
renameSync(backup, orphan)
|
|
116
|
+
} catch {
|
|
117
|
+
orphan = backup
|
|
118
|
+
}
|
|
119
|
+
console.warn(`[rs-workflow] 创建换入失败且还原失败,旧目录保留在 ${orphan}(不会被自动清理,需人工处置)`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw error
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(stagingRoot, { recursive: true, force: true })
|
|
126
|
+
if (orphan === null) rmSync(backup, { recursive: true, force: true })
|
|
127
|
+
}
|
|
128
|
+
return existed ? 'updated' : 'created'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function releaseFlowTemplate(entry, dshHome) {
|
|
132
|
+
const flowId = typeof entry?.id === 'string' ? entry.id.trim() : ''
|
|
133
|
+
if (!FLOW_ID_RE.test(flowId)) throw new Error(`流程 id 非法: ${JSON.stringify(entry?.id)}(须匹配 ${FLOW_ID_RE.source})`)
|
|
134
|
+
// 创建前重校验(设置里可能被外部改坏);v5 模板文本为严格 JSON
|
|
135
|
+
let parsed
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(entry.json)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
throw new Error(`模板 JSON 解析失败: ${error.message}`)
|
|
140
|
+
}
|
|
141
|
+
const errors = validateTemplate(parsed)
|
|
142
|
+
if (errors.length > 0) throw new Error('模板校验失败:\n' + errors.map((e) => `${e.target}: ${e.message}`).join('\n'))
|
|
143
|
+
const dest = flowPresetDest(flowId, dshHome)
|
|
144
|
+
cleanStaleStaging(dirname(dest))
|
|
145
|
+
const existed = existsSync(dest)
|
|
146
|
+
if (existed && !ownedByUs(readMarker(dest))) throw new Error(`目标目录归属他人,拒绝覆盖: ${dest}`)
|
|
147
|
+
return atomicReplace(dest, existed, (out) => {
|
|
148
|
+
writeFileSync(join(out, FLOW_FILE), entry.json, 'utf8')
|
|
149
|
+
writeFileSync(join(out, PRESET_YML), presetYaml(entry, flowId), 'utf8')
|
|
150
|
+
writeFileSync(join(out, AGENT_YAML), releaseAgentYaml(flowId), 'utf8')
|
|
151
|
+
writeFileSync(join(out, MARKER_NAME), markerJson(), 'utf8')
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function unreleaseFlowTemplate(flowId, dshHome) {
|
|
156
|
+
const dest = flowPresetDest(String(flowId ?? '').trim(), dshHome)
|
|
157
|
+
if (!existsSync(dest)) return 'missing'
|
|
158
|
+
if (!ownedByUs(readMarker(dest))) return 'foreign'
|
|
159
|
+
rmSync(dest, { recursive: true, force: true })
|
|
160
|
+
cleanStaleStaging(dirname(dest))
|
|
161
|
+
return 'removed'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isReleaseDir(root, name) {
|
|
165
|
+
return name.startsWith(FLOW_PRESET_PREFIX) && statSync(join(root, name), { throwIfNoEntry: false })?.isDirectory() === true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function releasedTemplateIds(dshHome) {
|
|
169
|
+
const root = presetRoot(dshHome)
|
|
170
|
+
let entries
|
|
171
|
+
try {
|
|
172
|
+
entries = readdirSync(root)
|
|
173
|
+
} catch {
|
|
174
|
+
return []
|
|
175
|
+
}
|
|
176
|
+
const ids = []
|
|
177
|
+
for (const name of entries) {
|
|
178
|
+
if (!isReleaseDir(root, name)) continue
|
|
179
|
+
const marker = readMarker(join(root, name))
|
|
180
|
+
if (ownedByUs(marker) && marker.kind === MARKER_KIND_FLOW) ids.push(name.slice(FLOW_PRESET_PREFIX.length))
|
|
181
|
+
}
|
|
182
|
+
return ids
|
|
183
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// config 形态 schema:slots/budgets 两节(模板存 templates.json,不入 config),自有文件存储
|
|
2
|
+
// (头注释口径对齐 data-design.md;历史三节形态已删,config.json 只承载 slots/budgets)
|
|
3
|
+
// 契约源 docs/rsww-v5/data-design.md 存储布局节;board config-save 校验口径同源
|
|
4
|
+
import z from '@deepseek-ai/schemastery'
|
|
5
|
+
|
|
6
|
+
// 工作位键集与 lib/template.mjs 的 SLOT_KEYS 互为镜像(非派生),test/settings.test.mjs 对拍钉住
|
|
7
|
+
export const SLOT_KEYS = ['planner', 'executor', 'reviewer', 'executor-loop', 'reviewer-approve', 'executor-escalate']
|
|
8
|
+
export const BUDGET_KEYS = ['maxStepFail', 'approveRounds', 'escalateLimit']
|
|
9
|
+
export const DEFAULT_BUDGETS = { maxStepFail: 2, approveRounds: 2, escalateLimit: 2 }
|
|
10
|
+
|
|
11
|
+
// 模块常量,不开放配置(无 workflow 节)
|
|
12
|
+
export const DEFAULT_CONCURRENCY = 4
|
|
13
|
+
export const KEEP_RUNS = 200
|
|
14
|
+
|
|
15
|
+
const BUDGET_MIN = 1
|
|
16
|
+
const BUDGET_MAX = 10
|
|
17
|
+
const SLOT_FORM = '取值形态:string[](候选依次轮换;兼容历史 string 单模型存储),缺省空数组'
|
|
18
|
+
const BUDGET_FORM = `取值形态:整数,clamp [${BUDGET_MIN},${BUDGET_MAX}]`
|
|
19
|
+
|
|
20
|
+
// 文案三要素:绑定对象/缺省降级链/取值形态(末段经 FORM 常量拼接);文案不参与校验,校验口径以键集与 clamp 常量为准
|
|
21
|
+
const SLOT_DESCRIPTIONS = {
|
|
22
|
+
planner: '规划域位:大纲/计划/分诊类步骤显式绑定;留空 = 会话默认模型',
|
|
23
|
+
executor: '执行域默认位:常规步骤未声明 slot 时的缺省绑定;候选失败依次轮换',
|
|
24
|
+
reviewer: '审批域通用位:审批域绑定(approve 原语步固定 reviewer-approve,不降级至此)',
|
|
25
|
+
'executor-loop': '循环与重做缺省位:for_each 实例与审批重做批次未声明 slot 时的缺省绑定',
|
|
26
|
+
'reviewer-approve': '审批裁决位:type:"approve" 步骤固定绑定,模板 slot 字段不可覆盖',
|
|
27
|
+
'executor-escalate': '升级缺省位:onExhausted 指向的 escalate-only 步骤未声明 slot 时的缺省绑定',
|
|
28
|
+
}
|
|
29
|
+
const BUDGET_DESCRIPTIONS = {
|
|
30
|
+
maxStepFail: '步骤连续调用失败上限;步骤 maxFail 字段缺省取此值;达上限步骤 failed、下游 skipped',
|
|
31
|
+
approveRounds: 'approve 步骤默认重审轮次(步骤未声明 rounds 时);耗尽走 onExhausted',
|
|
32
|
+
escalateLimit: 'run 级升级账上限;升级累计达此值整流程 blocked',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildSlots() {
|
|
36
|
+
const slot = (description) => z.union([z.string(), z.array(z.string())]).default([]).description(`${description};${SLOT_FORM}`)
|
|
37
|
+
return z.object(Object.fromEntries(SLOT_KEYS.map((key) => [key, slot(SLOT_DESCRIPTIONS[key])])))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// clamp 在归一层执行,schema 只声明合法域(v3 结论:min/max 为校验非钳制)
|
|
41
|
+
function buildBudgets() {
|
|
42
|
+
const budget = (key) => z.number().default(DEFAULT_BUDGETS[key]).min(BUDGET_MIN).max(BUDGET_MAX).description(`${BUDGET_DESCRIPTIONS[key]};${BUDGET_FORM}`)
|
|
43
|
+
return z.object(Object.fromEntries(BUDGET_KEYS.map((key) => [key, budget(key)])))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 存储形态 schema(config.json 载入面归一;载入走 normalizeConfig,此 schema 供工具化校验)
|
|
47
|
+
export const CONFIG_SCHEMA = z.object({ slots: buildSlots(), budgets: buildBudgets() })
|
|
48
|
+
|
|
49
|
+
const isEntry = (value) => value !== null && typeof value === 'object'
|
|
50
|
+
const clampBudget = (value) => Math.min(BUDGET_MAX, Math.max(BUDGET_MIN, value))
|
|
51
|
+
|
|
52
|
+
// 归一恒为数组形态:历史 string 单模型存储升为单元素数组(读侧兼容,写侧不再产出 string)
|
|
53
|
+
function normalizeSlot(value) {
|
|
54
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string')
|
|
55
|
+
if (typeof value === 'string' && value !== '') return [value]
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeSlots(value) {
|
|
60
|
+
const source = isEntry(value) ? value : {}
|
|
61
|
+
return Object.fromEntries(SLOT_KEYS.map((key) => [key, normalizeSlot(source[key])]))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeBudgets(value) {
|
|
65
|
+
const source = isEntry(value) ? value : {}
|
|
66
|
+
return Object.fromEntries(BUDGET_KEYS.map((key) => [key, Number.isInteger(source[key]) ? clampBudget(source[key]) : DEFAULT_BUDGETS[key]]))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 旧值自清理归一:未声明键剔除、缺省键补默认;返回新对象不改入参
|
|
70
|
+
export function normalizeConfig(value) {
|
|
71
|
+
const source = isEntry(value) ? value : {}
|
|
72
|
+
return { slots: normalizeSlots(source.slots), budgets: normalizeBudgets(source.budgets) }
|
|
73
|
+
}
|
package/lib/spec.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// spec — 流程模板 DSL v5 规范全文(模板编辑规则的唯一真相源)
|
|
2
|
+
// 消费:设置页「模板规范」折叠页渲染。
|
|
3
|
+
// 验收契约:含 type:"approve" 与 inputs 章节;不含「教学重问」与「<output」字样。
|
|
4
|
+
|
|
5
|
+
export const SPEC_TEXT = `# 若水工作流流程模板 DSL 规范(v5)
|
|
6
|
+
|
|
7
|
+
流程模板 = 一份严格 JSON 文本,声明一个强流程:每一步安排 AI 产出什么,引擎强制校验后推进。
|
|
8
|
+
在设置页「若水工作流 → 流程模板」新建或编辑模板,保存前经 host 权威校验(dryRun)。
|
|
9
|
+
文本必须是合法 JSON(键双引号、无注释、无尾逗号);本规范示例中的行尾注释仅为说明字段含义,写入模板时不要携带。
|
|
10
|
+
|
|
11
|
+
## 1. 顶层结构
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"id": "novel",
|
|
15
|
+
"label": "小说写作",
|
|
16
|
+
"description": "...",
|
|
17
|
+
"inputs": { "chapterCount": "章节数说明" },
|
|
18
|
+
"autoApprove": false,
|
|
19
|
+
"steps": [ ... ]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
字段含义:id 必填 ^[a-z][a-z0-9-]*$;label 必填,模板显示名;description 必填,一句话适用场景
|
|
23
|
+
(分诊目录展示给引擎);inputs 可选,运行时入参声明(见 §7);autoApprove 可选布尔(缺省 false,
|
|
24
|
+
true = 审批由主循环代审,不再转呈页签,见 §4);steps 必填非空,按文档顺序缺省链式依赖。
|
|
25
|
+
|
|
26
|
+
未知字段一律拒绝(拼写错误防静默失效);校验不合法会逐条报错(target 定位到 step:<id> 或 top:<field>)。
|
|
27
|
+
|
|
28
|
+
## 2. 步骤字段
|
|
29
|
+
|
|
30
|
+
每个步骤:
|
|
31
|
+
{
|
|
32
|
+
"id": "outline",
|
|
33
|
+
"label": "生成大纲",
|
|
34
|
+
"slot": "executor-loop",
|
|
35
|
+
"prompt": "...",
|
|
36
|
+
"load": ["skill:x", "doc:docs/spec.md"],
|
|
37
|
+
"outputs": { "outline": "章节大纲:每行一条 'N. 章节标题——梗概',共 N 章" },
|
|
38
|
+
"listOutputs": ["outline"],
|
|
39
|
+
"after": ["collect"],
|
|
40
|
+
"maxFail": 3,
|
|
41
|
+
"for_each": "outline.outline",
|
|
42
|
+
"mode": "sequential",
|
|
43
|
+
"type": "ai",
|
|
44
|
+
"target": "draft",
|
|
45
|
+
"rounds": 2,
|
|
46
|
+
"onExhausted": "blocked",
|
|
47
|
+
"flow": "{triage.route}",
|
|
48
|
+
"input": { "brief": "{triage.brief}" }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
字段含义:id 必填,步骤内唯一,^[a-z][a-z0-9-]*$(小写字母开头,仅小写字母/数字/连字符);label 可选,看板显示名;slot 可选,模型
|
|
52
|
+
工作位(仅常规步骤可自定义),六键全集:"planner" "executor" "reviewer" "executor-loop"(循环/重做)
|
|
53
|
+
"reviewer-approve"(审批) "executor-escalate"(升级);prompt 必填,指令模板,写清做什么、按什么材料
|
|
54
|
+
做、做到什么程度——产出要求由引擎自动附加 [产出要求] 节,不要在 prompt 里写格式要求;load 可选,
|
|
55
|
+
强制加载资源(见 §7);outputs 可选,产出契约(见 §3);listOutputs 可选,声明哪些产出按 string
|
|
56
|
+
数组解析(for_each 数据源必须是 list 产出);after 可选,依赖步骤 id 数组,缺省 = 文档序前一个常规
|
|
57
|
+
步骤(见 §5),禁止环;maxFail 可选,失败重试上限 1..10(缺省取预算 maxStepFail=2);for_each 可选,
|
|
58
|
+
循环(见 §6);mode 循环推进:sequential(默认,串行携带)| parallel(并行);type 默认 "ai",
|
|
59
|
+
"approve" = 人工审批(见 §4),"flow" = 嵌套子流程(见 §8);target 为 type=approve 必填,被审步骤
|
|
60
|
+
id;rounds 为 type=approve 可选,重审轮次上限(缺省预算 approveRounds=2);onExhausted 为
|
|
61
|
+
type=approve 可选,缺省 "blocked"(终局阻塞),其他步骤 id = 升级出口;flow 为 type=flow 必填,子流程
|
|
62
|
+
id,支持 {step.output} 动态路由;input 为 type=flow 可选传参(值须为单占位符 {step.output})。
|
|
63
|
+
|
|
64
|
+
## 3. 产出契约(强流程核心)
|
|
65
|
+
|
|
66
|
+
outputs 声明"这步必须产出什么"。引擎给子代理的指令自动附带 [产出要求] 节:字段含义与完成口径,并要求
|
|
67
|
+
完成后以 structured_output 工具提交。schema 由 outputs 自动生成:object + 全字段 required +
|
|
68
|
+
additionalProperties:false;listOutputs 产出类型为 string 数组。
|
|
69
|
+
|
|
70
|
+
子代理未提交结构化产出、字段缺失或为空 → 记一次失败;失败重试达上限(maxFail/预算)该步骤 failed、
|
|
71
|
+
其下游 skipped,流程以 failed 终局。产出说明要具体可判:写清格式、数量、口径,不要写"合理即可"。
|
|
72
|
+
|
|
73
|
+
## 4. 审批步骤(type:"approve")
|
|
74
|
+
|
|
75
|
+
审批步骤安排一次审校。被审 target 完成且依赖就绪后,流程进入外部裁决(waiting_approval):
|
|
76
|
+
本段暂停并出账待裁决摘要(target 产出、审批口径、上轮意见),裁决经页签或主循环回写——
|
|
77
|
+
|
|
78
|
+
- APPROVED:被审 target 链放行,流程继续;
|
|
79
|
+
- REJECTED(未耗尽):target 置回待办并重做,重做指令自动附 [重做说明] 节(裁决意见 comments +
|
|
80
|
+
被审步骤原产出);审批步自身也回到待办等待再审;重审次数计账,达 rounds 上限即耗尽;
|
|
81
|
+
- 耗尽:onExhausted 缺省或 ="blocked" → 流程终局 blocked(等用户介入);onExhausted=<升级步 id> → 触发升级步骤。
|
|
82
|
+
|
|
83
|
+
裁决来源由顶层 autoApprove 决定:false(缺省)转呈页签,用户点击裁决,主循环亦可代审(by=main-agent,
|
|
84
|
+
意见必填);true 直接由主循环代审。页签与主循环先到先得,幂等。审批步骤不派发子代理。
|
|
85
|
+
|
|
86
|
+
升级账:每次走升级出口 escalations+1,达预算 escalateLimit(默认 2)流程终局 blocked。
|
|
87
|
+
|
|
88
|
+
## 5. 缺省依赖链
|
|
89
|
+
|
|
90
|
+
未声明 after 的步骤依赖文档序前一个常规步骤(首步无依赖);显式 after(含空数组)完全取代缺省。
|
|
91
|
+
升级出口步骤(仅被 onExhausted 引用、无常规依赖)脱离缺省链,不参与调度。
|
|
92
|
+
|
|
93
|
+
## 6. 升级步(出口步骤)
|
|
94
|
+
|
|
95
|
+
- 脱离常规调度图:唯一触发边 = 审批耗尽;禁止被其他步骤 after 引用,禁止引用 target 的产出
|
|
96
|
+
(只可引用 target 上游步骤产出——target 已被判不合格);
|
|
97
|
+
- 派发内容自定(保守交付/降级方案),完成后流程正常终局判定。
|
|
98
|
+
|
|
99
|
+
## 7. 运行时入参(inputs)与资源加载
|
|
100
|
+
|
|
101
|
+
- inputs 在顶层声明后,任意步骤 prompt 用 {input.name} 引用;未提供时解析空串;
|
|
102
|
+
- 初跑经会话消息承接(首个规划步骤应写明从 {request} 推导入参的口径);重跑对话框可显式覆盖;
|
|
103
|
+
- load 引用:skill:<名称> 经技能服务取全文;doc:<相对路径> 读工作区文件;超长截断,缺失不阻断(该节缺省)。
|
|
104
|
+
|
|
105
|
+
## 8. 循环(for_each)
|
|
106
|
+
|
|
107
|
+
for_each: "<stepId>.<listOutput>"——数据源必须是上游步骤的 listOutputs 产出。
|
|
108
|
+
- sequential(默认):实例串行,后一实例 prompt 可用 {本步id.outputName} 引用上一实例产出(顺序携带);
|
|
109
|
+
- parallel:实例独立并行;{item} 当前实例值,{item.index} 序号(从 1 起);
|
|
110
|
+
- 数据源为空数组 → 本步 skipped;实例失败独立计账,任一实例失败达上限 = 本步 failed。
|
|
111
|
+
|
|
112
|
+
## 9. 嵌套子流程(type:"flow")
|
|
113
|
+
|
|
114
|
+
- flow: "子模板id" 字面路由,或 "{stepId.outputName}" 动态路由(分诊);
|
|
115
|
+
- input: { name: "{stepId.outputName}" } 传参,子流程内以 {input.name} 引用;
|
|
116
|
+
- 子流程复用当前 run 记录(步骤前缀区分),产出按子步骤扁平挂载到本步骤产出(for_each 子步按实例序收编同名字段数组);
|
|
117
|
+
- 嵌套深度上限 3;动态路由目标必须存在于模板集(跨流程校验);
|
|
118
|
+
- 审批仅顶层可达:子模板含 approve 步时,引用它的模板集合校验拒绝(模板集校验与释放权威点)。
|
|
119
|
+
|
|
120
|
+
## 10. 分诊惯例
|
|
121
|
+
|
|
122
|
+
多入口模板标准开头:一个 triage 步骤,outputs 声明 route(候选模板 id)与 brief(交接摘要),
|
|
123
|
+
"listOutputs":["route"];后接 "type":"flow" 步骤 "flow":"{triage.route}"、"input":{"brief":"{triage.brief}"}。
|
|
124
|
+
route 为空 = 无候选命中,该步骤按空路由 done 收口(流程结束)。
|
|
125
|
+
|
|
126
|
+
## 11. 完整示例
|
|
127
|
+
|
|
128
|
+
示例 A(inputs + 审批闭环 + 循环):
|
|
129
|
+
{
|
|
130
|
+
"id": "novel", "label": "小说写作", "description": "按章节数要求写小说初稿并终审",
|
|
131
|
+
"inputs": { "chapterCount": "章节数,正整数" },
|
|
132
|
+
"steps": [
|
|
133
|
+
{ "id": "outline", "prompt": "按 {input.chapterCount} 章要求生成大纲;需求:{request}",
|
|
134
|
+
"outputs": { "outline": "每行 'N. 章节标题——梗概'" }, "listOutputs": ["outline"] },
|
|
135
|
+
{ "id": "write-chapter", "for_each": "outline.outline", "mode": "sequential",
|
|
136
|
+
"prompt": "写第 {item.index} 章《{item}》,衔接上一章成稿:{write-chapter.draft}",
|
|
137
|
+
"outputs": { "draft": "本章正文" } },
|
|
138
|
+
{ "id": "review", "type": "approve", "after": ["write-chapter"], "target": "write-chapter",
|
|
139
|
+
"rounds": 2, "onExhausted": "polish", "prompt": "审校全书一致性;需求:{request}" },
|
|
140
|
+
{ "id": "polish", "prompt": "按 {request} 产出保守修订清单,不重写正文",
|
|
141
|
+
"outputs": { "fixes": "修订建议清单" } }
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
示例 B(分诊动态路由,节选):
|
|
146
|
+
{ "id": "triage", "prompt": "分析需求选流程;候选与适用见模板描述", "outputs": { "route": "模板 id", "brief": "交接摘要" }, "listOutputs": ["route"] },
|
|
147
|
+
{ "id": "run", "type": "flow", "flow": "{triage.route}", "input": { "brief": "{triage.brief}" } }
|
|
148
|
+
|
|
149
|
+
## 12. 编写守则(AI 必读)
|
|
150
|
+
|
|
151
|
+
0. 目标锚点:模板是把模型自驱变强规则驱动的载体——每步只安排一件事、产出可判、衔接走引用,
|
|
152
|
+
禁止把"做什么、做多少、怎么算完"的决定权留给模型;
|
|
153
|
+
1. 每步只安排一件事,产出契约可判;禁止把多件事塞一步;
|
|
154
|
+
2. prompt 写"做什么与做到什么程度",不写产出格式(引擎统一附加 [产出要求]);
|
|
155
|
+
3. 上下游衔接一律走产出引用({stepId.outputName}),不靠"记住上文";
|
|
156
|
+
4. 交付落盘(写文件)是子代理工具能力,prompt 可要求"把成稿写入 docs/xxx.md",
|
|
157
|
+
但产出契约仍以结构化提交为准(引擎只认结构化产出);
|
|
158
|
+
5. 步骤数 3~12 为宜;过长的流程拆成多模板用分诊/嵌套组合;
|
|
159
|
+
6. 保存前自查:每个 prompt 的占位符都有来源;for_each 数据源是 listOutputs;
|
|
160
|
+
approve 的 target 存在且 onExhausted 合法;动态路由候选与 description 一致。
|
|
161
|
+
`
|
package/lib/storage.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// storage — rs-workflow 自有文件存储(不经宿主 settings 服务,settings.yaml 不承载本插件配置)
|
|
2
|
+
// 布局:<dataDir>/v5/{templates,config}.json;dataDir(DSH_RS_WORKFLOW_DATA_DIR 覆写)
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { homedir, tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export function dataDir() {
|
|
8
|
+
if (process.env.DSH_RS_WORKFLOW_DATA_DIR) return process.env.DSH_RS_WORKFLOW_DATA_DIR
|
|
9
|
+
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-rs-workflow')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function v5File(name) {
|
|
13
|
+
return join(dataDir(), 'v5', name)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadJson(name, fallback) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(v5File(name), 'utf8'))
|
|
19
|
+
} catch {
|
|
20
|
+
return fallback
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 写临时文件后原子改名,断电不产生半截 JSON
|
|
25
|
+
export function saveJson(name, data) {
|
|
26
|
+
const target = v5File(name)
|
|
27
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
28
|
+
const tmp = `${join(tmpdir(), 'rsww')}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2))
|
|
31
|
+
renameSync(tmp, target)
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(tmp, { force: true })
|
|
34
|
+
}
|
|
35
|
+
}
|