@mzzsfy/dsh-rs-workflow 0.2.4 → 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
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// orchestrator — 主循环工具行:rs_workflow_start/status/resume/cancel/message/resume_from/verdict 七工具
|
|
2
|
+
// 编排以分段 continuable job 推进:每段 = jobs.start 包装 driver.runSegment,settle 负载经 tool-jobs
|
|
3
|
+
// 完成通知唤醒主循环;推进责任唯一在 rs_workflow_resume(页签 control 仅清 paused/裁决)
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import { gate } from './planner-gate.mjs'
|
|
6
|
+
import { validateTemplate } from './template.mjs'
|
|
7
|
+
import { startRun, buildSeed } from './driver/index.mjs'
|
|
8
|
+
import { registry, registerInitiator, unregisterInitiator, registerResumer } from './driver/control.mjs'
|
|
9
|
+
import { reportStore } from './store.mjs'
|
|
10
|
+
import { loadJson } from './storage.mjs'
|
|
11
|
+
import { normalizeConfig } from './settings-schema.mjs'
|
|
12
|
+
|
|
13
|
+
const MAX_REJECTS = 3
|
|
14
|
+
const SEGMENT_KIND = 'rsww-segment'
|
|
15
|
+
const SETTLE_OUTPUT_LIMIT = 64 * 1024
|
|
16
|
+
|
|
17
|
+
// 已启用模板表(自有存储 → {entry, parsed};gate/驱动共用此形态)
|
|
18
|
+
export function enabledTemplates() {
|
|
19
|
+
const out = []
|
|
20
|
+
for (const entry of loadJson('templates.json', [])) {
|
|
21
|
+
if (entry.enabled === false || typeof entry.json !== 'string') continue
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(entry.json)
|
|
24
|
+
if (validateTemplate(parsed).length === 0) out.push({ entry, parsed })
|
|
25
|
+
} catch { /* 损坏模板不进候选 */ }
|
|
26
|
+
}
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 模式行激活入口(config: { role:'orchestrator', templateId });宿主服务缺一目工具即报错 */
|
|
31
|
+
export function registerOrchestrator(ctx, config) {
|
|
32
|
+
const expectedTemplateId = config.templateId
|
|
33
|
+
// 连续拒单计数与现役 run:行实例内存态,进程重启清零
|
|
34
|
+
const rejectCounts = new Map()
|
|
35
|
+
const activeRuns = new Map()
|
|
36
|
+
|
|
37
|
+
const registered = ctx.inject(['tools', 'workflowEngine', 'jobs'], (tctx) => {
|
|
38
|
+
const engine = tctx.workflowEngine
|
|
39
|
+
const jobs = tctx.jobs
|
|
40
|
+
|
|
41
|
+
const configOf = () => normalizeConfig(loadJson('config.json', undefined))
|
|
42
|
+
|
|
43
|
+
const agentIdOf = (agent) => String(agent?.id ?? '')
|
|
44
|
+
|
|
45
|
+
// 会话现役 run:同会话同一时刻至多一个未终态 run
|
|
46
|
+
const currentRunId = (agent) => {
|
|
47
|
+
const runId = activeRuns.get(agentIdOf(agent))
|
|
48
|
+
if (runId === undefined) return undefined
|
|
49
|
+
// run 可能已被页签删除:get 抛异常须自愈清位,不能让编排入口变砖
|
|
50
|
+
let record
|
|
51
|
+
try { record = reportStore().get(runId) } catch { record = undefined }
|
|
52
|
+
if (record === undefined || record.finishedAt !== undefined) {
|
|
53
|
+
activeRuns.delete(agentIdOf(agent))
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
return runId
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 段 job:jobs.start 包装 runSegment;settle 负载进 output,经 tool-jobs 完成通知唤醒主循环
|
|
60
|
+
function startSegmentJob(agent, driver) {
|
|
61
|
+
const promise = driver.runSegment()
|
|
62
|
+
return jobs.start({
|
|
63
|
+
kind: SEGMENT_KIND,
|
|
64
|
+
label: `若水编排段推进 ${driver.runId}`,
|
|
65
|
+
outputLimitBytes: SETTLE_OUTPUT_LIMIT,
|
|
66
|
+
owner: agent,
|
|
67
|
+
run: () => ({
|
|
68
|
+
cancel: () => driver.cancel(),
|
|
69
|
+
done: promise.then((payload) => ({ status: 'completed', output: JSON.stringify(payload) })),
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 断点续跑挂靠:board resume-from 经此回调在原会话重建种子 run(engine/parent 闭包自本域)
|
|
75
|
+
function startSeedRun(agent, record, fromStepId, inputs) {
|
|
76
|
+
const templates = enabledTemplates()
|
|
77
|
+
const template = templates.find((t) => t.entry.id === record.templateId)
|
|
78
|
+
if (template === undefined) return { ok: false, error: `模板不存在或已禁用: ${record.templateId}` }
|
|
79
|
+
// 单活跃约束:种子 run 与 start 工具同守卫;主循环成为其推进者(activeRuns 注册,补拉/守卫生效)
|
|
80
|
+
const current = currentRunId(agent)
|
|
81
|
+
if (current !== undefined) return { ok: false, error: `本会话已有进行中的编排 ${current},不可续跑` }
|
|
82
|
+
const seed = buildSeed(record, record.plan, fromStepId, inputs)
|
|
83
|
+
const driver = startRun({
|
|
84
|
+
template: template.parsed, templateSet: templates.map((t) => t.parsed),
|
|
85
|
+
plan: record.plan, warnings: record.warnings ?? [],
|
|
86
|
+
request: record.request, inputs: seed.inputs, state: seed,
|
|
87
|
+
sessionId: agentIdOf(agent), workspace: agent.session?.header?.cwd ?? process.cwd(),
|
|
88
|
+
engine, slots: configOf().slots ?? {}, budgets: configOf().budgets ?? {},
|
|
89
|
+
parent: agent,
|
|
90
|
+
})
|
|
91
|
+
activeRuns.set(agentIdOf(agent), driver.runId)
|
|
92
|
+
// 种子 run 首段拉起:与 start 工具同责,缺此则续跑 run 停在 running 无活跃段
|
|
93
|
+
startSegmentJob(agent, driver)
|
|
94
|
+
return { ok: true, runId: driver.runId }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function finishRun(agent, runId) {
|
|
98
|
+
if (currentRunId(agent) === runId) activeRuns.delete(agentIdOf(agent))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 段拉起挂靠:页签裁决受理后 board 经此回到本域拉下一段(幂等:活跃段不重拉);点位与 initiator 挂靠一致
|
|
102
|
+
function ensureResumer(agent) {
|
|
103
|
+
registerResumer(agentIdOf(agent), (runId) => {
|
|
104
|
+
const driver = registry.drivers.get(runId)
|
|
105
|
+
if (driver === undefined || driver.active) return false
|
|
106
|
+
startSegmentJob(agent, driver)
|
|
107
|
+
return true
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const tools = [
|
|
112
|
+
defineTool({
|
|
113
|
+
name: 'rs_workflow_start',
|
|
114
|
+
description: [
|
|
115
|
+
'启动若水工作流编排:提交用户请求原文与结构化规划,受理后引擎分段推进。',
|
|
116
|
+
'受理返回 { ok, runId, status };规划被拒返回 { ok:false, errors }(按 errors 修正重交;连续 3 次被拒须回退直接答复并说明)。',
|
|
117
|
+
'templateId 必须等于本组合锚定的模板 id;plan 缺省 = 保底全序(不推荐,多步骤任务应提交 steps/brief)。',
|
|
118
|
+
].join(''),
|
|
119
|
+
parameters: {
|
|
120
|
+
request: { type: 'string', required: true, description: '用户请求原文(逐字,不转写)' },
|
|
121
|
+
templateId: { type: 'string', required: true, description: '本组合锚定的模板 id' },
|
|
122
|
+
inputs: { type: 'object', additionalProperties: true, description: '模板顶层 inputs 的键值,值必须为字符串' },
|
|
123
|
+
plan: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
additionalProperties: true,
|
|
126
|
+
description: '结构化规划 { steps: [{ref, note?, done?}], brief? }:steps 各步要点(note)与验收口径(done);缺省 = 保底全序',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
output: {
|
|
130
|
+
schema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
properties: {
|
|
134
|
+
ok: { type: 'boolean', required: true },
|
|
135
|
+
runId: { type: 'string' },
|
|
136
|
+
status: { type: 'string' },
|
|
137
|
+
errors: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
138
|
+
hint: { type: 'string' },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
142
|
+
},
|
|
143
|
+
async execute(args, exec) {
|
|
144
|
+
const agent = exec.agent
|
|
145
|
+
// 工具参数扁平:gate 入参 = 顶层三字段 + plan(steps/brief)组装(见 feat/orchestrator.md)
|
|
146
|
+
const gatePlan = {
|
|
147
|
+
request: args.request, templateId: args.templateId, inputs: args.inputs,
|
|
148
|
+
brief: args.plan?.brief, steps: args.plan?.steps,
|
|
149
|
+
}
|
|
150
|
+
const rejects = (id) => {
|
|
151
|
+
const next = (rejectCounts.get(id) ?? 0) + 1
|
|
152
|
+
rejectCounts.set(id, next)
|
|
153
|
+
return next
|
|
154
|
+
}
|
|
155
|
+
const rejected = (errors) => {
|
|
156
|
+
const count = rejects(agentIdOf(agent))
|
|
157
|
+
if (count >= MAX_REJECTS) return { ok: false, errors, hint: `已连续 ${MAX_REJECTS} 次规划被拒,本会话编排入口关闭:回退直接答复并向用户说明` }
|
|
158
|
+
return { ok: false, errors, hint: '修正 plan 后重新调用;连续 3 次失败将回退直接答复' }
|
|
159
|
+
}
|
|
160
|
+
// 连续拒单达上限:本会话工具恒失败
|
|
161
|
+
if ((rejectCounts.get(agentIdOf(agent)) ?? 0) >= MAX_REJECTS) {
|
|
162
|
+
return { ok: false, errors: [], hint: `已连续 ${MAX_REJECTS} 次规划被拒,本会话编排入口关闭:回退直接答复并向用户说明` }
|
|
163
|
+
}
|
|
164
|
+
if (currentRunId(agent) !== undefined) {
|
|
165
|
+
return { ok: false, errors: [], hint: `本会话已有进行中的编排 ${currentRunId(agent)},先等待终态或取消` }
|
|
166
|
+
}
|
|
167
|
+
const templates = enabledTemplates()
|
|
168
|
+
const template = templates.find((t) => t.entry.id === args.templateId)
|
|
169
|
+
if (template === undefined) {
|
|
170
|
+
// 组合/环境错误而非规划错误:不计连续拒单,hint 指向组合而非 plan
|
|
171
|
+
return { ok: false, errors: [{ target: 'templateId', message: `模板不存在或未启用: ${args.templateId}` }], hint: '本组合未启用该模板:检查组合锚定与模板启用状态,勿修改 plan' }
|
|
172
|
+
}
|
|
173
|
+
const outcome = gate(gatePlan, template, { expectedTemplateId })
|
|
174
|
+
if (!outcome.ok) return rejected(outcome.errors)
|
|
175
|
+
const driver = startRun({
|
|
176
|
+
template: template.parsed, templateSet: templates.map((t) => t.parsed),
|
|
177
|
+
plan: outcome.planScript, warnings: outcome.warnings ?? [],
|
|
178
|
+
request: args.request, inputs: args.inputs ?? {},
|
|
179
|
+
engine, slots: configOf().slots ?? {}, budgets: configOf().budgets ?? {},
|
|
180
|
+
// 会话工作区取自会话 header(子代理继承同源);宿主 cwd 仅兜底
|
|
181
|
+
sessionId: agentIdOf(agent), workspace: agent.session?.header?.cwd ?? process.cwd(),
|
|
182
|
+
parent: agent,
|
|
183
|
+
})
|
|
184
|
+
rejectCounts.delete(agentIdOf(agent))
|
|
185
|
+
activeRuns.set(agentIdOf(agent), driver.runId)
|
|
186
|
+
// 断点续跑挂靠:本会话 agent 成为推进器,board resume-from 据此重建种子 run
|
|
187
|
+
registerInitiator(agentIdOf(agent), (record, fromStepId, inputs) => startSeedRun(agent, record, fromStepId, inputs))
|
|
188
|
+
ensureResumer(agent)
|
|
189
|
+
startSegmentJob(agent, driver)
|
|
190
|
+
return { ok: true, runId: driver.runId, status: driver.state.status }
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
defineTool({
|
|
194
|
+
name: 'rs_workflow_status',
|
|
195
|
+
description: '查询若水编排状态:runId 缺省 = 本会话现役 run。返回 status/awaitingResume(补拉判定信号)/steps 账目/waiting 待裁决摘要。',
|
|
196
|
+
parameters: {
|
|
197
|
+
runId: { type: 'string', description: '缺省 = 本会话现役 run' },
|
|
198
|
+
},
|
|
199
|
+
output: {
|
|
200
|
+
schema: { type: 'object', additionalProperties: true },
|
|
201
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
202
|
+
},
|
|
203
|
+
async execute(args, exec) {
|
|
204
|
+
const store = reportStore()
|
|
205
|
+
const runId = typeof args.runId === 'string' && args.runId !== '' ? args.runId : currentRunId(exec.agent)
|
|
206
|
+
if (runId === undefined) return { ok: false, error: '本会话无现役 run(编排未启动)' }
|
|
207
|
+
let record
|
|
208
|
+
try { record = store.get(runId) } catch { record = undefined }
|
|
209
|
+
if (record === undefined) return { ok: false, error: '运行记录不存在:' + runId }
|
|
210
|
+
// 唤醒轮挂靠:主循环任一唤醒轮必先 status,借机注册断点续跑推进器(进程重启后表空,
|
|
211
|
+
// 页签 resume-from 恰是重启中断的唯一恢复路径,不能依赖 start/resume 才有挂靠)
|
|
212
|
+
registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
|
|
213
|
+
ensureResumer(exec.agent)
|
|
214
|
+
const driver = registry.drivers.get(runId)
|
|
215
|
+
const steps = {}
|
|
216
|
+
for (const [id, s] of Object.entries(record.state?.steps ?? {})) {
|
|
217
|
+
steps[id] = { status: s.status, failCount: s.failCount ?? 0 }
|
|
218
|
+
}
|
|
219
|
+
const waiting = []
|
|
220
|
+
if (record.status === 'waiting_approval') {
|
|
221
|
+
const stepId = record.state?.waitingApproval
|
|
222
|
+
const planStep = (record.plan?.steps ?? []).find((p) => p.ref === stepId)
|
|
223
|
+
waiting.push({ stepId, note: planStep?.note ?? '', done: planStep?.done ?? '' })
|
|
224
|
+
}
|
|
225
|
+
// paused+已入队裁决:主循环据 pendingVerdicts 可知 resume 拉段后裁决将生效
|
|
226
|
+
const pendingVerdicts = (record.state?.pendingApprovals ?? []).length
|
|
227
|
+
return {
|
|
228
|
+
ok: true, runId, status: record.status,
|
|
229
|
+
awaitingResume: record.status === 'paused' && driver?.awaitingResume === true,
|
|
230
|
+
steps,
|
|
231
|
+
// 宿主校验工具输出须为纯 JSON:undefined 值键会被判无效输出
|
|
232
|
+
...(waiting.length > 0 ? { waiting } : {}),
|
|
233
|
+
...(pendingVerdicts > 0 ? { pendingVerdicts } : {}),
|
|
234
|
+
summary: record.summary ?? '',
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
defineTool({
|
|
239
|
+
name: 'rs_workflow_resume',
|
|
240
|
+
description: [
|
|
241
|
+
'拉起若水编排下一段(推进责任唯一在此):paused 拉起时同步置 running;已推进/终态返回 skipped。',
|
|
242
|
+
'裁决回写后、页签恢复后、running 无活跃段(待拉起)时调用。',
|
|
243
|
+
].join(''),
|
|
244
|
+
parameters: {
|
|
245
|
+
runId: { type: 'string', required: true, description: '要推进的 run' },
|
|
246
|
+
},
|
|
247
|
+
output: {
|
|
248
|
+
schema: { type: 'object', additionalProperties: true },
|
|
249
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
250
|
+
},
|
|
251
|
+
async execute(args, exec) {
|
|
252
|
+
const store = reportStore()
|
|
253
|
+
let record
|
|
254
|
+
try { record = store.get(args.runId) } catch { record = undefined }
|
|
255
|
+
if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
|
|
256
|
+
if (record.finishedAt !== undefined) {
|
|
257
|
+
finishRun(exec.agent, args.runId)
|
|
258
|
+
return { ok: true, runId: args.runId, status: record.status, summary: record.summary }
|
|
259
|
+
}
|
|
260
|
+
const driver = registry.drivers.get(args.runId)
|
|
261
|
+
if (driver === undefined) {
|
|
262
|
+
return { ok: false, error: '编排驱动器未注册(进程重启后 run 已收敛为终态,请 rs_workflow_status 确认)' }
|
|
263
|
+
}
|
|
264
|
+
// 页签已推进(有活跃段):不重复拉起
|
|
265
|
+
if (driver.active) return { ok: true, runId: args.runId, status: record.status, skipped: true }
|
|
266
|
+
if (record.status === 'paused') {
|
|
267
|
+
driver.state.status = 'running'
|
|
268
|
+
driver.awaitingResume = false
|
|
269
|
+
driver.persistState()
|
|
270
|
+
}
|
|
271
|
+
if (record.status === 'waiting_approval') {
|
|
272
|
+
return { ok: true, runId: args.runId, status: record.status, skipped: true, hint: '先裁决(control approve/reject 或经会话页签),再 resume 拉起下一段' }
|
|
273
|
+
}
|
|
274
|
+
startSegmentJob(exec.agent, driver)
|
|
275
|
+
registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
|
|
276
|
+
ensureResumer(exec.agent)
|
|
277
|
+
return { ok: true, runId: args.runId, status: record.status }
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
defineTool({
|
|
281
|
+
name: 'rs_workflow_cancel',
|
|
282
|
+
description: '取消若水编排(幂等):running 有活跃段则 abort 收敛;waiting_approval/paused 即时终态;已终态返回摘要无副作用。',
|
|
283
|
+
parameters: {
|
|
284
|
+
runId: { type: 'string', required: true, description: '要取消的 run' },
|
|
285
|
+
},
|
|
286
|
+
output: {
|
|
287
|
+
schema: { type: 'object', additionalProperties: true },
|
|
288
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
289
|
+
},
|
|
290
|
+
async execute(args, exec) {
|
|
291
|
+
const store = reportStore()
|
|
292
|
+
let record
|
|
293
|
+
try { record = store.get(args.runId) } catch { record = undefined }
|
|
294
|
+
if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
|
|
295
|
+
if (record.finishedAt !== undefined) {
|
|
296
|
+
finishRun(exec.agent, args.runId)
|
|
297
|
+
return { ok: true, runId: args.runId, status: record.status, summary: record.summary }
|
|
298
|
+
}
|
|
299
|
+
const driver = registry.drivers.get(args.runId)
|
|
300
|
+
if (driver === undefined) return { ok: false, error: '编排驱动器未注册' }
|
|
301
|
+
driver.cancel()
|
|
302
|
+
const hint = '已完成步骤保留,可在会话页签断点续跑'
|
|
303
|
+
if (driver.active) {
|
|
304
|
+
await driver.runSegment().catch(() => {})
|
|
305
|
+
let after
|
|
306
|
+
try { after = store.get(args.runId) } catch { after = undefined }
|
|
307
|
+
finishRun(exec.agent, args.runId)
|
|
308
|
+
return { ok: true, runId: args.runId, status: after?.status ?? 'cancelled', summary: after?.summary ?? '', hint }
|
|
309
|
+
}
|
|
310
|
+
let after
|
|
311
|
+
try { after = store.get(args.runId) } catch { after = undefined }
|
|
312
|
+
finishRun(exec.agent, args.runId)
|
|
313
|
+
return { ok: true, runId: args.runId, status: after?.status ?? 'cancelled', hint }
|
|
314
|
+
},
|
|
315
|
+
}),
|
|
316
|
+
defineTool({
|
|
317
|
+
name: 'rs_workflow_message',
|
|
318
|
+
description: '向进行中的若水编排转达用户纠偏:下一步骤边界进入编排(纠偏类经主循环转译后 inject 注入后续指令)。',
|
|
319
|
+
parameters: {
|
|
320
|
+
runId: { type: 'string', required: true, description: '目标 run' },
|
|
321
|
+
text: { type: 'string', required: true, description: '用户纠偏内容(转译后)' },
|
|
322
|
+
inject: { type: 'boolean', description: 'true = 注入后续指令;缺省 false 排队' },
|
|
323
|
+
},
|
|
324
|
+
output: {
|
|
325
|
+
schema: { type: 'object', additionalProperties: true },
|
|
326
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
327
|
+
},
|
|
328
|
+
async execute(args) {
|
|
329
|
+
const driver = registry.drivers.get(args.runId)
|
|
330
|
+
if (driver === undefined) return { ok: false, error: '编排驱动器未注册(run 未终态但不在内存,或已终态)' }
|
|
331
|
+
const accepted = driver.handlePost({ kind: 'message', text: args.text, inject: args.inject === true })
|
|
332
|
+
return accepted ? { ok: true } : { ok: false, error: 'run 已终态,消息不予受理' }
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
defineTool({
|
|
336
|
+
name: 'rs_workflow_resume_from',
|
|
337
|
+
description: [
|
|
338
|
+
'断点续跑/从头重跑(主循环通道,与页签 ResumePicker 同能力):目标 run 须终态或已收敛。',
|
|
339
|
+
'经挂靠机制在原会话重建种子 run:done 步骤产出继承,fromStepId 及其后代闭包重置(缺省 = 从第一个未完成步续跑);',
|
|
340
|
+
'inputs 可选覆盖模板入参。旧 run 记录保留作审计,旧 run 未消费的纠偏消息不带入新 run。',
|
|
341
|
+
].join(''),
|
|
342
|
+
parameters: {
|
|
343
|
+
runId: { type: 'string', required: true, description: '要续跑的 run(终态)' },
|
|
344
|
+
fromStepId: { type: 'string', description: '从该步骤重来(其后代全部重置);缺省 = 断点续跑' },
|
|
345
|
+
inputs: { type: 'object', additionalProperties: true, description: '覆盖模板顶层 inputs,值必须为字符串' },
|
|
346
|
+
},
|
|
347
|
+
output: {
|
|
348
|
+
schema: { type: 'object', additionalProperties: true },
|
|
349
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
350
|
+
},
|
|
351
|
+
async execute(args, exec) {
|
|
352
|
+
const store = reportStore()
|
|
353
|
+
let record
|
|
354
|
+
try { record = store.get(args.runId) } catch { record = undefined }
|
|
355
|
+
if (record === undefined) return { ok: false, error: '运行记录不存在:' + args.runId }
|
|
356
|
+
// 活跃守卫:未终态(或 driver 仍在内存)不可续跑,与页签 resume-from 同口径
|
|
357
|
+
if (registry.drivers.has(args.runId) || record.finishedAt === undefined) {
|
|
358
|
+
return { ok: false, error: '运行进行中,不可续跑' }
|
|
359
|
+
}
|
|
360
|
+
// 会话归属守卫:仅原 run 所属会话的主循环可续跑,防跨会话接管他人 run
|
|
361
|
+
if (record.sessionId !== agentIdOf(exec.agent)) {
|
|
362
|
+
return { ok: false, error: '该 run 属于其他会话,本会话不可续跑' }
|
|
363
|
+
}
|
|
364
|
+
if (args.fromStepId !== undefined && (record.plan?.steps?.some((p) => p.ref === args.fromStepId)) !== true) {
|
|
365
|
+
return { ok: false, error: 'fromStepId 不在剧本中:' + args.fromStepId }
|
|
366
|
+
}
|
|
367
|
+
if (currentRunId(exec.agent) !== undefined) {
|
|
368
|
+
return { ok: false, error: `本会话已有进行中的编排 ${currentRunId(exec.agent)},不可续跑` }
|
|
369
|
+
}
|
|
370
|
+
// 唤醒轮挂靠兜底:重启后 initiator 表空,续跑前借本调用重注册(status/resume 同款)
|
|
371
|
+
registerInitiator(agentIdOf(exec.agent), (record2, fromStepId, inputs) => startSeedRun(exec.agent, record2, fromStepId, inputs))
|
|
372
|
+
ensureResumer(exec.agent)
|
|
373
|
+
const outcome = startSeedRun(exec.agent, record, args.fromStepId, args.inputs)
|
|
374
|
+
if (!outcome.ok) return { ok: false, error: outcome.error }
|
|
375
|
+
return { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run;rs_workflow_status 跟踪推进' }
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
defineTool({
|
|
379
|
+
name: 'rs_workflow_verdict',
|
|
380
|
+
description: [
|
|
381
|
+
'回写若水编排裁决(主循环裁决通道,与页签先到先得):run 处于 waiting_approval 时受理。',
|
|
382
|
+
'autoApprove=true 代审:by=main-agent + 代审意见;ask_user 转呈真人:by=user + 用户意见。裁决后 rs_workflow_resume 拉起下一段。',
|
|
383
|
+
].join(''),
|
|
384
|
+
parameters: {
|
|
385
|
+
runId: { type: 'string', description: '要裁决的 run(缺省 = 本会话现役 run)' },
|
|
386
|
+
verdict: { type: 'string', required: true, description: 'approve = 通过;reject = 驳回(重做)' },
|
|
387
|
+
reason: { type: 'string', required: true, description: '裁决意见(审计必填):驳回=重做口径,通过=放行依据' },
|
|
388
|
+
by: { type: 'string', description: '裁决来源:main-agent=代审(缺省);user=真人转呈后的用户裁决' },
|
|
389
|
+
},
|
|
390
|
+
output: {
|
|
391
|
+
schema: { type: 'object', additionalProperties: true },
|
|
392
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
393
|
+
},
|
|
394
|
+
async execute(args, exec) {
|
|
395
|
+
if (args.verdict !== 'approve' && args.verdict !== 'reject') {
|
|
396
|
+
return { ok: false, error: 'verdict 须为 approve|reject' }
|
|
397
|
+
}
|
|
398
|
+
if (typeof args.reason !== 'string' || args.reason.trim() === '') {
|
|
399
|
+
return { ok: false, error: 'reason 必填(审计口径与页签一致)' }
|
|
400
|
+
}
|
|
401
|
+
const by = args.by === 'user' ? 'user' : 'main-agent'
|
|
402
|
+
const runId = typeof args.runId === 'string' && args.runId !== '' ? args.runId : currentRunId(exec.agent)
|
|
403
|
+
if (runId === undefined) return { ok: false, error: '本会话无现役 run(编排未启动)' }
|
|
404
|
+
const driver = registry.drivers.get(runId)
|
|
405
|
+
if (driver === undefined) return { ok: false, error: '编排驱动器未注册(run 未终态但不在内存,或已终态)' }
|
|
406
|
+
const accepted = driver.handlePost({ kind: args.verdict, by, reason: args.reason })
|
|
407
|
+
const record = reportStore().get(runId)
|
|
408
|
+
if (accepted) {
|
|
409
|
+
return { ok: true, runId, status: record?.status, hint: '裁决已受理;rs_workflow_resume 拉起下一段' }
|
|
410
|
+
}
|
|
411
|
+
// 竞态口径:页签先裁时后到方不受理,但 run 可能已翻 running 且无活跃段(等 resume 拉起),
|
|
412
|
+
// 主循环须继续承担推进责任,否则 run 挂死在 running 无段状态
|
|
413
|
+
if (!driver.active && record?.status === 'running') {
|
|
414
|
+
startSegmentJob(exec.agent, driver)
|
|
415
|
+
return { ok: true, runId, status: record.status, hint: '裁决已被页签先裁(先到先得);已代为拉起下一段' }
|
|
416
|
+
}
|
|
417
|
+
return { ok: false, error: '裁决未受理(状态不符或已被页签先裁)' }
|
|
418
|
+
},
|
|
419
|
+
}),
|
|
420
|
+
]
|
|
421
|
+
for (const tool of tools) tctx.effect(() => tctx.tools.register(tool), 'rs-workflow orchestrator: ' + tool.name)
|
|
422
|
+
// 行上下文(tool context)无 agent 属性,effect 回调在此返回 undefined 触发宿主 dispose 契约异常,
|
|
423
|
+
// 导致整个 inject 回调失败、七工具全部未注册(43267fc 引入,实机定位);清理由既有路径承担:
|
|
424
|
+
// run 终态 finishRun/unregisterInitiator 与 cancel 的 finishRun 清 activeRuns;行销毁后的
|
|
425
|
+
// stale initiator 表项在下次 start 受理时被覆盖,不产生跨会话续跑。
|
|
426
|
+
})
|
|
427
|
+
return { rejectCounts, activeRuns }
|
|
428
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// planner-gate — 规划校验闸(v5 新增,纯函数):校验主模型 PLAN_SCHEMA 提交并生成 run 执行剧本
|
|
2
|
+
// 契约源 docs/rsww-v5/feat/planner-gate.md;错误/警告形态 [{ target, message }],与 template-save 一致
|
|
3
|
+
|
|
4
|
+
const err = (target, message) => ({ target, message })
|
|
5
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
6
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== ''
|
|
7
|
+
const PLACEHOLDER_RE = /\{([^{}]+)\}/g
|
|
8
|
+
|
|
9
|
+
// 模板直接依赖表:显式 after;缺省 = 文档序前一步;升级步(onExhausted 目标)脱离常规图无缺省边
|
|
10
|
+
export function templateDeps(parsed) {
|
|
11
|
+
const steps = Array.isArray(parsed?.steps) ? parsed.steps : []
|
|
12
|
+
const byId = new Map(steps.filter((s) => isObj(s) && typeof s.id === 'string').map((s) => [s.id, s]))
|
|
13
|
+
const escalateOnly = new Set()
|
|
14
|
+
for (const s of steps) {
|
|
15
|
+
if (isObj(s) && s.type === 'approve' && typeof s.onExhausted === 'string' && s.onExhausted !== 'blocked' && byId.has(s.onExhausted)) {
|
|
16
|
+
escalateOnly.add(s.onExhausted)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const order = steps.map((s, i) => (isObj(s) && typeof s.id === 'string' ? s.id : `#${i}`))
|
|
20
|
+
const deps = new Map()
|
|
21
|
+
steps.forEach((s, i) => {
|
|
22
|
+
if (!isObj(s) || typeof s.id !== 'string') return
|
|
23
|
+
let d
|
|
24
|
+
if (Array.isArray(s.after)) d = s.after
|
|
25
|
+
else if (escalateOnly.has(s.id) || i === 0) d = []
|
|
26
|
+
else {
|
|
27
|
+
// 缺省边依赖文档序最近的前一个常规步骤:升级步仅审批耗尽可达,常规步不得依赖它
|
|
28
|
+
let j = i - 1
|
|
29
|
+
while (j >= 0 && escalateOnly.has(order[j])) j--
|
|
30
|
+
d = j >= 0 ? [order[j]] : []
|
|
31
|
+
}
|
|
32
|
+
deps.set(s.id, d.filter((x) => byId.has(x) && x !== s.id))
|
|
33
|
+
})
|
|
34
|
+
return { deps, byId, order, escalateOnly }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 压缩依赖:被裁剪步骤的依赖按传递闭包穿透( kept→自身;pruned→其压缩依赖集 )
|
|
38
|
+
function compressed(depsOf, id, kept) {
|
|
39
|
+
if (kept.has(id)) return [id]
|
|
40
|
+
const out = []
|
|
41
|
+
for (const d of depsOf.get(id) ?? []) out.push(...compressed(depsOf, d, kept))
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dedup = (arr) => [...new Set(arr)]
|
|
46
|
+
|
|
47
|
+
// 占位符引用的步骤集(prompt/input/flow 中的 {stepId.out})
|
|
48
|
+
function referencedSteps(step) {
|
|
49
|
+
const texts = []
|
|
50
|
+
if (typeof step.prompt === 'string') texts.push(step.prompt)
|
|
51
|
+
if (isObj(step.input)) texts.push(...Object.values(step.input).filter((v) => typeof v === 'string'))
|
|
52
|
+
if (typeof step.flow === 'string') texts.push(step.flow)
|
|
53
|
+
const refs = new Set()
|
|
54
|
+
for (const text of texts) {
|
|
55
|
+
for (const m of text.matchAll(PLACEHOLDER_RE)) {
|
|
56
|
+
const ph = m[1]
|
|
57
|
+
if (ph === 'request' || ph === 'item' || ph === 'item.index' || ph.startsWith('input.')) continue
|
|
58
|
+
const dot = ph.indexOf('.')
|
|
59
|
+
if (dot > 0) refs.add(ph.slice(0, dot))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return refs
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 主入口:gate(plan, template, expected)
|
|
66
|
+
// plan = orchestrator 组装后的 PLAN_SCHEMA(request/templateId/inputs 顶层注入;brief/steps 来自 plan 工具参数)
|
|
67
|
+
// 显式 plan = plan 工具参数含 steps/brief 任一字段;plan 缺省(或空对象)→ 保底全序
|
|
68
|
+
export function gate(plan, template, expected) {
|
|
69
|
+
const explicit = plan != null && (plan.steps !== undefined || plan.brief !== undefined)
|
|
70
|
+
const stepsIn = explicit && Array.isArray(plan.steps) ? plan.steps : []
|
|
71
|
+
const templateId = plan != null && typeof plan.templateId === 'string' ? plan.templateId : (typeof expected?.expectedTemplateId === 'string' ? expected.expectedTemplateId : '')
|
|
72
|
+
const inputs = plan != null && isObj(plan.inputs) ? plan.inputs : {}
|
|
73
|
+
|
|
74
|
+
// #1 模板存在且 enabled(失败即短路:后续规则全部依赖模板)
|
|
75
|
+
if (templateId === '' || !isObj(template) || !isObj(template.entry) || template.entry.id !== templateId) {
|
|
76
|
+
return { ok: false, errors: [err('templateId', `模板不存在: ${templateId}`)] }
|
|
77
|
+
}
|
|
78
|
+
if (template.entry.enabled === false) {
|
|
79
|
+
return { ok: false, errors: [err('templateId', `模板已禁用: ${templateId}`)] }
|
|
80
|
+
}
|
|
81
|
+
const parsed = isObj(template.parsed) ? template.parsed : { steps: [] }
|
|
82
|
+
const { deps: depsOf, byId, escalateOnly } = templateDeps(parsed)
|
|
83
|
+
|
|
84
|
+
const errors = []
|
|
85
|
+
const warnings = []
|
|
86
|
+
|
|
87
|
+
// #1b 组合锚定
|
|
88
|
+
if (typeof expected?.expectedTemplateId === 'string' && expected.expectedTemplateId !== '' && templateId !== expected.expectedTemplateId) {
|
|
89
|
+
errors.push(err('templateId', `与本组合锚定模板不符: ${templateId}`))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const kept = new Set()
|
|
93
|
+
const indexOf = new Map()
|
|
94
|
+
if (explicit) {
|
|
95
|
+
// #2 steps 非空
|
|
96
|
+
if (!Array.isArray(plan.steps) || plan.steps.length === 0) {
|
|
97
|
+
errors.push(err('steps', '至少保留一个步骤'))
|
|
98
|
+
} else {
|
|
99
|
+
plan.steps.forEach((entry, i) => {
|
|
100
|
+
const ref = isObj(entry) ? entry.ref : entry
|
|
101
|
+
if (typeof ref !== 'string' || !byId.has(ref)) {
|
|
102
|
+
errors.push(err(`steps[${i}].ref`, `未知步骤: ${typeof ref === 'string' ? ref : JSON.stringify(ref) ?? ref}`))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (kept.has(ref)) {
|
|
106
|
+
errors.push(err(`steps[${i}].ref`, `步骤重复: ${ref}`))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
kept.add(ref)
|
|
110
|
+
indexOf.set(ref, i)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const tStepOf = (id) => byId.get(id)
|
|
116
|
+
|
|
117
|
+
if (kept.size > 0) {
|
|
118
|
+
// #5 子序列拓扑:顺序颠倒 / 审批目标与 for_each 数据源被裁 / 占位符引用被裁步
|
|
119
|
+
for (const id of kept) {
|
|
120
|
+
const s = tStepOf(id)
|
|
121
|
+
for (const d of depsOf.get(id) ?? []) {
|
|
122
|
+
if (kept.has(d)) {
|
|
123
|
+
if ((indexOf.get(d) ?? 0) > (indexOf.get(id) ?? 0)) {
|
|
124
|
+
errors.push(err('steps', `裁剪破坏依赖: ${id} 依赖 ${d},但顺序颠倒`))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (s.type === 'approve' && typeof s.target === 'string' && !kept.has(s.target)) {
|
|
129
|
+
errors.push(err('steps', `approve 目标不在剧本: ${s.target}(审批步 ${id} 不可裁剪其裁决对象)`))
|
|
130
|
+
} else if (s.type === 'approve' && typeof s.target === 'string' && (indexOf.get(s.target) ?? Infinity) > (indexOf.get(id) ?? 0)) {
|
|
131
|
+
errors.push(err('steps', `裁剪破坏依赖: ${id} 依赖 ${s.target},但顺序颠倒`))
|
|
132
|
+
}
|
|
133
|
+
if (typeof s.for_each === 'string') {
|
|
134
|
+
const src = s.for_each.split('.')[0]
|
|
135
|
+
if (!kept.has(src)) errors.push(err('steps', `for_each 数据源不在剧本: ${src}`))
|
|
136
|
+
}
|
|
137
|
+
for (const ref of referencedSteps(s)) {
|
|
138
|
+
if (!kept.has(ref)) errors.push(err('steps', `裁剪破坏依赖: ${id} 依赖 ${ref},但 ${ref} 不在剧本中`))
|
|
139
|
+
}
|
|
140
|
+
// w1 审批耗尽出口被裁
|
|
141
|
+
if (s.type === 'approve' && typeof s.onExhausted === 'string' && s.onExhausted !== 'blocked' && !kept.has(s.onExhausted)) {
|
|
142
|
+
warnings.push(err('steps', `审批步 ${id} 的耗尽出口 ${s.onExhausted} 不在剧本,耗尽兜底降级为 blocked`))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 僵尸升级步:保留升级步但其审批步被裁——永不就绪且 run 会误判 completed
|
|
147
|
+
for (const id of kept) {
|
|
148
|
+
if (!escalateOnly.has(id)) continue
|
|
149
|
+
const guarded = [...kept].some((k) => { const st = byId.get(k); return st?.type === 'approve' && st.onExhausted === id })
|
|
150
|
+
if (!guarded) errors.push(err('steps', `升级步 ${id} 保留但其审批步不在剧本:升级步须与审批步同裁同留`))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (explicit && stepsIn.length > 0) {
|
|
155
|
+
// #6 note/done 非空
|
|
156
|
+
stepsIn.forEach((entry, i) => {
|
|
157
|
+
const ref = isObj(entry) ? entry.ref : entry
|
|
158
|
+
if (typeof ref !== 'string' || !kept.has(ref)) return
|
|
159
|
+
if (!nonEmpty(isObj(entry) ? entry.note : undefined)) errors.push(err(`steps[${i}].note`, `步骤 ${ref} 的执行要点为空`))
|
|
160
|
+
if (!nonEmpty(isObj(entry) ? entry.done : undefined)) errors.push(err(`steps[${i}].done`, `步骤 ${ref} 的验收口径为空`))
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// #7 inputs 键 ⊆ 模板声明,值必须为字符串
|
|
165
|
+
const declared = isObj(parsed.inputs) ? parsed.inputs : {}
|
|
166
|
+
for (const [k, v] of Object.entries(inputs)) {
|
|
167
|
+
if (!Object.hasOwn(declared, k)) errors.push(err(`inputs.${k}`, `未声明的入参: ${k}`))
|
|
168
|
+
else if (typeof v !== 'string') errors.push(err(`inputs.${k}`, `入参必须为字符串: ${k}`))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// #8 显式 plan 时 brief 非空
|
|
172
|
+
if (explicit && !nonEmpty(plan.brief)) errors.push(err('brief', '验收口径为空'))
|
|
173
|
+
|
|
174
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
175
|
+
|
|
176
|
+
// 剧本生成
|
|
177
|
+
const scriptSteps = explicit
|
|
178
|
+
? stepsIn.map((entry) => ({ ref: entry.ref, note: typeof entry.note === 'string' ? entry.note : '', done: typeof entry.done === 'string' ? entry.done : '' }))
|
|
179
|
+
: parsed.steps.filter((s) => isObj(s) && typeof s.id === 'string').map((s) => ({ ref: s.id, note: '', done: '' }))
|
|
180
|
+
// 保底路径全序保留:压缩闭包 = 剧本全集(explicit 时 kept 已是剧本)
|
|
181
|
+
if (!explicit) for (const s of scriptSteps) kept.add(s.ref)
|
|
182
|
+
const deps = {}
|
|
183
|
+
for (const s of scriptSteps) {
|
|
184
|
+
deps[s.ref] = dedup((depsOf.get(s.ref) ?? []).flatMap((d) => compressed(depsOf, d, kept))).filter((d) => d !== s.ref)
|
|
185
|
+
}
|
|
186
|
+
if (!explicit && template.entry.autoApprove === true) {
|
|
187
|
+
warnings.push(err('plan', '保底全序且模板 autoApprove=true:代审将按模板内联口径执行,无任务级验收口径'))
|
|
188
|
+
}
|
|
189
|
+
const brief = explicit ? plan.brief.trim() : (typeof template.entry.description === 'string' ? template.entry.description : '')
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
planScript: { source: explicit ? 'model' : 'fallback', brief, steps: scriptSteps, deps },
|
|
193
|
+
warnings,
|
|
194
|
+
}
|
|
195
|
+
}
|