loop.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 主循环(harness 核心)
  4. * ----------------------------------------------------------------------------
  5. * 每回合:LLM → parseAgentAction → 护栏(求交/钳制/预算/循环侦测)→ 工具执行
  6. * → 观察回注 → …,直到模型给出 final 或触发预算强制收敛。
  7. * 消息形状保持严格 user/assistant 交替;工具观察以 [工具结果] 文本回注(digest
  8. * 已在工具内裁剪)。取消在回合间隙检查(进行中的 HTTP 调用不可中断,属已知边界)。
  9. */
  10. const llm = require('./llm.js');
  11. const guard = require('./guard.js');
  12. const prompt = require('./prompt.js');
  13. const registry = require('./tools/registry.js');
  14. const skills = require('./skills.js');
  15. const metrics = require('./metrics.js');
  16. const provenance = require('./provenance.js');
  17. const trace = require('./trace.js');
  18. const nlQuery = require('../../nl-query.js');
  19. const z2 = (n) => String(n).padStart(2, '0');
  20. function cancelledError() {
  21. const e = new Error('已取消');
  22. e.code = 'AGENT_CANCELLED';
  23. return e;
  24. }
  25. /** 「应用到报表」载荷:取最后一次成功查询的钳制后参数,前端据此落报表筛选 */
  26. function appliedFrom(tool, args) {
  27. if (!args) return null;
  28. if (tool === 'query_report') {
  29. return { mode: args.mode, from: args.from, to: args.to, shopIds: args.shopIds || [], metrics: args.metrics };
  30. }
  31. if (tool === 'compare_periods') {
  32. return { mode: 'shop', from: args.from, to: args.to, shopIds: args.shops || [], metrics: args.metrics };
  33. }
  34. return null;
  35. }
  36. function fallbackAnswer(rec, reason) {
  37. const lastOk = [...rec.steps].reverse().find((s) => s.type === 'tool' && s.ok);
  38. if (lastOk && lastOk.summary) {
  39. return `(${reason},未生成完整结论)最后一步取数结果:\n\n${String(lastOk.summary).slice(0, 800)}`;
  40. }
  41. return `(${reason})本次未能完成分析,请重试或换一个更具体的问题。`;
  42. }
  43. /**
  44. * 运行一次智能体回合循环。
  45. * @param {object} o { rec, svc, cfg: {chatApiBase,chatApiKey,chatModel}, mock: {next(rec)}|null }
  46. * @returns {object} { answer, chart, appliedFrom, thought }
  47. */
  48. async function runAgent(o) {
  49. const { rec, svc, cfg, mock } = o;
  50. const meta = svc.store.getEntity(rec.entity) || {};
  51. const shops = meta.shops || [];
  52. const dateRange = nlQuery.lakeDateRange(rec.entity, svc);
  53. const ctx = { svc, entityKey: rec.entity, meta, shops, dateRange, cloud: o.cloud || null };
  54. const today = (() => {
  55. const d = new Date();
  56. return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
  57. })();
  58. const messages = [{
  59. role: 'system',
  60. content: prompt.buildSystemPrompt({
  61. svc, entityName: meta.name || rec.entity, shops, dateRange, today,
  62. toolsText: registry.manifestText(),
  63. skillsText: skills.manifestText(),
  64. metricsText: metrics.manifestText(),
  65. pointedSkill: rec.skill ? skills.get(rec.skill) : null,
  66. fewShotsText: o.fewShotsText || '',
  67. }),
  68. }];
  69. for (const h of rec.history || []) messages.push(h);
  70. messages.push({ role: 'user', content: rec.question });
  71. const startedAt = Date.now();
  72. let repairs = 0;
  73. let repeatWarned = false;
  74. let lastApplied = null;
  75. let lastReportUrl = null;
  76. const detector = new guard.LoopDetector();
  77. const callModel = async (msgs) => {
  78. rec.usage.llmCalls += 1;
  79. trace.pushEvent(rec, 'llm.call', { turns: msgs.length, mock: !!mock });
  80. const t0 = Date.now();
  81. if (mock) {
  82. return { reply: mock.next(rec), usage: null, ms: Date.now() - t0 };
  83. }
  84. // 模型路由:深度分析优先用专用模型(chatModelAgent),缺省回落主对话模型
  85. const out = await llm.chatCompletionDetailed({
  86. apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey,
  87. model: cfg.chatModelAgent || cfg.chatModel,
  88. messages: msgs, timeoutMs: guard.BUDGET.llmTimeoutMs, temperature: 0.2,
  89. jsonMode: !!cfg.chatJsonMode,
  90. });
  91. // 治理:run 级 token 用量累计(成本展示)
  92. if (out.usage) {
  93. rec.usage.tokensIn = (rec.usage.tokensIn || 0) + (out.usage.promptTokens || 0);
  94. rec.usage.tokensOut = (rec.usage.tokensOut || 0) + (out.usage.completionTokens || 0);
  95. }
  96. return { reply: out.content, usage: out.usage, ms: Date.now() - t0 };
  97. };
  98. const finishWith = (parsed) => {
  99. // 数字溯源 post-check(零 token 规则):回答中的数值必须能在工具结果里找到出处
  100. const prov = provenance.checkProvenance(parsed.final.answer, rec.steps);
  101. rec.provenance = { total: prov.total, untracedCount: prov.untraced.length, untraced: prov.untraced, ratio: prov.ratio };
  102. return {
  103. answer: parsed.final.answer,
  104. chart: guard.clampChart(parsed.final.chart, svc),
  105. appliedFrom: lastApplied,
  106. reportUrl: lastReportUrl,
  107. thought: parsed.thought || '',
  108. };
  109. };
  110. /** 工具执行(主循环与复用打法共用):留痕/verdict 归档/应用与报告链接捕获 */
  111. const executeTool = async (toolName, tool, args) => {
  112. rec.usage.toolCalls += 1;
  113. const step = { type: 'tool', tool: toolName, args, ok: false, ms: 0 };
  114. rec.steps.push(step);
  115. trace.pushEvent(rec, 'tool.start', { tool: toolName });
  116. const t0 = Date.now();
  117. try {
  118. const out = await tool.handler(ctx, args);
  119. step.ok = true;
  120. step.summary = String(out.summary || '').slice(0, 4000);
  121. step.data = out.data || null;
  122. if (out.data && out.data.totalRows != null) step.rows = out.data.totalRows;
  123. step.bytes = JSON.stringify(out.data || null).length;
  124. // 校验类工具的 verdict 归档(quality/identity/cross-layer/freshness/coverage/cloud)
  125. if (out.data && Array.isArray(out.data.verdicts)) {
  126. for (const v of out.data.verdicts) {
  127. rec.verdicts.push({ id: `v${rec.verdicts.length + 1}`, tool: toolName, ...v });
  128. }
  129. }
  130. const applied = appliedFrom(toolName, args);
  131. if (applied) lastApplied = applied;
  132. if (toolName === 'build_report' && out.data && out.data.url) lastReportUrl = out.data.url;
  133. } catch (e) {
  134. step.error = String(e.message || e).slice(0, 500);
  135. } finally {
  136. step.ms = Date.now() - t0;
  137. trace.pushEvent(rec, 'tool.done', { tool: toolName, ok: step.ok, ms: step.ms, error: step.error });
  138. }
  139. return step;
  140. };
  141. const forceFinal = async (reason) => {
  142. messages.push({ role: 'user', content: `${reason}:请立即基于以上已有信息输出 final(JSON),不要再调用工具。` });
  143. try {
  144. const turn = await callModel(messages);
  145. const parsed = llm.parseAgentAction(turn.reply);
  146. if (parsed.type === 'final') return finishWith(parsed);
  147. } catch { /* 强制收敛失败走兜底话术 */ }
  148. return { answer: fallbackAnswer(rec, reason), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
  149. };
  150. // 打法复用:命中已验证查询序列 → 直接重跑(缓存的是打法,数据按当前湖重算)→ 单次收敛
  151. if (o.plan && Array.isArray(o.plan.steps) && o.plan.steps.length) {
  152. rec.replayFrom = o.plan.question;
  153. rec.steps.push({ type: 'thought', text: `命中已验证打法「${o.plan.question}」,直接复用其查询序列(数据按当前湖重算)`, ms: 0 });
  154. for (const ps of o.plan.steps) {
  155. if (rec.cancelRequested) throw cancelledError();
  156. const tool = registry.get(ps.tool);
  157. if (!tool) continue;
  158. let args;
  159. try { args = tool.sanitize(ps.args || {}, ctx); } catch { continue; } // 口径变更等导致的参数失配:跳过该步
  160. const step = await executeTool(ps.tool, tool, args);
  161. messages.push({ role: 'user', content: `[工具结果] ${ps.tool}\n${step.summary || step.error || ''}` });
  162. }
  163. messages.push({ role: 'user', content: '以上为按既有打法重跑的最新数据结果,请立即基于其输出 final(JSON),不要再调用工具。' });
  164. try {
  165. const turn = await callModel(messages);
  166. const parsed = llm.parseAgentAction(turn.reply);
  167. if (parsed.type === 'final') return finishWith(parsed);
  168. } catch { /* 收敛失败走兜底 */ }
  169. return { answer: fallbackAnswer(rec, '复用打法收敛失败'), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
  170. }
  171. while (true) {
  172. if (rec.cancelRequested) throw cancelledError();
  173. // 预算(轮次/工具次数/墙钟)触发强制收敛:给模型最后一次输出 final 的机会
  174. if (rec.usage.llmCalls >= guard.BUDGET.maxTurns
  175. || rec.usage.toolCalls >= guard.BUDGET.maxToolCalls
  176. || Date.now() - startedAt >= guard.BUDGET.runTimeoutMs) {
  177. return forceFinal('已达预算上限');
  178. }
  179. let reply;
  180. let turnMeta = null;
  181. try {
  182. turnMeta = await callModel(messages);
  183. reply = turnMeta.reply;
  184. } catch (e) {
  185. throw new Error('对话模型调用失败:' + (e.message || e));
  186. }
  187. let parsed;
  188. try {
  189. parsed = llm.parseAgentAction(reply);
  190. repairs = 0;
  191. } catch (e) {
  192. if (repairs >= guard.BUDGET.maxRepairs) throw new Error('模型连续输出无法解析:' + (e.message || e));
  193. repairs += 1;
  194. trace.pushEvent(rec, 'notice', { text: '解析失败,回注修复' });
  195. messages.push({ role: 'assistant', content: String(reply).slice(0, 2000) });
  196. messages.push({ role: 'user', content: `你的输出无法解析(${e.message})。请重新输出:只输出一个 JSON 对象,action 或 final 二选一。` });
  197. continue;
  198. }
  199. rec.steps.push({
  200. type: 'thought',
  201. turn: rec.usage.llmCalls,
  202. text: parsed.thought,
  203. ms: turnMeta.ms,
  204. tokensIn: turnMeta.usage ? turnMeta.usage.promptTokens : null,
  205. tokensOut: turnMeta.usage ? turnMeta.usage.completionTokens : null,
  206. });
  207. messages.push({ role: 'assistant', content: String(reply).slice(0, 4000) });
  208. if (parsed.type === 'final') {
  209. return finishWith(parsed);
  210. }
  211. // 工具名与清单求交(防幻觉工具名)
  212. const tool = registry.get(parsed.tool);
  213. if (!tool) {
  214. if (repairs >= guard.BUDGET.maxRepairs) throw new Error(`模型连续引用不存在的工具(${parsed.tool})`);
  215. repairs += 1;
  216. trace.pushEvent(rec, 'notice', { text: `未知工具 ${parsed.tool},回注清单` });
  217. messages.push({
  218. role: 'user',
  219. content: `工具「${parsed.tool}」不存在。可用工具:\n${registry.manifestText()}\n请重新输出动作 JSON。`,
  220. });
  221. continue;
  222. }
  223. // 参数钳制(sanitize 抛错 = 参数非法,回注让模型修正)
  224. let args;
  225. try {
  226. args = tool.sanitize(parsed.args, ctx);
  227. } catch (e) {
  228. if (repairs >= guard.BUDGET.maxRepairs) throw new Error(`工具参数连续非法(${parsed.tool}):${e.message || e}`);
  229. repairs += 1;
  230. trace.pushEvent(rec, 'notice', { text: `${parsed.tool} 参数非法,回注修复` });
  231. messages.push({ role: 'user', content: `工具「${parsed.tool}」参数非法:${e.message || e}。请修正后重新输出动作 JSON。` });
  232. continue;
  233. }
  234. // 循环侦测:完全相同的调用先提醒,再犯强制收敛
  235. const verdict = detector.record(parsed.tool, args);
  236. if (verdict === 'loop') {
  237. if (repeatWarned) {
  238. trace.pushEvent(rec, 'notice', { text: '重复调用循环,强制收敛' });
  239. return { answer: fallbackAnswer(rec, '检测到重复调用'), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
  240. }
  241. repeatWarned = true;
  242. messages.push({ role: 'user', content: '提醒:你已连续多次发出完全相同的调用。若信息已足够请立即输出 final;确需重试请更换参数。' });
  243. continue;
  244. }
  245. const step = await executeTool(parsed.tool, tool, args);
  246. messages.push(step.ok
  247. ? { role: 'user', content: `[工具结果] ${parsed.tool}\n${step.summary}` }
  248. : { role: 'user', content: `[工具失败] ${parsed.tool}:${step.error}。请修正参数重试或换路径。` });
  249. if (step.ok && verdict === 'repeat') {
  250. messages.push({ role: 'user', content: '提醒:这与上一次调用完全相同。若信息已足够请输出 final;确需重试请更换参数。' });
  251. }
  252. }
  253. }
  254. module.exports = { runAgent };