import { randomUUID } from "node:crypto"; import type { AgentReply, AgentTraceEvent } from "./crmAgent"; import { prisma } from "../utils/prisma"; import { ensureDataset } from "../dataset/exporter"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; import { ChatOpenAI } from "@langchain/openai"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { getLangfuseClient } from "../utils/langfuse"; function iso(d: Date) { return d.toISOString(); } function cyclePrompt(userText: string, cycle: number, cycleNotes: string[]) { if (cycle === 1) return userText; return [ "Continue solving the same user request.", `User request: ${userText}`, cycleNotes.length ? `Progress notes:\n- ${cycleNotes.join("\n- ")}` : "No progress notes yet.", "Do the next useful step. If done, produce final concise answer.", ].join("\n"); } type GigachatTokenCache = { token: string; expiresAtSec: number; }; let gigachatTokenCache: GigachatTokenCache | null = null; function normalizeAuthHeader(authKey: string, scheme: "Basic" | "Bearer") { const key = authKey.trim(); if (!key) return ""; if (key.startsWith("Basic ") || key.startsWith("Bearer ")) return key; return `${scheme} ${key}`; } async function requestGigachatToken(input: { authKey: string; scope: string; oauthUrl: string; authScheme: "Basic" | "Bearer"; }) { const body = new URLSearchParams(); body.set("scope", input.scope); const res = await fetch(input.oauthUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", RqUID: randomUUID(), Authorization: normalizeAuthHeader(input.authKey, input.authScheme), }, body: body.toString(), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`GigaChat oauth failed: ${res.status} ${text.slice(0, 240)}`); } const payload = (await res.json()) as { access_token?: string; expires_at?: number }; if (!payload?.access_token) { throw new Error("GigaChat oauth failed: access_token is missing"); } return { token: payload.access_token, expiresAtSec: typeof payload.expires_at === "number" ? payload.expires_at : Math.floor(Date.now() / 1000) + 25 * 60, }; } async function getGigachatAccessToken(input: { authKey: string; scope: string; oauthUrl: string; }) { const nowSec = Math.floor(Date.now() / 1000); if (gigachatTokenCache && gigachatTokenCache.expiresAtSec - nowSec > 60) { return gigachatTokenCache.token; } try { const token = await requestGigachatToken({ ...input, authScheme: "Basic" }); gigachatTokenCache = token; return token.token; } catch { const token = await requestGigachatToken({ ...input, authScheme: "Bearer" }); gigachatTokenCache = token; return token.token; } } type SnapshotOptions = { teamId: string; contact?: string; contactsLimit?: number; }; function makeId(prefix: string) { return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; } async function resolveContact(teamId: string, contactRef: string) { const contact = contactRef.trim(); if (!contact) return null; const exact = await prisma.contact.findFirst({ where: { teamId, OR: [{ id: contact }, { name: contact }] }, select: { id: true, name: true }, }); if (exact) return exact; return prisma.contact.findFirst({ where: { teamId, name: { contains: contact } }, orderBy: { updatedAt: "desc" }, select: { id: true, name: true }, }); } async function buildCrmSnapshot(input: SnapshotOptions) { const now = new Date(); const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const contactLimit = Math.max(1, Math.min(input.contactsLimit ?? 25, 80)); const selectedContact = input.contact ? await resolveContact(input.teamId, input.contact) : null; const contactWhere = selectedContact ? { teamId: input.teamId, id: selectedContact.id } : { teamId: input.teamId }; const [contacts, upcoming, deals, docs, totalContacts, totalDeals, totalEvents] = await Promise.all([ prisma.contact.findMany({ where: contactWhere, orderBy: { updatedAt: "desc" }, take: selectedContact ? 1 : contactLimit, include: { note: { select: { content: true, updatedAt: true } }, messages: { select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true }, orderBy: { occurredAt: "desc" }, take: 4, }, events: { select: { id: true, title: true, startsAt: true, endsAt: true, isArchived: true }, orderBy: { startsAt: "asc" }, where: { startsAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) } }, take: 4, }, deals: { select: { id: true, title: true, stage: true, amount: true, updatedAt: true, nextStep: true, summary: true, currentStepId: true, steps: { select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, orderBy: [{ order: "asc" }, { createdAt: "asc" }], }, }, orderBy: { updatedAt: "desc" }, take: 3, }, pins: { select: { id: true, text: true, updatedAt: true }, orderBy: { updatedAt: "desc" }, take: 3, }, }, }), prisma.calendarEvent.findMany({ where: { teamId: input.teamId, startsAt: { gte: now, lte: in7 } }, orderBy: { startsAt: "asc" }, take: 20, include: { contact: { select: { name: true } } }, }), prisma.deal.findMany({ where: { teamId: input.teamId }, orderBy: { updatedAt: "desc" }, take: 20, include: { contact: { select: { name: true, company: true } }, steps: { select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, }, }), prisma.workspaceDocument.findMany({ where: { teamId: input.teamId }, orderBy: { updatedAt: "desc" }, take: 12, select: { id: true, type: true, title: true, summary: true, updatedAt: true, owner: true, scope: true }, }), prisma.contact.count({ where: { teamId: input.teamId } }), prisma.deal.count({ where: { teamId: input.teamId } }), prisma.calendarEvent.count({ where: { teamId: input.teamId, startsAt: { gte: now } } }), ]); const byStage = new Map(); for (const d of deals) byStage.set(d.stage, (byStage.get(d.stage) ?? 0) + 1); return { meta: { generatedAt: iso(now), focusContactId: selectedContact?.id ?? null, contactsIncluded: contacts.length, mode: selectedContact ? "focused" : "team", }, totals: { contacts: totalContacts, deals: totalDeals, upcomingEvents: totalEvents, }, stats: { dealsByStage: [...byStage.entries()] .sort((a, b) => b[1] - a[1]) .map(([stage, count]) => ({ stage, count })), }, upcomingEvents: upcoming.map((e) => ({ id: e.id, title: e.title, startsAt: iso(e.startsAt), endsAt: iso(e.endsAt ?? e.startsAt), isArchived: e.isArchived, note: e.note, contact: e.contact?.name ?? null, })), deals: deals.map((d) => ({ id: d.id, title: d.title, stage: d.stage, amount: d.amount, nextStep: d.nextStep, summary: d.summary, currentStepId: d.currentStepId, steps: d.steps.map((s) => ({ id: s.id, title: s.title, status: s.status, dueAt: s.dueAt ? iso(s.dueAt) : null, order: s.order, completedAt: s.completedAt ? iso(s.completedAt) : null, })), updatedAt: iso(d.updatedAt), contact: { name: d.contact.name, company: d.contact.company, }, })), contacts: contacts.map((c) => ({ id: c.id, name: c.name, company: c.company, country: c.country, location: c.location, email: c.email, phone: c.phone, avatarUrl: c.avatarUrl, updatedAt: iso(c.updatedAt), summary: c.note?.content ?? null, summaryUpdatedAt: c.note?.updatedAt ? iso(c.note.updatedAt) : null, latestMessages: c.messages.map((m) => ({ id: m.id, occurredAt: iso(m.occurredAt), channel: m.channel, direction: m.direction, kind: m.kind, content: m.content, })), latestEvents: c.events.map((e) => ({ id: e.id, title: e.title, startsAt: iso(e.startsAt), endsAt: iso(e.endsAt ?? e.startsAt), isArchived: e.isArchived, })), deals: c.deals.map((d) => ({ id: d.id, title: d.title, stage: d.stage, amount: d.amount, nextStep: d.nextStep, summary: d.summary, currentStepId: d.currentStepId, steps: d.steps.map((s) => ({ id: s.id, title: s.title, status: s.status, dueAt: s.dueAt ? iso(s.dueAt) : null, order: s.order, completedAt: s.completedAt ? iso(s.completedAt) : null, })), updatedAt: iso(d.updatedAt), })), pins: c.pins.map((p) => ({ id: p.id, text: p.text, updatedAt: iso(p.updatedAt), })), })), documents: docs.map((d) => ({ id: d.id, type: d.type, title: d.title, summary: d.summary, owner: d.owner, scope: d.scope, updatedAt: iso(d.updatedAt), })), }; } export async function runLangGraphCrmAgentFor(input: { teamId: string; userId: string; userText: string; requestId?: string; conversationId?: string; onTrace?: (event: AgentTraceEvent) => Promise | void; }): Promise { const openrouterApiKey = (process.env.OPENROUTER_API_KEY ?? "").trim(); const openrouterBaseURL = (process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1").trim(); const openrouterModel = (process.env.OPENROUTER_MODEL ?? "openai/gpt-4o-mini").trim(); const openrouterReferer = (process.env.OPENROUTER_HTTP_REFERER ?? "").trim(); const openrouterTitle = (process.env.OPENROUTER_X_TITLE ?? "").trim(); const openrouterReasoningEnabled = (process.env.OPENROUTER_REASONING_ENABLED ?? "").trim() === "1"; const genericApiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY; const genericBaseURL = process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || process.env.DASHSCOPE_BASE_URL || process.env.QWEN_BASE_URL; const genericModel = process.env.LLM_MODEL || process.env.OPENAI_MODEL || process.env.DASHSCOPE_MODEL || process.env.QWEN_MODEL || "gpt-4o-mini"; const gigachatAuthKey = (process.env.GIGACHAT_AUTH_KEY ?? "").trim(); const gigachatModel = (process.env.GIGACHAT_MODEL ?? "").trim(); const gigachatScope = (process.env.GIGACHAT_SCOPE ?? "").trim(); const gigachatOauthUrl = (process.env.GIGACHAT_OAUTH_URL ?? "https://ngw.devices.sberbank.ru:9443/api/v2/oauth").trim(); const gigachatBaseUrl = (process.env.GIGACHAT_BASE_URL ?? "https://gigachat.devices.sberbank.ru/api/v1").trim(); const useGigachat = Boolean(gigachatAuthKey && gigachatScope); let llmApiKey = genericApiKey; let llmBaseURL = genericBaseURL; let llmModel = genericModel; let llmHeaders: Record | undefined; let llmReasoningEnabled = false; if (openrouterApiKey) { llmApiKey = openrouterApiKey; llmBaseURL = openrouterBaseURL; llmModel = openrouterModel; llmReasoningEnabled = openrouterReasoningEnabled; llmHeaders = { ...(openrouterReferer ? { "HTTP-Referer": openrouterReferer } : {}), ...(openrouterTitle ? { "X-Title": openrouterTitle } : {}), }; } if (useGigachat) { try { llmApiKey = await getGigachatAccessToken({ authKey: gigachatAuthKey, scope: gigachatScope, oauthUrl: gigachatOauthUrl, }); llmBaseURL = gigachatBaseUrl; llmModel = gigachatModel || "GigaChat-2-Max"; } catch (e: any) { throw new Error(`Не удалось получить токен GigaChat: ${String(e?.message || e)}`); } } if (!llmApiKey) { throw new Error( "LLM API key is not configured. Set OPENROUTER_API_KEY (or GIGACHAT_AUTH_KEY/GIGACHAT_SCOPE) and restart.", ); } // Keep the dataset fresh so the "CRM filesystem" stays in sync with DB. await ensureDataset({ teamId: input.teamId, userId: input.userId }); const toolsUsed: string[] = []; const dbWrites: Array<{ kind: string; detail: string }> = []; const toolRuns: NonNullable = []; async function emitTrace(event: AgentTraceEvent) { lfTrace?.event({ name: "agent.trace", input: { text: event.text, toolRun: event.toolRun ?? null, }, metadata: { requestId: input.requestId ?? null, }, }); if (!input.onTrace) return; try { await input.onTrace(event); } catch { // Trace transport errors must not break the agent response. } } function compact(value: unknown, max = 240) { const text = typeof value === "string" ? value : JSON.stringify(value); if (!text) return ""; return text.length > max ? `${text.slice(0, max)}...` : text; } function stableStringify(value: unknown): string { const walk = (input: unknown): unknown => { if (Array.isArray(input)) return input.map(walk); if (!input || typeof input !== "object") return input; const obj = input as Record; return Object.fromEntries( Object.keys(obj) .sort() .map((key) => [key, walk(obj[key])]), ); }; return JSON.stringify(walk(value)); } const CrmToolSchema = z.object({ action: z.enum([ "getContactsList", "getContactSnapshot", "getUserCalendarWindow", "updateContactSummary", "createUserCalendarEvent", ]), // read actions query: z.string().optional(), from: z.string().optional(), to: z.string().optional(), limit: z.number().int().optional(), offset: z.number().int().min(0).optional(), includeArchived: z.boolean().optional(), messagesLimit: z.number().int().optional(), eventsLimit: z.number().int().optional(), dealsLimit: z.number().int().optional(), // write actions contact: z.string().optional(), contactId: z.string().optional(), summary: z.string().optional(), content: z.string().optional(), title: z.string().optional(), start: z.string().optional(), end: z.string().optional(), note: z.string().optional(), archived: z.boolean().optional(), }); const readActionNames = new Set([ "getContactsList", "getContactSnapshot", "getUserCalendarWindow", ]); const readToolCache = new Map(); const invalidateReadCache = () => { readToolCache.clear(); }; const crmTool = tool( async (rawInput: unknown) => { const raw = CrmToolSchema.parse(rawInput); const toolName = `crm:${raw.action}`; const startedAt = new Date().toISOString(); toolsUsed.push(toolName); await emitTrace({ text: `Использую инструмент: ${toolName}` }); const executeAction = async () => { const readCacheKey = readActionNames.has(raw.action) ? `${raw.action}:${stableStringify(raw)}` : ""; const cacheReadResult = (result: string) => { if (readCacheKey) { readToolCache.set(readCacheKey, result); } return result; }; if (readCacheKey && readToolCache.has(readCacheKey)) { return JSON.stringify( { cached: true, action: raw.action, note: "Identical read query was already returned earlier in this request. Reuse previous tool output.", }, null, 2, ); } const fromValue = raw.from ?? raw.start; const toValue = raw.to ?? raw.end; if (raw.action === "getContactsList") { const q = (raw.query ?? "").trim(); const limit = Math.max(1, Math.min(raw.limit ?? 50, 200)); const offset = Math.max(0, raw.offset ?? 0); const now = new Date(); const items = await prisma.contact.findMany({ where: { teamId: input.teamId, ...(q ? { OR: [ { name: { contains: q } }, { company: { contains: q } }, { email: { contains: q } }, { phone: { contains: q } }, ], } : {}), }, orderBy: [{ updatedAt: "desc" }, { id: "asc" }], skip: offset, take: limit, include: { note: { select: { content: true, updatedAt: true } }, messages: { select: { occurredAt: true, channel: true, direction: true, kind: true, content: true }, orderBy: { occurredAt: "desc" }, take: 1, }, events: { select: { id: true, title: true, startsAt: true, endsAt: true, isArchived: true }, where: { startsAt: { gte: now }, ...(raw.includeArchived ? {} : { isArchived: false }), }, orderBy: { startsAt: "asc" }, take: 1, }, deals: { select: { id: true, stage: true, title: true, amount: true, updatedAt: true, nextStep: true, summary: true }, orderBy: { updatedAt: "desc" }, take: 1, }, _count: { select: { messages: true, events: true, deals: true }, }, }, }); return cacheReadResult( JSON.stringify( { items: items.map((c) => ({ id: c.id, name: c.name, company: c.company, country: c.country, location: c.location, email: c.email, phone: c.phone, summary: c.note?.content ?? null, lastMessage: c.messages[0] ? { occurredAt: c.messages[0].occurredAt.toISOString(), channel: c.messages[0].channel, direction: c.messages[0].direction, kind: c.messages[0].kind, content: c.messages[0].content, } : null, nextEvent: c.events[0] ? { id: c.events[0].id, title: c.events[0].title, startsAt: c.events[0].startsAt.toISOString(), endsAt: (c.events[0].endsAt ?? c.events[0].startsAt).toISOString(), isArchived: c.events[0].isArchived, } : null, latestDeal: c.deals[0] ?? null, counts: c._count, })), pagination: { offset, limit, returned: items.length, hasMore: items.length === limit, nextOffset: offset + items.length, }, }, null, 2, ), ); } if (raw.action === "getContactSnapshot") { const contactRef = (raw.contact ?? "").trim(); const contactId = (raw.contactId ?? "").trim(); const messagesLimit = Math.max(1, Math.min(raw.messagesLimit ?? 20, 100)); const eventsLimit = Math.max(1, Math.min(raw.eventsLimit ?? 20, 100)); const dealsLimit = Math.max(1, Math.min(raw.dealsLimit ?? 5, 20)); let target: { id: string; name: string } | null = null; if (contactId) { target = await prisma.contact.findFirst({ where: { id: contactId, teamId: input.teamId }, select: { id: true, name: true }, }); } if (!target && contactRef) { target = await resolveContact(input.teamId, contactRef); } if (!target) { throw new Error("contact/contactId is required"); } const from = fromValue ? new Date(fromValue) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const to = toValue ? new Date(toValue) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { throw new Error("from/to range is invalid"); } const contact = await prisma.contact.findFirst({ where: { id: target.id, teamId: input.teamId }, include: { note: { select: { content: true, updatedAt: true } }, messages: { select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true }, orderBy: { occurredAt: "desc" }, take: messagesLimit, }, events: { select: { id: true, title: true, startsAt: true, endsAt: true, note: true, isArchived: true }, where: { startsAt: { gte: from, lte: to }, ...(raw.includeArchived ? {} : { isArchived: false }), }, orderBy: { startsAt: "asc" }, take: eventsLimit, }, deals: { select: { id: true, title: true, stage: true, amount: true, nextStep: true, summary: true, currentStepId: true, updatedAt: true, steps: { select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, orderBy: [{ order: "asc" }, { createdAt: "asc" }], }, }, orderBy: { updatedAt: "desc" }, take: dealsLimit, }, _count: { select: { messages: true, events: true, deals: true }, }, }, }); if (!contact) throw new Error("contact not found"); return cacheReadResult( JSON.stringify( { contact: { id: contact.id, name: contact.name, company: contact.company, country: contact.country, location: contact.location, email: contact.email, phone: contact.phone, updatedAt: contact.updatedAt.toISOString(), }, summary: contact.note?.content ?? null, note: contact.note ? { content: contact.note.content, updatedAt: contact.note.updatedAt.toISOString(), } : null, messages: contact.messages.map((m) => ({ id: m.id, occurredAt: m.occurredAt.toISOString(), channel: m.channel, direction: m.direction, kind: m.kind, content: m.content, durationSec: m.durationSec, transcript: m.transcriptJson, })), events: contact.events.map((e) => ({ id: e.id, title: e.title, startsAt: e.startsAt.toISOString(), endsAt: (e.endsAt ?? e.startsAt).toISOString(), note: e.note, isArchived: e.isArchived, })), deals: contact.deals.map((d) => ({ id: d.id, title: d.title, stage: d.stage, amount: d.amount, nextStep: d.nextStep, summary: d.summary, currentStepId: d.currentStepId, updatedAt: d.updatedAt.toISOString(), steps: d.steps.map((s) => ({ id: s.id, title: s.title, status: s.status, dueAt: s.dueAt ? s.dueAt.toISOString() : null, order: s.order, completedAt: s.completedAt ? s.completedAt.toISOString() : null, })), })), counts: contact._count, }, null, 2, ), ); } if (raw.action === "getUserCalendarWindow") { const from = fromValue ? new Date(fromValue) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const to = toValue ? new Date(toValue) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { throw new Error("from/to range is invalid"); } const limit = Math.max(1, Math.min(raw.limit ?? 100, 500)); const offset = Math.max(0, raw.offset ?? 0); const where = { teamId: input.teamId, startsAt: { gte: from, lte: to }, ...(raw.includeArchived ? {} : { isArchived: false }), }; const [total, items] = await Promise.all([ prisma.calendarEvent.count({ where }), prisma.calendarEvent.findMany({ where, orderBy: { startsAt: "asc" }, skip: offset, take: limit, include: { contact: { select: { id: true, name: true, company: true } } }, }), ]); return cacheReadResult( JSON.stringify( { window: { from: from.toISOString(), to: to.toISOString() }, pagination: { offset, limit, returned: items.length, total, hasMore: offset + items.length < total, nextOffset: offset + items.length, }, items: items.map((e) => ({ id: e.id, title: e.title, startsAt: e.startsAt.toISOString(), endsAt: (e.endsAt ?? e.startsAt).toISOString(), note: e.note, isArchived: e.isArchived, contact: e.contact ? { id: e.contact.id, name: e.contact.name, company: e.contact.company, } : null, })), }, null, 2, ), ); } if (raw.action === "updateContactSummary") { const contactId = (raw.contactId ?? "").trim(); const content = (raw.summary ?? raw.content ?? "").trim(); if (!contactId) throw new Error("contactId is required"); if (!content) throw new Error("summary is required"); const contact = await prisma.contact.findFirst({ where: { id: contactId, teamId: input.teamId }, select: { id: true, name: true }, }); if (!contact) throw new Error("contactId not found"); await prisma.contactNote.upsert({ where: { contactId: contact.id }, update: { content }, create: { contactId: contact.id, content }, }); invalidateReadCache(); dbWrites.push({ kind: "updateContactSummary", detail: `${contact.name}: summary updated` }); return JSON.stringify({ ok: true, applied: 1 }, null, 2); } if (raw.action === "createUserCalendarEvent") { const title = (raw.title ?? "").trim(); const start = raw.start ? new Date(raw.start) : null; if (!title) throw new Error("title is required"); if (!start || Number.isNaN(start.getTime())) throw new Error("start is invalid"); const end = raw.end ? new Date(raw.end) : null; const contactName = (raw.contact ?? "").trim(); const contactId = (raw.contactId ?? "").trim(); const contact = (contactId ? await prisma.contact.findFirst({ where: { id: contactId, teamId: input.teamId }, select: { id: true, name: true }, }) : null) || (contactName ? await resolveContact(input.teamId, contactName) : null); if (contactId && !contact) throw new Error("contact not found"); const created = await prisma.calendarEvent.create({ data: { teamId: input.teamId, title, startsAt: start, endsAt: end && !Number.isNaN(end.getTime()) ? end : null, note: (raw.note ?? "").trim() || null, isArchived: Boolean(raw.archived), contactId: contact?.id ?? null, }, }); invalidateReadCache(); dbWrites.push({ kind: "createUserCalendarEvent", detail: `created event ${created.id}` }); return JSON.stringify({ ok: true, applied: 1, eventId: created.id, contactId: contact?.id ?? null, }, null, 2); } return JSON.stringify({ ok: false, error: "unknown action" }); }; try { const result = await executeAction(); const run = { name: toolName, status: "ok", input: compact(raw), output: compact(result), at: startedAt, } as const; toolRuns.push(run); await emitTrace({ text: `Tool finished: ${toolName}`, toolRun: run, }); return result; } catch (error: any) { const run = { name: toolName, status: "error", input: compact(raw), output: compact(error?.message || String(error)), at: startedAt, } as const; toolRuns.push(run); await emitTrace({ text: `Tool failed: ${toolName}`, toolRun: run, }); throw error; } }, { name: "crm", description: "CRM tool with exactly five actions: getContactsList, getContactSnapshot, getUserCalendarWindow, updateContactSummary, createUserCalendarEvent.", schema: CrmToolSchema, }, ); const snapshot = await buildCrmSnapshot({ teamId: input.teamId }); const snapshotJson = JSON.stringify(snapshot, null, 2); const model = new ChatOpenAI({ apiKey: llmApiKey, model: llmModel, temperature: 0.2, ...(llmReasoningEnabled ? { modelKwargs: { reasoning: { enabled: true }, }, } : {}), ...(llmBaseURL || llmHeaders ? { configuration: { ...(llmBaseURL ? { baseURL: llmBaseURL } : {}), ...(llmHeaders ? { defaultHeaders: llmHeaders } : {}), }, } : {}), }); const agent = createReactAgent({ llm: model, tools: [crmTool], }); const tracingFlag = (process.env.LANGSMITH_TRACING ?? process.env.LANGCHAIN_TRACING_V2 ?? "").trim().toLowerCase(); const tracingEnabled = tracingFlag === "1" || tracingFlag === "true" || tracingFlag === "yes"; const langfuse = getLangfuseClient(); const lfTrace = langfuse?.trace({ id: input.requestId ?? makeId("trace"), name: "clientsflow.crm_agent_request", userId: input.userId, sessionId: input.conversationId ?? undefined, input: input.userText, metadata: { teamId: input.teamId, userId: input.userId, requestId: input.requestId ?? null, conversationId: input.conversationId ?? null, }, tags: ["clientsflow", "crm-agent", "langgraph"], }); let finalText = ""; const cycleNotes: string[] = []; const system = [ "You are Pilot, a CRM assistant.", "Rules:", "- Be concrete and complete. Do not cut important details in the final answer.", "- Work in short iterative cycles. Do not stop after the first thought if the task needs more than one action.", "- You are given a structured CRM JSON snapshot as baseline context.", "- Only use these actions: crm.getContactsList, crm.getContactSnapshot, crm.getUserCalendarWindow, crm.updateContactSummary, crm.createUserCalendarEvent.", "- Use crm.getContactsList first to choose contacts, then crm.getContactSnapshot for deep context, then crm.getUserCalendarWindow for schedule validation.", "- Avoid repeating identical read calls with the same arguments.", "- Write actions are immediate DB changes. Do not mention staging or commit.", "- Do not claim you sent an external message; you can only create CRM records.", "", "CRM Snapshot JSON:", snapshotJson, ].join("\n"); const extractText = (value: unknown, depth = 0): string => { if (depth > 5 || value == null) return ""; if (typeof value === "string") return value.trim(); if (Array.isArray(value)) { const parts = value .map((item) => extractText(item, depth + 1)) .filter(Boolean); return parts.join("\n").trim(); } if (typeof value !== "object") return ""; const obj = value as Record; for (const key of ["text", "content", "answer", "output_text", "final_text"]) { const text = extractText(obj[key], depth + 1); if (text) return text; } if (Array.isArray(obj.parts)) { const text = extractText(obj.parts, depth + 1); if (text) return text; } return ""; }; const messageType = (msg: any): string => { if (typeof msg?._getType === "function") { try { return String(msg._getType() ?? ""); } catch { // ignore } } return String(msg?.type ?? msg?.role ?? msg?.constructor?.name ?? ""); }; const extractResult = (res: any) => { const extractedText = (() => { const messages = Array.isArray(res?.messages) ? res.messages : []; for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i]; const type = messageType(msg).toLowerCase(); if (!type.includes("ai") && !type.includes("assistant")) continue; const text = extractText(msg?.content) || extractText(msg); if (text) return text; } return extractText(res?.output) || extractText(res?.response) || extractText(res?.finalResponse) || ""; })(); return { text: extractedText.trim(), }; }; for (let cycle = 1; ; cycle += 1) { const userPrompt = cyclePrompt(input.userText, cycle, cycleNotes); const cycleSpan = lfTrace?.span({ name: "agent.cycle", input: userPrompt, metadata: { cycle, requestId: input.requestId ?? null, }, }); await emitTrace({ text: "Анализирую задачу и текущий контекст CRM." }); const beforeRuns = toolRuns.length; const beforeWrites = dbWrites.length; let res: any; try { const invokeConfig: Record = {}; const recursionLimit = Number(process.env.CF_AGENT_RECURSION_LIMIT ?? "1000000"); if (Number.isFinite(recursionLimit) && recursionLimit > 0) { invokeConfig.recursionLimit = recursionLimit; } if (tracingEnabled) { invokeConfig.runName = "clientsflow.crm_agent_cycle"; invokeConfig.tags = ["clientsflow", "crm-agent", "langgraph"]; invokeConfig.metadata = { teamId: input.teamId, userId: input.userId, requestId: input.requestId ?? null, conversationId: input.conversationId ?? null, cycle, }; } res = await agent.invoke( { messages: [ { role: "system", content: system }, { role: "user", content: userPrompt }, ], }, invokeConfig, ); } catch (e: any) { await emitTrace({ text: "Один из шагов завершился ошибкой." }); cycleSpan?.end({ output: "error", level: "ERROR", statusMessage: String(e?.message ?? e ?? "unknown_error"), }); throw e; } const parsed = extractResult(res); if (parsed.text) { finalText = parsed.text; } const progressed = toolRuns.length > beforeRuns || dbWrites.length > beforeWrites; cycleSpan?.end({ output: parsed.text || "", metadata: { progressed, toolRunsDelta: toolRuns.length - beforeRuns, dbWritesDelta: dbWrites.length - beforeWrites, }, }); if (progressed) { cycleNotes.push(`Cycle ${cycle}: updated tools/data state.`); } await emitTrace({ text: progressed ? "Продвигаюсь по задаче и обновляю рабочий набор изменений." : "Промежуточный шаг не дал прогресса, проверяю следующий вариант.", }); const done = !progressed && cycle > 1; if (done) { await emitTrace({ text: "Формирую итоговый ответ." }); break; } } lfTrace?.update({ output: finalText || null, metadata: { toolsUsedCount: toolsUsed.length, toolRunsCount: toolRuns.length, dbWritesCount: dbWrites.length, }, }); void langfuse?.flushAsync().catch(() => {}); if (!finalText) { throw new Error("Model returned empty response"); } const plan: string[] = []; return { text: finalText, plan, thinking: [], tools: toolsUsed, toolRuns, dbWrites: dbWrites.length ? dbWrites : undefined, }; }