deep-research.js 5.42 KB
/**
 * deep-research —— 按需触发 org-hunt 做一次轻量调研(46 号文档 Phase 2)。
 *
 * org-hunt 目前只能作为图节点被 `workgraph-planning` 动态选中调用(`deliverable.json`
 * 模板,FDE 训练场景),交付物是 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json`
 * (≥50 条)那套重契约。这里**不**套用那套契约——"task 入队前的快速调研参考"是时间敏感
 * 场景,直接照训练场景的产出量级要求会不可控地慢。org-hunt 的图节点契约、`deliverable.json`
 * 模板本身都不动,这里只是另开一条**图外**的调用路径,让它以"轻量问答"的形式被叫起来
 * (调用方式仿 `dream-service.callDreamAgent`:直接 `AgentManager.callAgent`,不经图引擎)。
 *
 * **默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量):org-hunt 的延迟/成功率在"日常任务规划、
 * 时间敏感"场景下从未验证过(46 号文档「已知的高风险点」原话),且 subtask-planning 这条
 * 触发链已经因为疏于隔离出过一次真事故(44 个残留 CLI 子进程,见 subtask-planning.js 头注释)。
 * 不开这个开关,这个模块连 org-hunt 都不会尝试雇佣/调用——只打一行日志说明跳过。
 */
const path = require('path');

const RESEARCH_AGENT_ID = 'org-hunt';

// ── 注入点(仿 subtask-planning.setPlanSubtasks)──────────────────────
let triggerImpl = defaultTriggerDeepResearch;
let isInjected = false;

/** 替换调研实现(传 null 回默认实现)。测试用,避免真起 CLI */
function setTriggerDeepResearch(fn) {
  triggerImpl = fn || defaultTriggerDeepResearch;
  isInjected = !!fn;
}

/**
 * @param {{companyId: string, task: object, topic: string}} ctx
 * @returns {Promise<{findings: string, sources: string[], tokens: number} | null>}
 *   `null` = 没跑(开关关闭 / 测试环境未注入 / 调用失败 / 没解析出结构化结果)——
 *   调用方一律当"没有调研结果"处理,不是错误,不该抛出去打断规划流程。
 */
async function triggerDeepResearch(ctx) {
  if (process.env.ENABLE_DEEP_RESEARCH !== '1') {
    console.log('[DeepResearch] ENABLE_DEEP_RESEARCH 未开启,跳过(默认关闭,见文件头注释)');
    return null;
  }
  if (process.env.VITEST && !isInjected) {
    console.log('[DeepResearch] VITEST 环境且未显式注入,跳过真实调用(避免测试意外起 CLI 子进程)');
    return null;
  }
  try {
    return await triggerImpl(ctx);
  } catch (err) {
    console.warn(`[DeepResearch] 触发失败,按"没有调研结果"处理:${err.message}`);
    return null;
  }
}

/** 寻址名。与 `subtask-planning.resolvePlanningAgentName` 同一个模式:按需幂等雇一次 */
function resolveResearchAgentName(companyId) {
  if (!companyId) return RESEARCH_AGENT_ID;
  const { CompanyService, listCompanyAgents } = require('./company-service');
  const isHired = () => listCompanyAgents(companyId)
    .some((r) => r.role_name === RESEARCH_AGENT_ID || r.system_agent_id === RESEARCH_AGENT_ID);
  if (!isHired()) {
    try {
      new CompanyService().hire(companyId, RESEARCH_AGENT_ID, {});
    } catch (e) {
      console.warn(`[DeepResearch] 按需雇佣 ${RESEARCH_AGENT_ID} 失败(${companyId}): ${e.message}`);
    }
  }
  return isHired() ? `${companyId}:${RESEARCH_AGENT_ID}` : RESEARCH_AGENT_ID;
}

function buildResearchPrompt({ topic }) {
  return [
    '这是一次**任务规划前的快速调研参考**,不是你平时的训练场景任务——',
    '不需要按你 `agent.md` 平时的产出契约交 `knowhow_raw.md`/`crawl_config.json`/`seed_data.json` 三件套,',
    '也不需要凑够 ≥50 条种子数据。只需要快速查一下下面这件事业界/公开资料里有没有值得参考的方法论,',
    '给一段简短总结即可。',
    '',
    '## 调研主题',
    topic || '(调用方没给主题)',
    '',
    '## 交回方式(只读你回复里最后一个 ```json 代码块)',
    '```json',
    '{"findings": "…(几句话总结,没查到就写清楚没查到)", "sources": ["url 或来源描述", "…"]}',
    '```',
    '查不到有用信息就如实说 `findings` 里没有可用结论、`sources` 给 `[]`——不要编。',
  ].join('\n');
}

async function defaultTriggerDeepResearch({ companyId, task, topic }) {
  const AgentManager = require('../src/core/agent-manager');
  const { extractJson } = require('./dream-service');
  const manager = new AgentManager({}, path.resolve(__dirname, '../..'));
  const agentName = resolveResearchAgentName(companyId);

  let tokens = 0;
  const onAcpEvent = (ev) => {
    if (!ev || ev.method !== 'session/result') return;
    const u = (ev.params && ev.params.usage) || null;
    if (u) tokens += (u.input || 0) + (u.output || 0);
  };

  const res = await manager.callAgent(agentName, buildResearchPrompt({ topic }), {
    onAcpEvent,
    metadata: { taskId: task && task.id, layer: 'deep-research', companyId },
  });

  const obj = extractJson(res && res.output, 'object');
  if (!obj || typeof obj.findings !== 'string' || !obj.findings.trim()) return null;
  return {
    findings: obj.findings.trim(),
    sources: Array.isArray(obj.sources) ? obj.sources.filter((s) => typeof s === 'string') : [],
    tokens,
  };
}

module.exports = {
  RESEARCH_AGENT_ID,
  triggerDeepResearch,
  setTriggerDeepResearch,
  buildResearchPrompt,
  resolveResearchAgentName,
};