/** * 28 §4.4(二轮修正)—— 交付物归宿按链路分叉,同一段提示词里只准有一条「你必须写到」。 * * ── 锁的是哪个静默失败 ────────────────────────────────────────────────── * 一轮把 §4.4 的「交付物必须写到 {companyRoot}/workspace/」无差别塞进了所有链路的坐标声明, * 于是 graph 节点的提示词里同时出现两条互相排斥的指令: * ① 坐标声明:交付物写 data/companies/{cid}/workspace/ * ② graph-engine.describeWriteBoundary:只准写项目根 / 节点私有目录, * 「禁止写这两处之外的任何路径,尤其是 REPO_ROOT」——而 workspace 正在 REPO_ROOT 下 * agent 听①:产物进不了 graph_files(indexNodeOutputs 只扫 runs/{rootRunId}/nodes/{nodeRunId}/), * 还可能被 applyBoundaryGuard 判越界;听②:①那行是废话。**两种都不报错**, * 所以断言只能落在「提示词里这类指令出现了几次」上。 * * ── 为什么用真跑一遍 graph 拿任务描述 ─────────────────────────────────── * 分叉判据是「任务描述里已经带了 graph 的写入边界段」(prompt-builder.taskCarriesGraphWriteBoundary)。 * 手写一段假的任务描述只能证明判据函数自己自洽,证明不了 graph-engine 真的产出那个段头 —— * 而这两侧一旦漂移,症状恰好就是分叉失效。describeWriteBoundary 按本仓约定不导出 * (见 graph-engine module.exports 那段注释),所以走真入口 advanceOne 把 ctx.task 抓回来。 * * 隔离手法同 graph-boundary.test.js:GRAPH_DB_PATH / GRAPH_RUNS_DIR 先设再 require, * 仓库 data/ 全程不被碰;agent 调用走注入的假 executor,不起 Claude CLI。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const AgentManager = require('../../src/core/agent-manager'); const locator = require('../../services/agent-locator'); const { companyWorkspaceDir, GRAPH_WRITE_BOUNDARY_HEADER } = require('../../src/core/prompt-builder'); /** * 「你必须把交付物写到 X」这类**归宿指令**的计数器。 * * 两条链路各有各的措辞,都收进来:前者是坐标声明(agent-chat 链路)的, * 后者是 describeWriteBoundary(graph 链路)的。同一段提示词里命中两次 = 给了 agent * 两个家,就是本文件要挡的那个 bug。 */ const WRITE_MANDATE = /(你的交付物必须写到|一切代码 \/ 测试 \/ 配置 \/ 交付物都写在)/g; const countMandates = (s) => (s.match(WRITE_MANDATE) || []).length; describe('提示词的交付物归宿:graph 节点 vs agent-chat 两条链路', () => { const TEST_DB_PATH = '/tmp/test-prompt-write-boundary.sqlite'; // 常驻注册表条目(28 号裁决:测试建的本体不删),id 固定;内容每次重写不漂 const AGENT_ID = 'zz-wb-agent'; const BODY_DIR = locator.bodyDir(AGENT_ID); const COMPANY_ID = 'zz-wb-co'; let wdb; let engine; let manager; let runsDir; /** 真跑一遍 graph 抓回来的节点任务描述(含 describeWriteBoundary 那段) */ let graphTask; /** graph 链路 / agent-chat 链路各自的完整提示词 */ let graphPrompt; let chatPrompt; /** 本次调用的 cwd(= system/agents/{aid}/{cid}/{taskId}/) */ let graphCwd; let chatCwd; beforeAll(async () => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-wb-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', ]) { delete require.cache[require.resolve(m)]; } wdb = require('../../services/work-graph-db'); engine = require('../../services/graph-engine'); // 本体(agent-locator 的根是常量、没有 env 覆盖,所以建在真的注册表里,跑完不删) fs.mkdirSync(BODY_DIR, { recursive: true }); fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# zz-wb-agent\n\n单测用的常驻本体。\n', 'utf-8'); fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# zz-wb-agent 的人格\n\n只写该写的地方。\n', 'utf-8'); fs.writeFileSync(path.join(BODY_DIR, 'config.json'), JSON.stringify({ id: AGENT_ID, name: AGENT_ID, type: 'skill', status: 'active' }, null, 2), 'utf-8'); manager = new AgentManager({}, locator.REPO_ROOT); // ① 真跑一个单节点 run,把节点收到的任务描述抓回来 const db = wdb.getDb(); const ts = new Date().toISOString(); db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?) `).run(AGENT_ID, AGENT_ID, `system/agents/${AGENT_ID}`, ts, ts); const g = wdb.createWorkGraph({ companyId: COMPANY_ID, nodes: [{ nodeName: 'impl', systemAgentId: AGENT_ID }], edges: [], }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: COMPANY_ID }); engine.markRunReady(run.id); engine.setAgentExecutor(async (ctx) => { graphTask = ctx.task; return { status: 'success' }; }); engine.startRun(run.id, { mode: 'manual' }); await engine.advanceOne(run.id); engine.setAgentExecutor(null); // ② 用同一份任务描述过一遍真正的提示词装配(graph-engine → agent-manager → PromptBuilder) const addr = `${COMPANY_ID}:${AGENT_ID}`; graphCwd = manager.getAgentConfig(addr, { metadata: { taskId: run.id } }).agent.workDir; graphPrompt = manager.buildFullPrompt(addr, graphTask); // ③ agent-chat 链路:同一个本体、同一家公司,只是任务描述里没有写入边界段 // (裁决 A 之后 convId 充当 taskId,这里用一个 conv-* 形态的值) chatCwd = manager.getAgentConfig(addr, { metadata: { taskId: 'conv-wb-1' } }).agent.workDir; chatPrompt = manager.buildFullPrompt(addr, '帮我写一份本周复盘'); }); afterAll(() => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_RUNS_DIR; // 只清公司态(本次跑的 cwd 都在它下面),本体留在注册表里 fs.rmSync(locator.companyDir(AGENT_ID, COMPANY_ID), { recursive: true, force: true }); // 同一个进程里后续文件再 require 时要拿回没有 override 的那份 for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { delete require.cache[require.resolve(m)]; } }); it('graph-engine 真的在任务描述里发出了那个段头(分叉判据的另一半)', () => { expect(graphTask).toContain(GRAPH_WRITE_BOUNDARY_HEADER); }); it('graph 节点链路:没有 workspace 那条交付指令,归宿只有一套', () => { // 公司 workspace 在 REPO_ROOT 下、既非项目根也非节点私有目录,写进去等于让 agent 越界 expect(graphPrompt).not.toContain(companyWorkspaceDir(COMPANY_ID)); expect(graphPrompt).not.toContain('你的交付物必须写到'); // 唯一那条来自 describeWriteBoundary(项目根),坐标声明只指路、不另给归宿 expect(countMandates(graphPrompt)).toBe(1); expect(graphPrompt).toContain(`按下方任务描述里的「${GRAPH_WRITE_BOUNDARY_HEADER}」写`); }); it('agent-chat 链路:交付物仍写公司共享 workspace,同样只有一套', () => { expect(chatPrompt).toContain(`你的交付物必须写到:${companyWorkspaceDir(COMPANY_ID)}/`); expect(countMandates(chatPrompt)).toBe(1); // 反向:chat 链路不该凭空冒出 graph 的写入边界段 expect(chatPrompt).not.toContain(GRAPH_WRITE_BOUNDARY_HEADER); }); /** * subgraph 节点的规划调用(defaultPlanWorkGraph → buildPlanningPrompt)是**第三条** * 进 PromptBuilder 的路,而它不走 describeWriteBoundary、自己拼写入边界那一段。 * * 上面两个用例只覆盖 agent_call 节点,于是二轮修复漏了这一条:buildPlanningPrompt * 原来写的段头是手写的 `## 写入边界(硬约束)`,taskCarriesGraphWriteBoundary 判不出来 * → 坐标声明补上「交付物必须写到 {companyRoot}/workspace/」,而同一段提示词里 * 规划提示词自己那句是「禁止写 REPO_ROOT 下的任何路径」,companyRoot 正在 REPO_ROOT 下。 * 规划本体听哪条都跑得完、都不报错。 */ it('subgraph 规划链路:buildPlanningPrompt 用的是同一个段头常量,分叉不落空', async () => { const AM = require('../../src/core/agent-manager'); const orig = AM.prototype.callAgent; let planTask = null; AM.prototype.callAgent = async (name, task) => { planTask = task; return { output: '```json\n{"nodes":[],"edges":[]}\n```' }; }; try { await engine.defaultPlanWorkGraph({ companyId: COMPANY_ID, runId: 'wb-plan-run', rootRunId: 'wb-plan-run', nodeRunId: 'wb-plan-nr', defNodeId: 'wb-plan-def', nodeName: 'planner', systemAgentId: AGENT_ID, attempt: 1, projectRoot: null, task: '规划一张子图', remediationNote: null, run: { id: 'wb-plan-run', company_id: COMPANY_ID, goal_id: null, task_id: null, okr_node_id: null }, }); } catch { /* 图为空会被校验打回,本用例只要那份任务描述 */ } finally { AM.prototype.callAgent = orig; } expect(planTask).toContain(GRAPH_WRITE_BOUNDARY_HEADER); // 拿同一份任务描述过一遍真装配(用带公司段的地址,否则 chat 分支本来就不会触发,测了个寂寞) const planPrompt = manager.buildFullPrompt(`${COMPANY_ID}:${AGENT_ID}`, planTask); expect(planPrompt).not.toContain('你的交付物必须写到'); expect(planPrompt).not.toContain(companyWorkspaceDir(COMPANY_ID)); }); it('两条链路都给出 cwd 与本体目录的绝对路径(相对引用漏改时的唯一兜底)', () => { for (const [prompt, cwd] of [[graphPrompt, graphCwd], [chatPrompt, chatCwd]]) { expect(path.isAbsolute(cwd)).toBe(true); expect(prompt).toContain(`你的工作目录(cwd):${cwd}`); expect(prompt).toContain(`你的本体文件:${BODY_DIR}`); } // cwd 恒为深度 3(裁决 A):{aid}/{cid}/{taskId},两次调用只有第三段不同 expect(path.dirname(graphCwd)).toBe(path.join(BODY_DIR, COMPANY_ID)); expect(chatCwd).toBe(path.join(BODY_DIR, COMPANY_ID, 'conv-wb-1')); }); });