trace.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 运行留痕与会话索引
  4. * ----------------------------------------------------------------------------
  5. * 文件布局(data/agent/,GENPIC_AGENT_DIR 环境变量可覆盖——smoke 隔离用):
  6. * <entityKey>/runs/<runId>.json 单次运行全量(步骤/事件/结论,成败都留痕)
  7. * <entityKey>/sessions.json 会话索引(标题=首问,runs 摘要,双上限防膨胀)
  8. * 内存中的 run 注册表在 index.js;这里只管磁盘侧。历史结论回灌(压缩:
  9. * 只带问答文字,不带工具观察)供多轮追问。
  10. */
  11. const fs = require('fs');
  12. const path = require('path');
  13. const crypto = require('crypto');
  14. const AGENT_DIR = process.env.GENPIC_AGENT_DIR
  15. ? path.resolve(process.env.GENPIC_AGENT_DIR)
  16. : path.join(__dirname, '..', '..', 'data', 'agent');
  17. const MAX_SESSIONS = 30;
  18. const RUNS_PER_SESSION = 30;
  19. function readJsonSafe(file, fallback) {
  20. try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
  21. }
  22. function writeJsonAtomic(file, obj) {
  23. fs.mkdirSync(path.dirname(file), { recursive: true });
  24. const temp = file + '.tmp';
  25. fs.writeFileSync(temp, JSON.stringify(obj), 'utf8');
  26. fs.renameSync(temp, file);
  27. }
  28. function sanitizeKey(v) {
  29. return String(v || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
  30. }
  31. function newId(prefix) {
  32. return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
  33. }
  34. // ---------- 运行记录 ----------
  35. function newRunRecord({ entity, entityName, question, mode, sessionId }) {
  36. return {
  37. runId: newId('run'),
  38. entity: sanitizeKey(entity),
  39. entityName: entityName || entity,
  40. sessionId: sessionId || newId('s'),
  41. mode: mode === 'quick' ? 'quick' : 'agent',
  42. question: String(question || '').slice(0, 500),
  43. status: 'running',
  44. cancelRequested: false,
  45. steps: [],
  46. events: [],
  47. verdicts: [],
  48. provenance: null,
  49. error: null,
  50. usage: { llmCalls: 0, toolCalls: 0 },
  51. result: null,
  52. startedAt: new Date().toISOString(),
  53. finishedAt: null,
  54. };
  55. }
  56. function pushEvent(rec, type, payload = {}) {
  57. rec.events.push({ t: new Date().toISOString(), type, ...payload });
  58. if (rec.events.length > 400) rec.events.splice(0, rec.events.length - 400);
  59. }
  60. function runFile(entityKey, runId) {
  61. return path.join(AGENT_DIR, sanitizeKey(entityKey), 'runs', `${String(runId).replace(/[^a-zA-Z0-9_-]/g, '')}.json`);
  62. }
  63. function persistRun(rec) {
  64. try { writeJsonAtomic(runFile(rec.entity, rec.runId), rec); } catch { /* 留痕失败不影响主流程 */ }
  65. }
  66. function loadRun(entityKey, runId) {
  67. return readJsonSafe(runFile(entityKey, runId), null);
  68. }
  69. // ---------- 会话索引 ----------
  70. function sessionsFile(entityKey) {
  71. return path.join(AGENT_DIR, sanitizeKey(entityKey), 'sessions.json');
  72. }
  73. function loadSessions(entityKey) {
  74. return readJsonSafe(sessionsFile(entityKey), { sessions: [] }).sessions;
  75. }
  76. function saveSessions(entityKey, sessions) {
  77. writeJsonAtomic(sessionsFile(entityKey), { sessions });
  78. }
  79. /** 把一次运行登记进会话索引(会话不存在则以该问题为标题创建;双上限裁剪) */
  80. function upsertSessionRun(entityKey, { sessionId, runId, question, mode, status, at }) {
  81. const sessions = loadSessions(entityKey);
  82. let session = sessions.find((s) => s.sessionId === sessionId);
  83. if (!session) {
  84. session = { sessionId, title: String(question || '新会话').slice(0, 40), createdAt: at, runs: [] };
  85. sessions.unshift(session);
  86. }
  87. if (!Array.isArray(session.runs)) session.runs = [];
  88. session.updatedAt = at;
  89. const entry = { runId, question: String(question || '').slice(0, 200), mode, status, at };
  90. const idx = session.runs.findIndex((r) => r.runId === runId);
  91. if (idx >= 0) session.runs[idx] = entry;
  92. else session.runs.unshift(entry);
  93. if (session.runs.length > RUNS_PER_SESSION) session.runs.length = RUNS_PER_SESSION;
  94. saveSessions(entityKey, sessions.slice(0, MAX_SESSIONS));
  95. }
  96. /** 删除会话:摘除索引并清理该会话的全部运行文件 */
  97. function deleteSession(entityKey, sessionId) {
  98. const sessions = loadSessions(entityKey);
  99. const idx = sessions.findIndex((s) => s.sessionId === sessionId);
  100. if (idx < 0) return false;
  101. const [gone] = sessions.splice(idx, 1);
  102. saveSessions(entityKey, sessions);
  103. for (const r of gone.runs || []) {
  104. try { fs.unlinkSync(runFile(entityKey, r.runId)); } catch { /* 已不存在 */ }
  105. }
  106. return true;
  107. }
  108. /**
  109. * 压缩历史:取该会话最近若干次成功运行的「问 / 答」文字回灌为新运行的上文。
  110. * 工具观察一律不带(防上下文随轮次膨胀),模型需要的数据重新取。
  111. */
  112. function loadRecentConclusions(entityKey, sessionId, limit = 4) {
  113. const session = loadSessions(entityKey).find((s) => s.sessionId === sessionId);
  114. if (!session) return [];
  115. const done = session.runs.filter((r) => r.status === 'done').slice(0, limit);
  116. const out = [];
  117. for (const r of done.reverse()) {
  118. const rec = loadRun(entityKey, r.runId);
  119. if (!rec) continue;
  120. const answer = rec.result && rec.result.answer;
  121. if (!answer) continue;
  122. out.push({ role: 'user', content: rec.question });
  123. out.push({ role: 'assistant', content: String(answer).slice(0, 1500) });
  124. }
  125. return out;
  126. }
  127. module.exports = {
  128. AGENT_DIR, newRunRecord, pushEvent, persistRun, loadRun,
  129. loadSessions, upsertSessionRun, deleteSession, loadRecentConclusions, newId, sanitizeKey,
  130. };