/** * PromptBuilder - 根据 Agent 类型拼接 protocol + prompt */ 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)。 * * 导出出去是为了让 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 = '\n'; state += ` ${new Date().toISOString()}\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 += ' \n'; for (const c of companies) { state += ` \n`; const agents = cs.listAgents(c.id); if (agents && agents.length > 0) { for (const a of agents) { state += ` \n`; } } const gs = new OkrService(c.id); const goals = gs.list(); if (goals && goals.length > 0) { state += ' \n'; for (const g of goals.filter(g => g.status !== 'done')) { state += ` \n`; } state += ' \n'; } state += ` \n`; } state += ' \n'; } const globalGs = new OkrService(); const globalGoals = globalGs.list(); if (globalGoals && globalGoals.length > 0) { state += ' \n'; for (const g of globalGoals.filter(g => g.status !== 'done')) { state += ` \n`; } state += ' \n'; } } catch (e) { state += ` \n`; } state += '\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')); } // 「本体装配静默坑」修复: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 和 // 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, };