diff --git a/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md b/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md index 37167fb..ebc9008 100644 --- a/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md +++ b/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md @@ -1,6 +1,6 @@ # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源 -> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-19.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。Phase 2-5 尚未开工 +> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭),单测覆盖,同样还没做真实端到端回归。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3-5 涉及核心调度引擎范式级改动(任务池认领)+ 全新子任务级预算模型,风险量级不同,本轮不做,另起设计讨论** > **日期**: 2026-09-01 > **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 > **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。 @@ -280,3 +280,30 @@ goal 创建 ### 遗留:还没做真实端到端回归 设计文档「Phase 1 验收标准」一节列的三条(① dispatch 后 subtask-planning 真的被触发并产出 artifact;② org-planner 展开的子图节点提示词里真的能读到这段参考;③ subtask-planning 没跑完时 org-planner 完全不受影响)——**①③ 已经被集成测试间接覆盖**(用注入桩验证了机制本身是对的),**②的"提示词真的拼上了共享索引这一段"也已经用集成测试直接验证**(`describeInputs` 真实读到 `subtask_plan` 并列进提示词)。**没做的是**:用真实公司、真实 goal、真实 Claude CLI 走一遍全链路,确认 subtask-planning 在真实调用下真的产出有意义的分解(不是机制通不通,是内容有没有用)——考虑到刚踩过的真实 CLI 子进程事故,这次真跑验证需要更谨慎地控制范围(单个任务、有人盯着、跑完立刻检查有没有残留进程),不建议顺手跑,值得单独作为一步来做。 + +--- + +## Phase 2 实现记录(2026-09-02,收窄版,代码已实现,未做真实端到端回归) + +用户原话要求"把 Phase 2-5 一次性做完"。动手前先做了一轮代码调研,发现文档字面描述掩盖了真实工作量落差:dream(29-DREAM)只**单向写回**经验,没有任何可查询索引;"R-19.3 共享索引"是笔误(实指 `TASK_SHARED_KINDS`/R-9.3,跟经验库无关);org-hunt 只能作为图节点被 `workgraph-planning` 动态选中,没有图外可调用入口;Phase 3(任务池认领)是"改动面最大的一条",要给纯拓扑驱动的 `graph-engine.js` 并行引入认领制调度层;Phase 4 需要全新的子任务级 run + 预算模型;Phase 5 在 3/4 落地前没有真实"可认领池"状态可展示。跟用户对齐后,**这一轮只做 Phase 2(收窄版)**,Phase 3-5 涉及核心调度引擎范式级改动 + 全新预算模型,风险量级不同,另起设计讨论。 + +### 落地清单 + +| 内容 | 位置 | +|---|---| +| 经验检索(新模块) | `server/services/experience-lookup.js`——`queryExperience({companyId, task})`:查这家公司已雇佣 agent 的 `memory/experience.md`(dream 沉淀),关键词重叠打分(中文用双字滑窗近似分词,不引依赖)。命中为零是真实信号("查不到"),不是异常 | +| 按需 Deep Research(新模块) | `server/services/deep-research.js`——`triggerDeepResearch({companyId, task, topic})`:图外直接 `AgentManager.callAgent('org-hunt', ...)`,轻量提示词,不套用 org-hunt 训练场景的三件套契约。**默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量,不为 `'1'` 直接返回 `null`,连注入的假实现都不调用) | +| `subtask-planning.js` 两段式改造 | `defaultPlanSubtasks`:先查经验库拼进提示词 → 调一次 agent,agent 自评 `confidence` → 经验为空或 `confidence:"low"` 就触发调研 → 有结果就带着调研结果再调一次取代第一轮。`reasoning_source`(`model_only`/`memory`/`deep_research`)由服务端根据实际发生的分支算出,不是 agent 自报的。新增更细粒度注入点 `setSubtaskAgentCaller`(仿 `dream-service.setDreamAgentCaller`),能测到这段编排逻辑本身,不只是测"落盘"那一层 | +| `subtask-plan.json` 增补字段 | `reasoning_source`/`experience_refs`/`research_used`,纯增量、向后兼容旧的注入桩(缺失时兜底 `model_only`/`[]`/`false`) | +| `system/agents/subtask-planning/agent.md` | 输出契约加 `confidence` 字段(硬约束 9),补充"你可能会看到两种提示词"一节 | +| 测试 | `experience-lookup.test.js`(6 条)、`deep-research.test.js`(6 条,含"关闭开关时哪怕注入了假实现也不调用"这条最重要的安全阀)、`subtask-planning.test.js` 扩展(`extractConfidence` + `buildSubtaskPlanningPrompt` 的经验/调研小节,7 条新增)、`subtask-planning-materialize.test.js` 扩展(新增字段落盘 2 条 + `defaultPlanSubtasks` 三条分支编排 4 条)。全量单测 1177 条,1175 passed,唯二失败(`.env.example` 覆盖 + 测试 id 稳定性)与本次改动无关、改动前即存在。测试全程 `ps aux` 确认零残留 CLI 子进程 | + +### 与原设计的出入 + +1. **"历史同类 goal 执行记录"这条来源本轮不做**——只做 agent 经验区块检索。原设计的用户原话把"经验库/memory/dream 沉淀"和"历史同类 goal 检索"并列成一条来源,但后者需要语义检索基础设施(当前没有),贸然上关键词匹配对"同类"这种判断噪音会很大。这是范围收窄,不是遗忘,留给以后需要时再评估怎么做语义检索。 +2. **Deep research 复用 org-hunt,但用轻量提示词,不套用它的训练场景三件套契约**(`knowhow_raw.md`/`crawl_config.json`/`seed_data.json` ≥50 条)。原因:那套契约是为 FDE 训练场景校准的,直接套用在"人在等一条 task 入队"这种时间敏感场景会不可控地慢。org-hunt 本身的图节点契约、`deliverable.json` 模板都没动。 +3. **Deep research 触发默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量)。org-hunt 的延迟/成功率在"日常任务规划、时间敏感"场景下从未验证过,且 Phase 1 已经因为一个新触发点疏于隔离出过真事故。这跟 Phase 1 `withSubtaskPlanning` 默认 `false` 是同一个谨慎级别——机制已实现且单测覆盖,但真实调用需要用户手动开关、挑单个任务验证。 + +### 遗留:还没做真实端到端回归 + +跟 Phase 1 一样,机制通不通已经用注入桩验证(三条 `reasoning_source` 分支、经验命中/未命中、调研开/关/成功/失败都有独立测试覆盖)。**没做的是**:真实开一次 `ENABLE_DEEP_RESEARCH=1`,挑一个真实任务,确认①经验检索真的能从生产环境的 dream 沉淀里查到有用内容(目前多数本体 dream 还没攒够料,可能长期查不到,这是预期状态不是 bug);②org-hunt 用轻量提示词跑起来的真实延迟、真实调研质量;③两轮调用叠加后的真实 token/时间成本。不在本轮自动测试范围内,需要用户后续单独决定何时验证。 diff --git a/server/services/deep-research.js b/server/services/deep-research.js new file mode 100644 index 0000000..a5a9676 --- /dev/null +++ b/server/services/deep-research.js @@ -0,0 +1,120 @@ +/** + * deep-research —— 按需触发 org-hunt 做一次轻量调研(46 号文档 Phase 2)。 + * + * org-hunt 目前只能作为图节点被 `workgraph-planning` 动态选中调用(`deliverable.json` + * 模板,FDE 训练场景),交付物是 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json` + * (≥50 条)那套重契约。这里**不**套用那套契约——"task 入队前的快速调研参考"是时间敏感 + * 场景,直接照训练场景的产出量级要求会不可控地慢。org-hunt 的图节点契约、`deliverable.json` + * 模板本身都不动,这里只是另开一条**图外**的调用路径,让它以"轻量问答"的形式被叫起来 + * (调用方式仿 `dream-service.callDreamAgent`:直接 `AgentManager.callAgent`,不经图引擎)。 + * + * **默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量):org-hunt 的延迟/成功率在"日常任务规划、 + * 时间敏感"场景下从未验证过(46 号文档「已知的高风险点」原话),且 subtask-planning 这条 + * 触发链已经因为疏于隔离出过一次真事故(44 个残留 CLI 子进程,见 subtask-planning.js 头注释)。 + * 不开这个开关,这个模块连 org-hunt 都不会尝试雇佣/调用——只打一行日志说明跳过。 + */ +const path = require('path'); + +const RESEARCH_AGENT_ID = 'org-hunt'; + +// ── 注入点(仿 subtask-planning.setPlanSubtasks)────────────────────── +let triggerImpl = defaultTriggerDeepResearch; +let isInjected = false; + +/** 替换调研实现(传 null 回默认实现)。测试用,避免真起 CLI */ +function setTriggerDeepResearch(fn) { + triggerImpl = fn || defaultTriggerDeepResearch; + isInjected = !!fn; +} + +/** + * @param {{companyId: string, task: object, topic: string}} ctx + * @returns {Promise<{findings: string, sources: string[], tokens: number} | null>} + * `null` = 没跑(开关关闭 / 测试环境未注入 / 调用失败 / 没解析出结构化结果)—— + * 调用方一律当"没有调研结果"处理,不是错误,不该抛出去打断规划流程。 + */ +async function triggerDeepResearch(ctx) { + if (process.env.ENABLE_DEEP_RESEARCH !== '1') { + console.log('[DeepResearch] ENABLE_DEEP_RESEARCH 未开启,跳过(默认关闭,见文件头注释)'); + return null; + } + if (process.env.VITEST && !isInjected) { + console.log('[DeepResearch] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)'); + return null; + } + try { + return await triggerImpl(ctx); + } catch (err) { + console.warn(`[DeepResearch] 触发失败,按"没有调研结果"处理:${err.message}`); + return null; + } +} + +/** 寻址名。与 `subtask-planning.resolvePlanningAgentName` 同一个模式:按需幂等雇一次 */ +function resolveResearchAgentName(companyId) { + if (!companyId) return RESEARCH_AGENT_ID; + const { CompanyService, listCompanyAgents } = require('./company-service'); + const isHired = () => listCompanyAgents(companyId) + .some((r) => r.role_name === RESEARCH_AGENT_ID || r.system_agent_id === RESEARCH_AGENT_ID); + if (!isHired()) { + try { + new CompanyService().hire(companyId, RESEARCH_AGENT_ID, {}); + } catch (e) { + console.warn(`[DeepResearch] 按需雇佣 ${RESEARCH_AGENT_ID} 失败(${companyId}): ${e.message}`); + } + } + return isHired() ? `${companyId}:${RESEARCH_AGENT_ID}` : RESEARCH_AGENT_ID; +} + +function buildResearchPrompt({ topic }) { + return [ + '这是一次**任务规划前的快速调研参考**,不是你平时的训练场景任务——', + '不需要按你 `agent.md` 平时的产出契约交 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json` 三件套,', + '也不需要凑够 ≥50 条种子数据。只需要快速查一下下面这件事业界/公开资料里有没有值得参考的方法论,', + '给一段简短总结即可。', + '', + '## 调研主题', + topic || '(调用方没给主题)', + '', + '## 交回方式(只读你回复里最后一个 ```json 代码块)', + '```json', + '{"findings": "…(几句话总结,没查到就写清楚没查到)", "sources": ["url 或来源描述", "…"]}', + '```', + '查不到有用信息就如实说 `findings` 里没有可用结论、`sources` 给 `[]`——不要编。', + ].join('\n'); +} + +async function defaultTriggerDeepResearch({ companyId, task, topic }) { + const AgentManager = require('../src/core/agent-manager'); + const { extractJson } = require('./dream-service'); + const manager = new AgentManager({}, path.resolve(__dirname, '../..')); + const agentName = resolveResearchAgentName(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); + }; + + const res = await manager.callAgent(agentName, buildResearchPrompt({ topic }), { + onAcpEvent, + metadata: { taskId: task && task.id, layer: 'deep-research', companyId }, + }); + + const obj = extractJson(res && res.output, 'object'); + if (!obj || typeof obj.findings !== 'string' || !obj.findings.trim()) return null; + return { + findings: obj.findings.trim(), + sources: Array.isArray(obj.sources) ? obj.sources.filter((s) => typeof s === 'string') : [], + tokens, + }; +} + +module.exports = { + RESEARCH_AGENT_ID, + triggerDeepResearch, + setTriggerDeepResearch, + buildResearchPrompt, + resolveResearchAgentName, +}; diff --git a/server/services/experience-lookup.js b/server/services/experience-lookup.js new file mode 100644 index 0000000..cef4feb --- /dev/null +++ b/server/services/experience-lookup.js @@ -0,0 +1,84 @@ +/** + * experience-lookup —— 查经验库(46 号文档 Phase 2)。 + * + * dream(29-DREAM,见 `dream-service.js`)把跨公司经验沉淀到本体根 `memory/experience.md`, + * 但只是**单向写**——没有任何可查询的索引。这个模块补上"查"这一半,给 subtask-planning 用: + * 关键词命中就当"经验库有答案",命中为零就是"查不到"(真实信号,不是异常,早期系统 dream + * 还没攒够料时这是常态)。 + * + * 查询范围收窄到**这家公司已雇佣的 agent**——他们才是真正会去执行这条 task 的角色, + * 比空扫全系统本体贴题,也比"历史同类 goal 检索"(本轮不做,需要语义检索基础设施)便宜。 + * 打分用最朴素的关键词重叠计数,不引入任何依赖——"查不到"本身就是有效信号,没必要在 + * 一个准确度上限很低的检索层上堆复杂度。 + */ +const fs = require('fs'); +const path = require('path'); +const locator = require('./agent-locator'); +const { CROSS_COMPANY_MEMORY } = require('./dream-service'); + +const EXPERIENCE_FILE = 'experience.md'; + +/** + * 切词。中文没有空格分词,不引入分词依赖(也没这个必要——"查不到"本身就是有效信号), + * 用最朴素的字符双字滑窗(bigram)近似:把整句中文当一个词只会让任何两句话的重叠率 + * 趋近于零,bigram 至少能在"碰撞检测"vs"碰撞检测踩过的坑"这种真实重叠场景下命中。 + * 拉丁字母/数字按非字母数字切分,整段当一个词。单字符(不管中英文)噪音大于信号,丢掉。 + */ +function tokenize(text) { + const raw = String(text || '').toLowerCase(); + const tokens = new Set(); + for (const w of raw.split(/[^a-z0-9]+/)) { + if (w.length > 1) tokens.add(w); + } + for (const run of raw.match(/[一-龥]+/g) || []) { + for (let i = 0; i < run.length - 1; i += 1) tokens.add(run.slice(i, i + 2)); + } + return Array.from(tokens); +} + +/** + * 读一个本体的 `experience.md`,按 `### 标题` 分块(与 `dream-service.auditDistilled` + * 同一种切法——两处都在解析 `writeDistilled` 落的同一份格式,切法不能各写一套)。 + */ +function readExperienceBlocks(agentId) { + const file = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, EXPERIENCE_FILE); + let text; + try { text = fs.readFileSync(file, 'utf-8'); } catch { return []; } + return text.split(/\n### /).slice(1).map((block) => { + const lines = block.split('\n'); + const title = lines[0].trim(); + const body = lines.slice(1).join('\n').replace(/^> 提炼于.*$/gm, '').trim(); + return { agentId, title, body }; + }).filter((b) => b.title); +} + +/** + * @param {{companyId: string, task: object, limit?: number}} ctx + * @returns {{matches: Array<{agentId: string, title: string, body: string, score: number}>, + * queriedAgents: string[]}} + */ +function queryExperience({ companyId, task, limit = 5 } = {}) { + const queryTokens = tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`); + if (!companyId || !queryTokens.length) return { matches: [], queriedAgents: [] }; + + let agentIds = []; + try { + const { listCompanyAgents } = require('./company-service'); + agentIds = [...new Set(listCompanyAgents(companyId).map((r) => r.system_agent_id))]; + } catch { + return { matches: [], queriedAgents: [] }; + } + + const matches = []; + for (const agentId of agentIds) { + for (const block of readExperienceBlocks(agentId)) { + const blockTokens = new Set(tokenize(`${block.title} ${block.body}`)); + const score = queryTokens.reduce((n, w) => n + (blockTokens.has(w) ? 1 : 0), 0); + if (score > 0) matches.push({ ...block, score }); + } + } + matches.sort((a, b) => b.score - a.score); + return { matches: matches.slice(0, limit), queriedAgents: agentIds }; +} + +module.exports = { queryExperience, tokenize, readExperienceBlocks }; diff --git a/server/services/subtask-planning.js b/server/services/subtask-planning.js index 36b6fb7..5bbc98a 100644 --- a/server/services/subtask-planning.js +++ b/server/services/subtask-planning.js @@ -18,6 +18,13 @@ * brd/prd/sdd 各节点、乃至后续 org-coder)的「上游输入」提示词段落都会自动带上它, * 不用为 org-planner 单独写一段 prompt-builder 注入逻辑(46 号文档「与 org-planner 的 * 衔接点」一节最初设想的软注入方案,改走这条更省事、且是引擎已验证在用的现成机制)。 + * + * ── Phase 2(46 号文档):接上经验库检索 + 按需 deep research ────────────────── + * 第一轮调用前先查 `experience-lookup.queryExperience`(dream 沉淀的跨公司经验)拼进提示词; + * 查不到经验,或 agent 自评 `confidence:"low"`,就再调 `deep-research.triggerDeepResearch` + * (默认关闭,见该文件头注释),拿到调研结果就带着它做第二轮调用取代第一轮结果。 + * `reasoning_source` 由服务端根据这两轮实际发生了什么算出来,不是 agent 自报的—— + * agent 唯一自报的判断是 `confidence`,用来决定要不要触发调研,不直接决定 `reasoning_source`。 */ const path = require('path'); @@ -45,6 +52,23 @@ function setPlanSubtasks(fn) { } /** + * 更细粒度的注入点:只换掉 `defaultPlanSubtasks` 内部真正起 CLI 的那一层 + * (写法仿 `dream-service.setDreamAgentCaller`),不像 `setPlanSubtasks` 那样把整段 + * "查经验库 → 判断要不要调研 → 两轮调用"编排逻辑一起换掉。Phase 2 加的正是这段编排逻辑, + * 留这个更细的缝才测得到它本身的分支,而不是只测"调用方传回什么就原样落盘"。 + */ +let agentCaller = null; +/** 替换 agent 调用层(传 null 回真实现)。只给测试用 */ +function setSubtaskAgentCaller(fn) { agentCaller = fn; } + +async function callSubtaskPlanningAgent(agentName, prompt, opts) { + if (agentCaller) return agentCaller(agentName, prompt, opts); + const AgentManager = require('../src/core/agent-manager'); + const manager = new AgentManager({}, REPO_ROOT); + return manager.callAgent(agentName, prompt, opts); +} + +/** * 当前生效的规划实现。调用方一律走这里。 * * **踩过的坑,写在这里免得下次重犯**:`task-planning`/`workgraph-planning` 那一整套 @@ -66,7 +90,9 @@ function setPlanSubtasks(fn) { function planSubtasks(ctx) { if (process.env.VITEST && !isInjected) { console.log('[SubtaskPlanning] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)'); - return Promise.resolve({ steps: [], tokens: 0 }); + return Promise.resolve({ + steps: [], tokens: 0, reasoningSource: 'model_only', experienceRefs: [], researchUsed: false, + }); } return planSubtasksImpl(ctx); } @@ -76,15 +102,15 @@ function planSubtasks(ctx) { // ──────────────────────────────────────────────────────────────── /** - * 调 subtask-planning 拆一次 task 的步骤。单轮调用,没有重试。 + * 调 subtask-planning 拆一次 task 的步骤。先查经验库,再按需触发一次 deep research + * (Phase 2,46 号文档),最多两轮 agent 调用,没有重试。 * * @param {{companyId: string, task: object}} ctx task 是 tasks 表的整行 - * @returns {Promise<{steps: object[], tokens: number}>} + * @returns {Promise<{steps: object[], tokens: number, reasoningSource: string, + * experienceRefs: string[], researchUsed: boolean}>} * steps 形如 `[{seq, title, why, depends_on_hint: [seq…]}]`,seq 恒为 1..N 连续(重新编号过) */ async function defaultPlanSubtasks(ctx) { - const AgentManager = require('../src/core/agent-manager'); - const manager = new AgentManager({}, REPO_ROOT); const agentName = resolvePlanningAgentName(ctx.companyId); let tokens = 0; @@ -93,22 +119,50 @@ async function defaultPlanSubtasks(ctx) { const u = (ev.params && ev.params.usage) || null; if (u) tokens += (u.input || 0) + (u.output || 0); }; + const callOnce = async (prompt) => { + const res = await callSubtaskPlanningAgent(agentName, prompt, { + onAcpEvent, + metadata: { + // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId + // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。 + taskId: ctx.task.id, + layer: 'subtask-plan', + goalId: ctx.task.goal_id, + companyId: ctx.companyId, + }, + }); + return res && res.output; + }; - const prompt = buildSubtaskPlanningPrompt(ctx); - const res = await manager.callAgent(agentName, prompt, { - onAcpEvent, - metadata: { - // taskId 决定 CLI 的 cwd。这里就是真的那条 task 的 id(不像 task-planning 借用 goalId - // 顶替——subtask-planning 天然运行在一条具体 task 之下,不用借)。 - taskId: ctx.task.id, - layer: 'subtask-plan', - goalId: ctx.task.goal_id, - companyId: ctx.companyId, - }, - }); + const { queryExperience } = require('./experience-lookup'); + const exp = queryExperience({ companyId: ctx.companyId, task: ctx.task }); + const experienceRefs = exp.matches.map((m) => `${m.agentId}: ${m.title}`); + + const round1Output = await callOnce(buildSubtaskPlanningPrompt(ctx, { experience: exp.matches })); + let steps = normalizeSteps(extractPlannedSteps(round1Output)); + const confidence = extractConfidence(round1Output); + + let reasoningSource = exp.matches.length ? 'memory' : 'model_only'; + let researchUsed = false; + + const needResearch = exp.matches.length === 0 || confidence === 'low'; + if (needResearch) { + const { triggerDeepResearch } = require('./deep-research'); + const topic = [ctx.task.title, ctx.task.description].filter(Boolean).join(' — '); + const research = await triggerDeepResearch({ companyId: ctx.companyId, task: ctx.task, topic }); + if (research) { + tokens += research.tokens || 0; + const round2Output = await callOnce(buildSubtaskPlanningPrompt(ctx, { experience: exp.matches, research })); + const round2Steps = normalizeSteps(extractPlannedSteps(round2Output)); + if (round2Steps.length) { + steps = round2Steps; + reasoningSource = 'deep_research'; + researchUsed = true; + } + } + } - const rawSteps = extractPlannedSteps(res && res.output); - return { steps: normalizeSteps(rawSteps), tokens }; + return { steps, tokens, reasoningSource, experienceRefs, researchUsed }; } /** @@ -133,9 +187,17 @@ function resolvePlanningAgentName(companyId) { // 提示词 // ──────────────────────────────────────────────────────────────── -function buildSubtaskPlanningPrompt(ctx) { +/** + * @param {{companyId: string, task: object}} ctx + * @param {{experience?: Array<{agentId:string,title:string,body:string}>, + * research?: {findings:string, sources?:string[]} | null}} [extra] + * `experience`:`experience-lookup.queryExperience` 的命中结果(Phase 2)。 + * `research`:`deep-research.triggerDeepResearch` 的结果,只在触发过调研时传入(第二轮)。 + */ +function buildSubtaskPlanningPrompt(ctx, extra = {}) { const t = ctx.task || {}; - return [ + const { experience = [], research = null } = extra; + const lines = [ `你正在为公司 **${ctx.companyId}** 的一条 task 想"怎么做"的步骤分解。`, '', '## task 描述(要拆步骤的对象,task-planning 已经切好的一条边界)', @@ -144,18 +206,35 @@ function buildSubtaskPlanningPrompt(ctx) { `- taskId:${t.id}(只读,不要写进你的产出)`, `- 标题:${t.title || '(无)'}`, '', + ]; + if (experience.length) { + lines.push('## 已知相关经验(供参考,不强制采纳,来自其他公司同角色沉淀下来的经验)'); + for (const e of experience) lines.push(`- [来自 ${e.agentId}] **${e.title}** —— ${e.body}`); + lines.push(''); + } + if (research) { + lines.push('## 调研参考(系统判断需要调研后跑的一次快速调研,供参考,不强制采纳)'); + lines.push(research.findings); + if (research.sources && research.sources.length) lines.push(`来源:${research.sources.join('、')}`); + lines.push(''); + } + lines.push( '## 交回方式(引擎只读你回复里最后一个 ```json 代码块)', '```json', - '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}]}', + '{"steps":[{"seq":1,"title":"…","why":"…","depends_on_hint":[]}],"confidence":"high"}', '```', `- \`seq\`:1..N 连续整数、与数组顺序一致;条数上限 **${MAX_STEPS}**,一步也是合法答案。`, '- `depends_on_hint`:只能写本次产出的 `seq` 数字(1-based),没有就写 `[]`——它是提示,' + '不是硬依赖,没有任何机制会强制执行它。', - '- 只输出这三个字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。', + '- `confidence`:`"high"` 或 `"low"`——你能看到的信息(task 描述 + 上面若列出的经验/调研参考)' + + '不足以让你对分解有把握时选 `"low"`;缺经验/调研参考不必然是 `"low"`,常规任务模型自身规划' + + '能力足够时正常给 `"high"`。`"low"` 会让系统再帮你查一轮资料,重新问你一次。', + '- 只输出这两个顶层字段。想法写在 JSON 代码块**前面**的正文里,代码块之后不要再写任何字。', '', '你的产出会原样附进 org-planner 的任务提示词,当作参考。它没有义务采纳,' - + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。', - ].join('\n'); + + '这里没有写入前校验、没有重试轮次——唯一的失败模式是列得没用,不是格式错。' + ); + return lines.join('\n'); } // ──────────────────────────────────────────────────────────────── @@ -176,6 +255,18 @@ function extractPlannedSteps(output) { return []; } +/** 从回复里取 `confidence` 字段(`"high"`/`"low"`)。取不到就是 null——按"未知"处理,不强制触发调研 */ +function extractConfidence(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 (doc && (doc.confidence === 'high' || doc.confidence === 'low')) return doc.confidence; + } + return null; +} + function jsonCandidates(text) { const out = []; const fence = /```(?:json|jsonc)?\s*([\s\S]*?)```/g; @@ -258,7 +349,9 @@ function normalizeSteps(raw) { async function triggerSubtaskPlanning({ run, task }) { try { if (!run || !task) return; - const { steps, tokens } = await planSubtasks({ companyId: run.company_id, task }); + const { + steps, tokens, reasoningSource, experienceRefs, researchUsed, + } = await planSubtasks({ companyId: run.company_id, task }); if (!steps.length) { console.log(`[SubtaskPlanning] task ${task.id} 没有可用的步骤分解(0 条),跳过写入`); return; @@ -272,7 +365,16 @@ async function triggerSubtaskPlanning({ run, task }) { const file = path.join(artifactsDir, 'subtask-plan.json'); fs.writeFileSync( file, - JSON.stringify({ steps, generated_by: PLANNING_AGENT_ID, task_id: task.id }, null, 2), + JSON.stringify({ + steps, + generated_by: PLANNING_AGENT_ID, + task_id: task.id, + // Phase 2(46 号文档):这条分解到底是"模型自己想的"/"查到经验库"/"触发了调研", + // 由服务端根据实际发生了什么算出来(见 defaultPlanSubtasks),不是 agent 自报的。 + reasoning_source: reasoningSource || 'model_only', + experience_refs: experienceRefs || [], + research_used: !!researchUsed, + }, null, 2), 'utf-8' ); @@ -297,9 +399,14 @@ module.exports = { MAX_STEPS, planSubtasks, setPlanSubtasks, + setSubtaskAgentCaller, triggerSubtaskPlanning, - // 下面三个导出是给测试用的 + // 下面几个导出是给测试用的。`defaultPlanSubtasks` 直接调用会绕开 `planSubtasks()` + // 的 VITEST 短路——测试这条分支编排逻辑必须搭配 `setSubtaskAgentCaller` 注入, + // 不要在没注入的情况下直接调用它(会真的起 CLI)。 extractPlannedSteps, + extractConfidence, normalizeSteps, buildSubtaskPlanningPrompt, + defaultPlanSubtasks, }; diff --git a/server/tests/unit/deep-research.test.js b/server/tests/unit/deep-research.test.js new file mode 100644 index 0000000..953e0ef --- /dev/null +++ b/server/tests/unit/deep-research.test.js @@ -0,0 +1,80 @@ +/** + * 46 号文档 Phase 2:deep-research 按需触发 org-hunt 的轻量调研钩子。 + * + * 最重要的一条是"默认关闭"这条安全阀——`ENABLE_DEEP_RESEARCH` 不为 `'1'` 时, + * 连注入的假实现都不应该被调用到,直接短路返回 null(避免真事故:Phase 1 就因为 + * 一个新触发点疏于隔离,一次单测跑出 44 个残留 CLI 子进程)。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + triggerDeepResearch, setTriggerDeepResearch, buildResearchPrompt, +} from '../../services/deep-research.js'; + +describe('deep-research · triggerDeepResearch', () => { + const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH; + + beforeEach(() => { + setTriggerDeepResearch(null); + }); + afterEach(() => { + setTriggerDeepResearch(null); + if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH; + else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE; + }); + + it('ENABLE_DEEP_RESEARCH 未开启时直接返回 null,不调用任何实现(哪怕注入了假实现)', async () => { + delete process.env.ENABLE_DEEP_RESEARCH; + let called = false; + setTriggerDeepResearch(async () => { called = true; return { findings: 'x', sources: [], tokens: 1 }; }); + + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: '贪吃蛇怎么做' }); + expect(out).toBeNull(); + expect(called).toBe(false); + }); + + it('开启后走注入实现,返回其结果', async () => { + process.env.ENABLE_DEEP_RESEARCH = '1'; + setTriggerDeepResearch(async (ctx) => { + expect(ctx.topic).toBe('贪吃蛇怎么做'); + return { findings: '业界通常先定义棋盘再做碰撞检测', sources: ['https://example.com'], tokens: 123 }; + }); + + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: '贪吃蛇怎么做' }); + expect(out).toEqual({ + findings: '业界通常先定义棋盘再做碰撞检测', + sources: ['https://example.com'], + tokens: 123, + }); + }); + + it('开启后注入实现抛错:吞掉,返回 null,不向调用方传播', async () => { + process.env.ENABLE_DEEP_RESEARCH = '1'; + setTriggerDeepResearch(async () => { throw new Error('CLI 起不来'); }); + + await expect( + triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: 't' }) + ).resolves.toBeNull(); + }); + + it('VITEST 环境且未注入时即使开启开关也跳过真实调用', async () => { + process.env.ENABLE_DEEP_RESEARCH = '1'; + // 不调用 setTriggerDeepResearch,保持默认实现——process.env.VITEST 由 vitest 自动置位 + const out = await triggerDeepResearch({ companyId: 'co-x', task: { id: 'T-1' }, topic: 't' }); + expect(out).toBeNull(); + }); +}); + +describe('deep-research · buildResearchPrompt', () => { + it('提示词包含调研主题、交回格式,明确说明不套用训练场景三件套契约', () => { + const prompt = buildResearchPrompt({ topic: '贪吃蛇怎么做' }); + expect(prompt).toContain('贪吃蛇怎么做'); + expect(prompt).toContain('findings'); + expect(prompt).toContain('sources'); + expect(prompt).toContain('不需要'); + }); + + it('主题缺失时给出明确占位', () => { + const prompt = buildResearchPrompt({}); + expect(prompt).toContain('调用方没给主题'); + }); +}); diff --git a/server/tests/unit/experience-lookup.test.js b/server/tests/unit/experience-lookup.test.js new file mode 100644 index 0000000..8b6321f --- /dev/null +++ b/server/tests/unit/experience-lookup.test.js @@ -0,0 +1,119 @@ +/** + * 46 号文档 Phase 2:experience-lookup 查经验库。 + * + * 夹具模式照抄 `dream-service.test.js`:真实磁盘下建一个 `zz-explookup-*` 测试 agentId, + * 写 `memory/experience.md`(dream `writeDistilled` 落的同一种格式),`company_agents` + * 表插入雇佣关系——测的是"读得到 dream 已经写好的经验"这一半,不是 dream 本身。 + */ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-explookup-')); +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); + +const ROOT = path.resolve(__dirname, '../../..'); +const AGENT = 'zz-explookup-body'; +const OTHER_AGENT = 'zz-explookup-other'; +const COMPANY = 'zz-explookup-co'; +const BODY = path.join(ROOT, 'system', 'agents', AGENT); +const OTHER_BODY = path.join(ROOT, 'system', 'agents', OTHER_AGENT); + +const { queryExperience, tokenize, readExperienceBlocks } = require('../../services/experience-lookup'); +const wdb = require('../../services/work-graph-db'); + +function writeExperience(agentId, blocks) { + const dir = path.join(ROOT, 'system', 'agents', agentId, 'memory'); + fs.mkdirSync(dir, { recursive: true }); + const body = blocks.map((b) => `### ${b.title}\n\n${b.body}\n\n> 提炼于 2026-09-01;原料:x:memory.md#1\n`).join('\n'); + fs.writeFileSync(path.join(dir, 'experience.md'), `# 跨公司经验(dream 提炼)\n\n${body}`, 'utf-8'); +} + +function hire(companyId, agentId) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + db.prepare(`INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) + .run(agentId, agentId, `system/agents/${agentId}`, now, now); + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now); +} + +describe('experience-lookup · queryExperience', () => { + beforeEach(() => { + fs.rmSync(BODY, { recursive: true, force: true }); + fs.rmSync(OTHER_BODY, { recursive: true, force: true }); + }); + + afterAll(() => { + fs.rmSync(BODY, { recursive: true, force: true }); + fs.rmSync(OTHER_BODY, { recursive: true, force: true }); + try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {} + }); + + it('命中相关经验,按关键词重叠打分排序', () => { + hire(COMPANY, AGENT); + writeExperience(AGENT, [ + { title: '贪吃蛇碰撞检测踩过的坑', body: '棋盘边界判断要在移动前做,不然会穿墙' }, + { title: '完全不相关的经验', body: '发工资流程走财务系统,不归这里管' }, + ]); + + const { matches, queriedAgents } = queryExperience({ + companyId: COMPANY, + task: { title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' }, + }); + + expect(queriedAgents).toContain(AGENT); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].title).toBe('贪吃蛇碰撞检测踩过的坑'); + expect(matches[0].agentId).toBe(AGENT); + expect(matches[0].score).toBeGreaterThan(0); + // 不相关的那条不该排在命中结果里靠前的位置(分数应明显更低或不命中) + const irrelevant = matches.find((m) => m.title === '完全不相关的经验'); + if (irrelevant) expect(irrelevant.score).toBeLessThan(matches[0].score); + }); + + it('没有命中时返回空数组,不是异常', () => { + hire(COMPANY, AGENT); + writeExperience(AGENT, [{ title: '完全不相关的经验', body: '发工资流程走财务系统' }]); + + const { matches } = queryExperience({ + companyId: COMPANY, + task: { title: '实现贪吃蛇', description: '碰撞检测' }, + }); + expect(matches).toEqual([]); + }); + + it('agent 没有 experience.md 文件时不报错,视为该 agent 无命中', () => { + hire(COMPANY, AGENT); // 雇了但从没被 dream 写过经验 + expect(() => queryExperience({ + companyId: COMPANY, + task: { title: '实现贪吃蛇', description: '碰撞检测' }, + })).not.toThrow(); + expect(readExperienceBlocks(AGENT)).toEqual([]); + }); + + it('公司没有雇任何 agent 时返回空数组', () => { + const { matches, queriedAgents } = queryExperience({ + companyId: 'zz-explookup-empty-co', + task: { title: '实现贪吃蛇', description: '碰撞检测' }, + }); + expect(matches).toEqual([]); + expect(queriedAgents).toEqual([]); + }); + + it('task 标题/描述都为空时返回空数组,不做无意义的全量扫描', () => { + hire(COMPANY, AGENT); + const { matches } = queryExperience({ companyId: COMPANY, task: {} }); + expect(matches).toEqual([]); + }); + + it('tokenize:拉丁词整段切,中文用双字滑窗(无分词依赖),单字符丢弃', () => { + const tokens = tokenize('贪吃蛇 game 的 A 实现'); + expect(tokens).toEqual(expect.arrayContaining(['贪吃', '吃蛇', 'game', '实现'])); + expect(tokens).not.toContain('a'); + expect(tokens).not.toContain('的'); + }); +}); diff --git a/server/tests/unit/subtask-planning-materialize.test.js b/server/tests/unit/subtask-planning-materialize.test.js index 49ff98b..21a14e7 100644 --- a/server/tests/unit/subtask-planning-materialize.test.js +++ b/server/tests/unit/subtask-planning-materialize.test.js @@ -7,11 +7,14 @@ * 隔离手法同 `graph-boundary.test.js`/`task-decomposition-materialize.test.js`: * 先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,agent 调用全走注入,不起 Claude CLI。 */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + describe, it, expect, beforeAll, afterAll, afterEach, +} from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; +const ROOT = path.resolve(__dirname, '../../..'); const TEST_DB_PATH = '/tmp/test-subtask-planning-materialize.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; const MODULES = [ @@ -176,4 +179,198 @@ describe('46 号文档 Phase 1:subtask-plan.json 共享区落地 + 任务级å… subtaskPlanning.setPlanSubtasks(null); } }); + + it('reasoning_source / experience_refs / research_used 原样落盘(Phase 2 新增字段,向后兼容)', async () => { + const companyId = 'co-subplan-5'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); + + subtaskPlanning.setPlanSubtasks(async () => ({ + steps: [{ seq: 1, title: '结合调研后的分解', why: '', depends_on_hint: [] }], + tokens: 88, + reasoningSource: 'deep_research', + experienceRefs: ['impl: 贪吃蛇碰撞检测踩过的坑'], + researchUsed: true, + })); + try { + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); + } finally { + subtaskPlanning.setPlanSubtasks(null); + } + + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json'); + const content = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(content.reasoning_source).toBe('deep_research'); + expect(content.experience_refs).toEqual(['impl: 贪吃蛇碰撞检测踩过的坑']); + expect(content.research_used).toBe(true); + }); + + it('注入的 planSubtasks 没带 Phase 2 字段时兜底为 model_only/空数组/false(向后兼容旧注入桩)', async () => { + const companyId = 'co-subplan-6'; + const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' }); + const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' }); + const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id }); + + subtaskPlanning.setPlanSubtasks(async () => ({ steps: [{ seq: 1, title: '一步' }], tokens: 1 })); + try { + await subtaskPlanning.triggerSubtaskPlanning({ run, task }); + } finally { + subtaskPlanning.setPlanSubtasks(null); + } + + const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json'); + const content = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(content.reasoning_source).toBe('model_only'); + expect(content.experience_refs).toEqual([]); + expect(content.research_used).toBe(false); + }); +}); + +describe('46 号文档 Phase 2:defaultPlanSubtasks 编排(经验检索 + 按需 deep research)', () => { + let wdb; + let subtaskPlanning; + let deepResearch; + const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH; + const EXP_AGENT = 'zz-subplan-p2-impl'; + const EXP_AGENT_DIR = path.join(ROOT, 'system', 'agents', EXP_AGENT); + + beforeAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + process.env.GRAPH_DB_PATH = TEST_DB_PATH; + for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) { + try { delete require.cache[require.resolve(m)]; } catch {} + } + wdb = require('../../services/work-graph-db'); + subtaskPlanning = require('../../services/subtask-planning'); + deepResearch = require('../../services/deep-research'); + }); + + afterAll(() => { + for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } + delete process.env.GRAPH_DB_PATH; + fs.rmSync(EXP_AGENT_DIR, { recursive: true, force: true }); + if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH; + else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE; + for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) { + try { delete require.cache[require.resolve(m)]; } catch {} + } + }); + + afterEach(() => { + subtaskPlanning.setSubtaskAgentCaller(null); + deepResearch.setTriggerDeepResearch(null); + }); + + function hireInto(companyId, agentId) { + const db = wdb.getDb(); + const now = new Date().toISOString(); + db.prepare(`INSERT OR IGNORE INTO system_agents + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) + .run(agentId, agentId, `system/agents/${agentId}`, now, now); + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) + VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now); + } + + it('经验库命中 + agent 给 high confidence → reasoning_source=memory,只调一轮,不触发调研', async () => { + const companyId = 'co-subplan-p2-memory'; + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); + hireInto(companyId, EXP_AGENT); + const expDir = path.join(EXP_AGENT_DIR, 'memory'); + fs.mkdirSync(expDir, { recursive: true }); + fs.writeFileSync( + path.join(expDir, 'experience.md'), + '# 跨公司经验(dream 提炼)\n\n### 贪吃蛇碰撞检测踩过的坑\n\n棋盘边界判断要在移动前做,不然会穿墙\n\n> 提炼于 2026-09-01;原料:x\n', + 'utf-8' + ); + + let calls = 0; + subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => { + calls += 1; + expect(prompt).toContain('已知相关经验'); + return { output: '```json\n{"steps":[{"seq":1,"title":"定义棋盘"}],"confidence":"high"}\n```' }; + }); + + const task = { id: 'T-p2-memory', title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' }; + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); + + expect(calls).toBe(1); + expect(result.reasoningSource).toBe('memory'); + expect(result.researchUsed).toBe(false); + expect(result.experienceRefs.length).toBeGreaterThan(0); + expect(result.steps[0].title).toBe('定义棋盘'); + }); + + it('经验库无命中 + deep research 关闭 → reasoning_source=model_only,只调一轮', async () => { + delete process.env.ENABLE_DEEP_RESEARCH; + const companyId = 'co-subplan-p2-modelonly'; + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); + + let calls = 0; + subtaskPlanning.setSubtaskAgentCaller(async () => { + calls += 1; + return { output: '```json\n{"steps":[{"seq":1,"title":"随便一步"}],"confidence":"high"}\n```' }; + }); + + const task = { id: 'T-p2-modelonly', title: '发一封周报', description: '把周报发给团队' }; + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); + + expect(calls).toBe(1); + expect(result.reasoningSource).toBe('model_only'); + expect(result.researchUsed).toBe(false); + expect(result.experienceRefs).toEqual([]); + }); + + it('经验库无命中 + deep research 开启且返回结果 → reasoning_source=deep_research,两轮调用', async () => { + process.env.ENABLE_DEEP_RESEARCH = '1'; + const companyId = 'co-subplan-p2-research'; + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); + + deepResearch.setTriggerDeepResearch(async () => ({ + findings: '业界通常先定义棋盘再做碰撞检测', sources: ['https://example.com'], tokens: 50, + })); + + let calls = 0; + subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => { + calls += 1; + if (calls === 2) expect(prompt).toContain('调研参考'); + return { + output: calls === 1 + ? '```json\n{"steps":[{"seq":1,"title":"第一轮草稿"}],"confidence":"low"}\n```' + : '```json\n{"steps":[{"seq":1,"title":"结合调研后的分解"}],"confidence":"high"}\n```', + }; + }); + + const task = { id: 'T-p2-research', title: '实现贪吃蛇', description: '一个全新玩法,系统里从没做过' }; + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); + + expect(calls).toBe(2); + expect(result.reasoningSource).toBe('deep_research'); + expect(result.researchUsed).toBe(true); + expect(result.steps[0].title).toBe('结合调研后的分解'); + expect(result.tokens).toBeGreaterThanOrEqual(50); + }); + + it('deep research 开启但没查到有用结果(返回 null)→ 仍是 model_only,不算失败', async () => { + process.env.ENABLE_DEEP_RESEARCH = '1'; + const companyId = 'co-subplan-p2-research-empty'; + hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID); + + deepResearch.setTriggerDeepResearch(async () => null); + + let calls = 0; + subtaskPlanning.setSubtaskAgentCaller(async () => { + calls += 1; + return { output: '```json\n{"steps":[{"seq":1,"title":"没有调研也照样给一版"}],"confidence":"low"}\n```' }; + }); + + const task = { id: 'T-p2-research-empty', title: '一个冷门任务', description: '系统里第一次遇到' }; + const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task }); + + expect(calls).toBe(1); // 调研没结果,不会有第二轮 + expect(result.reasoningSource).toBe('model_only'); + expect(result.researchUsed).toBe(false); + expect(result.steps[0].title).toBe('没有调研也照样给一版'); + }); }); diff --git a/server/tests/unit/subtask-planning.test.js b/server/tests/unit/subtask-planning.test.js index c6f06d0..643cdc6 100644 --- a/server/tests/unit/subtask-planning.test.js +++ b/server/tests/unit/subtask-planning.test.js @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { extractPlannedSteps, + extractConfidence, normalizeSteps, buildSubtaskPlanningPrompt, MAX_STEPS, @@ -50,6 +51,21 @@ describe('subtask-planning:extractPlannedSteps', () => { }); }); +describe('subtask-planning:extractConfidence(Phase 2 新增字段)', () => { + it('取到 "high"/"low" 就原样返回', () => { + expect(extractConfidence('```json\n{"steps":[],"confidence":"low"}\n```')).toBe('low'); + expect(extractConfidence('```json\n{"steps":[],"confidence":"high"}\n```')).toBe('high'); + }); + + it('取不到 / 值不合法 / 字段缺失时返回 null,不强行猜', () => { + expect(extractConfidence('```json\n{"steps":[]}\n```')).toBeNull(); + expect(extractConfidence('```json\n{"steps":[],"confidence":"maybe"}\n```')).toBeNull(); + expect(extractConfidence('我不知道')).toBeNull(); + expect(extractConfidence('')).toBeNull(); + expect(extractConfidence(null)).toBeNull(); + }); +}); + describe('subtask-planning:normalizeSteps(宽松归一化,单条不合法就丢那一条)', () => { it('正常路径:重新编号为 1..N,depends_on_hint 保留合法引用', () => { const raw = [ @@ -120,4 +136,33 @@ describe('subtask-planning:buildSubtaskPlanningPrompt', () => { const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc' } }); expect(prompt).toContain('调用方没给任务描述'); }); + + it('不传 extra 时不出现"已知相关经验"/"调研参考"小节标题(Phase 1 行为不变)', () => { + const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc', title: 't' } }); + expect(prompt).not.toContain('## 已知相关经验'); + expect(prompt).not.toContain('## 调研参考'); + expect(prompt).toContain('confidence'); + }); + + it('传 experience 命中时拼进"已知相关经验"小节,带来源 agentId', () => { + const prompt = buildSubtaskPlanningPrompt( + { companyId: 'co-x', task: { id: 'T-abc', title: 't' } }, + { experience: [{ agentId: 'impl', title: '踩过的坑', body: '别忘了边界检测' }] } + ); + expect(prompt).toContain('## 已知相关经验'); + expect(prompt).toContain('[来自 impl]'); + expect(prompt).toContain('踩过的坑'); + expect(prompt).toContain('别忘了边界检测'); + expect(prompt).not.toContain('## 调研参考'); + }); + + it('传 research 时拼进"调研参考"小节,带 findings 和 sources', () => { + const prompt = buildSubtaskPlanningPrompt( + { companyId: 'co-x', task: { id: 'T-abc', title: 't' } }, + { research: { findings: '业界通常先定义棋盘', sources: ['https://example.com'] } } + ); + expect(prompt).toContain('## 调研参考'); + expect(prompt).toContain('业界通常先定义棋盘'); + expect(prompt).toContain('https://example.com'); + }); }); diff --git a/system/agents/subtask-planning/agent.md b/system/agents/subtask-planning/agent.md index 4a18481..000286f 100644 --- a/system/agents/subtask-planning/agent.md +++ b/system/agents/subtask-planning/agent.md @@ -3,14 +3,15 @@ ## ⚡ 硬约束(这 8 条排在最前面,因为它们是判「这一轮算不算数」的全部依据) 1. **你的产出是一个 JSON 对象,不是别的任何东西。** 形状固定: - `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}]}` + `{"steps": [{"seq": 1, "title": "…", "why": "…", "depends_on_hint": []}], "confidence": "high"}` 2. **`depends_on_hint` 里只能出现本次产出的 `seq` 数字**(1-based),或者留空数组 `[]`。它是**提示**,不是硬依赖——下游读它只是为了理解你建议的先后关系,不会拿它去卡任何东西。 3. **`seq` 必须是 1..N 连续整数、不重复、按你数组的顺序排。** 4. **条数上限 12。** 你切的是"这件事怎么做"的步骤,不是"这件事有几个可以独立验收的模块"(那是 `task-planning` 的活,已经切过一轮,你面对的是它切出来的**一条** task)。超过 12 条通常说明你在写实现细节,不是写步骤。 -5. **只输出这三个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 +5. **只输出这四个字段。** 不许产出 `id` / `status` / `assignee` / `budget` —— 那些不归你管。 6. **JSON 代码块必须是你回复的最后一段。** 想法写在它**前面**的正文里。 7. **一步也是答案。** 这条 task 本身已经很小、拆了反而啰嗦时,输出**恰好 1 条**。这不是失败。 8. **不许发明需求。** task 描述里没有的东西,一个字都不许出现在你的步骤里——你是在梳理"怎么做",不是在扩写"做什么"。 +9. **`confidence` 如实给 `"high"` 或 `"low"`。** 这是你对"这份分解靠不靠谱"的自评:看到的信息(task 描述 + 提示词里若列出的相关经验/调研参考)不足以让你有把握时给 `"low"`——系统会据此帮你多查一轮资料再问你一次。缺经验/调研参考不等于要给 `"low"`:常规、你自己判断力就够用的任务,正常给 `"high"`。往低了报没有代价,往高了虚报会让一份没底的分解被当成有依据的参考传下去。 > **你的产出是建议,不是指令。** 没有任何机制强制 `org-planner` 遵照你写的步骤走——它是一份**参考**,帮下家更快进入状态,不是一道验收闸门,也不会卡住任何流程。想清楚"这样列出来对下家有没有用",比想"怎样才能不被打回"更重要——你没有重试轮次、没有 schema 校验会打回你,唯一的失败模式是列得没用。 @@ -39,3 +40,7 @@ 不要把"调研 / 设计 / 开发 / 测试"这种流水线阶段当成步骤——那是执行这条 task 的公司流水线本来就会走的路,不是你要梳理的东西。你梳理的是**这件事本身**内部的逻辑步骤(比如"实现贪吃蛇":先定义游戏状态和棋盘 → 再做移动和碰撞检测 → 再做计分和食物生成 → 最后做开始/结束/重来的交互),这跟"先调研再开发再测试"是两条完全不同的轴。 拿不准时,宁可列得粗一点、少一点,也不要为了凑步骤数硬拆——一份没用的分解比没有分解更浪费下家的阅读时间。 + +## 你可能会看到两种提示词(Phase 2,46 号文档) + +系统会先查一遍经验库(其他公司同角色沉淀下来的经验),查到相关的会拼进提示词的"已知相关经验"小节;查不到,或者你上一轮给了 `confidence:"low"`,系统会去做一次快速调研,再把结果拼进"调研参考"小节问你一次。这两段都只是**参考**,不是要你照抄——你的判断力仍然是主线,经验/调研信息不一致或跟这条 task 明显不搭时,按你自己的判断来,不强行采纳。