| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929 |
- 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,
- };
|