const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const { AgentContextBuilder, selectAuthoritativeHistory } = require('./agent-context-builder'); const RISK_PATTERN = /合同|签约|资质|合规|保证|承诺|最低价|贷款|利率|投诉|退款|发票|身份证|银行卡|法律|违约/; const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i; function isNoReplyNeededMessage(input = '') { const normalized = String(input || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim(); return Boolean(normalized && NO_REPLY_NEEDED_PATTERN.test(normalized)); } function claudeSessionResetReason(error) { const detail = `${error?.message || ''} ${error?.subtype || ''}`; if (/error_max_budget_usd|max[-_\s]?budget[-_\s]?usd/i.test(detail)) return 'budget_exceeded'; if (/session|conversation|resume/i.test(detail)) return 'session_invalid'; return ''; } function parseClaudeProcessResult(stdout = '', stderr = '', code = 0) { let payload = null; try { payload = JSON.parse(String(stdout || '').trim()); } catch {} if (code === 0) { return payload ? { payload } : { error: `Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}` }; } const structured = payload?.structured_output; const usableStructuredOutput = structured && typeof structured === 'object' && !Array.isArray(structured) && Object.prototype.hasOwnProperty.call(structured, 'reply') && Object.prototype.hasOwnProperty.call(structured, 'confidence') && Object.prototype.hasOwnProperty.call(structured, 'intent'); const safeError = String(stderr || '') .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]') .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]') .trim() .slice(0, 300); const structuredError = String(payload?.error || payload?.message || payload?.result || payload?.subtype || '').trim().slice(0, 300); const detail = safeError || structuredError || `退出码 ${code}`; if (usableStructuredOutput) { return { payload: { ...payload, is_error: false, process_warning: { exitCode: code, detail }, }, }; } return { error: `Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}` }; } class AgentNotConfiguredError extends Error { constructor() { super('Agent 模型尚未配置,消息已保留但不会生成伪造回复'); this.name = 'AgentNotConfiguredError'; } } class OpenAICompatibleClient { constructor(config) { this.config = config; } async complete(messages, tools) { if (!this.config.apiKey) throw new AgentNotConfiguredError(); const body = { model: this.config.model, temperature: 0.2, messages, }; if (Array.isArray(tools) && tools.length) { body.tools = tools; body.tool_choice = 'auto'; } const response = await fetch(`${this.config.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`); const message = payload.choices?.[0]?.message; if (!message) throw new Error('Agent 上游没有返回有效消息'); return message; } } class AnthropicCompatibleClient { constructor(config) { this.config = config; } toAnthropicMessages(messages) { return messages.filter(message => message.role !== 'system').map(message => { if (message.role === 'tool') { return { role: 'user', content: [{ type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content }] }; } if (message.role === 'assistant' && message.tool_calls?.length) { const content = []; if (message.content) content.push({ type: 'text', text: message.content }); for (const call of message.tool_calls) { let input = {}; try { input = JSON.parse(call.function.arguments || '{}'); } catch {} content.push({ type: 'tool_use', id: call.id, name: call.function.name, input }); } return { role: 'assistant', content }; } return { role: message.role, content: message.content }; }); } async complete(messages, tools) { if (!this.config.apiKey) throw new AgentNotConfiguredError(); const system = messages.find(message => message.role === 'system')?.content || ''; const body = { model: this.config.model, max_tokens: 1400, temperature: 0.2, system, messages: this.toAnthropicMessages(messages), }; if (Array.isArray(tools) && tools.length) { body.tools = tools.map(tool => ({ name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters, })); } const response = await fetch(`${this.config.baseUrl}/v1/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'x-api-key': this.config.apiKey, Authorization: `Bearer ${this.config.apiKey}`, }, body: JSON.stringify(body), signal: AbortSignal.timeout(45000), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`); const blocks = Array.isArray(payload.content) ? payload.content : []; const toolCalls = blocks.filter(block => block.type === 'tool_use').map(block => ({ id: block.id, function: { name: block.name, arguments: JSON.stringify(block.input || {}) }, })); return { content: blocks.filter(block => block.type === 'text').map(block => block.text).join('\n'), ...(toolCalls.length ? { tool_calls: toolCalls } : {}), }; } } function readJson(filePath, fallback = {}) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return fallback; } } function writeJsonAtomic(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8'); fs.renameSync(tempPath, filePath); } function resolveClaudeExecutable(config = {}) { const pathCandidates = String(process.env.PATH || '') .split(path.delimiter) .map(item => item.trim().replace(/^"|"$/g, '')) .filter(Boolean) .flatMap(dir => process.platform === 'win32' ? [ path.join(dir, 'claude.exe'), path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'), ] : [path.join(dir, 'claude')]); const candidates = [ config.claudeExecutable, process.env.CLAUDE_CODE_EXECUTABLE, ...pathCandidates, path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'), path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'), ].filter(Boolean); return candidates.find(candidate => fs.existsSync(candidate)) || ''; } function normalizeSessionLabel(value) { return String(value || '企微客户') .trim() .replace(/[\\/:*?"<>|\r\n]+/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 24) || '企微客户'; } function buildClaudeSessionName(context = {}, key = '') { const customerName = normalizeSessionLabel( context.conversation?.contact_name || context.conversation?.displayName || '企微客户' ); const reference = crypto.createHash('sha256').update(String(key || 'qiwei-default')).digest('hex').slice(0, 4); return `企微客户-${customerName}-${reference}`; } function uniqueIntelligence(items = [], keyFn) { const seen = new Set(); return items.filter(item => { const key = keyFn(item); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } function normalizedEvidence(value) { return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, ''); } function evidenceIsSupported(evidence, authoritativeText) { const source = normalizedEvidence(authoritativeText); const claim = normalizedEvidence(evidence); if (!source || !claim) return false; return source.includes(claim) || (source.length >= 4 && claim.includes(source)); } function supportedModelProfileUpdates(updates = {}, authoritativeText = '') { if (!updates || typeof updates !== 'object' || Array.isArray(updates)) return {}; const explicitNumbers = new Set(String(authoritativeText || '').match(/\d+(?:\.\d+)?/g) || []); return Object.fromEntries(Object.entries(updates).filter(([, value]) => { if (value === undefined || value === null || value === '') return false; const values = Array.isArray(value) ? value : [value]; return values.every(item => typeof item === 'number' ? explicitNumbers.has(String(item)) : evidenceIsSupported(String(item), authoritativeText)); })); } function unsupportedAttributedClaims(final = {}, authoritativeHistory = []) { const output = `${String(final.reply || '')}\n${String(final.reason || '')}`; const source = authoritativeHistory.map(item => item.content || '').join('\n'); const claims = []; const marker = /(?:您|客户)(?:之前|此前|刚才)?(?:曾经)?(?:提到|说过|说|表示)/g; for (const match of output.matchAll(marker)) { const tail = output.slice((match.index || 0) + match[0].length, (match.index || 0) + match[0].length + 100); const quoted = tail.match(/^\s*[::]?\s*["“‘']([^"”’'\r\n]{2,60})["”’']/); if (quoted && !evidenceIsSupported(quoted[1], source)) claims.push(quoted[1].trim()); } return [...new Set(claims)]; } function groundedConfirmationReply(profile = {}, inboundContent = '') { const budget = Number(profile.budgetWan || profile.budget || 0); const intent = profile.intent || profile.purpose || profile.need || ''; const confirmed = [intent, budget ? `预算 ${budget}` : '', profile.timeline || ''].filter(Boolean).join('、'); const opening = confirmed ? `收到,我先按${confirmed}继续整理。` : '收到,您刚才的信息我已经记录。'; const questions = []; if (!intent) questions.push('这次最希望解决的核心问题是什么'); if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`预算 ${budget} 是目标值还是上限`); if (!profile.timeline) questions.push('希望什么时候推进'); if (!questions.length) return `${opening}我会先核对可用方案,再给您准确回复。`; return `${opening}为了避免理解偏差,想再确认一下:${questions.join(';')}?`; } function enforceAuthoritativeGrounding(final = {}, authoritativeHistory = [], profile = {}, inboundContent = '') { const unsupported = unsupportedAttributedClaims(final, authoritativeHistory); if (!unsupported.length) return { ...final, groundingWarnings: [] }; return { ...final, reply: groundedConfirmationReply(profile, inboundContent), reason: `检测到模型引用了本轮有效会话中不存在的客户原话,已降级为确认式草稿。未支持内容:${unsupported.join('、')}`, confidence: Math.min(clamp(final.confidence), 0.68), requiresHuman: true, groundingWarnings: unsupported, }; } function extractExplicitCustomerIntelligence(content, currentProfile = {}, modelOutput = {}) { const text = String(content || '').trim(); const profileUpdates = supportedModelProfileUpdates(modelOutput.profileUpdates, text); const amount = text.match(/(\d+(?:\.\d+)?)\s*(?:万|元)?/); if (amount && (/预算|费用|报价|价格/.test(text))) { profileUpdates.budgetWan = Number(amount[1]); profileUpdates.budgetType = /上限|最多/.test(text) ? '上限' : /目标|大概/.test(text) ? '目标' : (currentProfile.budgetType || '待确认'); } if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) profileUpdates.urgency = '高'; const mergedProfile = { ...currentProfile, ...profileUpdates }; const factEvidence = [ mergedProfile.intent || mergedProfile.purpose ? `意图${mergedProfile.intent || mergedProfile.purpose}` : '', mergedProfile.need || mergedProfile.needs ? `需求${mergedProfile.need || mergedProfile.needs}` : '', mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}` : '', mergedProfile.timeline ? `计划时间${mergedProfile.timeline}` : '', mergedProfile.decisionMaker ? `决策人${mergedProfile.decisionMaker}` : '', ].filter(Boolean).join(',').slice(0, 240); const evidence = text.slice(0, 240); const tasks = Array.isArray(modelOutput.tasks) ? modelOutput.tasks.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({ ...item, businessKey: item.businessKey || item.key, managedBy: 'agent', })) : []; const alerts = Array.isArray(modelOutput.alerts) ? modelOutput.alerts.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({ ...item, businessKey: item.businessKey || item.key, managedBy: 'agent', })) : []; const hasBudget = Number(mergedProfile.budgetWan || mergedProfile.budget || 0) > 0; const hasPurpose = Boolean(mergedProfile.intent || mergedProfile.purpose || mergedProfile.need || mergedProfile.needs); const hasTimeline = Boolean(mergedProfile.timeline); if ((hasBudget || hasPurpose) && (!hasPurpose || !hasTimeline)) { tasks.push({ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户目标与计划时间', owner: '待分配', dueAt: '', priority: 'high', reason: `${[!hasPurpose ? '客户目标' : '', !hasTimeline ? '计划时间' : ''].filter(Boolean).join('和')}仍不明确。`, evidence: factEvidence || evidence, }); } if (hasPurpose && hasTimeline) { alerts.push({ businessKey: 'high_intent:core_demand_ready', managedBy: 'rule', type: 'high_intent', severity: 'high', title: '客户核心需求已基本成形', detail: '客户目标和计划时间已具备,可以从泛咨询进入下一步服务安排。', evidence: factEvidence || evidence, recommendedAction: '核对关键约束并确认下一步动作。', }); } if (/投诉|不满意|骗人|退款|举报|再也不|太差|生气/.test(text)) { alerts.push({ businessKey: 'complaint:manual_takeover', managedBy: 'event', type: 'complaint', severity: 'critical', title: '检测到投诉或强烈负面情绪', detail: '该消息不应由自动回复独立处理。', evidence, recommendedAction: '立即人工接管,先确认事实和客户诉求。', }); } else if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) { alerts.push({ businessKey: 'time_sensitive:follow_up', managedBy: 'event', type: 'time_sensitive', severity: 'high', title: '客户表达了明确时效要求', detail: '消息中包含较紧迫的时间表达。', evidence, recommendedAction: '优先确认具体截止时间并安排跟进。', }); } return { profileUpdates, tasks: uniqueIntelligence(tasks, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 8), alerts: uniqueIntelligence(alerts, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 6), }; } class ClaudeCodeSessionStore { constructor(filePath, project = {}) { this.filePath = filePath; this.project = project; } loadState() { const state = readJson(this.filePath, { version: 1, project: {}, sessions: {} }); state.version = 1; state.sessions ||= {}; state.project ||= {}; state.project.projectId = this.project.projectId || state.project.projectId || 'qiwei-project'; state.project.projectRoot = this.project.projectRoot || state.project.projectRoot || ''; state.project.controllerSessionId = this.project.mainSessionId || state.project.controllerSessionId || crypto.randomUUID(); state.project.boundMainSessionId = this.project.mainSessionId || state.project.boundMainSessionId || null; return state; } get(key) { return this.loadState().sessions?.[key] || null; } ensure(key, metadata = {}) { const state = this.loadState(); if (!state.sessions[key]) { state.sessions[key] = { id: crypto.randomUUID(), initialized: false, role: 'customer-agent', projectId: state.project.projectId, parentControllerSessionId: state.project.controllerSessionId, epochStartedAt: new Date().toISOString(), epochTurnCount: 0, memoryVersion: Number(metadata.memoryVersion || 0), createdAt: new Date().toISOString(), }; } const session = state.sessions[key]; session.role ||= 'customer-agent'; session.projectId ||= state.project.projectId; session.parentControllerSessionId ||= state.project.controllerSessionId; if (metadata.customerName) session.customerName = String(metadata.customerName).trim().slice(0, 80); if (metadata.displayName) session.displayName = String(metadata.displayName).trim().slice(0, 80); if (metadata.memoryVersion !== undefined) session.latestMemoryVersion = Number(metadata.memoryVersion || 0); writeJsonAtomic(this.filePath, state); return session; } markInitialized(key, metadata = {}) { const state = this.loadState(); if (!state.sessions?.[key]) return; state.sessions[key].initialized = true; state.sessions[key].epochTurnCount = Number(state.sessions[key].epochTurnCount || 0) + 1; if (metadata.memoryVersion !== undefined) state.sessions[key].latestMemoryVersion = Number(metadata.memoryVersion || 0); state.sessions[key].updatedAt = new Date().toISOString(); writeJsonAtomic(this.filePath, state); } setRole(key, role) { const state = this.loadState(); if (!state.sessions?.[key]) return false; state.sessions[key].role = String(role || 'customer-agent'); state.sessions[key].updatedAt = new Date().toISOString(); writeJsonAtomic(this.filePath, state); return true; } reset(key, metadata = {}) { const state = this.loadState(); const previous = state.sessions[key] || null; const epochHistory = previous ? [ ...(previous.epochHistory || []), { id: previous.id, startedAt: previous.epochStartedAt || previous.createdAt, endedAt: new Date().toISOString(), turnCount: Number(previous.epochTurnCount || 0), memoryVersion: Number(previous.memoryVersion || 0), latestMemoryVersion: Number(previous.latestMemoryVersion || previous.memoryVersion || 0), closedReason: metadata.closedReason || 'manual_reset', }, ].slice(-50) : []; state.sessions[key] = { id: crypto.randomUUID(), initialized: false, role: 'customer-agent', projectId: state.project.projectId, parentControllerSessionId: state.project.controllerSessionId, parentSessionId: metadata.parentSessionId || previous?.id || undefined, closedReason: metadata.closedReason || undefined, epochStartedAt: new Date().toISOString(), epochTurnCount: 0, memoryVersion: Number(metadata.memoryVersion || 0), epochHistory, customerName: metadata.customerName || undefined, displayName: metadata.displayName || undefined, createdAt: new Date().toISOString(), }; writeJsonAtomic(this.filePath, state); return state.sessions[key]; } rotateIfNeeded(key, metadata = {}, policy = {}) { const session = this.ensure(key, metadata); if (!session.initialized) return { session, reason: '' }; const maxTurns = Number(policy.maxTurns || 0); const maxAgeMs = Number(policy.maxAgeMs || 0); const ageMs = Date.now() - Date.parse(session.epochStartedAt || session.createdAt || 0); const reason = maxTurns > 0 && Number(session.epochTurnCount || 0) >= maxTurns ? 'epoch_turn_limit' : maxAgeMs > 0 && Number.isFinite(ageMs) && ageMs >= maxAgeMs ? 'epoch_age_limit' : ''; if (!reason) return { session, reason: '' }; return { session: this.reset(key, { ...metadata, parentSessionId: session.id, closedReason: reason }), reason, }; } } class ClaudeCodeClient { constructor(config, contextBuilder = null) { this.config = config; this.executable = resolveClaudeExecutable(config); this.workdir = path.resolve(config.claudeWorkdir || process.cwd()); this.sessionStore = new ClaudeCodeSessionStore(config.claudeSessionFile, { projectId: config.claudeProjectId, projectRoot: this.workdir, mainSessionId: config.claudeMainSessionId, }); this.queues = new Map(); this.contextBuilder = contextBuilder || new AgentContextBuilder({ config }); } isConfigured() { return Boolean(this.executable && fs.existsSync(this.workdir)); } outputSchema() { return this.config.outputSchema || { type: 'object', additionalProperties: false, properties: { reply: { type: 'string' }, confidence: { type: 'number', minimum: 0, maximum: 1 }, intent: { type: 'string' }, reason: { type: 'string' }, requiresHuman: { type: 'boolean' }, profileUpdates: { type: 'object' }, tasks: { type: 'array', maxItems: 6, items: { type: 'object', additionalProperties: false, properties: { key: { type: 'string' }, type: { type: 'string' }, title: { type: 'string' }, owner: { type: 'string' }, dueAt: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] }, reason: { type: 'string' }, evidence: { type: 'string' }, }, required: ['type', 'title', 'owner', 'dueAt', 'priority', 'reason', 'evidence'], }, }, alerts: { type: 'array', maxItems: 4, items: { type: 'object', additionalProperties: false, properties: { key: { type: 'string' }, type: { type: 'string' }, severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, title: { type: 'string' }, detail: { type: 'string' }, evidence: { type: 'string' }, recommendedAction: { type: 'string' }, }, required: ['type', 'severity', 'title', 'detail', 'evidence', 'recommendedAction'], }, }, }, required: ['reply', 'confidence', 'intent', 'reason', 'requiresHuman', 'profileUpdates', 'tasks', 'alerts'], }; } sessionKey(context = {}) { return String(context.conversation?.id || context.conversation?.contact_id || 'qiwei-default'); } buildPrompt(messages, context = {}) { return this.contextBuilder.buildClaudePrompt(messages, context); } runProcess(args, input = '') { if (!this.isConfigured()) throw new AgentNotConfiguredError(); return new Promise((resolve, reject) => { const childEnv = { ...process.env, NO_COLOR: '1' }; if (this.config.claudeBare !== false && childEnv.ANTHROPIC_AUTH_TOKEN) { childEnv.ANTHROPIC_API_KEY = childEnv.ANTHROPIC_AUTH_TOKEN; } const child = spawn(this.executable, args, { cwd: this.workdir, env: childEnv, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let finished = false; const maxBuffer = 8 * 1024 * 1024; const timer = setTimeout(() => { if (finished) return; child.kill('SIGTERM'); reject(new Error('Claude Code 处理超时,已转人工审核')); }, Number(this.config.claudeTimeoutMs || 120000)); child.stdout.on('data', chunk => { stdout += chunk.toString('utf8'); if (stdout.length > maxBuffer) child.kill('SIGTERM'); }); child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); if (stderr.length > maxBuffer) child.kill('SIGTERM'); }); child.stdin.on('error', () => {}); child.stdin.end(String(input || ''), 'utf8'); child.once('error', error => { if (finished) return; finished = true; clearTimeout(timer); reject(error); }); child.once('close', code => { if (finished) return; finished = true; clearTimeout(timer); const parsed = parseClaudeProcessResult(stdout, stderr, code); if (parsed.error) reject(new Error(parsed.error)); else resolve(parsed.payload); }); }); } async invoke(messages, context, session, options = {}) { const system = messages.find(message => message.role === 'system')?.content || ''; const businessPrompt = this.buildPrompt(messages, context); const prompt = `${system}\n\n${businessPrompt}`; const budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 1); const args = [ '--print', '--output-format', 'json', '--permission-mode', 'dontAsk', ...(this.config.claudeBare !== false ? ['--bare'] : []), ...(this.config.claudeEffort ? ['--effort', String(this.config.claudeEffort)] : []), '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'), '--model', String(this.config.model || 'sonnet'), '--max-budget-usd', String(budgetLimitUsd), '--json-schema', JSON.stringify(this.outputSchema()), '--name', session.displayName || buildClaudeSessionName(context, this.sessionKey(context)), ]; for (const dir of this.config.claudeAddDirs || []) { if (dir && fs.existsSync(dir)) args.push('--add-dir', path.resolve(dir)); } if (session.initialized) args.push('--resume', session.id); else args.push('--session-id', session.id); const payload = await this.runProcess(args, prompt); if (payload.is_error) { const subtype = String(payload.subtype || 'unknown'); const error = new Error(`Claude Code/Fmode 暂时不可用(${subtype})`); error.subtype = subtype; throw error; } const structured = payload.structured_output ?? payload.result; if (structured === undefined || structured === null || structured === '') { throw new Error('Claude Code 没有返回客服草稿'); } return { content: typeof structured === 'string' ? structured : JSON.stringify(structured), claudeCode: { model: this.config.model, sessionName: session.displayName || buildClaudeSessionName(context, this.sessionKey(context)), durationMs: Number(payload.duration_ms || 0), costUsd: Number(payload.total_cost_usd || 0), budgetLimitUsd, resumed: Boolean(session.initialized), systemChars: system.length, businessPromptChars: businessPrompt.length, promptChars: prompt.length, ...(payload.process_warning ? { processWarning: payload.process_warning } : {}), }, }; } async complete(messages, tools, context = {}) { const key = this.sessionKey(context); const metadata = { customerName: context.conversation?.contact_name || context.conversation?.displayName || '', displayName: buildClaudeSessionName(context, key), memoryVersion: Number(context.memoryContext?.snapshot?.version || 0), }; const previous = this.queues.get(key) || Promise.resolve(); const current = previous.catch(() => {}).then(async () => { const rotated = this.sessionStore.rotateIfNeeded(key, metadata, { maxTurns: this.config.claudeSessionMaxTurns, maxAgeMs: this.config.claudeSessionMaxAgeMs, }); let session = rotated.session; try { const result = await this.invoke(messages, context, session); this.sessionStore.markInitialized(key, metadata); if (rotated.reason && result.claudeCode) result.claudeCode.sessionResetReason = rotated.reason; return result; } catch (error) { const resetReason = claudeSessionResetReason(error); if (resetReason) { session = this.sessionStore.reset(key, { ...metadata, parentSessionId: session.id, closedReason: resetReason }); const retryMaxBudgetUsd = resetReason === 'budget_exceeded' ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 1) * 2, 3)) : undefined; const result = await this.invoke(messages, context, session, { maxBudgetUsd: retryMaxBudgetUsd }); this.sessionStore.markInitialized(key, metadata); if (result.claudeCode) result.claudeCode.sessionResetReason = resetReason; return result; } throw error; } }); this.queues.set(key, current); try { return await current; } finally { if (this.queues.get(key) === current) this.queues.delete(key); } } } function parseJsonCandidate(value) { if (value && typeof value === 'object' && !Array.isArray(value)) return value; const text = String(value || '').trim(); if (!text) return null; try { const parsed = JSON.parse(text); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; } catch {} const fenced = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]; for (const match of fenced) { try { const parsed = JSON.parse(match[1].trim()); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch {} } for (let start = text.indexOf('{'); start >= 0; start = text.indexOf('{', start + 1)) { let depth = 0; let inString = false; let escaped = false; for (let index = start; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') inString = false; continue; } if (char === '"') inString = true; else if (char === '{') depth += 1; else if (char === '}') { depth -= 1; if (depth === 0) { try { const parsed = JSON.parse(text.slice(start, index + 1)); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch {} break; } } } } return null; } function parseFinal(content) { const text = String(content || '').trim(); let parsed = parseJsonCandidate(text); for (let depth = 0; parsed && depth < 3; depth += 1) { const wrapped = !Object.prototype.hasOwnProperty.call(parsed, 'reply') ? parseJsonCandidate(parsed.structured_output) || parseJsonCandidate(parsed.result) : null; if (wrapped) { parsed = wrapped; continue; } const nestedReply = parseJsonCandidate(parsed.reply); if (!nestedReply || !Object.prototype.hasOwnProperty.call(nestedReply, 'reply')) break; parsed = { ...parsed, ...nestedReply }; } if (parsed && Object.prototype.hasOwnProperty.call(parsed, 'reply')) return parsed; const looksStructured = /^\s*[\[{]/.test(text) || /```(?:json)?/i.test(text) || /["']reply["']\s*:/.test(text); return { reply: looksStructured ? '' : text, confidence: 0.5, intent: 'unknown', reason: looksStructured ? '模型返回的结构化结果无法安全解析,已阻止 JSON 进入客户回复' : '模型未返回结构化结果,必须人工审核', requiresHuman: true, profileUpdates: {}, }; } function clamp(value) { return Math.max(0, Math.min(1, Number(value) || 0)); } class QiweiAgentRuntime { constructor({ config, knowledge, modelClient = null }) { this.config = config; this.knowledge = knowledge; this.contextBuilder = new AgentContextBuilder({ config, knowledge }); this.modelClient = modelClient || (config.provider === 'claude-code' ? new ClaudeCodeClient(config, this.contextBuilder) : config.provider === 'anthropic' ? new AnthropicCompatibleClient(config) : new OpenAICompatibleClient(config)); } tools() { return [ { type: 'function', function: { name: 'search_knowledge', description: '检索企业规则、FAQ 和沟通 Playbook。', parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'integer' } }, required: ['query'], }, }, }, { type: 'function', function: { name: 'get_customer_profile', description: '读取当前客户画像与标签。', parameters: { type: 'object', properties: {} }, }, }, ]; } async executeTool(name, args, context) { if (name === 'search_knowledge') return this.knowledge.search(args.query, args.limit || 5); if (name === 'get_customer_profile') return context.profile || { profile: {}, tags: [] }; return { error: `未知工具 ${name}` }; } async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent, channelType = 'private', directPrompt = '', memoryContext = {} }) { const history = messages.slice(-16).map(message => ({ role: message.direction === 'inbound' ? 'user' : 'assistant', content: message.content, })); const system = this.contextBuilder.buildSystemContext({ channelType, customerIntelligence, memoryContext }); const llmMessages = [{ role: 'system', content: system }, ...history]; const toolTrace = []; const citations = []; for (let round = 0; round < this.config.maxToolRounds; round += 1) { const assistant = await this.modelClient.complete(llmMessages, this.tools(), { conversation, profile, customerIntelligence, inboundContent, channelType, directPrompt, memoryContext, }); if (assistant.claudeCode) { toolTrace.push({ tool: 'claude_code_session', args: { provider: 'Fmode Studio', model: assistant.claudeCode.model }, result: { sessionName: assistant.claudeCode.sessionName, durationMs: assistant.claudeCode.durationMs, costUsd: assistant.claudeCode.costUsd, budgetLimitUsd: assistant.claudeCode.budgetLimitUsd, resumed: assistant.claudeCode.resumed, systemChars: assistant.claudeCode.systemChars, businessPromptChars: assistant.claudeCode.businessPromptChars, promptChars: assistant.claudeCode.promptChars, ...(assistant.claudeCode.processWarning ? { processWarning: assistant.claudeCode.processWarning } : {}), ...(assistant.claudeCode.sessionResetReason ? { sessionResetReason: assistant.claudeCode.sessionResetReason } : {}), }, }); } if (assistant.tool_calls?.length) { llmMessages.push({ role: 'assistant', content: assistant.content || '', tool_calls: assistant.tool_calls }); for (const call of assistant.tool_calls) { let args = {}; try { args = JSON.parse(call.function.arguments || '{}'); } catch {} const result = await this.executeTool(call.function.name, args, { conversation, profile, customerIntelligence }); toolTrace.push({ tool: call.function.name, args, result }); if (call.function.name === 'search_knowledge') { for (const item of result) citations.push({ id: item.id, source: item.source, heading: item.heading }); } llmMessages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); } continue; } const parsedFinal = parseFinal(assistant.content); const authoritativeHistory = selectAuthoritativeHistory(history, 10); const currentProfile = profile?.profile || profile || {}; const final = enforceAuthoritativeGrounding(parsedFinal, authoritativeHistory, currentProfile, inboundContent); const intelligence = extractExplicitCustomerIntelligence(inboundContent, currentProfile, final); const risky = RISK_PATTERN.test(inboundContent) || RISK_PATTERN.test(final.reply || ''); const confidence = risky ? Math.min(clamp(final.confidence), 0.75) : clamp(final.confidence); return { content: String(final.reply || '').trim(), confidence, intent: String(final.intent || 'unknown'), reason: String(final.reason || 'Agent 未提供说明'), requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7), profileUpdates: intelligence.profileUpdates, tasks: intelligence.tasks, alerts: intelligence.alerts, citations: [...new Map(citations.map(item => [item.id, item])).values()], toolTrace, }; } throw new Error('Agent 工具调用轮次超过上限,已转人工处理'); } } module.exports = { AgentNotConfiguredError, OpenAICompatibleClient, AnthropicCompatibleClient, ClaudeCodeClient, ClaudeCodeSessionStore, buildClaudeSessionName, claudeSessionResetReason, parseClaudeProcessResult, parseFinal, selectAuthoritativeHistory, enforceAuthoritativeGrounding, extractExplicitCustomerIntelligence, isNoReplyNeededMessage, resolveClaudeExecutable, QiweiAgentRuntime, };