From ecc44bd3d38132ba6d192d74635c814ec87fad38 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:58:18 +0700 Subject: [PATCH] feat(agent): optimize crm tool flow and reduce context bloat --- Frontend/server/agent/langgraphCrmAgent.ts | 620 +++++++++++++++++++-- 1 file changed, 560 insertions(+), 60 deletions(-) diff --git a/Frontend/server/agent/langgraphCrmAgent.ts b/Frontend/server/agent/langgraphCrmAgent.ts index 96cdc42..c7f535b 100644 --- a/Frontend/server/agent/langgraphCrmAgent.ts +++ b/Frontend/server/agent/langgraphCrmAgent.ts @@ -473,9 +473,26 @@ export async function runLangGraphCrmAgentFor(input: { 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([ "get_snapshot", + "list_contacts_digest", + "get_contact_snapshot", + "get_calendar_window", "query_contacts", "query_deals", "query_events", @@ -484,6 +501,7 @@ export async function runLangGraphCrmAgentFor(input: { "commit_changes", "update_contact_note", "create_event", + "create_events_batch", "create_message", "update_deal_stage", ]), @@ -493,9 +511,15 @@ export async function runLangGraphCrmAgentFor(input: { from: z.string().optional(), to: z.string().optional(), limit: z.number().int().optional(), + offset: z.number().int().min(0).optional(), mode: z.enum(["stage", "apply"]).optional(), + includeArchived: z.boolean().optional(), + messagesLimit: z.number().int().optional(), + eventsLimit: z.number().int().optional(), + dealsLimit: z.number().int().optional(), // writes contact: z.string().optional(), + contactId: z.string().optional(), content: z.string().optional(), title: z.string().optional(), start: z.string().optional(), @@ -510,6 +534,19 @@ export async function runLangGraphCrmAgentFor(input: { durationSec: z.number().int().optional(), transcript: z.array(z.string()).optional(), dealId: z.string().optional(), + events: z + .array( + z.object({ + contact: z.string().optional(), + contactId: z.string().optional(), + title: z.string(), + start: z.string(), + end: z.string().optional(), + note: z.string().optional(), + archived: z.boolean().optional(), + }), + ) + .optional(), }); const applyPendingChanges = async () => { @@ -584,6 +621,20 @@ export async function runLangGraphCrmAgentFor(input: { return { ok: true, applied: applied.length, changes: applied }; }; + const readActionNames = new Set([ + "get_snapshot", + "list_contacts_digest", + "get_contact_snapshot", + "get_calendar_window", + "query_contacts", + "query_deals", + "query_events", + ]); + const readToolCache = new Map(); + const invalidateReadCache = () => { + readToolCache.clear(); + }; + const crmTool = tool( async (rawInput: unknown) => { const raw = CrmToolSchema.parse(rawInput); @@ -593,17 +644,332 @@ export async function runLangGraphCrmAgentFor(input: { 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 === "get_snapshot") { const snapshot = await buildCrmSnapshot({ teamId: input.teamId, contact: raw.contact, contactsLimit: raw.limit, }); - return JSON.stringify(snapshot, null, 2); + return cacheReadResult(JSON.stringify(snapshot, null, 2)); + } + + if (raw.action === "list_contacts_digest") { + 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 === "get_contact_snapshot") { + 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 === "get_calendar_window") { + 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 === "query_contacts") { const q = (raw.query ?? "").trim(); + const offset = Math.max(0, raw.offset ?? 0); + const limit = Math.max(1, Math.min(raw.limit ?? 20, 100)); const items = await prisma.contact.findMany({ where: { teamId: input.teamId, @@ -619,80 +985,145 @@ export async function runLangGraphCrmAgentFor(input: { : {}), }, orderBy: { updatedAt: "desc" }, - take: Math.max(1, Math.min(raw.limit ?? 20, 100)), + skip: offset, + take: limit, include: { note: { select: { content: true, updatedAt: true } } }, }); - return JSON.stringify( - items.map((c) => ({ - id: c.id, - name: c.name, - company: c.company, - country: c.country, - location: c.location, - email: c.email, - phone: c.phone, - note: c.note?.content ?? null, - })), - null, - 2, + 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, + note: c.note?.content ?? null, + })), + pagination: { + offset, + limit, + returned: items.length, + hasMore: items.length === limit, + nextOffset: offset + items.length, + }, + }, + null, + 2, + ), ); } if (raw.action === "query_deals") { + const offset = Math.max(0, raw.offset ?? 0); + const limit = Math.max(1, Math.min(raw.limit ?? 20, 100)); + const updatedAt: { gte?: Date; lte?: Date } = {}; + if (fromValue) { + const from = new Date(fromValue); + if (!Number.isNaN(from.getTime())) updatedAt.gte = from; + } + if (toValue) { + const to = new Date(toValue); + if (!Number.isNaN(to.getTime())) updatedAt.lte = to; + } const items = await prisma.deal.findMany({ - where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) }, + where: { + teamId: input.teamId, + ...(raw.stage ? { stage: raw.stage } : {}), + ...(updatedAt.gte || updatedAt.lte ? { updatedAt } : {}), + }, orderBy: { updatedAt: "desc" }, - take: Math.max(1, Math.min(raw.limit ?? 20, 100)), + skip: offset, + take: limit, 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" }] }, + steps: { + select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + }, }, }); - return JSON.stringify( - items.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 ? s.dueAt.toISOString() : null, - order: s.order, - completedAt: s.completedAt ? s.completedAt.toISOString() : null, - })), - contact: d.contact.name, - company: d.contact.company, - })), - null, - 2, + return cacheReadResult( + JSON.stringify( + { + items: items.map((c) => ({ + id: c.id, + title: c.title, + stage: c.stage, + amount: c.amount, + nextStep: c.nextStep, + summary: c.summary, + currentStepId: c.currentStepId, + steps: c.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, + })), + contact: c.contact.name, + company: c.contact.company, + })), + pagination: { + offset, + limit, + returned: items.length, + hasMore: items.length === limit, + nextOffset: offset + items.length, + }, + }, + null, + 2, + ), ); } if (raw.action === "query_events") { - const from = raw.from ? new Date(raw.from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const to = raw.to ? new Date(raw.to) : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); + 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() + 60 * 24 * 60 * 60 * 1000); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + throw new Error("from/to range is invalid"); + } + const offset = Math.max(0, raw.offset ?? 0); + const limit = Math.max(1, Math.min(raw.limit ?? 100, 500)); const items = await prisma.calendarEvent.findMany({ - where: { teamId: input.teamId, startsAt: { gte: from, lte: to } }, + where: { + teamId: input.teamId, + startsAt: { gte: from, lte: to }, + ...(raw.title ? { title: { contains: raw.title } } : {}), + ...(raw.includeArchived ? {} : { isArchived: false }), + }, orderBy: { startsAt: "asc" }, - take: Math.max(1, Math.min(raw.limit ?? 100, 500)), + skip: offset, + take: limit, include: { contact: { select: { name: true } } }, }); - return JSON.stringify( - items.map((e) => ({ - id: e.id, - title: e.title, - startsAt: e.startsAt.toISOString(), - endsAt: (e.endsAt ?? e.startsAt).toISOString(), - note: e.note, - contact: e.contact?.name ?? null, - })), - null, - 2, + return cacheReadResult( + JSON.stringify( + { + 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?.name ?? null, + })), + pagination: { + offset, + limit, + returned: items.length, + hasMore: items.length === limit, + nextOffset: offset + items.length, + }, + }, + null, + 2, + ), ); } @@ -714,11 +1145,13 @@ export async function runLangGraphCrmAgentFor(input: { if (raw.action === "discard_changes") { const discarded = pendingChanges.length; pendingChanges.splice(0, pendingChanges.length); + invalidateReadCache(); return JSON.stringify({ ok: true, discarded }, null, 2); } if (raw.action === "commit_changes") { const committed = await applyPendingChanges(); + invalidateReadCache(); return JSON.stringify(committed, null, 2); } @@ -739,9 +1172,11 @@ export async function runLangGraphCrmAgentFor(input: { contactName: contact.name, content, }); + invalidateReadCache(); if (raw.mode === "apply") { const committed = await applyPendingChanges(); + invalidateReadCache(); return JSON.stringify(committed, null, 2); } return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2); @@ -755,9 +1190,15 @@ export async function runLangGraphCrmAgentFor(input: { const end = raw.end ? new Date(raw.end) : null; const contactName = (raw.contact ?? "").trim(); - const contact = contactName - ? await resolveContact(input.teamId, contactName) - : null; + 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); pendingChanges.push({ id: makeId("chg"), @@ -771,14 +1212,63 @@ export async function runLangGraphCrmAgentFor(input: { note: (raw.note ?? "").trim() || null, isArchived: Boolean(raw.archived), }); + invalidateReadCache(); if (raw.mode === "apply") { const committed = await applyPendingChanges(); + invalidateReadCache(); return JSON.stringify(committed, null, 2); } return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2); } + if (raw.action === "create_events_batch") { + const batch = Array.isArray(raw.events) ? raw.events : []; + if (!batch.length) throw new Error("events is required"); + const capped = batch.slice(0, 200); + + for (const item of capped) { + const title = (item.title ?? "").trim(); + const start = item.start ? new Date(item.start) : null; + if (!title) throw new Error("events[].title is required"); + if (!start || Number.isNaN(start.getTime())) throw new Error("events[].start is invalid"); + + const end = item.end ? new Date(item.end) : null; + const contactId = (item.contactId ?? "").trim(); + const contactName = (item.contact ?? "").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: ${contactId}`); + + pendingChanges.push({ + id: makeId("chg"), + type: "create_event", + createdAt: iso(new Date()), + contactId: contact?.id ?? null, + contactName: contact?.name ?? null, + title, + start: iso(start), + end: end && !Number.isNaN(end.getTime()) ? iso(end) : null, + note: (item.note ?? "").trim() || null, + isArchived: Boolean(item.archived), + }); + } + invalidateReadCache(); + + if (raw.mode === "apply") { + const committed = await applyPendingChanges(); + invalidateReadCache(); + return JSON.stringify(committed, null, 2); + } + return JSON.stringify({ ok: true, staged: capped.length, pending: pendingChanges.length }, null, 2); + } + if (raw.action === "create_message") { const contactName = (raw.contact ?? "").trim(); const text = (raw.text ?? "").trim(); @@ -805,9 +1295,11 @@ export async function runLangGraphCrmAgentFor(input: { durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null, transcript: Array.isArray(raw.transcript) ? raw.transcript : null, }); + invalidateReadCache(); if (raw.mode === "apply") { const committed = await applyPendingChanges(); + invalidateReadCache(); return JSON.stringify(committed, null, 2); } return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2); @@ -833,9 +1325,11 @@ export async function runLangGraphCrmAgentFor(input: { dealTitle: deal.title, stage, }); + invalidateReadCache(); if (raw.mode === "apply") { const committed = await applyPendingChanges(); + invalidateReadCache(); return JSON.stringify(committed, null, 2); } return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, null, 2); @@ -935,10 +1429,16 @@ export async function runLangGraphCrmAgentFor(input: { const system = [ "You are Pilot, a CRM assistant.", "Rules:", - "- Be concrete and concise.", + "- 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.", - "- If you need fresher or narrower data, call crm.get_snapshot/query_* tools.", + "- Prefer this data flow to keep context small:", + " 1) crm.list_contacts_digest for the roster and prioritization.", + " 2) crm.get_contact_snapshot for one focused contact (messages/events/deals/summary in one call).", + " 3) crm.get_calendar_window for calendar constraints.", + "- Use crm.query_* only for narrow follow-up filters.", + "- Avoid repeating identical read calls with the same arguments.", + "- If creating many events, prefer crm.create_events_batch instead of many crm.create_event calls.", "- For changes, stage first with mode=stage. Commit only when user asks to execute.", "- You can apply immediately with mode=apply only if user explicitly asked to do it now.", "- Use pending_changes and commit_changes to control staged updates.",