task-planning.js 21.7 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
/**
 * 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 打回:那些字段要么服务端生成、要么人在界面上定 */
const ALLOWED_KEYS = new Set(['seq', 'title', 'description', 'depends_on', 'dependsOn']);

// ────────────────────────────────────────────────────────────────
// 注入点(仿 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…]}]`,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":[]}]',
    '```',
    `- \`seq\`:1..N 连续整数、不重复、与数组顺序一致;条数上限 **${MAX_TASKS}**。`,
    '- `depends_on`:只能写本次产出的 `seq` 数字(1-based)。**不许写 task id** —— id 由服务端'
      + '在你交回之后才生成,你根本看不到;写字符串一定被打回。没有依赖就写 `[]`(不能省)。',
    '- 只输出这四个字段。`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` });
    }

    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,
    });
  });

  // 环:只有 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,
};