prompt-builder.js 14.5 KB
/**
 * 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 = '<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'));
      }
      // 「本体装配静默坑」修复: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,
};