19 Commits

Author SHA1 Message Date
melod1n a143d512ab Remove pipeline todo checklist 2026-05-18 22:43:51 +03:00
melod1n d47e2288d6 Add pipeline integration tests 2026-05-18 22:09:44 +03:00
melod1n 7b2bc93bc1 Add stale RAG provider cleanup 2026-05-18 21:27:41 +03:00
melod1n 75253534d8 Add AI observability commands and metrics 2026-05-18 20:58:19 +03:00
melod1n 53e9798193 Merge reply-chain documents into AI requests 2026-05-18 20:43:35 +03:00
melod1n df39d89ea8 Localize pipeline fallback notifications 2026-05-18 20:31:04 +03:00
melod1n 1773b44edd Add fallback target logging and unified failures 2026-05-18 20:22:47 +03:00
melod1n 507b15aa5f Add centralized pipeline fallback notifier 2026-05-18 20:13:19 +03:00
melod1n d163d72a0b Split model call and tool loop helpers 2026-05-18 19:55:00 +03:00
melod1n 57985ce87b Persist tool loop summary artifact 2026-05-18 19:31:48 +03:00
melod1n 9a105caf0b Add shared tool loop stop policy 2026-05-18 19:24:39 +03:00
melod1n 13df2a1c23 Extract shared tool batch adapter helper 2026-05-18 19:18:22 +03:00
melod1n 9352ade19f Summarize tool loop output 2026-05-18 19:05:13 +03:00
melod1n 9d6cdb008b Normalize model call output 2026-05-18 18:59:09 +03:00
melod1n e520c412af Route tool ranker fallback through executor 2026-05-18 17:16:28 +03:00
melod1n 58f5a645fd Add tool ranker fallback policy tests 2026-05-18 16:23:32 +03:00
melod1n c3481dfcfe Inline tool rank audit into stage 2026-05-18 16:10:03 +03:00
melod1n b16c213afb Isolate tool rank stage pipeline 2026-05-18 16:03:47 +03:00
melod1n 8aede4b053 Add unified request pipeline stages 2026-05-18 15:45:39 +03:00
73 changed files with 3826 additions and 1090 deletions
-155
View File
@@ -1,155 +0,0 @@
# User Request Pipeline TODO
Этот чеклист описывает оставшиеся задачи по доведению pipeline до чистой архитектуры. Текущее состояние уже рабочее: есть `UserRequestPipeline`, stage audit, `ai_requests`, internal artifacts, unified size gate, RAG/STT/final/error/tool-result artifacts и response pipeline. Ниже перечислены задачи, которые ещё нужно сделать, чтобы убрать оставшиеся архитектурные компромиссы.
## 1. Нормализовать хранение attachments, artifacts и audit
- [x] Создать отдельную таблицу `attachments`.
- [x] Поля `attachments`: `id`, `messageChatId`, `messageId`, `direction`, `scope`, `kind`, `artifactKind`, `fileId`, `fileUniqueId`, `fileName`, `mimeType`, `cachePath`, `sizeBytes`, `sha256`, `metadata`, `createdAt`.
- [x] Создать отдельную таблицу `artifacts`.
- [x] Поля `artifacts`: `id`, `requestId`, `messageChatId`, `messageId`, `kind`, `stage`, `attachmentId`, `payload`, `createdAt`.
- [x] Создать отдельную таблицу `request_audit`.
- [x] Поля `request_audit`: `id`, `requestId`, `messageChatId`, `messageId`, `stage`, `status`, `startedAt`, `finishedAt`, `durationMs`, `provider`, `model`, `details`, `error`.
- [x] Оставить обратную совместимость с текущими JSON-полями `messages.attachments` и `messages.pipelineAudit`.
- [x] Добавить миграцию: переносить существующие `messages.attachments` в новую таблицу `attachments`.
- [x] Добавить миграцию: переносить существующие `messages.pipelineAudit` в новую таблицу `request_audit`.
- [x] Обновить backup/export/import, чтобы новые таблицы попадали в JSON и SQL dump.
- [x] Добавить DAO/store слой: `AttachmentStore`, `ArtifactStore`, `RequestAuditStore`.
- [x] Перевести новые записи на нормализованные таблицы.
- [x] Оставить чтение legacy JSON только как fallback.
## 2. Сделать единый ArtifactStore API
- [x] Ввести `ArtifactStore.put(...)`.
- [x] Ввести `ArtifactStore.getByRequestId(requestId)`.
- [x] Ввести `ArtifactStore.getByMessage(chatId, messageId)`.
- [x] Ввести `ArtifactStore.getLatestRagForReplyChain(chatId, messageId)`.
- [x] Ввести `ArtifactStore.getTranscriptForMessage(chatId, messageId)`.
- [x] Перевести `rag-artifact-store.ts` на `ArtifactStore`.
- [x] Перевести `transcript-artifact-store.ts` на `ArtifactStore`.
- [x] Перевести `final-response-artifact-store.ts` на `ArtifactStore`.
- [x] Перевести `tool-result-artifact-store.ts` на `ArtifactStore`.
- [x] Оставить физические JSON-файлы как storage backend для payload, но регистрировать их в БД.
- [x] Добавить единый size gate для artifact payload до записи файла.
- [x] Добавить cleanup policy для временных/устаревших artifact файлов.
## 3. Расширить RAG artifact content
- [x] Расширить общий тип `RagArtifact`.
- [x] Для Ollama сохранять extracted documents.
- [x] Для Ollama сохранять selected chunks.
- [x] Для Ollama сохранять chunk scores.
- [x] Для Ollama сохранять skipped documents и причины пропуска.
- [x] Для Ollama сохранять embedding model, `topK`, `chunkSize`, `chunkOverlap`, `maxContextChars`.
- [x] Для OpenAI сохранять `vectorStoreIds`.
- [x] Для OpenAI сохранять source file mapping: local attachment -> uploaded/vector store file.
- [x] Для Mistral сохранять `libraryId`.
- [x] Для Mistral сохранять uploaded document ids.
- [x] Для Mistral сохранять source file mapping: local attachment -> Mistral document id.
- [ ] Добавить единый `providerState` schema для всех providers.
- [ ] Добавить tests на сериализацию `RagArtifact`.
- [ ] Добавить tests на то, что internal RAG artifacts не попадают обратно в user document context.
## 4. Вынести provider runners в adapter layer
- [ ] Ввести интерфейс `AiProviderAdapter`.
- [ ] Методы adapter-а: `mapMessages`, `rankTools`, `callModel`, `extractTextDelta`, `extractToolCalls`, `appendToolResults`, `finalize`.
- [ ] Реализовать `OpenAiProviderAdapter`.
- [ ] Реализовать `MistralProviderAdapter`.
- [ ] Реализовать `OllamaProviderAdapter`.
- [ ] Перенести provider-specific tool schema mapping внутрь adapter-ов.
- [ ] Перенести provider-specific streaming parsing внутрь adapter-ов.
- [ ] Перенести provider-specific tool result append внутрь adapter-ов.
- [ ] Упростить `runOpenAi`, `runMistral`, `runOllama` или заменить их adapter-driven runner-ом.
- [ ] Оставить compatibility wrappers для текущих imports.
- [ ] Добавить tests на adapter contract без реальных API.
## 5. Сделать tool-ranker полноценным pipeline stage
- [ ] Вынести вызов `ToolRanker.selectTools(...)` из provider runners.
- [ ] Добавить stage `tool_rank`, который работает через provider adapter.
- [ ] Добавить stage `filter_tools`, который фильтрует provider-specific tools по результату ranker.
- [ ] Хранить `ToolRankDecision` в `UserRequestPipelineState.toolRankDecisions`.
- [ ] Сохранять `ToolRankDecision` в `request_audit.details`.
- [ ] Убрать дублирующий ручной `tool-rank-audit.ts`, если stage полностью заменит его.
- [ ] Сохранить status UX: `🧩 Выбираю подходящие инструменты...`.
- [ ] Гарантировать `clearStatus()` после ranker success/failure.
- [ ] Добавить fallback через `PipelineFallbackExecutor`: main model, all tools, no tools.
- [ ] Добавить tests на fallback ranker policy.
## 6. Сделать model_call и tool_loop физически отдельными stages
- [ ] Stage `model_call` должен делать только один model request.
- [ ] Stage `model_call` должен возвращать normalized model output.
- [ ] Stage `tool_loop` должен решать, есть ли tool calls.
- [ ] Stage `tool_loop` должен выполнять tools через общий `executeToolBatch`.
- [ ] Stage `tool_loop` должен добавлять tool results в provider adapter.
- [ ] Stage `tool_loop` должен управлять max rounds.
- [ ] Stage `tool_loop` должен сохранять tool result artifacts.
- [ ] Stage `tool_loop` должен уметь завершаться без tools как `skipped`.
- [ ] Убрать tool loop из `runOpenAi`.
- [ ] Убрать tool loop из `runMistral`.
- [ ] Убрать tool loop из `runOllama`.
- [ ] Добавить tests на multi-round fake adapter.
## 7. Довести fallback notifications до централизованного UX
- [ ] Добавить `PipelineFallbackNotifier`.
- [ ] Для `notify_user` отправлять пользователю понятное сообщение.
- [ ] Для `continue_without_stage` писать короткий debug/audit без user notification.
- [ ] Для `use_alternate_target` логировать исходный и alternate target.
- [ ] Для `fail_request` завершать request через единый error path.
- [ ] Добавить локализацию fallback messages.
- [ ] Добавить отдельные тексты для RAG failure, STT failure, TTS failure, tool failure.
- [ ] Не спамить пользователя несколькими fallback notifications за один request.
- [ ] Сохранять fallback notification в `request_audit.details`.
## 8. Улучшить поведение reply-chain с документами
- [ ] Явно описать стратегию merge: current user attachments + reply-chain user attachments.
- [ ] Исключать `scope: internal_artifact` всегда.
- [ ] Исключать `scope: bot_output`, если это не user-provided file.
- [ ] Если пользователь отвечает новым документом на ответ бота с предыдущим документом, использовать оба документа.
- [ ] Если пользователь отвечает текстом на ответ бота, использовать документы из reply-chain.
- [ ] Если пользователь явно говорит "этот файл", приоритет отдавать новому вложению.
- [ ] Если несколько документов, добавлять их имена в prompt/RAG context.
- [ ] Добавить tests на follow-up с новым документом.
- [ ] Добавить tests на follow-up без нового документа.
- [ ] Добавить tests на то, что RAG internal JSON не становится пользовательским документом.
## 9. Интеграционные tests без реальных Telegram/AI API
- [ ] Создать fake `TelegramStreamMessage`.
- [ ] Создать fake provider adapter.
- [ ] Создать fake message store или in-memory DB fixture.
- [ ] Test: oversized input attachment rejected before download.
- [ ] Test: document input creates RAG artifact.
- [ ] Test: voice input creates transcript artifact.
- [ ] Test: final answer creates final_text artifact.
- [ ] Test: thrown error creates error artifact.
- [ ] Test: tool call creates tool_result artifact.
- [ ] Test: generated file creates generated_file artifact.
- [ ] Test: TTS requested creates tts_audio artifact.
- [ ] Test: fallback `continue_without_stage` continues request.
- [ ] Test: fallback `fail_request` stops request.
## 10. Operational cleanup and observability
- [ ] Add retention policy for `data/cache/internal-artifacts`.
- [ ] Add retention policy for stale RAG vector/library provider state.
- [ ] Add command or admin view for recent `ai_requests`.
- [ ] Add command or admin view for request audit by message id.
- [ ] Add command to inspect artifacts for a message.
- [ ] Add log correlation by `requestId` across AI logs, tool logs and DB audit.
- [ ] Add metrics counters: requests, failures, fallbacks, tool calls, RAG runs, TTS runs.
- [ ] Add startup migration logs for `ai_requests`, `attachments`, `artifacts`, `request_audit`.
## Suggested order
- [x] 1. Normalize DB tables: `attachments`, `artifacts`, `request_audit`.
- [ ] 2. Build `ArtifactStore` and migrate current artifact helpers to it.
- [ ] 3. Add fake integration tests for reply-chain documents and artifacts.
- [ ] 4. Introduce provider adapter interface.
- [ ] 5. Move `tool_rank` into pipeline stage.
- [ ] 6. Split `model_call` and `tool_loop` physically.
- [ ] 7. Add centralized fallback user notifications.
+10
View File
@@ -8,6 +8,13 @@
}, },
"providerChoice.default": "Default", "providerChoice.default": "Default",
"errorText": "⚠️ An error occurred.", "errorText": "⚠️ An error occurred.",
"pipelineFallback.generic": "⚠️ I had to skip part of the request, but I can continue.",
"pipelineFallback.notifyUser": "⚠️ I hit a problem and need to continue with a fallback.",
"pipelineFallback.failRequest": "⚠️ I could not finish this request.",
"pipelineFallback.documentRag": "⚠️ Document retrieval failed, so I will answer without RAG.",
"pipelineFallback.speechToText": "⚠️ Speech transcription failed, so I will continue without the audio transcript.",
"pipelineFallback.textToSpeech": "⚠️ Text-to-speech failed, so I will continue without audio output.",
"pipelineFallback.toolLoop": "⚠️ Tool execution failed, so I will continue without that tool.",
"waitThinkText": "⏳ Let me think...", "waitThinkText": "⏳ Let me think...",
"analyzingPictureText": "🔍 Analyzing the image...", "analyzingPictureText": "🔍 Analyzing the image...",
"analyzingPicturesText": "🔍 Analyzing the images...", "analyzingPicturesText": "🔍 Analyzing the images...",
@@ -176,6 +183,9 @@
"getWhenPluralUnitText": "{unit}s", "getWhenPluralUnitText": "{unit}s",
"getWhenDurationText": "{prefix}{value} {unit}", "getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": { "commandDescriptions": {
"aiAudit": "Inspect AI request audit and artifacts",
"aiMetrics": "Show AI observability counters",
"aiRequests": "Show recent AI requests",
"ae": "evaluation", "ae": "evaluation",
"adminsAdd": "Add user to admins", "adminsAdd": "Add user to admins",
"adminsRemove": "Remove user from admins", "adminsRemove": "Remove user from admins",
+10
View File
@@ -8,6 +8,13 @@
}, },
"providerChoice.default": "По умолчанию", "providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.", "errorText": "⚠️ Произошла ошибка.",
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
"waitThinkText": "⏳ Дайте-ка подумать...", "waitThinkText": "⏳ Дайте-ка подумать...",
"analyzingPictureText": "🔍 Анализирую изображение...", "analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...", "analyzingPicturesText": "🔍 Анализирую изображения...",
@@ -202,6 +209,9 @@
"getWhenPluralUnitText": "{unit}", "getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}", "getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": { "commandDescriptions": {
"aiRequests": "Показать последние AI-запросы",
"aiAudit": "Показать аудит AI-запроса и артефакты",
"aiMetrics": "Показать счётчики AI-обсервабилити",
"ae": "вычисление", "ae": "вычисление",
"adminsAdd": "Добавить пользователя в администраторы", "adminsAdd": "Добавить пользователя в администраторы",
"adminsRemove": "Удалить пользователя из администраторов", "adminsRemove": "Удалить пользователя из администраторов",
+10
View File
@@ -8,6 +8,13 @@
}, },
"providerChoice.default": "За замовчуванням", "providerChoice.default": "За замовчуванням",
"errorText": "⚠️ Сталася помилка.", "errorText": "⚠️ Сталася помилка.",
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
"waitThinkText": "⏳ Дайте-но подумати...", "waitThinkText": "⏳ Дайте-но подумати...",
"analyzingPictureText": "🔍 Аналізую зображення...", "analyzingPictureText": "🔍 Аналізую зображення...",
"analyzingPicturesText": "🔍 Аналізую зображення...", "analyzingPicturesText": "🔍 Аналізую зображення...",
@@ -201,6 +208,9 @@
"getWhenPluralUnitText": "{unit}", "getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}", "getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": { "commandDescriptions": {
"aiRequests": "Показати останні AI-запити",
"aiAudit": "Показати аудит AI-запиту та артефакти",
"aiMetrics": "Показати лічильники AI-спостережуваності",
"help": "Показати список команд", "help": "Показати список команд",
"settings": "Налаштування користувача", "settings": "Налаштування користувача",
"start": "Запустити бота", "start": "Запустити бота",
+3 -3
View File
@@ -1,9 +1,9 @@
import {Mistral} from "@mistralai/mistralai"; import {Mistral} from "@mistralai/mistralai";
import {Ollama} from "ollama"; import {Ollama} from "ollama";
import {OpenAI} from "openai"; import {OpenAI} from "openai";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment.js";
import {AiModelCapabilities} from "../model/ai-model-capabilities"; import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider.js";
export type AiCapabilityName = keyof AiModelCapabilities; export type AiCapabilityName = keyof AiModelCapabilities;
export type AiRuntimePurpose = AiCapabilityName | "chat"; export type AiRuntimePurpose = AiCapabilityName | "chat";
+13
View File
@@ -32,6 +32,7 @@ export type ConversationTurn = {
content: string; content: string;
deletedByBotAt?: number | null; deletedByBotAt?: number | null;
attachments: ConversationAttachment[]; attachments: ConversationAttachment[];
documentNames?: string[];
}; };
export type ConversationSnapshot = { export type ConversationSnapshot = {
@@ -123,6 +124,13 @@ function attachmentSummary(attachments: ConversationAttachment[]): string {
return ["[attachments]:", ...lines].join("\n"); return ["[attachments]:", ...lines].join("\n");
} }
function namesSummary(kind: string, names: string[]): string {
const filtered = names.map(name => name.trim()).filter(Boolean);
if (!filtered.length) return "";
return [`[${kind}]:`, ...filtered.map(name => `- ${name}`)].join("\n");
}
function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> { function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> {
if (bot) return new Set<AttachmentKind>(); if (bot) return new Set<AttachmentKind>();
@@ -160,6 +168,10 @@ function renderContentText(
parts.push("[message_state]: deleted_by_bot"); parts.push("[message_state]: deleted_by_bot");
} }
if (turn.documentNames?.length) {
parts.push(namesSummary("documents", turn.documentNames));
}
if (unsupported.length) { if (unsupported.length) {
parts.push(attachmentSummary(unsupported)); parts.push(attachmentSummary(unsupported));
} }
@@ -291,6 +303,7 @@ export async function buildConversationSnapshot(
content: part.content, content: part.content,
deletedByBotAt: part.deletedByBotAt, deletedByBotAt: part.deletedByBotAt,
attachments: buildConversationAttachments(part), attachments: buildConversationAttachments(part),
documentNames: part.documentNames,
})); }));
const imageCount = turns.reduce((sum, turn) => { const imageCount = turns.reduce((sum, turn) => {
+5
View File
@@ -0,0 +1,5 @@
export async function runSingleModelRequest<T>(params: {
execute: () => Promise<T>;
}): Promise<T> {
return await params.execute();
}
+112
View File
@@ -0,0 +1,112 @@
import type {ToolCallData} from "./unified-ai-runner.shared.js";
import type {ResponseStreamEvent} from "openai/resources/responses/responses";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeToolCallId(value: unknown, fallback: string): string {
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
}
function normalizeToolArguments(value: unknown): string {
if (typeof value === "string") return value;
return JSON.stringify(value ?? {});
}
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
return output
.filter(item => isRecord(item) && item.type === "function_call" && (typeof item.call_id === "string" || typeof item.name === "string"))
.map((item, index) => ({
id: normalizeToolCallId(item.call_id, `openai_${index}`),
name: typeof item.name === "string" ? item.name : "",
argumentsText: normalizeToolArguments(item.arguments),
}))
.filter(call => call.name.length > 0);
}
export function extractOpenAiTextDelta(input: unknown): string {
const event = input as ResponseStreamEvent | undefined;
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
}
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
const event = input as ResponseStreamEvent | undefined;
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
return extractOpenAiToolCalls({
output: [{
type: "function_call",
call_id: event.item.call_id ?? event.item.id,
name: event.item.name,
arguments: event.item.arguments,
}],
});
}
return [];
}
export function extractMistralToolCalls(calls: unknown): ToolCallData[] {
const normalized = Array.isArray(calls)
? calls
: isRecord(calls) && (Array.isArray(calls.toolCalls) || Array.isArray(calls.tool_calls))
? (calls.toolCalls ?? calls.tool_calls)
: [];
if (!Array.isArray(normalized)) return [];
return normalized
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `mistral_${index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractMistralTextDelta(input: unknown): string {
const delta = isRecord(input) ? input : {};
const content = delta.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
return "";
}
export function extractOllamaToolCalls(calls: unknown): ToolCallData[] {
const normalized = Array.isArray(calls)
? calls
: isRecord(calls) && Array.isArray(calls.tool_calls)
? calls.tool_calls
: [];
if (!Array.isArray(normalized)) return [];
return normalized
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `ollama_${index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOllamaTextDelta(input: unknown): string {
const chunk = isRecord(input) ? input.message : undefined;
return isRecord(chunk) && typeof chunk.content === "string" ? chunk.content : "";
}
+196
View File
@@ -0,0 +1,196 @@
import {AiProvider} from "../model/ai-provider.js";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {RuntimeConfigSnapshot, ToolCallData} from "./unified-ai-runner.shared.js";
import {getMistralTools, getOllamaTools, getOpenAIResponsesTools, getOpenAICodeInterpreterTool} from "./tool-mappers.js";
import type {MistralChatMessage as MistralMessageType} from "./mistral-chat-message.js";
import type {OpenAIChatMessage as OpenAiMessageType} from "./openai-chat-message.js";
import type {Message as OllamaMessage} from "ollama";
import {
extractMistralTextDelta,
extractMistralToolCalls,
extractOllamaTextDelta,
extractOllamaToolCalls,
extractOpenAiTextDelta,
extractOpenAiStreamingToolCalls,
extractOpenAiToolCalls,
} from "./provider-adapter-contract.js";
export type ProviderRankToolOptions = {
forCreator?: boolean;
vectorStoreIds?: string[];
};
export interface AiProviderAdapter {
readonly provider: AiProvider;
mapMessages(messages: readonly unknown[]): unknown[];
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[];
callModel<T>(request: unknown, execute: () => Promise<T>): Promise<T>;
extractTextDelta(input: unknown): string;
extractToolCalls(input: unknown): ToolCallData[];
extractStreamingToolCalls(input: unknown): ToolCallData[];
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void;
finalize(): Promise<void>;
}
function appendOllamaToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
content: results[index] ?? "",
tool_name: call.name,
});
}
}
class OpenAiProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.OPENAI;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as OpenAiMessageType[];
}
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
const tools: BoundaryValue[] = [
...getOpenAIResponsesTools(options?.forCreator) as BoundaryValue[],
getOpenAICodeInterpreterTool() as BoundaryValue,
{
type: "image_generation",
model: config.openAiImageTarget.model,
size: "auto",
moderation: "low",
output_format: "png",
partial_images: 3,
},
{type: "web_search"},
];
if (options?.vectorStoreIds?.length) {
tools.unshift({
type: "file_search",
vector_store_ids: options.vectorStoreIds,
});
}
return tools;
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractOpenAiTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractOpenAiToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return extractOpenAiStreamingToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
type: "function_call_output",
call_id: call.id,
output: results[index] ?? "",
});
}
}
async finalize(): Promise<void> {
return;
}
}
class MistralProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.MISTRAL;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as MistralMessageType[];
}
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
return getMistralTools(options?.forCreator) as BoundaryValue[];
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractMistralTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractMistralToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return this.extractToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: results[index] ?? "",
});
}
}
async finalize(): Promise<void> {
return;
}
}
class OllamaProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.OLLAMA;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as OllamaMessage[];
}
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
return getOllamaTools(options?.forCreator) as BoundaryValue[];
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractOllamaTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractOllamaToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return this.extractToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
appendOllamaToolResults(messages, calls, results);
}
async finalize(): Promise<void> {
return;
}
}
export function getProviderAdapter(provider: AiProvider): AiProviderAdapter {
switch (provider) {
case AiProvider.OPENAI:
return new OpenAiProviderAdapter();
case AiProvider.MISTRAL:
return new MistralProviderAdapter();
case AiProvider.OLLAMA:
return new OllamaProviderAdapter();
}
}
+77
View File
@@ -0,0 +1,77 @@
import type {AiProvider} from "../model/ai-provider";
export type RagArtifactSource = {
fileId: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
sha256?: string;
uploadedFileId?: string;
documentId?: string;
};
export type RagArtifactPayload = {
artifactKind: "rag";
provider: AiProvider;
createdAt: string;
sources: RagArtifactSource[];
providerState:
| {
provider: AiProvider.OPENAI;
vectorStoreIds: string[];
uploadedFileIds: string[];
}
| {
provider: AiProvider.MISTRAL;
libraryId?: string;
documentCount: number;
}
| {
provider: AiProvider.OLLAMA;
prepared: boolean;
embeddingModel?: string;
topK?: number;
chunkSize?: number;
chunkOverlap?: number;
maxContextChars?: number;
extractedDocuments: Array<{
documentIndex: number;
fileName: string;
textChars: number;
}>;
selectedChunks: Array<{
sourceId: string;
documentIndex: number;
documentName: string;
chunkIndex: number;
chunkCount: number;
textChars: number;
score?: number;
}>;
skippedDocuments: Array<{
documentIndex: number;
fileName: string;
reason: string;
}>;
query: string;
minScore: number;
maxArchiveFiles: number;
maxArchiveBytes: number;
maxArchiveDepth: number;
};
};
export function buildRagArtifactPayload(params: {
provider: AiProvider;
createdAt?: string;
sources: RagArtifactSource[];
providerState: RagArtifactPayload["providerState"];
}): RagArtifactPayload {
return {
artifactKind: "rag",
provider: params.provider,
createdAt: params.createdAt ?? new Date().toISOString(),
sources: params.sources,
providerState: params.providerState,
};
}
+15 -68
View File
@@ -4,75 +4,39 @@ import type {AiDownloadedFile} from "./telegram-attachments";
import type {PreparedDocumentRag} from "./document-rag-pipeline"; import type {PreparedDocumentRag} from "./document-rag-pipeline";
import type {OllamaRagArtifactDetails} from "./ollama-rag"; import type {OllamaRagArtifactDetails} from "./ollama-rag";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store"; import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
import {buildRagArtifactPayload, type RagArtifactPayload} from "./rag-artifact-payload";
type RagArtifactPayload = {
artifactKind: "rag";
provider: AiProvider;
createdAt: string;
sources: Array<{
fileId: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
sha256?: string;
uploadedFileId?: string;
documentId?: string;
}>;
providerState: {
vectorStoreIds?: string[];
libraryId?: string;
documentCount?: number;
prepared?: boolean;
uploadedFileIds?: string[];
embeddingModel?: string;
topK?: number;
chunkSize?: number;
chunkOverlap?: number;
maxContextChars?: number;
extractedDocuments?: Array<{
documentIndex: number;
fileName: string;
textChars: number;
}>;
selectedChunks?: Array<{
sourceId: string;
documentIndex: number;
documentName: string;
chunkIndex: number;
chunkCount: number;
textChars: number;
score?: number;
}>;
skippedDocuments?: Array<{
documentIndex: number;
fileName: string;
reason: string;
}>;
query?: string;
ollama?: OllamaRagArtifactDetails["providerState"];
};
};
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] { function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
switch (prepared.provider) { switch (prepared.provider) {
case AiProvider.OPENAI: case AiProvider.OPENAI:
return { return {
provider: AiProvider.OPENAI,
vectorStoreIds: prepared.vectorStoreIds, vectorStoreIds: prepared.vectorStoreIds,
uploadedFileIds: prepared.uploadedFileIds, uploadedFileIds: prepared.uploadedFileIds,
}; };
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return { return {
provider: AiProvider.MISTRAL,
libraryId: prepared.libraryId, libraryId: prepared.libraryId,
documentCount: prepared.documents.length, documentCount: prepared.documents.length,
}; };
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return { return {
provider: AiProvider.OLLAMA,
prepared: prepared.prepared, prepared: prepared.prepared,
embeddingModel: details?.embeddingModel, embeddingModel: details?.embeddingModel,
topK: details?.topK, topK: details?.topK,
chunkSize: details?.chunkSize, chunkSize: details?.chunkSize,
chunkOverlap: details?.chunkOverlap, chunkOverlap: details?.chunkOverlap,
maxContextChars: details?.maxContextChars, maxContextChars: details?.maxContextChars,
extractedDocuments: details?.artifact?.extractedDocuments ?? [],
selectedChunks: details?.artifact?.selectedChunks ?? [],
skippedDocuments: details?.artifact?.skippedDocuments ?? [],
query: details?.artifact?.query ?? "",
minScore: details?.artifact?.providerState?.minScore ?? 0,
maxArchiveFiles: details?.artifact?.providerState?.maxArchiveFiles ?? 0,
maxArchiveBytes: details?.artifact?.providerState?.maxArchiveBytes ?? 0,
maxArchiveDepth: details?.artifact?.providerState?.maxArchiveDepth ?? 0,
}; };
} }
} }
@@ -117,22 +81,11 @@ export async function persistRagArtifactAttachment(params: {
if (!sources.length) return Promise.resolve(undefined); if (!sources.length) return Promise.resolve(undefined);
const payload: RagArtifactPayload = { const payload = buildRagArtifactPayload({
artifactKind: "rag",
provider: params.provider, provider: params.provider,
createdAt: new Date().toISOString(),
sources, sources,
providerState: { providerState: providerState(params.prepared, params.details),
...providerState(params.prepared, params.details), });
...(params.details?.artifact ? {
extractedDocuments: params.details.artifact.extractedDocuments,
selectedChunks: params.details.artifact.selectedChunks,
skippedDocuments: params.details.artifact.skippedDocuments,
query: params.details.artifact.query,
ollama: params.details.artifact.providerState,
} : {}),
},
};
return await persistInternalJsonArtifactAttachment({ return await persistInternalJsonArtifactAttachment({
artifactKind: "rag", artifactKind: "rag",
fileNamePrefix: "rag", fileNamePrefix: "rag",
@@ -140,14 +93,8 @@ export async function persistRagArtifactAttachment(params: {
messageId: params.messageId, messageId: params.messageId,
payload, payload,
metadata: { metadata: {
provider: params.provider,
sourceFileNames: sources.map(source => source.fileName), sourceFileNames: sources.map(source => source.fileName),
...payload.providerState, ...payload.providerState,
embeddingModel: params.details?.embeddingModel,
topK: params.details?.topK,
chunkSize: params.details?.chunkSize,
chunkOverlap: params.details?.chunkOverlap,
maxContextChars: params.details?.maxContextChars,
}, },
}); });
} }
+75
View File
@@ -0,0 +1,75 @@
import type {RagArtifactPayload} from "./rag-artifact-payload";
export type ArtifactLike = {
id: string;
createdAt: string;
payload: string;
};
export type RagCleanupTarget = {
artifactId: string;
createdAt: string;
provider: RagArtifactPayload["providerState"]["provider"];
vectorStoreIds?: string[];
uploadedFileIds?: string[];
libraryId?: string;
};
export type RagCleanupPlan = {
cutoffAt: string;
targets: RagCleanupTarget[];
};
function parseRagArtifactPayload(payload: string): RagArtifactPayload | null {
try {
const parsed = JSON.parse(payload) as Partial<RagArtifactPayload>;
if (!parsed || parsed.artifactKind !== "rag" || !parsed.providerState) return null;
return parsed as RagArtifactPayload;
} catch {
return null;
}
}
export function buildStaleRagCleanupPlan(
artifacts: ArtifactLike[],
retentionDays = 14,
now = new Date(),
): RagCleanupPlan {
const cutoffAt = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const targets: RagCleanupTarget[] = [];
for (const artifact of artifacts) {
if (artifact.createdAt > cutoffAt) continue;
const payload = parseRagArtifactPayload(artifact.payload);
if (!payload || payload.artifactKind !== "rag") continue;
switch (payload.providerState.provider) {
case "OPENAI":
if (payload.providerState.vectorStoreIds.length || payload.providerState.uploadedFileIds.length) {
targets.push({
artifactId: artifact.id,
createdAt: artifact.createdAt,
provider: payload.providerState.provider,
vectorStoreIds: [...payload.providerState.vectorStoreIds],
uploadedFileIds: [...payload.providerState.uploadedFileIds],
});
}
break;
case "MISTRAL":
if (payload.providerState.libraryId) {
targets.push({
artifactId: artifact.id,
createdAt: artifact.createdAt,
provider: payload.providerState.provider,
libraryId: payload.providerState.libraryId,
});
}
break;
case "OLLAMA":
break;
}
}
return {cutoffAt, targets};
}
+117
View File
@@ -0,0 +1,117 @@
import {appLogger} from "../logging/logger.js";
import {DatabaseManager} from "../db/database-manager.js";
import {AiProvider} from "../model/ai-provider.js";
import {createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
import {deleteMistralLibrary} from "./unified-ai-runner.shared.js";
import {buildStaleRagCleanupPlan} from "./rag-retention-planner.js";
const logger = appLogger.child("rag-retention");
function unique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
async function cleanupOpenAiRag(vectorStoreIds: string[], uploadedFileIds: string[]): Promise<void> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "documents");
const client = createOpenAiClient(target);
for (const vectorStoreId of unique(vectorStoreIds)) {
const startedAt = Date.now();
logger.info("openai.vector_store.cleanup.start", {vectorStoreId});
try {
await client.vectorStores.delete(vectorStoreId);
logger.success("openai.vector_store.cleanup.done", {vectorStoreId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("openai.vector_store.cleanup.failed", {
vectorStoreId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
for (const fileId of unique(uploadedFileIds)) {
const startedAt = Date.now();
logger.info("openai.file.cleanup.start", {fileId});
try {
await client.files.delete(fileId);
logger.success("openai.file.cleanup.done", {fileId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("openai.file.cleanup.failed", {
fileId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
}
async function cleanupMistralRag(libraryId: string): Promise<void> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "documents");
const startedAt = Date.now();
logger.info("mistral.library.cleanup.start", {libraryId});
try {
await deleteMistralLibrary(libraryId, target);
logger.success("mistral.library.cleanup.done", {libraryId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("mistral.library.cleanup.failed", {
libraryId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
export async function cleanupStaleRagProviderState(retentionDays = 14): Promise<{
scannedArtifacts: number;
cleanupTargets: number;
openaiTargets: number;
mistralTargets: number;
}> {
const startedAt = Date.now();
const artifacts = await DatabaseManager.getAllArtifacts().catch(() => []);
const plan = buildStaleRagCleanupPlan(artifacts, retentionDays);
logger.info("cleanup.start", {
retentionDays,
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
cutoffAt: plan.cutoffAt,
});
let openaiTargets = 0;
let mistralTargets = 0;
for (const target of plan.targets) {
switch (target.provider) {
case "OPENAI":
openaiTargets += 1;
await cleanupOpenAiRag(target.vectorStoreIds ?? [], target.uploadedFileIds ?? []);
break;
case "MISTRAL":
mistralTargets += 1;
if (target.libraryId) {
await cleanupMistralRag(target.libraryId);
}
break;
case "OLLAMA":
break;
}
}
logger.success("cleanup.done", {
retentionDays,
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
openaiTargets,
mistralTargets,
duration: `${Date.now() - startedAt}ms`,
});
return {
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
openaiTargets,
mistralTargets,
};
}
+39
View File
@@ -0,0 +1,39 @@
import type {AiDownloadedFile} from "./telegram-attachments.js";
function downloadKey(download: AiDownloadedFile): string {
return [
download.kind,
download.fileId,
download.sha256 ?? "",
download.fileName,
].join(":");
}
export function mergeReplyChainDownloads(
currentDownloads: readonly AiDownloadedFile[],
replyChainDownloads: readonly AiDownloadedFile[],
): AiDownloadedFile[] {
const result: AiDownloadedFile[] = [];
const seen = new Set<string>();
for (const download of [...currentDownloads, ...replyChainDownloads]) {
const key = downloadKey(download);
if (seen.has(key)) continue;
seen.add(key);
result.push(download);
}
return result;
}
export function shouldPreferCurrentDownloads(text: string, currentDownloads: readonly AiDownloadedFile[]): boolean {
if (!currentDownloads.length) return false;
const normalized = text.trim().toLowerCase();
if (!normalized) return false;
return normalized.includes("this file")
|| normalized.includes("this document")
|| normalized.includes("этот файл")
|| normalized.includes("этот документ");
}
+19
View File
@@ -0,0 +1,19 @@
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
export type NormalizedModelOutput = {
text: string;
toolExecutions: TelegramToolExecutionRecord[];
outputAttachments: TelegramOutputAttachmentRecord[];
};
export function summarizeModelOutput(params: {
text: string;
toolExecutions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): NormalizedModelOutput {
return {
text: params.text.trim(),
toolExecutions: [...params.toolExecutions],
outputAttachments: [...params.outputAttachments],
};
}
+17
View File
@@ -5,6 +5,7 @@ import {Environment} from "../common/environment";
import {MessageStore} from "../common/message-store"; import {MessageStore} from "../common/message-store";
import {createQueuedFunction} from "../util/async-lock"; import {createQueuedFunction} from "../util/async-lock";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {appLogger} from "../logging/logger";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment"; import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
@@ -13,11 +14,13 @@ import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings"; import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline"; import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
import {recordToolCall} from "../common/ai-observability.js";
const TELEGRAM_LIMIT = 4096; const TELEGRAM_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024; const TELEGRAM_CAPTION_LIMIT = 1024;
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024; const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
const EDIT_INTERVAL_MS = 4500; const EDIT_INTERVAL_MS = 4500;
const logger = appLogger.child("telegram-stream-message");
export type TelegramArtifactFile = { export type TelegramArtifactFile = {
kind: "image" | "file"; kind: "image" | "file";
@@ -238,6 +241,13 @@ export class TelegramStreamMessage {
recordToolExecution(record: TelegramToolExecutionRecord): void { recordToolExecution(record: TelegramToolExecutionRecord): void {
this.toolExecutions.push(record); this.toolExecutions.push(record);
recordToolCall();
logger.debug("tool.execution.recorded", {
requestId: this.cancelRequestId,
toolName: record.toolName,
callId: record.callId,
resultChars: record.resultChars,
});
} }
getToolExecutions(): TelegramToolExecutionRecord[] { getToolExecutions(): TelegramToolExecutionRecord[] {
@@ -246,6 +256,13 @@ export class TelegramStreamMessage {
recordOutputAttachment(record: TelegramOutputAttachmentRecord): void { recordOutputAttachment(record: TelegramOutputAttachmentRecord): void {
this.outputAttachments.push(record); this.outputAttachments.push(record);
logger.debug("output_attachment.recorded", {
requestId: this.cancelRequestId,
artifactKind: record.artifactKind,
fileName: record.fileName,
sizeBytes: record.sizeBytes,
messageId: record.messageId,
});
} }
getOutputAttachments(): TelegramOutputAttachmentRecord[] { getOutputAttachments(): TelegramOutputAttachmentRecord[] {
+28
View File
@@ -0,0 +1,28 @@
import type {AiProviderAdapter} from "./provider-adapters.js";
import {executeToolBatch, type ToolCallData, type ToolExecutionMemory} from "./unified-ai-runner.shared.js";
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
import type {ToolRuntimeContext} from "./tools/runtime.js";
export async function executeToolBatchWithAdapter(params: {
userId: number | undefined | null;
toolCalls: ToolCallData[];
streamMessage: TelegramStreamMessage;
toolContext: ToolRuntimeContext;
toolMemory: ToolExecutionMemory;
adapter: AiProviderAdapter;
appendTargets?: unknown[][];
}): Promise<string[]> {
const results = await executeToolBatch(
params.userId,
params.toolCalls,
params.streamMessage,
params.toolContext,
params.toolMemory,
);
for (const target of params.appendTargets ?? []) {
params.adapter.appendToolResults(target, params.toolCalls, results);
}
return results;
}
+39
View File
@@ -0,0 +1,39 @@
import type {StoredAttachment} from "../model/stored-attachment";
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistToolLoopSummaryArtifactAttachment(params: {
chatId: number;
messageId: number;
text: string;
executions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): Promise<StoredAttachment | undefined> {
if (!params.executions.length) return undefined;
return await persistInternalJsonArtifactAttachment({
artifactKind: "tool_result",
fileNamePrefix: "tool-loop-summary",
chatId: params.chatId,
messageId: params.messageId,
payload: {
stage: "tool_loop",
text: params.text.trim(),
executions: params.executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
argumentsText: execution.argumentsText,
resultChars: execution.resultChars,
startedAt: execution.startedAt,
finishedAt: execution.finishedAt,
})),
outputAttachments: params.outputAttachments,
},
metadata: {
stage: "tool_loop",
toolExecutions: params.executions.length,
outputAttachments: params.outputAttachments.length,
textChars: params.text.trim().length,
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export type ToolLoopStopReason = "no_tool_calls" | "max_rounds_reached";
export type ToolLoopContinuation = {
continue: boolean;
reason?: ToolLoopStopReason;
remainingRounds: number;
};
export function decideToolLoopContinuation(params: {
round: number;
maxRounds: number;
toolCalls: readonly ToolCallData[];
}): ToolLoopContinuation {
const remainingRounds = Math.max(params.maxRounds - params.round - 1, 0);
if (!params.toolCalls.length) {
return {
continue: false,
reason: "no_tool_calls",
remainingRounds,
};
}
if (remainingRounds === 0) {
return {
continue: false,
reason: "max_rounds_reached",
remainingRounds,
};
}
return {
continue: true,
remainingRounds,
};
}
+22
View File
@@ -0,0 +1,22 @@
export type ToolLoopRoundOutcome = {
shouldContinue: boolean;
maxRoundsReached?: boolean;
};
export async function runToolLoopRounds(params: {
maxRounds: number;
onRound: (round: number) => Promise<ToolLoopRoundOutcome>;
onMaxRoundsReached?: (round: number) => Promise<void> | void;
}): Promise<void> {
for (let round = 0; round < params.maxRounds; round++) {
const outcome = await params.onRound(round);
if (!outcome.shouldContinue) {
if (outcome.maxRoundsReached) {
await params.onMaxRoundsReached?.(round);
}
return;
}
}
await params.onMaxRoundsReached?.(params.maxRounds - 1);
}
+56
View File
@@ -0,0 +1,56 @@
import type {PipelineArtifact} from "./user-request-pipeline/types.js";
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
import {summarizeModelOutput} from "./response-model-output.js";
export type ToolLoopSummary = {
status: "succeeded" | "skipped";
fallbackAction?: "continue_without_stage";
details: {
modelOutput: ReturnType<typeof summarizeModelOutput>;
count: number;
tools: Array<{
toolName: string;
callId: string;
resultChars: number;
}>;
};
artifacts?: PipelineArtifact[];
};
export function summarizeToolLoop(params: {
text: string;
executions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): ToolLoopSummary {
const count = params.executions.length;
const tools = params.executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
resultChars: execution.resultChars,
}));
return {
status: count ? "succeeded" : "skipped",
fallbackAction: count ? undefined : "continue_without_stage",
details: {
modelOutput: summarizeModelOutput({
text: params.text,
toolExecutions: params.executions,
outputAttachments: params.outputAttachments,
}),
count,
tools,
},
artifacts: count ? [{
kind: "tool_result",
stage: "tool_loop",
createdAt: new Date().toISOString(),
toolName: "summary",
callId: "tool_loop_summary",
resultText: JSON.stringify({
count,
tools,
}),
}] : undefined,
};
}
+4 -4
View File
@@ -1,8 +1,8 @@
import {AiTool} from "./tool-types"; import {AiTool} from "./tool-types";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider.js";
import {getTools} from "./tools/registry"; import {getTools} from "./tools/registry.js";
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search"; import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator"; import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
export type AiProviderName = "ollama" | "openai" | "mistral"; export type AiProviderName = "ollama" | "openai" | "mistral";
-32
View File
@@ -1,32 +0,0 @@
import {AiProvider} from "../model/ai-provider";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {PipelineAuditEvent} from "./user-request-pipeline";
import {logError} from "../util/utils";
export async function storeToolRankAudit(params: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
selectedTools?: string[];
error?: unknown;
}): Promise<void> {
const event: PipelineAuditEvent = {
stage: "tool_rank",
status: params.error ? "failed" : "succeeded",
startedAt: params.startedAtIso,
finishedAt: new Date().toISOString(),
durationMs: Date.now() - params.startedAt,
provider: params.provider,
model: params.model,
details: {
round: params.round,
selectedTools: params.selectedTools ?? [],
},
error: params.error instanceof Error ? params.error.message : params.error ? String(params.error) : undefined,
};
await params.streamMessage.storePipelineAudit([event]).catch(logError);
}
+146
View File
@@ -0,0 +1,146 @@
import {AiProvider} from "../model/ai-provider.js";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
import type {RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
import {allToolSchemaNames, toolSchemaNames} from "./tool-schema-utils.js";
import type {ToolRanker} from "./unified-ai-runner.tool-ranker.js";
import type {PipelineAuditEvent} from "./user-request-pipeline/types.js";
function latestUserText(messages: readonly { role?: string; content?: unknown }[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => typeof part === "object" && part !== null && "text" in part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: "")
.filter(Boolean)
.join("\n");
}
}
return "";
}
export async function runToolRankStage(params: {
provider: AiProvider;
model: string;
round: number;
config: RuntimeConfigSnapshot;
availableTools: readonly BoundaryValue[];
messages: readonly { role?: string; content?: unknown }[];
streamMessage: TelegramStreamMessage;
signal: AbortSignal;
toolRanker?: ToolRanker;
storeAudit?: (params: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
availableTools: string[];
selectedTools?: string[];
usedRanker?: boolean;
error?: unknown;
}) => Promise<void>;
}): Promise<{
filteredTools: BoundaryValue[];
selectedToolNames: string[];
usedRanker: boolean;
}> {
const toolRanker = params.toolRanker ?? new (await import("./unified-ai-runner.tool-ranker.js")).ToolRanker(params.config);
const startedAt = Date.now();
const startedAtIso = new Date().toISOString();
const filterSelectedTools = (selectedToolNames: readonly string[]): BoundaryValue[] => {
const selected = new Set(selectedToolNames);
return params.availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
};
const storeAudit = params.storeAudit ?? (async (auditParams: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
availableTools: string[];
selectedTools?: string[];
usedRanker?: boolean;
error?: unknown;
}) => {
const event: PipelineAuditEvent = {
stage: "tool_rank",
status: auditParams.error ? "failed" : "succeeded",
startedAt: auditParams.startedAtIso,
finishedAt: new Date().toISOString(),
durationMs: Date.now() - auditParams.startedAt,
provider: auditParams.provider,
model: auditParams.model,
details: {
round: auditParams.round,
availableTools: auditParams.availableTools,
selectedTools: auditParams.selectedTools ?? [],
usedRanker: auditParams.usedRanker ?? false,
toolRankDecision: {
provider: auditParams.provider,
round: auditParams.round,
availableTools: auditParams.availableTools,
selectedTools: auditParams.selectedTools ?? [],
usedRanker: auditParams.usedRanker ?? false,
},
},
error: auditParams.error instanceof Error ? auditParams.error.message : auditParams.error ? String(auditParams.error) : undefined,
};
await auditParams.streamMessage.storePipelineAudit([event]);
});
params.streamMessage.setStatus("🧩 Выбираю подходящие инструменты...");
await params.streamMessage.flush();
try {
const selection = await toolRanker.selectTools({
provider: params.provider,
userQuery: latestUserText(params.messages),
availableTools: params.availableTools,
round: params.round,
signal: params.signal,
});
params.streamMessage.clearStatus();
await params.streamMessage.flush();
await storeAudit({
streamMessage: params.streamMessage,
provider: params.provider,
model: params.model,
round: params.round,
startedAt,
startedAtIso,
availableTools: allToolSchemaNames(params.availableTools),
selectedTools: selection.toolNames,
usedRanker: selection.usedRanker,
});
return {
filteredTools: filterSelectedTools(selection.toolNames),
selectedToolNames: selection.toolNames,
usedRanker: selection.usedRanker,
};
} catch (error) {
params.streamMessage.clearStatus();
await params.streamMessage.flush();
await storeAudit({
streamMessage: params.streamMessage,
provider: params.provider,
model: params.model,
round: params.round,
startedAt,
startedAtIso,
availableTools: allToolSchemaNames(params.availableTools),
error,
});
throw error;
}
}
+56
View File
@@ -0,0 +1,56 @@
import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {decidePipelineFallback, type PipelineFallbackDecision} from "./user-request-pipeline/fallback-executor.js";
export type ToolRankerFallbackSelection = {
toolNames: string[];
usedRanker: boolean;
};
export type ToolRankerFallbackDecision = PipelineFallbackDecision & ToolRankerFallbackSelection;
function fallbackActionForPolicy(policy: ToolRankerFallbackPolicy) {
return policy === ToolRankerFallbackPolicy.MAIN_MODEL
? "use_alternate_target"
: "continue_without_stage";
}
export function decideToolRankerFallback(params: {
fallbackPolicy: ToolRankerFallbackPolicy;
availableToolNames: readonly string[];
reason: "unavailable" | "failed";
}): ToolRankerFallbackDecision {
const action = fallbackActionForPolicy(params.fallbackPolicy);
const decision = decidePipelineFallback({
stage: "tool_rank",
reason: params.reason,
policies: [{
stage: "tool_rank",
onUnavailable: action,
onFailed: action,
}],
});
return {
...decision,
toolNames: params.fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS
? []
: [...params.availableToolNames],
usedRanker: false,
};
}
export function resolveToolRankerFallbackSelection(params: {
fallbackPolicy: ToolRankerFallbackPolicy;
availableToolNames: readonly string[];
}): ToolRankerFallbackSelection {
const decision = decideToolRankerFallback({
fallbackPolicy: params.fallbackPolicy,
availableToolNames: params.availableToolNames,
reason: "failed",
});
return {
toolNames: decision.toolNames,
usedRanker: decision.usedRanker,
};
}
+5 -5
View File
@@ -1,12 +1,12 @@
import type {BoundaryValue} from "../common/boundary-types"; import type {BoundaryValue} from "../common/boundary-types.js";
import type {AiRuntimeTarget} from "./ai-runtime-target"; import type {AiRuntimeTarget} from "./ai-runtime-target.js";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider.js";
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared"; import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared.js";
import { import {
buildToolRankerSystemPrompt, buildToolRankerSystemPrompt,
getToolRankerAvailableToolInfos, getToolRankerAvailableToolInfos,
type ToolRankerToolInfo, type ToolRankerToolInfo,
} from "./tool-ranker-metadata"; } from "./tool-ranker-metadata.js";
export type ToolRankerMessage = { export type ToolRankerMessage = {
role?: string; role?: string;
+33
View File
@@ -0,0 +1,33 @@
import type {BoundaryValue} from "../common/boundary-types.js";
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asOptionalString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function toolSchemaName(tool: BoundaryValue): string | undefined {
if (!isRecord(tool)) return undefined;
const fn = isRecord(tool.function) ? tool.function : undefined;
const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
return asOptionalString(directName);
}
export function toolSchemaNames(tool: BoundaryValue): string[] {
if (!isRecord(tool)) return [];
if (Array.isArray(tool.functionDeclarations)) {
return tool.functionDeclarations
.map(declaration => isRecord(declaration) ? asOptionalString(declaration.name) : undefined)
.filter((name): name is string => !!name);
}
const name = toolSchemaName(tool);
return name ? [name] : [];
}
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] {
return [...new Set(tools.flatMap(toolSchemaNames))];
}
+4 -4
View File
@@ -2,11 +2,11 @@ import {spawn} from "node:child_process";
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises"; import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types.js";
import {Environment} from "../../common/environment"; import {Environment} from "../../common/environment.js";
import {toolsLogger} from "./tool-logger"; import {toolsLogger} from "./tool-logger.js";
import {randomUUID} from "node:crypto"; import {randomUUID} from "node:crypto";
import {AiJsonObject} from "../tool-types"; import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("python-interpreter"); const logger = toolsLogger.child("python-interpreter");
+1 -1
View File
@@ -1,3 +1,3 @@
import {appLogger} from "../../logging/logger"; import {appLogger} from "../../logging/logger.js";
export const toolsLogger = appLogger.child("ai-tools"); export const toolsLogger = appLogger.child("ai-tools");
+65 -11
View File
@@ -2,7 +2,10 @@ import {AiProvider} from "../model/ai-provider";
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings"; import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline"; import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import type {AiDownloadedFile} from "./telegram-attachments"; import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
import {mergeReplyChainDownloads, shouldPreferCurrentDownloads} from "./reply-chain-downloads";
import {attachmentsToDownloadedFiles, type AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message"; import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {ChatMessage} from "./chat-messages-types"; import type {ChatMessage} from "./chat-messages-types";
import type {OpenAIChatMessage} from "./openai-chat-message"; import type {OpenAIChatMessage} from "./openai-chat-message";
@@ -12,6 +15,7 @@ import {prepareDocumentRag} from "./document-rag-pipeline";
import {persistRagArtifactAttachment} from "./rag-artifact-store"; import {persistRagArtifactAttachment} from "./rag-artifact-store";
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store"; import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
import type {ToolRuntimeContext} from "./tools/runtime"; import type {ToolRuntimeContext} from "./tools/runtime";
import {recordPipelineFallback, recordRagRun} from "../common/ai-observability.js";
import { import {
appendTranscriptToChatMessages, appendTranscriptToChatMessages,
collectTextMessages, collectTextMessages,
@@ -21,6 +25,7 @@ import {
stripAudioFromRunnerMessages, stripAudioFromRunnerMessages,
toolRuntimeContextFromDownloads, toolRuntimeContextFromDownloads,
transcribeAudioIfNeeded, transcribeAudioIfNeeded,
collectStoredReplyChainAttachments,
UnifiedRunOptions, UnifiedRunOptions,
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {aiLog} from "../logging/ai-logger"; import {aiLog} from "../logging/ai-logger";
@@ -60,7 +65,7 @@ function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnaps
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState { function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
return { return {
requestId: `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`, requestId: options.requestId ?? `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id, replyToMessageId: options.msg.reply_to_message?.message_id,
@@ -90,6 +95,12 @@ export async function prepareUnifiedAiRequestPipeline(params: {
controller: AbortController; controller: AbortController;
}): Promise<PreparedUnifiedAiRequest> { }): Promise<PreparedUnifiedAiRequest> {
const {options, config, downloads, streamMessage, controller} = params; const {options, config, downloads, streamMessage, controller} = params;
const replyChainDownloads = shouldPreferCurrentDownloads(options.text, downloads)
? downloads
: mergeReplyChainDownloads(
downloads,
attachmentsToDownloadedFiles(await collectStoredReplyChainAttachments(options.msg)),
);
const prepared: MutablePreparedContext = { const prepared: MutablePreparedContext = {
chatMessages: [], chatMessages: [],
imageCount: 0, imageCount: 0,
@@ -109,7 +120,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
details: { details: {
phase: "ai_request_prepare", phase: "ai_request_prepare",
provider: options.provider, provider: options.provider,
downloads: downloads.map(download => ({ downloads: replyChainDownloads.map(download => ({
kind: download.kind, kind: download.kind,
fileName: download.fileName, fileName: download.fileName,
mimeType: download.mimeType, mimeType: download.mimeType,
@@ -126,15 +137,15 @@ export async function prepareUnifiedAiRequestPipeline(params: {
options.msg, options.msg,
options.text, options.text,
options.provider, options.provider,
downloads, replyChainDownloads,
config, config,
runtimeTargetFor(options, config), runtimeTargetFor(options, config),
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE, options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
); );
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages; prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
prepared.imageCount = collected.imageCount; prepared.imageCount = collected.imageCount;
prepared.firstRoundStatus = initialStatus(downloads, prepared.imageCount); prepared.firstRoundStatus = initialStatus(replyChainDownloads, prepared.imageCount);
prepared.toolContext = toolRuntimeContextFromDownloads(downloads); prepared.toolContext = toolRuntimeContextFromDownloads(replyChainDownloads);
return { return {
stage: "collect_conversation_context", stage: "collect_conversation_context",
@@ -169,11 +180,11 @@ export async function prepareUnifiedAiRequestPipeline(params: {
prepared.transcript = await transcribeAudioIfNeeded( prepared.transcript = await transcribeAudioIfNeeded(
options.provider, options.provider,
options.msg.from?.id, options.msg.from?.id,
downloads, replyChainDownloads,
streamMessage, streamMessage,
controller.signal, controller.signal,
).catch(error => { ).catch(error => {
if (downloads.some(isTranscribableAudioDownload)) throw error; if (replyChainDownloads.some(isTranscribableAudioDownload)) throw error;
return ""; return "";
}); });
@@ -188,7 +199,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
const transcriptArtifact = await persistTranscriptArtifactAttachment({ const transcriptArtifact = await persistTranscriptArtifactAttachment({
provider: options.provider, provider: options.provider,
transcript, transcript,
downloads, downloads: replyChainDownloads,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
}); });
@@ -233,7 +244,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
prepared.preparedDocumentRag = await prepareDocumentRag( prepared.preparedDocumentRag = await prepareDocumentRag(
options.provider, options.provider,
downloads, replyChainDownloads,
prepared.chatMessages, prepared.chatMessages,
streamMessage, streamMessage,
config, config,
@@ -244,7 +255,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
const ragArtifact = await persistRagArtifactAttachment({ const ragArtifact = await persistRagArtifactAttachment({
provider: options.provider, provider: options.provider,
prepared: prepared.preparedDocumentRag, prepared: prepared.preparedDocumentRag,
downloads, downloads: replyChainDownloads,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
@@ -264,6 +275,10 @@ export async function prepareUnifiedAiRequestPipeline(params: {
await streamMessage.storeInternalAttachment(ragArtifact); await streamMessage.storeInternalAttachment(ragArtifact);
} }
if (prepared.preparedDocumentRag) {
recordRagRun();
}
return { return {
stage: "document_rag", stage: "document_rag",
status: prepared.preparedDocumentRag ? "succeeded" : "skipped", status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
@@ -290,6 +305,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
]; ];
const state = createAiRequestPipelineState(options); const state = createAiRequestPipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const pipeline = new UserRequestPipeline({ const pipeline = new UserRequestPipeline({
stages, stages,
stageNames: [ stageNames: [
@@ -301,6 +317,44 @@ export async function prepareUnifiedAiRequestPipeline(params: {
"document_rag", "document_rag",
"audit_finish", "audit_finish",
], ],
onFallback: async decision => {
recordPipelineFallback(decision.action);
if (decision.action === "use_alternate_target") {
aiLog("warn", "request.fallback.use_alternate_target", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
...buildToolRankFallbackTargetDetails(options.provider, config),
});
}
if (decision.action === "fail_request") {
aiLog("error", "request.fallback.fail_request", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
});
}
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: nowIso(),
finishedAt: nowIso(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
...(decision.action === "use_alternate_target"
? buildToolRankFallbackTargetDetails(options.provider, config)
: {}),
},
});
},
}); });
await pipeline.run(state, controller.signal); await pipeline.run(state, controller.signal);
await streamMessage.storePipelineAudit(state.audit); await streamMessage.storePipelineAudit(state.audit);
+139 -24
View File
@@ -2,6 +2,7 @@ import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {ifTrue, logError} from "../util/utils"; import {ifTrue, logError} from "../util/utils";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline"; import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {getProviderAdapter} from "./provider-adapters";
import type {AiDownloadedFile} from "./telegram-attachments"; import type {AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message"; import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline"; import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
@@ -9,15 +10,22 @@ import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message"; import type {MistralChatMessage} from "./mistral-chat-message";
import type {ChatMessage} from "./chat-messages-types"; import type {ChatMessage} from "./chat-messages-types";
import { import {
allToolSchemaNames,
providerName, providerName,
RuntimeConfigSnapshot, RuntimeConfigSnapshot,
snapshotModel, snapshotModel,
TELEGRAM_LIMIT, TELEGRAM_LIMIT,
UnifiedRunOptions, UnifiedRunOptions,
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {runToolRankStage} from "./tool-rank-stage";
import {runOpenAi} from "./unified-ai-runner.openai"; import {runOpenAi} from "./unified-ai-runner.openai";
import {runOllama} from "./unified-ai-runner.ollama"; import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral"; import {runMistral} from "./unified-ai-runner.mistral";
import {summarizeModelOutput} from "./response-model-output";
import {summarizeToolLoop} from "./tool-loop-summary";
import {persistToolLoopSummaryArtifactAttachment} from "./tool-loop-artifact-store";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
import { import {
resolveTextToSpeechProviderForUser, resolveTextToSpeechProviderForUser,
sendSynthesizedSpeech, sendSynthesizedSpeech,
@@ -26,6 +34,7 @@ import {
} from "./text-to-speech"; } from "./text-to-speech";
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store"; import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
import {aiLog} from "../logging/ai-logger"; import {aiLog} from "../logging/ai-logger";
import {recordPipelineFallback, recordTtsRun} from "../common/ai-observability.js";
function nowIso(): string { function nowIso(): string {
return new Date().toISOString(); return new Date().toISOString();
@@ -33,7 +42,7 @@ function nowIso(): string {
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState { function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
return { return {
requestId: `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`, requestId: options.requestId ?? `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id, replyToMessageId: options.msg.reply_to_message?.message_id,
@@ -159,6 +168,10 @@ export async function runUnifiedAiResponsePipeline(params: {
}): Promise<void> { }): Promise<void> {
const {options, config, downloads, prepared, streamMessage, controller} = params; const {options, config, downloads, prepared, streamMessage, controller} = params;
const state = createResponsePipelineState(options); const state = createResponsePipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const adapter = getProviderAdapter(options.provider);
let selectedToolNames: string[] = [];
let filteredTools: unknown[] = [];
const stages: UserRequestPipelineStage[] = [ const stages: UserRequestPipelineStage[] = [
{ {
@@ -177,6 +190,62 @@ export async function runUnifiedAiResponsePipeline(params: {
}; };
}, },
}, },
{
name: "tool_rank",
async run() {
const availableTools = adapter.rankTools(config, {
forCreator: options.msg.from?.id === Environment.CREATOR_ID,
vectorStoreIds: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
? prepared.preparedDocumentRag.vectorStoreIds
: [],
});
const rankResult = await runToolRankStage({
provider: options.provider,
model: snapshotModel(options.provider, config),
round: state.toolRankDecisions.length,
config,
availableTools,
messages: prepared.chatMessages,
streamMessage,
signal: controller.signal,
});
selectedToolNames = rankResult.selectedToolNames;
filteredTools = rankResult.filteredTools;
state.toolRankDecisions.push({
provider: options.provider,
round: state.toolRankDecisions.length,
availableTools: allToolSchemaNames(availableTools),
selectedTools: selectedToolNames,
usedRanker: rankResult.usedRanker,
});
return {
stage: "tool_rank",
status: "succeeded",
details: {
selectedTools: selectedToolNames,
usedRanker: rankResult.usedRanker,
availableTools: allToolSchemaNames(availableTools),
toolRankDecision: state.toolRankDecisions.at(-1),
},
};
},
},
{
name: "filter_tools",
async run() {
return {
stage: "filter_tools",
status: "succeeded",
details: {
selectedTools: selectedToolNames,
filteredToolCount: filteredTools.length,
},
};
},
},
{ {
name: "model_call", name: "model_call",
async run() { async run() {
@@ -192,6 +261,13 @@ export async function runUnifiedAiResponsePipeline(params: {
return { return {
stage: "model_call", stage: "model_call",
status: "succeeded", status: "succeeded",
details: {
modelOutput: summarizeModelOutput({
text: streamMessage.getText(),
toolExecutions: streamMessage.getToolExecutions(),
outputAttachments: streamMessage.getOutputAttachments(),
}),
},
}; };
}, },
}, },
@@ -199,33 +275,31 @@ export async function runUnifiedAiResponsePipeline(params: {
name: "tool_loop", name: "tool_loop",
async run() { async run() {
const executions = streamMessage.getToolExecutions(); const executions = streamMessage.getToolExecutions();
const outputAttachments = streamMessage.getOutputAttachments();
const summary = summarizeToolLoop({
text: streamMessage.getText(),
executions,
outputAttachments,
});
const persisted = await persistToolLoopSummaryArtifactAttachment({
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
text: streamMessage.getText(),
executions,
outputAttachments,
});
if (persisted) {
await streamMessage.storeInternalAttachment(persisted);
}
return { return {
stage: "tool_loop", stage: "tool_loop",
status: executions.length ? "succeeded" : "skipped", ...summary,
fallbackAction: executions.length ? undefined : "continue_without_stage",
details: { details: {
count: executions.length, ...summary.details,
tools: executions.map(execution => ({ persistedSummaryArtifact: !!persisted,
toolName: execution.toolName,
callId: execution.callId,
resultChars: execution.resultChars,
})),
}, },
artifacts: executions.length ? [{
kind: "tool_result",
stage: "tool_loop",
createdAt: new Date().toISOString(),
toolName: "summary",
callId: "tool_loop_summary",
resultText: JSON.stringify({
count: executions.length,
tools: executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
resultChars: execution.resultChars,
})),
}),
}] : undefined,
}; };
}, },
}, },
@@ -284,6 +358,7 @@ export async function runUnifiedAiResponsePipeline(params: {
name: "text_to_speech", name: "text_to_speech",
async run() { async run() {
const status = await synthesizeResponseIfRequested({options, config, streamMessage}); const status = await synthesizeResponseIfRequested({options, config, streamMessage});
recordTtsRun(status);
return { return {
stage: "text_to_speech", stage: "text_to_speech",
status, status,
@@ -312,6 +387,8 @@ export async function runUnifiedAiResponsePipeline(params: {
stages, stages,
stageNames: [ stageNames: [
"audit_start", "audit_start",
"tool_rank",
"filter_tools",
"model_call", "model_call",
"tool_loop", "tool_loop",
"output_size_gate", "output_size_gate",
@@ -320,6 +397,44 @@ export async function runUnifiedAiResponsePipeline(params: {
"persist_output_artifacts", "persist_output_artifacts",
"audit_finish", "audit_finish",
], ],
onFallback: async decision => {
recordPipelineFallback(decision.action);
if (decision.action === "use_alternate_target") {
aiLog("warn", "response.fallback.use_alternate_target", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
...buildToolRankFallbackTargetDetails(options.provider, config),
});
}
if (decision.action === "fail_request") {
aiLog("error", "response.fallback.fail_request", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
});
}
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
...(decision.action === "use_alternate_target"
? buildToolRankFallbackTargetDetails(options.provider, config)
: {}),
},
});
},
}); });
try { try {
+145 -128
View File
@@ -1,30 +1,27 @@
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {getMistralTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target"; import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {ToolRanker} from "./unified-ai-runner.tool-ranker"; import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import { import {
contentFromMistralDelta,
executeToolBatch,
MAX_TOOL_ROUNDS, MAX_TOOL_ROUNDS,
MistralDeltaLike,
MistralDocumentReference, MistralDocumentReference,
mistralToolCalls,
normalizeMistralToolCalls,
roundStatus, roundStatus,
RuntimeConfigSnapshot, RuntimeConfigSnapshot,
StreamingToolCallAccumulator, StreamingToolCallAccumulator,
ToolCallData, ToolCallData,
ToolExecutionMemory ToolExecutionMemory
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
import {storeToolRankAudit} from "./tool-rank-audit";
export async function runMistral( export async function runMistral(
msg: Message, msg: Message,
@@ -39,8 +36,9 @@ export async function runMistral(
): Promise<void> { ): Promise<void> {
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
const mistralAi = createMistralClient(config.mistralChatTarget); const mistralAi = createMistralClient(config.mistralChatTarget);
const toolRanker = new ToolRanker(config); const adapter = getProviderAdapter(AiProvider.MISTRAL);
const availableTools = getMistralTools(msg.from?.id === Environment.CREATOR_ID); const availableTools = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID});
const requestMessages = adapter.mapMessages([...messages]) as unknown as MistralChatMessage[];
aiLog("info", "mistral.run.start", { aiLog("info", "mistral.run.start", {
stream, stream,
target: aiLogProviderTarget(config.mistralChatTarget), target: aiLogProviderTarget(config.mistralChatTarget),
@@ -50,142 +48,161 @@ export async function runMistral(
}); });
const toolMemory: ToolExecutionMemory = new Map(); const toolMemory: ToolExecutionMemory = new Map();
try {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now();
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { const rankResult = await runToolRankStage({
const roundStartedAt = Date.now();
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
streamMessage.setStatus(Environment.getSelectingToolsText());
await streamMessage.flush();
const toolRankStartedAt = Date.now();
const toolRankStartedAtIso = new Date().toISOString();
const rankerSelection = await toolRanker.selectTools({
provider: AiProvider.MISTRAL, provider: AiProvider.MISTRAL,
userQuery: latestUserTextFromMessages(messages), model: config.mistralChatTarget.model,
availableTools,
round, round,
config,
availableTools,
messages,
streamMessage,
signal, signal,
})
.catch(async error => {
streamMessage.clearStatus();
await streamMessage.flush();
await storeToolRankAudit({
streamMessage,
provider: AiProvider.MISTRAL,
model: config.mistralChatTarget.model,
round,
startedAt: toolRankStartedAt,
startedAtIso: toolRankStartedAtIso,
error,
});
throw error;
}); });
streamMessage.clearStatus(); const filteredTools = rankResult.filteredTools;
await streamMessage.flush(); const requestTools = filteredTools.length ? filteredTools : undefined;
await storeToolRankAudit({
streamMessage,
provider: AiProvider.MISTRAL,
model: config.mistralChatTarget.model,
round,
startedAt: toolRankStartedAt,
startedAtIso: toolRankStartedAtIso,
selectedTools: rankerSelection.toolNames,
});
const filteredTools = filterRankedTools(availableTools, rankerSelection.toolNames);
const requestTools = filteredTools.length ? filteredTools : undefined;
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? ""); streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush(); await streamMessage.flush();
if (!stream) {
const request = {
model: config.mistralChatTarget.model,
messages: requestMessages,
tools: requestTools,
documents: documents
} as Parameters<typeof mistralAi.chat.complete>[0];
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => mistralAi.chat.complete(request, {signal})),
});
const message = response.choices?.[0]?.message;
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
streamMessage.append(text);
const calls = adapter.extractToolCalls(message);
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return {shouldContinue: false};
messages.push({
role: "assistant",
content: text,
toolCalls: calls.map(call => ({
id: call.id,
function: {name: call.name, arguments: call.argumentsText},
})),
});
requestMessages.push({
role: "assistant",
content: text,
toolCalls: calls.map(call => ({
id: call.id,
function: {name: call.name, arguments: call.argumentsText},
})),
});
await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolMemory,
adapter,
appendTargets: [messages, requestMessages],
});
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "mistral.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
return {shouldContinue: true};
}
if (!stream) {
const request = { const request = {
model: config.mistralChatTarget.model, model: config.mistralChatTarget.model,
messages, messages: requestMessages,
tools: requestTools, tools: requestTools,
documents: documents documents: documents
} as Parameters<typeof mistralAi.chat.complete>[0]; } as Parameters<typeof mistralAi.chat.stream>[0];
const response = await mistralAi.chat.complete(request, {signal}); const streamResponse = await runSingleModelRequest({
const message = response.choices?.[0]?.message; execute: () => adapter.callModel(request, () => mistralAi.chat.stream(request, {signal})),
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? ""); });
streamMessage.append(text); aiLog("debug", "mistral.stream.open", {round});
const calls = normalizeMistralToolCalls(mistralToolCalls(message)); let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
for await (const event of streamResponse) {
if (signal.aborted) throw new Error("Aborted");
const choice = event.data?.choices?.[0];
const delta = choice?.delta;
const mistralDelta = delta;
streamMessage.append(adapter.extractTextDelta(mistralDelta));
const rawDeltaCalls = adapter.extractStreamingToolCalls(mistralDelta);
if (rawDeltaCalls.length) {
calls = toolCallAccumulator.add(rawDeltaCalls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round, round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length, textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall), calls: calls.map(aiLogToolCall),
}); });
if (!calls.length) return; if (!calls.length) return {shouldContinue: false};
const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({ messages.push({
role: "assistant", role: "assistant",
content: text, content: roundText,
toolCalls: calls.map(call => ({ toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
id: call.id,
function: {name: call.name, arguments: call.argumentsText},
})),
}); });
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); requestMessages.push({
for (const [index, call] of calls.entries()) { role: "assistant",
messages.push({ content: roundText,
role: "tool", toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
name: call.name,
toolCallId: call.id,
content: toolResults[index] ?? "",
});
}
continue;
}
const request = {
model: config.mistralChatTarget.model,
messages,
tools: requestTools,
documents: documents
} as Parameters<typeof mistralAi.chat.stream>[0];
const streamResponse = await mistralAi.chat.stream(request, {signal});
aiLog("debug", "mistral.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
for await (const event of streamResponse) {
if (signal.aborted) throw new Error("Aborted");
const choice = event.data?.choices?.[0];
const delta = choice?.delta;
const mistralDelta = delta as MistralDeltaLike;
streamMessage.append(contentFromMistralDelta(mistralDelta));
const rawDeltaCalls = mistralToolCalls(mistralDelta);
if (rawDeltaCalls.length) {
calls = toolCallAccumulator.add(rawDeltaCalls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({
role: "assistant",
content: roundText,
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
});
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: toolResults[index] ?? "",
}); });
} await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolMemory,
adapter,
appendTargets: [messages, requestMessages],
});
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "mistral.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
return {shouldContinue: true};
},
});
} finally {
await adapter.finalize().catch(() => undefined);
} }
} }
+83 -75
View File
@@ -5,7 +5,6 @@ import {Environment} from "../common/environment";
import type {BoundaryValue} from "../common/boundary-types"; import type {BoundaryValue} from "../common/boundary-types";
import {bot, notesDir} from "../index"; import {bot, notesDir} from "../index";
import {clamp, logError} from "../util/utils"; import {clamp, logError} from "../util/utils";
import {getOllamaTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {ChatMessage} from "./chat-messages-types"; import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama"; import {ChatRequest, Tool} from "ollama";
@@ -14,20 +13,18 @@ import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import { import {
allToolSchemaNames, allToolSchemaNames,
appendOllamaToolResults,
dedupeToolCalls, dedupeToolCalls,
DEFAULT_OLLAMA_CONTEXT_SIZE, DEFAULT_OLLAMA_CONTEXT_SIZE,
executeToolBatch,
isOllamaModelActive, isOllamaModelActive,
isRecord, isRecord,
MAX_OLLAMA_CONTEXT_SIZE, MAX_OLLAMA_CONTEXT_SIZE,
MAX_TOOL_ROUNDS, MAX_TOOL_ROUNDS,
MIN_OLLAMA_CONTEXT_SIZE, MIN_OLLAMA_CONTEXT_SIZE,
normalizeOllamaToolCalls,
OllamaToolCallLike,
roundStatus, roundStatus,
RuntimeConfigSnapshot, RuntimeConfigSnapshot,
safeJsonParseObject, safeJsonParseObject,
@@ -35,14 +32,15 @@ import {
ToolCallData, ToolCallData,
ToolExecutionMemory ToolExecutionMemory
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {ToolRanker} from "./unified-ai-runner.tool-ranker"; import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {getToolPrompts} from "./tools/registry"; import {getToolPrompts} from "./tools/registry";
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes"; import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
import {getModelCapabilities} from "./provider-model-runtime"; import {getModelCapabilities} from "./provider-model-runtime";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import {storeToolRankAudit} from "./tool-rank-audit";
export async function runOllama( export async function runOllama(
msg: Message, msg: Message,
@@ -157,9 +155,12 @@ export async function runOllama(
} }
const toolMemory: ToolExecutionMemory = new Map(); const toolMemory: ToolExecutionMemory = new Map();
const adapter = getProviderAdapter(AiProvider.OLLAMA);
try { try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now(); const roundStartedAt = Date.now();
aiLog("debug", "ollama.round.start", { aiLog("debug", "ollama.round.start", {
round, round,
@@ -183,7 +184,7 @@ export async function runOllama(
let activeToolNames: string[] = []; let activeToolNames: string[] = [];
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[]; const availableOllamaTools: Tool[] = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID}) as Tool[];
aiLog("debug", "ollama.tools.available", { aiLog("debug", "ollama.tools.available", {
round, round,
@@ -191,44 +192,18 @@ export async function runOllama(
rankerEnabled: !!config.ollamaToolRankerTarget, rankerEnabled: !!config.ollamaToolRankerTarget,
}); });
streamMessage.setStatus(Environment.getSelectingToolsText()); const rankResult = await runToolRankStage({
await streamMessage.flush();
const toolRankStartedAt = Date.now();
const toolRankStartedAtIso = new Date().toISOString();
const rankerSelection = await new ToolRanker(config).selectTools({
provider: AiProvider.OLLAMA,
userQuery: latestUserTextFromMessages(messages),
availableTools: availableOllamaTools,
round,
signal,
})
.catch(async error => {
streamMessage.clearStatus();
await streamMessage.flush();
await storeToolRankAudit({
streamMessage,
provider: AiProvider.OLLAMA,
model,
round,
startedAt: toolRankStartedAt,
startedAtIso: toolRankStartedAtIso,
error,
});
throw error;
});
streamMessage.clearStatus();
await streamMessage.flush();
await storeToolRankAudit({
streamMessage,
provider: AiProvider.OLLAMA, provider: AiProvider.OLLAMA,
model, model,
round, round,
startedAt: toolRankStartedAt, config,
startedAtIso: toolRankStartedAtIso, availableTools: availableOllamaTools,
selectedTools: rankerSelection.toolNames, messages,
streamMessage,
signal,
}); });
const filteredTools = [...new Set(filterRankedTools(availableOllamaTools, rankerSelection.toolNames))]; const filteredTools = [...new Set(rankResult.filteredTools as Tool[])];
activeToolNames = filteredTools.map(t => t.function.name ?? ""); activeToolNames = filteredTools.map(t => t.function.name ?? "");
if (filteredTools.length > 0) { if (filteredTools.length > 0) {
request.tools = [...filteredTools]; request.tools = [...filteredTools];
@@ -256,24 +231,23 @@ export async function runOllama(
round, round,
tools: activeToolNames, tools: activeToolNames,
count: activeToolNames.length, count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker, usedRanker: rankResult.usedRanker,
}); });
} }
if (!stream) { if (!stream) {
const response = await ollama.chat({ const response = await runSingleModelRequest({
...request, execute: () => adapter.callModel(request, () => ollama.chat({
stream: false ...request,
stream: false
})),
}); });
const message = response.message; const message = response.message;
const rawContent = message?.content ?? ""; const rawContent = message?.content ?? "";
const nativeCalls = dedupeToolCalls( const nativeCalls = dedupeToolCalls(
normalizeOllamaToolCalls( adapter.extractToolCalls(message),
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
),
); );
const responseText = rawContent; const responseText = rawContent;
@@ -298,10 +272,10 @@ export async function runOllama(
if (!nativeCalls.length) { if (!nativeCalls.length) {
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)}); aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
break; return {shouldContinue: false};
} }
const calls = nativeCalls; const calls = adapter.extractToolCalls(message).length ? adapter.extractToolCalls(message) : nativeCalls;
aiLog("info", "ollama.tool_calls", { aiLog("info", "ollama.tool_calls", {
round, round,
@@ -319,22 +293,40 @@ export async function runOllama(
})), })),
}); });
appendOllamaToolResults( await executeToolBatchWithAdapter({
messages, userId: msg.from?.id,
calls, toolCalls: calls,
await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory), streamMessage,
); toolContext,
toolMemory,
adapter,
appendTargets: [messages],
});
continue; const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
return {shouldContinue: true};
} }
aiLog("debug", "ollama.stream.messages", { aiLog("debug", "ollama.stream.messages", {
round, round,
messageCount: request.messages?.length ?? 0, messageCount: request.messages?.length ?? 0,
}); });
const response = await ollama.chat({ const response = await runSingleModelRequest({
...request, execute: () => adapter.callModel(request, () => ollama.chat({
stream: true ...request,
stream: true
})),
}); });
aiLog("debug", "ollama.stream.open", {round}); aiLog("debug", "ollama.stream.open", {round});
@@ -354,10 +346,7 @@ export async function runOllama(
const localToolCalls: ToolCallData[] = []; const localToolCalls: ToolCallData[] = [];
localToolCalls.push(...normalizeOllamaToolCalls( localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message));
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking); const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
const previousStatus = streamMessage.getStatus(); const previousStatus = streamMessage.getStatus();
@@ -377,13 +366,10 @@ export async function runOllama(
} }
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) { if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
streamMessage.append(chunk.message?.content ?? ""); streamMessage.append(adapter.extractTextDelta(chunk));
} }
calls.push(...normalizeOllamaToolCalls( calls.push(...adapter.extractStreamingToolCalls(chunk.message));
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
if (chunk.done) { if (chunk.done) {
aiLog("debug", "ollama.stream.done", { aiLog("debug", "ollama.stream.done", {
@@ -416,7 +402,7 @@ export async function runOllama(
duration: aiLogDuration(runnerStartedAt), duration: aiLogDuration(runnerStartedAt),
}); });
break; return {shouldContinue: false};
} }
calls.splice(0, calls.length, ...dedupeToolCalls(calls)); calls.splice(0, calls.length, ...dedupeToolCalls(calls));
@@ -439,7 +425,27 @@ export async function runOllama(
})), })),
}); });
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory); const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolMemory,
adapter,
appendTargets: [messages],
});
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
@@ -471,9 +477,11 @@ export async function runOllama(
}).catch(logError); }).catch(logError);
} }
appendOllamaToolResults(messages, calls, toolResults); return {shouldContinue: true};
} },
});
} finally { } finally {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
await adapter.finalize().catch(() => undefined);
} }
} }
+286 -275
View File
@@ -17,11 +17,8 @@ import {
AsyncIterableStream, AsyncIterableStream,
buildSystemInstruction, buildSystemInstruction,
collectOpenAiResponseCodeInterpreterCalls, collectOpenAiResponseCodeInterpreterCalls,
collectOpenAiResponseFunctionCalls,
collectOpenAiResponseImages, collectOpenAiResponseImages,
collectOpenAiResponseText, collectOpenAiResponseText,
executeToolBatch,
getOpenAIResponsesToolsWithImage,
MAX_TOOL_ROUNDS, MAX_TOOL_ROUNDS,
OPENAI_IMAGE_PARTIALS, OPENAI_IMAGE_PARTIALS,
openAiResponseItemCallId, openAiResponseItemCallId,
@@ -35,6 +32,10 @@ import {
errorMessage, errorMessage,
allToolSchemaNames allToolSchemaNames
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {bot} from "../index"; import {bot} from "../index";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -42,10 +43,9 @@ import {logError} from "../util/utils";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files"; import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings"; import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments"; import {AiDownloadedFile} from "./telegram-attachments";
import {ToolRanker} from "./unified-ai-runner.tool-ranker";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline"; import {getProviderAdapter} from "./provider-adapters";
import {storeToolRankAudit} from "./tool-rank-audit"; import {runToolRankStage} from "./tool-rank-stage";
export async function runOpenAi( export async function runOpenAi(
msg: Message, msg: Message,
@@ -60,16 +60,15 @@ export async function runOpenAi(
documentRag?: OpenAiDocumentRagContext, documentRag?: OpenAiDocumentRagContext,
): Promise<void> { ): Promise<void> {
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = [...messages] as Array<ResponseInputItem | OpenAiResponseOutputItem>;
const openAi = createOpenAiClient(config.openAiChatTarget); const openAi = createOpenAiClient(config.openAiChatTarget);
const ownsDocumentRag = !documentRag; const ownsDocumentRag = !documentRag;
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document")); const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
const toolRanker = new ToolRanker(config); const adapter = getProviderAdapter(AiProvider.OPENAI);
const availableTools = getOpenAIResponsesToolsWithImage( let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = adapter.mapMessages(messages) as unknown as Array<ResponseInputItem | OpenAiResponseOutputItem>;
config, const availableTools = adapter.rankTools(config, {
msg.from?.id === Environment.CREATOR_ID, forCreator: msg.from?.id === Environment.CREATOR_ID,
preparedDocumentRag?.vectorStoreIds ?? [], vectorStoreIds: preparedDocumentRag?.vectorStoreIds ?? [],
); });
const systemPrompt = buildSystemInstruction( const systemPrompt = buildSystemInstruction(
config, config,
@@ -90,78 +89,266 @@ export async function runOpenAi(
const toolMemory: ToolExecutionMemory = new Map(); const toolMemory: ToolExecutionMemory = new Map();
try { try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { await runToolLoopRounds({
const roundStartedAt = Date.now(); maxRounds: MAX_TOOL_ROUNDS,
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream}); onRound: async (round) => {
streamMessage.setStatus(Environment.getSelectingToolsText()); const roundStartedAt = Date.now();
await streamMessage.flush(); aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
const toolRankStartedAt = Date.now(); const rankResult = await runToolRankStage({
const toolRankStartedAtIso = new Date().toISOString();
const rankerSelection = await toolRanker.selectTools({
provider: AiProvider.OPENAI, provider: AiProvider.OPENAI,
userQuery: latestUserTextFromMessages(messages), model: config.openAiChatTarget.model,
availableTools,
round, round,
config,
availableTools,
messages,
streamMessage,
signal, signal,
})
.catch(async error => {
streamMessage.clearStatus();
await streamMessage.flush();
await storeToolRankAudit({
streamMessage,
provider: AiProvider.OPENAI,
model: config.openAiChatTarget.model,
round,
startedAt: toolRankStartedAt,
startedAtIso: toolRankStartedAtIso,
error,
});
throw error;
}); });
streamMessage.clearStatus(); const filteredTools = rankResult.filteredTools;
await streamMessage.flush(); const requestTools = preparedDocumentRag?.vectorStoreIds.length
await storeToolRankAudit({ ? (() => {
streamMessage, const tools = [...filteredTools];
provider: AiProvider.OPENAI, const hasFileSearch = allToolSchemaNames(tools).includes("file_search");
model: config.openAiChatTarget.model, if (!hasFileSearch) {
round, const fileSearchTool = availableTools.find(tool => allToolSchemaNames([tool]).includes("file_search"));
startedAt: toolRankStartedAt, if (fileSearchTool) {
startedAtIso: toolRankStartedAtIso, tools.unshift(fileSearchTool);
selectedTools: rankerSelection.toolNames, }
}); }
const filteredTools = filterRankedTools(availableTools, rankerSelection.toolNames); return tools.length ? tools : undefined;
const requestTools = preparedDocumentRag?.vectorStoreIds.length })()
? (() => { : (filteredTools.length ? filteredTools : undefined);
const tools = [...filteredTools];
const hasFileSearch = allToolSchemaNames(tools).includes("file_search"); if (!stream) {
if (!hasFileSearch) { const request: ResponseCreateParamsNonStreaming = {
const fileSearchTool = availableTools.find(tool => allToolSchemaNames([tool]).includes("file_search")); model: config.openAiChatTarget.model,
if (fileSearchTool) { input: responseInput as ResponseInputItem[],
tools.unshift(fileSearchTool); tools: requestTools as ResponseCreateParamsNonStreaming["tools"],
instructions: systemPrompt,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
}) as OpenAiResponseLike;
const responseText = collectOpenAiResponseText(response);
streamMessage.append(responseText);
aiLog("debug", "openai.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
outputItems: response?.output?.length ?? 0,
});
const images = collectOpenAiResponseImages(response);
if (images.length) {
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
images[images.length - 1],
`final_${round}`,
Environment.getImageGenDoneText(config.openAiImageTarget.model),
true,
);
}
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response);
if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", {
round,
duration: aiLogDuration(roundStartedAt),
calls: codeInterpreterCalls.map(call => ({
id: call.id,
status: call.status,
containerId: call.containerId,
codeChars: call.code?.length ?? 0,
outputItems: call.outputs.length,
})),
});
}
const calls = adapter.extractToolCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return {shouldContinue: false};
const toolCalls = calls.map(call => ({
id: call.id,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext,
toolMemory,
adapter,
appendTargets: [toolOutputs],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
if (!uploadFilesResult.uploaded) {
const old = toolOutputs[uploadFilesResult.toolIndex];
const callId = old?.call_id;
if (uploadFilesResult.toolIndex >= 0) {
delete toolOutputs[uploadFilesResult.toolIndex];
}
if (callId) {
toolOutputs.push({
type: "function_call_output" as const,
call_id: callId,
output: "Error: " + uploadFilesResult.error
});
}
} }
} }
return tools.length ? tools : undefined;
})()
: (filteredTools.length ? filteredTools : undefined);
if (!stream) { const continuation = decideToolLoopContinuation({
const request: ResponseCreateParamsNonStreaming = { round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
return {shouldContinue: true};
}
let completedResponse: OpenAiResponseLike | null = null;
const request: ResponseCreateParamsStreaming = {
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
tools: requestTools as ResponseCreateParamsNonStreaming["tools"], stream: true,
instructions: systemPrompt, tools: requestTools as ResponseCreateParamsStreaming["tools"],
parallel_tool_calls: true,
instructions: systemPrompt
}; };
const response = await openAi.responses.create(request, {signal}) as OpenAiResponseLike; const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
}) as AsyncIterableStream<ResponseStreamEvent>;
const responseText = collectOpenAiResponseText(response); aiLog("debug", "openai.stream.open", {round});
streamMessage.append(responseText);
aiLog("debug", "openai.response.received", { let localToolCalls: ToolCallData[] = [];
for await (const event of response) {
if (signal.aborted) throw new Error("Aborted");
switch (event.type) {
case "response.output_text.delta":
streamMessage.append(adapter.extractTextDelta(event));
break;
case "response.image_generation_call.in_progress":
streamMessage.setStatus(Environment.startingImageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.generating":
streamMessage.setStatus(Environment.imageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.partial_image": {
const iteration = (event.partial_image_index ?? 0) + 1;
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
event.partial_image_b64,
`partial_${round}_${iteration}`,
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
false,
);
break;
}
case "response.image_generation_call.completed":
streamMessage.setStatus(Environment.finalizingImageGenText);
await streamMessage.flush();
break;
case "response.file_search_call.in_progress":
case "response.file_search_call.searching":
streamMessage.setStatus(Environment.getUseToolText(["file_search"]));
await streamMessage.flush();
break;
case "response.file_search_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call.in_progress":
case "response.code_interpreter_call.interpreting":
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
await streamMessage.flush();
break;
case "response.code_interpreter_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call_code.delta":
case "response.code_interpreter_call_code.done":
break;
case "response.output_item.added":
{
const streamedCalls = adapter.extractStreamingToolCalls(event);
if (streamedCalls.length) {
localToolCalls.push(...streamedCalls);
}
aiLog("info", "openai.stream.tool_call.added", {
round,
toolCalls: localToolCalls.map(aiLogToolCall)
});
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
await streamMessage.flush();
}
break;
case "response.output_item.done":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
const itemId = openAiResponseItemCallId(item);
const index = localToolCalls.findIndex(c => c.id === itemId);
if (index !== -1) {
localToolCalls.splice(index, 1);
if (localToolCalls.length === 0) {
streamMessage.clearStatus();
} else {
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
}
await streamMessage.flush();
}
}
break;
case "response.function_call_arguments.delta":
break;
case "response.function_call_arguments.done":
break;
case "response.completed":
completedResponse = event.response as OpenAiResponseLike;
break;
case "response.failed":
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
case "error":
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
}
}
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
aiLog("debug", "openai.stream.completed", {
round, round,
duration: aiLogDuration(roundStartedAt), duration: aiLogDuration(roundStartedAt),
textChars: responseText.length, outputItems: completedResponse?.output?.length ?? 0,
outputItems: response?.output?.length ?? 0,
}); });
const images = collectOpenAiResponseImages(response);
const images = collectOpenAiResponseImages(completedResponse);
if (images.length) { if (images.length) {
await showOpenAiGeneratedImage( await showOpenAiGeneratedImage(
streamMessage, streamMessage,
@@ -173,7 +360,7 @@ export async function runOpenAi(
); );
} }
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response); const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse);
if (codeInterpreterCalls.length) { if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", { aiLog("info", "openai.code_interpreter_calls", {
round, round,
@@ -188,29 +375,33 @@ export async function runOpenAi(
}); });
} }
const calls = collectOpenAiResponseFunctionCalls(response); const calls = adapter.extractToolCalls(completedResponse);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round, round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({ calls: calls.map(call => ({
id: call.callId, id: call.id,
name: call.name, name: call.name,
arguments: safeJsonParseObject(call.argumentsText) arguments: safeJsonParseObject(call.argumentsText)
})), })),
}); });
if (!calls.length) return; if (!calls.length) return {shouldContinue: false};
const toolCalls = calls.map(call => ({ const toolCalls = calls.map(call => ({
id: call.callId, id: call.id,
name: call.name, name: call.name,
argumentsText: call.argumentsText, argumentsText: call.argumentsText,
})); }));
const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory); const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
const toolOutputs = calls.map((call, index) => ({ const toolResults = await executeToolBatchWithAdapter({
type: "function_call_output" as const, userId: msg.from?.id,
call_id: call.callId, toolCalls,
output: toolResults[index] ?? "", streamMessage,
})); toolContext,
toolMemory,
adapter,
appendTargets: [toolOutputs],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults); const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) { if (uploadFilesResult.found) {
@@ -230,207 +421,27 @@ export async function runOpenAi(
} }
} }
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs]; const continuation = decideToolLoopContinuation({
continue;
}
let completedResponse: OpenAiResponseLike | null = null;
const request: ResponseCreateParamsStreaming = {
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
stream: true,
tools: requestTools as ResponseCreateParamsStreaming["tools"],
parallel_tool_calls: true,
instructions: systemPrompt
};
const response = await openAi.responses.create(request, {signal}) as AsyncIterableStream<ResponseStreamEvent>;
aiLog("debug", "openai.stream.open", {round});
let localToolCalls: ToolCallData[] = [];
for await (const event of response) {
if (signal.aborted) throw new Error("Aborted");
switch (event.type) {
case "response.output_text.delta":
streamMessage.append(event.delta ?? "");
break;
case "response.image_generation_call.in_progress":
streamMessage.setStatus(Environment.startingImageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.generating":
streamMessage.setStatus(Environment.imageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.partial_image": {
const iteration = (event.partial_image_index ?? 0) + 1;
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
event.partial_image_b64,
`partial_${round}_${iteration}`,
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
false,
);
break;
}
case "response.image_generation_call.completed":
streamMessage.setStatus(Environment.finalizingImageGenText);
await streamMessage.flush();
break;
case "response.file_search_call.in_progress":
case "response.file_search_call.searching":
streamMessage.setStatus(Environment.getUseToolText(["file_search"]));
await streamMessage.flush();
break;
case "response.file_search_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call.in_progress":
case "response.code_interpreter_call.interpreting":
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
await streamMessage.flush();
break;
case "response.code_interpreter_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call_code.delta":
case "response.code_interpreter_call_code.done":
break;
case "response.output_item.added":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
localToolCalls.push({
id: openAiResponseItemCallId(item),
name: item.name ?? "",
argumentsText: item.arguments ?? "{}",
});
aiLog("info", "openai.stream.tool_call.added", {
round,
toolCalls: localToolCalls.map(aiLogToolCall)
});
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
await streamMessage.flush();
}
break;
case "response.output_item.done":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
const itemId = openAiResponseItemCallId(item);
const index = localToolCalls.findIndex(c => c.id === itemId);
if (index !== -1) {
localToolCalls.splice(index, 1);
if (localToolCalls.length === 0) {
streamMessage.clearStatus();
} else {
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
}
await streamMessage.flush();
}
}
break;
case "response.function_call_arguments.delta":
break;
case "response.function_call_arguments.done":
break;
case "response.completed":
completedResponse = event.response as OpenAiResponseLike;
break;
case "response.failed":
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
case "error":
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
}
}
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
aiLog("debug", "openai.stream.completed", {
round,
duration: aiLogDuration(roundStartedAt),
outputItems: completedResponse?.output?.length ?? 0,
});
const images = collectOpenAiResponseImages(completedResponse);
if (images.length) {
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
images[images.length - 1],
`final_${round}`,
Environment.getImageGenDoneText(config.openAiImageTarget.model),
true,
);
}
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse);
if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", {
round, round,
duration: aiLogDuration(roundStartedAt), maxRounds: MAX_TOOL_ROUNDS,
calls: codeInterpreterCalls.map(call => ({ toolCalls: calls,
id: call.id,
status: call.status,
containerId: call.containerId,
codeChars: call.code?.length ?? 0,
outputItems: call.outputs.length,
})),
}); });
} if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
const calls = collectOpenAiResponseFunctionCalls(completedResponse); round,
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { maxRounds: MAX_TOOL_ROUNDS,
round, });
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.callId,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
const toolCalls = calls.map(call => ({
id: call.callId,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
const toolOutputs = calls.map((call, index) => ({
type: "function_call_output",
call_id: call.callId,
output: toolResults[index] ?? "",
}));
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
if (!uploadFilesResult.uploaded) {
const old = toolOutputs[uploadFilesResult.toolIndex];
const callId = old?.call_id;
if (uploadFilesResult.toolIndex >= 0) {
delete toolOutputs[uploadFilesResult.toolIndex];
}
if (callId) {
toolOutputs.push({
type: "function_call_output" as const,
call_id: callId,
output: "Error: " + uploadFilesResult.error
});
}
} }
}
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs]; responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
} return {shouldContinue: true};
},
});
} finally { } finally {
if (ownsDocumentRag) { if (ownsDocumentRag) {
await preparedDocumentRag?.cleanup().catch(logError); await preparedDocumentRag?.cleanup().catch(logError);
} }
await adapter.finalize().catch(logError);
} }
} }
+34 -30
View File
@@ -2,40 +2,39 @@ import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs"; import * as fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type {BoundaryValue} from "../common/boundary-types"; import type {BoundaryValue} from "../common/boundary-types";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider.js";
import {ToolRankerFallbackPolicy} from "../common/policies"; import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment.js";
import {photoGenDir} from "../index"; import {delay, logError, replyToMessage} from "../util/utils.js";
import {delay, logError, replyToMessage} from "../util/utils"; import {MessageStore} from "../common/message-store.js";
import {MessageStore} from "../common/message-store"; import type {OpenAiResponseTool} from "./tool-mappers.js";
import type {OpenAiResponseTool} from "./tool-mappers"; import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers.js";
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers"; import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message.js";
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message"; import {AiDownloadedFile} from "./telegram-attachments.js";
import {AiDownloadedFile} from "./telegram-attachments"; import {getRuntimeCapabilities} from "./provider-model-runtime.js";
import {getRuntimeCapabilities} from "./provider-model-runtime"; import {StoredAttachment} from "../model/stored-attachment.js";
import {StoredAttachment} from "../model/stored-attachment"; import {AiChatMessage, ChatMessage} from "./chat-messages-types.js";
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
import {ListResponse, Ollama} from "ollama"; import {ListResponse, Ollama} from "ollama";
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime"; import {executeToolCall, ToolRuntimeContext} from "./tools/runtime.js";
import {MessageImagePart, MessagePart} from "../common/message-part"; import {MessageImagePart, MessagePart} from "../common/message-part.js";
import {KeyedAsyncLock} from "../util/async-lock"; import {KeyedAsyncLock} from "../util/async-lock.js";
import {type AiRequestQueueTarget} from "./provider-request-queue"; import {type AiRequestQueueTarget} from "./provider-request-queue.js";
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator"; import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator.js";
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings"; import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings.js";
import { import {
isTranscribableAudioDownload, isTranscribableAudioDownload,
resolveSpeechToTextProviderForUser, resolveSpeechToTextProviderForUser,
transcribeSpeechDownloads transcribeSpeechDownloads
} from "./speech-to-text"; } from "./speech-to-text.js";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions"; import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message.js";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer.js";
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target"; import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger.js";
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline"; import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline.js";
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses"; import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store"; import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store.js";
import {filterUserVisibleStoredAttachments} from "../common/stored-attachment-utils"; import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
export type {Message} from "typescript-telegram-bot-api"; export type {Message} from "typescript-telegram-bot-api";
export type {AiRuntimeTarget} from "./ai-runtime-target"; export type {AiRuntimeTarget} from "./ai-runtime-target";
@@ -72,9 +71,14 @@ export const MAX_OLLAMA_CONTEXT_SIZE = 262144;
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768; export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
export const toolResourceLocks = new KeyedAsyncLock(); export const toolResourceLocks = new KeyedAsyncLock();
function photoGenDir(): string {
return path.join(Environment.DATA_PATH, "cache", "photo", "gen");
}
export type UnifiedRunOptions = { export type UnifiedRunOptions = {
provider: AiProvider; provider: AiProvider;
msg: Message; msg: Message;
requestId?: string;
isGuestMsg?: boolean; isGuestMsg?: boolean;
text: string; text: string;
stream?: boolean; stream?: boolean;
@@ -512,13 +516,13 @@ export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set<A
if (msg.video) kinds.add("video"); if (msg.video) kinds.add("video");
} }
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 1): Promise<StoredAttachment[]> { export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 40): Promise<StoredAttachment[]> {
const attachments: StoredAttachment[] = []; const attachments: StoredAttachment[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
let current = await MessageStore.get(msg.chat.id, msg.message_id); let current = await MessageStore.get(msg.chat.id, msg.message_id);
for (let i = 0; current && i < limit; i++) { for (let i = 0; current && i < limit; i++) {
for (const attachment of filterUserVisibleStoredAttachments(current?.attachments ?? [])) { for (const attachment of filterUserInputStoredAttachments(current?.attachments ?? [])) {
const key = [ const key = [
attachment.kind, attachment.kind,
attachment.fileUniqueId || attachment.fileId, attachment.fileUniqueId || attachment.fileId,
@@ -1523,7 +1527,7 @@ export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, l
} { } {
const buffer = Buffer.from(b64, "base64"); const buffer = Buffer.from(b64, "base64");
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`; const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
const cachePath = path.join(photoGenDir, fileName); const cachePath = path.join(photoGenDir(), fileName);
fs.writeFileSync(cachePath, buffer); fs.writeFileSync(cachePath, buffer);
return {buffer, cachePath, fileName}; return {buffer, cachePath, fileName};
} }
+27 -24
View File
@@ -1,20 +1,21 @@
import {ChatCompletionMessageParam} from "openai/resources/chat/completions"; import {ChatCompletionMessageParam} from "openai/resources/chat/completions";
import {ChatRequest} from "ollama"; import {ChatRequest} from "ollama";
import {BoundaryValue} from "../common/boundary-types"; import {BoundaryValue} from "../common/boundary-types.js";
import {ToolRankerFallbackPolicy} from "../common/policies"; import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target"; import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target.js";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger.js";
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared"; import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
import { import {
buildRankerContext, buildRankerContext,
buildRankerTarget, buildRankerTarget,
buildToolRankerPrompt, buildToolRankerPrompt,
filterRankedTools, filterRankedTools,
ToolRankerSelection, ToolRankerSelection,
} from "./tool-ranker-pipeline"; } from "./tool-ranker-pipeline.js";
import {allToolSchemaNames} from "./unified-ai-runner.shared"; import {allToolSchemaNames} from "./unified-ai-runner.shared.js";
import {sanitizeToolRankerResult} from "./tool-ranker-metadata"; import {sanitizeToolRankerResult} from "./tool-ranker-metadata.js";
import {resolveToolRankerFallbackSelection} from "./tool-ranker-fallback.js";
export class ToolRanker { export class ToolRanker {
constructor(private readonly config: RuntimeConfigSnapshot) { constructor(private readonly config: RuntimeConfigSnapshot) {
@@ -27,8 +28,15 @@ export class ToolRanker {
round: number; round: number;
signal: AbortSignal; signal: AbortSignal;
messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[]; messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[];
runRanker?: (
provider: AiProvider,
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
prompt: string,
userQuery: string,
) => Promise<string>;
}): Promise<ToolRankerSelection> { }): Promise<ToolRankerSelection> {
const {availableTools, provider, round, signal, userQuery} = args; const {availableTools, provider, round, signal, userQuery} = args;
const runRanker = args.runRanker ?? this.runRanker.bind(this);
const availableNames = allToolSchemaNames(availableTools); const availableNames = allToolSchemaNames(availableTools);
const fallbackPolicy = this.config.toolRankerFallbackPolicy; const fallbackPolicy = this.config.toolRankerFallbackPolicy;
const configuredTarget = buildRankerTarget(this.config, provider); const configuredTarget = buildRankerTarget(this.config, provider);
@@ -41,11 +49,10 @@ export class ToolRanker {
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined); const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
if (!target) { if (!target) {
if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) { return resolveToolRankerFallbackSelection({
return {toolNames: [], usedRanker: false}; fallbackPolicy,
} availableToolNames: availableNames,
});
return {toolNames: availableNames, usedRanker: false};
} }
const startedAt = Date.now(); const startedAt = Date.now();
@@ -63,7 +70,7 @@ export class ToolRanker {
try { try {
if (signal.aborted) throw new Error("Aborted"); if (signal.aborted) throw new Error("Aborted");
const raw = await this.runRanker(provider, target, ranker.prompt, userQuery); const raw = await runRanker(provider, target, ranker.prompt, userQuery);
if (signal.aborted) throw new Error("Aborted"); if (signal.aborted) throw new Error("Aborted");
const selectedNames = sanitizeToolRankerResult({ const selectedNames = sanitizeToolRankerResult({
raw, raw,
@@ -106,7 +113,7 @@ export class ToolRanker {
const fallbackRanker = buildToolRankerPrompt( const fallbackRanker = buildToolRankerPrompt(
buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools), buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools),
); );
const raw = await this.runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery); const raw = await runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery);
const selectedNames = sanitizeToolRankerResult({ const selectedNames = sanitizeToolRankerResult({
raw, raw,
availableToolNames: availableNames, availableToolNames: availableNames,
@@ -151,14 +158,10 @@ export class ToolRanker {
error: failureMessage, error: failureMessage,
}); });
if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) { return resolveToolRankerFallbackSelection({
return {toolNames: [], usedRanker: false}; fallbackPolicy,
} availableToolNames: availableNames,
});
return {
toolNames: availableNames,
usedRanker: false,
};
} }
} }
+27 -11
View File
@@ -35,6 +35,7 @@ import {persistErrorArtifactAttachment} from "./final-response-artifact-store";
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline"; import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
import {AiRequestStore} from "../common/ai-request-store"; import {AiRequestStore} from "../common/ai-request-store";
import type {StoredAiRequestStatus} from "../model/stored-ai-request"; import type {StoredAiRequestStatus} from "../model/stored-ai-request";
import {recordAiRequestFinish, recordAiRequestStart} from "../common/ai-observability.js";
export type {ToolCallData} from "./unified-ai-runner.shared"; export type {ToolCallData} from "./unified-ai-runner.shared";
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
@@ -49,6 +50,7 @@ async function executeUnifiedAiRequest(
const requestStartedAt = Date.now(); const requestStartedAt = Date.now();
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined; let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
aiLog("info", "request.execute.start", { aiLog("info", "request.execute.start", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
stream: options.stream ?? true, stream: options.stream ?? true,
think: options.think, think: options.think,
@@ -74,6 +76,7 @@ async function executeUnifiedAiRequest(
if (preparedRequest.finishAfterTranscript) return; if (preparedRequest.finishAfterTranscript) return;
aiLog("debug", "request.messages.collected", { aiLog("debug", "request.messages.collected", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
chatMessages: preparedRequest.chatMessages.length, chatMessages: preparedRequest.chatMessages.length,
imageCount: preparedRequest.imageCount, imageCount: preparedRequest.imageCount,
@@ -91,6 +94,7 @@ async function executeUnifiedAiRequest(
controller, controller,
}); });
aiLog("success", "request.execute.done", { aiLog("success", "request.execute.done", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt), duration: aiLogDuration(requestStartedAt),
responseChars: streamMessage.getText().length, responseChars: streamMessage.getText().length,
@@ -99,6 +103,7 @@ async function executeUnifiedAiRequest(
return; return;
} catch (e) { } catch (e) {
aiLog("error", "request.execute.failed", { aiLog("error", "request.execute.failed", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt), duration: aiLogDuration(requestStartedAt),
error: e instanceof Error ? e : String(e), error: e instanceof Error ? e : String(e),
@@ -117,6 +122,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg); const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
aiLog("info", "run.start", { aiLog("info", "run.start", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider), provider: providerName(options.provider),
model: snapshotModel(options.provider, config), model: snapshotModel(options.provider, config),
message: aiLogMessageIdentity(options.msg), message: aiLogMessageIdentity(options.msg),
@@ -133,6 +139,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) { if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
aiLog("warn", "run.rejected.unsupported_attachment", { aiLog("warn", "run.rejected.unsupported_attachment", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider), provider: providerName(options.provider),
requestedAttachmentKinds: [...requestedAttachmentKinds], requestedAttachmentKinds: [...requestedAttachmentKinds],
}); });
@@ -150,6 +157,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName), text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
}).catch(logError); }).catch(logError);
aiLog("warn", "run.rejected.missing_attachment_cache", { aiLog("warn", "run.rejected.missing_attachment_cache", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
}); });
return; return;
@@ -166,6 +174,8 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
provider: providerName(options.provider), provider: providerName(options.provider),
controller controller
}); });
options.requestId ??= cancel.id;
const requestId = options.requestId;
const streamMessage = new TelegramStreamMessage( const streamMessage = new TelegramStreamMessage(
options.msg, options.msg,
cancel.id, cancel.id,
@@ -180,10 +190,11 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
); );
cancel.onCancel = () => streamMessage.cancel(cancel.provider); cancel.onCancel = () => streamMessage.cancel(cancel.provider);
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds); const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id}); aiLog("debug", "run.queue.target", {requestId, target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
const aiRequestStartedAt = new Date().toISOString(); const aiRequestStartedAt = new Date().toISOString();
recordAiRequestStart();
await AiRequestStore.put({ await AiRequestStore.put({
requestId: cancel.id, requestId,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
fromId: options.msg.from?.id ?? 0, fromId: options.msg.from?.id ?? 0,
@@ -197,7 +208,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
const queueMessage = await streamMessage.start(Environment.waitThinkText); const queueMessage = await streamMessage.start(Environment.waitThinkText);
responseMessageId = queueMessage.message_id; responseMessageId = queueMessage.message_id;
await AiRequestStore.put({ await AiRequestStore.put({
requestId: cancel.id, requestId,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
responseMessageId, responseMessageId,
@@ -207,8 +218,9 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
status: "running", status: "running",
startedAt: aiRequestStartedAt, startedAt: aiRequestStartedAt,
}).catch(logError); }).catch(logError);
setAiCancelMessageId(cancel.id, queueMessage.message_id); setAiCancelMessageId(requestId, queueMessage.message_id);
aiLog("info", "run.queue.enter", { aiLog("info", "run.queue.enter", {
requestId,
cancelId: cancel.id, cancelId: cancel.id,
queueMessageId: queueMessage.message_id, queueMessageId: queueMessage.message_id,
target: aiLogProviderTarget(queueTarget), target: aiLogProviderTarget(queueTarget),
@@ -217,15 +229,16 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
await aiProviderRequestQueue.enqueue(queueTarget, { await aiProviderRequestQueue.enqueue(queueTarget, {
signal: controller.signal, signal: controller.signal,
onPositionChange: async requestsBefore => { onPositionChange: async requestsBefore => {
aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore}); aiLog("debug", "run.queue.position", {requestId, cancelId: cancel.id, requestsBefore});
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore)); streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
await streamMessage.flush(); await streamMessage.flush();
}, },
run: async (): Promise<null> => { run: async (): Promise<null> => {
const queueWaitFinishedAt = Date.now(); const queueWaitFinishedAt = Date.now();
aiLog("info", "run.queue.dequeued", {cancelId: cancel.id}); aiLog("info", "run.queue.dequeued", {requestId, cancelId: cancel.id});
const downloads = attachmentsToDownloadedFiles(cached.attachments); const downloads = attachmentsToDownloadedFiles(cached.attachments);
aiLog("debug", "run.downloads.ready", { aiLog("debug", "run.downloads.ready", {
requestId,
count: downloads.length, count: downloads.length,
downloads: downloads.map(d => ({ downloads: downloads.map(d => ({
kind: d.kind, kind: d.kind,
@@ -239,12 +252,13 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage); await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
aiRequestStatus = "succeeded"; aiRequestStatus = "succeeded";
aiLog("success", "run.queue.task.done", { aiLog("success", "run.queue.task.done", {
requestId,
cancelId: cancel.id, cancelId: cancel.id,
duration: aiLogDuration(queueWaitFinishedAt), duration: aiLogDuration(queueWaitFinishedAt),
}); });
} finally { } finally {
cleanupDownloads(downloads); cleanupDownloads(downloads);
aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length}); aiLog("debug", "run.downloads.cleaned", {requestId, cancelId: cancel.id, count: downloads.length});
} }
return null; return null;
}, },
@@ -253,13 +267,13 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) { if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) {
aiRequestStatus = "aborted"; aiRequestStatus = "aborted";
aiRequestError = e instanceof Error ? e.message : String(e); aiRequestError = e instanceof Error ? e.message : String(e);
aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); aiLog("warn", "run.aborted", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
streamMessage.replaceText(streamMessage.getText()); streamMessage.replaceText(streamMessage.getText());
await streamMessage.finish(); await streamMessage.finish();
} else { } else {
aiRequestStatus = "failed"; aiRequestStatus = "failed";
aiRequestError = e instanceof Error ? e.message : String(e); aiRequestError = e instanceof Error ? e.message : String(e);
aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); aiLog("error", "run.failed", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
await streamMessage.fail(e instanceof Error ? e : String(e)); await streamMessage.fail(e instanceof Error ? e : String(e));
try { try {
@@ -279,7 +293,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
await AiRequestStore.put({ await AiRequestStore.put({
requestId: cancel.id, requestId,
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
messageId: options.msg.message_id, messageId: options.msg.message_id,
responseMessageId, responseMessageId,
@@ -291,8 +305,10 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
error: aiRequestError, error: aiRequestError,
}).catch(logError); }).catch(logError);
finishAiRequest(cancel.id); recordAiRequestFinish(aiRequestStatus);
finishAiRequest(requestId);
aiLog("success", "run.finished", { aiLog("success", "run.finished", {
requestId,
cancelId: cancel.id, cancelId: cancel.id,
provider: providerName(options.provider), provider: providerName(options.provider),
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
@@ -0,0 +1,12 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export class PipelineRequestFailure extends Error {
constructor(public readonly decision: PipelineFallbackDecision, message: string) {
super(message);
this.name = "PipelineRequestFailure";
}
}
export function raisePipelineRequestFailure(decision: PipelineFallbackDecision, stageName: string): never {
throw new PipelineRequestFailure(decision, `Pipeline send failed at stage ${stageName} with fallback action ${decision.action}`);
}
@@ -0,0 +1,16 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export function fallbackNotificationKey(requestId: string, decision: PipelineFallbackDecision): string {
return `${requestId}:${decision.stage}:${decision.action}`;
}
export class PipelineFallbackNotificationRegistry {
private readonly notifiedKeys = new Set<string>();
claim(requestId: string, decision: PipelineFallbackDecision): boolean {
const key = fallbackNotificationKey(requestId, decision);
if (this.notifiedKeys.has(key)) return false;
this.notifiedKeys.add(key);
return true;
}
}
@@ -0,0 +1,26 @@
import {Localization} from "../../common/localization.js";
import type {PipelineFallbackAction, PipelineStageName} from "./types.js";
export function resolvePipelineFallbackText(
stage: PipelineStageName,
action: PipelineFallbackAction,
locale?: string,
): string | undefined {
if (action === "continue_without_stage") return undefined;
if (action === "fail_request") return Localization.text("pipelineFallback.failRequest", {}, "⚠️ I could not finish this request.", locale);
switch (stage) {
case "speech_to_text":
return Localization.text("pipelineFallback.speechToText", {}, "⚠️ Speech transcription failed, so I will continue without the audio transcript.", locale);
case "document_rag":
return Localization.text("pipelineFallback.documentRag", {}, "⚠️ Document retrieval failed, so I will answer without RAG.", locale);
case "tool_loop":
return Localization.text("pipelineFallback.toolLoop", {}, "⚠️ Tool execution failed, so I will continue without that tool.", locale);
case "text_to_speech":
return Localization.text("pipelineFallback.textToSpeech", {}, "⚠️ Text-to-speech failed, so I will continue without audio output.", locale);
default:
return action === "notify_user"
? Localization.text("pipelineFallback.notifyUser", {}, "⚠️ I hit a problem and need to continue with a fallback.", locale)
: Localization.text("pipelineFallback.generic", {}, "⚠️ I had to skip part of the request, but I can continue.", locale);
}
}
@@ -0,0 +1,43 @@
import type {Message} from "typescript-telegram-bot-api";
import {Localization} from "../../common/localization.js";
import {replyToMessage, logError} from "../../util/utils.js";
import type {PipelineFallbackDecision} from "./fallback-executor.js";
import {PipelineFallbackNotificationRegistry} from "./fallback-notifier-registry.js";
import {resolvePipelineFallbackText} from "./fallback-notifier-text.js";
export class PipelineFallbackNotifier {
private readonly registry = new PipelineFallbackNotificationRegistry();
constructor(
private readonly sourceMessage: Message,
private readonly responseLanguage?: string,
private readonly sendFallbackMessage: (text: string) => Promise<void> = async text => {
await replyToMessage({
message: this.sourceMessage,
text,
});
},
) {}
async notify(requestId: string, decision: PipelineFallbackDecision): Promise<{notified: boolean; text?: string}> {
if (!this.registry.claim(requestId, decision)) {
return {notified: false};
}
const locale = this.responseLanguage === "default"
? Localization.currentLocale()
: Localization.normalizeLocale(this.responseLanguage) ?? Localization.currentLocale();
const text = resolvePipelineFallbackText(decision.stage, decision.action, locale);
if (!text) {
return {notified: false};
}
try {
await this.sendFallbackMessage(text);
return {notified: true, text};
} catch (error) {
logError(error instanceof Error ? error : String(error));
return {notified: false, text};
}
}
}
@@ -0,0 +1,15 @@
import {AiProvider} from "../../model/ai-provider.js";
import type {RuntimeConfigSnapshot} from "../unified-ai-runner.shared.js";
import {aiLogProviderTarget} from "../../logging/ai-logger.js";
import {buildRankerTarget} from "../tool-ranker-pipeline.js";
import {providerChatTarget} from "../unified-ai-runner.shared.js";
export function buildToolRankFallbackTargetDetails(provider: AiProvider, config: RuntimeConfigSnapshot) {
const sourceTarget = buildRankerTarget(config, provider);
const alternateTarget = providerChatTarget(provider, config);
return {
sourceTarget: aiLogProviderTarget(sourceTarget),
alternateTarget: aiLogProviderTarget(alternateTarget),
};
}
+3 -2
View File
@@ -1,5 +1,6 @@
import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js"; import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js";
import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js"; import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js";
import {raisePipelineRequestFailure} from "./fallback-failure.js";
import type { import type {
PipelineAuditEvent, PipelineAuditEvent,
PipelineFallbackPolicy, PipelineFallbackPolicy,
@@ -66,7 +67,7 @@ export class UserRequestPipeline {
}, },
})); }));
if (decision.shouldFailRequest) { if (decision.shouldFailRequest) {
throw new Error(`Required pipeline stage is not registered: ${stageName}`); raisePipelineRequestFailure(decision, stageName);
} }
continue; continue;
} }
@@ -112,7 +113,7 @@ export class UserRequestPipeline {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
})); }));
if (decision.shouldFailRequest) { if (decision.shouldFailRequest) {
throw error; raisePipelineRequestFailure(decision, stageName);
} }
} }
} }
+33
View File
@@ -0,0 +1,33 @@
import {Message} from "typescript-telegram-bot-api";
import {Command} from "../base/command.js";
import {Requirements} from "../base/requirements.js";
import {Requirement} from "../base/requirement.js";
import {Environment} from "../common/environment.js";
import {buildAiAuditReport, replyWithTrimmedText, resolveAuditTarget} from "./ai-observability.js";
import {logError, sendErrorPlaceholder} from "../util/utils.js";
export class AIAudit extends Command {
command = ["aiaudit", "audit"];
argsMode = "optional" as const;
requirements = Requirements.Build(Requirement.BOT_ADMIN);
title = Environment.commandTitles.aiAudit;
description = Environment.commandDescriptions.aiAudit;
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
try {
const target = resolveAuditTarget(msg, match?.[3] ?? null);
if (!target) {
await replyWithTrimmedText(msg, "Usage: reply to a message or pass messageId, or chatId messageId.");
return;
}
const text = await buildAiAuditReport(target);
await replyWithTrimmedText(msg, text);
} catch (error) {
logError(error instanceof Error ? error : String(error));
await sendErrorPlaceholder(msg).catch(logError);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import {Message} from "typescript-telegram-bot-api";
import {Command} from "../base/command.js";
import {Requirements} from "../base/requirements.js";
import {Requirement} from "../base/requirement.js";
import {Environment} from "../common/environment.js";
import {buildAiMetricsReport, replyWithTrimmedText} from "./ai-observability.js";
import {logError, sendErrorPlaceholder} from "../util/utils.js";
export class AIMetrics extends Command {
command = ["aimetrics", "metrics"];
argsMode = "none" as const;
requirements = Requirements.Build(Requirement.BOT_ADMIN);
title = Environment.commandTitles.aiMetrics;
description = Environment.commandDescriptions.aiMetrics;
async execute(msg: Message): Promise<void> {
try {
const text = await buildAiMetricsReport();
await replyWithTrimmedText(msg, text);
} catch (error) {
logError(error instanceof Error ? error : String(error));
await sendErrorPlaceholder(msg).catch(logError);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import {Message} from "typescript-telegram-bot-api";
import {DatabaseManager} from "../db/database-manager.js";
import type {AttachmentDbRow} from "../db/db-types.js";
import {replyToMessage} from "../util/utils.js";
import {snapshotAiObservability} from "../common/ai-observability.js";
export type AuditTarget = {
chatId: number;
messageId: number;
};
export function resolveAuditTarget(msg: Message, argsText?: string | null): AuditTarget | null {
if (msg.reply_to_message) {
return {
chatId: msg.chat.id,
messageId: msg.reply_to_message.message_id,
};
}
const args = argsText?.trim().split(/\s+/).filter(Boolean) ?? [];
if (!args.length) return null;
if (args.length === 1) {
const messageId = Number(args[0]);
if (!Number.isFinite(messageId)) return null;
return {
chatId: msg.chat.id,
messageId,
};
}
const chatId = Number(args[0]);
const messageId = Number(args[1]);
if (!Number.isFinite(chatId) || !Number.isFinite(messageId)) return null;
return {chatId, messageId};
}
function formatSize(bytes: number | null | undefined): string {
if (!Number.isFinite(bytes ?? NaN)) return "n/a";
const value = Number(bytes);
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${value} B`;
}
function clip(value: string | null | undefined, max = 120): string {
const text = (value ?? "").trim();
if (!text) return "n/a";
return text.length <= max ? text : `${text.slice(0, max)}`;
}
function formatAttachmentLine(index: number, attachment: AttachmentDbRow): string {
return [
`${index + 1}.`,
attachment.direction,
attachment.kind,
attachment.fileName,
`size=${formatSize(attachment.sizeBytes)}`,
attachment.artifactKind ? `artifact=${attachment.artifactKind}` : null,
].filter(Boolean).join(" ");
}
export async function buildAiAuditReport(target: AuditTarget): Promise<string> {
const [request, audits, artifacts, attachments] = await Promise.all([
DatabaseManager.getAiRequestByMessage(target.chatId, target.messageId),
DatabaseManager.getRequestAuditsByMessage(target.chatId, target.messageId),
DatabaseManager.getArtifactsByMessage(target.chatId, target.messageId),
DatabaseManager.getAttachmentsByMessage(target.chatId, target.messageId),
]);
const lines: string[] = [
"AI observability audit",
`chatId: ${target.chatId}`,
`messageId: ${target.messageId}`,
"",
"AI request:",
];
if (request) {
lines.push(
` requestId: ${request.requestId}`,
` provider: ${request.provider}`,
` model: ${request.model}`,
` status: ${request.status}`,
` startedAt: ${request.startedAt}`,
` finishedAt: ${request.finishedAt ?? "n/a"}`,
` error: ${clip(request.error, 240)}`,
);
} else {
lines.push(" not found");
}
lines.push("", `Pipeline audits: ${audits.length}`);
audits.slice(0, 12).forEach((audit, index) => {
lines.push(
` ${index + 1}. ${audit.stage} ${audit.status}` +
`${audit.durationMs !== null ? ` ${audit.durationMs}ms` : ""}` +
`${audit.provider ? ` provider=${audit.provider}` : ""}` +
`${audit.model ? ` model=${audit.model}` : ""}` +
`${audit.error ? ` error=${clip(audit.error, 120)}` : ""}`,
);
});
if (audits.length > 12) {
lines.push(` … and ${audits.length - 12} more`);
}
lines.push("", `Artifacts: ${artifacts.length}`);
artifacts.slice(0, 12).forEach((artifact, index) => {
lines.push(
` ${index + 1}. ${artifact.kind} stage=${artifact.stage}` +
`${artifact.attachmentId ? ` attachmentId=${artifact.attachmentId}` : ""}` +
`${artifact.createdAt ? ` createdAt=${artifact.createdAt}` : ""}`,
);
});
if (artifacts.length > 12) {
lines.push(` … and ${artifacts.length - 12} more`);
}
lines.push("", `Attachments: ${attachments.length}`);
attachments.slice(0, 12).forEach((attachment, index) => {
lines.push(` ${formatAttachmentLine(index, attachment)}`);
});
if (attachments.length > 12) {
lines.push(` … and ${attachments.length - 12} more`);
}
return lines.join("\n");
}
export async function buildAiMetricsReport(): Promise<string> {
const snapshot = snapshotAiObservability();
const [aiRequests, attachments, artifacts, requestAudits] = await Promise.all([
DatabaseManager.getAllAiRequests(),
DatabaseManager.getAllAttachments(),
DatabaseManager.getAllArtifacts(),
DatabaseManager.getAllRequestAudits(),
]);
return [
"AI observability metrics",
`requests: total=${snapshot.requests.total} succeeded=${snapshot.requests.succeeded} failed=${snapshot.requests.failed} aborted=${snapshot.requests.aborted}`,
`fallbacks: total=${snapshot.fallbacks.total} ignore=${snapshot.fallbacks.ignore} notify_user=${snapshot.fallbacks.notifyUser} continue_without_stage=${snapshot.fallbacks.continueWithoutStage} use_alternate_target=${snapshot.fallbacks.useAlternateTarget} fail_request=${snapshot.fallbacks.failRequest}`,
`tool calls: ${snapshot.toolCalls}`,
`RAG runs: ${snapshot.ragRuns}`,
`TTS runs: total=${snapshot.ttsRuns.total} succeeded=${snapshot.ttsRuns.succeeded} failed=${snapshot.ttsRuns.failed} skipped=${snapshot.ttsRuns.skipped}`,
`db rows: ai_requests=${aiRequests.length} attachments=${attachments.length} artifacts=${artifacts.length} request_audit=${requestAudits.length}`,
].join("\n");
}
export async function replyWithTrimmedText(msg: Message, text: string): Promise<void> {
const maxLength = 3800;
const nextText = text.length <= maxLength ? text : `${text.slice(0, maxLength)}\n… (trimmed)`;
await replyToMessage({message: msg, text: nextText});
}
+51
View File
@@ -0,0 +1,51 @@
import {Message} from "typescript-telegram-bot-api";
import {Command} from "../base/command.js";
import {Requirements} from "../base/requirements.js";
import {Requirement} from "../base/requirement.js";
import {Environment} from "../common/environment.js";
import {DatabaseManager} from "../db/database-manager.js";
import {logError, sendErrorPlaceholder} from "../util/utils.js";
import {replyWithTrimmedText} from "./ai-observability.js";
function formatRequestLine(index: number, request: Awaited<ReturnType<typeof DatabaseManager.getAllAiRequests>>[number]): string {
return [
`${index + 1}.`,
`requestId=${request.requestId}`,
`chatId=${request.chatId}`,
`messageId=${request.messageId}`,
request.responseMessageId ? `responseMessageId=${request.responseMessageId}` : null,
`provider=${request.provider}`,
`model=${request.model}`,
`status=${request.status}`,
`startedAt=${request.startedAt}`,
request.finishedAt ? `finishedAt=${request.finishedAt}` : null,
request.error ? `error=${request.error}` : null,
].filter(Boolean).join(" ");
}
export class AIRequests extends Command {
command = ["airequests"];
argsMode = "none" as const;
requirements = Requirements.Build(Requirement.BOT_ADMIN);
title = Environment.commandTitles.aiRequests;
description = Environment.commandDescriptions.aiRequests;
async execute(msg: Message): Promise<void> {
try {
const requests = (await DatabaseManager.getAllAiRequests()).slice(-10).reverse();
const lines = [
"Recent AI requests",
`count: ${requests.length}`,
"",
...requests.map((request, index) => formatRequestLine(index, request)),
];
await replyWithTrimmedText(msg, lines.join("\n"));
} catch (error) {
logError(error instanceof Error ? error : String(error));
await sendErrorPlaceholder(msg).catch(logError);
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import type {PipelineFallbackAction} from "../ai/user-request-pipeline";
import type {StoredAiRequestStatus} from "../model/stored-ai-request.js";
type CounterSnapshot = {
total: number;
succeeded: number;
failed: number;
aborted: number;
};
export type AiObservabilitySnapshot = {
requests: CounterSnapshot;
fallbacks: {
total: number;
ignore: number;
notifyUser: number;
continueWithoutStage: number;
useAlternateTarget: number;
failRequest: number;
};
toolCalls: number;
ragRuns: number;
ttsRuns: {
total: number;
succeeded: number;
failed: number;
skipped: number;
};
};
const requestCounters = {
total: 0,
succeeded: 0,
failed: 0,
aborted: 0,
};
const fallbackCounters = {
total: 0,
ignore: 0,
notifyUser: 0,
continueWithoutStage: 0,
useAlternateTarget: 0,
failRequest: 0,
};
const ttsCounters = {
total: 0,
succeeded: 0,
failed: 0,
skipped: 0,
};
let toolCalls = 0;
let ragRuns = 0;
function incrementFallback(action: PipelineFallbackAction): void {
fallbackCounters.total += 1;
switch (action) {
case "ignore":
fallbackCounters.ignore += 1;
break;
case "notify_user":
fallbackCounters.notifyUser += 1;
break;
case "continue_without_stage":
fallbackCounters.continueWithoutStage += 1;
break;
case "use_alternate_target":
fallbackCounters.useAlternateTarget += 1;
break;
case "fail_request":
fallbackCounters.failRequest += 1;
break;
}
}
export function recordAiRequestStart(): void {
requestCounters.total += 1;
}
export function recordAiRequestFinish(status: StoredAiRequestStatus): void {
switch (status) {
case "succeeded":
requestCounters.succeeded += 1;
break;
case "failed":
requestCounters.failed += 1;
break;
case "aborted":
requestCounters.aborted += 1;
break;
case "running":
break;
}
}
export function recordPipelineFallback(action: PipelineFallbackAction): void {
incrementFallback(action);
}
export function recordToolCall(): void {
toolCalls += 1;
}
export function recordRagRun(): void {
ragRuns += 1;
}
export function recordTtsRun(status: "succeeded" | "failed" | "skipped"): void {
ttsCounters.total += 1;
ttsCounters[status] += 1;
}
export function snapshotAiObservability(): AiObservabilitySnapshot {
return {
requests: {...requestCounters},
fallbacks: {...fallbackCounters},
toolCalls,
ragRuns,
ttsRuns: {...ttsCounters},
};
}
+9
View File
@@ -0,0 +1,9 @@
import type {StoredAttachment} from "../model/stored-attachment";
export function filterUserVisibleStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
return attachments.filter(attachment => attachment.scope !== "internal_artifact");
}
export function filterUserInputStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
return attachments.filter(attachment => attachment.scope === "user_input" || attachment.scope === undefined);
}
+53 -12
View File
@@ -3,18 +3,21 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import {parse as parseDotEnv} from "dotenv"; import {parse as parseDotEnv} from "dotenv";
import {z} from "zod"; import {z} from "zod";
import {appLogger} from "../logging/logger"; import {appLogger} from "../logging/logger.js";
import type {BoundaryValue, ErrorLike} from "./boundary-types"; import type {BoundaryValue, ErrorLike} from "./boundary-types";
import {saveData} from "../db/database"; import {Answers} from "../model/answers.js";
import {Answers} from "../model/answers"; import {AiProvider} from "../model/ai-provider.js";
import {ifTrue} from "../util/utils"; import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies.js";
import {AiProvider} from "../model/ai-provider"; import {ToolRankerFallbackPolicy} from "./policies.js";
import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies"; import type {ToolCallData} from "../ai/unified-ai-runner.js";
import {ToolRankerFallbackPolicy} from "./policies"; import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js";
import type {ToolCallData} from "../ai/unified-ai-runner"; import {Localization, type LocalizationParams} from "./localization.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator";
import {Localization, type LocalizationParams} from "./localization"; function parseBooleanLike(value: string): boolean {
const normalized = value.trim().toLowerCase();
return ["true", "t", "y", "1"].includes(normalized);
}
type EnvRecord = Record<string, string>; type EnvRecord = Record<string, string>;
type StringEnumLike = Record<string, string>; type StringEnumLike = Record<string, string>;
@@ -53,7 +56,7 @@ function booleanWithDefaultSchema(defaultValue: boolean) {
return defaultValue; return defaultValue;
} }
return ifTrue(normalized); return parseBooleanLike(normalized);
}, z.boolean()) }, z.boolean())
.default(defaultValue) .default(defaultValue)
.catch(defaultValue); .catch(defaultValue);
@@ -62,7 +65,7 @@ function booleanWithDefaultSchema(defaultValue: boolean) {
const optionalBooleanSchema = z const optionalBooleanSchema = z
.preprocess(value => { .preprocess(value => {
const normalized = normalizeString(value as BoundaryValue); const normalized = normalizeString(value as BoundaryValue);
return normalized === undefined ? undefined : ifTrue(normalized); return normalized === undefined ? undefined : parseBooleanLike(normalized);
}, z.boolean().optional()) }, z.boolean().optional())
.optional() .optional()
.catch(undefined); .catch(undefined);
@@ -820,6 +823,34 @@ export class Environment {
return this.text("noTextToSynthesizeText", "No text to synthesize."); return this.text("noTextToSynthesizeText", "No text to synthesize.");
} }
static get pipelineFallbackGenericText() {
return this.text("pipelineFallbackGenericText", "⚠️ I had to skip part of the request, but I can continue.");
}
static get pipelineFallbackNotifyText() {
return this.text("pipelineFallbackNotifyText", "⚠️ I hit a problem and need to continue with a fallback.");
}
static get pipelineFallbackFailText() {
return this.text("pipelineFallbackFailText", "⚠️ I could not finish this request.");
}
static get pipelineFallbackRagText() {
return this.text("pipelineFallbackRagText", "⚠️ Document retrieval failed, so I will answer without RAG.");
}
static get pipelineFallbackSpeechToTextText() {
return this.text("pipelineFallbackSpeechToTextText", "⚠️ Speech transcription failed, so I will continue without the audio transcript.");
}
static get pipelineFallbackTextToSpeechText() {
return this.text("pipelineFallbackTextToSpeechText", "⚠️ Text-to-speech failed, so I will continue without audio output.");
}
static get pipelineFallbackToolText() {
return this.text("pipelineFallbackToolText", "⚠️ Tool execution failed, so I will continue without that tool.");
}
static get mistralTtsNoAudioDataText() { static get mistralTtsNoAudioDataText() {
return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData."); return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData.");
} }
@@ -960,6 +991,9 @@ export class Environment {
choice: "/choice a, b, ..., c", choice: "/choice a, b, ..., c",
coin: "/coin", coin: "/coin",
debug: "/debug", debug: "/debug",
aiRequests: "/aiRequests",
aiAudit: "/aiAudit [reply|messageId|chatId messageId]",
aiMetrics: "/aiMetrics",
dice: "/dice", dice: "/dice",
distort: "/distort [amp] [wavelength]", distort: "/distort [amp] [wavelength]",
help: "/help", help: "/help",
@@ -1010,6 +1044,9 @@ export class Environment {
choice: this.text("commandDescriptions.choice", "Choose a random value"), choice: this.text("commandDescriptions.choice", "Choose a random value"),
coin: this.text("commandDescriptions.coin", "Heads or tails"), coin: this.text("commandDescriptions.coin", "Heads or tails"),
debug: this.text("commandDescriptions.debug", "Returns msg (or reply) as json"), debug: this.text("commandDescriptions.debug", "Returns msg (or reply) as json"),
aiRequests: this.text("commandDescriptions.aiRequests", "Show recent AI requests"),
aiAudit: this.text("commandDescriptions.aiAudit", "Inspect AI request audit and artifacts"),
aiMetrics: this.text("commandDescriptions.aiMetrics", "Show AI observability counters"),
dice: this.text("commandDescriptions.dice", "Sends random or specific dice"), dice: this.text("commandDescriptions.dice", "Sends random or specific dice"),
distort: this.text("commandDescriptions.distort", "Distortion of picture"), distort: this.text("commandDescriptions.distort", "Distortion of picture"),
help: this.text("commandDescriptions.help", "Show list of commands"), help: this.text("commandDescriptions.help", "Show list of commands"),
@@ -1939,6 +1976,7 @@ export class Environment {
if (!has) { if (!has) {
this.ADMIN_IDS.add(id); this.ADMIN_IDS.add(id);
const {saveData} = await import("../db/database.js");
await saveData(); await saveData();
} }
@@ -1950,6 +1988,7 @@ export class Environment {
if (has) { if (has) {
this.ADMIN_IDS.delete(id); this.ADMIN_IDS.delete(id);
const {saveData} = await import("../db/database.js");
await saveData(); await saveData();
} }
@@ -1966,6 +2005,7 @@ export class Environment {
} }
this.MUTED_IDS.add(id); this.MUTED_IDS.add(id);
const {saveData} = await import("../db/database.js");
await saveData(); await saveData();
return true; return true;
} }
@@ -1976,6 +2016,7 @@ export class Environment {
} }
this.MUTED_IDS.delete(id); this.MUTED_IDS.delete(id);
const {saveData} = await import("../db/database.js");
await saveData(); await saveData();
return true; return true;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import {AsyncLocalStorage} from "node:async_hooks"; import {AsyncLocalStorage} from "node:async_hooks";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {appLogger} from "../logging/logger"; import {appLogger} from "../logging/logger.js";
const logger = appLogger.child("localization"); const logger = appLogger.child("localization");
+3
View File
@@ -20,6 +20,9 @@ export type MessagePart = {
audios?: string[]; audios?: string[];
audioParts?: MessageAudioPart[]; audioParts?: MessageAudioPart[];
documents?: string[]; documents?: string[];
documentNames?: string[];
videos?: string[]; videos?: string[];
videoNotes?: string[]; videoNotes?: string[];
videoNames?: string[];
videoNoteNames?: string[];
} }
+1 -4
View File
@@ -1,6 +1,7 @@
import path from "node:path"; import path from "node:path";
import {Environment} from "./environment"; import {Environment} from "./environment";
import {StoredAttachment} from "../model/stored-attachment"; import {StoredAttachment} from "../model/stored-attachment";
export {filterUserVisibleStoredAttachments} from "./attachment-visibility";
export function photoCachePathForUniqueId(uniqueId: string): string { export function photoCachePathForUniqueId(uniqueId: string): string {
return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`); return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`);
@@ -44,7 +45,3 @@ export function uniqueStoredAttachments(attachments: StoredAttachment[]): Stored
return result; return result;
} }
export function filterUserVisibleStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
return attachments.filter(attachment => attachment.scope !== "internal_artifact");
}
+4 -4
View File
@@ -1,9 +1,9 @@
import * as fs from "fs"; import * as fs from "fs";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment.js";
import {logError} from "../util/utils"; import {logError} from "../util/utils.js";
import {Answers} from "../model/answers"; import {Answers} from "../model/answers.js";
import path from "node:path"; import path from "node:path";
import {KeyedAsyncLock} from "../util/async-lock"; import {KeyedAsyncLock} from "../util/async-lock.js";
type DataJsonFile = { type DataJsonFile = {
admins: number[] admins: number[]
+87 -64
View File
@@ -1,9 +1,9 @@
import "dotenv/config"; import "dotenv/config";
import {appLogger} from "./logging/logger"; import {appLogger} from "./logging/logger.js";
import {Environment} from "./common/environment"; import {Environment} from "./common/environment.js";
import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api"; import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api";
import {Command} from "./base/command"; import {Command} from "./base/command.js";
import type {LogDetails} from "./logging/logger"; import type {LogDetails} from "./logging/logger.js";
import { import {
initSystemSpecs, initSystemSpecs,
logError, logError,
@@ -13,68 +13,72 @@ import {
processInlineQuery, processInlineQuery,
processMyChatMember, processMyChatMember,
processNewMessage processNewMessage
} from "./util/utils"; } from "./util/utils.js";
import {Ae} from "./commands/ae"; import {Ae} from "./commands/ae.js";
import {Help} from "./commands/help"; import {Help} from "./commands/help.js";
import {Ignore} from "./commands/ignore"; import {Ignore} from "./commands/ignore.js";
import {Unignore} from "./commands/unignore"; import {Unignore} from "./commands/unignore.js";
import {Ping} from "./commands/ping"; import {Ping} from "./commands/ping.js";
import {RandomString} from "./commands/random-string"; import {RandomString} from "./commands/random-string.js";
import {SystemInfo} from "./commands/system-info"; import {SystemInfo} from "./commands/system-info.js";
import {Test} from "./commands/test"; import {Test} from "./commands/test.js";
import {readData, retrieveAnswers} from "./db/database"; import {readData, retrieveAnswers} from "./db/database.js";
import {Uptime} from "./commands/uptime"; import {Uptime} from "./commands/uptime.js";
import {WhatBetter} from "./commands/what-better"; import {WhatBetter} from "./commands/what-better.js";
import {When} from "./commands/when"; import {When} from "./commands/when.js";
import {RandomInt} from "./commands/random-int"; import {RandomInt} from "./commands/random-int.js";
import {Ban} from "./commands/ban"; import {Ban} from "./commands/ban.js";
import {Quote} from "./commands/quote"; import {Quote} from "./commands/quote.js";
import {OllamaSearch} from "./commands/ollama-search"; import {OllamaSearch} from "./commands/ollama-search.js";
import {Id} from "./commands/id"; import {Id} from "./commands/id.js";
import {AdminsAdd} from "./commands/admins-add"; import {AdminsAdd} from "./commands/admins-add.js";
import {AdminsRemove} from "./commands/admins-remove"; import {AdminsRemove} from "./commands/admins-remove.js";
import {Shutdown} from "./commands/shutdown"; import {Shutdown} from "./commands/shutdown.js";
import {Leave} from "./commands/leave"; import {Leave} from "./commands/leave.js";
import {OllamaChat} from "./commands/ollama-chat"; import {OllamaChat} from "./commands/ollama-chat.js";
import {Start} from "./commands/start"; import {Start} from "./commands/start.js";
import {Choice} from "./commands/choice"; import {Choice} from "./commands/choice.js";
import {Coin} from "./commands/coin"; import {Coin} from "./commands/coin.js";
import {Qr} from "./commands/qr"; import {Qr} from "./commands/qr.js";
import {Distort} from "./commands/distort"; import {Distort} from "./commands/distort.js";
import {Dice} from "./commands/dice"; import {Dice} from "./commands/dice.js";
import {Unban} from "./commands/unban"; import {Unban} from "./commands/unban.js";
import {Title} from "./commands/title"; import {Title} from "./commands/title.js";
import {MessageDao} from "./db/message-dao"; import {MessageDao} from "./db/message-dao.js";
import {DatabaseManager} from "./db/database-manager"; import {DatabaseManager} from "./db/database-manager.js";
import {UserDao} from "./db/user-dao"; import {UserDao} from "./db/user-dao.js";
import {UserStore} from "./common/user-store"; import {UserStore} from "./common/user-store.js";
import {CallbackCommand} from "./base/callback-command"; import {CallbackCommand} from "./base/callback-command.js";
import {AiCancel} from "./callback_commands/ai-cancel"; import {AiCancel} from "./callback_commands/ai-cancel.js";
import {AiRegenerate} from "./callback_commands/ai-regenerate"; import {AiRegenerate} from "./callback_commands/ai-regenerate.js";
import {MistralChat} from "./commands/mistral-chat"; import {MistralChat} from "./commands/mistral-chat.js";
import {Transliteration} from "./commands/transliteration"; import {Transliteration} from "./commands/transliteration.js";
import {OllamaListModels} from "./commands/ollama-list-models"; import {OllamaListModels} from "./commands/ollama-list-models.js";
import {OllamaGetModel} from "./commands/ollama-get-model"; import {OllamaGetModel} from "./commands/ollama-get-model.js";
import {OllamaSetModel} from "./commands/ollama-set-model"; import {OllamaSetModel} from "./commands/ollama-set-model.js";
import {MistralGetModel} from "./commands/mistral-get-model"; import {MistralGetModel} from "./commands/mistral-get-model.js";
import {MistralSetModel} from "./commands/mistral-set-model"; import {MistralSetModel} from "./commands/mistral-set-model.js";
import {MistralListModels} from "./commands/mistral-list-models"; import {MistralListModels} from "./commands/mistral-list-models.js";
import {Debug} from "./commands/debug"; import {Debug} from "./commands/debug.js";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {OpenAIChat} from "./commands/openai-chat"; import {OpenAIChat} from "./commands/openai-chat.js";
import {OpenAIListModels} from "./commands/openai-list-models"; import {OpenAIListModels} from "./commands/openai-list-models.js";
import {OpenAIGetModel} from "./commands/openai-get-model"; import {OpenAIGetModel} from "./commands/openai-get-model.js";
import {OpenAISetModel} from "./commands/openai-set-model"; import {OpenAISetModel} from "./commands/openai-set-model.js";
import {Info} from "./commands/info"; import {Info} from "./commands/info.js";
import {AdminsList} from "./commands/admins-list"; import {AdminsList} from "./commands/admins-list.js";
import {ExportDb} from "./commands/export-db"; import {ExportDb} from "./commands/export-db.js";
import {ImportDb} from "./commands/import-db"; import {ImportDb} from "./commands/import-db.js";
import {Settings} from "./commands/settings"; import {Settings} from "./commands/settings.js";
import {UserSettingsCallback} from "./callback_commands/user-settings"; import {UserSettingsCallback} from "./callback_commands/user-settings.js";
import {TextToSpeech} from "./commands/text-to-speech"; import {TextToSpeech} from "./commands/text-to-speech.js";
import {SpeechToText} from "./commands/speech-to-text"; import {SpeechToText} from "./commands/speech-to-text.js";
import {cleanupInternalArtifactCache} from "./ai/internal-artifact-store"; import {cleanupInternalArtifactCache} from "./ai/internal-artifact-store.js";
import {AIAudit} from "./commands/ai-audit.js";
import {AIMetrics} from "./commands/ai-metrics.js";
import {AIRequests} from "./commands/ai-requests.js";
import {cleanupStaleRagProviderState} from "./ai/rag-retention.js";
process.setUncaughtExceptionCaptureCallback(logError); process.setUncaughtExceptionCaptureCallback(logError);
@@ -119,6 +123,9 @@ export const commands: Command[] = [
new Settings(), new Settings(),
new TextToSpeech(), new TextToSpeech(),
new SpeechToText(), new SpeechToText(),
new AIRequests(),
new AIAudit(),
new AIMetrics(),
new AdminsAdd(), new AdminsAdd(),
new AdminsRemove(), new AdminsRemove(),
@@ -272,6 +279,22 @@ async function main() {
}, () => ({notesRootFilePath})); }, () => ({notesRootFilePath}));
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14})); await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
await measureStartupStep("cleanup_stale_rag_provider_state", () => cleanupStaleRagProviderState(), () => ({retentionDays: 14}));
await measureStartupStep("observability.snapshot", async () => {
const [aiRequests, attachments, artifacts, requestAudits] = await Promise.all([
DatabaseManager.getAllAiRequests(),
DatabaseManager.getAllAttachments(),
DatabaseManager.getAllArtifacts(),
DatabaseManager.getAllRequestAudits(),
]);
return {
aiRequests: aiRequests.length,
attachments: attachments.length,
artifacts: artifacts.length,
requestAudits: requestAudits.length,
};
}, () => ({tables: ["ai_requests", "attachments", "artifacts", "request_audit"]}));
const cmds = await measureStartupStep("build_commands", () => commands.filter(cmd => { const cmds = await measureStartupStep("build_commands", () => commands.filter(cmd => {
return cmd.title && cmd.title.startsWith("/") && cmd.title.split(" ").length === 1 && cmd.description; return cmd.title && cmd.title.startsWith("/") && cmd.title.split(" ").length === 1 && cmd.description;
+1 -1
View File
@@ -1,5 +1,5 @@
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger"; import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger.js";
export type AiRunnerLogLevel = LogLevel; export type AiRunnerLogLevel = LogLevel;
export type AiRunnerLogDetails = LogDetails; export type AiRunnerLogDetails = LogDetails;
+1 -1
View File
@@ -1,4 +1,4 @@
import {AiProvider} from "./ai-provider"; import {AiProvider} from "./ai-provider.js";
export type AiEndpointInfo = { export type AiEndpointInfo = {
provider?: AiProvider; provider?: AiProvider;
+1 -1
View File
@@ -1,4 +1,4 @@
import {AiCapabilityInfo} from "./ai-capability-info"; import {AiCapabilityInfo} from "./ai-capability-info.js";
export class AiModelCapabilities { export class AiModelCapabilities {
chat: AiCapabilityInfo | undefined; chat: AiCapabilityInfo | undefined;
+79 -36
View File
@@ -1,7 +1,7 @@
import * as si from "systeminformation"; import * as si from "systeminformation";
import {appLogger} from "../logging/logger"; import {appLogger} from "../logging/logger.js";
import {Command} from "../base/command"; import {Command} from "../base/command.js";
import {CallbackCommand} from "../base/callback-command"; import {CallbackCommand} from "../base/callback-command.js";
import { import {
CallbackQuery, CallbackQuery,
ChatMember, ChatMember,
@@ -15,39 +15,40 @@ import {
TelegramBot, TelegramBot,
User User
} from "typescript-telegram-bot-api"; } from "typescript-telegram-bot-api";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment.js";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors"; import {TelegramError} from "typescript-telegram-bot-api/dist/errors.js";
import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index"; import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index.js";
import os from "os"; import os from "os";
import axios from "axios"; import axios from "axios";
import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part"; import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part.js";
import {StoredMessage} from "../model/stored-message"; import {StoredMessage} from "../model/stored-message.js";
import sharp from "sharp"; import sharp from "sharp";
import {UserStore} from "../common/user-store"; import {UserStore} from "../common/user-store.js";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {MessageStore} from "../common/message-store"; import {MessageStore} from "../common/message-store.js";
import {SystemInfo} from "../commands/system-info"; import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
import {PrefixResponse} from "../commands/prefix-response"; import {SystemInfo} from "../commands/system-info.js";
import {ChatCommand} from "../base/chat-command"; import {PrefixResponse} from "../commands/prefix-response.js";
import {AiProvider} from "../model/ai-provider"; import {ChatCommand} from "../base/chat-command.js";
import {SendOptions} from "../model/send-options"; import {AiProvider} from "../model/ai-provider.js";
import {EditOptions} from "../model/edit-options"; import {SendOptions} from "../model/send-options.js";
import {StoredUser} from "../model/stored-user"; import {EditOptions} from "../model/edit-options.js";
import {StoredAttachment} from "../model/stored-attachment"; import {StoredUser} from "../model/stored-user.js";
import {AiDownloadedFile} from "../ai/telegram-attachments"; import {StoredAttachment} from "../model/stored-attachment.js";
import {runUnifiedAi} from "../ai/unified-ai-runner"; import {AiDownloadedFile} from "../ai/telegram-attachments.js";
import {enqueueTelegramApiCall} from "./telegram-api-queue"; import {runUnifiedAi} from "../ai/unified-ai-runner.js";
import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock"; import {enqueueTelegramApiCall} from "./telegram-api-queue.js";
import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings"; import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock.js";
import {Localization} from "../common/localization"; import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings.js";
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target"; import {Localization} from "../common/localization.js";
import {RandomUtils} from "./random-utils"; import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target.js";
import {HtmlUtils} from "./html-utils"; import {RandomUtils} from "./random-utils.js";
import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner"; import {HtmlUtils} from "./html-utils.js";
import type {BoundaryValue, ErrorLike} from "../common/boundary-types"; import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner.js";
import {createStoredImageAttachment, photoCachePathForUniqueId, uniqueStoredAttachments} from "../common/stored-attachment-utils"; import type {BoundaryValue, ErrorLike} from "../common/boundary-types.js";
import {runTelegramMessageAttachmentPipeline} from "../ai/user-request-pipeline"; import {createStoredImageAttachment, photoCachePathForUniqueId, uniqueStoredAttachments} from "../common/stored-attachment-utils.js";
import {runTelegramMessageAttachmentPipeline} from "../ai/user-request-pipeline/index.js";
const imageProcessingSemaphore = new AsyncSemaphore(2); const imageProcessingSemaphore = new AsyncSemaphore(2);
const fileWriteLocks = new KeyedAsyncLock(); const fileWriteLocks = new KeyedAsyncLock();
@@ -1487,12 +1488,13 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText; const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText;
const imageNames = await loadImagesIfExists(msg); const imageNames = await loadImagesIfExists(msg);
const messageDownloads = includeDownloads ? downloads : []; const messageDownloads = includeDownloads ? downloads : [];
const storedImageAttachments = isStoredMessage(msg) const storedAttachments = isStoredMessage(msg)
? (msg.attachments ?? []).filter(attachment => attachment.kind === "image" && fs.existsSync(attachment.cachePath)) ? filterUserInputStoredAttachments(msg.attachments ?? []).filter(attachment => fs.existsSync(attachment.cachePath))
: []; : [];
const storedImageAttachments = storedAttachments.filter(attachment => attachment.kind === "image");
if (!cleanText && !quoteText && textRequired) return; if (!cleanText && !quoteText && textRequired) return;
if (!cleanText && !quoteText && !imageNames?.length && !storedImageAttachments.length && !messageDownloads.length) return; if (!cleanText && !quoteText && !imageNames?.length && !storedAttachments.length && !messageDownloads.length) return;
const fromId = isStoredMessage(msg) ? msg.fromId : msg.from?.id; const fromId = isStoredMessage(msg) ? msg.fromId : msg.from?.id;
const user = await UserStore.get(isStoredMessage(msg) ? msg.fromId : msg.from?.id ?? -1); const user = await UserStore.get(isStoredMessage(msg) ? msg.fromId : msg.from?.id ?? -1);
@@ -1527,11 +1529,19 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
}); });
const imageParts = [...photoImageParts, ...cachedImageParts]; const imageParts = [...photoImageParts, ...cachedImageParts];
const storedDocumentAttachments = storedAttachments.filter(attachment => attachment.kind === "document");
const storedVideoAttachments = storedAttachments.filter(attachment => attachment.kind === "video");
const storedVideoNoteAttachments = storedAttachments.filter(attachment => attachment.kind === "video-note");
const storedAudioAttachments = storedAttachments.filter(attachment => attachment.kind === "audio");
const audios: string[] = []; const audios: string[] = [];
const audioParts: MessageAudioPart[] = []; const audioParts: MessageAudioPart[] = [];
const documents: string[] = []; const documents: string[] = [];
const documentNames: string[] = [];
const videos: string[] = []; const videos: string[] = [];
const videoNames: string[] = [];
const videoNotes: string[] = []; const videoNotes: string[] = [];
const videoNoteNames: string[] = [];
if (messageDownloads.length) { if (messageDownloads.length) {
messageDownloads messageDownloads
@@ -1544,21 +1554,51 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
messageDownloads messageDownloads
.filter(d => d.kind === "document") .filter(d => d.kind === "document")
.forEach(d => documents.push(d.buffer.toString("base64"))); .forEach(d => {
documents.push(d.buffer.toString("base64"));
documentNames.push(d.fileName);
});
messageDownloads messageDownloads
.filter(d => d.kind === "video") .filter(d => d.kind === "video")
.forEach(v => videos.push(v.buffer.toString("base64"))); .forEach(v => {
videos.push(v.buffer.toString("base64"));
videoNames.push(v.fileName);
});
messageDownloads messageDownloads
.filter(d => d.kind === "video-note") .filter(d => d.kind === "video-note")
.forEach(v => { .forEach(v => {
const data = v.buffer.toString("base64"); const data = v.buffer.toString("base64");
videoNotes.push(data); videoNotes.push(data);
videoNoteNames.push(v.fileName);
audioParts.push({data, mimeType: mimeTypeFromAudioDownload(v)}); audioParts.push({data, mimeType: mimeTypeFromAudioDownload(v)});
}); });
} }
storedAudioAttachments.forEach(attachment => {
const data = Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64");
audios.push(data);
audioParts.push({data, mimeType: attachment.mimeType || "audio/ogg"});
});
storedDocumentAttachments.forEach(attachment => {
documents.push(Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64"));
documentNames.push(attachment.fileName);
});
storedVideoAttachments.forEach(attachment => {
videos.push(Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64"));
videoNames.push(attachment.fileName);
});
storedVideoNoteAttachments.forEach(attachment => {
const data = Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64");
videoNotes.push(data);
videoNoteNames.push(attachment.fileName);
audioParts.push({data, mimeType: attachment.mimeType || "video/mp4"});
});
const content = [ const content = [
quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "", quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "",
cleanText ?? "" cleanText ?? ""
@@ -1576,8 +1616,11 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
audios: audios.length ? audios : undefined, audios: audios.length ? audios : undefined,
audioParts: audioParts.length ? audioParts : undefined, audioParts: audioParts.length ? audioParts : undefined,
documents: documents.length ? documents : undefined, documents: documents.length ? documents : undefined,
documentNames: documentNames.length ? documentNames : undefined,
videos: videos.length ? videos : undefined, videos: videos.length ? videos : undefined,
videoNames: videoNames.length ? videoNames : undefined,
videoNotes: videoNotes.length ? videoNotes : undefined, videoNotes: videoNotes.length ? videoNotes : undefined,
videoNoteNames: videoNoteNames.length ? videoNoteNames : undefined,
}); });
} }
}; };
+24
View File
@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
const observability = await import("../dist/common/ai-observability.js");
test("ai observability snapshot counts recorded events", () => {
const before = observability.snapshotAiObservability();
observability.recordAiRequestStart();
observability.recordAiRequestFinish("succeeded");
observability.recordPipelineFallback("notify_user");
observability.recordToolCall();
observability.recordRagRun();
observability.recordTtsRun("skipped");
const after = observability.snapshotAiObservability();
assert.equal(after.requests.total, before.requests.total + 1);
assert.equal(after.requests.succeeded, before.requests.succeeded + 1);
assert.equal(after.fallbacks.notifyUser, before.fallbacks.notifyUser + 1);
assert.equal(after.toolCalls, before.toolCalls + 1);
assert.equal(after.ragRuns, before.ragRuns + 1);
assert.equal(after.ttsRuns.skipped, before.ttsRuns.skipped + 1);
});
+17
View File
@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
const {runSingleModelRequest} = await import("../dist/ai/model-call-stage.js");
test("single model request wrapper executes exactly once", async () => {
let calls = 0;
const result = await runSingleModelRequest({
async execute() {
calls += 1;
return "ok";
},
});
assert.equal(result, "ok");
assert.equal(calls, 1);
});
+33
View File
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
const {PipelineFallbackNotificationRegistry} = await import("../dist/ai/user-request-pipeline/fallback-notifier-registry.js");
const {resolvePipelineFallbackText} = await import("../dist/ai/user-request-pipeline/fallback-notifier-text.js");
test("pipeline fallback text maps notify_user to a user-facing message", () => {
assert.match(resolvePipelineFallbackText("document_rag", "notify_user"), /RAG/i);
assert.match(resolvePipelineFallbackText("speech_to_text", "notify_user"), /transcription/i);
assert.match(resolvePipelineFallbackText("tool_loop", "notify_user"), /tool/i);
});
test("pipeline fallback text is localized when locale is provided", () => {
assert.match(resolvePipelineFallbackText("document_rag", "notify_user", "ru"), /RAG|документ/i);
assert.match(resolvePipelineFallbackText("text_to_speech", "notify_user", "ua"), /аудіо|мовлення/i);
});
test("pipeline fallback text stays silent for continue_without_stage", () => {
assert.equal(resolvePipelineFallbackText("document_rag", "continue_without_stage"), undefined);
assert.equal(resolvePipelineFallbackText("tool_loop", "continue_without_stage"), undefined);
});
test("pipeline fallback notification registry deduplicates one request-stage-action", () => {
const registry = new PipelineFallbackNotificationRegistry();
const decision = {
stage: "tool_loop",
action: "notify_user",
};
assert.equal(registry.claim("request-1", decision), true);
assert.equal(registry.claim("request-1", decision), false);
assert.equal(registry.claim("request-2", decision), true);
});
+398
View File
@@ -0,0 +1,398 @@
import test from "node:test";
import assert from "node:assert/strict";
const {UserRequestPipeline} = await import("../dist/ai/user-request-pipeline/pipeline.js");
const {PIPELINE_ATTACHMENT_LIMIT_BYTES} = await import("../dist/ai/user-request-pipeline/types.js");
class FakeTelegramStreamMessage {
constructor() {
this.status = "";
this.text = "";
this.toolExecutions = [];
this.outputAttachments = [];
this.internalAttachments = [];
this.pipelineAudits = [];
this.finished = false;
this.failed = false;
}
setStatus(status) {
this.status = status;
}
clearStatus() {
this.status = "";
}
append(delta) {
this.text += delta;
}
replaceText(text) {
this.text = text;
}
getText() {
return this.text;
}
recordToolExecution(record) {
this.toolExecutions.push(record);
}
getToolExecutions() {
return [...this.toolExecutions];
}
recordOutputAttachment(record) {
this.outputAttachments.push(record);
}
getOutputAttachments() {
return [...this.outputAttachments];
}
async storeInternalAttachment(attachment) {
this.internalAttachments.push(attachment);
}
async storePipelineAudit(events) {
this.pipelineAudits.push(...events);
}
async finish() {
this.finished = true;
}
async fail() {
this.failed = true;
}
}
class FakeProviderAdapter {
constructor() {
this.calls = [];
}
async callModel(request, execute) {
this.calls.push(request);
return await execute();
}
appendToolResults(messages, calls, results) {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
content: results[index] ?? "",
});
}
}
}
class FakeMemoryStore {
constructor() {
this.rows = [];
}
persist(state) {
this.rows.push({
requestId: state.requestId,
audit: [...state.audit],
artifacts: [...state.artifacts],
outputAttachments: [...state.outputAttachments],
});
}
}
function createBaseState() {
return {
requestId: "integration-request-1",
chatId: 10,
messageId: 20,
fromId: 30,
receivedAt: new Date().toISOString(),
text: "process my attachments",
settings: {
provider: "OLLAMA",
responseLanguage: "en",
voiceMode: "execute",
imageOutputMode: "photo",
},
inputAttachments: [],
outputAttachments: [],
artifacts: [],
toolRankDecisions: [],
audit: [],
};
}
function artifact(kind, stage, extra = {}) {
return {
kind,
stage,
createdAt: "2026-05-18T00:00:00.000Z",
...extra,
};
}
function outputAttachment(fileName, kind = "file") {
return {
direction: "output",
kind,
fileId: `${fileName}-file-id`,
fileName,
sizeBytes: 1024,
cachePath: `/tmp/${fileName}`,
};
}
test("integration pipeline rejects oversized attachment before later stages", async () => {
const stream = new FakeTelegramStreamMessage();
const state = createBaseState();
state.inputAttachments.push({
direction: "input",
kind: "document",
fileId: "doc-oversized",
fileName: "big.pdf",
sizeBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES + 1,
cachePath: "/tmp/big.pdf",
});
const pipeline = new UserRequestPipeline({
stages: [{
name: "input_size_gate",
async run() {
stream.setStatus("Checking size");
const tooLarge = state.inputAttachments.some(attachment => attachment.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES);
stream.clearStatus();
return {
stage: "input_size_gate",
status: tooLarge ? "fallback" : "succeeded",
fallbackAction: tooLarge ? "notify_user" : undefined,
};
},
}],
stageNames: ["input_size_gate"],
});
await pipeline.run(state, new AbortController().signal);
assert.equal(state.audit.at(-1)?.status, "fallback");
assert.equal(state.audit.at(-1)?.details?.fallbackAction, "notify_user");
assert.equal(stream.status, "");
});
test("integration pipeline carries artifacts through fake document, voice, tool and tts stages", async () => {
const stream = new FakeTelegramStreamMessage();
const adapter = new FakeProviderAdapter();
const store = new FakeMemoryStore();
const state = createBaseState();
state.inputAttachments.push(
{
direction: "input",
kind: "document",
fileId: "doc-1",
fileName: "contract.pdf",
sizeBytes: 1024,
cachePath: "/tmp/contract.pdf",
},
{
direction: "input",
kind: "audio",
fileId: "audio-1",
fileName: "voice.ogg",
sizeBytes: 2048,
cachePath: "/tmp/voice.ogg",
},
);
const pipeline = new UserRequestPipeline({
stages: [
{
name: "input_size_gate",
async run() {
return {
stage: "input_size_gate",
status: "succeeded",
};
},
},
{
name: "document_rag",
async run() {
stream.setStatus("RAG");
stream.clearStatus();
return {
stage: "document_rag",
status: "succeeded",
artifacts: [artifact("rag", "document_rag", {
provider: "OLLAMA",
sourceAttachmentIds: ["doc-1"],
extractedText: "contract text",
})],
};
},
},
{
name: "speech_to_text",
async run() {
return {
stage: "speech_to_text",
status: "succeeded",
artifacts: [artifact("transcript", "speech_to_text", {
text: "transcribed voice",
sourceAttachmentIds: ["audio-1"],
model: "fake-stt",
})],
};
},
},
{
name: "model_call",
async run() {
const reply = await adapter.callModel({provider: "OLLAMA", model: "fake-model"}, async () => {
stream.append("final answer");
return "final answer";
});
return {
stage: "model_call",
status: "succeeded",
artifacts: [artifact("final_text", "model_call", {
text: reply,
})],
};
},
},
{
name: "tool_loop",
async run() {
const calls = [{id: "tool-call-1", name: "read_file", argumentsText: "{\"path\":\"docs/a.md\"}"}];
const results = ["tool result"];
adapter.appendToolResults([], calls, results);
stream.recordToolExecution({
toolName: "read_file",
callId: "tool-call-1",
argumentsText: "{\"path\":\"docs/a.md\"}",
resultChars: results[0].length,
startedAt: "2026-05-18T00:00:00.000Z",
finishedAt: "2026-05-18T00:00:01.000Z",
});
return {
stage: "tool_loop",
status: "succeeded",
artifacts: [artifact("tool_result", "tool_loop", {
toolName: "read_file",
callId: "tool-call-1",
resultText: results[0],
})],
};
},
},
{
name: "persist_output_artifacts",
async run() {
const generatedFile = outputAttachment("report.txt", "file");
stream.recordOutputAttachment({
artifactKind: "generated_file",
fileName: generatedFile.fileName,
mimeType: "text/plain",
sizeBytes: generatedFile.sizeBytes,
messageId: 321,
});
return {
stage: "persist_output_artifacts",
status: "succeeded",
artifacts: [artifact("generated_file", "persist_output_artifacts", {
attachmentId: generatedFile.fileId,
})],
attachments: [generatedFile],
};
},
},
{
name: "text_to_speech",
async run() {
stream.recordOutputAttachment({
artifactKind: "tts_audio",
fileName: "answer.ogg",
mimeType: "audio/ogg",
sizeBytes: 4096,
messageId: 322,
});
return {
stage: "text_to_speech",
status: "succeeded",
artifacts: [artifact("tts_audio", "text_to_speech", {
attachmentId: "tts-audio-id",
})],
attachments: [outputAttachment("answer.ogg", "audio")],
};
},
},
{
name: "audit_finish",
async run() {
store.persist(state);
return {
stage: "audit_finish",
status: "succeeded",
};
},
},
],
stageNames: [
"input_size_gate",
"document_rag",
"speech_to_text",
"model_call",
"tool_loop",
"persist_output_artifacts",
"text_to_speech",
"audit_finish",
],
});
await pipeline.run(state, new AbortController().signal);
assert.equal(adapter.calls.length, 1);
assert.equal(stream.getText(), "final answer");
assert.equal(stream.getToolExecutions().length, 1);
assert.equal(stream.getOutputAttachments().length, 2);
assert.equal(state.artifacts.some(entry => entry.kind === "rag"), true);
assert.equal(state.artifacts.some(entry => entry.kind === "transcript"), true);
assert.equal(state.artifacts.some(entry => entry.kind === "final_text"), true);
assert.equal(state.artifacts.some(entry => entry.kind === "tool_result"), true);
assert.equal(state.artifacts.some(entry => entry.kind === "generated_file"), true);
assert.equal(state.artifacts.some(entry => entry.kind === "tts_audio"), true);
assert.equal(store.rows.length, 1);
assert.equal(store.rows[0].artifacts.length >= 6, true);
});
test("integration pipeline stops on fail_request fallback", async () => {
const stream = new FakeTelegramStreamMessage();
const state = createBaseState();
const pipeline = new UserRequestPipeline({
stages: [{
name: "input_size_gate",
async run() {
stream.setStatus("Boom");
throw new Error("boom");
},
}],
stageNames: ["input_size_gate", "document_rag"],
fallbackPolicies: [{
stage: "input_size_gate",
onUnavailable: "fail_request",
onFailed: "fail_request",
}],
});
await assert.rejects(() => pipeline.run(state, new AbortController().signal), /PipelineRequestFailure/);
assert.equal(state.audit.some(entry => entry.stage === "document_rag"), false);
});
+83
View File
@@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
const {
extractOpenAiToolCalls,
extractOpenAiStreamingToolCalls,
extractOpenAiTextDelta,
extractMistralToolCalls,
extractMistralTextDelta,
extractOllamaToolCalls,
extractOllamaTextDelta,
} = await import("../dist/ai/provider-adapter-contract.js");
test("openai contract extracts text delta and function calls", () => {
assert.equal(extractOpenAiTextDelta({type: "response.output_text.delta", delta: "hello"}), "hello");
const calls = extractOpenAiToolCalls({
output: [{
type: "function_call",
call_id: "call-1",
name: "read_file",
arguments: "{\"path\":\"src/index.ts\"}",
}],
});
assert.equal(calls.length, 1);
assert.equal(calls[0].id, "call-1");
assert.equal(calls[0].name, "read_file");
const streamed = extractOpenAiStreamingToolCalls({
type: "response.output_item.added",
item: {
type: "function_call",
id: "call-2",
name: "search_files",
arguments: "{\"query\":\"sendMessage\"}",
},
});
assert.equal(streamed.length, 1);
assert.equal(streamed[0].id, "call-2");
assert.equal(streamed[0].name, "search_files");
});
test("mistral contract extracts content and tool calls", () => {
assert.equal(extractMistralTextDelta({
content: [{text: "hello"}, {text: " world"}],
}), "hello world");
const calls = extractMistralToolCalls({
toolCalls: [{
id: "m-1",
function: {
name: "get_weather",
arguments: {location: "Moscow"},
},
}],
});
assert.equal(calls.length, 1);
assert.equal(calls[0].id, "m-1");
assert.equal(calls[0].name, "get_weather");
});
test("ollama contract extracts content and tool calls", () => {
assert.equal(extractOllamaTextDelta({
message: {content: "hello from ollama"},
}), "hello from ollama");
const calls = extractOllamaToolCalls({
tool_calls: [{
id: "o-1",
function: {
name: "web_search",
arguments: {query: "openai docs"},
},
}],
});
assert.equal(calls.length, 1);
assert.equal(calls[0].id, "o-1");
assert.equal(calls[0].name, "web_search");
});
+47 -114
View File
@@ -1,32 +1,13 @@
import test, {after} from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-rag-")); const {
process.env.BOT_TOKEN = process.env.BOT_TOKEN ?? "test-token"; buildRagArtifactPayload,
process.env.CREATOR_ID = process.env.CREATOR_ID ?? "1"; } = await import("../dist/ai/rag-artifact-payload.js");
process.env.DATA_PATH = tempRoot; const {
process.env.DB_PATH = `file:${path.join(tempRoot, "test.sqlite")}`; filterUserVisibleStoredAttachments,
process.env.TEST_ENVIRONMENT = "true"; } = await import("../dist/common/attachment-visibility.js");
const {Environment} = await import("../dist/common/environment.js");
Environment.load();
const {DatabaseManager} = await import("../dist/db/database-manager.js");
DatabaseManager.init();
await DatabaseManager.ready;
const {ArtifactStore} = await import("../dist/common/artifact-store.js");
const {filterUserVisibleStoredAttachments} = await import("../dist/common/stored-attachment-utils.js");
const {AiProvider} = await import("../dist/model/ai-provider.js"); const {AiProvider} = await import("../dist/model/ai-provider.js");
const {persistRagArtifactAttachment} = await import("../dist/ai/rag-artifact-store.js");
after(async () => {
await DatabaseManager.close().catch(() => undefined);
fs.rmSync(tempRoot, {recursive: true, force: true});
});
test("internal artifacts are not treated as user-visible attachments", () => { test("internal artifacts are not treated as user-visible attachments", () => {
const visible = filterUserVisibleStoredAttachments([ const visible = filterUserVisibleStoredAttachments([
@@ -50,105 +31,57 @@ test("internal artifacts are not treated as user-visible attachments", () => {
assert.equal(visible[0].fileId, "visible"); assert.equal(visible[0].fileId, "visible");
}); });
test("RAG artifacts persist structured ollama metadata", async () => { test("RAG artifact payload keeps ollama retrieval metadata", () => {
const chatId = 42; const payload = buildRagArtifactPayload({
const messageId = 7;
const attachment = await persistRagArtifactAttachment({
provider: AiProvider.OLLAMA, provider: AiProvider.OLLAMA,
prepared: { createdAt: "2026-01-01T00:00:00.000Z",
provider: AiProvider.OLLAMA, sources: [{
prepared: true,
cleanup: async () => undefined,
artifact: {
query: "What is in the file?",
extractedDocuments: [
{documentIndex: 0, fileName: "report.txt", textChars: 120},
],
selectedChunks: [
{
sourceId: "doc1-1",
documentIndex: 0,
documentName: "report.txt",
chunkIndex: 0,
chunkCount: 1,
textChars: 120,
score: 0.91,
},
],
skippedDocuments: [
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
],
providerState: {
embeddingModel: "nomic-embed-text:latest",
topK: 8,
chunkSize: 1400,
chunkOverlap: 220,
maxContextChars: 14000,
minScore: 0.12,
maxArchiveFiles: 200,
maxArchiveBytes: 50 * 1024 * 1024,
maxArchiveDepth: 2,
},
},
},
downloads: [{
kind: "document",
fileId: "file-1", fileId: "file-1",
fileName: "report.txt", fileName: "report.txt",
buffer: Buffer.from("hello world"), mimeType: "text/plain",
path: path.join(tempRoot, "report.txt"), sizeBytes: 12,
sha256: "abc123",
uploadedFileId: "uploaded-1",
}], }],
chatId, providerState: {
messageId, provider: AiProvider.OLLAMA,
details: { prepared: true,
embeddingModel: "nomic-embed-text:latest", embeddingModel: "nomic-embed-text:latest",
topK: 8, topK: 8,
chunkSize: 1400, chunkSize: 1400,
chunkOverlap: 220, chunkOverlap: 220,
maxContextChars: 14000, maxContextChars: 14000,
artifact: { extractedDocuments: [
query: "What is in the file?", {documentIndex: 0, fileName: "report.txt", textChars: 120},
extractedDocuments: [ ],
{documentIndex: 0, fileName: "report.txt", textChars: 120}, selectedChunks: [
], {
selectedChunks: [ sourceId: "doc1-1",
{ documentIndex: 0,
sourceId: "doc1-1", documentName: "report.txt",
documentIndex: 0, chunkIndex: 0,
documentName: "report.txt", chunkCount: 1,
chunkIndex: 0, textChars: 120,
chunkCount: 1, score: 0.91,
textChars: 120,
score: 0.91,
},
],
skippedDocuments: [
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
],
providerState: {
embeddingModel: "nomic-embed-text:latest",
topK: 8,
chunkSize: 1400,
chunkOverlap: 220,
maxContextChars: 14000,
minScore: 0.12,
maxArchiveFiles: 200,
maxArchiveBytes: 50 * 1024 * 1024,
maxArchiveDepth: 2,
}, },
}, ],
skippedDocuments: [
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
],
minScore: 0.12,
maxArchiveFiles: 200,
maxArchiveBytes: 50 * 1024 * 1024,
maxArchiveDepth: 2,
query: "What is in the file?",
}, },
}); });
assert.equal(attachment?.artifactKind, "rag"); assert.equal(payload.artifactKind, "rag");
assert.equal(fs.existsSync(attachment.cachePath), true); assert.equal(payload.provider, AiProvider.OLLAMA);
assert.equal(payload.sources[0].uploadedFileId, "uploaded-1");
const stored = await ArtifactStore.getByMessage(chatId, messageId); assert.equal(payload.providerState.provider, AiProvider.OLLAMA);
assert.equal(stored.length, 1); assert.equal(payload.providerState.query, "What is in the file?");
assert.equal(stored[0].kind, "rag"); assert.equal(payload.providerState.selectedChunks[0].score, 0.91);
assert.equal(stored[0].payload.providerState.query, "What is in the file?"); assert.equal(payload.providerState.skippedDocuments[0].reason, "unsupported format");
assert.equal(stored[0].payload.providerState.selectedChunks[0].score, 0.91); assert.equal(payload.providerState.embeddingModel, "nomic-embed-text:latest");
assert.equal(stored[0].payload.providerState.skippedDocuments[0].reason, "unsupported format");
assert.equal(stored[0].payload.providerState.ollama.embeddingModel, "nomic-embed-text:latest");
}); });
+53
View File
@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
const {buildStaleRagCleanupPlan} = await import("../dist/ai/rag-retention-planner.js");
test("stale rag cleanup plan selects only older rag artifacts", () => {
const plan = buildStaleRagCleanupPlan([
{
id: "recent-openai",
createdAt: "2026-05-18T00:00:00.000Z",
payload: JSON.stringify({
artifactKind: "rag",
providerState: {
provider: "OPENAI",
vectorStoreIds: ["vs_1"],
uploadedFileIds: ["file_1"],
},
}),
},
{
id: "stale-openai",
createdAt: "2026-04-01T00:00:00.000Z",
payload: JSON.stringify({
artifactKind: "rag",
providerState: {
provider: "OPENAI",
vectorStoreIds: ["vs_2"],
uploadedFileIds: ["file_2"],
},
}),
},
{
id: "stale-ollama",
createdAt: "2026-04-01T00:00:00.000Z",
payload: JSON.stringify({
artifactKind: "rag",
providerState: {
provider: "OLLAMA",
prepared: true,
},
}),
},
], 14, new Date("2026-05-18T00:00:00.000Z"));
assert.equal(plan.targets.length, 1);
assert.deepEqual(plan.targets[0], {
artifactId: "stale-openai",
createdAt: "2026-04-01T00:00:00.000Z",
provider: "OPENAI",
vectorStoreIds: ["vs_2"],
uploadedFileIds: ["file_2"],
});
});
+131
View File
@@ -0,0 +1,131 @@
import test from "node:test";
import assert from "node:assert/strict";
const {filterUserInputStoredAttachments} = await import("../dist/common/attachment-visibility.js");
const {mergeReplyChainDownloads, shouldPreferCurrentDownloads} = await import("../dist/ai/reply-chain-downloads.js");
test("reply chain attachment visibility keeps only user input attachments", () => {
const attachments = filterUserInputStoredAttachments([
{
kind: "document",
fileId: "user-doc",
fileName: "user.txt",
cachePath: "/tmp/user.txt",
scope: "user_input",
},
{
kind: "document",
fileId: "bot-doc",
fileName: "bot.txt",
cachePath: "/tmp/bot.txt",
scope: "bot_output",
},
{
kind: "document",
fileId: "internal-doc",
fileName: "internal.json",
cachePath: "/tmp/internal.json",
scope: "internal_artifact",
},
]);
assert.equal(attachments.length, 1);
assert.equal(attachments[0].fileId, "user-doc");
});
test("reply chain downloads keep current input first and deduplicate chain copies", () => {
const merged = mergeReplyChainDownloads(
[
{
kind: "document",
fileId: "new-doc",
fileName: "new.txt",
buffer: Buffer.from("new"),
path: "/tmp/new.txt",
},
{
kind: "document",
fileId: "shared-doc",
fileName: "shared.txt",
buffer: Buffer.from("current"),
path: "/tmp/current-shared.txt",
},
],
[
{
kind: "document",
fileId: "shared-doc",
fileName: "shared.txt",
buffer: Buffer.from("reply-chain"),
path: "/tmp/reply-shared.txt",
},
{
kind: "document",
fileId: "old-doc",
fileName: "old.txt",
buffer: Buffer.from("old"),
path: "/tmp/old.txt",
},
],
);
assert.equal(merged.length, 3);
assert.equal(merged[0].fileId, "new-doc");
assert.equal(merged[1].fileId, "shared-doc");
assert.equal(merged[2].fileId, "old-doc");
});
test("reply chain downloads are used when there is no new document", () => {
const merged = mergeReplyChainDownloads([], [
{
kind: "document",
fileId: "reply-doc",
fileName: "reply.txt",
buffer: Buffer.from("reply"),
path: "/tmp/reply.txt",
},
]);
assert.equal(merged.length, 1);
assert.equal(merged[0].fileId, "reply-doc");
});
test("reply chain prefers current downloads when user points to this file", () => {
assert.equal(
shouldPreferCurrentDownloads("Please answer about this file", [{
kind: "document",
fileId: "new-doc",
fileName: "new.txt",
buffer: Buffer.from("new"),
path: "/tmp/new.txt",
}]),
true,
);
assert.equal(
shouldPreferCurrentDownloads("ответь по этому файлу", [{
kind: "document",
fileId: "new-doc",
fileName: "new.txt",
buffer: Buffer.from("new"),
path: "/tmp/new.txt",
}]),
false,
);
assert.equal(
shouldPreferCurrentDownloads("ответь на этот файл", [{
kind: "document",
fileId: "new-doc",
fileName: "new.txt",
buffer: Buffer.from("new"),
path: "/tmp/new.txt",
}]),
true,
);
assert.equal(
shouldPreferCurrentDownloads("this file", []),
false,
);
});
+21
View File
@@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
const {summarizeModelOutput} = await import("../dist/ai/response-model-output.js");
test("model output summary trims text and copies attachment records", () => {
const toolExecutions = [{toolName: "read_file", callId: "1", argumentsText: "{}", resultChars: 10}];
const outputAttachments = [{artifactKind: "generated_file", fileName: "out.txt", sizeBytes: 12}];
const summary = summarizeModelOutput({
text: " hello world ",
toolExecutions,
outputAttachments,
});
assert.deepEqual(summary, {
text: "hello world",
toolExecutions,
outputAttachments,
});
});
+40
View File
@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
const {decideToolLoopContinuation} = await import("../dist/ai/tool-loop-control.js");
test("tool loop continuation stops when there are no tool calls", () => {
const decision = decideToolLoopContinuation({
round: 0,
maxRounds: 3,
toolCalls: [],
});
assert.equal(decision.continue, false);
assert.equal(decision.reason, "no_tool_calls");
assert.equal(decision.remainingRounds, 2);
});
test("tool loop continuation stops on the last allowed round", () => {
const decision = decideToolLoopContinuation({
round: 2,
maxRounds: 3,
toolCalls: [{id: "call-1", name: "read_file", argumentsText: "{}"}],
});
assert.equal(decision.continue, false);
assert.equal(decision.reason, "max_rounds_reached");
assert.equal(decision.remainingRounds, 0);
});
test("tool loop continuation allows further rounds when tools remain and rounds are left", () => {
const decision = decideToolLoopContinuation({
round: 1,
maxRounds: 3,
toolCalls: [{id: "call-1", name: "read_file", argumentsText: "{}"}],
});
assert.equal(decision.continue, true);
assert.equal(decision.reason, undefined);
assert.equal(decision.remainingRounds, 1);
});
+37
View File
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
const {runToolLoopRounds} = await import("../dist/ai/tool-loop-runner.js");
test("tool loop runner stops when handler requests it", async () => {
const rounds = [];
await runToolLoopRounds({
maxRounds: 5,
async onRound(round) {
rounds.push(round);
return {shouldContinue: round < 1};
},
});
assert.deepEqual(rounds, [0, 1]);
});
test("tool loop runner calls max rounds hook when handler never stops", async () => {
const rounds = [];
let maxRoundsReached = -1;
await runToolLoopRounds({
maxRounds: 3,
async onRound(round) {
rounds.push(round);
return {shouldContinue: true};
},
onMaxRoundsReached(round) {
maxRoundsReached = round;
},
});
assert.deepEqual(rounds, [0, 1, 2]);
assert.equal(maxRoundsReached, 2);
});
+47
View File
@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
const {summarizeToolLoop} = await import("../dist/ai/tool-loop-summary.js");
test("tool loop summary skips empty tool execution batches", () => {
const summary = summarizeToolLoop({
text: "answer",
executions: [],
outputAttachments: [],
});
assert.equal(summary.status, "skipped");
assert.equal(summary.fallbackAction, "continue_without_stage");
assert.equal(summary.details.count, 0);
assert.deepEqual(summary.details.tools, []);
assert.deepEqual(summary.details.modelOutput, {
text: "answer",
toolExecutions: [],
outputAttachments: [],
});
assert.equal(summary.artifacts, undefined);
});
test("tool loop summary reports executions and summary artifact", () => {
const summary = summarizeToolLoop({
text: "answer",
executions: [{
toolName: "read_file",
callId: "call-1",
argumentsText: "{}",
resultChars: 12,
}],
outputAttachments: [],
});
assert.equal(summary.status, "succeeded");
assert.equal(summary.fallbackAction, undefined);
assert.equal(summary.details.count, 1);
assert.deepEqual(summary.details.tools, [{
toolName: "read_file",
callId: "call-1",
resultChars: 12,
}]);
assert.equal(summary.artifacts?.[0]?.kind, "tool_result");
assert.equal(summary.artifacts?.[0]?.stage, "tool_loop");
});
+126
View File
@@ -0,0 +1,126 @@
import test from "node:test";
import assert from "node:assert/strict";
const {runToolRankStage} = await import("../dist/ai/tool-rank-stage.js");
function createStreamMessage() {
const events = [];
const state = {
status: "",
events,
setStatus(value) {
state.status = value;
},
clearStatus() {
state.status = "";
},
async flush() {},
async storePipelineAudit(batch) {
events.push(...batch);
},
};
return state;
}
function createAuditRecorder() {
const events = [];
return {
events,
async storeAudit(params) {
events.push({
stage: "tool_rank",
status: params.error ? "failed" : "succeeded",
details: {
round: params.round,
availableTools: params.availableTools,
selectedTools: params.selectedTools ?? [],
usedRanker: params.usedRanker ?? false,
toolRankDecision: {
provider: params.provider,
round: params.round,
availableTools: params.availableTools,
selectedTools: params.selectedTools ?? [],
usedRanker: params.usedRanker ?? false,
},
},
});
},
};
}
test("tool rank stage clears status after success and stores decision audit", async () => {
const streamMessage = createStreamMessage();
const audit = createAuditRecorder();
const result = await runToolRankStage({
provider: "OLLAMA",
model: "test-model",
round: 0,
config: {
toolRankerFallbackPolicy: "NO_TOOLS",
},
availableTools: [{name: "read_file"}],
messages: [{role: "user", content: "прочитай src/index.ts"}],
streamMessage,
signal: new AbortController().signal,
storeAudit: audit.storeAudit,
toolRanker: {
async selectTools() {
return {
toolNames: ["read_file"],
usedRanker: true,
};
},
},
});
assert.deepEqual(result.selectedToolNames, ["read_file"]);
assert.deepEqual(result.filteredTools, [{name: "read_file"}]);
assert.equal(result.usedRanker, true);
assert.equal(streamMessage.status, "");
assert.equal(audit.events.length, 1);
assert.equal(audit.events[0].stage, "tool_rank");
assert.equal(audit.events[0].status, "succeeded");
assert.deepEqual(audit.events[0].details.toolRankDecision, {
provider: "OLLAMA",
round: 0,
availableTools: ["read_file"],
selectedTools: ["read_file"],
usedRanker: true,
});
});
test("tool rank stage clears status after failure", async () => {
const streamMessage = createStreamMessage();
const audit = createAuditRecorder();
await assert.rejects(() => runToolRankStage({
provider: "OLLAMA",
model: "test-model",
round: 1,
config: {
toolRankerFallbackPolicy: "NO_TOOLS",
},
availableTools: [{name: "read_file"}],
messages: [{role: "user", content: "прочитай src/index.ts"}],
streamMessage,
signal: new AbortController().signal,
storeAudit: audit.storeAudit,
toolRanker: {
async selectTools() {
throw new Error("ranker failed");
},
},
}), /ranker failed/);
assert.equal(streamMessage.status, "");
assert.equal(audit.events.length, 1);
assert.equal(audit.events[0].stage, "tool_rank");
assert.equal(audit.events[0].status, "failed");
assert.deepEqual(audit.events[0].details.toolRankDecision, {
provider: "OLLAMA",
round: 1,
availableTools: ["read_file"],
selectedTools: [],
usedRanker: false,
});
});
+69
View File
@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
const {ToolRankerFallbackPolicy} = await import("../dist/common/policies.js");
const {
decideToolRankerFallback,
resolveToolRankerFallbackSelection,
} = await import("../dist/ai/tool-ranker-fallback.js");
const availableToolNames = ["read_file", "search_files"];
test("tool ranker fallback returns no tools when policy is NO_TOOLS", () => {
assert.deepEqual(
resolveToolRankerFallbackSelection({
fallbackPolicy: ToolRankerFallbackPolicy.NO_TOOLS,
availableToolNames,
}),
{
toolNames: [],
usedRanker: false,
},
);
});
test("tool ranker fallback returns all tools when policy is ALL_TOOLS", () => {
assert.deepEqual(
resolveToolRankerFallbackSelection({
fallbackPolicy: ToolRankerFallbackPolicy.ALL_TOOLS,
availableToolNames,
}),
{
toolNames: ["read_file", "search_files"],
usedRanker: false,
},
);
});
test("tool ranker fallback decision uses executor semantics", () => {
assert.deepEqual(
decideToolRankerFallback({
fallbackPolicy: ToolRankerFallbackPolicy.MAIN_MODEL,
availableToolNames,
reason: "failed",
}),
{
stage: "tool_rank",
reason: "failed",
action: "use_alternate_target",
shouldContinue: true,
shouldNotifyUser: false,
shouldFailRequest: false,
toolNames: ["read_file", "search_files"],
usedRanker: false,
},
);
});
test("tool ranker fallback keeps all tools when policy is MAIN_MODEL", () => {
assert.deepEqual(
resolveToolRankerFallbackSelection({
fallbackPolicy: ToolRankerFallbackPolicy.MAIN_MODEL,
availableToolNames,
}),
{
toolNames: ["read_file", "search_files"],
usedRanker: false,
},
);
});