Контекст диалога на Mastra Memory вместо своей таблицы

- Контекст диалога хранит Mastra Memory (LibSQL, memory.db рядом с DB_PATH), тред на чат
- Бюджет истории — messageHistory.maxTokens (CONTEXT_TOKEN_LIMIT, по умолчанию 12000)
- ToolCallFilter: результаты инструментов не попадают в прошлые реплики, внутри ответа — только свежий шаг
- Системный промпт стабилен для всего чата (кешируется провайдером); автор и упоминания — в <turn author_name>
- Фото передаются через context и не сохраняются в памяти
- maxSteps и maxOutputTokens (ANSWER_TOKEN_LIMIT) — в defaultOptions агента
- /context и /clear работают через Memory recall/deleteThread
- Удалены свои dialog_messages, dialog-repository и dialog-service

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 20:55:06 +02:00
parent b8f09227b0
commit b615518f22
19 changed files with 464 additions and 179 deletions

View File

@@ -1,58 +1,72 @@
import { Agent } from "@mastra/core/agent";
import { ToolCallFilter } from "@mastra/core/processors";
import { RequestContext } from "@mastra/core/request-context";
import type { Memory } from "@mastra/memory";
import { readPrompt } from "../../libs/prompts.js";
import type { DialogTurn, Persona } from "../../types.js";
import type { Persona } from "../../types.js";
import type { ChatHistoryService } from "../chat-history-service.js";
import type { ChatMemberService } from "../chat-member-service.js";
import type { DialogService } from "../dialog-service.js";
import { chatThreadId } from "./memory.js";
import { buildInstructions, buildUserText, type Question } from "./prompts.js";
import { createListChatMembersTool } from "./tools/list-chat-members-tool.js";
import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js";
import { createReadMemberMessagesTool } from "./tools/read-member-messages-tool.js";
import type { AssistantRequestContext } from "./tools/request-context.js";
import { assistantRequestContextSchema, type AssistantRequestContext } from "./tools/request-context.js";
export type { Question } from "./prompts.js";
interface AssistantServiceDeps {
model: string;
/** Контекст диалога — Mastra Memory (см. memory.ts). */
memory: Memory;
/** Максимальная длина ответа в токенах. */
answerTokenLimit: number;
/** Умеет ли модель смотреть изображения; если нет — фото ей не отправляем. */
imageInput: boolean;
chatHistoryService: ChatHistoryService;
chatMemberService: ChatMemberService;
dialogService: DialogService;
}
const MAX_AGENT_STEPS = 6;
/** Хватает на «прочитать архив/участников → ответить»; каждый лишний шаг — ещё один полный промпт. */
const MAX_AGENT_STEPS = 4;
/** Сообщение пользователя для модели: текст вопроса и скачанные картинки. */
const buildUserMessage = (question: Question) => ({
role: "user" as const,
content: [
{ type: "text" as const, text: buildUserText(question) },
...question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
],
});
/** Реплика прошлого диалога в формате сообщений Mastra (роль должна быть литералом). */
const toModelMessage = (turn: DialogTurn) =>
turn.role === "user"
? { role: "user" as const, content: turn.content }
: { role: "assistant" as const, content: turn.content };
/**
* Картинки уходят через `context`: модель видит их в этом запросе, но в память они не сохраняются —
* иначе байты фото тащились бы в каждый следующий вопрос.
*/
const imagesContext = (question: Question) =>
question.images.length === 0
? []
: [
{
role: "user" as const,
content: question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
},
];
export const createAssistantService = ({
model,
memory,
answerTokenLimit,
imageInput,
chatHistoryService,
chatMemberService,
dialogService,
}: AssistantServiceDeps) => {
const systemRules = readPrompt("system.md");
const agent = new Agent({
id: "boltun",
name: "Boltun",
instructions: systemRules,
requestContextSchema: assistantRequestContextSchema,
// Одинаковы для всех вопросов чата — провайдер может кешировать этот префикс.
instructions: ({ requestContext }) =>
buildInstructions(systemRules, requestContext.get("persona"), requestContext.get("chatTitle")),
model,
memory,
defaultOptions: { maxSteps: MAX_AGENT_STEPS, modelSettings: { maxOutputTokens: answerTokenLimit } },
// Результаты инструментов (сотни сообщений архива) не должны ездить в каждый запрос:
// первый фильтр убирает их из прошлых реплик, второй внутри ответа оставляет только самый свежий шаг.
inputProcessors: [new ToolCallFilter(), new ToolCallFilter({ filterAfterToolSteps: 1 })],
tools: {
readChatMessages: createReadChatMessagesTool(chatHistoryService),
listChatMembers: createListChatMembersTool(chatMemberService),
@@ -61,32 +75,36 @@ export const createAssistantService = ({
});
/**
* Ответ на вопрос в образе личности. Контекст диалога (прошлые вопросы и ответы) подкладываем сами,
* архив чата агент подгружает инструментами, когда нужно.
* Ответ на вопрос в образе личности. Прошлые вопросы и ответы чата подтягивает Mastra Memory,
* архив переписки агент читает инструментами, когда нужно.
*/
const answer = async (question: Question, persona: Persona): Promise<string> => {
const requestContext = new RequestContext<AssistantRequestContext>();
requestContext.set("chatId", question.chatId);
requestContext.set("chatTitle", question.chatTitle);
requestContext.set("persona", persona);
const history = dialogService.recent(question.chatId).map(toModelMessage);
const thread = chatThreadId(question.chatId);
const result = await agent.generate([...history, buildUserMessage(question)], {
instructions: buildInstructions(systemRules, question, persona),
const result = await agent.generate(buildUserText(question), {
context: imagesContext(question),
memory: { thread, resource: thread },
requestContext,
maxSteps: MAX_AGENT_STEPS,
});
const text = result.text.trim();
// Картинки в контекст не кладём — только текст вопроса; пустой ответ не запоминаем.
if (text) dialogService.remember(question.chatId, buildUserText(question), text);
return text;
return result.text.trim();
};
/** Сколько сообщений в контексте диалога чата: recall отдаёт `total`, не загружая весь тред. */
const dialogSize = async (chatId: number): Promise<number> =>
(await memory.recall({ threadId: chatThreadId(chatId), perPage: 1 })).total;
/** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */
const clearDialog = (chatId: number): Promise<void> => memory.deleteThread(chatThreadId(chatId));
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
return { answer, canSeeImages: imageInput };
return { answer, dialogSize, clearDialog, canSeeImages: imageInput };
};
export type AssistantService = ReturnType<typeof createAssistantService>;

View File

@@ -0,0 +1,30 @@
import { LibSQLStore } from "@mastra/libsql";
import { Memory } from "@mastra/memory";
import { ensureParentDir } from "../../libs/db.js";
interface AssistantMemoryOptions {
path: string;
/** Бюджет токенов на весь промпт: сверх него Mastra срезает самые старые реплики диалога. */
maxTokens: number;
}
/**
* Контекст диалога с ботом — Mastra Memory: тред на каждый чат, только история сообщений.
* Архив переписки чата живёт отдельно (SQLite бота) и читается инструментами.
*/
export const createAssistantMemory = ({ path, maxTokens }: AssistantMemoryOptions): Memory => {
ensureParentDir(path);
return new Memory({
storage: new LibSQLStore({ id: "boltun-memory", url: `file:${path}` }),
options: {
messageHistory: { maxTokens },
semanticRecall: false,
workingMemory: { enabled: false },
generateTitle: false,
},
});
};
/** Один тред на чат: вопросы разных админов и ответы бота — общий диалог (шаблон Mastra для групповых чатов). */
export const chatThreadId = (chatId: number): string => `chat:${chatId}`;

View File

@@ -15,8 +15,8 @@ export interface Question {
mentionedMembers: ChatMember[];
/** Упомянутые ники, которых бот в чате не встречал. */
unknownUsernames: string[];
/** Где в вопросе есть картинки — даже если модели их не отправляем. */
imageSources: ImageSource[];
/** Есть ли в вопросе картинки — даже если модели их не отправляем. */
hasImages: boolean;
/** Скачанные картинки для модели; пусто, если модель их не видит или скачать не вышло. */
images: ImageAttachment[];
}
@@ -27,45 +27,47 @@ const IMAGE_SOURCES: Record<ImageSource, string> = {
};
/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */
const imagesNote = ({ imageSources, images }: Question): string | undefined => {
if (imageSources.length === 0) return undefined;
const imagesNote = ({ hasImages, images }: Question): string | undefined => {
if (!hasImages) return undefined;
if (images.length === 0) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом.";
return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`;
};
const mentionsSection = (question: Question): string | undefined => {
const known = question.mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(question.mentionedMembers)}` : "";
const mentionsNote = ({ mentionedMembers, unknownUsernames }: Question): string | undefined => {
const known = mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(mentionedMembers)}` : "";
const unknown =
question.unknownUsernames.length > 0
? `Эти ники в чате не встречались: ${question.unknownUsernames.map((u) => `@${u}`).join(", ")}.`
: "";
unknownUsernames.length > 0 ? `Эти ники в чате не встречались: ${unknownUsernames.map((u) => `@${u}`).join(", ")}.` : "";
return [known, unknown].filter(Boolean).join("\n") || undefined;
};
/** Системные правила бота из `prompts/system.md` + личность + контекст конкретного вопроса. */
export const buildInstructions = (systemRules: string, question: Question, persona: Persona): string =>
[
`Твоя личность: ${persona.name}.\n${persona.prompt}`,
systemRules,
`Чат: «${question.chatTitle}». Вопрос задаёт ${question.askerName}.`,
mentionsSection(question),
]
.filter(Boolean)
.join("\n\n");
/**
* Системный промпт: одинаков для всех вопросов в чате (правила → личность → чат),
* поэтому провайдер может его кешировать. Всё, что про конкретный вопрос, — в реплике пользователя.
*/
export const buildInstructions = (systemRules: string, persona: Persona, chatTitle: string): string =>
[systemRules, `Твоя личность: ${persona.name}.\n${persona.prompt}`, `Чат: «${chatTitle}».`].join("\n\n");
export const buildPrompt = (question: Question): string => {
const escapeAttr = (value: string): string =>
value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/**
* Реплика пользователя в формате Mastra для общих тредов: `<turn author_name="…">`.
* Тег сохраняется в памяти, поэтому и в следующих вопросах модель видит, кто что спрашивал.
*/
export const buildUserText = (question: Question): string => {
const replied = question.repliedTo
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}`
: undefined;
const asked = question.text || "(текста нет — прислали только изображение, опиши и прокомментируй его)";
return [replied, imagesNote(question), replied ? `Вопрос: ${asked}` : asked].filter(Boolean).join("\n\n");
};
const body = [replied, mentionsNote(question), imagesNote(question), replied ? `Вопрос: ${asked}` : asked]
.filter(Boolean)
.join("\n\n");
/** Текст реплики пользователя — с именем, потому что в чате боту пишут разные админы. */
export const buildUserText = (question: Question): string => `${question.askerName}:\n${buildPrompt(question)}`;
return `<turn author_name="${escapeAttr(question.askerName)}">\n${body}\n</turn>`;
};

View File

@@ -1,6 +1,10 @@
import { z } from "zod";
/** Что каждый инструмент агента получает от бота: в каком чате задан вопрос. */
export const assistantRequestContextSchema = z.object({ chatId: z.number() });
/** Что агент и его инструменты получают от бота на каждый вопрос. */
export const assistantRequestContextSchema = z.object({
chatId: z.number(),
chatTitle: z.string(),
persona: z.object({ id: z.string(), name: z.string(), prompt: z.string() }),
});
export type AssistantRequestContext = z.infer<typeof assistantRequestContextSchema>;

View File

@@ -1,27 +0,0 @@
import type { DialogRepository } from "../repositories/dialog-repository.js";
import type { DialogTurn } from "../types.js";
interface DialogServiceDeps {
dialogRepository: DialogRepository;
/** Сколько последних пар «вопрос — ответ» модель видит в каждом запросе. */
exchangesLimit: number;
}
/** Контекст диалога с ботом — то, что модель «помнит» между вопросами. Архив чата живёт отдельно. */
export const createDialogService = ({ dialogRepository, exchangesLimit }: DialogServiceDeps) => {
const turnsLimit = exchangesLimit * 2;
const recent = (chatId: number): DialogTurn[] => dialogRepository.latest(chatId, turnsLimit);
const remember = (chatId: number, question: string, answer: string): void =>
dialogRepository.appendExchangeAndPrune(chatId, question, answer, turnsLimit);
const stats = (chatId: number) => ({ exchanges: Math.floor(dialogRepository.countByChat(chatId) / 2), exchangesLimit });
/** Начать диалог с чистого листа; архив сообщений чата не трогаем. Возвращает, сколько пар забыто. */
const clear = (chatId: number): number => Math.floor(dialogRepository.deleteByChat(chatId) / 2);
return { recent, remember, stats, clear };
};
export type DialogService = ReturnType<typeof createDialogService>;