task-planning.js
23.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
/**
* task-planning —— 把一个 goal 拆成 task DAG(27-P3C §四.9)
*
* 链路**照抄** `graph-engine.defaultPlanWorkGraph`,不另造一套:
* 调本体 → 解析产出 → 写入前校验 → 不过就把 `errors` 原样回喂 → 最多 3 轮 → 过了交给调用方落库。
* 照抄的是四件已经被真跑验证过的事,缺任何一件都会退化成一条静默失败的路:
* ① `errors: [{rule, detail}]` 的形状 —— `system/agents/task-planning/agent.md`
* 「被打回来怎么办」那张表就是按这五个 rule 写的,链路侧另造 rule 名会让那张表整个失效;
* ② `onAcpEvent` 累加 `session/result.usage` —— 这是 goal 层唯一一笔花费的唯一来源;
* ③ **两条抛出路径都把已烧掉的 token 挂在 error 上**(`err.tokens`),否则拆分失败时
* 那几轮花费在账上直接蒸发(`defaultPlanWorkGraph` 注释里点名的「隐形的一半」);
* ④ `metadata.taskId` 必须传(这里用 goalId)—— AgentManager 的 `_applyRuntimeScope`
* 拿它拼 cwd,取不到会落进 `_global/_no-task`,所有拆分调用叠在同一个坑里且不报错。
*
* **本模块只产出 specs,不碰数据库。** 落库(含 seq → 真 id 的翻译)留在 `routes/company.js`
* 里批 1 已有的那一段:那是同步路径与拆分路径共用的唯一持有者,降级路径不该长出第二套语义。
*
* 与 `graph-engine` 的规划链路的**唯一实质差别**:本模块跑在任何 graph run 存在之前 ——
* 没有 nodeRunId、没有 timeline、没有项目根,所以既不写 timeline,也**绝不能**在任务描述里
* 出现 `GRAPH_WRITE_BOUNDARY_HEADER` 那个字面量:`PromptBuilder.buildRuntimeCoordinates`
* 靠它判断本次调用是不是 graph 链路,判据命中会让坐标声明不再给交付物归宿,
* 而本体正文里的 `$W` 正是从那一行读的 —— 两边同时落空,且不报错。
*/
const path = require('path');
const REPO_ROOT = path.resolve(__dirname, '../..');
/** 全局唯一的一份 goal 拆分本体(`system/agents/task-planning/`) */
const PLANNING_AGENT_ID = 'task-planning';
/**
* 拆分的最多轮次。与 `graph-engine.PLAN_MAX_ROUNDS` 同值(3),也与 §八 验收 20
* 「连续 3 轮非法 JSON → 降级」同口径 —— 每一轮都是一次真实 LLM 调用,
* 而且外层还有「降级成 1 条同描述 task」兜着,不必在这一层烧完。
*/
const PLAN_MAX_ROUNDS = 3;
/**
* 一个 goal 最多拆几条。超过这个数几乎一定是在拆**阶段**而不是拆**任务**
* (agent.md Step 2 的机械判据),与本体正文的「条数上限 8」是同一个数。
*/
const MAX_TASKS = 8;
/** 产出里允许出现的键。多余键一律按 schema 打回:那些字段要么服务端生成、要么人在界面上定
*
* 43 号文档:`acceptance_mode` 是这四个字段之外**唯一**的例外——要不要按验收条目拆子任务,
* 是一次语义判断(跟「要不要拆多个 task」用的是同一种判断力),没法像 mode/priority/budget
* 那样交给服务端按 goal 级默认值机械注入,所以必须让 agent 自己决定、自己写出来。
*/
const ALLOWED_KEYS = new Set(['seq', 'title', 'description', 'depends_on', 'dependsOn', 'acceptance_mode', 'acceptanceMode']);
const ACCEPTANCE_MODES = new Set(['holistic', 'itemized']);
// ────────────────────────────────────────────────────────────────
// 注入点(仿 graph-engine.setPlanWorkGraph)
// ────────────────────────────────────────────────────────────────
/**
* 默认实现真的会起 Claude CLI 调 task-planning。这个注入点保留只为一件事:
* 测试注入假拆分,避免真起 CLI(慢、要钱、且产出不确定)。
*
* @type {(ctx: {companyId: string, goal: object}) => Promise<{specs: object[], tokens?: number, rounds?: number}>}
*/
let planTasksImpl = defaultPlanTasks;
/** 替换拆分实现(传 null 回默认实现) */
function setPlanTasks(fn) {
planTasksImpl = fn || defaultPlanTasks;
}
/** 当前生效的拆分实现。调用方一律走这里,不要直接调 defaultPlanTasks */
function planTasks(ctx) {
return planTasksImpl(ctx);
}
// ────────────────────────────────────────────────────────────────
// 默认实现
// ────────────────────────────────────────────────────────────────
/**
* 调 task-planning 拆一次 goal。
*
* @param {{companyId: string, goal: object}} ctx goal 是 goals 表的整行
* @returns {Promise<{specs: object[], tokens: number, rounds: number}>}
* specs 形如 `[{seq, title, description, depends_on: [seq…], acceptance_mode}]`,seq 恒为 1..N 连续
* @throws 轮次耗尽 / 调用本身炸了;两种情况都在 error 上挂了 `tokens`(已烧掉的那部分)
*/
async function defaultPlanTasks(ctx) {
const AgentManager = require('../src/core/agent-manager');
// 空 config:AgentManager.getAgentConfig 有基于文件系统的动态寻址回退,本体恒在
// system/agents/{aid},与 agent-chat.js 的用法一致。
const manager = new AgentManager({}, REPO_ROOT);
const agentName = resolvePlanningAgentName(ctx.companyId);
let tokens = 0;
const onAcpEvent = (ev) => {
if (!ev || ev.method !== 'session/result') return;
const u = (ev.params && ev.params.usage) || null;
if (u) tokens += (u.input || 0) + (u.output || 0);
};
let errors = null;
let lastOutput = '';
for (let round = 1; round <= PLAN_MAX_ROUNDS; round++) {
const prompt = buildTaskPlanningPrompt(ctx, { round, errors, lastOutput });
let res;
try {
res = await manager.callAgent(agentName, prompt, {
onAcpEvent,
metadata: {
// taskId 决定 CLI 的 cwd(system/agents/task-planning/{cid}/{taskId}/)。
// goalId 是这里唯一天然的一次性标识:不传的话所有公司所有拆分共用 _no-task 一个目录。
taskId: ctx.goal.id,
layer: 'goal-plan',
goalId: ctx.goal.id,
companyId: ctx.companyId,
},
});
} catch (e) {
// 调用本身炸了(本体寻址失败 / CLI 起不来)不是「拆错了」,重贴 errors 再来一轮没有意义。
// 直接上抛,但把已经烧掉的 token 挂上去,否则这条路径上的花费就蒸发了。
e.tokens = (e.tokens || 0) + tokens;
throw e;
}
lastOutput = (res && res.output) || '';
const picked = extractPlannedTasks(lastOutput);
if (!picked) {
errors = [{
rule: 'output',
detail: '回复里没找到可解析的任务数组。把最终产出放进回复**最后一段**的 ```json 代码块里,'
+ '顶层是数组,形状 [{"seq":1,"title":"…","description":"…","depends_on":[]}]',
}];
continue;
}
const checked = validatePlannedTasks(picked);
if (checked.ok) {
console.log(`[TaskPlanning] goal ${ctx.goal.id} 拆出 ${checked.specs.length} 条 task(第 ${round} 轮,${tokens} tokens)`);
return { specs: checked.specs, tokens, rounds: round };
}
errors = checked.errors;
console.warn(`[TaskPlanning] goal ${ctx.goal.id} 第 ${round}/${PLAN_MAX_ROUNDS} 轮未过校验:${JSON.stringify(errors)}`);
}
const err = new Error(
`${PLANNING_AGENT_ID} 连续 ${PLAN_MAX_ROUNDS} 轮未产出合法任务 DAG(goal ${ctx.goal.id}):`
+ JSON.stringify(errors)
);
err.tokens = tokens;
err.planErrors = errors;
throw err;
}
/**
* 本体的寻址名。做法与 `graph-engine.resolvePlanningAgentName` 一致:按需幂等雇一次,
* 判据查 `company_agents` 表(**不是**目录 —— 公司私有目录在 28 批次 4 后被清空,
* 按目录判恒为 false,地址会退化成裸名、把公司分区整个丢掉)。
*
* 与 workgraph-planning 那份的**唯一差别**:这里不需要撤 org node。
* `company-service.NON_ORG_AGENT_TYPES` 现在含 `meta-planning`(task-planning 的 config.type
* 就是它),`decideOrgNodeRecording` 直接判 record:false,hire 根本不会写 org_graph_nodes。
* graph-engine 那句 `removeNode` 的注释停在「只豁免 meta-bootstrap」的旧事实上,别照抄。
*/
function resolvePlanningAgentName(companyId) {
if (!companyId) return PLANNING_AGENT_ID;
const { CompanyService, listCompanyAgents } = require('./company-service');
const isHired = () => listCompanyAgents(companyId).some((r) => r.role_name === PLANNING_AGENT_ID);
if (!isHired()) {
try {
new CompanyService().hire(companyId, PLANNING_AGENT_ID, {});
} catch (e) {
// 公司记录不存在 / 本体没毕业:不在这里报错,退回裸名让 AgentManager 给出它自己的诊断
console.warn(`[TaskPlanning] 按需雇佣 ${PLANNING_AGENT_ID} 失败(${companyId}): ${e.message}`);
}
}
return isHired() ? `${companyId}:${PLANNING_AGENT_ID}` : PLANNING_AGENT_ID;
}
// ────────────────────────────────────────────────────────────────
// 提示词
// ────────────────────────────────────────────────────────────────
/**
* agent.md Step 1 点名的三项输入,一项都不能少:
* ① goal 描述(= 验收边界,唯一真相来源)② 公司 org graph 摘要 ③ 可用角色清单
* 外加「交回方式」与重试轮的 errors 回喂。
*
* ⚠️ 这里**故意不写**任何写入边界段头:本次调用不在任何 run 里、没有项目根,
* 段头(或它的同义中文标题)出现在任务描述里都会改变 PromptBuilder 的分叉判定,见文件头注释。
*/
function buildTaskPlanningPrompt(ctx, { round = 1, errors = null, lastOutput = '' } = {}) {
const g = ctx.goal || {};
const lines = [
`你正在把公司 **${ctx.companyId}** 的一个 goal 拆成任务 DAG。`,
'',
'## ① goal 描述(人提的需求原文 = 验收边界)',
g.description || '(调用方没给需求描述)',
'',
`- goalId:${g.id}(只读,不要写进你的产出)`,
`- 标题:${g.title || '(无)'}`,
`- 优先级:${g.priority || 'P1'}`,
'',
'## ② 公司 org graph 摘要(每条 task 都会各自走一遍这条流水线)',
describeOrgGraph(ctx.companyId),
'',
'## ③ 可用角色清单',
describeHiredRoles(ctx.companyId),
'',
'## 交回方式(引擎只读你回复里最后一个 ```json 代码块)',
'顶层是**数组**,每个元素四个必填字段 + 一个可选字段:',
'```json',
'[{"seq":1,"title":"…","description":"…","depends_on":[],"acceptance_mode":"holistic"}]',
'```',
`- \`seq\`:1..N 连续整数、不重复、与数组顺序一致;条数上限 **${MAX_TASKS}**。`,
'- `depends_on`:只能写本次产出的 `seq` 数字(1-based)。**不许写 task id** —— id 由服务端'
+ '在你交回之后才生成,你根本看不到;写字符串一定被打回。没有依赖就写 `[]`(不能省)。',
'- `acceptance_mode`(可选,缺省 `"holistic"`):这条 task 的验收后续要不要按验收条目拆成'
+ '子任务逐条判——判断轴跟 Step 2 判「要不要拆多个 task」是同一种(够不够独立成立、值不值得'
+ '单独验收)。多数任务留空/写 `"holistic"` 即可;只有一件对外是单一交付物、但内部有多条'
+ '可以各自独立验收的验收标准的任务,才写 `"itemized"`。不确定就留 `"holistic"`——错误地'
+ '拆细比保守合并的代价更高。',
'- 只输出这五个字段(`acceptance_mode` 可以不写)。`id` / `status` / `mode` / `budget`'
+ ' / `priority` / `agent` 一律不许出现。',
'- 每条 `description` 必须**自包含**:下游每条 task 起独立的 run,那个 run 只看得到自己'
+ '这一条描述(它会成为该 run 的 task-goal.json),不许写「同上」「见 task 1」这类跨条引用。',
'- 拆分理由写在 JSON 代码块**前面**的正文里;代码块之后不要再写任何字。',
'',
`校验不过时引擎会把 errors 原样回喂给你,最多 ${PLAN_MAX_ROUNDS} 轮;`
+ '耗尽后系统降级成「1 条同描述的 task」并在界面标注拆分失败 —— 那等于这次调用白烧。',
];
if (round > 1) {
lines.push(
'',
`## ⚠️ 上一轮没通过(第 ${round} 轮)`,
'引擎对你上一轮的产出跑了写入前校验,结果如下。**读懂 rule,改具体那一处,再交一次**;',
'不要原样重发,也不要整份重写(重写常常在修好一条的同时踩坏另一条):',
'```json',
JSON.stringify(errors || [], null, 2),
'```',
);
if (lastOutput) lines.push(`(上一轮回复摘要:${String(lastOutput).slice(-800)})`);
}
// 只滤掉 null/undefined(拼不出内容的可选段),'' 是**故意的空行**
return lines.filter((l) => l != null).join('\n');
}
/** org graph 摘要:节点名 + 流转边。查不到就说查不到,agent.md 明写「缺项照常拆,别停下来问人」 */
function describeOrgGraph(companyId) {
try {
const { getGraph } = require('./org-graph-service');
const { nodes, edges } = getGraph(companyId);
if (!nodes || !nodes.length) return '- (这家公司还没有 org graph)';
const byId = new Map(nodes.map((n) => [String(n.id), n.roleName || n.id]));
const out = [`- 节点(${nodes.length}):${nodes.map((n) => n.roleName || n.id).join(' / ')}`];
if (edges && edges.length) {
out.push('- 流转:');
for (const e of edges) {
out.push(` - ${byId.get(String(e.from)) || e.from} --[${e.condition}]--> ${byId.get(String(e.to)) || e.to}`);
}
}
return out.join('\n');
} catch (e) {
return `- (读不到 org graph:${e.message})`;
}
}
/** 可用角色清单:这家公司雇了谁。判据查 company_agents 表,与寻址那一处同源 */
function describeHiredRoles(companyId) {
try {
const { listCompanyAgents } = require('./company-service');
const rows = listCompanyAgents(companyId) || [];
if (!rows.length) return '- (这家公司还没有雇任何角色)';
return rows.map((r) => `- ${r.role_name}(本体 ${r.system_agent_id || r.role_name})`).join('\n');
} catch (e) {
return `- (读不到角色清单:${e.message})`;
}
}
// ────────────────────────────────────────────────────────────────
// 产出解析 + 写入前校验
// ────────────────────────────────────────────────────────────────
/**
* 从回复里取出任务数组。
*
* 与 `extractPlannedGraph` 同一个思路(```json 围栏优先,其次括号配平扫裸结构),
* 但**从后往前**取:agent.md 硬约束 6 要求 JSON 块是回复的最后一段,
* 而正文里举的反例 / 引用的上一轮产出常常也是合法 JSON,从前往后会认错那一份。
*
* `{"tasks":[…]}` 这种最常见的跑偏也认下来(省一整轮 LLM 调用);其余形状一律回 null 走 rule=output。
*
* @returns {object[]|null}
*/
function extractPlannedTasks(output) {
const text = String(output || '');
if (!text.trim()) return null;
const candidates = jsonCandidates(text);
for (let i = candidates.length - 1; i >= 0; i -= 1) {
const doc = safeJsonParse(candidates[i]);
if (Array.isArray(doc)) return doc;
if (doc && Array.isArray(doc.tasks)) return doc.tasks;
}
return null;
}
/** ```json 围栏块,其次括号配平扫出的裸数组 / 裸对象 */
function jsonCandidates(text) {
const out = [];
const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g;
let m;
while ((m = fence.exec(text))) out.push(m[1]);
// ponytail: 配平扫描不理会字符串字面量里的括号,description 里带 `]` 会切错。
// 上限是「那一个候选 JSON.parse 失败,退到下一个候选」,不是崩溃;
// 且围栏路径覆盖绝大多数正常产出。真要修就得引一个流式 JSON 扫描器,不值。
out.push(...balanced(text, '[', ']'), ...balanced(text, '{', '}'));
return out;
}
function balanced(text, open, close) {
const out = [];
let depth = 0;
let start = -1;
for (let i = 0; i < text.length; i += 1) {
const c = text[i];
if (c === open) {
if (depth === 0) start = i;
depth += 1;
} else if (c === close && depth > 0) {
depth -= 1;
if (depth === 0) out.push(text.slice(start, i + 1));
}
}
return out;
}
function safeJsonParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}
/**
* 写入前校验。**一次把全部问题收齐再回喂**(不是遇到第一条就返回):
* 每轮回喂都要烧一次 LLM 调用,只报第一条错误等于用三轮修三个本可以一起修的问题。
*
* rule 取值域必须与 `system/agents/task-planning/agent.md`「被打回来怎么办」那张表一致:
* output(这里不产)/ schema / cycle / reference / limit。另造 rule 名会让本体正文里
* 那张「收到这个 rule 该做什么」的表整个失效 —— agent 收到一个它没见过的 rule 只能瞎猜。
*
* ⚠️ 这里的环检测**不是**数据层 `validateDependsOn` 的第二个持有者:两者判的不是同一件事。
* 这里判的是 **seq 空间**里的引用(这批 task 还没有 id、还没进库,数据层根本看不到它们);
* 数据层判的是**已落库的 task id** 之间的环,落库时照跑不误,仍是那一侧的唯一权威。
*
* @returns {{ok: true, specs: object[]} | {ok: false, errors: {rule: string, detail: string}[]}}
*/
function validatePlannedTasks(raw) {
if (!Array.isArray(raw)) {
return { ok: false, errors: [{ rule: 'schema', detail: '顶层必须是一个数组,不是对象' }] };
}
if (!raw.length) {
return { ok: false, errors: [{ rule: 'schema', detail: '产出是空数组。一个 goal 至少要拆出 1 条 task(goal 本身就是一件事时输出恰好 1 条,那是正确答案)' }] };
}
const errors = [];
if (raw.length > MAX_TASKS) {
errors.push({ rule: 'limit', detail: `产出 ${raw.length} 条,上限 ${MAX_TASKS} 条 —— 超过说明在拆阶段而不是拆任务,回头合并` });
}
const specs = [];
raw.forEach((item, i) => {
const at = `第 ${i + 1} 条`;
if (!item || typeof item !== 'object' || Array.isArray(item)) {
errors.push({ rule: 'schema', detail: `${at} 不是一个对象` });
return;
}
const extra = Object.keys(item).filter((k) => !ALLOWED_KEYS.has(k));
if (extra.length) {
errors.push({ rule: 'schema', detail: `${at} 有多余键 ${extra.join(' / ')}:只许 seq / title / description / depends_on / acceptance_mode` });
}
// 没写就是没判断要拆——缺省 holistic(保守,不引入新行为),不是错误
const rawMode = item.acceptance_mode !== undefined ? item.acceptance_mode : item.acceptanceMode;
let acceptanceMode = 'holistic';
if (rawMode !== undefined && rawMode !== null) {
if (!ACCEPTANCE_MODES.has(rawMode)) {
errors.push({ rule: 'schema', detail: `${at} 的 acceptance_mode 只能是 "holistic" 或 "itemized",收到 ${JSON.stringify(rawMode)}` });
} else {
acceptanceMode = rawMode;
}
}
const description = String(item.description == null ? '' : item.description).trim();
if (!description) errors.push({ rule: 'schema', detail: `${at} 的 description 为空 —— 它就是这条 task 的验收边界,不能缺` });
// seq 必须与数组下标对齐:agent 自己写的号一旦与顺序错位,depends_on 引的就是另一条 task,
// 而那条错误依赖完全合法(不成环、在范围内),落库后没有任何人再能发现它。
if (!Number.isInteger(Number(item.seq)) || Number(item.seq) !== i + 1) {
errors.push({ rule: 'schema', detail: `${at} 的 seq 应为 ${i + 1}(1..N 连续、与数组顺序一致),收到 ${JSON.stringify(item.seq)}` });
}
const rawDeps = item.depends_on !== undefined ? item.depends_on : item.dependsOn;
if (rawDeps !== undefined && rawDeps !== null && !Array.isArray(rawDeps)) {
errors.push({ rule: 'schema', detail: `${at} 的 depends_on 必须是数组(没有依赖就写 [])` });
}
const deps = [];
for (const d of Array.isArray(rawDeps) ? rawDeps : []) {
const n = typeof d === 'number' || /^\d+$/.test(String(d).trim()) ? Number(d) : NaN;
if (!Number.isInteger(n) || n < 1 || n > raw.length) {
errors.push({ rule: 'reference', detail: `${at} 的 depends_on 引用了 ${JSON.stringify(d)},只能是本次产出的 seq(1..${raw.length} 的整数),不能是 task id` });
continue;
}
if (n === i + 1) {
errors.push({ rule: 'cycle', detail: `${at} 的 depends_on 引用了自己` });
continue;
}
if (!deps.includes(n)) deps.push(n);
}
specs.push({
seq: i + 1,
title: String(item.title == null ? '' : item.title).trim(),
description,
depends_on: deps,
acceptance_mode: acceptanceMode,
});
});
// 环:只有 seq 全部对齐、引用全部合法时才谈得上。上面已经报过错就别再叠一条误导性的环报告。
if (!errors.length) {
const cycle = findCycle(specs);
if (cycle) {
errors.push({ rule: 'cycle', detail: `depends_on 成环:${cycle.join(' → ')}。让被依赖的排在前面,seq 小的先` });
}
}
return errors.length ? { ok: false, errors } : { ok: true, specs };
}
/**
* seq 空间里找一个环。Kahn 剥点,剥不动的那些就在环上。
* @returns {number[]|null} 环上的 seq(升序),无环返回 null
*/
function findCycle(specs) {
const indeg = new Map(specs.map((s) => [s.seq, 0]));
const out = new Map(specs.map((s) => [s.seq, []]));
for (const s of specs) {
for (const d of s.depends_on) {
indeg.set(s.seq, indeg.get(s.seq) + 1);
out.get(d).push(s.seq);
}
}
const queue = [...indeg.entries()].filter(([, n]) => n === 0).map(([seq]) => seq);
let removed = 0;
while (queue.length) {
const seq = queue.shift();
removed += 1;
for (const next of out.get(seq)) {
indeg.set(next, indeg.get(next) - 1);
if (indeg.get(next) === 0) queue.push(next);
}
}
if (removed === specs.length) return null;
return [...indeg.entries()].filter(([, n]) => n > 0).map(([seq]) => seq).sort((a, b) => a - b);
}
module.exports = {
PLANNING_AGENT_ID,
PLAN_MAX_ROUNDS,
MAX_TASKS,
planTasks,
setPlanTasks,
// 下面三个导出是给测试用的(校验规则是这一层唯一的复杂逻辑,值得单独钉住)
extractPlannedTasks,
validatePlannedTasks,
buildTaskPlanningPrompt,
};