feat(agent): optimize crm tool flow and reduce context bloat

This commit is contained in:
Ruslan Bakiev
2026-02-20 10:58:18 +07:00
parent b3602d142e
commit ecc44bd3d3

View File

@@ -473,9 +473,26 @@ export async function runLangGraphCrmAgentFor(input: {
return text.length > max ? `${text.slice(0, max)}...` : text; 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<string, unknown>;
return Object.fromEntries(
Object.keys(obj)
.sort()
.map((key) => [key, walk(obj[key])]),
);
};
return JSON.stringify(walk(value));
}
const CrmToolSchema = z.object({ const CrmToolSchema = z.object({
action: z.enum([ action: z.enum([
"get_snapshot", "get_snapshot",
"list_contacts_digest",
"get_contact_snapshot",
"get_calendar_window",
"query_contacts", "query_contacts",
"query_deals", "query_deals",
"query_events", "query_events",
@@ -484,6 +501,7 @@ export async function runLangGraphCrmAgentFor(input: {
"commit_changes", "commit_changes",
"update_contact_note", "update_contact_note",
"create_event", "create_event",
"create_events_batch",
"create_message", "create_message",
"update_deal_stage", "update_deal_stage",
]), ]),
@@ -493,9 +511,15 @@ export async function runLangGraphCrmAgentFor(input: {
from: z.string().optional(), from: z.string().optional(),
to: z.string().optional(), to: z.string().optional(),
limit: z.number().int().optional(), limit: z.number().int().optional(),
offset: z.number().int().min(0).optional(),
mode: z.enum(["stage", "apply"]).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 // writes
contact: z.string().optional(), contact: z.string().optional(),
contactId: z.string().optional(),
content: z.string().optional(), content: z.string().optional(),
title: z.string().optional(), title: z.string().optional(),
start: z.string().optional(), start: z.string().optional(),
@@ -510,6 +534,19 @@ export async function runLangGraphCrmAgentFor(input: {
durationSec: z.number().int().optional(), durationSec: z.number().int().optional(),
transcript: z.array(z.string()).optional(), transcript: z.array(z.string()).optional(),
dealId: 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 () => { const applyPendingChanges = async () => {
@@ -584,6 +621,20 @@ export async function runLangGraphCrmAgentFor(input: {
return { ok: true, applied: applied.length, changes: applied }; 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<string, string>();
const invalidateReadCache = () => {
readToolCache.clear();
};
const crmTool = tool( const crmTool = tool(
async (rawInput: unknown) => { async (rawInput: unknown) => {
const raw = CrmToolSchema.parse(rawInput); const raw = CrmToolSchema.parse(rawInput);
@@ -593,17 +644,332 @@ export async function runLangGraphCrmAgentFor(input: {
await emitTrace({ text: `Использую инструмент: ${toolName}` }); await emitTrace({ text: `Использую инструмент: ${toolName}` });
const executeAction = async () => { 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") { if (raw.action === "get_snapshot") {
const snapshot = await buildCrmSnapshot({ const snapshot = await buildCrmSnapshot({
teamId: input.teamId, teamId: input.teamId,
contact: raw.contact, contact: raw.contact,
contactsLimit: raw.limit, 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") { if (raw.action === "query_contacts") {
const q = (raw.query ?? "").trim(); 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({ const items = await prisma.contact.findMany({
where: { where: {
teamId: input.teamId, teamId: input.teamId,
@@ -619,80 +985,145 @@ export async function runLangGraphCrmAgentFor(input: {
: {}), : {}),
}, },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: Math.max(1, Math.min(raw.limit ?? 20, 100)), skip: offset,
take: limit,
include: { note: { select: { content: true, updatedAt: true } } }, include: { note: { select: { content: true, updatedAt: true } } },
}); });
return JSON.stringify( return cacheReadResult(
items.map((c) => ({ JSON.stringify(
id: c.id, {
name: c.name, items: items.map((c) => ({
company: c.company, id: c.id,
country: c.country, name: c.name,
location: c.location, company: c.company,
email: c.email, country: c.country,
phone: c.phone, location: c.location,
note: c.note?.content ?? null, email: c.email,
})), phone: c.phone,
null, note: c.note?.content ?? null,
2, })),
pagination: {
offset,
limit,
returned: items.length,
hasMore: items.length === limit,
nextOffset: offset + items.length,
},
},
null,
2,
),
); );
} }
if (raw.action === "query_deals") { 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({ 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" }, orderBy: { updatedAt: "desc" },
take: Math.max(1, Math.min(raw.limit ?? 20, 100)), skip: offset,
take: limit,
include: { include: {
contact: { select: { name: true, company: true } }, 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( return cacheReadResult(
items.map((d) => ({ JSON.stringify(
id: d.id, {
title: d.title, items: items.map((c) => ({
stage: d.stage, id: c.id,
amount: d.amount, title: c.title,
nextStep: d.nextStep, stage: c.stage,
summary: d.summary, amount: c.amount,
currentStepId: d.currentStepId, nextStep: c.nextStep,
steps: d.steps.map((s) => ({ summary: c.summary,
id: s.id, currentStepId: c.currentStepId,
title: s.title, steps: c.steps.map((s) => ({
status: s.status, id: s.id,
dueAt: s.dueAt ? s.dueAt.toISOString() : null, title: s.title,
order: s.order, status: s.status,
completedAt: s.completedAt ? s.completedAt.toISOString() : null, dueAt: s.dueAt ? s.dueAt.toISOString() : null,
})), order: s.order,
contact: d.contact.name, completedAt: s.completedAt ? s.completedAt.toISOString() : null,
company: d.contact.company, })),
})), contact: c.contact.name,
null, company: c.contact.company,
2, })),
pagination: {
offset,
limit,
returned: items.length,
hasMore: items.length === limit,
nextOffset: offset + items.length,
},
},
null,
2,
),
); );
} }
if (raw.action === "query_events") { if (raw.action === "query_events") {
const from = raw.from ? new Date(raw.from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const from = fromValue ? new Date(fromValue) : 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 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({ 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" }, orderBy: { startsAt: "asc" },
take: Math.max(1, Math.min(raw.limit ?? 100, 500)), skip: offset,
take: limit,
include: { contact: { select: { name: true } } }, include: { contact: { select: { name: true } } },
}); });
return JSON.stringify( return cacheReadResult(
items.map((e) => ({ JSON.stringify(
id: e.id, {
title: e.title, items: items.map((e) => ({
startsAt: e.startsAt.toISOString(), id: e.id,
endsAt: (e.endsAt ?? e.startsAt).toISOString(), title: e.title,
note: e.note, startsAt: e.startsAt.toISOString(),
contact: e.contact?.name ?? null, endsAt: (e.endsAt ?? e.startsAt).toISOString(),
})), note: e.note,
null, isArchived: e.isArchived,
2, 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") { if (raw.action === "discard_changes") {
const discarded = pendingChanges.length; const discarded = pendingChanges.length;
pendingChanges.splice(0, pendingChanges.length); pendingChanges.splice(0, pendingChanges.length);
invalidateReadCache();
return JSON.stringify({ ok: true, discarded }, null, 2); return JSON.stringify({ ok: true, discarded }, null, 2);
} }
if (raw.action === "commit_changes") { if (raw.action === "commit_changes") {
const committed = await applyPendingChanges(); const committed = await applyPendingChanges();
invalidateReadCache();
return JSON.stringify(committed, null, 2); return JSON.stringify(committed, null, 2);
} }
@@ -739,9 +1172,11 @@ export async function runLangGraphCrmAgentFor(input: {
contactName: contact.name, contactName: contact.name,
content, content,
}); });
invalidateReadCache();
if (raw.mode === "apply") { if (raw.mode === "apply") {
const committed = await applyPendingChanges(); const committed = await applyPendingChanges();
invalidateReadCache();
return JSON.stringify(committed, null, 2); return JSON.stringify(committed, null, 2);
} }
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, 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 end = raw.end ? new Date(raw.end) : null;
const contactName = (raw.contact ?? "").trim(); const contactName = (raw.contact ?? "").trim();
const contact = contactName const contactId = (raw.contactId ?? "").trim();
? await resolveContact(input.teamId, contactName) const contact =
: null; (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({ pendingChanges.push({
id: makeId("chg"), id: makeId("chg"),
@@ -771,14 +1212,63 @@ export async function runLangGraphCrmAgentFor(input: {
note: (raw.note ?? "").trim() || null, note: (raw.note ?? "").trim() || null,
isArchived: Boolean(raw.archived), isArchived: Boolean(raw.archived),
}); });
invalidateReadCache();
if (raw.mode === "apply") { if (raw.mode === "apply") {
const committed = await applyPendingChanges(); const committed = await applyPendingChanges();
invalidateReadCache();
return JSON.stringify(committed, null, 2); return JSON.stringify(committed, null, 2);
} }
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, 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") { if (raw.action === "create_message") {
const contactName = (raw.contact ?? "").trim(); const contactName = (raw.contact ?? "").trim();
const text = (raw.text ?? "").trim(); const text = (raw.text ?? "").trim();
@@ -805,9 +1295,11 @@ export async function runLangGraphCrmAgentFor(input: {
durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null, durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null,
transcript: Array.isArray(raw.transcript) ? raw.transcript : null, transcript: Array.isArray(raw.transcript) ? raw.transcript : null,
}); });
invalidateReadCache();
if (raw.mode === "apply") { if (raw.mode === "apply") {
const committed = await applyPendingChanges(); const committed = await applyPendingChanges();
invalidateReadCache();
return JSON.stringify(committed, null, 2); return JSON.stringify(committed, null, 2);
} }
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, 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, dealTitle: deal.title,
stage, stage,
}); });
invalidateReadCache();
if (raw.mode === "apply") { if (raw.mode === "apply") {
const committed = await applyPendingChanges(); const committed = await applyPendingChanges();
invalidateReadCache();
return JSON.stringify(committed, null, 2); return JSON.stringify(committed, null, 2);
} }
return JSON.stringify({ ok: true, staged: true, pending: pendingChanges.length }, 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 = [ const system = [
"You are Pilot, a CRM assistant.", "You are Pilot, a CRM assistant.",
"Rules:", "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.", "- 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.", "- 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.", "- 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.", "- 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.", "- Use pending_changes and commit_changes to control staged updates.",