Прошлые размышления модели не уходят в историю: фикс ошибок Gemini

- ProviderHistoryCompat с правилом strip-past-reasoning: из прошлых реплик вырезаются reasoning-части
  и подписи (reasoning_details), текущий ответ не трогается — Gemini нужны подписи внутри цикла
- Причина: подписи Gemini 3 привязаны к исходному контексту, а из истории вырезаются вызовы инструментов
  и в треде смешаны ответы разных моделей — провайдер отвечал ошибкой (finishReason other/error)
- Пустые реплики ассистента из упавших запросов тоже не отправляются
- onError: в лог пишется текст ошибки провайдера

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 22:27:48 +02:00
parent c3150d5bb7
commit fd2a219ae1
2 changed files with 44 additions and 2 deletions

View File

@@ -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),

View File

@@ -0,0 +1,38 @@
import type { CompatRule } from "@mastra/core/processors";
type Prompt = Parameters<NonNullable<CompatRule["applyToPrompt"]>>[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)];
},
};