prompt-builder.js
13.8 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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
/**
* PromptBuilder - 根据 Agent 类型拼接 protocol + prompt
*/
const fs = require('fs');
const path = require('path');
const { companyRootDir } = require('../../services/company-paths');
const { loadRecentMemory } = require('../../services/memory-service');
/**
* 交付物的唯一归宿:公司共享 workspace(28 §4.4)。
*
* 导出出去是为了让 agent-chat 的「公司环境上下文」那段声明引同一份 —— 两处各 join 一次
* 'workspace',就是 goal-contract.js 注释里点过名的「两份逐字同构的约定」的第三份。
*/
function companyWorkspaceDir(companyId) {
return path.join(companyRootDir(companyId), 'workspace');
}
/**
* graph 节点链路写入边界段的段头 —— 由 `graph-engine.describeWriteBoundary()` 拼进任务描述。
*
* 常量定义在这里、由 graph-engine 引用,是为了让「产出这段文本的一侧」和「据此改口径的一侧」
* 共用同一个字符串对象:两份逐字同构的字面量在 goal-contract.js 那里已经失效过一次,
* 而这一份一旦漂移,症状是提示词里悄悄多出第二个「你必须写到」——照样跑完,不报错。
*/
const GRAPH_WRITE_BOUNDARY_HEADER = '[写入边界 —— 硬约束,越界会被判失败]';
/**
* 判据:本次调用走的是 **graph 节点链路** 还是人直接对话的 **agent-chat 链路**。
*
* 两条链路的交付物归宿本来就不同,不能统一(这是 28 §4.4 v0.1 过度一般化的地方):
* - graph 节点:产物要被 `graph-engine.indexNodeOutputs` 扫进 graph_files,而它只扫
* `data/companies/{cid}/runs/{rootRunId}/nodes/{nodeRunId}/`;写去公司 workspace
* 既进不了索引,又会被 `applyBoundaryGuard` 当成越界(workspace 在 REPO_ROOT 下)。
* - agent-chat:没有节点、没有项目根,交付物落公司共享 workspace —— 这是
* agent-chat.js「公司环境上下文」那段早于 28 号就有的既有约定。
* 所以坐标声明必须分叉。同一段提示词里给 agent 两个「你必须写到」,它听哪条都跑得完、
* 都不报错,只是有一半产物没人找得到 —— 这正是要挡的静默失败。
*
* 判据取「任务描述里已经带了 graph 的写入边界段」,而不是「options 里有没有 nodeRunId」:
* nodeRunId 只到 `agent-manager.callAgent` 的 metadata 为止,进不了
* `PromptBuilder.build(name, task)` 的入参;而这里真正要防的本来就是「同一段提示词里出现
* 第二份写入归宿」,按那段文本在不在场判断是直接命中,任何未来复用 describeWriteBoundary
* 的调用方也自动适用。
*/
function taskCarriesGraphWriteBoundary(userTask) {
return typeof userTask === 'string' && userTask.includes(GRAPH_WRITE_BOUNDARY_HEADER);
}
class PromptBuilder {
constructor(config, baseDir) {
this.config = config;
this.baseDir = baseDir;
this.protocolCache = new Map();
this.promptCache = new Map();
}
/**
* 动态生成环境快照 (Company, Agent, Goals)
*/
getSystemStateXML() {
let state = '<env_status>\n';
state += ` <timestamp>${new Date().toISOString()}</timestamp>\n`;
try {
const { CompanyService } = require('../../services/company-service');
const { OkrService } = require('../../services/okr-service');
const cs = new CompanyService();
const companies = cs.list();
if (companies.length > 0) {
state += ' <companies>\n';
for (const c of companies) {
state += ` <company id="${c.id}" name="${c.name}">\n`;
const agents = cs.listAgents(c.id);
if (agents && agents.length > 0) {
for (const a of agents) {
state += ` <agent id="${c.id}:${a.id}" role="${a.type}" status="${a.status || 'ready'}" />\n`;
}
}
const gs = new OkrService(c.id);
const goals = gs.list();
if (goals && goals.length > 0) {
state += ' <active_goals>\n';
for (const g of goals.filter(g => g.status !== 'done')) {
state += ` <goal id="${g.id}" title="${g.title}" status="${g.status}" />\n`;
}
state += ' </active_goals>\n';
}
state += ` </company>\n`;
}
state += ' </companies>\n';
}
const globalGs = new OkrService();
const globalGoals = globalGs.list();
if (globalGoals && globalGoals.length > 0) {
state += ' <global_active_goals>\n';
for (const g of globalGoals.filter(g => g.status !== 'done')) {
state += ` <goal id="${g.id}" title="${g.title}" status="${g.status}" />\n`;
}
state += ' </global_active_goals>\n';
}
} catch (e) {
state += ` <!-- Error loading env state: ${e.message} -->\n`;
}
state += '</env_status>\n';
return state;
}
/**
* 加载文件内容(带缓存)
*/
loadFile(filePath) {
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.baseDir, filePath);
if (!this.promptCache.has(fullPath)) {
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${fullPath}`);
}
this.promptCache.set(fullPath, fs.readFileSync(fullPath, 'utf-8'));
}
return this.promptCache.get(fullPath);
}
/**
* 获取 role protocol 内容
*/
getProtocol(type) {
const protocolPath = this.config.rolePrompts?.[type] || this.config.protocols?.[type];
if (!protocolPath) {
return '';
}
return this.loadFile(protocolPath);
}
/**
* 获取 agent prompt 内容(本体 agent.md / SKILL.md)
*
* ⚠️ 故意**不走** this.loadFile:那份缓存是进程级且永不失效,而本体是可写的
* (evolver / agent-creator 会重写 agent.md,28 §4.1 只把它标成「只读」是对 agent 说的)。
* 缓存住的后果正是 28 §八 验收 5 要挡的那条:改完本体,长驻进程里的
* `agent-chat.js` 模块级 agentManager 直到重启都还在用旧定义 —— 照样跑、照样成功,
* 只是用的是上一版人格,没有任何报错。同样的理由见 buildCompanyStateParts。
* protocol 那侧仍走缓存:那是仓库里的静态协议文件,没有运行时写入方。
*/
getAgentPrompt(agentName) {
const agent = this.config.agents[agentName];
if (!agent) {
throw new Error(`Agent not found: ${agentName}`);
}
const promptPath = agent.prompt || agent.systemPrompt;
if (!promptPath) {
return '';
}
const fullPath = path.isAbsolute(promptPath) ? promptPath : path.join(this.baseDir, promptPath);
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${fullPath}`);
}
return fs.readFileSync(fullPath, 'utf-8');
}
/**
* 构建可用 Agent 列表(用于 master protocol)
*/
buildAvailableAgentsList() {
const excluded = new Set(['master', 'gateway']);
return Object.entries(this.config.agents)
.filter(([name]) => !excluded.has(name))
.map(([name, cfg]) => `- **${name}** (${cfg.type}): ${cfg.description}`)
.join('\n');
}
/**
* 公司态两段:`user.md`(汇报关系)+ memory 注入(28 §4.3 的第 4、5 段)。
*
* 都用 fs 直读而不是 this.loadFile:后者带**永不失效**的进程级缓存,
* 而这两样是运行态 —— 缓存住就等于把记忆冻结在进程启动那一刻,
* agent 越跑越像失忆,且没有任何报错。
*/
buildCompanyStateParts(agent, agentName) {
const dir = agent.companyDir;
if (!dir) return [];
const parts = [];
const read = (p) => {
try { return fs.readFileSync(p, 'utf-8').trim(); } catch { return ''; }
};
const userMd = read(path.join(dir, 'user.md'));
if (userMd) parts.push(userMd);
const memoryMd = read(path.join(dir, 'memory', 'memory.md'));
if (memoryMd) parts.push(`# 我的记忆\n\n${memoryMd}`);
// memory-service 现有逻辑,落点换成公司态目录:记忆归属 (agentId, companyId),
// 同一个本体在两家公司各记各的,不共享(28 §一「非目标」第一条)。
const recent = loadRecentMemory(agentName, dir);
if (recent) parts.push(recent);
return parts;
}
/**
* 运行时坐标声明(28 §4.4)—— 一律给**绝对路径**。
*
* 前两行(cwd / 本体目录)两条链路都有,它们是两个静默失败的唯一兜底:agent.md 里的
* 相对引用(`../../../soul.md`)漏改的话 agent 读不到本体、静默降级成通用 Base Agent 文本。
*
* 第三行「交付物写哪」**按链路分叉**,判据见 taskCarriesGraphWriteBoundary:
* graph 节点链路的归宿由任务描述里的写入边界段说了算,这里只做指路、不给第二个归宿;
* agent-chat 链路才写公司共享 workspace。原先无差别给所有链路写 workspace 的版本,
* 会让 graph 节点同时收到两条互相排斥的「你必须写到」。
*
* companyRoot 一律走 company-paths.companyRootDir():goal-contract.js 的注释记着
* 「两份逐字同构的约定」失效过一次,这里不再手拼第三份。
*/
buildRuntimeCoordinates(agent, userTask = '') {
const cwd = agent.workDir;
const body = agent.bodyDir;
if (!cwd && !body) return '';
const lines = ['# 运行时坐标(绝对路径,以此为准)', ''];
if (cwd) lines.push(`- 你的工作目录(cwd):${cwd} —— 只放草稿和日志,会被清理`);
if (body) {
// 相对提示按实算,不照抄设计稿:§4.1 写的 `../../../` 把结尾斜杠数成了一级,
// body 模式的真值是 `../..`;legacy 模式本体就在 cwd 里(`.`)。
// 给 agent 一个差一级的相对路径 = 它读不到 soul.md 且不报错。
const rel = cwd ? `(= ${path.relative(cwd, body) || '.'})` : '';
lines.push(`- 你的本体文件:${body}${rel} —— 只读,不要改`);
}
if (taskCarriesGraphWriteBoundary(userTask)) {
// graph 节点链路:归宿已经在任务描述的写入边界段里定死(项目根 / 节点私有目录),
// 这里只指路。再写一遍 workspace 就是给它第二个家 —— 而那个家既不被
// indexNodeOutputs 索引,又会被边界守卫判越界。
lines.push(`- 你的交付物:按下方任务描述里的「${GRAPH_WRITE_BOUNDARY_HEADER}」写(项目根 / 节点私有目录),本节不另给归宿`);
} else if (agent.companyId) {
// agent-chat 链路。没有公司就没有交付物归宿,宁可不写这一行 —— 编一个
// data/companies/_global/workspace 出来,等于给产物造了第二个家,而产物有且只有一个家(§5.4)。
lines.push(`- 你的交付物必须写到:${companyWorkspaceDir(agent.companyId)}/ —— 同事共享,会被索引`);
}
return lines.join('\n');
}
/**
* 构建完整的 system prompt
* - SKILL.md 模式: 直接使用 SKILL.md(已包含 protocol),不再叠加 protocol
* - 旧模式: protocol + agent prompt(向后兼容非 Innovator agent)
*/
build(agentName, userTask = '') {
const agent = this.config.agents[agentName];
if (!agent) {
throw new Error(`Agent not found: ${agentName}`);
}
const parts = [];
const promptPath = agent.prompt || agent.systemPrompt || '';
const isSkillBased = promptPath.endsWith('SKILL.md');
if (isSkillBased) {
// SKILL.md 已包含 protocol,直接使用
const skillPrompt = this.getAgentPrompt(agentName);
if (skillPrompt) parts.push(skillPrompt);
} else {
// 旧模式:先加 protocol,再加 agent prompt
const protocol = this.getProtocol(agent.type);
if (protocol) {
let processedProtocol = protocol;
if (agent.type === 'master') {
processedProtocol = processedProtocol.replace(
'{{AVAILABLE_AGENTS}}',
this.buildAvailableAgentsList()
);
}
parts.push(processedProtocol);
}
const agentPrompt = this.getAgentPrompt(agentName);
if (agentPrompt) parts.push(agentPrompt);
if (agent.companyContextPath && fs.existsSync(agent.companyContextPath)) {
// 同样绕开 this.loadFile 的进程级缓存:company-context.md 是**运行态**(招聘、
// company-service 改公司信息都会重写它),缓存住等于把公司语境冻结在进程启动那一刻,
// 与 getAgentPrompt / buildCompanyStateParts 是同一条理由。protocol 才是仓库里的静态文件。
parts.push(fs.readFileSync(agent.companyContextPath, 'utf-8'));
}
}
// 公司态 + 运行时坐标:**两条分支都要装**。SKILL.md 模式跳过的是 protocol 和
// company-context.md 的拼接,不是「这个 agent 不属于任何公司、不在任何目录里跑」——
// 少装的后果是它照样跑、照样成功,只是没有记忆、把产物写在会被清理的 cwd 里。
parts.push(...this.buildCompanyStateParts(agent, agentName));
const coordinates = this.buildRuntimeCoordinates(agent, userTask);
if (coordinates) parts.push(coordinates);
// User Task
if (userTask) {
parts.push(`---\n\n## 当前任务\n\n${userTask}`);
}
// 在最终拼接前,注入上帝视角的环境感知
parts.unshift(this.getSystemStateXML());
let result = parts.join('\n\n---\n\n');
// 替换 {{AVAILABLE_AGENTS}} 占位符(可能在 SKILL.md 或 protocol 中)
if (result.includes('{{AVAILABLE_AGENTS}}')) {
result = result.replace(/\{\{AVAILABLE_AGENTS\}\}/g, this.buildAvailableAgentsList());
}
return result;
}
}
module.exports = {
PromptBuilder,
companyWorkspaceDir,
GRAPH_WRITE_BOUNDARY_HEADER,
taskCarriesGraphWriteBoundary,
};