From 507ec3fae5169ad7dce2835936bd72eaeaf76e2e Mon Sep 17 00:00:00 2001 From: yibo Date: Fri, 21 Aug 2026 17:42:28 +0800 Subject: [PATCH] fix(agent-body): work 节点补齐 soul/tools/identity 等身份上下文,org node 角色定义截断改为可见 --- server/services/graph-engine.js | 16 ++++++++++++++-- server/src/core/identity-context.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/src/core/prompt-builder.js | 11 +++++++++++ server/tests/unit/describe-role-definition-truncation.test.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/identity-context.test.js | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 server/src/core/identity-context.js create mode 100644 server/tests/unit/describe-role-definition-truncation.test.js create mode 100644 server/tests/unit/identity-context.test.js diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js index 591368b..bb47e06 100644 --- a/server/services/graph-engine.js +++ b/server/services/graph-engine.js @@ -919,8 +919,17 @@ function describeRoleDefinition(ctx) { for (const f of candidates) { try { if (!fs.existsSync(f)) continue; - // 只给开头一段:规划要的是「这个节点负责什么职能」,不是它的完整工作流 - return `- 角色定义摘录(${path.relative(REPO_ROOT, f)}):\n${fs.readFileSync(f, 'utf8').slice(0, 2000)}`; + // 只给开头一段:规划要的是「这个节点负责什么职能」,不是它的完整工作流。 + // 但截断必须**可见**——原来悄悄切掉后半段,agent.md 写得长一点(常见于职能说明 + // 排在文件后半的本体),「这个节点负责什么职能」这句关键描述就可能根本没进提示词, + // 而 workgraph-planning 拿不到信号、只会按看到的那半截自行推断,不会知道自己漏看了。 + // 加一句提示 + 完整路径,给它一个自己去读全文的退路,不是把上限拉大(2000 仍是故意的)。 + const full = fs.readFileSync(f, 'utf8'); + const excerpt = full.slice(0, 2000); + const truncNote = full.length > 2000 + ? `\n(⚠️ 原文共 ${full.length} 字,已截断到前 2000 字——如果上面没读到「这个节点负责什么职能」,说明它在后半段,去读完整文件 ${f})` + : ''; + return `- 角色定义摘录(${path.relative(REPO_ROOT, f)}):\n${excerpt}${truncNote}`; } catch { /* 读不动就换下一个候选 */ } } return '- 角色定义:未找到该本体的 agent.md,请按节点名推断职能,并在规划记录里写明这一项缺失。'; @@ -4379,6 +4388,9 @@ module.exports = { // W9:失败上下文段的构造与它的数据源。describeFailureContext 的真实调用方是 advanceOne, // 导出只是让单测能不起真 CLI 直接验这一段文本(同 ensureProjectRoot / diffRepoStatus 的口径)。 describeFailureContext, + // 「本体装配静默坑」修复:截断可见性只有一条判据(截断了就必须带⚠️提示), + // 导出给单测直接验,同 describeFailureContext 的口径。 + describeRoleDefinition, LOOP_HARD_CAP_EDGE, LOOP_HARD_CAP_RUN, }; diff --git a/server/src/core/identity-context.js b/server/src/core/identity-context.js new file mode 100644 index 0000000..0d56e58 --- /dev/null +++ b/server/src/core/identity-context.js @@ -0,0 +1,99 @@ +/** + * 本体身份上下文(soul/identity/tools/heartbeat/OKR/hippocampus/skills)的共用读法。 + * + * ── 修的是哪个坑 ────────────────────────────────────────────────────────── + * graph-engine 驱动的 work 节点(`agent_call`)此前只有 `agent.md` 进提示词—— + * `PromptBuilder.build()` 走的是「protocol + agent.md + companyContextPath + + * user.md/memory.md/近期记忆」这一条链路,`soul.md`/`identity.md`/`tools.md`/ + * `heartbeat.md`/OKR 目标/hippocampus 检索/skills 全部缺失。而交互式的 + * `agent-chat.js`(`buildSystemPrompt()`)一直都在读这些文件,两条链路从未同步过。 + * 静默到什么程度:agent 照样跑、照样成功,只是少了人格/工具约束/长期记忆检索, + * 表现为「work 节点里的 agent 比 chat 里蠢一截」,没有任何报错或日志能看出原因。 + * + * ── 这里只补缺的,不重复已有的 ──────────────────────────────────────────── + * `agent.md` 由 `PromptBuilder.getAgentPrompt()` 已经读过;`user.md`/`memory.md`/ + * 近期记忆由 `PromptBuilder.buildCompanyStateParts()` 已经读过——这个函数**不重复** + * 那五样,只读 agent-chat 有、PromptBuilder 没有的那几样。 + * + * ── ponytail:没有让 agent-chat.js 反过来调用这份实现 ───────────────────── + * 两处现在是「同构但独立」的两份代码,不是真正共享一份——`buildSystemPrompt()` + * 是长期稳定的高频路径,为了这次修复去动它,风险和收益不成比例。真要收敛成 + * 一份实现,得先给 `buildSystemPrompt()` 补一圈契约测试再动。升级路径:有余力 + * 时把 agent-chat.js 里对应的读文件片段也换成调这份实现,届时可以直接删掉 + * agent-chat.js 里那几段、只保留它自己特有的东西(company 环境上下文 / 运行时坐标 + * 声明 / userMessage 相关的 hippocampus 搜索文本拼接)。 + */ +const fs = require('fs'); +const path = require('path'); +const { OkrService } = require('../../services/okr-service'); +const { retrieveRelevantPlans } = require('../../services/memory-service'); + +// 与 agent-chat.js 同款用法:全局 OKR 索引(不按公司分库),module 级单例。 +const okrService = new OkrService(); + +function readTrim(p) { + try { return fs.readFileSync(p, 'utf-8').trim(); } catch { return ''; } +} + +/** + * @param {object} a + * @param {string} a.bodyDir 本体目录(agent.md/soul.md/.claude/ 所在,只读) + * @param {string} [a.agentId] OKR/hippocampus 检索用的身份键;没有则跳过这两段 + * @param {string} [a.searchText=''] hippocampus 检索用的当前任务文本(agent-chat 用的是 + * userMessage + todo.md,这里用调用方传入的任务描述) + * @returns {string[]} 按顺序排好的提示词分段,调用方自己 push 进 parts 数组 + */ +function buildIdentityContextParts({ bodyDir, agentId, searchText = '' }) { + const parts = []; + if (!bodyDir) return parts; + + // soul.md / identity.md / tools.md —— agent.md 已经由调用方读过,这里不重复 + for (const f of ['soul.md', 'identity.md', 'tools.md']) { + const c = readTrim(path.join(bodyDir, f)); + if (c) parts.push(c); + } + + const heartbeat = readTrim(path.join(bodyDir, 'heartbeat.md')); + if (heartbeat) parts.push(heartbeat); + + if (agentId) { + try { + const goalContext = okrService.getActiveContextForAgent(agentId); + if (goalContext && goalContext.trim()) parts.push(goalContext.trim()); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[IdentityContext] ⚠️ 读取目标上下文失败: ${err.message}`); + } + + // user.md / memory.md / 近期记忆已经由 PromptBuilder.buildCompanyStateParts 读过, + // 不在这里重复;hippocampus 检索原实现里跟那三样同一批读,但 PromptBuilder 没有 + // 对应覆盖,仍然缺,补上。 + const hippocampusPlans = retrieveRelevantPlans(agentId, searchText); + if (hippocampusPlans) parts.push(hippocampusPlans); + } + + const mainSkill = readTrim(path.join(bodyDir, '.claude/skills/main_skill/SKILL.md')); + if (mainSkill) parts.push('---\n\n# 我的核心主技能 (执行准则)\n\n' + mainSkill); + + const localLessons = readTrim(path.join(bodyDir, 'workspace/_lessons.md')); + if (localLessons) { + parts.push('---\n\n## ⚠️ 本地试错防坑经验 (_lessons.md)\n不要重复踩下面的坑,仔细阅读并绕开:\n\n' + localLessons); + } + + const skillsMd = readTrim(path.join(bodyDir, '.claude/skills/skills.md')); + if (skillsMd) parts.push(skillsMd); + + // 扫描除 main_skill 外的所有 skill 子目录 + const skillsDir = path.join(bodyDir, '.claude', 'skills'); + if (fs.existsSync(skillsDir)) { + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === 'main_skill') continue; + const c = readTrim(path.join(skillsDir, entry.name, 'SKILL.md')); + if (c) parts.push(`---\n\n## 依赖技能: ${entry.name}\n\n${c}`); + } + } + + return parts; +} + +module.exports = { buildIdentityContextParts }; diff --git a/server/src/core/prompt-builder.js b/server/src/core/prompt-builder.js index aa6903b..16c0b7c 100644 --- a/server/src/core/prompt-builder.js +++ b/server/src/core/prompt-builder.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const { companyRootDir } = require('../../services/company-paths'); const { loadRecentMemory } = require('../../services/memory-service'); +const { buildIdentityContextParts } = require('./identity-context'); /** * 交付物的唯一归宿:公司共享 workspace(28 §4.4)。 @@ -287,6 +288,16 @@ class PromptBuilder { // 与 getAgentPrompt / buildCompanyStateParts 是同一条理由。protocol 才是仓库里的静态文件。 parts.push(fs.readFileSync(agent.companyContextPath, 'utf-8')); } + // 「本体装配静默坑」修复:soul.md/identity.md/tools.md/heartbeat.md/OKR 目标/ + // hippocampus 检索/skills——agent-chat.js 一直在读,这条链路此前从未读过(见 + // identity-context.js 头注)。只对非 SKILL.md 模式补,因为 isSkillBased 的 agent + // 是「借住在别人本体目录下的一个 scoped skill」,不是一个完整身份,硬套 soul/identity + // 语义不通——它连 companyDir 都恒为 null(agent-manager.js 的 scoped-skill 分支)。 + parts.push(...buildIdentityContextParts({ + bodyDir: agent.bodyDir, + agentId: agent.agentId, + searchText: userTask, + })); } // 公司态 + 运行时坐标:**两条分支都要装**。SKILL.md 模式跳过的是 protocol 和 diff --git a/server/tests/unit/describe-role-definition-truncation.test.js b/server/tests/unit/describe-role-definition-truncation.test.js new file mode 100644 index 0000000..c92bb95 --- /dev/null +++ b/server/tests/unit/describe-role-definition-truncation.test.js @@ -0,0 +1,63 @@ +/** + * Unit Test — describeRoleDefinition 截断可见性(本体装配静默坑修复) + * + * 原实现把 agent.md 悄悄切到前 2000 字:职能说明如果排在文件后半(本体写得长一点 + * 就很常见),workgraph-planning 拿到的角色定义里根本没有这句话,而它不会知道 + * 自己看到的是被切过的半截——「硬约束不前置就等于没写」。 + * + * 这里锁的是:截断了就必须带⚠️提示 + 完整路径;没截断(原文本来就短)就什么都不多说。 + * 用真实 system/agents/ 目录起一个 zz- 前缀的临时本体(同款手法见 + * base-graduation-gate.test.js 的 zz-hire-test-co),跑完删掉,不留痕迹—— + * locator.bodyDir() 没有 env 覆盖点,读的就是真实仓库路径,测试只能顺着这条路走。 + */ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents'); + +describe('describeRoleDefinition 截断可见性', () => { + const testAgentId = 'zz-test-role-trunc'; + const testAgentDir = path.join(AGENTS_ROOT, testAgentId); + + afterEach(() => { + fs.rmSync(testAgentDir, { recursive: true, force: true }); + }); + + it('agent.md 超过 2000 字时,截断段落带⚠️提示 + 完整路径', () => { + fs.mkdirSync(testAgentDir, { recursive: true }); + const longContent = `# ${testAgentId}\n` + 'x'.repeat(2500); + fs.writeFileSync(path.join(testAgentDir, 'agent.md'), longContent); + + delete require.cache[require.resolve('../../services/graph-engine.js')]; + const engine = require('../../services/graph-engine.js'); + const result = engine.describeRoleDefinition({ systemAgentId: testAgentId, companyId: null }); + + expect(result).toContain(`⚠️ 原文共 ${longContent.length} 字,已截断到前 2000 字`); + expect(result).toContain(path.join(testAgentDir, 'agent.md')); + // 截断本身没变——2000 字上限仍是故意的,摘录长度不应该被这次修复顺带改掉 + const excerpt = result.split(':\n')[1].split('\n(⚠️')[0]; + expect(excerpt.length).toBe(2000); + }); + + it('agent.md 不超过 2000 字时,不带任何截断提示', () => { + fs.mkdirSync(testAgentDir, { recursive: true }); + const shortContent = `# ${testAgentId}\n短本体`; + fs.writeFileSync(path.join(testAgentDir, 'agent.md'), shortContent); + + delete require.cache[require.resolve('../../services/graph-engine.js')]; + const engine = require('../../services/graph-engine.js'); + const result = engine.describeRoleDefinition({ systemAgentId: testAgentId, companyId: null }); + + expect(result).not.toContain('⚠️'); + expect(result).toContain('短本体'); + }); + + it('本体不存在时回落到明确的「未找到」提示,不是截断逻辑的边缘情况', () => { + delete require.cache[require.resolve('../../services/graph-engine.js')]; + const engine = require('../../services/graph-engine.js'); + const result = engine.describeRoleDefinition({ systemAgentId: 'zz-does-not-exist-role-trunc', companyId: null }); + expect(result).toContain('未找到该本体的 agent.md'); + }); +}); diff --git a/server/tests/unit/identity-context.test.js b/server/tests/unit/identity-context.test.js new file mode 100644 index 0000000..fa90ddf --- /dev/null +++ b/server/tests/unit/identity-context.test.js @@ -0,0 +1,150 @@ +/** + * Unit Tests — identity-context.js(本体装配静默坑修复) + * + * 锁的是「work 节点只有 agent.md,soul/identity/tools/heartbeat/OKR/hippocampus/skills + * 全部缺失」这条修复的具体行为:读了哪些文件、按什么条件跳过、不重复已经被 + * PromptBuilder.buildCompanyStateParts 读过的那三样(user.md/memory.md/近期记忆)。 + * + * OkrService / retrieveRelevantPlans 两个依赖没有走 vi.mock——本仓库服务层是纯 + * CommonJS(require/module.exports),vi.mock 面向的是 Vite 处理过的 ESM 模块图, + * 对 identity-context.js 内部自己 require() 出来的这条链路不生效(实测:mock 完全不拦截, + * `okrMod.OkrService` 收到的还是真实的类,不是 vi.fn())。改用更直接的手法——CJS 的 + * `require()` 在同一进程内对同一路径返回**同一个** `module.exports` 对象,所以直接在 + * `beforeEach` 里把这两个依赖模块的导出属性**原地换成假实现**,`identity-context.js` + * 用 `require()` 拿到的就是换过的那份;`afterEach` 换回来,不留痕迹。 + * 这两个依赖也没有任何 env 覆盖点能让它们落到隔离的临时目录(不像 GRAPH_DB_PATH 那套), + * 真去读会既不确定又污染仓库数据,所以只验证「调用了、传参对不对、返回值有没有被 + * 正确拼进去」,不验证它们内部的真实检索逻辑(那是各自模块自己的测试该管的事)。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const okrModule = require('../../services/okr-service'); +const memoryModule = require('../../services/memory-service'); +const originalOkrService = okrModule.OkrService; +const originalRetrieveRelevantPlans = memoryModule.retrieveRelevantPlans; + +describe('identity-context.buildIdentityContextParts', () => { + let bodyDir; + let buildIdentityContextParts; + let okrCalls; + let hippocampusCalls; + + beforeEach(() => { + bodyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-ctx-body-')); + + okrCalls = []; + hippocampusCalls = []; + // 原地换成假实现:identity-context.js 的 require('../../services/okr-service') + // 拿到的是同一个 module.exports 对象,属性换了它就用的是假的。 + okrModule.OkrService = function FakeOkrService() { + return { + getActiveContextForAgent(agentId) { + okrCalls.push(agentId); + return agentId === 'has-goal' ? '## 🎯 当前参与的目标\n\n模拟目标文本' : ''; + }, + }; + }; + memoryModule.retrieveRelevantPlans = (agentId, searchText) => { + hippocampusCalls.push([agentId, searchText]); + return agentId === 'has-goal' ? `模拟检索结果(搜索词:${searchText})` : ''; + }; + + delete require.cache[require.resolve('../../src/core/identity-context.js')]; + ({ buildIdentityContextParts } = require('../../src/core/identity-context.js')); + }); + + afterEach(() => { + okrModule.OkrService = originalOkrService; + memoryModule.retrieveRelevantPlans = originalRetrieveRelevantPlans; + delete require.cache[require.resolve('../../src/core/identity-context.js')]; + fs.rmSync(bodyDir, { recursive: true, force: true }); + }); + + function write(rel, content) { + const fp = path.join(bodyDir, rel); + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, content); + } + + it('bodyDir 为空时返回空数组,不抛错', () => { + expect(buildIdentityContextParts({ bodyDir: null })).toEqual([]); + }); + + it('soul.md/identity.md/tools.md 都写了时按顺序读出,缺的那个跳过', () => { + write('soul.md', 'MY SOUL'); + write('tools.md', 'MY TOOLS'); // identity.md 故意不写 + const parts = buildIdentityContextParts({ bodyDir }); + expect(parts).toContain('MY SOUL'); + expect(parts).toContain('MY TOOLS'); + expect(parts.join('\n')).not.toContain('identity.md'); + }); + + it('heartbeat.md 存在时读入,不存在时跳过(不报错)', () => { + expect(buildIdentityContextParts({ bodyDir })).not.toContain(expect.stringContaining('HEARTBEAT')); + write('heartbeat.md', 'HEARTBEAT CONTENT'); + expect(buildIdentityContextParts({ bodyDir })).toContain('HEARTBEAT CONTENT'); + }); + + it('没有 agentId 时不查 OKR / hippocampus(不是「查了但是空」,是根本不查)', () => { + buildIdentityContextParts({ bodyDir }); + expect(okrCalls).toEqual([]); + expect(hippocampusCalls).toEqual([]); + }); + + it('有 agentId 时查 OKR 与 hippocampus,结果非空才拼进去', () => { + const parts = buildIdentityContextParts({ bodyDir, agentId: 'has-goal', searchText: '写一份 PRD' }); + expect(parts.some((p) => p.includes('模拟目标文本'))).toBe(true); + expect(parts.some((p) => p.includes('模拟检索结果'))).toBe(true); + expect(hippocampusCalls).toContainEqual(['has-goal', '写一份 PRD']); + + // agentId 存在但两个依赖都返回空字符串——不拼空段落 + const empty = buildIdentityContextParts({ bodyDir, agentId: 'no-goal', searchText: 'x' }); + expect(empty.some((p) => p.includes('目标') || p.includes('检索'))).toBe(false); + }); + + it('OKR 查询抛错时不影响其它内容,只是那一段缺失(不整体失败)', () => { + okrModule.OkrService = function ThrowingOkrService() { + return { getActiveContextForAgent: () => { throw new Error('boom'); } }; + }; + delete require.cache[require.resolve('../../src/core/identity-context.js')]; + ({ buildIdentityContextParts } = require('../../src/core/identity-context.js')); + + write('soul.md', 'STILL HERE'); + const parts = buildIdentityContextParts({ bodyDir, agentId: 'whoever' }); + expect(parts).toContain('STILL HERE'); + }); + + it('main_skill/SKILL.md 存在时带专属标题拼入', () => { + write('.claude/skills/main_skill/SKILL.md', 'MAIN SKILL BODY'); + const parts = buildIdentityContextParts({ bodyDir }); + expect(parts.some((p) => p.includes('我的核心主技能') && p.includes('MAIN SKILL BODY'))).toBe(true); + }); + + it('workspace/_lessons.md 存在时带警示标题拼入', () => { + write('workspace/_lessons.md', '别再犯这个错'); + const parts = buildIdentityContextParts({ bodyDir }); + expect(parts.some((p) => p.includes('本地试错防坑经验') && p.includes('别再犯这个错'))).toBe(true); + }); + + it('skills.md 与其它 skill 子目录(排除 main_skill)都被扫到', () => { + write('.claude/skills/skills.md', 'SKILLS INDEX'); + write('.claude/skills/main_skill/SKILL.md', 'MAIN'); // 不应该被当成「依赖技能」再扫一次 + write('.claude/skills/foo-skill/SKILL.md', 'FOO SKILL BODY'); + write('.claude/skills/bar-skill/SKILL.md', 'BAR SKILL BODY'); + const parts = buildIdentityContextParts({ bodyDir }); + expect(parts).toContain('SKILLS INDEX'); + expect(parts.some((p) => p.includes('依赖技能: foo-skill') && p.includes('FOO SKILL BODY'))).toBe(true); + expect(parts.some((p) => p.includes('依赖技能: bar-skill') && p.includes('BAR SKILL BODY'))).toBe(true); + // main_skill 不能又被当成一个「依赖技能」重复拼一次 + expect(parts.filter((p) => p.includes('MAIN')).length).toBe(1); + }); + + it('.claude/skills 目录本身不存在时不报错,其它内容照常返回', () => { + write('soul.md', 'OK'); + expect(() => buildIdentityContextParts({ bodyDir })).not.toThrow(); + expect(buildIdentityContextParts({ bodyDir })).toContain('OK'); + }); +}); -- libgit2 0.21.4