Commit a0d8410e395e8f85d38037535cc7775256f5a2b5
1 parent
4075dcf1
feat(dream): 步骤 2/5 与步骤 3 的骨架 —— 公司清单查表、空转保护、去敏闸门、可追溯
## 步骤 2:「有哪些公司」只认 company_agents 表
system/agents/{aid}/{cid}/ 是**约定不是校验**。实查 96 个二级目录里混着
train/ workspace/ 和带时间戳的测试残留(tmp-w7-*-1786986291739-*)。
拿目录枚举公司 = 把测试夹具当经验原料喂给提炼 agent。
改成查表,另补保留段 _global(全局 agent 的运行态落点,没人「雇」它所以不在表里)。
**目录从此只用来定位文件,不用来枚举公司。**
守卫里真造了 train/ 和 tmp-w7-* 两个目录,断言它们不进原料。
## 空转保护:水位线按「条数」不按 mtime
实查 75 份 memory.md 只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。
不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱。
**判据用条数而不是 mtime**:mtime 会被任何一次无关写入推动(例如别处重写整份
memory.md),条数只在真的多出记录时增长。用 mtime 的话,空转保护会在
「文件被碰过但内容没变」时失效。
## 决策 A 的失效方向写进代码
去敏判定 agent **抛异常也算拒绝**,并把「为什么」写进 rejected[]。
否则现场只看到「少了一条」,查不出是判否还是判炸。
「少沉淀一条经验」与「泄漏一次机密」代价不对称,判据必须偏向贵的那一侧。
## 步骤 5:可追溯
每条原料带 source({公司}:memory.md#{行号}),提炼产物落盘时把 sources
写进 experience.md 的引用行。没有它,dream 的产出无法验证对错 ——
与 FDE 那边「LLM 自己汇报分数」是同一个问题。
## 步骤 3 还差什么(写在文档 §5.3,别当成做完了)
编排 / 空转 / 闸门 / 落盘 / 可追溯都跑通了(6 例守卫),
**缺两次真 agent 调用的默认实现**:提炼 agent 与去敏判定 agent 的本体还不存在。
默认实现现在是一句显式 throw(「尚未接线」)而**不是静默返回空** ——
静默返回空会让调用方以为「跑了但没提炼出东西」,那是本仓库反复出现的失效形态。
也还没写定义文件:writeDistilled 目前只写本体根的 memory/experience.md
(纯经验积累,删掉无副作用)。三个 git 跟踪的定义留给下一步,
definitionWritability() 已能报出「现在能不能写、不能写是为什么」。
全量:144 文件 / 1316 测试全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
3 changed files
with
409 additions
and
4 deletions
docs/todo/system-design/29-dream-design.md
| ... | ... | @@ -210,17 +210,18 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; |
| 210 | 210 | |
| 211 | 211 | ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) |
| 212 | 212 | |
| 213 | -> **进度**:步骤 1(上游记忆写入)与步骤 4(定义文件闸门)已实现;2 / 3 / 5 未开工。 | |
| 213 | +> **进度**:步骤 1、2、4、5 已实现;步骤 3 只落了**骨架 + 注入缝**, | |
| 214 | +> 两次 agent 调用(提炼 / 去敏判定)的**默认实现尚未接线** —— 见 §5.3。 | |
| 214 | 215 | |
| 215 | 216 | 用户定的是两件一起做,不分先后。拆成可验收的步骤: |
| 216 | 217 | |
| 217 | 218 | | 步 | 内容 | 为什么不能省 | |
| 218 | 219 | |---|---|---| |
| 219 | 220 | | 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | |
| 220 | -| 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | | |
| 221 | -| 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 | | |
| 221 | +| 2 | ✅ **「有哪些公司」改查 `company_agents` 表**(§1.3)—— 已完成 | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | | |
| 222 | +| 3 | 🟡 **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写落点 —— **骨架已落,两次 agent 调用未接线** | 主体 | | |
| 222 | 223 | | 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | |
| 223 | -| 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | | |
| 224 | +| 5 | ✅ **可追溯**:每条沉淀记住它是从哪几条记录提炼来的 —— 已完成 | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | | |
| 224 | 225 | |
| 225 | 226 | **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 |
| 226 | 227 | |
| ... | ... | @@ -251,3 +252,36 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; |
| 251 | 252 | 守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 |
| 252 | 253 | 长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 |
| 253 | 254 | 无 toolCall 不写(保住原有行为,别顺手放宽)。 |
| 255 | + | |
| 256 | +### 5.2 步骤 2 / 5 与 3 的骨架(`dream-service.js`) | |
| 257 | + | |
| 258 | +**步骤 2 —— 公司清单只认 `company_agents` 表。** | |
| 259 | +`companiesOf(agentId)` 查表,另把保留段 `_global` 补上(全局 agent 的运行态落在那里, | |
| 260 | +没人「雇」它,所以不在表里)。目录从此**只用来定位文件,不用来枚举公司**。 | |
| 261 | +守卫里造了两个「看起来像公司」的目录(`train/` 与 `tmp-w7-bootstrap-1786986291739-9n2xv`), | |
| 262 | +断言它们不进原料 —— 那正是实查到的两种残留形态。 | |
| 263 | + | |
| 264 | +**空转保护 —— 水位线按「条数」而不是 mtime。** | |
| 265 | +mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md`),条数只在真的多出记录时增长。 | |
| 266 | +拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 | |
| 267 | +没有新原料时 `dreamOnce` 直接返回 `skipped: 'no-new-material'`,**一次 agent 调用都不起**。 | |
| 268 | + | |
| 269 | +**步骤 5 —— 可追溯。** | |
| 270 | +每条原料带 `source`(`{公司}:memory.md#{行号}`),提炼产物落盘时把 `sources` 一并写进 | |
| 271 | +`experience.md` 的引用行。人抽查时能顺着回去看。 | |
| 272 | + | |
| 273 | +**决策 A 的失效方向在代码里是硬的**:判定 agent **抛异常也算拒绝**, | |
| 274 | +并把「为什么」写进 `rejected[]` —— 否则现场只看到「少了一条」,查不出是判否还是判炸。 | |
| 275 | + | |
| 276 | +### 5.3 步骤 3 还差什么(别当成做完了) | |
| 277 | + | |
| 278 | +`setDistiller` / `setScreener` 两个注入缝已经在,`dreamOnce` 的编排、空转保护、 | |
| 279 | +去敏闸门、落盘、可追溯都跑通了(6 例守卫)。**缺的是两次真 agent 调用的默认实现**: | |
| 280 | + | |
| 281 | +- 提炼 agent 与去敏判定 agent 的**本体还不存在**(需要两份 `agent.md` + `soul.md`); | |
| 282 | +- 默认实现现在是一句显式的 throw(「尚未接线」),**不是静默返回空** —— | |
| 283 | + 静默返回空会让调用方以为「跑了但没提炼出东西」,那是本仓库反复出现的失效形态。 | |
| 284 | + | |
| 285 | +**还没写定义文件**:`writeDistilled` 目前只写本体根的 `memory/experience.md` | |
| 286 | +(纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义 | |
| 287 | +留给下一步,`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」。 | ... | ... |
server/services/dream-service.js
0 → 100644
| 1 | +/** | |
| 2 | + * dream —— 经验汇流([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。 | |
| 3 | + * | |
| 4 | + * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验, | |
| 5 | + * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。 | |
| 6 | + * | |
| 7 | + * ── 四条拍板(2026-08-20,§三)────────────────────────────────────── | |
| 8 | + * A 去敏 = 提炼 agent 按写作要求产出 + **独立的判定 agent** 过闸门。 | |
| 9 | + * 失效方向是「少沉淀」不是「泄漏」—— 两种错代价不对称时判据偏向贵的那侧。 | |
| 10 | + * B 落点 = 本体根 `memory/`(跨公司那一份)与 `SKILL.md` 都写; | |
| 11 | + * `soul.md` / `agent.md` 直接落盘,但写前必须过 definition-write-gate。 | |
| 12 | + * C 触发 = 定时每日一次。 | |
| 13 | + * D 顺序 = 上游记忆写入与 dream 一起做。 | |
| 14 | + * | |
| 15 | + * ── 两条贯穿全程的判据 ────────────────────────────────────────────── | |
| 16 | + * **① 「有哪些公司」只认 `company_agents` 表,不扫目录。** | |
| 17 | + * `system/agents/{aid}/{cid}/` 是约定不是校验,实查 96 个二级目录里混着 | |
| 18 | + * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。 | |
| 19 | + * 拿目录枚举公司 = 把它们当原料。目录只用来**定位文件**,不用来**枚举公司**。 | |
| 20 | + * | |
| 21 | + * **② 空转不许起 agent 调用。** | |
| 22 | + * 实查 75 份 memory.md 里只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。 | |
| 23 | + * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱,日志里全是噪声。 | |
| 24 | + * 判据是水位线(`dream.json` 的 `lastRunAt`)与原料的 mtime 比对。 | |
| 25 | + */ | |
| 26 | + | |
| 27 | +const fs = require('fs'); | |
| 28 | +const path = require('path'); | |
| 29 | +const locator = require('./agent-locator'); | |
| 30 | +const { canOverwriteDefinition } = require('./definition-write-gate'); | |
| 31 | + | |
| 32 | +const RAW_SECTION = '## 原始记录(未提炼)'; | |
| 33 | +/** 本体根上的跨公司经验落点(决策 B) */ | |
| 34 | +const CROSS_COMPANY_MEMORY = 'memory'; | |
| 35 | +const STATE_FILE = 'dream.json'; | |
| 36 | + | |
| 37 | +// ── 注入缝:两次 agent 调用都可替换(写法照抄 graph-engine.setAgentExecutor)── | |
| 38 | +let distiller = null; | |
| 39 | +let screener = null; | |
| 40 | +/** 替换「提炼」实现(传 null 回默认实现) */ | |
| 41 | +function setDistiller(fn) { distiller = fn; } | |
| 42 | +/** 替换「去敏判定」实现(传 null 回默认实现) */ | |
| 43 | +function setScreener(fn) { screener = fn; } | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * 这个本体在哪些公司有过运行态?**答案来自 `company_agents` 表**(判据①)。 | |
| 47 | + * | |
| 48 | + * `_global` 单独补上:它是保留段,全局 agent 的运行态落在那里, | |
| 49 | + * 不在 company_agents 里(那张表记的是「公司雇了谁」,全局 agent 没人雇)。 | |
| 50 | + * 算不算「一家公司」在设计里是 ❓,这里的处理是**算原料、但在提炼时标明它是全局态**。 | |
| 51 | + */ | |
| 52 | +function companiesOf(agentId) { | |
| 53 | + const out = []; | |
| 54 | + try { | |
| 55 | + const { listCompanyAgents } = require('./company-service'); | |
| 56 | + for (const row of listCompanyAgents()) { | |
| 57 | + if (row.system_agent_id === agentId || row.role_name === agentId) out.push(row.company_id); | |
| 58 | + } | |
| 59 | + } catch (err) { | |
| 60 | + // 库还没建好不该让 dream 整个挂掉,但**必须让调用方知道这一轮的原料是不全的** | |
| 61 | + console.warn(`[Dream] ⚠️ 读 company_agents 失败,本轮只看 _global: ${err.message}`); | |
| 62 | + } | |
| 63 | + if (fs.existsSync(path.join(locator.bodyDir(agentId), '_global'))) out.push('_global'); | |
| 64 | + return [...new Set(out)]; | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** 一条原料:正文 + 它从哪来(决策 D 的「可追溯」起步) */ | |
| 68 | +function readRawEntries(agentId, companyId) { | |
| 69 | + const md = path.join(locator.companyDir(agentId, companyId), 'memory', 'memory.md'); | |
| 70 | + let text; | |
| 71 | + try { text = fs.readFileSync(md, 'utf-8'); } catch { return []; } | |
| 72 | + const idx = text.indexOf(RAW_SECTION); | |
| 73 | + if (idx < 0) return []; | |
| 74 | + const after = text.slice(idx + RAW_SECTION.length); | |
| 75 | + const section = after.split(/\n## /)[0]; | |
| 76 | + return section | |
| 77 | + .split('\n') | |
| 78 | + .map((l) => l.trim()) | |
| 79 | + .filter((l) => l.startsWith('- ')) | |
| 80 | + .map((l, i) => ({ agentId, companyId, source: `${companyId}:memory.md#${i + 1}`, text: l.slice(2).trim() })); | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** 本体根的 dream 状态(水位线 + 上一轮产出) */ | |
| 84 | +function statePath(agentId) { | |
| 85 | + return path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, STATE_FILE); | |
| 86 | +} | |
| 87 | +function readState(agentId) { | |
| 88 | + try { return JSON.parse(fs.readFileSync(statePath(agentId), 'utf-8')); } catch { return {}; } | |
| 89 | +} | |
| 90 | +function writeState(agentId, state) { | |
| 91 | + const p = statePath(agentId); | |
| 92 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | |
| 93 | + fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * 采集这个本体的全部新原料。 | |
| 98 | + * | |
| 99 | + * **水位线按「条数」而不是 mtime**:mtime 会被任何一次无关写入(例如另一段代码 | |
| 100 | + * 重写整份 memory.md)推动,而条数只在真的多出记录时才增长。 | |
| 101 | + * 拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 | |
| 102 | + */ | |
| 103 | +function collectMaterial(agentId) { | |
| 104 | + const state = readState(agentId); | |
| 105 | + const seen = state.seenCounts || {}; | |
| 106 | + const fresh = []; | |
| 107 | + const counts = {}; | |
| 108 | + for (const cid of companiesOf(agentId)) { | |
| 109 | + const entries = readRawEntries(agentId, cid); | |
| 110 | + counts[cid] = entries.length; | |
| 111 | + const from = seen[cid] || 0; | |
| 112 | + if (entries.length > from) fresh.push(...entries.slice(from)); | |
| 113 | + } | |
| 114 | + return { fresh, counts }; | |
| 115 | +} | |
| 116 | + | |
| 117 | +/** | |
| 118 | + * 跑一轮 dream。 | |
| 119 | + * | |
| 120 | + * @param {string} agentId 本体 id | |
| 121 | + * @param {{dryRun?: boolean}} opts | |
| 122 | + * @returns {Promise<object>} 这一轮做了什么(含 skipped 的原因,别让调用方猜) | |
| 123 | + */ | |
| 124 | +async function dreamOnce(agentId, { dryRun = false } = {}) { | |
| 125 | + const { fresh, counts } = collectMaterial(agentId); | |
| 126 | + | |
| 127 | + // 判据②:没有新原料就**一次 agent 调用都不起** | |
| 128 | + if (fresh.length === 0) { | |
| 129 | + return { agentId, skipped: 'no-new-material', counts, distilled: 0, accepted: 0 }; | |
| 130 | + } | |
| 131 | + | |
| 132 | + const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh }); | |
| 133 | + const accepted = []; | |
| 134 | + const rejected = []; | |
| 135 | + for (const c of candidates || []) { | |
| 136 | + // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed) | |
| 137 | + let verdict; | |
| 138 | + try { | |
| 139 | + verdict = await (screener || defaultScreener)({ agentId, candidate: c }); | |
| 140 | + } catch (err) { | |
| 141 | + rejected.push({ ...c, reason: `判定失败,按拒绝处理:${err.message}` }); | |
| 142 | + continue; | |
| 143 | + } | |
| 144 | + if (verdict && verdict.shareable === true) accepted.push(c); | |
| 145 | + else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' }); | |
| 146 | + } | |
| 147 | + | |
| 148 | + if (!dryRun && accepted.length) writeDistilled(agentId, accepted); | |
| 149 | + if (!dryRun) { | |
| 150 | + writeState(agentId, { | |
| 151 | + ...readState(agentId), | |
| 152 | + seenCounts: counts, | |
| 153 | + lastRunAt: new Date().toISOString(), | |
| 154 | + lastAccepted: accepted.length, | |
| 155 | + lastRejected: rejected.length, | |
| 156 | + }); | |
| 157 | + } | |
| 158 | + return { agentId, skipped: null, counts, distilled: (candidates || []).length, accepted: accepted.length, rejected }; | |
| 159 | +} | |
| 160 | + | |
| 161 | +/** | |
| 162 | + * 落盘(决策 B)。 | |
| 163 | + * | |
| 164 | + * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。 | |
| 165 | + * `SKILL.md` / `soul.md` / `agent.md` 是 git 跟踪的**定义**, | |
| 166 | + * 每一份都必须先过 `definition-write-gate` —— 覆盖一份尚未进入 git 历史的内容 | |
| 167 | + * 等于让上一版永久消失,且不报错。这里只做**闸门检查 + 记录**, | |
| 168 | + * 真正改定义留给后续步骤(本轮不写定义文件,见 §五 实现记录)。 | |
| 169 | + * | |
| 170 | + * **每条沉淀都带 sources**(决策 D):没有它,dream 的产出无法验证对错。 | |
| 171 | + */ | |
| 172 | +function writeDistilled(agentId, accepted) { | |
| 173 | + const dir = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY); | |
| 174 | + fs.mkdirSync(dir, { recursive: true }); | |
| 175 | + const file = path.join(dir, 'experience.md'); | |
| 176 | + const now = new Date().toISOString().substring(0, 10); | |
| 177 | + const blocks = accepted.map((c) => [ | |
| 178 | + `### ${c.title || '(未命名)'}`, | |
| 179 | + '', | |
| 180 | + c.body || '', | |
| 181 | + '', | |
| 182 | + `> 提炼于 ${now};原料:${(c.sources || []).join('、') || '(未记录)'}`, | |
| 183 | + '', | |
| 184 | + ].join('\n')); | |
| 185 | + const head = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8').replace(/\s*$/, '') : '# 跨公司经验(dream 提炼)'; | |
| 186 | + fs.writeFileSync(file, `${head}\n\n${blocks.join('\n')}`, 'utf-8'); | |
| 187 | + return file; | |
| 188 | +} | |
| 189 | + | |
| 190 | +/** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */ | |
| 191 | +function definitionWritability(agentId) { | |
| 192 | + const body = locator.bodyDir(agentId); | |
| 193 | + return ['agent.md', 'soul.md', 'SKILL.md'].map((f) => ({ | |
| 194 | + file: f, ...canOverwriteDefinition(path.join(body, f)), | |
| 195 | + })); | |
| 196 | +} | |
| 197 | + | |
| 198 | +async function defaultDistiller() { | |
| 199 | + throw new Error('[Dream] 提炼实现尚未接线(29-DREAM §四 步骤 3):请注入 setDistiller 或等待默认实现落地'); | |
| 200 | +} | |
| 201 | +async function defaultScreener() { | |
| 202 | + throw new Error('[Dream] 去敏判定实现尚未接线(29-DREAM §四 步骤 3):请注入 setScreener 或等待默认实现落地'); | |
| 203 | +} | |
| 204 | + | |
| 205 | +module.exports = { | |
| 206 | + dreamOnce, collectMaterial, companiesOf, readRawEntries, | |
| 207 | + writeDistilled, definitionWritability, readState, writeState, | |
| 208 | + setDistiller, setScreener, | |
| 209 | + RAW_SECTION, CROSS_COMPANY_MEMORY, | |
| 210 | +}; | ... | ... |
server/tests/unit/dream-service.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * dream 的骨架(29-DREAM §四 步骤 2 / 3 / 5)。 | |
| 3 | + * | |
| 4 | + * ── 三条判据各盯一个已知会出事的地方 ────────────────────────────────── | |
| 5 | + * ① **「有哪些公司」只认 `company_agents` 表,不扫目录。** | |
| 6 | + * `system/agents/{aid}/{cid}/` 是约定不是校验:实查 96 个二级目录里混着 | |
| 7 | + * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。 | |
| 8 | + * 拿目录枚举公司 = 把测试夹具当经验原料喂给提炼 agent。 | |
| 9 | + * ② **空转不许起 agent 调用。** | |
| 10 | + * 实查 75 份 memory.md 只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。 | |
| 11 | + * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱。 | |
| 12 | + * ③ **去敏判定失败 = 拒绝**(决策 A 的失效方向)。 | |
| 13 | + * 「少沉淀一条经验」与「泄漏一次机密」代价不对称,判据必须偏向贵的那一侧。 | |
| 14 | + * | |
| 15 | + * 外加可追溯(决策 D):每条沉淀都要带得回原料。 | |
| 16 | + */ | |
| 17 | +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; | |
| 18 | +import fs from 'fs'; | |
| 19 | +import os from 'os'; | |
| 20 | +import path from 'path'; | |
| 21 | + | |
| 22 | +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-dream-')); | |
| 23 | +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); | |
| 24 | + | |
| 25 | +const ROOT = path.resolve(__dirname, '../../..'); | |
| 26 | +const AGENT = 'zz-dream-body'; | |
| 27 | +const CO_A = 'zz-dream-co-a'; | |
| 28 | +const CO_B = 'zz-dream-co-b'; | |
| 29 | +const BODY = path.join(ROOT, 'system', 'agents', AGENT); | |
| 30 | + | |
| 31 | +const dream = require('../../services/dream-service'); | |
| 32 | +const wdb = require('../../services/work-graph-db'); | |
| 33 | + | |
| 34 | +function seedMemory(companyId, lines) { | |
| 35 | + const dir = path.join(BODY, companyId, 'memory'); | |
| 36 | + fs.mkdirSync(dir, { recursive: true }); | |
| 37 | + fs.writeFileSync(path.join(dir, 'memory.md'), | |
| 38 | + `# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n${dream.RAW_SECTION}\n${lines.map((l) => `- ${l}`).join('\n')}\n`, | |
| 39 | + 'utf-8'); | |
| 40 | +} | |
| 41 | + | |
| 42 | +function hire(companyId) { | |
| 43 | + const db = wdb.getDb(); | |
| 44 | + const now = new Date().toISOString(); | |
| 45 | + db.prepare(`INSERT OR IGNORE INTO system_agents | |
| 46 | + (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) | |
| 47 | + VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) | |
| 48 | + .run(AGENT, AGENT, `system/agents/${AGENT}`, now, now); | |
| 49 | + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) | |
| 50 | + VALUES (?, ?, ?, 'active', ?)`).run(companyId, AGENT, AGENT, now); | |
| 51 | +} | |
| 52 | + | |
| 53 | +describe('dream · 骨架', () => { | |
| 54 | + beforeEach(() => { | |
| 55 | + fs.rmSync(BODY, { recursive: true, force: true }); | |
| 56 | + dream.setDistiller(null); | |
| 57 | + dream.setScreener(null); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + afterAll(() => { | |
| 61 | + fs.rmSync(BODY, { recursive: true, force: true }); | |
| 62 | + fs.rmSync(TMP, { recursive: true, force: true }); | |
| 63 | + delete process.env.GRAPH_DB_PATH; | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('① 公司清单来自 company_agents 表 —— 目录里的测试残留不算公司', () => { | |
| 67 | + hire(CO_A); | |
| 68 | + seedMemory(CO_A, ['给 A 做官网 → 验收脚本先行']); | |
| 69 | + // 目录里再造两个「看起来像公司」的:一个是 ArenaSK 时代的运行态,一个是带时间戳的测试残留 | |
| 70 | + for (const junk of ['train', 'tmp-w7-bootstrap-1786986291739-9n2xv']) { | |
| 71 | + seedMemory(junk, ['这条不该被当成经验原料']); | |
| 72 | + } | |
| 73 | + const companies = dream.companiesOf(AGENT); | |
| 74 | + expect(companies).toContain(CO_A); | |
| 75 | + expect(companies).not.toContain('train'); | |
| 76 | + expect(companies).not.toContain('tmp-w7-bootstrap-1786986291739-9n2xv'); | |
| 77 | + | |
| 78 | + const { fresh } = dream.collectMaterial(AGENT); | |
| 79 | + expect(fresh.map((f) => f.text)).toEqual(['给 A 做官网 → 验收脚本先行']); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it('② 没有新原料 → 一次 agent 调用都不起', async () => { | |
| 83 | + hire(CO_A); | |
| 84 | + seedMemory(CO_A, ['第一条']); | |
| 85 | + const distill = vi.fn(async () => []); | |
| 86 | + dream.setDistiller(distill); | |
| 87 | + | |
| 88 | + const first = await dream.dreamOnce(AGENT); | |
| 89 | + expect(first.skipped).toBeNull(); | |
| 90 | + expect(distill).toHaveBeenCalledTimes(1); | |
| 91 | + | |
| 92 | + // 原料没变 → 第二轮直接跳过,且**说清为什么**(别让调用方猜) | |
| 93 | + const second = await dream.dreamOnce(AGENT); | |
| 94 | + expect(second.skipped).toBe('no-new-material'); | |
| 95 | + expect(distill).toHaveBeenCalledTimes(1); | |
| 96 | + | |
| 97 | + // 多了一条才继续,且只喂**增量**(不是整段重来) | |
| 98 | + seedMemory(CO_A, ['第一条', '第二条']); | |
| 99 | + dream.setDistiller(async ({ entries }) => { | |
| 100 | + expect(entries.map((e) => e.text)).toEqual(['第二条']); | |
| 101 | + return []; | |
| 102 | + }); | |
| 103 | + expect((await dream.dreamOnce(AGENT)).skipped).toBeNull(); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => { | |
| 107 | + hire(CO_A); | |
| 108 | + seedMemory(CO_A, ['一条经验']); | |
| 109 | + dream.setDistiller(async () => [ | |
| 110 | + { title: '通过的', body: '抽象经验', sources: ['a'] }, | |
| 111 | + { title: '被拒的', body: '含公司名', sources: ['b'] }, | |
| 112 | + { title: '判定炸了的', body: '???', sources: ['c'] }, | |
| 113 | + ]); | |
| 114 | + dream.setScreener(async ({ candidate }) => { | |
| 115 | + if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了'); | |
| 116 | + return { shareable: candidate.title === '通过的', reason: '含公司特定信息' }; | |
| 117 | + }); | |
| 118 | + | |
| 119 | + const r = await dream.dreamOnce(AGENT); | |
| 120 | + expect(r.distilled).toBe(3); | |
| 121 | + expect(r.accepted).toBe(1); | |
| 122 | + expect(r.rejected).toHaveLength(2); | |
| 123 | + // 判定抛异常那条必须留下**为什么**,否则现场只看到「少了一条」 | |
| 124 | + expect(r.rejected.find((x) => x.title === '判定炸了的').reason).toContain('判定失败'); | |
| 125 | + | |
| 126 | + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8'); | |
| 127 | + expect(exp).toContain('通过的'); | |
| 128 | + expect(exp).not.toContain('被拒的'); | |
| 129 | + }); | |
| 130 | + | |
| 131 | + it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => { | |
| 132 | + hire(CO_A); | |
| 133 | + seedMemory(CO_A, ['素材一']); | |
| 134 | + dream.setDistiller(async ({ entries }) => [ | |
| 135 | + { title: '经验甲', body: '正文', sources: entries.map((e) => e.source) }, | |
| 136 | + ]); | |
| 137 | + dream.setScreener(async () => ({ shareable: true })); | |
| 138 | + await dream.dreamOnce(AGENT); | |
| 139 | + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8'); | |
| 140 | + expect(exp).toContain(`${CO_A}:memory.md#1`); | |
| 141 | + }); | |
| 142 | + | |
| 143 | + it('两家公司的原料都进来,且各自带自己的出处(跨公司汇流的最小形态)', async () => { | |
| 144 | + hire(CO_A); hire(CO_B); | |
| 145 | + seedMemory(CO_A, ['A 的经验']); | |
| 146 | + seedMemory(CO_B, ['B 的经验']); | |
| 147 | + const seenSources = []; | |
| 148 | + dream.setDistiller(async ({ entries }) => { | |
| 149 | + seenSources.push(...entries.map((e) => e.source)); | |
| 150 | + return []; | |
| 151 | + }); | |
| 152 | + await dream.dreamOnce(AGENT); | |
| 153 | + expect(seenSources).toEqual(expect.arrayContaining([`${CO_A}:memory.md#1`, `${CO_B}:memory.md#1`])); | |
| 154 | + }); | |
| 155 | + | |
| 156 | + it('默认实现没接线时抛得明白(别让调用方以为「跑了但没产出」)', async () => { | |
| 157 | + hire(CO_A); | |
| 158 | + seedMemory(CO_A, ['一条']); | |
| 159 | + await expect(dream.dreamOnce(AGENT)).rejects.toThrow(/提炼实现尚未接线/); | |
| 160 | + }); | |
| 161 | +}); | ... | ... |