import { Agent } from "@mastra/core/agent"; import { ProviderHistoryCompat, 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 { Persona } from "../../types.js"; import type { ChatHistoryService } from "../chat-history-service.js"; import type { ChatMemberService } from "../chat-member-service.js"; import { stripPastReasoning } from "./history-compat.js"; import { chatIdOfThread, 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 { 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; } /** Шаги агента: несколько на чтение архива и участников, последний — всегда текстовый ответ. */ const MAX_AGENT_STEPS = 6; /** * Картинки уходят через `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, }: AssistantServiceDeps) => { const systemRules = readPrompt("system.md"); const agent = new Agent({ id: "boltun", name: "Boltun", requestContextSchema: assistantRequestContextSchema, // Одинаковы для всех вопросов чата — провайдер может кешировать этот префикс. instructions: ({ requestContext }) => buildInstructions(systemRules, requestContext.get("persona"), requestContext.get("chatTitle")), model, memory, defaultOptions: { maxSteps: MAX_AGENT_STEPS, modelSettings: { maxOutputTokens: answerTokenLimit }, // На последнем шаге инструменты запрещены: иначе модель может потратить все шаги на чтение архива // и так и не ответить (в ответе бота было бы пусто). prepareStep: ({ stepNumber }) => (stepNumber >= MAX_AGENT_STEPS - 1 ? { toolChoice: "none" } : undefined), // Провайдеры (OpenRouter/Gemini) присылают ошибку частью ответа — без этого в логе только «finishReason: other». onError: ({ error }) => console.error("Ошибка провайдера модели:", error), }, // Результаты инструментов (сотни сообщений архива) нужны только в том ответе, где их запросили: // из прошлых реплик их убираем. Внутри ответа не трогаем — иначе модель забывает прочитанное и читает снова. // Прошлые размышления модели тоже убираем (см. history-compat.ts) — иначе Gemini ломается на старых подписях. inputProcessors: [new ToolCallFilter(), new ProviderHistoryCompat({ additionalRules: [stripPastReasoning] })], tools: { readChatMessages: createReadChatMessagesTool(chatHistoryService), listChatMembers: createListChatMembersTool(chatMemberService), readMemberMessages: createReadMemberMessagesTool({ chatHistoryService, chatMemberService }), }, }); /** * Ответ на вопрос в образе личности. Прошлые вопросы и ответы чата подтягивает Mastra Memory, * архив переписки агент читает инструментами, когда нужно. */ const answer = async (question: Question, persona: Persona): Promise => { const requestContext = new RequestContext(); requestContext.set("chatId", question.chatId); requestContext.set("chatTitle", question.chatTitle); requestContext.set("persona", persona); const thread = chatThreadId(question.chatId); const result = await agent.generate(buildUserText(question), { context: imagesContext(question), memory: { thread, resource: thread }, requestContext, }); const text = result.text.trim(); if (!text) console.warn(`Пустой ответ модели: finishReason=${result.finishReason}, шагов=${result.steps.length}`); return text; }; /** Сколько сообщений в контексте диалога чата: recall отдаёт `total`, не загружая весь тред. */ const dialogSize = async (chatId: number): Promise => (await memory.recall({ threadId: chatThreadId(chatId), perPage: 1 })).total; /** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */ const clearDialog = (chatId: number): Promise => memory.deleteThread(chatThreadId(chatId)); /** Чаты, где есть контекст диалога, и сколько в нём реплик — для выбора в личке с ботом. */ const dialogChats = async (): Promise<{ chatId: number; messages: number }[]> => { const { threads } = await memory.listThreads({ perPage: false }); const chatIds = threads.flatMap((thread) => { const chatId = chatIdOfThread(thread.id); return chatId === undefined ? [] : [chatId]; }); const chats = await Promise.all(chatIds.map(async (chatId) => ({ chatId, messages: await dialogSize(chatId) }))); return chats.filter((chat) => chat.messages > 0).sort((a, b) => b.messages - a.messages); }; /** Боту стоит скачивать фото, только если модель умеет их смотреть. */ return { answer, dialogSize, clearDialog, dialogChats, canSeeImages: imageInput }; }; export type AssistantService = ReturnType;