prompt-write-boundary.test.js
11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* 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, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, ?, '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'));
});
});