loop.js 9.9 KB

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