/** * 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, };