diff --git a/src/services/assistant/assistant-service.ts b/src/services/assistant/assistant-service.ts index 445c662..cc7b5ec 100644 --- a/src/services/assistant/assistant-service.ts +++ b/src/services/assistant/assistant-service.ts @@ -1,11 +1,12 @@ import { Agent } from "@mastra/core/agent"; -import { ToolCallFilter } from "@mastra/core/processors"; +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 { chatThreadId } from "./memory.js"; import { buildInstructions, buildUserText, type Question } from "./prompts.js"; import { createListChatMembersTool } from "./tools/list-chat-members-tool.js"; @@ -69,10 +70,13 @@ export const createAssistantService = ({ // На последнем шаге инструменты запрещены: иначе модель может потратить все шаги на чтение архива // и так и не ответить (в ответе бота было бы пусто). prepareStep: ({ stepNumber }) => (stepNumber >= MAX_AGENT_STEPS - 1 ? { toolChoice: "none" } : undefined), + // Провайдеры (OpenRouter/Gemini) присылают ошибку частью ответа — без этого в логе только «finishReason: other». + onError: ({ error }) => console.error("Ошибка провайдера модели:", error), }, // Результаты инструментов (сотни сообщений архива) нужны только в том ответе, где их запросили: // из прошлых реплик их убираем. Внутри ответа не трогаем — иначе модель забывает прочитанное и читает снова. - inputProcessors: [new ToolCallFilter()], + // Прошлые размышления модели тоже убираем (см. history-compat.ts) — иначе Gemini ломается на старых подписях. + inputProcessors: [new ToolCallFilter(), new ProviderHistoryCompat({ additionalRules: [stripPastReasoning] })], tools: { readChatMessages: createReadChatMessagesTool(chatHistoryService), listChatMembers: createListChatMembersTool(chatMemberService), diff --git a/src/services/assistant/history-compat.ts b/src/services/assistant/history-compat.ts new file mode 100644 index 0000000..ceacbae --- /dev/null +++ b/src/services/assistant/history-compat.ts @@ -0,0 +1,38 @@ +import type { CompatRule } from "@mastra/core/processors"; + +type Prompt = Parameters>[0]["prompt"]; + +type PromptMessage = Prompt[number]; + +/** Реплика ассистента из прошлого ответа без размышлений и их подписей (`providerOptions.openrouter.reasoning_details`). */ +const withoutReasoning = (message: PromptMessage): PromptMessage[] => { + if (message.role !== "assistant") return [message]; + + const { providerOptions: _messageOptions, ...rest } = message; + + const content = rest.content + .filter((part) => part.type !== "reasoning") + .map(({ providerOptions: _partOptions, ...part }) => part); + + return content.length > 0 ? [{ ...rest, content }] : []; +}; + +/** + * Правило для Mastra `ProviderHistoryCompat`: из прошлых ответов убираем размышления модели. + * + * Зачем: Gemini 3 подписывает размышления (`reasoning.encrypted`), и подпись привязана к исходному контексту. + * Мы вырезаем из истории вызовы инструментов (`ToolCallFilter`), а в одном треде бывают ответы разных моделей — + * старые подписи перестают сходиться, и провайдер отвечает ошибкой. Для ответа прошлые размышления не нужны, + * а токены на них тратятся. Текущий ответ (всё после последней реплики пользователя) не трогаем — там подписи + * нужны Gemini для вызовов инструментов внутри цикла. + */ +export const stripPastReasoning: CompatRule = { + name: "strip-past-reasoning", + applyToPrompt: ({ prompt }) => { + const currentTurnStart = prompt.findLastIndex((message) => message.role === "user"); + + if (currentTurnStart <= 0) return undefined; + + return [...prompt.slice(0, currentTurnStart).flatMap(withoutReasoning), ...prompt.slice(currentTurnStart)]; + }, +};