agent-runtime.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929
  1. const crypto = require('crypto');
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { spawn } = require('child_process');
  5. const { AgentContextBuilder, selectAuthoritativeHistory } = require('./agent-context-builder');
  6. const RISK_PATTERN = /合同|签约|资质|合规|保证|承诺|最低价|贷款|利率|投诉|退款|发票|身份证|银行卡|法律|违约/;
  7. const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
  8. function isNoReplyNeededMessage(input = '') {
  9. const normalized = String(input || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim();
  10. return Boolean(normalized && NO_REPLY_NEEDED_PATTERN.test(normalized));
  11. }
  12. function claudeSessionResetReason(error) {
  13. const detail = `${error?.message || ''} ${error?.subtype || ''}`;
  14. if (/error_max_budget_usd|max[-_\s]?budget[-_\s]?usd/i.test(detail)) return 'budget_exceeded';
  15. if (/session|conversation|resume/i.test(detail)) return 'session_invalid';
  16. return '';
  17. }
  18. function parseClaudeProcessResult(stdout = '', stderr = '', code = 0) {
  19. let payload = null;
  20. try { payload = JSON.parse(String(stdout || '').trim()); } catch {}
  21. if (code === 0) {
  22. return payload
  23. ? { payload }
  24. : { error: `Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}` };
  25. }
  26. const structured = payload?.structured_output;
  27. const usableStructuredOutput = structured
  28. && typeof structured === 'object'
  29. && !Array.isArray(structured)
  30. && Object.prototype.hasOwnProperty.call(structured, 'reply')
  31. && Object.prototype.hasOwnProperty.call(structured, 'confidence')
  32. && Object.prototype.hasOwnProperty.call(structured, 'intent');
  33. const safeError = String(stderr || '')
  34. .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
  35. .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
  36. .trim()
  37. .slice(0, 300);
  38. const structuredError = String(payload?.error || payload?.message || payload?.result || payload?.subtype || '').trim().slice(0, 300);
  39. const detail = safeError || structuredError || `退出码 ${code}`;
  40. if (usableStructuredOutput) {
  41. return {
  42. payload: {
  43. ...payload,
  44. is_error: false,
  45. process_warning: { exitCode: code, detail },
  46. },
  47. };
  48. }
  49. return { error: `Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}` };
  50. }
  51. class AgentNotConfiguredError extends Error {
  52. constructor() {
  53. super('Agent 模型尚未配置,消息已保留但不会生成伪造回复');
  54. this.name = 'AgentNotConfiguredError';
  55. }
  56. }
  57. class OpenAICompatibleClient {
  58. constructor(config) { this.config = config; }
  59. async complete(messages, tools) {
  60. if (!this.config.apiKey) throw new AgentNotConfiguredError();
  61. const body = {
  62. model: this.config.model,
  63. temperature: 0.2,
  64. messages,
  65. };
  66. if (Array.isArray(tools) && tools.length) {
  67. body.tools = tools;
  68. body.tool_choice = 'auto';
  69. }
  70. const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
  71. method: 'POST',
  72. headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}` },
  73. body: JSON.stringify(body),
  74. signal: AbortSignal.timeout(30000),
  75. });
  76. const payload = await response.json().catch(() => ({}));
  77. if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`);
  78. const message = payload.choices?.[0]?.message;
  79. if (!message) throw new Error('Agent 上游没有返回有效消息');
  80. return message;
  81. }
  82. }
  83. class AnthropicCompatibleClient {
  84. constructor(config) { this.config = config; }
  85. toAnthropicMessages(messages) {
  86. return messages.filter(message => message.role !== 'system').map(message => {
  87. if (message.role === 'tool') {
  88. return { role: 'user', content: [{ type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content }] };
  89. }
  90. if (message.role === 'assistant' && message.tool_calls?.length) {
  91. const content = [];
  92. if (message.content) content.push({ type: 'text', text: message.content });
  93. for (const call of message.tool_calls) {
  94. let input = {};
  95. try { input = JSON.parse(call.function.arguments || '{}'); } catch {}
  96. content.push({ type: 'tool_use', id: call.id, name: call.function.name, input });
  97. }
  98. return { role: 'assistant', content };
  99. }
  100. return { role: message.role, content: message.content };
  101. });
  102. }
  103. async complete(messages, tools) {
  104. if (!this.config.apiKey) throw new AgentNotConfiguredError();
  105. const system = messages.find(message => message.role === 'system')?.content || '';
  106. const body = {
  107. model: this.config.model,
  108. max_tokens: 1400,
  109. temperature: 0.2,
  110. system,
  111. messages: this.toAnthropicMessages(messages),
  112. };
  113. if (Array.isArray(tools) && tools.length) {
  114. body.tools = tools.map(tool => ({
  115. name: tool.function.name,
  116. description: tool.function.description,
  117. input_schema: tool.function.parameters,
  118. }));
  119. }
  120. const response = await fetch(`${this.config.baseUrl}/v1/messages`, {
  121. method: 'POST',
  122. headers: {
  123. 'Content-Type': 'application/json',
  124. 'anthropic-version': '2023-06-01',
  125. 'x-api-key': this.config.apiKey,
  126. Authorization: `Bearer ${this.config.apiKey}`,
  127. },
  128. body: JSON.stringify(body),
  129. signal: AbortSignal.timeout(45000),
  130. });
  131. const payload = await response.json().catch(() => ({}));
  132. if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`);
  133. const blocks = Array.isArray(payload.content) ? payload.content : [];
  134. const toolCalls = blocks.filter(block => block.type === 'tool_use').map(block => ({
  135. id: block.id,
  136. function: { name: block.name, arguments: JSON.stringify(block.input || {}) },
  137. }));
  138. return {
  139. content: blocks.filter(block => block.type === 'text').map(block => block.text).join('\n'),
  140. ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
  141. };
  142. }
  143. }
  144. function readJson(filePath, fallback = {}) {
  145. try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
  146. catch { return fallback; }
  147. }
  148. function writeJsonAtomic(filePath, value) {
  149. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  150. const tempPath = `${filePath}.${process.pid}.tmp`;
  151. fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
  152. fs.renameSync(tempPath, filePath);
  153. }
  154. function resolveClaudeExecutable(config = {}) {
  155. const pathCandidates = String(process.env.PATH || '')
  156. .split(path.delimiter)
  157. .map(item => item.trim().replace(/^"|"$/g, ''))
  158. .filter(Boolean)
  159. .flatMap(dir => process.platform === 'win32'
  160. ? [
  161. path.join(dir, 'claude.exe'),
  162. path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  163. ]
  164. : [path.join(dir, 'claude')]);
  165. const candidates = [
  166. config.claudeExecutable,
  167. process.env.CLAUDE_CODE_EXECUTABLE,
  168. ...pathCandidates,
  169. path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  170. path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  171. ].filter(Boolean);
  172. return candidates.find(candidate => fs.existsSync(candidate)) || '';
  173. }
  174. function normalizeSessionLabel(value) {
  175. return String(value || '企微客户')
  176. .trim()
  177. .replace(/[\\/:*?"<>|\r\n]+/g, '-')
  178. .replace(/\s+/g, '-')
  179. .replace(/-+/g, '-')
  180. .replace(/^-|-$/g, '')
  181. .slice(0, 24) || '企微客户';
  182. }
  183. function buildClaudeSessionName(context = {}, key = '') {
  184. const customerName = normalizeSessionLabel(
  185. context.conversation?.contact_name || context.conversation?.displayName || '企微客户'
  186. );
  187. const reference = crypto.createHash('sha256').update(String(key || 'qiwei-default')).digest('hex').slice(0, 4);
  188. return `企微客户-${customerName}-${reference}`;
  189. }
  190. function uniqueIntelligence(items = [], keyFn) {
  191. const seen = new Set();
  192. return items.filter(item => {
  193. const key = keyFn(item);
  194. if (!key || seen.has(key)) return false;
  195. seen.add(key);
  196. return true;
  197. });
  198. }
  199. function normalizedEvidence(value) {
  200. return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
  201. }
  202. function evidenceIsSupported(evidence, authoritativeText) {
  203. const source = normalizedEvidence(authoritativeText);
  204. const claim = normalizedEvidence(evidence);
  205. if (!source || !claim) return false;
  206. return source.includes(claim) || (source.length >= 4 && claim.includes(source));
  207. }
  208. function supportedModelProfileUpdates(updates = {}, authoritativeText = '') {
  209. if (!updates || typeof updates !== 'object' || Array.isArray(updates)) return {};
  210. const explicitNumbers = new Set(String(authoritativeText || '').match(/\d+(?:\.\d+)?/g) || []);
  211. return Object.fromEntries(Object.entries(updates).filter(([, value]) => {
  212. if (value === undefined || value === null || value === '') return false;
  213. const values = Array.isArray(value) ? value : [value];
  214. return values.every(item => typeof item === 'number'
  215. ? explicitNumbers.has(String(item))
  216. : evidenceIsSupported(String(item), authoritativeText));
  217. }));
  218. }
  219. function unsupportedAttributedClaims(final = {}, authoritativeHistory = []) {
  220. const output = `${String(final.reply || '')}\n${String(final.reason || '')}`;
  221. const source = authoritativeHistory.map(item => item.content || '').join('\n');
  222. const claims = [];
  223. const marker = /(?:您|客户)(?:之前|此前|刚才)?(?:曾经)?(?:提到|说过|说|表示)/g;
  224. for (const match of output.matchAll(marker)) {
  225. const tail = output.slice((match.index || 0) + match[0].length, (match.index || 0) + match[0].length + 100);
  226. const quoted = tail.match(/^\s*[::]?\s*["“‘']([^"”’'\r\n]{2,60})["”’']/);
  227. if (quoted && !evidenceIsSupported(quoted[1], source)) claims.push(quoted[1].trim());
  228. }
  229. return [...new Set(claims)];
  230. }
  231. function groundedConfirmationReply(profile = {}, inboundContent = '') {
  232. const budget = Number(profile.budgetWan || profile.budget || 0);
  233. const intent = profile.intent || profile.purpose || profile.need || '';
  234. const confirmed = [intent, budget ? `预算 ${budget}` : '', profile.timeline || ''].filter(Boolean).join('、');
  235. const opening = confirmed ? `收到,我先按${confirmed}继续整理。` : '收到,您刚才的信息我已经记录。';
  236. const questions = [];
  237. if (!intent) questions.push('这次最希望解决的核心问题是什么');
  238. if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`预算 ${budget} 是目标值还是上限`);
  239. if (!profile.timeline) questions.push('希望什么时候推进');
  240. if (!questions.length) return `${opening}我会先核对可用方案,再给您准确回复。`;
  241. return `${opening}为了避免理解偏差,想再确认一下:${questions.join(';')}?`;
  242. }
  243. function enforceAuthoritativeGrounding(final = {}, authoritativeHistory = [], profile = {}, inboundContent = '') {
  244. const unsupported = unsupportedAttributedClaims(final, authoritativeHistory);
  245. if (!unsupported.length) return { ...final, groundingWarnings: [] };
  246. return {
  247. ...final,
  248. reply: groundedConfirmationReply(profile, inboundContent),
  249. reason: `检测到模型引用了本轮有效会话中不存在的客户原话,已降级为确认式草稿。未支持内容:${unsupported.join('、')}`,
  250. confidence: Math.min(clamp(final.confidence), 0.68),
  251. requiresHuman: true,
  252. groundingWarnings: unsupported,
  253. };
  254. }
  255. function extractExplicitCustomerIntelligence(content, currentProfile = {}, modelOutput = {}) {
  256. const text = String(content || '').trim();
  257. const profileUpdates = supportedModelProfileUpdates(modelOutput.profileUpdates, text);
  258. const amount = text.match(/(\d+(?:\.\d+)?)\s*(?:万|元)?/);
  259. if (amount && (/预算|费用|报价|价格/.test(text))) {
  260. profileUpdates.budgetWan = Number(amount[1]);
  261. profileUpdates.budgetType = /上限|最多/.test(text) ? '上限' : /目标|大概/.test(text) ? '目标' : (currentProfile.budgetType || '待确认');
  262. }
  263. if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) profileUpdates.urgency = '高';
  264. const mergedProfile = { ...currentProfile, ...profileUpdates };
  265. const factEvidence = [
  266. mergedProfile.intent || mergedProfile.purpose ? `意图${mergedProfile.intent || mergedProfile.purpose}` : '',
  267. mergedProfile.need || mergedProfile.needs ? `需求${mergedProfile.need || mergedProfile.needs}` : '',
  268. mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}` : '',
  269. mergedProfile.timeline ? `计划时间${mergedProfile.timeline}` : '',
  270. mergedProfile.decisionMaker ? `决策人${mergedProfile.decisionMaker}` : '',
  271. ].filter(Boolean).join(',').slice(0, 240);
  272. const evidence = text.slice(0, 240);
  273. const tasks = Array.isArray(modelOutput.tasks)
  274. ? modelOutput.tasks.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({
  275. ...item,
  276. businessKey: item.businessKey || item.key,
  277. managedBy: 'agent',
  278. }))
  279. : [];
  280. const alerts = Array.isArray(modelOutput.alerts)
  281. ? modelOutput.alerts.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({
  282. ...item,
  283. businessKey: item.businessKey || item.key,
  284. managedBy: 'agent',
  285. }))
  286. : [];
  287. const hasBudget = Number(mergedProfile.budgetWan || mergedProfile.budget || 0) > 0;
  288. const hasPurpose = Boolean(mergedProfile.intent || mergedProfile.purpose || mergedProfile.need || mergedProfile.needs);
  289. const hasTimeline = Boolean(mergedProfile.timeline);
  290. if ((hasBudget || hasPurpose) && (!hasPurpose || !hasTimeline)) {
  291. tasks.push({
  292. businessKey: 'qualification:purpose_and_timeline',
  293. managedBy: 'rule',
  294. type: 'qualification',
  295. title: '确认客户目标与计划时间',
  296. owner: '待分配',
  297. dueAt: '',
  298. priority: 'high',
  299. reason: `${[!hasPurpose ? '客户目标' : '', !hasTimeline ? '计划时间' : ''].filter(Boolean).join('和')}仍不明确。`,
  300. evidence: factEvidence || evidence,
  301. });
  302. }
  303. if (hasPurpose && hasTimeline) {
  304. alerts.push({
  305. businessKey: 'high_intent:core_demand_ready',
  306. managedBy: 'rule',
  307. type: 'high_intent',
  308. severity: 'high',
  309. title: '客户核心需求已基本成形',
  310. detail: '客户目标和计划时间已具备,可以从泛咨询进入下一步服务安排。',
  311. evidence: factEvidence || evidence,
  312. recommendedAction: '核对关键约束并确认下一步动作。',
  313. });
  314. }
  315. if (/投诉|不满意|骗人|退款|举报|再也不|太差|生气/.test(text)) {
  316. alerts.push({
  317. businessKey: 'complaint:manual_takeover',
  318. managedBy: 'event',
  319. type: 'complaint',
  320. severity: 'critical',
  321. title: '检测到投诉或强烈负面情绪',
  322. detail: '该消息不应由自动回复独立处理。',
  323. evidence,
  324. recommendedAction: '立即人工接管,先确认事实和客户诉求。',
  325. });
  326. } else if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) {
  327. alerts.push({
  328. businessKey: 'time_sensitive:follow_up',
  329. managedBy: 'event',
  330. type: 'time_sensitive',
  331. severity: 'high',
  332. title: '客户表达了明确时效要求',
  333. detail: '消息中包含较紧迫的时间表达。',
  334. evidence,
  335. recommendedAction: '优先确认具体截止时间并安排跟进。',
  336. });
  337. }
  338. return {
  339. profileUpdates,
  340. tasks: uniqueIntelligence(tasks, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 8),
  341. alerts: uniqueIntelligence(alerts, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 6),
  342. };
  343. }
  344. class ClaudeCodeSessionStore {
  345. constructor(filePath, project = {}) {
  346. this.filePath = filePath;
  347. this.project = project;
  348. }
  349. loadState() {
  350. const state = readJson(this.filePath, { version: 1, project: {}, sessions: {} });
  351. state.version = 1;
  352. state.sessions ||= {};
  353. state.project ||= {};
  354. state.project.projectId = this.project.projectId || state.project.projectId || 'qiwei-project';
  355. state.project.projectRoot = this.project.projectRoot || state.project.projectRoot || '';
  356. state.project.controllerSessionId = this.project.mainSessionId
  357. || state.project.controllerSessionId
  358. || crypto.randomUUID();
  359. state.project.boundMainSessionId = this.project.mainSessionId || state.project.boundMainSessionId || null;
  360. return state;
  361. }
  362. get(key) {
  363. return this.loadState().sessions?.[key] || null;
  364. }
  365. ensure(key, metadata = {}) {
  366. const state = this.loadState();
  367. if (!state.sessions[key]) {
  368. state.sessions[key] = {
  369. id: crypto.randomUUID(),
  370. initialized: false,
  371. role: 'customer-agent',
  372. projectId: state.project.projectId,
  373. parentControllerSessionId: state.project.controllerSessionId,
  374. epochStartedAt: new Date().toISOString(),
  375. epochTurnCount: 0,
  376. memoryVersion: Number(metadata.memoryVersion || 0),
  377. createdAt: new Date().toISOString(),
  378. };
  379. }
  380. const session = state.sessions[key];
  381. session.role ||= 'customer-agent';
  382. session.projectId ||= state.project.projectId;
  383. session.parentControllerSessionId ||= state.project.controllerSessionId;
  384. if (metadata.customerName) session.customerName = String(metadata.customerName).trim().slice(0, 80);
  385. if (metadata.displayName) session.displayName = String(metadata.displayName).trim().slice(0, 80);
  386. if (metadata.memoryVersion !== undefined) session.latestMemoryVersion = Number(metadata.memoryVersion || 0);
  387. writeJsonAtomic(this.filePath, state);
  388. return session;
  389. }
  390. markInitialized(key, metadata = {}) {
  391. const state = this.loadState();
  392. if (!state.sessions?.[key]) return;
  393. state.sessions[key].initialized = true;
  394. state.sessions[key].epochTurnCount = Number(state.sessions[key].epochTurnCount || 0) + 1;
  395. if (metadata.memoryVersion !== undefined) state.sessions[key].latestMemoryVersion = Number(metadata.memoryVersion || 0);
  396. state.sessions[key].updatedAt = new Date().toISOString();
  397. writeJsonAtomic(this.filePath, state);
  398. }
  399. setRole(key, role) {
  400. const state = this.loadState();
  401. if (!state.sessions?.[key]) return false;
  402. state.sessions[key].role = String(role || 'customer-agent');
  403. state.sessions[key].updatedAt = new Date().toISOString();
  404. writeJsonAtomic(this.filePath, state);
  405. return true;
  406. }
  407. reset(key, metadata = {}) {
  408. const state = this.loadState();
  409. const previous = state.sessions[key] || null;
  410. const epochHistory = previous ? [
  411. ...(previous.epochHistory || []),
  412. {
  413. id: previous.id,
  414. startedAt: previous.epochStartedAt || previous.createdAt,
  415. endedAt: new Date().toISOString(),
  416. turnCount: Number(previous.epochTurnCount || 0),
  417. memoryVersion: Number(previous.memoryVersion || 0),
  418. latestMemoryVersion: Number(previous.latestMemoryVersion || previous.memoryVersion || 0),
  419. closedReason: metadata.closedReason || 'manual_reset',
  420. },
  421. ].slice(-50) : [];
  422. state.sessions[key] = {
  423. id: crypto.randomUUID(),
  424. initialized: false,
  425. role: 'customer-agent',
  426. projectId: state.project.projectId,
  427. parentControllerSessionId: state.project.controllerSessionId,
  428. parentSessionId: metadata.parentSessionId || previous?.id || undefined,
  429. closedReason: metadata.closedReason || undefined,
  430. epochStartedAt: new Date().toISOString(),
  431. epochTurnCount: 0,
  432. memoryVersion: Number(metadata.memoryVersion || 0),
  433. epochHistory,
  434. customerName: metadata.customerName || undefined,
  435. displayName: metadata.displayName || undefined,
  436. createdAt: new Date().toISOString(),
  437. };
  438. writeJsonAtomic(this.filePath, state);
  439. return state.sessions[key];
  440. }
  441. rotateIfNeeded(key, metadata = {}, policy = {}) {
  442. const session = this.ensure(key, metadata);
  443. if (!session.initialized) return { session, reason: '' };
  444. const maxTurns = Number(policy.maxTurns || 0);
  445. const maxAgeMs = Number(policy.maxAgeMs || 0);
  446. const ageMs = Date.now() - Date.parse(session.epochStartedAt || session.createdAt || 0);
  447. const reason = maxTurns > 0 && Number(session.epochTurnCount || 0) >= maxTurns
  448. ? 'epoch_turn_limit'
  449. : maxAgeMs > 0 && Number.isFinite(ageMs) && ageMs >= maxAgeMs
  450. ? 'epoch_age_limit'
  451. : '';
  452. if (!reason) return { session, reason: '' };
  453. return {
  454. session: this.reset(key, { ...metadata, parentSessionId: session.id, closedReason: reason }),
  455. reason,
  456. };
  457. }
  458. }
  459. class ClaudeCodeClient {
  460. constructor(config, contextBuilder = null) {
  461. this.config = config;
  462. this.executable = resolveClaudeExecutable(config);
  463. this.workdir = path.resolve(config.claudeWorkdir || process.cwd());
  464. this.sessionStore = new ClaudeCodeSessionStore(config.claudeSessionFile, {
  465. projectId: config.claudeProjectId,
  466. projectRoot: this.workdir,
  467. mainSessionId: config.claudeMainSessionId,
  468. });
  469. this.queues = new Map();
  470. this.contextBuilder = contextBuilder || new AgentContextBuilder({ config });
  471. }
  472. isConfigured() {
  473. return Boolean(this.executable && fs.existsSync(this.workdir));
  474. }
  475. outputSchema() {
  476. return this.config.outputSchema || {
  477. type: 'object',
  478. additionalProperties: false,
  479. properties: {
  480. reply: { type: 'string' },
  481. confidence: { type: 'number', minimum: 0, maximum: 1 },
  482. intent: { type: 'string' },
  483. reason: { type: 'string' },
  484. requiresHuman: { type: 'boolean' },
  485. profileUpdates: { type: 'object' },
  486. tasks: {
  487. type: 'array',
  488. maxItems: 6,
  489. items: {
  490. type: 'object',
  491. additionalProperties: false,
  492. properties: {
  493. key: { type: 'string' },
  494. type: { type: 'string' },
  495. title: { type: 'string' },
  496. owner: { type: 'string' },
  497. dueAt: { type: 'string' },
  498. priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] },
  499. reason: { type: 'string' },
  500. evidence: { type: 'string' },
  501. },
  502. required: ['type', 'title', 'owner', 'dueAt', 'priority', 'reason', 'evidence'],
  503. },
  504. },
  505. alerts: {
  506. type: 'array',
  507. maxItems: 4,
  508. items: {
  509. type: 'object',
  510. additionalProperties: false,
  511. properties: {
  512. key: { type: 'string' },
  513. type: { type: 'string' },
  514. severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
  515. title: { type: 'string' },
  516. detail: { type: 'string' },
  517. evidence: { type: 'string' },
  518. recommendedAction: { type: 'string' },
  519. },
  520. required: ['type', 'severity', 'title', 'detail', 'evidence', 'recommendedAction'],
  521. },
  522. },
  523. },
  524. required: ['reply', 'confidence', 'intent', 'reason', 'requiresHuman', 'profileUpdates', 'tasks', 'alerts'],
  525. };
  526. }
  527. sessionKey(context = {}) {
  528. return String(context.conversation?.id || context.conversation?.contact_id || 'qiwei-default');
  529. }
  530. buildPrompt(messages, context = {}) {
  531. return this.contextBuilder.buildClaudePrompt(messages, context);
  532. }
  533. runProcess(args, input = '') {
  534. if (!this.isConfigured()) throw new AgentNotConfiguredError();
  535. return new Promise((resolve, reject) => {
  536. const childEnv = { ...process.env, NO_COLOR: '1' };
  537. if (this.config.claudeBare !== false && childEnv.ANTHROPIC_AUTH_TOKEN) {
  538. childEnv.ANTHROPIC_API_KEY = childEnv.ANTHROPIC_AUTH_TOKEN;
  539. }
  540. const child = spawn(this.executable, args, {
  541. cwd: this.workdir,
  542. env: childEnv,
  543. windowsHide: true,
  544. stdio: ['pipe', 'pipe', 'pipe'],
  545. });
  546. let stdout = '';
  547. let stderr = '';
  548. let finished = false;
  549. const maxBuffer = 8 * 1024 * 1024;
  550. const timer = setTimeout(() => {
  551. if (finished) return;
  552. child.kill('SIGTERM');
  553. reject(new Error('Claude Code 处理超时,已转人工审核'));
  554. }, Number(this.config.claudeTimeoutMs || 120000));
  555. child.stdout.on('data', chunk => {
  556. stdout += chunk.toString('utf8');
  557. if (stdout.length > maxBuffer) child.kill('SIGTERM');
  558. });
  559. child.stderr.on('data', chunk => {
  560. stderr += chunk.toString('utf8');
  561. if (stderr.length > maxBuffer) child.kill('SIGTERM');
  562. });
  563. child.stdin.on('error', () => {});
  564. child.stdin.end(String(input || ''), 'utf8');
  565. child.once('error', error => {
  566. if (finished) return;
  567. finished = true;
  568. clearTimeout(timer);
  569. reject(error);
  570. });
  571. child.once('close', code => {
  572. if (finished) return;
  573. finished = true;
  574. clearTimeout(timer);
  575. const parsed = parseClaudeProcessResult(stdout, stderr, code);
  576. if (parsed.error) reject(new Error(parsed.error));
  577. else resolve(parsed.payload);
  578. });
  579. });
  580. }
  581. async invoke(messages, context, session, options = {}) {
  582. const system = messages.find(message => message.role === 'system')?.content || '';
  583. const businessPrompt = this.buildPrompt(messages, context);
  584. const prompt = `${system}\n\n${businessPrompt}`;
  585. const budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 1);
  586. const args = [
  587. '--print',
  588. '--output-format', 'json',
  589. '--permission-mode', 'dontAsk',
  590. ...(this.config.claudeBare !== false ? ['--bare'] : []),
  591. ...(this.config.claudeEffort ? ['--effort', String(this.config.claudeEffort)] : []),
  592. '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'),
  593. '--model', String(this.config.model || 'sonnet'),
  594. '--max-budget-usd', String(budgetLimitUsd),
  595. '--json-schema', JSON.stringify(this.outputSchema()),
  596. '--name', session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
  597. ];
  598. for (const dir of this.config.claudeAddDirs || []) {
  599. if (dir && fs.existsSync(dir)) args.push('--add-dir', path.resolve(dir));
  600. }
  601. if (session.initialized) args.push('--resume', session.id);
  602. else args.push('--session-id', session.id);
  603. const payload = await this.runProcess(args, prompt);
  604. if (payload.is_error) {
  605. const subtype = String(payload.subtype || 'unknown');
  606. const error = new Error(`Claude Code/Fmode 暂时不可用(${subtype})`);
  607. error.subtype = subtype;
  608. throw error;
  609. }
  610. const structured = payload.structured_output ?? payload.result;
  611. if (structured === undefined || structured === null || structured === '') {
  612. throw new Error('Claude Code 没有返回客服草稿');
  613. }
  614. return {
  615. content: typeof structured === 'string' ? structured : JSON.stringify(structured),
  616. claudeCode: {
  617. model: this.config.model,
  618. sessionName: session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
  619. durationMs: Number(payload.duration_ms || 0),
  620. costUsd: Number(payload.total_cost_usd || 0),
  621. budgetLimitUsd,
  622. resumed: Boolean(session.initialized),
  623. systemChars: system.length,
  624. businessPromptChars: businessPrompt.length,
  625. promptChars: prompt.length,
  626. ...(payload.process_warning ? { processWarning: payload.process_warning } : {}),
  627. },
  628. };
  629. }
  630. async complete(messages, tools, context = {}) {
  631. const key = this.sessionKey(context);
  632. const metadata = {
  633. customerName: context.conversation?.contact_name || context.conversation?.displayName || '',
  634. displayName: buildClaudeSessionName(context, key),
  635. memoryVersion: Number(context.memoryContext?.snapshot?.version || 0),
  636. };
  637. const previous = this.queues.get(key) || Promise.resolve();
  638. const current = previous.catch(() => {}).then(async () => {
  639. const rotated = this.sessionStore.rotateIfNeeded(key, metadata, {
  640. maxTurns: this.config.claudeSessionMaxTurns,
  641. maxAgeMs: this.config.claudeSessionMaxAgeMs,
  642. });
  643. let session = rotated.session;
  644. try {
  645. const result = await this.invoke(messages, context, session);
  646. this.sessionStore.markInitialized(key, metadata);
  647. if (rotated.reason && result.claudeCode) result.claudeCode.sessionResetReason = rotated.reason;
  648. return result;
  649. } catch (error) {
  650. const resetReason = claudeSessionResetReason(error);
  651. if (resetReason) {
  652. session = this.sessionStore.reset(key, { ...metadata, parentSessionId: session.id, closedReason: resetReason });
  653. const retryMaxBudgetUsd = resetReason === 'budget_exceeded'
  654. ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 1) * 2, 3))
  655. : undefined;
  656. const result = await this.invoke(messages, context, session, { maxBudgetUsd: retryMaxBudgetUsd });
  657. this.sessionStore.markInitialized(key, metadata);
  658. if (result.claudeCode) result.claudeCode.sessionResetReason = resetReason;
  659. return result;
  660. }
  661. throw error;
  662. }
  663. });
  664. this.queues.set(key, current);
  665. try { return await current; }
  666. finally { if (this.queues.get(key) === current) this.queues.delete(key); }
  667. }
  668. }
  669. function parseJsonCandidate(value) {
  670. if (value && typeof value === 'object' && !Array.isArray(value)) return value;
  671. const text = String(value || '').trim();
  672. if (!text) return null;
  673. try {
  674. const parsed = JSON.parse(text);
  675. return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
  676. } catch {}
  677. const fenced = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
  678. for (const match of fenced) {
  679. try {
  680. const parsed = JSON.parse(match[1].trim());
  681. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
  682. } catch {}
  683. }
  684. for (let start = text.indexOf('{'); start >= 0; start = text.indexOf('{', start + 1)) {
  685. let depth = 0;
  686. let inString = false;
  687. let escaped = false;
  688. for (let index = start; index < text.length; index += 1) {
  689. const char = text[index];
  690. if (inString) {
  691. if (escaped) escaped = false;
  692. else if (char === '\\') escaped = true;
  693. else if (char === '"') inString = false;
  694. continue;
  695. }
  696. if (char === '"') inString = true;
  697. else if (char === '{') depth += 1;
  698. else if (char === '}') {
  699. depth -= 1;
  700. if (depth === 0) {
  701. try {
  702. const parsed = JSON.parse(text.slice(start, index + 1));
  703. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
  704. } catch {}
  705. break;
  706. }
  707. }
  708. }
  709. }
  710. return null;
  711. }
  712. function parseFinal(content) {
  713. const text = String(content || '').trim();
  714. let parsed = parseJsonCandidate(text);
  715. for (let depth = 0; parsed && depth < 3; depth += 1) {
  716. const wrapped = !Object.prototype.hasOwnProperty.call(parsed, 'reply')
  717. ? parseJsonCandidate(parsed.structured_output) || parseJsonCandidate(parsed.result)
  718. : null;
  719. if (wrapped) {
  720. parsed = wrapped;
  721. continue;
  722. }
  723. const nestedReply = parseJsonCandidate(parsed.reply);
  724. if (!nestedReply || !Object.prototype.hasOwnProperty.call(nestedReply, 'reply')) break;
  725. parsed = { ...parsed, ...nestedReply };
  726. }
  727. if (parsed && Object.prototype.hasOwnProperty.call(parsed, 'reply')) return parsed;
  728. const looksStructured = /^\s*[\[{]/.test(text) || /```(?:json)?/i.test(text) || /["']reply["']\s*:/.test(text);
  729. return {
  730. reply: looksStructured ? '' : text,
  731. confidence: 0.5,
  732. intent: 'unknown',
  733. reason: looksStructured
  734. ? '模型返回的结构化结果无法安全解析,已阻止 JSON 进入客户回复'
  735. : '模型未返回结构化结果,必须人工审核',
  736. requiresHuman: true,
  737. profileUpdates: {},
  738. };
  739. }
  740. function clamp(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }
  741. class QiweiAgentRuntime {
  742. constructor({ config, knowledge, modelClient = null }) {
  743. this.config = config;
  744. this.knowledge = knowledge;
  745. this.contextBuilder = new AgentContextBuilder({ config, knowledge });
  746. this.modelClient = modelClient || (config.provider === 'claude-code'
  747. ? new ClaudeCodeClient(config, this.contextBuilder)
  748. : config.provider === 'anthropic'
  749. ? new AnthropicCompatibleClient(config)
  750. : new OpenAICompatibleClient(config));
  751. }
  752. tools() {
  753. return [
  754. {
  755. type: 'function',
  756. function: {
  757. name: 'search_knowledge',
  758. description: '检索企业规则、FAQ 和沟通 Playbook。',
  759. parameters: {
  760. type: 'object',
  761. properties: { query: { type: 'string' }, limit: { type: 'integer' } },
  762. required: ['query'],
  763. },
  764. },
  765. },
  766. {
  767. type: 'function',
  768. function: {
  769. name: 'get_customer_profile',
  770. description: '读取当前客户画像与标签。',
  771. parameters: { type: 'object', properties: {} },
  772. },
  773. },
  774. ];
  775. }
  776. async executeTool(name, args, context) {
  777. if (name === 'search_knowledge') return this.knowledge.search(args.query, args.limit || 5);
  778. if (name === 'get_customer_profile') return context.profile || { profile: {}, tags: [] };
  779. return { error: `未知工具 ${name}` };
  780. }
  781. async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent, channelType = 'private', directPrompt = '', memoryContext = {} }) {
  782. const history = messages.slice(-16).map(message => ({
  783. role: message.direction === 'inbound' ? 'user' : 'assistant',
  784. content: message.content,
  785. }));
  786. const system = this.contextBuilder.buildSystemContext({ channelType, customerIntelligence, memoryContext });
  787. const llmMessages = [{ role: 'system', content: system }, ...history];
  788. const toolTrace = [];
  789. const citations = [];
  790. for (let round = 0; round < this.config.maxToolRounds; round += 1) {
  791. const assistant = await this.modelClient.complete(llmMessages, this.tools(), {
  792. conversation,
  793. profile,
  794. customerIntelligence,
  795. inboundContent,
  796. channelType,
  797. directPrompt,
  798. memoryContext,
  799. });
  800. if (assistant.claudeCode) {
  801. toolTrace.push({
  802. tool: 'claude_code_session',
  803. args: { provider: 'Fmode Studio', model: assistant.claudeCode.model },
  804. result: {
  805. sessionName: assistant.claudeCode.sessionName,
  806. durationMs: assistant.claudeCode.durationMs,
  807. costUsd: assistant.claudeCode.costUsd,
  808. budgetLimitUsd: assistant.claudeCode.budgetLimitUsd,
  809. resumed: assistant.claudeCode.resumed,
  810. systemChars: assistant.claudeCode.systemChars,
  811. businessPromptChars: assistant.claudeCode.businessPromptChars,
  812. promptChars: assistant.claudeCode.promptChars,
  813. ...(assistant.claudeCode.processWarning ? { processWarning: assistant.claudeCode.processWarning } : {}),
  814. ...(assistant.claudeCode.sessionResetReason ? { sessionResetReason: assistant.claudeCode.sessionResetReason } : {}),
  815. },
  816. });
  817. }
  818. if (assistant.tool_calls?.length) {
  819. llmMessages.push({ role: 'assistant', content: assistant.content || '', tool_calls: assistant.tool_calls });
  820. for (const call of assistant.tool_calls) {
  821. let args = {};
  822. try { args = JSON.parse(call.function.arguments || '{}'); } catch {}
  823. const result = await this.executeTool(call.function.name, args, { conversation, profile, customerIntelligence });
  824. toolTrace.push({ tool: call.function.name, args, result });
  825. if (call.function.name === 'search_knowledge') {
  826. for (const item of result) citations.push({ id: item.id, source: item.source, heading: item.heading });
  827. }
  828. llmMessages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
  829. }
  830. continue;
  831. }
  832. const parsedFinal = parseFinal(assistant.content);
  833. const authoritativeHistory = selectAuthoritativeHistory(history, 10);
  834. const currentProfile = profile?.profile || profile || {};
  835. const final = enforceAuthoritativeGrounding(parsedFinal, authoritativeHistory, currentProfile, inboundContent);
  836. const intelligence = extractExplicitCustomerIntelligence(inboundContent, currentProfile, final);
  837. const risky = RISK_PATTERN.test(inboundContent) || RISK_PATTERN.test(final.reply || '');
  838. const confidence = risky ? Math.min(clamp(final.confidence), 0.75) : clamp(final.confidence);
  839. return {
  840. content: String(final.reply || '').trim(),
  841. confidence,
  842. intent: String(final.intent || 'unknown'),
  843. reason: String(final.reason || 'Agent 未提供说明'),
  844. requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7),
  845. profileUpdates: intelligence.profileUpdates,
  846. tasks: intelligence.tasks,
  847. alerts: intelligence.alerts,
  848. citations: [...new Map(citations.map(item => [item.id, item])).values()],
  849. toolTrace,
  850. };
  851. }
  852. throw new Error('Agent 工具调用轮次超过上限,已转人工处理');
  853. }
  854. }
  855. module.exports = {
  856. AgentNotConfiguredError,
  857. OpenAICompatibleClient,
  858. AnthropicCompatibleClient,
  859. ClaudeCodeClient,
  860. ClaudeCodeSessionStore,
  861. buildClaudeSessionName,
  862. claudeSessionResetReason,
  863. parseClaudeProcessResult,
  864. parseFinal,
  865. selectAuthoritativeHistory,
  866. enforceAuthoritativeGrounding,
  867. extractExplicitCustomerIntelligence,
  868. isNoReplyNeededMessage,
  869. resolveClaudeExecutable,
  870. QiweiAgentRuntime,
  871. };