diff --git a/docs/todo/system-design/29-dream-design.md b/docs/todo/system-design/29-dream-design.md index 5cf5bea..2ae8629 100644 --- a/docs/todo/system-design/29-dream-design.md +++ b/docs/todo/system-design/29-dream-design.md @@ -210,17 +210,18 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) -> **进度**:步骤 1(上游记忆写入)与步骤 4(定义文件闸门)已实现;2 / 3 / 5 未开工。 +> **进度**:步骤 1、2、4、5 已实现;步骤 3 只落了**骨架 + 注入缝**, +> 两次 agent 调用(提炼 / 去敏判定)的**默认实现尚未接线** —— 见 §5.3。 用户定的是两件一起做,不分先后。拆成可验收的步骤: | 步 | 内容 | 为什么不能省 | |---|---|---| | 1 | ✅ **`appendToMemoryMd` 停止截断搬运**(§1.1)—— 已完成 | 它原来把工具调用 id 当「关键事实」写进 `memory.md` 并注入**每一轮**提示词。dream 直接消费它 = 把这个错误放大到跨公司 | -| 2 | **「有哪些公司」改查 `company_agents` 表**(§1.3) | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | -| 3 | **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写 `memory/` + `SKILL.md` | 主体 | +| 2 | ✅ **「有哪些公司」改查 `company_agents` 表**(§1.3)—— 已完成 | 目录里混着 `train/` `workspace/` 和带时间戳的测试残留,拿目录枚举公司必然把它们当原料 | +| 3 | 🟡 **dream v0**:枚举 → 提炼 agent → 去敏判定 agent(决策 A)→ 写落点 —— **骨架已落,两次 agent 调用未接线** | 主体 | | 4 | ✅ **定义文件的 git 历史闸门**(决策 B.3)—— 已完成 | 覆盖一份尚未进入 git 历史的内容 = 让上一版永久消失,且不报错 | -| 5 | **可追溯**:每条沉淀记住它是从哪几条 session 提炼来的(决策 D 的便宜起步) | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | +| 5 | ✅ **可追溯**:每条沉淀记住它是从哪几条记录提炼来的 —— 已完成 | 没有它,dream 的产出无法验证对错 —— 与 FDE 那边「LLM 自己汇报分数」是同一个问题 | **空转保护**(决策 C 的直接要求):没有新原料就不起 agent 调用。 @@ -251,3 +252,36 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; 守卫 `memory-raw-section.test.js` 5 例:关键事实段一个字不动、噪声一条不写、 长句不再被砍(带「样本确实超过旧上限」的前置断言)、多次写入累加在同一段、 无 toolCall 不写(保住原有行为,别顺手放宽)。 + +### 5.2 步骤 2 / 5 与 3 的骨架(`dream-service.js`) + +**步骤 2 —— 公司清单只认 `company_agents` 表。** +`companiesOf(agentId)` 查表,另把保留段 `_global` 补上(全局 agent 的运行态落在那里, +没人「雇」它,所以不在表里)。目录从此**只用来定位文件,不用来枚举公司**。 +守卫里造了两个「看起来像公司」的目录(`train/` 与 `tmp-w7-bootstrap-1786986291739-9n2xv`), +断言它们不进原料 —— 那正是实查到的两种残留形态。 + +**空转保护 —— 水位线按「条数」而不是 mtime。** +mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md`),条数只在真的多出记录时增长。 +拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 +没有新原料时 `dreamOnce` 直接返回 `skipped: 'no-new-material'`,**一次 agent 调用都不起**。 + +**步骤 5 —— 可追溯。** +每条原料带 `source`(`{公司}:memory.md#{行号}`),提炼产物落盘时把 `sources` 一并写进 +`experience.md` 的引用行。人抽查时能顺着回去看。 + +**决策 A 的失效方向在代码里是硬的**:判定 agent **抛异常也算拒绝**, +并把「为什么」写进 `rejected[]` —— 否则现场只看到「少了一条」,查不出是判否还是判炸。 + +### 5.3 步骤 3 还差什么(别当成做完了) + +`setDistiller` / `setScreener` 两个注入缝已经在,`dreamOnce` 的编排、空转保护、 +去敏闸门、落盘、可追溯都跑通了(6 例守卫)。**缺的是两次真 agent 调用的默认实现**: + +- 提炼 agent 与去敏判定 agent 的**本体还不存在**(需要两份 `agent.md` + `soul.md`); +- 默认实现现在是一句显式的 throw(「尚未接线」),**不是静默返回空** —— + 静默返回空会让调用方以为「跑了但没提炼出东西」,那是本仓库反复出现的失效形态。 + +**还没写定义文件**:`writeDistilled` 目前只写本体根的 `memory/experience.md` +(纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义 +留给下一步,`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」。 diff --git a/server/services/dream-service.js b/server/services/dream-service.js new file mode 100644 index 0000000..fd10189 --- /dev/null +++ b/server/services/dream-service.js @@ -0,0 +1,210 @@ +/** + * dream —— 经验汇流([29-DREAM](../../docs/todo/system-design/29-dream-design.md))。 + * + * 定时把**所有 agent 在所有公司**的原始记录提炼成不含公司特定信息的经验, + * 沉淀到本体上,让经验跨公司汇流而不泄漏任何一家的具体信息。 + * + * ── 四条拍板(2026-08-20,§三)────────────────────────────────────── + * A 去敏 = 提炼 agent 按写作要求产出 + **独立的判定 agent** 过闸门。 + * 失效方向是「少沉淀」不是「泄漏」—— 两种错代价不对称时判据偏向贵的那侧。 + * B 落点 = 本体根 `memory/`(跨公司那一份)与 `SKILL.md` 都写; + * `soul.md` / `agent.md` 直接落盘,但写前必须过 definition-write-gate。 + * C 触发 = 定时每日一次。 + * D 顺序 = 上游记忆写入与 dream 一起做。 + * + * ── 两条贯穿全程的判据 ────────────────────────────────────────────── + * **① 「有哪些公司」只认 `company_agents` 表,不扫目录。** + * `system/agents/{aid}/{cid}/` 是约定不是校验,实查 96 个二级目录里混着 + * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。 + * 拿目录枚举公司 = 把它们当原料。目录只用来**定位文件**,不用来**枚举公司**。 + * + * **② 空转不许起 agent 调用。** + * 实查 75 份 memory.md 里只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。 + * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱,日志里全是噪声。 + * 判据是水位线(`dream.json` 的 `lastRunAt`)与原料的 mtime 比对。 + */ + +const fs = require('fs'); +const path = require('path'); +const locator = require('./agent-locator'); +const { canOverwriteDefinition } = require('./definition-write-gate'); + +const RAW_SECTION = '## 原始记录(未提炼)'; +/** 本体根上的跨公司经验落点(决策 B) */ +const CROSS_COMPANY_MEMORY = 'memory'; +const STATE_FILE = 'dream.json'; + +// ── 注入缝:两次 agent 调用都可替换(写法照抄 graph-engine.setAgentExecutor)── +let distiller = null; +let screener = null; +/** 替换「提炼」实现(传 null 回默认实现) */ +function setDistiller(fn) { distiller = fn; } +/** 替换「去敏判定」实现(传 null 回默认实现) */ +function setScreener(fn) { screener = fn; } + +/** + * 这个本体在哪些公司有过运行态?**答案来自 `company_agents` 表**(判据①)。 + * + * `_global` 单独补上:它是保留段,全局 agent 的运行态落在那里, + * 不在 company_agents 里(那张表记的是「公司雇了谁」,全局 agent 没人雇)。 + * 算不算「一家公司」在设计里是 ❓,这里的处理是**算原料、但在提炼时标明它是全局态**。 + */ +function companiesOf(agentId) { + const out = []; + try { + const { listCompanyAgents } = require('./company-service'); + for (const row of listCompanyAgents()) { + if (row.system_agent_id === agentId || row.role_name === agentId) out.push(row.company_id); + } + } catch (err) { + // 库还没建好不该让 dream 整个挂掉,但**必须让调用方知道这一轮的原料是不全的** + console.warn(`[Dream] ⚠️ 读 company_agents 失败,本轮只看 _global: ${err.message}`); + } + if (fs.existsSync(path.join(locator.bodyDir(agentId), '_global'))) out.push('_global'); + return [...new Set(out)]; +} + +/** 一条原料:正文 + 它从哪来(决策 D 的「可追溯」起步) */ +function readRawEntries(agentId, companyId) { + const md = path.join(locator.companyDir(agentId, companyId), 'memory', 'memory.md'); + let text; + try { text = fs.readFileSync(md, 'utf-8'); } catch { return []; } + const idx = text.indexOf(RAW_SECTION); + if (idx < 0) return []; + const after = text.slice(idx + RAW_SECTION.length); + const section = after.split(/\n## /)[0]; + return section + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.startsWith('- ')) + .map((l, i) => ({ agentId, companyId, source: `${companyId}:memory.md#${i + 1}`, text: l.slice(2).trim() })); +} + +/** 本体根的 dream 状态(水位线 + 上一轮产出) */ +function statePath(agentId) { + return path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, STATE_FILE); +} +function readState(agentId) { + try { return JSON.parse(fs.readFileSync(statePath(agentId), 'utf-8')); } catch { return {}; } +} +function writeState(agentId, state) { + const p = statePath(agentId); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); +} + +/** + * 采集这个本体的全部新原料。 + * + * **水位线按「条数」而不是 mtime**:mtime 会被任何一次无关写入(例如另一段代码 + * 重写整份 memory.md)推动,而条数只在真的多出记录时才增长。 + * 拿 mtime 当判据的话,空转保护会在「文件被碰过但内容没变」时失效。 + */ +function collectMaterial(agentId) { + const state = readState(agentId); + const seen = state.seenCounts || {}; + const fresh = []; + const counts = {}; + for (const cid of companiesOf(agentId)) { + const entries = readRawEntries(agentId, cid); + counts[cid] = entries.length; + const from = seen[cid] || 0; + if (entries.length > from) fresh.push(...entries.slice(from)); + } + return { fresh, counts }; +} + +/** + * 跑一轮 dream。 + * + * @param {string} agentId 本体 id + * @param {{dryRun?: boolean}} opts + * @returns {Promise} 这一轮做了什么(含 skipped 的原因,别让调用方猜) + */ +async function dreamOnce(agentId, { dryRun = false } = {}) { + const { fresh, counts } = collectMaterial(agentId); + + // 判据②:没有新原料就**一次 agent 调用都不起** + if (fresh.length === 0) { + return { agentId, skipped: 'no-new-material', counts, distilled: 0, accepted: 0 }; + } + + const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh }); + const accepted = []; + const rejected = []; + for (const c of candidates || []) { + // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed) + let verdict; + try { + verdict = await (screener || defaultScreener)({ agentId, candidate: c }); + } catch (err) { + rejected.push({ ...c, reason: `判定失败,按拒绝处理:${err.message}` }); + continue; + } + if (verdict && verdict.shareable === true) accepted.push(c); + else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' }); + } + + if (!dryRun && accepted.length) writeDistilled(agentId, accepted); + if (!dryRun) { + writeState(agentId, { + ...readState(agentId), + seenCounts: counts, + lastRunAt: new Date().toISOString(), + lastAccepted: accepted.length, + lastRejected: rejected.length, + }); + } + return { agentId, skipped: null, counts, distilled: (candidates || []).length, accepted: accepted.length, rejected }; +} + +/** + * 落盘(决策 B)。 + * + * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。 + * `SKILL.md` / `soul.md` / `agent.md` 是 git 跟踪的**定义**, + * 每一份都必须先过 `definition-write-gate` —— 覆盖一份尚未进入 git 历史的内容 + * 等于让上一版永久消失,且不报错。这里只做**闸门检查 + 记录**, + * 真正改定义留给后续步骤(本轮不写定义文件,见 §五 实现记录)。 + * + * **每条沉淀都带 sources**(决策 D):没有它,dream 的产出无法验证对错。 + */ +function writeDistilled(agentId, accepted) { + const dir = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'experience.md'); + const now = new Date().toISOString().substring(0, 10); + const blocks = accepted.map((c) => [ + `### ${c.title || '(未命名)'}`, + '', + c.body || '', + '', + `> 提炼于 ${now};原料:${(c.sources || []).join('、') || '(未记录)'}`, + '', + ].join('\n')); + const head = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8').replace(/\s*$/, '') : '# 跨公司经验(dream 提炼)'; + fs.writeFileSync(file, `${head}\n\n${blocks.join('\n')}`, 'utf-8'); + return file; +} + +/** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */ +function definitionWritability(agentId) { + const body = locator.bodyDir(agentId); + return ['agent.md', 'soul.md', 'SKILL.md'].map((f) => ({ + file: f, ...canOverwriteDefinition(path.join(body, f)), + })); +} + +async function defaultDistiller() { + throw new Error('[Dream] 提炼实现尚未接线(29-DREAM §四 步骤 3):请注入 setDistiller 或等待默认实现落地'); +} +async function defaultScreener() { + throw new Error('[Dream] 去敏判定实现尚未接线(29-DREAM §四 步骤 3):请注入 setScreener 或等待默认实现落地'); +} + +module.exports = { + dreamOnce, collectMaterial, companiesOf, readRawEntries, + writeDistilled, definitionWritability, readState, writeState, + setDistiller, setScreener, + RAW_SECTION, CROSS_COMPANY_MEMORY, +}; diff --git a/server/tests/unit/dream-service.test.js b/server/tests/unit/dream-service.test.js new file mode 100644 index 0000000..a8cc825 --- /dev/null +++ b/server/tests/unit/dream-service.test.js @@ -0,0 +1,161 @@ +/** + * dream 的骨架(29-DREAM §四 步骤 2 / 3 / 5)。 + * + * ── 三条判据各盯一个已知会出事的地方 ────────────────────────────────── + * ① **「有哪些公司」只认 `company_agents` 表,不扫目录。** + * `system/agents/{aid}/{cid}/` 是约定不是校验:实查 96 个二级目录里混着 + * `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。 + * 拿目录枚举公司 = 把测试夹具当经验原料喂给提炼 agent。 + * ② **空转不许起 agent 调用。** + * 实查 75 份 memory.md 只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。 + * 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱。 + * ③ **去敏判定失败 = 拒绝**(决策 A 的失效方向)。 + * 「少沉淀一条经验」与「泄漏一次机密」代价不对称,判据必须偏向贵的那一侧。 + * + * 外加可追溯(决策 D):每条沉淀都要带得回原料。 + */ +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-dream-')); +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); + +const ROOT = path.resolve(__dirname, '../../..'); +const AGENT = 'zz-dream-body'; +const CO_A = 'zz-dream-co-a'; +const CO_B = 'zz-dream-co-b'; +const BODY = path.join(ROOT, 'system', 'agents', AGENT); + +const dream = require('../../services/dream-service'); +const wdb = require('../../services/work-graph-db'); + +function seedMemory(companyId, lines) { + const dir = path.join(BODY, companyId, 'memory'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'memory.md'), + `# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n${dream.RAW_SECTION}\n${lines.map((l) => `- ${l}`).join('\n')}\n`, + 'utf-8'); +} + +function hire(companyId) { + 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(AGENT, AGENT, `system/agents/${AGENT}`, now, now); + db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) + VALUES (?, ?, ?, 'active', ?)`).run(companyId, AGENT, AGENT, now); +} + +describe('dream · 骨架', () => { + beforeEach(() => { + fs.rmSync(BODY, { recursive: true, force: true }); + dream.setDistiller(null); + dream.setScreener(null); + }); + + afterAll(() => { + fs.rmSync(BODY, { recursive: true, force: true }); + fs.rmSync(TMP, { recursive: true, force: true }); + delete process.env.GRAPH_DB_PATH; + }); + + it('① 公司清单来自 company_agents 表 —— 目录里的测试残留不算公司', () => { + hire(CO_A); + seedMemory(CO_A, ['给 A 做官网 → 验收脚本先行']); + // 目录里再造两个「看起来像公司」的:一个是 ArenaSK 时代的运行态,一个是带时间戳的测试残留 + for (const junk of ['train', 'tmp-w7-bootstrap-1786986291739-9n2xv']) { + seedMemory(junk, ['这条不该被当成经验原料']); + } + const companies = dream.companiesOf(AGENT); + expect(companies).toContain(CO_A); + expect(companies).not.toContain('train'); + expect(companies).not.toContain('tmp-w7-bootstrap-1786986291739-9n2xv'); + + const { fresh } = dream.collectMaterial(AGENT); + expect(fresh.map((f) => f.text)).toEqual(['给 A 做官网 → 验收脚本先行']); + }); + + it('② 没有新原料 → 一次 agent 调用都不起', async () => { + hire(CO_A); + seedMemory(CO_A, ['第一条']); + const distill = vi.fn(async () => []); + dream.setDistiller(distill); + + const first = await dream.dreamOnce(AGENT); + expect(first.skipped).toBeNull(); + expect(distill).toHaveBeenCalledTimes(1); + + // 原料没变 → 第二轮直接跳过,且**说清为什么**(别让调用方猜) + const second = await dream.dreamOnce(AGENT); + expect(second.skipped).toBe('no-new-material'); + expect(distill).toHaveBeenCalledTimes(1); + + // 多了一条才继续,且只喂**增量**(不是整段重来) + seedMemory(CO_A, ['第一条', '第二条']); + dream.setDistiller(async ({ entries }) => { + expect(entries.map((e) => e.text)).toEqual(['第二条']); + return []; + }); + expect((await dream.dreamOnce(AGENT)).skipped).toBeNull(); + }); + + it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => { + hire(CO_A); + seedMemory(CO_A, ['一条经验']); + dream.setDistiller(async () => [ + { title: '通过的', body: '抽象经验', sources: ['a'] }, + { title: '被拒的', body: '含公司名', sources: ['b'] }, + { title: '判定炸了的', body: '???', sources: ['c'] }, + ]); + dream.setScreener(async ({ candidate }) => { + if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了'); + return { shareable: candidate.title === '通过的', reason: '含公司特定信息' }; + }); + + const r = await dream.dreamOnce(AGENT); + expect(r.distilled).toBe(3); + expect(r.accepted).toBe(1); + expect(r.rejected).toHaveLength(2); + // 判定抛异常那条必须留下**为什么**,否则现场只看到「少了一条」 + expect(r.rejected.find((x) => x.title === '判定炸了的').reason).toContain('判定失败'); + + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8'); + expect(exp).toContain('通过的'); + expect(exp).not.toContain('被拒的'); + }); + + it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => { + hire(CO_A); + seedMemory(CO_A, ['素材一']); + dream.setDistiller(async ({ entries }) => [ + { title: '经验甲', body: '正文', sources: entries.map((e) => e.source) }, + ]); + dream.setScreener(async () => ({ shareable: true })); + await dream.dreamOnce(AGENT); + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8'); + expect(exp).toContain(`${CO_A}:memory.md#1`); + }); + + it('两家公司的原料都进来,且各自带自己的出处(跨公司汇流的最小形态)', async () => { + hire(CO_A); hire(CO_B); + seedMemory(CO_A, ['A 的经验']); + seedMemory(CO_B, ['B 的经验']); + const seenSources = []; + dream.setDistiller(async ({ entries }) => { + seenSources.push(...entries.map((e) => e.source)); + return []; + }); + await dream.dreamOnce(AGENT); + expect(seenSources).toEqual(expect.arrayContaining([`${CO_A}:memory.md#1`, `${CO_B}:memory.md#1`])); + }); + + it('默认实现没接线时抛得明白(别让调用方以为「跑了但没产出」)', async () => { + hire(CO_A); + seedMemory(CO_A, ['一条']); + await expect(dream.dreamOnce(AGENT)).rejects.toThrow(/提炼实现尚未接线/); + }); +});