25 Commits

Author SHA1 Message Date
melod1n 46a99605e6 Add OpenAI compatible chat backend 2026-05-22 20:52:35 +03:00
melod1n 321d185592 bump versions 2026-05-21 17:05:33 +03:00
melod1n a3f19f0413 Fix startup schema migration deadlock 2026-05-19 17:58:09 +03:00
melod1n c613c636e1 Add local tool filtering 2026-05-19 08:33:18 +03:00
melod1n 7f5011b871 bump libs 2026-05-19 01:53:57 +03:00
melod1n 5b67e23060 shitton 2026-05-19 01:46:12 +03:00
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
109 changed files with 7212 additions and 1419 deletions
+25
View File
@@ -43,6 +43,18 @@ ONLY_FOR_CREATOR_MODE=false
# Use user names in AI prompts
USE_NAMES_IN_PROMPT=true
# Disable all built-in local tools and keep only MCP tools
DISABLE_LOCAL_TOOLS=false
# Filter built-in local tools by name.
# LOCAL_TOOL_ALLOWLIST lets through only the listed tools.
# LOCAL_TOOL_DENYLIST removes the listed tools.
# Examples:
# LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
# LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
LOCAL_TOOL_ALLOWLIST=
LOCAL_TOOL_DENYLIST=
# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md)
SYSTEM_PROMPT=
@@ -91,6 +103,10 @@ OLLAMA_MAX_CONCURRENT_REQUESTS=1
# OpenAI
OPENAI_API_KEY=
OPENAI_BASE_URL=
# Backend mode:
# official = OpenAI responses API
# compatible = OpenAI-compatible chat.completions servers like llama.cpp
OPENAI_BACKEND=official
OPENAI_MODEL=gpt-4.1-nano
OPENAI_IMAGE_MODEL=gpt-image-1-mini
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
@@ -99,6 +115,14 @@ OPENAI_TTS_VOICE=alloy
OPENAI_TTS_INSTRUCTIONS=
OPENAI_MAX_CONCURRENT_REQUESTS=3
# MCP servers
# JSON array or {"mcpServers": {"name": {...}}}
# Stdio example:
# MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
# HTTP example:
# MCP_SERVERS=[{"name":"remote-tools","transport":"http","url":"https://example.com/mcp"}]
MCP_SERVERS=
# Per-capability AI endpoint overrides
# Pattern:
# <PROVIDER>_<CAPABILITY>_MODEL=
@@ -113,6 +137,7 @@ OPENAI_MAX_CONCURRENT_REQUESTS=3
# OLLAMA_ADDRESS or OLLAMA_BASE_URL.
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
# TRANSCRIPTION, STT, TTS.
# Backend override: OPENAI_BACKEND=official|compatible.
#
# Examples:
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
+314
View File
@@ -0,0 +1,314 @@
# OPENAI Compatible Target Implementation
## Purpose
Add a separate execution path for OpenAI-compatible backends such as `llama.cpp`, while keeping the current official OpenAI path unchanged.
## Checklist
- [x] Add explicit OpenAI backend mode in config
- [x] Route OpenAI requests to separate official and compatible runners
- [x] Keep official OpenAI on `responses.create(...)`
- [x] Add compatible `chat.completions.create(...)` runner
- [x] Add compatible tool-call extractors
- [x] Add backend selection tests
- [x] Add basic memory/config regression coverage
- [x] Normalize compatible streaming tool-call assembly
- [x] Preserve file upload behavior in compatible backend
- [x] Guard unsupported OpenAI-only tools for compatible backend
- [x] Add environment docs and example config entries
- [x] Add real-server integration coverage for compatible backend
- [x] Revisit shared orchestration extraction for further deduplication
## Non-Goals
1. Do not change the current official OpenAI `responses.create(...)` behavior.
2. Do not auto-switch behavior only because `OPENAI_BASE_URL` is set.
3. Do not merge compatible backend quirks into the official OpenAI runner.
4. Do not remove or weaken existing tool ranking, memory, RAG, logging, or upload behavior.
## Current State
1. `src/ai/unified-ai-runner.openai.ts` currently uses the official `responses` API.
2. `src/ai/provider-adapters.ts` already has provider-specific adapters and tool/result mapping.
3. `src/ai/provider-adapter-contract.ts` already contains `responses`-style extractors.
4. `src/ai/openai-chat-message.ts` currently models `responses`-style messages, not `chat.completions` tool messages.
5. `src/ai/unified-ai-request-pipeline.ts` prepares chat context and runtime state before the model call.
6. `src/ai/ai-runtime-target.ts` resolves provider targets, base URLs, models, and keys.
7. `src/ai/unified-ai-runner.tool-ranker.ts` already uses a `chat.completions`-style call path, which is closer to compatible backends.
## Target Architecture
1. Official OpenAI backend stays on `responses.create(...)`.
2. Compatible OpenAI backend uses `chat.completions.create(...)`.
3. Backend selection is explicit through config, for example `OPENAI_BACKEND=official|compatible`.
4. Shared preparation logic remains common.
5. Transport-specific request formatting and response parsing are split.
## Configuration Design
1. Add a new config value `OPENAI_BACKEND`.
2. Allowed values should be `official` and `compatible`.
3. Default must be `official`.
4. Keep `OPENAI_BASE_URL` as a transport setting only.
5. `OPENAI_BASE_URL` must not imply compatible mode by itself.
6. Extend environment schema and runtime config to expose this value.
7. Update env docs and example env files.
## Step 1: Config and Target Selection
1. Update `src/common/environment.ts`.
2. Add a new environment field for backend mode.
3. Add setters if the codebase uses runtime env mutation in tests.
4. Update the startup schema and runtime snapshot.
5. Add tests for default value and explicit `compatible` selection.
Expected result:
- Official OpenAI stays unchanged by default.
- Explicit `OPENAI_BACKEND=compatible` selects the new execution path.
## Step 2: Split Runner Selection
1. Update the unified AI execution entry point.
2. Add a small backend selector for OpenAI targets.
3. Route official mode to the current runner.
4. Route compatible mode to a new compatible runner.
5. Keep other providers untouched.
Expected result:
- One codepath for official OpenAI.
- One codepath for OpenAI-compatible servers.
## Step 3: Shared Orchestration Extraction
1. Identify logic that is identical for both OpenAI branches.
2. Extract common orchestration into a shared helper where possible.
3. Keep these pieces shared:
- memory prompt injection
- tool ranking
- tool loop control
- logging and timing
- cancellation handling
- file upload post-processing
- document RAG preparation and cleanup
4. Keep transport-specific pieces separate:
- request shape
- response parsing
- tool result message shape
- streaming event parsing
Expected result:
- Less duplicate logic.
- Cleaner separation between official and compatible behavior.
## Step 4: Compatible Message Model
1. Update `src/ai/openai-chat-message.ts` or create a sibling type file for compatible chat messages.
2. Model `system`, `user`, `assistant`, and `tool` roles explicitly.
3. Support `tool_calls` on assistant messages.
4. Support `tool_call_id` on tool result messages.
5. Preserve support for text and multimodal user content where the backend supports it.
6. Avoid forcing `responses` output types into `chat.completions`.
Expected result:
- Compatible runner can build valid `chat.completions` message arrays.
## Step 5: Compatible Contract Extractors
1. Extend `src/ai/provider-adapter-contract.ts`.
2. Add extractors for `chat.completions` tool calls.
3. Add extractors for `chat.completions` streaming tool call deltas.
4. Keep existing `responses` extractors intact.
5. Normalize tool call IDs, names, and argument text the same way as existing extractors.
6. Ensure arguments are always represented as JSON text for the tool loop.
Expected result:
- Compatible runner can parse tool calls from both normal and streaming responses.
## Step 6: Compatible Provider Adapter
1. Update `src/ai/provider-adapters.ts`.
2. Add a separate adapter or branch for OpenAI-compatible chat.completions behavior.
3. Reuse existing tool ranking where safe.
4. Make `appendToolResults(...)` emit `role: "tool"` messages with `tool_call_id`.
5. Keep official OpenAI adapter outputting `function_call_output`.
6. Keep Mistral and Ollama unchanged.
Expected result:
- Each backend uses the tool result shape it expects.
## Step 7: Compatible Runner Implementation
1. Create a new file such as `src/ai/unified-ai-runner.openai-compatible.ts`.
2. Use `openai.chat.completions.create(...)`.
3. Pass `messages`, `tools`, `model`, `stream`, and `signal`.
4. Map system prompt and memory prompt into the `messages` array correctly.
5. Keep the tool loop structure from the current runner.
6. Append assistant tool-call messages and tool result messages between rounds.
7. Continue until no tool calls remain or max rounds is reached.
Expected result:
- Compatible backends can complete multi-round tool flows.
## Step 8: Tool Call Loop Semantics
1. Preserve `MAX_TOOL_ROUNDS`.
2. Preserve tool ranking before each round.
3. Preserve memory tool selection.
4. Preserve file search injection when document RAG is active.
5. Preserve file upload post-processing.
6. Preserve max-rounds warnings and continuation decisions.
7. Keep the final text visible in the stream message exactly as today.
Expected result:
- Compatible backend behaves like the current runner from the users perspective.
## Step 9: Streaming Behavior
1. Implement streaming event handling for `chat.completions`.
2. Parse text deltas and append them to `TelegramStreamMessage`.
3. Parse `delta.tool_calls` and keep incremental tool-call state.
4. Update status text when tool usage starts and ends.
5. Keep image generation and file-search status handling if the backend emits compatible signals.
6. Finalize the stream only after the terminal completion event.
Expected result:
- Streaming works without losing tool call state.
## Step 10: Tool Result Handling
1. After each tool execution round, append tool results using the compatible message format.
2. Ensure each tool result keeps the correct `tool_call_id`.
3. Preserve the existing file upload hook.
4. If upload fails, convert the failure into a tool result error string.
5. Preserve the same tool memory map behavior.
Expected result:
- The backend receives a valid message history for the next round.
## Step 11: Prompt and Memory Injection
1. Keep `buildSystemInstruction(...)` as the source of system prompt assembly.
2. Keep `buildUserMemoryPrompt(...)` injected as a separate block.
3. Preserve the explicit separation between assistant memory and user memory.
4. Preserve the `user.md` and `system.md` memory layout.
5. Ensure compatible backend receives the same semantic prompt content.
Expected result:
- Memory behavior stays identical across official and compatible backends.
## Step 12: Tool Ranking Compatibility
1. Review `src/ai/unified-ai-runner.tool-ranker.ts`.
2. Verify whether the current JSON response handling is safe for compatible backends.
3. If a backend cannot guarantee strict JSON mode, add a fallback parser.
4. Keep ranking inputs and outputs consistent across both branches.
5. Do not weaken tool selection heuristics.
Expected result:
- Tool ranking remains deterministic enough for both branches.
## Step 13: File Search and RAG
1. Keep document RAG preparation in the request pipeline.
2. Keep vector store preparation for official OpenAI.
3. Decide whether compatible backend supports file search or needs a no-op fallback.
4. If unsupported, guard the tool list so the compatible backend never receives unsupported tools.
5. Keep cleanup behavior for temporary artifacts.
Expected result:
- Compatible backend does not receive tools it cannot execute.
## Step 14: Error Handling
1. Preserve abort handling.
2. Preserve response failure handling.
3. Preserve stream error handling.
4. Surface backend-specific incompatibilities as explicit errors.
5. Do not silently fall back from compatible to official mode.
6. Keep logs actionable.
Expected result:
- Failures are obvious and debuggable.
## Step 15: Logging and Observability
1. Keep the current AI logs and duration tracking.
2. Add backend mode to log metadata.
3. Log tool calls, tool outputs, and round transitions in both branches.
4. Preserve existing observability hooks.
5. Add explicit labels for official vs compatible runs.
Expected result:
- Debugging remains easy after the split.
## Step 16: Tests
1. Add unit tests for backend selection.
2. Add unit tests for compatible message conversion.
3. Add unit tests for compatible tool call extraction.
4. Add integration tests for a tool-call round trip using mocked `chat.completions`.
5. Add tests proving the official `responses` path is unchanged.
6. Add tests for streaming tool call parsing if the backend supports it.
7. Add tests for fallback behavior in the tool ranker if needed.
Expected result:
- Both branches are covered and regressions are visible quickly.
## Step 17: Suggested File Changes
1. `src/common/environment.ts`
2. `src/ai/ai-runtime-target.ts`
3. `src/ai/unified-ai-request-pipeline.ts`
4. `src/ai/unified-ai-runner.openai.ts`
5. `src/ai/unified-ai-runner.openai-compatible.ts`
6. `src/ai/provider-adapter-contract.ts`
7. `src/ai/provider-adapters.ts`
8. `src/ai/openai-chat-message.ts`
9. `src/ai/unified-ai-runner.tool-ranker.ts`
10. `test/*.test.mjs`
11. `.env.example`
12. Documentation files for backend selection
## Implementation Order
1. [x] Add config flag and wire it through environment parsing.
2. [x] Add backend selection logic.
3. [x] Add compatible message and extractor support.
4. [x] Create the compatible runner.
5. [x] Reuse shared orchestration where possible.
6. [x] Wire tests.
7. [x] Verify official behavior is unchanged.
8. [x] Verify compatible backend works with a real OpenAI-compatible server.
## Verification Plan
1. Run unit tests.
2. Run integration tests.
3. Verify official OpenAI path still uses `responses.create(...)`.
4. Verify compatible path uses `chat.completions.create(...)`.
5. Verify a `llama.cpp`-style server can complete a tool loop.
6. Verify memory tools still work.
7. Verify document RAG and file upload behavior do not regress.
## Risks
1. Some OpenAI-compatible servers do not support every official OpenAI feature.
2. Streaming tool call deltas may differ across providers.
3. JSON-mode assumptions in the ranker may not hold for all compatible servers.
4. Tool schema filtering may need backend-specific allowlists.
5. Message conversion mistakes can break tool loops silently if not tested.
## Acceptance Criteria
1. Official OpenAI behavior is unchanged.
2. Compatible backend can run a full chat loop with tools.
3. Tool calls are correctly extracted and executed.
4. Tool results are appended in the correct format.
5. Memory injection still works.
6. Document RAG and file upload behavior remain functional or fail explicitly.
7. Tests cover both branches.
## Final Note
The key design rule is simple: keep official OpenAI `responses` behavior intact, and introduce OpenAI-compatible `chat.completions` behavior as a separate backend mode with its own parsing and message shape.
-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.
+20
View File
@@ -7,6 +7,7 @@ Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written i
```bash
cp .env.example .env
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (MISTRAL_API_KEY, OPENAI_API_KEY, OLLAMA_ADDRESS)
# For OpenAI-compatible servers (llama.cpp, etc.), set OPENAI_BACKEND=compatible and OPENAI_BASE_URL.
# Optional: set DATABASE_URL to postgres://... for PostgreSQL or :memory: for ephemeral SQLite.
# Optional: set DATA_PATH if you want to override the default local storage directory.
```
@@ -27,6 +28,25 @@ The bot initializes and migrates its database schema automatically on startup.
`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup.
`/importdb` restores the database from the JSON backup format.
MCP tool servers can be configured through `MCP_SERVERS` in `.env`. Use a JSON array with `stdio` or `http` transports. Example:
```bash
MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
```
If you want to disable all built-in local tools and use only MCP tools, set:
```bash
DISABLE_LOCAL_TOOLS=true
```
If you want a partial filter instead, use tool names:
```bash
LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
```
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
```bash
+25 -21
View File
@@ -14,8 +14,8 @@
"emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3",
"openai": "^6.37.0",
"pg": "^8.20.0",
"openai": "^6.38.0",
"pg": "^8.21.0",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"systeminformation": "^5.31.6",
@@ -27,12 +27,12 @@
"@eslint/js": "^9.39.4",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"eslint": "^9.39.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"typescript-eslint": "^8.59.4",
},
},
},
@@ -179,7 +179,7 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
@@ -187,25 +187,25 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -399,7 +399,7 @@
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
"openai": ["openai@6.37.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ=="],
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -415,15 +415,15 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="],
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
"pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="],
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="],
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
"pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
@@ -495,7 +495,7 @@
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="],
"typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="],
"typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="],
@@ -551,12 +551,16 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"typescript-telegram-bot-api/axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
+10
View File
@@ -8,6 +8,13 @@
},
"providerChoice.default": "Default",
"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...",
"analyzingPictureText": "🔍 Analyzing the image...",
"analyzingPicturesText": "🔍 Analyzing the images...",
@@ -176,6 +183,9 @@
"getWhenPluralUnitText": "{unit}s",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"aiAudit": "Inspect AI request audit and artifacts",
"aiMetrics": "Show AI observability counters",
"aiRequests": "Show recent AI requests",
"ae": "evaluation",
"adminsAdd": "Add user to admins",
"adminsRemove": "Remove user from admins",
+10
View File
@@ -8,6 +8,13 @@
},
"providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.",
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
"waitThinkText": "⏳ Дайте-ка подумать...",
"analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...",
@@ -202,6 +209,9 @@
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"aiRequests": "Показать последние AI-запросы",
"aiAudit": "Показать аудит AI-запроса и артефакты",
"aiMetrics": "Показать счётчики AI-обсервабилити",
"ae": "вычисление",
"adminsAdd": "Добавить пользователя в администраторы",
"adminsRemove": "Удалить пользователя из администраторов",
+10
View File
@@ -8,6 +8,13 @@
},
"providerChoice.default": "За замовчуванням",
"errorText": "⚠️ Сталася помилка.",
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
"waitThinkText": "⏳ Дайте-но подумати...",
"analyzingPictureText": "🔍 Аналізую зображення...",
"analyzingPicturesText": "🔍 Аналізую зображення...",
@@ -201,6 +208,9 @@
"getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": {
"aiRequests": "Показати останні AI-запити",
"aiAudit": "Показати аудит AI-запиту та артефакти",
"aiMetrics": "Показати лічильники AI-спостережуваності",
"help": "Показати список команд",
"settings": "Налаштування користувача",
"start": "Запустити бота",
+115 -115
View File
@@ -17,8 +17,8 @@
"emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3",
"openai": "^6.37.0",
"pg": "^8.20.0",
"openai": "^6.38.0",
"pg": "^8.21.0",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"systeminformation": "^5.31.6",
@@ -30,12 +30,12 @@
"@eslint/js": "^9.39.4",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"eslint": "^9.39.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3"
"typescript-eslint": "^8.59.4"
}
},
"node_modules/@emnapi/runtime": {
@@ -1246,9 +1246,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -1286,17 +1286,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/type-utils": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/type-utils": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -1309,7 +1309,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -1325,16 +1325,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -1350,14 +1350,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.3",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -1372,14 +1372,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1390,9 +1390,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1407,15 +1407,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1432,9 +1432,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1446,16 +1446,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1513,16 +1513,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3"
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1537,13 +1537,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1590,6 +1590,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -1661,31 +1673,6 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2575,6 +2562,19 @@
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2847,9 +2847,9 @@
}
},
"node_modules/openai": {
"version": "6.37.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz",
"integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==",
"version": "6.38.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz",
"integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
@@ -2959,14 +2959,14 @@
}
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -2974,7 +2974,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -2986,16 +2986,16 @@
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
@@ -3008,18 +3008,18 @@
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
@@ -3455,16 +3455,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.59.3",
"@typescript-eslint/parser": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3"
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+4 -4
View File
@@ -22,8 +22,8 @@
"emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3",
"openai": "^6.37.0",
"pg": "^8.20.0",
"openai": "^6.38.0",
"pg": "^8.21.0",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"systeminformation": "^5.31.6",
@@ -35,11 +35,11 @@
"@eslint/js": "^9.39.4",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"eslint": "^9.39.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3"
"typescript-eslint": "^8.59.4"
}
}
+24 -4
View File
@@ -1,12 +1,12 @@
import {Mistral} from "@mistralai/mistralai";
import {Ollama} from "ollama";
import {OpenAI} from "openai";
import {Environment} from "../common/environment";
import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment.js";
import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
import {AiProvider} from "../model/ai-provider.js";
export type AiCapabilityName = keyof AiModelCapabilities;
export type AiRuntimePurpose = AiCapabilityName | "chat";
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
export type AiRuntimeTarget = {
provider: AiProvider;
@@ -24,6 +24,7 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
thinking: ["THINKING", "THINK"],
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
tools: ["TOOLS", "CHAT"],
memoryCompress: ["MEMORY_COMPRESS"],
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
audio: ["AUDIO"],
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
@@ -155,6 +156,25 @@ export function resolveAiRuntimeTarget(
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions};
}
function hasExplicitTargetConfig(provider: AiProvider, purpose: AiRuntimePurpose): boolean {
const prefix = providerPrefix(provider);
return [
...endpointEnvNames(provider, purpose),
...apiKeyEnvNames(provider, purpose),
...modelEnvNames(provider, purpose),
...systemPromptEnvNames(provider, purpose),
].some(name => !!env(name)) || !!env(`${prefix}_${PURPOSE_SUFFIXES[purpose][0]}_MODEL`);
}
export function resolveOptionalAiRuntimeTarget(
provider: AiProvider,
purpose: AiRuntimePurpose,
modelOverride?: string,
): AiRuntimeTarget | undefined {
if (!hasExplicitTargetConfig(provider, purpose)) return undefined;
return resolveAiRuntimeTarget(provider, purpose, modelOverride);
}
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
return left.provider === right.provider
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
+18 -1
View File
@@ -16,6 +16,7 @@ import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./uni
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {OllamaChatMessage} from "./ollama-chat-message";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
export type ConversationAttachment = {
kind: AttachmentKind;
@@ -32,6 +33,7 @@ export type ConversationTurn = {
content: string;
deletedByBotAt?: number | null;
attachments: ConversationAttachment[];
documentNames?: string[];
};
export type ConversationSnapshot = {
@@ -123,6 +125,13 @@ function attachmentSummary(attachments: ConversationAttachment[]): string {
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> {
if (bot) return new Set<AttachmentKind>();
@@ -160,6 +169,10 @@ function renderContentText(
parts.push("[message_state]: deleted_by_bot");
}
if (turn.documentNames?.length) {
parts.push(namesSummary("documents", turn.documentNames));
}
if (unsupported.length) {
parts.push(attachmentSummary(unsupported));
}
@@ -255,11 +268,13 @@ function buildSystemInstruction(
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string {
return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n");
}
@@ -291,17 +306,19 @@ export async function buildConversationSnapshot(
content: part.content,
deletedByBotAt: part.deletedByBotAt,
attachments: buildConversationAttachments(part),
documentNames: part.documentNames,
}));
const imageCount = turns.reduce((sum, turn) => {
if (turn.bot) return sum;
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
}, 0);
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
return {
turns,
imageCount,
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions),
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
};
}
+4
View File
@@ -42,6 +42,10 @@ export async function prepareDocumentRag(
const documents = downloads.filter(download => download.kind === "document");
if (!documents.length) return undefined;
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
return undefined;
}
switch (provider) {
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(config.openAiChatTarget);
+421
View File
@@ -0,0 +1,421 @@
import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process";
import type {BoundaryValue} from "../../common/boundary-types.js";
import {toolsLogger} from "../tools/tool-logger.js";
import type {McpServerConfig} from "./mcp-config.js";
const logger = toolsLogger.child("mcp");
const MCP_PROTOCOL_VERSION = "2025-06-18";
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
export type McpToolDefinition = {
name: string;
description?: string;
inputSchema?: BoundaryValue;
};
type JsonRpcRequest = {
jsonrpc: "2.0";
id: number;
method: string;
params?: BoundaryValue;
};
type JsonRpcNotification = {
jsonrpc: "2.0";
method: string;
params?: BoundaryValue;
};
type JsonRpcResponse = {
jsonrpc?: "2.0";
id?: BoundaryValue;
result?: BoundaryValue;
error?: {
code?: number;
message?: string;
data?: BoundaryValue;
};
};
interface JsonRpcTransport {
request(method: string, params?: BoundaryValue): Promise<BoundaryValue>;
notify(method: string, params?: BoundaryValue): Promise<void>;
close(): Promise<void>;
}
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toJsonRpcResponse(value: BoundaryValue): JsonRpcResponse | undefined {
if (!isRecord(value)) return undefined;
if (value.jsonrpc !== undefined && value.jsonrpc !== "2.0") return undefined;
return value as JsonRpcResponse;
}
function extractJsonRpcResult(response: BoundaryValue, expectedId?: number): BoundaryValue {
const parsed = toJsonRpcResponse(response);
if (!parsed) {
throw new Error("Invalid JSON-RPC response from MCP server.");
}
if (parsed.error) {
throw new Error(parsed.error.message || "MCP server returned an error.");
}
if (expectedId !== undefined && parsed.id !== undefined && parsed.id !== expectedId) {
throw new Error(`Unexpected JSON-RPC response id from MCP server. Expected ${expectedId}, got ${String(parsed.id)}.`);
}
return parsed.result ?? {};
}
function parseSsePayload(text: string): BoundaryValue[] {
const events: string[] = [];
let current: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line) {
if (current.length) {
events.push(current.join("\n"));
current = [];
}
continue;
}
if (line.startsWith("data:")) {
current.push(line.slice(5).replace(/^ /, ""));
}
}
if (current.length) {
events.push(current.join("\n"));
}
return events.map(event => {
try {
return JSON.parse(event) as BoundaryValue;
} catch {
return undefined;
}
}).filter((event): event is BoundaryValue => event !== undefined);
}
function timeoutPromise<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});
}
class StdioJsonRpcTransport implements JsonRpcTransport {
private readonly process: ChildProcessWithoutNullStreams;
private readonly pending = new Map<number, {resolve: (value: BoundaryValue) => void; reject: (error: Error) => void;}>();
private buffer = "";
private nextId = 1;
constructor(private readonly config: McpServerConfig) {
if (!config.command) {
throw new Error(`MCP stdio server '${config.name}' is missing command.`);
}
this.process = spawn(config.command, config.args ?? [], {
cwd: config.cwd,
env: {
...process.env,
...config.env,
},
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.process.stdout.on("data", chunk => this.handleStdout(chunk));
this.process.stderr.on("data", chunk => {
const text = chunk.toString("utf8").trim();
if (text) logger.debug("stdio.stderr", {server: config.name, text});
});
this.process.on("error", error => this.failAll(error));
this.process.on("exit", code => this.failAll(new Error(`MCP stdio server '${config.name}' exited with code ${code ?? "unknown"}.`)));
}
private handleStdout(chunk: Buffer): void {
this.buffer += chunk.toString("utf8");
let newlineIndex = this.buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
newlineIndex = this.buffer.indexOf("\n");
if (!line) continue;
try {
const message = JSON.parse(line) as JsonRpcResponse | JsonRpcNotification;
if ("id" in message && message.id !== undefined) {
const pending = this.pending.get(Number(message.id));
if (pending) {
this.pending.delete(Number(message.id));
if ("error" in message && message.error) {
pending.reject(new Error(message.error.message || "MCP stdio request failed."));
} else {
pending.resolve((message as JsonRpcResponse).result ?? {});
}
}
continue;
}
if ("method" in message) {
logger.debug("stdio.notification", {server: this.config.name, method: message.method});
}
} catch (error) {
logger.warn("stdio.parse_failed", {
server: this.config.name,
line: line.slice(0, 500),
error: error instanceof Error ? error.message : String(error),
});
}
}
}
private failAll(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
if (this.process.exitCode !== null) {
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
}
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
const result = new Promise<BoundaryValue>((resolve, reject) => {
this.pending.set(id, {resolve, reject});
});
this.process.stdin.write(`${JSON.stringify(request)}\n`);
return timeoutPromise(result, this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, `${this.config.name}.${method}`);
}
async notify(method: string, params?: BoundaryValue): Promise<void> {
if (this.process.exitCode !== null) {
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
}
const notification: JsonRpcNotification = {
jsonrpc: "2.0",
method,
params,
};
this.process.stdin.write(`${JSON.stringify(notification)}\n`);
}
async close(): Promise<void> {
this.failAll(new Error(`MCP stdio server '${this.config.name}' closed.`));
if (!this.process.killed) {
this.process.kill();
}
}
}
class HttpJsonRpcTransport implements JsonRpcTransport {
private nextId = 1;
private sessionId?: string;
constructor(private readonly config: McpServerConfig) {
if (!config.url) {
throw new Error(`MCP HTTP server '${config.name}' is missing url.`);
}
}
private async post(body: BoundaryValue): Promise<Response> {
const controller = new AbortController();
const timeoutMs = this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(this.config.url!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
...(this.sessionId ? {"Mcp-Session-Id": this.sessionId} : {}),
...(this.config.headers ?? {}),
},
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(timeoutId));
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
const response = await this.post(request);
const sessionId = response.headers.get("Mcp-Session-Id");
if (sessionId) {
this.sessionId = sessionId;
}
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`MCP HTTP server '${this.config.name}' returned ${response.status}: ${errorText || response.statusText}`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
let payload: BoundaryValue;
if (contentType.includes("text/event-stream")) {
const text = await response.text();
const messages = parseSsePayload(text);
const responseMessage = messages.map(toJsonRpcResponse).find(message => message?.id === id && (message.result !== undefined || message.error));
payload = extractJsonRpcResult(responseMessage ?? messages[0] ?? {}, id);
} else {
payload = extractJsonRpcResult(await response.json() as BoundaryValue, id);
}
return payload;
}
async notify(method: string, params?: BoundaryValue): Promise<void> {
const response = await this.post({
jsonrpc: "2.0",
method,
params,
});
const sessionId = response.headers.get("Mcp-Session-Id");
if (sessionId) {
this.sessionId = sessionId;
}
if (!response.ok && response.status !== 202) {
const errorText = await response.text().catch(() => "");
throw new Error(`MCP HTTP notification failed for '${this.config.name}' with ${response.status}: ${errorText || response.statusText}`);
}
}
async close(): Promise<void> {
return;
}
}
function createTransport(config: McpServerConfig): JsonRpcTransport {
return config.transport === "stdio"
? new StdioJsonRpcTransport(config)
: new HttpJsonRpcTransport(config);
}
function normalizeToolResultContent(content: BoundaryValue): string {
if (content === undefined || content === null) return "";
if (typeof content === "string") return content;
if (typeof content === "number" || typeof content === "boolean") return String(content);
if (Array.isArray(content)) return content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
if (!isRecord(content)) return JSON.stringify(content);
if (content.type === "text" && typeof content.text === "string") return content.text;
if (content.type === "image") {
return `[image ${typeof content.mimeType === "string" ? content.mimeType : "unknown"}]`;
}
if (content.type === "resource" && isRecord(content.resource)) {
if (typeof content.resource.text === "string") return content.resource.text;
return JSON.stringify(content.resource);
}
return JSON.stringify(content);
}
export class McpClient {
private readonly transport: JsonRpcTransport;
private initialized = false;
constructor(readonly config: McpServerConfig) {
this.transport = createTransport(config);
}
async initialize(): Promise<void> {
if (this.initialized) return;
await this.transport.request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: "tg-chat-bot",
version: "1.0.0",
},
capabilities: {},
});
await this.transport.notify("notifications/initialized");
this.initialized = true;
}
async listTools(): Promise<McpToolDefinition[]> {
await this.initialize();
const result = await this.transport.request("tools/list");
if (!isRecord(result)) return [];
const tools = Array.isArray(result.tools) ? result.tools : [];
return tools.flatMap(tool => {
if (!isRecord(tool) || typeof tool.name !== "string") return [];
return [{
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
inputSchema: tool.inputSchema,
}];
});
}
async callTool(name: string, args?: BoundaryValue): Promise<string> {
await this.initialize();
const result = await this.transport.request("tools/call", {
name,
arguments: args ?? {},
});
if (!isRecord(result)) {
return normalizeToolResultContent(result);
}
const content = Array.isArray(result.content) ? result.content : [];
const text = content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
if (result.isError) {
return text ? `[MCP error] ${text}` : "[MCP error]";
}
return text || JSON.stringify(result);
}
async close(): Promise<void> {
await this.transport.close();
}
}
+106
View File
@@ -0,0 +1,106 @@
import type {BoundaryValue} from "../../common/boundary-types.js";
export type McpTransport = "stdio" | "http";
export type McpServerConfig = {
name: string;
transport: McpTransport;
command?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
timeoutMs?: number;
};
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function toStringRecord(value: BoundaryValue): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const result: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") {
result[key] = String(entry);
}
}
return Object.keys(result).length ? result : undefined;
}
function toStringArray(value: BoundaryValue): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const items = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.map(item => item.trim());
return items.length ? items : undefined;
}
function toPositiveInt(value: BoundaryValue): number | undefined {
const n = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(n) || n <= 0) return undefined;
return Math.floor(n);
}
function normalizeServerConfig(value: BoundaryValue, fallbackName?: string): McpServerConfig | undefined {
if (!isRecord(value)) return undefined;
const name = asString(value.name) ?? fallbackName;
const transportRaw = asString(value.transport);
const transport = transportRaw === "http" || transportRaw === "stdio" ? transportRaw : undefined;
if (!name || !transport) return undefined;
return {
name,
transport,
command: asString(value.command),
args: toStringArray(value.args),
cwd: asString(value.cwd),
env: toStringRecord(value.env),
url: asString(value.url),
headers: toStringRecord(value.headers),
timeoutMs: toPositiveInt(value.timeoutMs),
};
}
export function parseMcpServerConfigs(raw: string | undefined): McpServerConfig[] {
if (!raw?.trim()) return [];
let parsed: BoundaryValue;
try {
parsed = JSON.parse(raw) as BoundaryValue;
} catch (error) {
throw new Error(`Invalid MCP_SERVERS JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (Array.isArray(parsed)) {
return parsed.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
}
if (!isRecord(parsed)) {
return [];
}
if (Array.isArray(parsed.servers)) {
return parsed.servers.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
}
if (isRecord(parsed.mcpServers)) {
return Object.entries(parsed.mcpServers).flatMap(([name, item]) => normalizeServerConfig(item, name) ? [normalizeServerConfig(item, name)!] : []);
}
const single = normalizeServerConfig(parsed);
return single ? [single] : [];
}
+123
View File
@@ -0,0 +1,123 @@
import type {AiJsonValue, AiToolParameters} from "../tool-types.js";
import type {BoundaryValue} from "../../common/boundary-types.js";
type JsonSchemaRecord = Record<string, BoundaryValue>;
function isRecord(value: BoundaryValue): value is JsonSchemaRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toAiJsonValue(value: BoundaryValue): AiJsonValue | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.map(item => toAiJsonValue(item) ?? null);
}
if (!isRecord(value)) return undefined;
const result: Record<string, AiJsonValue> = {};
for (const [key, entry] of Object.entries(value)) {
const normalized = toAiJsonValue(entry);
if (normalized !== undefined) {
result[key] = normalized;
}
}
return result;
}
function normalizeType(value: BoundaryValue): AiToolParameters["type"] | undefined {
const candidates = Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: typeof value === "string"
? [value]
: [];
const prioritized = candidates.find(item => item !== "null") ?? candidates[0];
if (!prioritized) return undefined;
switch (prioritized) {
case "object":
case "string":
case "number":
case "integer":
case "boolean":
case "array":
return prioritized;
default:
return undefined;
}
}
export function convertJsonSchemaToToolParameters(schema: BoundaryValue): AiToolParameters | undefined {
if (!isRecord(schema)) return undefined;
const declaredType = normalizeType(schema.type);
const inferredType = declaredType
?? (schema.properties !== undefined || schema.additionalProperties !== undefined ? "object" : undefined)
?? (schema.items !== undefined ? "array" : undefined)
?? "object";
const result: AiToolParameters = {
type: inferredType,
};
const description = typeof schema.description === "string" && schema.description.trim().length > 0
? schema.description.trim()
: undefined;
if (description) result.description = description;
const defaultValue = toAiJsonValue(schema.default);
if (defaultValue !== undefined) result.default = defaultValue;
if (Array.isArray(schema.enum)) {
const enumValues = schema.enum
.filter((item): item is string => typeof item === "string" && item.length > 0);
if (enumValues.length) result.enum = enumValues;
}
if (typeof schema.minItems === "number") result.minItems = schema.minItems;
if (typeof schema.maxItems === "number") result.maxItems = schema.maxItems;
if (typeof schema.minimum === "number") result.minimum = schema.minimum;
if (typeof schema.maximum === "number") result.maximum = schema.maximum;
if (Array.isArray(schema.required)) {
const required = schema.required.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
if (required.length) result.required = required;
}
if (inferredType === "object" || schema.properties !== undefined || schema.additionalProperties !== undefined) {
if (isRecord(schema.properties)) {
const properties: Record<string, AiToolParameters> = {};
for (const [key, value] of Object.entries(schema.properties)) {
const converted = convertJsonSchemaToToolParameters(value);
if (converted) properties[key] = converted;
}
if (Object.keys(properties).length) result.properties = properties;
}
if (schema.additionalProperties !== undefined) {
result.additionalProperties = typeof schema.additionalProperties === "boolean"
? schema.additionalProperties
: convertJsonSchemaToToolParameters(schema.additionalProperties);
}
}
if (inferredType === "array" || schema.items !== undefined) {
if (Array.isArray(schema.items)) {
const firstItem = schema.items[0];
if (firstItem !== undefined) {
const converted = convertJsonSchemaToToolParameters(firstItem);
if (converted) result.items = converted;
}
} else {
const converted = convertJsonSchemaToToolParameters(schema.items);
if (converted) result.items = converted;
}
}
return result;
}
+165
View File
@@ -0,0 +1,165 @@
import {Environment} from "../../common/environment.js";
import type {AiTool} from "../tool-types.js";
import type {ToolHandler} from "../tools/types.js";
import {normalizeToolArguments} from "../tools/utils.js";
import {toolsLogger} from "../tools/tool-logger.js";
import {convertJsonSchemaToToolParameters} from "./mcp-json-schema.js";
import {McpClient, type McpToolDefinition} from "./mcp-client.js";
import {parseMcpServerConfigs, type McpServerConfig} from "./mcp-config.js";
const logger = toolsLogger.child("mcp-registry");
type McpToolBinding = {
server: McpServerConfig;
client: McpClient;
remoteToolName: string;
localToolName: string;
tool: AiTool;
};
type McpInitSummary = {
servers: number;
loadedServers: number;
tools: number;
failedServers: string[];
};
const toolBindings = new Map<string, McpToolBinding>();
const clients = new Map<string, McpClient>();
let initPromise: Promise<McpInitSummary> | undefined;
function sanitizeSegment(value: string): string {
return value
.trim()
.replace(/[^a-zA-Z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "") || "tool";
}
function buildLocalToolName(serverName: string, toolName: string): string {
return `mcp__${sanitizeSegment(serverName)}__${sanitizeSegment(toolName)}`;
}
function buildTool(serverName: string, tool: McpToolDefinition): AiTool {
const localName = buildLocalToolName(serverName, tool.name);
const description = tool.description?.trim()
? `[MCP ${serverName}] ${tool.description.trim()}`
: `[MCP ${serverName}] ${tool.name}`;
return {
type: "function",
function: {
name: localName,
description,
parameters: convertJsonSchemaToToolParameters(tool.inputSchema),
},
};
}
async function loadServer(config: McpServerConfig): Promise<{loaded: boolean; tools: number}> {
const client = new McpClient(config);
clients.set(config.name, client);
try {
const remoteTools = await client.listTools();
let loaded = 0;
for (const remoteTool of remoteTools) {
const localName = buildLocalToolName(config.name, remoteTool.name);
if (toolBindings.has(localName)) {
logger.warn("tool.duplicate", {
server: config.name,
tool: remoteTool.name,
localName,
});
continue;
}
const binding: McpToolBinding = {
server: config,
client,
remoteToolName: remoteTool.name,
localToolName: localName,
tool: buildTool(config.name, remoteTool),
};
toolBindings.set(localName, binding);
loaded += 1;
}
logger.info("server.loaded", {
server: config.name,
transport: config.transport,
tools: loaded,
});
return {loaded: true, tools: loaded};
} catch (error) {
logger.error("server.failed", {
server: config.name,
transport: config.transport,
error: error instanceof Error ? error.message : String(error),
});
await client.close().catch(() => undefined);
clients.delete(config.name);
return {loaded: false, tools: 0};
}
}
export async function initializeMcpTools(): Promise<McpInitSummary> {
if (initPromise) return initPromise;
initPromise = (async () => {
toolBindings.clear();
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
clients.clear();
const configs = parseMcpServerConfigs(Environment.MCP_SERVERS);
const results = await Promise.all(configs.map(config => loadServer(config)));
return {
servers: configs.length,
loadedServers: results.filter(result => result.loaded).length,
tools: [...results].reduce((sum, result) => sum + result.tools, 0),
failedServers: configs.filter((_, index) => !results[index]?.loaded).map(config => config.name),
};
})();
try {
const summary = await initPromise;
logger.info("init.done", summary);
return summary;
} catch (error) {
initPromise = undefined;
logger.error("init.failed", {error: error instanceof Error ? error.message : String(error)});
throw error;
}
}
export function getMcpTools(): AiTool[] {
return [...toolBindings.values()].map(binding => binding.tool);
}
export function getMcpToolHandlers(): Record<string, ToolHandler> {
const handlers: Record<string, ToolHandler> = {};
for (const binding of toolBindings.values()) {
handlers[binding.localToolName] = async args => {
const normalized = normalizeToolArguments(args, undefined);
return binding.client.callTool(binding.remoteToolName, normalized);
};
}
return handlers;
}
export function getMcpToolPrompts(_toolNames: string[]): string[] {
return [];
}
export async function shutdownMcpTools(): Promise<void> {
initPromise = undefined;
toolBindings.clear();
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
clients.clear();
}
+5
View File
@@ -0,0 +1,5 @@
export async function runSingleModelRequest<T>(params: {
execute: () => Promise<T>;
}): Promise<T> {
return await params.execute();
}
+66
View File
@@ -0,0 +1,66 @@
import {isRecord} from "./unified-ai-runner.shared.js";
import type {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message.js";
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export function responseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
export function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAICompatibleChatMessage[] {
return messages.map((message): OpenAICompatibleChatMessage => {
if (message.role === "system") {
return {role: "system", content: responseContentToText(message.content)};
}
if (message.role === "assistant") {
const text = responseContentToText(message.content);
return text.length
? {role: "assistant", content: text}
: {role: "assistant", content: null};
}
const content = Array.isArray(message.content)
? (() => {
const parts = message.content.map((part): {type: "text"; text: string} | {type: "image_url"; image_url: {url: string}} => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
});
return parts.every(part => part.type === "text")
? parts.map(part => part.text).join("")
: parts;
})()
: message.content;
return {role: "user", content};
});
}
export function buildAssistantToolMessage(calls: ToolCallData[], text: string): OpenAICompatibleChatMessage {
return {
role: "assistant",
content: text,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
};
}
+3
View File
@@ -2,6 +2,7 @@ import type {
ResponseInputMessageContentList,
ResponseOutputMessage,
} from "openai/resources/responses/responses";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
type OpenAIInputChatMessage = {
type: "message";
@@ -17,3 +18,5 @@ type OpenAIOutputChatMessage = {
} & Pick<ResponseOutputMessage, "id" | "status">;
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
export type OpenAICompatibleChatMessage = ChatCompletionMessageParam;
+74
View File
@@ -0,0 +1,74 @@
import {Message} from "typescript-telegram-bot-api";
import fs from "node:fs";
import path from "node:path";
import {bot} from "../index.js";
import {Environment} from "../common/environment.js";
import {logError} from "../util/utils.js";
import {errorMessage} from "./unified-ai-runner.shared.js";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files.js";
export async function tryToUploadFiles(
msg: Message,
toolResults: string[]
): Promise<
| { found: false }
| { found: true, uploaded: true }
| { found: boolean, uploaded: false, error: string, toolIndex: number }
> {
let sendFileAttachment: {
result: SendFileAttachmentResult & { success: true },
toolIndex: number
} | null = null;
let found = false;
try {
for (const [index, toolResult] of toolResults.entries()) {
const raw = JSON.parse(toolResult);
const res = SendFileAttachmentResultSchema.safeParse(raw);
if (res.success) {
found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: index};
}
}
}
if (!found) {
return {found: false};
}
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
const attachmentPath = attachmentRoot
? path.join(
attachmentRoot,
String(msg.from?.id),
sendFileAttachment?.result?.attachment?.relativePath ?? "",
)
: "";
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(attachmentPath),
});
return {found: true, uploaded: true};
} catch (e) {
logError(e instanceof Error ? e : String(e));
return {
found: found,
uploaded: false,
error: errorMessage(e instanceof Error ? e : String(e)),
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
}
}
+198
View File
@@ -0,0 +1,198 @@
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 ?? {});
}
function normalizeToolArgumentsChunk(value: unknown): string {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
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 extractOpenAiChatTextDelta(input: unknown): string {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const content = delta && typeof delta.content === "string" ? delta.content : "";
return content;
}
export function normalizeStreamingTextDelta(existingText: string, deltaText: string): string {
if (!deltaText) return "";
if (!existingText) return deltaText;
if (deltaText.startsWith(existingText)) {
return deltaText.slice(existingText.length);
}
return deltaText;
}
export function extractOpenAiChatToolCalls(response: unknown): ToolCallData[] {
const record = isRecord(response) ? response : undefined;
const choice = record && Array.isArray(record.choices) && isRecord(record.choices[0]) ? record.choices[0] : undefined;
const message = isRecord(choice?.message) ? choice.message : undefined;
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
return toolCalls
.filter((item, index) => isRecord(item) && ((typeof item.id === "string") || typeof item.index === "number" || index >= 0))
.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, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOpenAiChatStreamingToolCalls(input: unknown): ToolCallData[] {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
return toolCalls
.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, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArgumentsChunk(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.id.length > 0);
}
export function mergeToolCallChunks(existing: ToolCallData[], chunks: ToolCallData[]): ToolCallData[] {
const merged = new Map<string, ToolCallData>(existing.map(call => [call.id, {...call}]));
for (const chunk of chunks) {
const current = merged.get(chunk.id);
if (!current) {
merged.set(chunk.id, {...chunk});
continue;
}
merged.set(chunk.id, {
id: current.id,
name: current.name || chunk.name,
argumentsText: current.argumentsText + (chunk.argumentsText ?? ""),
});
}
return [...merged.values()];
}
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();
}
}
+2 -1
View File
@@ -196,7 +196,8 @@ export async function getRuntimeCapabilities(
target?: AiRuntimeTarget
): Promise<AiModelCapabilities> {
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
const targetPurpose = target?.purpose && target.purpose !== "memoryCompress" ? target.purpose : "chat";
const result = await getModelCapabilities(provider, runtimeTarget.model, targetPurpose) ?? buildCapabilities({});
for (const capabilityName of CAPABILITY_NAMES) {
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
+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 {OllamaRagArtifactDetails} from "./ollama-rag";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
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"];
};
};
import {buildRagArtifactPayload, type RagArtifactPayload} from "./rag-artifact-payload";
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
switch (prepared.provider) {
case AiProvider.OPENAI:
return {
provider: AiProvider.OPENAI,
vectorStoreIds: prepared.vectorStoreIds,
uploadedFileIds: prepared.uploadedFileIds,
};
case AiProvider.MISTRAL:
return {
provider: AiProvider.MISTRAL,
libraryId: prepared.libraryId,
documentCount: prepared.documents.length,
};
case AiProvider.OLLAMA:
return {
provider: AiProvider.OLLAMA,
prepared: prepared.prepared,
embeddingModel: details?.embeddingModel,
topK: details?.topK,
chunkSize: details?.chunkSize,
chunkOverlap: details?.chunkOverlap,
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);
const payload: RagArtifactPayload = {
artifactKind: "rag",
const payload = buildRagArtifactPayload({
provider: params.provider,
createdAt: new Date().toISOString(),
sources,
providerState: {
...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,
} : {}),
},
};
providerState: providerState(params.prepared, params.details),
});
return await persistInternalJsonArtifactAttachment({
artifactKind: "rag",
fileNamePrefix: "rag",
@@ -140,14 +93,8 @@ export async function persistRagArtifactAttachment(params: {
messageId: params.messageId,
payload,
metadata: {
provider: params.provider,
sourceFileNames: sources.map(source => source.fileName),
...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 {createQueuedFunction} from "../util/async-lock";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {appLogger} from "../logging/logger";
import fs from "node:fs";
import path from "node:path";
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 {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
import {recordToolCall} from "../common/ai-observability.js";
const TELEGRAM_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024;
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
const EDIT_INTERVAL_MS = 4500;
const logger = appLogger.child("telegram-stream-message");
export type TelegramArtifactFile = {
kind: "image" | "file";
@@ -238,6 +241,13 @@ export class TelegramStreamMessage {
recordToolExecution(record: TelegramToolExecutionRecord): void {
this.toolExecutions.push(record);
recordToolCall();
logger.debug("tool.execution.recorded", {
requestId: this.cancelRequestId,
toolName: record.toolName,
callId: record.callId,
resultChars: record.resultChars,
});
}
getToolExecutions(): TelegramToolExecutionRecord[] {
@@ -246,6 +256,13 @@ export class TelegramStreamMessage {
recordOutputAttachment(record: TelegramOutputAttachmentRecord): void {
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[] {
+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,
};
}
+27 -4
View File
@@ -1,8 +1,9 @@
import {AiTool} from "./tool-types";
import {AiProvider} from "../model/ai-provider";
import {getTools} from "./tools/registry";
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
import {AiProvider} from "../model/ai-provider.js";
import {getTools} from "./tools/registry.js";
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
import {toolSchemaNames} from "./tool-schema-utils.js";
export type AiProviderName = "ollama" | "openai" | "mistral";
@@ -26,6 +27,11 @@ export function getOpenAITools(forCreator?: boolean): AiTool[] {
}));
}
export function getOpenAICompatibleTools(forCreator?: boolean): AiTool[] {
// The compatible chat.completions backend only accepts plain function tools.
return getOpenAITools(forCreator);
}
export type OpenAiResponseTool = {
type: "function";
name: string;
@@ -79,3 +85,20 @@ export function getProviderTools(provider: AiProvider, forCreator?: boolean): Ai
return getOpenAITools(forCreator);
}
}
export function ensureToolsSelected<T>(availableTools: readonly T[], selectedTools: readonly T[], toolNames: readonly string[]): T[] {
const selected = [...selectedTools];
const selectedNames = new Set(selected.flatMap(tool => toolSchemaNames(tool as never)));
for (const toolName of toolNames) {
if (selectedNames.has(toolName)) continue;
const extraTool = availableTools.find(tool => toolSchemaNames(tool as never).includes(toolName));
if (extraTool) {
selected.unshift(extraTool);
selectedNames.add(toolName);
}
}
return selected;
}
-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,
};
}
+138 -5
View File
@@ -102,6 +102,100 @@ export const TOOL_RANKER_TOOL_INFOS = {
example("где определён BotService?", ["search_files"]),
],
),
read_user_info: tool(
"read_user_info",
"Read persistent user memory from user.md.",
"Use before editing or when the user asks what you remember about them.",
[
example("что ты помнишь обо мне?", ["read_user_info"]),
example("покажи мою память", ["read_user_info"]),
],
),
read_system_info: tool(
"read_system_info",
"Read persistent assistant memory from system.md.",
"Use before editing or when the user asks what instructions you remember about yourself.",
[
example("что ты помнишь о себе?", ["read_system_info"]),
example("покажи память о тебе", ["read_system_info"]),
],
),
add_user_info: tool(
"add_user_info",
"Append a durable fact about the user to persistent memory.",
"Use when the user asks to remember a new fact, preference, identity detail, or profile information about themselves.",
[
example("запомни, что меня зовут Иван", ["add_user_info"]),
example("запомни, что я люблю чай", ["add_user_info"]),
example("remember that I like short answers", ["add_user_info"]),
],
),
add_system_info: tool(
"add_system_info",
"Append a durable instruction about the assistant to persistent memory.",
"Use when the user asks to remember a new assistant identity, style, or behavior instruction.",
[
example("тебя зовут Евлампий", ["add_system_info"]),
example("ты ИИ помощник", ["add_system_info"]),
example("remember you are a concise assistant", ["add_system_info"]),
],
),
remove_user_info: tool(
"remove_user_info",
"Remove a specific user fact from persistent memory.",
"Use when the user asks to forget, delete, or remove a specific fact about themselves.",
[
example("забудь, что я люблю кофе", ["remove_user_info"]),
example("удали из памяти, что я живу в Москве", ["remove_user_info"]),
example("forget that I work at ACME", ["remove_user_info"]),
],
),
remove_system_info: tool(
"remove_system_info",
"Remove a specific assistant instruction from persistent memory.",
"Use when the user asks to forget or remove a specific instruction about the assistant.",
[
example("забудь, что тебя зовут Евлампий", ["remove_system_info"]),
example("убери правило отвечать коротко", ["remove_system_info"]),
example("forget that you are a concise assistant", ["remove_system_info"]),
],
),
replace_user_info: tool(
"replace_user_info",
"Replace the full user memory with a new compact version.",
"Use when the user wants to overwrite all remembered user info, for example when they say to forget everything and keep only the new fact.",
[
example("забудь всё обо мне и запиши только это: меня зовут Иван", ["replace_user_info"]),
example("замени всю память обо мне на: люблю чай и короткие ответы", ["replace_user_info"]),
],
),
replace_system_info: tool(
"replace_system_info",
"Replace the full assistant memory with a new compact version.",
"Use when the user wants to overwrite all remembered assistant info or instructions.",
[
example("забудь всё о себе и запиши только это: тебя зовут Евлампий", ["replace_system_info"]),
example("замени инструкцию о себе на: ты краткий ИИ помощник", ["replace_system_info"]),
],
),
delete_user_info: tool(
"delete_user_info",
"Delete user.md entirely.",
"Use when the user explicitly asks to delete all remembered user info, not just a fragment.",
[
example("удали всю память обо мне", ["delete_user_info"]),
example("forget all user memory", ["delete_user_info"]),
],
),
delete_system_info: tool(
"delete_system_info",
"Delete system.md entirely.",
"Use when the user explicitly asks to delete all remembered assistant info, not just a fragment.",
[
example("удали всю память о себе", ["delete_system_info"]),
example("forget all assistant memory", ["delete_system_info"]),
],
),
create_file: tool(
"create_file",
"Create a new small file.",
@@ -352,6 +446,20 @@ function toolNamesFromTool(tool: BoundaryValue): string[] {
return name ? [name] : [];
}
function fallbackToolInfoFromTool(toolValue: BoundaryValue, name: string): ToolRankerToolInfo | undefined {
if (!isRecord(toolValue)) return undefined;
const fn = isRecord(toolValue.function) ? toolValue.function : undefined;
const description = asOptionalString(fn?.description ?? toolValue.description)
?? `Tool ${name}.`;
return tool(
name,
description,
"Use when the tool description matches the user's request.",
);
}
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
}
@@ -363,10 +471,25 @@ export function getToolRankerToolInfos(names: readonly string[]): ToolRankerTool
}
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
return getToolRankerToolInfos([
"no_tool",
...availableTools.flatMap(toolNamesFromTool),
]);
const infos = new Map<string, ToolRankerToolInfo>();
infos.set("no_tool", TOOL_RANKER_TOOL_INFOS.no_tool);
for (const tool of availableTools) {
for (const name of toolNamesFromTool(tool)) {
if (infos.has(name)) continue;
const known = getToolRankerToolInfo(name);
const fallback = fallbackToolInfoFromTool(tool, name);
if (known) {
infos.set(name, known);
} else if (fallback) {
infos.set(name, fallback);
}
}
}
return [...infos.values()];
}
function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string {
@@ -414,6 +537,16 @@ function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
pushIfAvailable("read_file", "known local file path -> read_file");
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
pushIfAvailable("read_user_info", "inspect remembered user info -> read_user_info");
pushIfAvailable("read_system_info", "inspect remembered assistant info -> read_system_info");
pushIfAvailable("add_user_info", "remember a new user fact -> add_user_info");
pushIfAvailable("add_system_info", "remember a new assistant instruction -> add_system_info");
pushIfAvailable("remove_user_info", "forget a user fact -> remove_user_info");
pushIfAvailable("remove_system_info", "forget an assistant instruction -> remove_system_info");
pushIfAvailable("replace_user_info", "overwrite all user memory -> replace_user_info");
pushIfAvailable("replace_system_info", "overwrite all assistant memory -> replace_system_info");
pushIfAvailable("delete_user_info", "delete all user memory -> delete_user_info");
pushIfAvailable("delete_system_info", "delete all assistant memory -> delete_system_info");
pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch");
pushIfAvailable("update_file", "full existing file replacement -> update_file");
pushIfAvailable("create_file", "small new file -> create_file");
@@ -471,7 +604,7 @@ export function buildToolRankerSystemPrompt(params: {
const includeExamples = params.includeExamples ?? false;
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
const compact = params.compact ?? true;
const availableTools = getToolRankerToolInfos(params.availableTools.map(tool => tool.name));
const availableTools = params.availableTools;
const availableToolNames = availableTools.map(tool => tool.name);
const sections: string[] = [
+5 -5
View File
@@ -1,12 +1,12 @@
import type {BoundaryValue} from "../common/boundary-types";
import type {AiRuntimeTarget} from "./ai-runtime-target";
import {AiProvider} from "../model/ai-provider";
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {AiRuntimeTarget} from "./ai-runtime-target.js";
import {AiProvider} from "../model/ai-provider.js";
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared.js";
import {
buildToolRankerSystemPrompt,
getToolRankerAvailableToolInfos,
type ToolRankerToolInfo,
} from "./tool-ranker-metadata";
} from "./tool-ranker-metadata.js";
export type ToolRankerMessage = {
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))];
}
+5 -5
View File
@@ -1,11 +1,11 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import path from "node:path";
import {readFile, writeFile} from "node:fs/promises";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
import {asNonEmptyString} from "./utils.js";
import fs from "node:fs";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("create-note");
+1 -1
View File
@@ -1,5 +1,5 @@
import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
import {asNonEmptyString} from "./utils.js";
import {AiJsonObject} from "../tool-types";
export const getCurrentDateTimeTool = {
+4 -4
View File
@@ -3,8 +3,8 @@ import fs from "node:fs";
import path from "node:path";
import {z} from "zod";
import {Environment} from "../../common/environment";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
import {Environment} from "../../common/environment.js";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
import {
MAX_COPY_ENTRIES,
MAX_COPY_TOTAL_BYTES,
@@ -23,8 +23,8 @@ import {
MAX_PATCH_SEARCH_BYTES,
MAX_STREAM_WRITE_IDLE_MS,
MAX_STREAM_WRITE_SESSIONS,
} from "./limits";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
} from "./limits.js";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils.js";
// =============================================================================
// Public types and schemas
+3 -3
View File
@@ -1,7 +1,7 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("market-rates");
+5 -5
View File
@@ -1,11 +1,11 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import path from "node:path";
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
import {notesDir, notesRootFile} from "../../index.js";
import {asNonEmptyString} from "./utils.js";
import {toolsLogger} from "./tool-logger.js";
import {z} from "zod";
import {AiJsonObject} from "../tool-types";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("notes");
+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 os from "node:os";
import path from "node:path";
import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment";
import {toolsLogger} from "./tool-logger";
import {AiTool} from "../tool-types.js";
import {Environment} from "../../common/environment.js";
import {toolsLogger} from "./tool-logger.js";
import {randomUUID} from "node:crypto";
import {AiJsonObject} from "../tool-types";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("python-interpreter");
+117 -51
View File
@@ -1,17 +1,17 @@
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather";
import {Environment} from "../../common/environment.js";
import {AiTool} from "../tool-types.js";
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
import {shellExecute, shellExecuteTool} from "./shell.js";
import {ToolHandler} from "./types.js";
import {getWeather, getWeatherTool} from "./weather.js";
import {
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
getFinancialMarketData,
getFinancialMarketDataToolPrompt,
getMarketRates
} from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
} from "./market-rates.js";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
import {
beginFileWrite,
beginFileWriteTool,
@@ -44,11 +44,14 @@ import {
updateFileTool,
writeFileChunk,
writeFileChunkTool
} from "./files";
} from "./files.js";
import {executeMemoryTool, memoryToolPrompt, memoryTools, type MemoryToolName} from "./user-memory.js";
import {getMcpToolHandlers, getMcpToolPrompts, getMcpTools} from "../mcp/mcp-registry.js";
export const defaultTools: AiTool[] = [
getCurrentDateTimeTool,
getFinancialMarketData,
...memoryTools,
];
export const fileTools = [
@@ -72,44 +75,67 @@ export const fileTools = [
deletePathTool,
] satisfies AiTool[];
// export const notesFileTools: AiTool[] = [
// createNoteTool,
// listNotesTool,
// getNoteContentTool,
// updateNoteContentTool,
// deleteNoteTool,
// sendNoteAsFileTool,
// searchNotesTool
// ]
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
if (!raw?.trim()) return undefined;
const names = raw
.split(",")
.map(item => item.trim().toLowerCase())
.filter(Boolean);
return names.length ? new Set(names) : undefined;
}
function isLocalToolEnabled(toolName: string): boolean {
if (Environment.DISABLE_LOCAL_TOOLS) return false;
const allowlist = parseToolNameSet(Environment.LOCAL_TOOL_ALLOWLIST);
if (allowlist && !allowlist.has(toolName.toLowerCase())) return false;
const denylist = parseToolNameSet(Environment.LOCAL_TOOL_DENYLIST);
if (denylist && denylist.has(toolName.toLowerCase())) return false;
return true;
}
function filterEnabledTools(tools: AiTool[]): AiTool[] {
return tools.filter(tool => isLocalToolEnabled(tool.function.name));
}
export const getTools = (forCreator?: boolean) => {
const tools: AiTool[] = [
...defaultTools,
// ...notesFileTools
];
const tools: AiTool[] = [];
if (Environment.DISABLE_LOCAL_TOOLS) {
tools.push(...getMcpTools());
return tools;
}
tools.push(...filterEnabledTools(defaultTools));
if (Environment.BRAVE_SEARCH_API_KEY) {
tools.push(webSearchTool);
tools.push(...filterEnabledTools([webSearchTool]));
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
tools.push(getWeatherTool);
tools.push(...filterEnabledTools([getWeatherTool]));
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(...fileTools);
tools.push(...filterEnabledTools(fileTools));
}
if (forCreator) {
if (Environment.ENABLE_PYTHON_INTERPRETER) {
tools.push(pythonInterpreterTool);
tools.push(...filterEnabledTools([pythonInterpreterTool]));
}
if (Environment.ENABLE_UNSAFE_EVAL) {
tools.push(shellExecuteTool);
tools.push(...filterEnabledTools([shellExecuteTool]));
}
}
tools.push(...getMcpTools());
return tools;
};
@@ -135,44 +161,83 @@ export const fileToolHandlers = {
};
export const getToolHandlers = () => {
let handlers: Record<string, ToolHandler> = {
get_datetime: getCurrentDateTime,
get_financial_market_data: getMarketRates,
// create_note: createNote,
// list_notes: listNotes,
// get_note_content: getNoteContent,
// update_note_content: updateNoteContent,
// delete_note: deleteNote,
// send_note_as_file: sendNoteAsFile,
// search_notes: searchNotes,
...fileToolHandlers,
python_interpreter: runPythonInterpreter,
shell_execute: shellExecute,
web_search: webSearch,
get_weather: getWeather,
const handlers: Record<string, ToolHandler> = {
...getMcpToolHandlers(),
};
if (Environment.DISABLE_LOCAL_TOOLS) {
return handlers;
}
if (isLocalToolEnabled("get_datetime")) handlers.get_datetime = getCurrentDateTime;
if (isLocalToolEnabled("get_financial_market_data")) handlers.get_financial_market_data = getMarketRates;
for (const tool of memoryTools) {
if (!isLocalToolEnabled(tool.function.name)) continue;
handlers[tool.function.name] = async (args, context) => {
const userId = typeof args?.userId === "number" ? args.userId : undefined;
if (!userId) {
return {success: false, error: "Missing userId"};
}
return executeMemoryTool(tool.function.name as MemoryToolName, {
userId,
content: typeof args?.content === "string" ? args.content : undefined,
}, context);
};
}
if (isLocalToolEnabled("read_file")) handlers.read_file = readFile;
if (isLocalToolEnabled("list_directory")) handlers.list_directory = listDirectory;
if (isLocalToolEnabled("search_files")) handlers.search_files = searchFiles;
if (isLocalToolEnabled("create_file")) handlers.create_file = createFile;
if (isLocalToolEnabled("begin_file_write")) handlers.begin_file_write = beginFileWrite;
if (isLocalToolEnabled("write_file_chunk")) handlers.write_file_chunk = writeFileChunk;
if (isLocalToolEnabled("finish_file_write")) handlers.finish_file_write = finishFileWrite;
if (isLocalToolEnabled("cancel_file_write")) handlers.cancel_file_write = cancelFileWrite;
if (isLocalToolEnabled("send_file_as_attachment")) handlers.send_file_as_attachment = sendFileAsAttachment;
if (isLocalToolEnabled("create_directory")) handlers.create_directory = createDirectory;
if (isLocalToolEnabled("copy_path")) handlers.copy_path = copyPath;
if (isLocalToolEnabled("update_file")) handlers.update_file = updateFile;
if (isLocalToolEnabled("edit_file_patch")) handlers.edit_file_patch = editFilePatch;
if (isLocalToolEnabled("rename_path")) handlers.rename_path = renamePath;
if (isLocalToolEnabled("delete_path")) handlers.delete_path = deletePath;
if (isLocalToolEnabled("python_interpreter")) handlers.python_interpreter = (args, _context) => runPythonInterpreter(args);
if (isLocalToolEnabled("shell_execute")) handlers.shell_execute = shellExecute;
if (isLocalToolEnabled("web_search")) handlers.web_search = webSearch;
if (isLocalToolEnabled("get_weather")) handlers.get_weather = getWeather;
return handlers;
};
export function getToolPrompts(toolNames: string[]): string[] {
if (Environment.DISABLE_LOCAL_TOOLS) {
return getMcpToolPrompts(toolNames);
}
const prompts: string[] = [];
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
let memoryPromptAdded = false;
for (const toolName of toolNames) {
if (!isLocalToolEnabled(toolName)) {
continue;
}
if (!prompts.includes(fileToolsToolPrompt) &&
fileTools.map(t => t.function.name).includes(toolName)) {
prompts.push(fileToolsToolPrompt);
continue;
}
if (memoryToolNames.has(toolName)) {
if (!memoryPromptAdded) {
prompts.push(memoryToolPrompt);
memoryPromptAdded = true;
}
continue;
}
switch (toolName) {
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
prompts.push(getFinancialMarketDataToolPrompt);
@@ -185,5 +250,6 @@ export function getToolPrompts(toolNames: string[]): string[] {
}
}
prompts.push(...getMcpToolPrompts(toolNames));
return prompts;
}
+12 -7
View File
@@ -1,14 +1,19 @@
import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import {getToolHandlers} from "./registry.js";
import {normalizeToolArguments} from "./utils.js";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
import type {MemoryRuntimeContext} from "./user-memory.js";
import type {AiRuntimeTarget} from "../ai-runtime-target.js";
import type {AiProvider} from "../../model/ai-provider.js";
const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[];
};
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
} & MemoryRuntimeContext;
function stringifyToolResult(result: AiJsonValue): string {
if (typeof result === "string") return result;
@@ -48,7 +53,7 @@ export async function executeToolCall(
}
const arguments1 = normalizeToolArguments(args, userId);
const result = await handler(arguments1);
const result = await handler(arguments1, context);
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
+5 -5
View File
@@ -1,10 +1,10 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import path from "node:path";
import {readdir, readFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import {notesDir, notesRootFile} from "../../index.js";
import {asNonEmptyString} from "./utils.js";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
const logger = toolsLogger.child("search-notes");
+2 -2
View File
@@ -1,6 +1,6 @@
import {AiTool} from "../tool-types";
import {runCommand} from "../../util/utils";
import {asNonEmptyString} from "./utils";
import {runCommand} from "../../util/utils.js";
import {asNonEmptyString} from "./utils.js";
import {AiJsonObject} from "../tool-types";
export const shellExecuteTool = {
+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");
+2 -1
View File
@@ -1,3 +1,4 @@
import {AiJsonObject, AiJsonValue} from "../tool-types";
import type {ToolRuntimeContext} from "./runtime.js";
export type ToolHandler = (args?: AiJsonObject) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
export type ToolHandler = (args?: AiJsonObject, context?: ToolRuntimeContext) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
+582
View File
@@ -0,0 +1,582 @@
import path from "node:path";
import {readFile, rename, writeFile, mkdir, rm} from "node:fs/promises";
import {AiProvider} from "../../model/ai-provider.js";
import {Environment} from "../../common/environment.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveOptionalAiRuntimeTarget, type AiRuntimeTarget} from "../ai-runtime-target.js";
import {AiTool} from "../tool-types.js";
import {toolsLogger} from "./tool-logger.js";
import {asNonEmptyString} from "./utils.js";
const logger = toolsLogger.child("user-memory");
function memoryDir(): string {
return path.join(Environment.DATA_PATH, "memory");
}
export const USER_MEMORY_MAX_CHARS = 1000;
export type MemoryScope = "user" | "system";
export type MemoryAction = "add" | "replace" | "remove";
export type MemoryRuntimeContext = {
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
};
export type MemoryOperationResult =
| {success: true; scope: MemoryScope; filePath: string; content: string; chars: number; compressed: boolean}
| {success: false; scope: MemoryScope; error: string};
type CompressionRunResult = {
content: string;
};
export type MemoryCompressionRunner = (params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}) => Promise<string>;
function extractMistralText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part && typeof (part as {text?: unknown}).text === "string") {
return (part as {text: string}).text;
}
return "";
})
.join("");
}
export type MemoryToolName =
| "read_user_info"
| "read_system_info"
| "add_user_info"
| "add_system_info"
| "remove_user_info"
| "remove_system_info"
| "replace_user_info"
| "replace_system_info"
| "delete_user_info"
| "delete_system_info";
export const MEMORY_TOOL_NAMES: MemoryToolName[] = [
"read_user_info",
"read_system_info",
"add_user_info",
"add_system_info",
"remove_user_info",
"remove_system_info",
"replace_user_info",
"replace_system_info",
"delete_user_info",
"delete_system_info",
];
type MemoryToolSpec = {
name: MemoryToolName;
scope: MemoryScope;
kind: "read" | "write" | "delete";
action?: MemoryAction;
description: string;
prompt: string;
};
const MEMORY_TOOL_SPECS: MemoryToolSpec[] = [
{
name: "read_user_info",
scope: "user",
kind: "read",
description: "Read persistent user memory from user.md.",
prompt: `Use when you need to inspect remembered user facts before editing or answering.`,
},
{
name: "read_system_info",
scope: "system",
kind: "read",
description: "Read persistent assistant memory from system.md.",
prompt: `Use when you need to inspect remembered assistant instructions before editing or answering.`,
},
{
name: "add_user_info",
scope: "user",
kind: "write",
action: "add",
description: "Append a durable fact about the user to user.md.",
prompt: `Use for new user facts, preferences, identity details, and profile information. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "add_system_info",
scope: "system",
kind: "write",
action: "add",
description: "Append a durable instruction about the assistant to system.md.",
prompt: `Use for new assistant identity, style, or behavior instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_user_info",
scope: "user",
kind: "write",
action: "remove",
description: "Remove a specific user fact or fragment from user.md.",
prompt: `Use when the user asks to forget something about themselves. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_system_info",
scope: "system",
kind: "write",
action: "remove",
description: "Remove a specific assistant instruction or fragment from system.md.",
prompt: `Use when the user asks to forget something about the assistant. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_user_info",
scope: "user",
kind: "write",
action: "replace",
description: "Replace user.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered user info, such as "forget everything about me and remember only this". Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_system_info",
scope: "system",
kind: "write",
action: "replace",
description: "Replace system.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered assistant info or instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "delete_user_info",
scope: "user",
kind: "delete",
description: "Delete the user memory file user.md.",
prompt: `Use when the user asks to delete all remembered user info and remove the memory file entirely.`,
},
{
name: "delete_system_info",
scope: "system",
kind: "delete",
description: "Delete the assistant memory file system.md.",
prompt: `Use when the user asks to delete all remembered assistant info and remove the memory file entirely.`,
},
];
export const memoryToolPrompt = [
"Use the memory tools to manage persistent per-user memory.",
"- `read_*` shows the current file content before editing.",
"- `user.md` stores durable facts about the user.",
"- `system.md` stores durable facts/instructions about the assistant itself.",
"- `add_*` appends a new fact or instruction.",
"- `remove_*` removes a specific fact or fragment.",
"- `replace_*` rewrites the whole file when the user wants to overwrite memory.",
"- `delete_*` removes the file entirely.",
`- Keep each file at or below ${USER_MEMORY_MAX_CHARS} characters.`,
].join("\n");
function createMemoryTool(spec: MemoryToolSpec): AiTool {
return {
type: "function",
function: {
name: spec.name,
description: spec.description,
parameters: {
type: "object",
properties: spec.kind === "read" || spec.kind === "delete" ? {} : {
content: {
type: "string",
description: spec.action === "remove"
? "Exact text or fragment to remove from memory."
: "Text to append or replace in memory.",
},
},
required: spec.kind === "read" || spec.kind === "delete" ? [] : ["content"],
},
},
} satisfies AiTool;
}
export const memoryTools = MEMORY_TOOL_SPECS.map(createMemoryTool);
function normalizeUserId(userId: number): number | null {
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
}
function normalizeMemoryText(value: string): string {
return value.replaceAll("\r\n", "\n");
}
function getMemoryUserDir(userId: number): string {
return path.join(memoryDir(), String(userId));
}
export function getMemoryFilePath(userId: number, scope: MemoryScope): string {
return path.join(getMemoryUserDir(userId), `${scope}.md`);
}
async function ensureMemoryDir(userId: number): Promise<string> {
const dir = getMemoryUserDir(userId);
await mkdir(dir, {recursive: true});
return dir;
}
async function readMemoryFile(userId: number, scope: MemoryScope): Promise<string> {
const filePath = getMemoryFilePath(userId, scope);
try {
return normalizeMemoryText(await readFile(filePath, "utf-8"));
} catch (error) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
}
}
async function writeMemoryFile(userId: number, scope: MemoryScope, content: string): Promise<string> {
const normalized = normalizeMemoryText(content);
const filePath = getMemoryFilePath(userId, scope);
await ensureMemoryDir(userId);
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, normalized, "utf-8");
await rename(tempPath, filePath);
return filePath;
}
function trimToLimit(content: string, limit = USER_MEMORY_MAX_CHARS): string {
if (content.length <= limit) return content;
return content.slice(0, limit).trimEnd();
}
function stripCodeFences(content: string): string {
const trimmed = content.trim();
const fenced = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) return fenced[1].trim();
return trimmed;
}
function sameTarget(left: AiRuntimeTarget | undefined, right: AiRuntimeTarget | undefined): boolean {
if (!left || !right) return false;
return left.provider === right.provider
&& left.model === right.model
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
async function compressWithTarget(params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}): Promise<CompressionRunResult> {
const {target, scope, currentText, limit} = params;
const systemPrompt = [
"You compress persistent memory for a chat bot.",
"Return only the rewritten Markdown text.",
"Preserve important facts, preferences, identities, instructions, and durable context.",
"Remove noise, duplication, stale details, and low-value filler.",
`Keep the result at or below ${limit} characters.`,
"Do not add explanations, bullet labels, or code fences.",
].join("\n");
const userPrompt = [
`Memory scope: ${scope}`,
`Character limit: ${limit}`,
"Current memory:",
currentText.trim() || "(empty)",
"",
"Rewrite it as compact Markdown only.",
].join("\n");
logger.info("compress.start", {provider: target.provider, model: target.model, scope, chars: currentText.length});
switch (target.provider) {
case AiProvider.OPENAI: {
const client = createOpenAiClient(target);
const response = await client.chat.completions.create({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = response.choices[0]?.message?.content ?? "";
return {content: stripCodeFences(text)};
}
case AiProvider.MISTRAL: {
const client = createMistralClient(target);
const response = await client.chat.complete({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
} as Parameters<typeof client.chat.complete>[0]);
const text = extractMistralText(response.choices?.[0]?.message?.content);
return {content: stripCodeFences(text)};
}
case AiProvider.OLLAMA: {
const client = createOllamaClient(target);
const response = await client.chat({
model: target.model,
stream: false,
options: {temperature: 0},
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = typeof response.message?.content === "string" ? response.message.content : "";
return {content: stripCodeFences(text)};
}
}
}
export async function compressMemoryWithFallback(params: {
provider?: AiProvider;
currentTarget?: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit?: number;
}, runner: MemoryCompressionRunner = async (input) => (await compressWithTarget(input)).content): Promise<{content: string; compressed: boolean; usedTarget?: AiRuntimeTarget}> {
const limit = params.limit ?? USER_MEMORY_MAX_CHARS;
const trimmed = normalizeMemoryText(params.currentText);
if (trimmed.length <= limit) {
return {content: trimmed, compressed: false};
}
const explicitTarget = params.provider ? resolveOptionalAiRuntimeTarget(params.provider, "memoryCompress") : undefined;
const targets = [explicitTarget, params.currentTarget].filter((target, index, list): target is AiRuntimeTarget => !!target && list.findIndex(item => sameTarget(item, target)) === index);
for (const target of targets) {
try {
const content = trimToLimit(await runner({target, scope: params.scope, currentText: trimmed, limit}), limit);
if (content.length <= limit) {
return {content, compressed: true, usedTarget: target};
}
} catch (error) {
logger.warn("compress.failed", {
provider: params.provider,
scope: params.scope,
target: target.model,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {content: trimToLimit(trimmed, limit), compressed: true};
}
async function compressMemoryIfNeeded(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
limit?: number;
}): Promise<{content: string; compressed: boolean}> {
const {scope, context, limit = USER_MEMORY_MAX_CHARS} = params;
const result = await compressMemoryWithFallback({
provider: context?.provider,
currentTarget: context?.runtimeTarget,
scope,
currentText: params.content,
limit,
});
if (!result.compressed) {
return result;
}
if (result.content.length > limit) {
return {content: trimToLimit(result.content, limit), compressed: true};
}
return {content: result.content, compressed: true};
}
async function finalizeMemoryWrite(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
}): Promise<{filePath: string; content: string; compressed: boolean}> {
const {userId, scope, context} = params;
const compressed = await compressMemoryIfNeeded({userId, scope, content: params.content, context});
const filePath = await writeMemoryFile(userId, scope, compressed.content);
return {filePath, content: compressed.content, compressed: compressed.compressed};
}
function findMemoryToolSpec(toolName: string): MemoryToolSpec | undefined {
return MEMORY_TOOL_SPECS.find(spec => spec.name === toolName);
}
function isMemoryWriteTool(spec: MemoryToolSpec): spec is MemoryToolSpec & {kind: "write"; action: MemoryAction} {
return spec.kind === "write";
}
export async function buildUserMemoryPrompt(userId: number | undefined | null): Promise<string | undefined> {
const normalizedUserId = typeof userId === "number" ? normalizeUserId(userId) : null;
if (!normalizedUserId) return undefined;
const [userMemoryResult, systemMemoryResult] = await Promise.all([
readUserMemory(normalizedUserId, "user"),
readUserMemory(normalizedUserId, "system"),
]);
const userMemory = userMemoryResult.success ? userMemoryResult.content : "";
const systemMemory = systemMemoryResult.success ? systemMemoryResult.content : "";
const blocks: string[] = [];
if (systemMemory.trim()) {
blocks.push([
"## Assistant memory (system.md)",
"This is information about the assistant and its behavior.",
systemMemory.trim(),
].join("\n"));
}
if (userMemory.trim()) {
blocks.push([
"## User memory (user.md)",
"This is information about the user.",
userMemory.trim(),
].join("\n"));
}
return blocks.length ? blocks.join("\n\n") : undefined;
}
export async function readUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
try {
const content = await readMemoryFile(normalizedUserId, scope);
return {
success: true,
scope,
filePath: getMemoryFilePath(normalizedUserId, scope),
content,
chars: content.length,
compressed: false,
};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function updateUserMemory(args: {
userId: number;
scope: MemoryScope;
action: MemoryAction;
content?: string;
context?: MemoryRuntimeContext;
}): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(args.userId);
if (!normalizedUserId) {
return {success: false, scope: args.scope, error: "Invalid userId"};
}
try {
const current = await readMemoryFile(normalizedUserId, args.scope);
let next = current;
switch (args.action) {
case "add": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!content.trim()) {
return {success: false, scope: args.scope, error: "No content provided"};
}
next = [current.trimEnd(), content.trim()].filter(Boolean).join(current.trim() ? "\n\n" : "");
break;
}
case "replace": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
next = content;
break;
}
case "remove": {
const needle = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!needle.trim()) {
return {success: false, scope: args.scope, error: "No text to remove provided"};
}
if (!current.includes(needle)) {
return {success: false, scope: args.scope, error: "Text not found in memory"};
}
next = current.split(needle).join("").trim();
break;
}
}
const finalized = await finalizeMemoryWrite({userId: normalizedUserId, scope: args.scope, content: next, context: args.context});
logger.debug("write.done", {
userId: normalizedUserId,
scope: args.scope,
chars: finalized.content.length,
compressed: finalized.compressed,
filePath: finalized.filePath,
});
return {
success: true,
scope: args.scope,
filePath: finalized.filePath,
content: finalized.content,
chars: finalized.content.length,
compressed: finalized.compressed,
};
} catch (error) {
return {success: false, scope: args.scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function executeMemoryTool(toolName: MemoryToolName, args: {userId: number; content?: string}, context?: MemoryRuntimeContext): Promise<MemoryOperationResult> {
const spec = findMemoryToolSpec(toolName);
if (!spec) {
return {success: false, scope: "user", error: `Unknown memory tool: ${toolName}`};
}
if (spec.kind === "read") {
return readUserMemory(args.userId, spec.scope);
}
if (spec.kind === "delete") {
return deleteUserMemory(args.userId, spec.scope);
}
if (!isMemoryWriteTool(spec)) {
return {success: false, scope: spec.scope, error: `Unsupported memory tool: ${toolName}`};
}
return updateUserMemory({
userId: args.userId,
scope: spec.scope,
action: spec.action,
content: args.content,
context,
});
}
export async function deleteUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
const filePath = getMemoryFilePath(normalizedUserId, scope);
try {
await rm(filePath, {force: true});
return {success: true, scope, filePath, content: "", chars: 0, compressed: false};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {Ollama} from "ollama";
import {toolsLogger} from "./tool-logger";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import type {BoundaryValue} from "../../common/boundary-types";
+5 -5
View File
@@ -1,11 +1,11 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {toolsLogger} from "./tool-logger.js";
const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiJsonObject, AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
import {Environment} from "../../common/environment.js";
import {logError} from "../../util/utils.js";
import {AiJsonObject, AiTool} from "../tool-types.js";
import {asNonEmptyString} from "./utils.js";
export const getWeatherTool = {
type: "function",
+5 -5
View File
@@ -1,11 +1,11 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {toolsLogger} from "./tool-logger.js";
const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
import {asBoolean, asNonEmptyString} from "./utils";
import {Environment} from "../../common/environment.js";
import {logError} from "../../util/utils.js";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
import {asBoolean, asNonEmptyString} from "./utils.js";
type BraveSearchProfile = {
name?: string;
+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 {Environment} from "../common/environment";
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 {ChatMessage} from "./chat-messages-types";
import type {OpenAIChatMessage} from "./openai-chat-message";
@@ -12,6 +15,7 @@ import {prepareDocumentRag} from "./document-rag-pipeline";
import {persistRagArtifactAttachment} from "./rag-artifact-store";
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
import type {ToolRuntimeContext} from "./tools/runtime";
import {recordPipelineFallback, recordRagRun} from "../common/ai-observability.js";
import {
appendTranscriptToChatMessages,
collectTextMessages,
@@ -21,6 +25,7 @@ import {
stripAudioFromRunnerMessages,
toolRuntimeContextFromDownloads,
transcribeAudioIfNeeded,
collectStoredReplyChainAttachments,
UnifiedRunOptions,
} from "./unified-ai-runner.shared";
import {aiLog} from "../logging/ai-logger";
@@ -60,7 +65,7 @@ function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnaps
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
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,
messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id,
@@ -90,6 +95,12 @@ export async function prepareUnifiedAiRequestPipeline(params: {
controller: AbortController;
}): Promise<PreparedUnifiedAiRequest> {
const {options, config, downloads, streamMessage, controller} = params;
const replyChainDownloads = shouldPreferCurrentDownloads(options.text, downloads)
? downloads
: mergeReplyChainDownloads(
downloads,
attachmentsToDownloadedFiles(await collectStoredReplyChainAttachments(options.msg)),
);
const prepared: MutablePreparedContext = {
chatMessages: [],
imageCount: 0,
@@ -109,7 +120,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
details: {
phase: "ai_request_prepare",
provider: options.provider,
downloads: downloads.map(download => ({
downloads: replyChainDownloads.map(download => ({
kind: download.kind,
fileName: download.fileName,
mimeType: download.mimeType,
@@ -126,15 +137,15 @@ export async function prepareUnifiedAiRequestPipeline(params: {
options.msg,
options.text,
options.provider,
downloads,
replyChainDownloads,
config,
runtimeTargetFor(options, config),
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
);
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
prepared.imageCount = collected.imageCount;
prepared.firstRoundStatus = initialStatus(downloads, prepared.imageCount);
prepared.toolContext = toolRuntimeContextFromDownloads(downloads);
prepared.firstRoundStatus = initialStatus(replyChainDownloads, prepared.imageCount);
prepared.toolContext = toolRuntimeContextFromDownloads(replyChainDownloads);
return {
stage: "collect_conversation_context",
@@ -169,11 +180,11 @@ export async function prepareUnifiedAiRequestPipeline(params: {
prepared.transcript = await transcribeAudioIfNeeded(
options.provider,
options.msg.from?.id,
downloads,
replyChainDownloads,
streamMessage,
controller.signal,
).catch(error => {
if (downloads.some(isTranscribableAudioDownload)) throw error;
if (replyChainDownloads.some(isTranscribableAudioDownload)) throw error;
return "";
});
@@ -188,7 +199,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
const transcriptArtifact = await persistTranscriptArtifactAttachment({
provider: options.provider,
transcript,
downloads,
downloads: replyChainDownloads,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
});
@@ -233,7 +244,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
prepared.preparedDocumentRag = await prepareDocumentRag(
options.provider,
downloads,
replyChainDownloads,
prepared.chatMessages,
streamMessage,
config,
@@ -244,7 +255,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
const ragArtifact = await persistRagArtifactAttachment({
provider: options.provider,
prepared: prepared.preparedDocumentRag,
downloads,
downloads: replyChainDownloads,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
@@ -264,6 +275,10 @@ export async function prepareUnifiedAiRequestPipeline(params: {
await streamMessage.storeInternalAttachment(ragArtifact);
}
if (prepared.preparedDocumentRag) {
recordRagRun();
}
return {
stage: "document_rag",
status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
@@ -290,6 +305,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
];
const state = createAiRequestPipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const pipeline = new UserRequestPipeline({
stages,
stageNames: [
@@ -301,6 +317,44 @@ export async function prepareUnifiedAiRequestPipeline(params: {
"document_rag",
"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 streamMessage.storePipelineAudit(state.audit);
+155 -24
View File
@@ -2,6 +2,7 @@ import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {ifTrue, logError} from "../util/utils";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {getProviderAdapter} from "./provider-adapters";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
@@ -9,15 +10,23 @@ import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {ChatMessage} from "./chat-messages-types";
import {
allToolSchemaNames,
providerName,
RuntimeConfigSnapshot,
snapshotModel,
TELEGRAM_LIMIT,
UnifiedRunOptions,
} from "./unified-ai-runner.shared";
import {runToolRankStage} from "./tool-rank-stage";
import {runOpenAi} from "./unified-ai-runner.openai";
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
import {runOllama} from "./unified-ai-runner.ollama";
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 {
resolveTextToSpeechProviderForUser,
sendSynthesizedSpeech,
@@ -26,6 +35,7 @@ import {
} from "./text-to-speech";
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
import {aiLog} from "../logging/ai-logger";
import {recordPipelineFallback, recordTtsRun} from "../common/ai-observability.js";
function nowIso(): string {
return new Date().toISOString();
@@ -33,7 +43,7 @@ function nowIso(): string {
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
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,
messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id,
@@ -71,6 +81,21 @@ async function runProviderModelCall(params: {
switch (options.provider) {
case AiProvider.OPENAI:
if (config.openAiBackend === "compatible") {
await runOpenAiCompatible(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
streamMessage,
signal,
options.stream ?? true,
options.msg,
config,
prepared.toolContext,
downloads,
);
return;
}
await runOpenAi(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
@@ -159,6 +184,10 @@ export async function runUnifiedAiResponsePipeline(params: {
}): Promise<void> {
const {options, config, downloads, prepared, streamMessage, controller} = params;
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[] = [
{
@@ -177,6 +206,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",
async run() {
@@ -192,6 +277,13 @@ export async function runUnifiedAiResponsePipeline(params: {
return {
stage: "model_call",
status: "succeeded",
details: {
modelOutput: summarizeModelOutput({
text: streamMessage.getText(),
toolExecutions: streamMessage.getToolExecutions(),
outputAttachments: streamMessage.getOutputAttachments(),
}),
},
};
},
},
@@ -199,33 +291,31 @@ export async function runUnifiedAiResponsePipeline(params: {
name: "tool_loop",
async run() {
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 {
stage: "tool_loop",
status: executions.length ? "succeeded" : "skipped",
fallbackAction: executions.length ? undefined : "continue_without_stage",
...summary,
details: {
count: executions.length,
tools: executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
resultChars: execution.resultChars,
})),
...summary.details,
persistedSummaryArtifact: !!persisted,
},
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 +374,7 @@ export async function runUnifiedAiResponsePipeline(params: {
name: "text_to_speech",
async run() {
const status = await synthesizeResponseIfRequested({options, config, streamMessage});
recordTtsRun(status);
return {
stage: "text_to_speech",
status,
@@ -312,6 +403,8 @@ export async function runUnifiedAiResponsePipeline(params: {
stages,
stageNames: [
"audit_start",
"tool_rank",
"filter_tools",
"model_call",
"tool_loop",
"output_size_gate",
@@ -320,6 +413,44 @@ export async function runUnifiedAiResponsePipeline(params: {
"persist_output_artifacts",
"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 {
+99 -72
View File
@@ -1,30 +1,29 @@
import {Environment} from "../common/environment";
import {getMistralTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
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 {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {
contentFromMistralDelta,
executeToolBatch,
MAX_TOOL_ROUNDS,
MistralDeltaLike,
MistralDocumentReference,
mistralToolCalls,
normalizeMistralToolCalls,
roundStatus,
RuntimeConfigSnapshot,
StreamingToolCallAccumulator,
ToolCallData,
ToolExecutionMemory
} 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 {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
import {storeToolRankAudit} from "./tool-rank-audit";
export async function runMistral(
msg: Message,
@@ -39,8 +38,9 @@ export async function runMistral(
): Promise<void> {
const runnerStartedAt = Date.now();
const mistralAi = createMistralClient(config.mistralChatTarget);
const toolRanker = new ToolRanker(config);
const availableTools = getMistralTools(msg.from?.id === Environment.CREATOR_ID);
const adapter = getProviderAdapter(AiProvider.MISTRAL);
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", {
stream,
target: aiLogProviderTarget(config.mistralChatTarget),
@@ -50,49 +50,25 @@ export async function runMistral(
});
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
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");
streamMessage.setStatus(Environment.getSelectingToolsText());
await streamMessage.flush();
const toolRankStartedAt = Date.now();
const toolRankStartedAtIso = new Date().toISOString();
const rankerSelection = await toolRanker.selectTools({
const rankResult = await runToolRankStage({
provider: AiProvider.MISTRAL,
userQuery: latestUserTextFromMessages(messages),
model: config.mistralChatTarget.model,
round,
config,
availableTools,
round,
messages,
streamMessage,
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();
await streamMessage.flush();
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 filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
const requestTools = filteredTools.length ? filteredTools : undefined;
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
@@ -101,22 +77,24 @@ export async function runMistral(
if (!stream) {
const request = {
model: config.mistralChatTarget.model,
messages,
messages: requestMessages,
tools: requestTools,
documents: documents
} as Parameters<typeof mistralAi.chat.complete>[0];
const response = await mistralAi.chat.complete(request, {signal});
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 = normalizeMistralToolCalls(mistralToolCalls(message));
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;
if (!calls.length) return {shouldContinue: false};
messages.push({
role: "assistant",
content: text,
@@ -125,25 +103,50 @@ export async function runMistral(
function: {name: call.name, arguments: call.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] ?? "",
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: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
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,
});
}
continue;
return {shouldContinue: true};
}
const request = {
model: config.mistralChatTarget.model,
messages,
messages: requestMessages,
tools: requestTools,
documents: documents
} as Parameters<typeof mistralAi.chat.stream>[0];
const streamResponse = await mistralAi.chat.stream(request, {signal});
const streamResponse = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => mistralAi.chat.stream(request, {signal})),
});
aiLog("debug", "mistral.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
@@ -154,11 +157,10 @@ export async function runMistral(
const choice = event.data?.choices?.[0];
const delta = choice?.delta;
const mistralDelta = delta as MistralDeltaLike;
const mistralDelta = delta;
streamMessage.append(adapter.extractTextDelta(mistralDelta));
streamMessage.append(contentFromMistralDelta(mistralDelta));
const rawDeltaCalls = mistralToolCalls(mistralDelta);
const rawDeltaCalls = adapter.extractStreamingToolCalls(mistralDelta);
if (rawDeltaCalls.length) {
calls = toolCallAccumulator.add(rawDeltaCalls);
streamMessage.setStatus(Environment.getUseToolText(calls));
@@ -171,21 +173,46 @@ export async function runMistral(
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
if (!calls.length) return {shouldContinue: false};
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] ?? "",
requestMessages.push({
role: "assistant",
content: roundText,
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
});
await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
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);
}
}
+89 -71
View File
@@ -5,7 +5,6 @@ import {Environment} from "../common/environment";
import type {BoundaryValue} from "../common/boundary-types";
import {bot, notesDir} from "../index";
import {clamp, logError} from "../util/utils";
import {getOllamaTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama";
@@ -14,20 +13,20 @@ import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {
allToolSchemaNames,
appendOllamaToolResults,
dedupeToolCalls,
DEFAULT_OLLAMA_CONTEXT_SIZE,
executeToolBatch,
isOllamaModelActive,
isRecord,
MAX_OLLAMA_CONTEXT_SIZE,
MAX_TOOL_ROUNDS,
MIN_OLLAMA_CONTEXT_SIZE,
normalizeOllamaToolCalls,
OllamaToolCallLike,
roundStatus,
RuntimeConfigSnapshot,
safeJsonParseObject,
@@ -35,14 +34,15 @@ import {
ToolCallData,
ToolExecutionMemory
} 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 {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
import {getModelCapabilities} from "./provider-model-runtime";
import {AiProvider} from "../model/ai-provider";
import {Message} from "typescript-telegram-bot-api";
import {storeToolRankAudit} from "./tool-rank-audit";
export async function runOllama(
msg: Message,
@@ -157,9 +157,12 @@ export async function runOllama(
}
const toolMemory: ToolExecutionMemory = new Map();
const adapter = getProviderAdapter(AiProvider.OLLAMA);
try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now();
aiLog("debug", "ollama.round.start", {
round,
@@ -183,7 +186,7 @@ export async function runOllama(
let activeToolNames: string[] = [];
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", {
round,
@@ -191,44 +194,18 @@ export async function runOllama(
rankerEnabled: !!config.ollamaToolRankerTarget,
});
streamMessage.setStatus(Environment.getSelectingToolsText());
await streamMessage.flush();
const toolRankStartedAt = Date.now();
const toolRankStartedAtIso = new Date().toISOString();
const rankerSelection = await new ToolRanker(config).selectTools({
const rankResult = await runToolRankStage({
provider: AiProvider.OLLAMA,
userQuery: latestUserTextFromMessages(messages),
model,
round,
config,
availableTools: availableOllamaTools,
round,
messages,
streamMessage,
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,
model,
round,
startedAt: toolRankStartedAt,
startedAtIso: toolRankStartedAtIso,
selectedTools: rankerSelection.toolNames,
});
const filteredTools = [...new Set(filterRankedTools(availableOllamaTools, rankerSelection.toolNames))];
const filteredTools = [...new Set(ensureToolsSelected(availableOllamaTools, rankResult.filteredTools as Tool[], MEMORY_TOOL_NAMES) as Tool[])];
activeToolNames = filteredTools.map(t => t.function.name ?? "");
if (filteredTools.length > 0) {
request.tools = [...filteredTools];
@@ -256,24 +233,23 @@ export async function runOllama(
round,
tools: activeToolNames,
count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker,
usedRanker: rankResult.usedRanker,
});
}
if (!stream) {
const response = await ollama.chat({
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => ollama.chat({
...request,
stream: false
})),
});
const message = response.message;
const rawContent = message?.content ?? "";
const nativeCalls = dedupeToolCalls(
normalizeOllamaToolCalls(
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
),
adapter.extractToolCalls(message),
);
const responseText = rawContent;
@@ -298,10 +274,10 @@ export async function runOllama(
if (!nativeCalls.length) {
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", {
round,
@@ -319,22 +295,44 @@ export async function runOllama(
})),
});
appendOllamaToolResults(
messages,
calls,
await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory),
);
await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
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", {
round,
messageCount: request.messages?.length ?? 0,
});
const response = await ollama.chat({
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => ollama.chat({
...request,
stream: true
})),
});
aiLog("debug", "ollama.stream.open", {round});
@@ -354,10 +352,7 @@ export async function runOllama(
const localToolCalls: ToolCallData[] = [];
localToolCalls.push(...normalizeOllamaToolCalls(
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message));
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
const previousStatus = streamMessage.getStatus();
@@ -377,13 +372,10 @@ export async function runOllama(
}
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
streamMessage.append(chunk.message?.content ?? "");
streamMessage.append(adapter.extractTextDelta(chunk));
}
calls.push(...normalizeOllamaToolCalls(
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
calls.push(...adapter.extractStreamingToolCalls(chunk.message));
if (chunk.done) {
aiLog("debug", "ollama.stream.done", {
@@ -416,7 +408,7 @@ export async function runOllama(
duration: aiLogDuration(runnerStartedAt),
});
break;
return {shouldContinue: false};
}
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
@@ -439,7 +431,31 @@ 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: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
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;
@@ -471,9 +487,11 @@ export async function runOllama(
}).catch(logError);
}
appendOllamaToolResults(messages, calls, toolResults);
}
return {shouldContinue: true};
},
});
} finally {
if (interval) clearInterval(interval);
await adapter.finalize().catch(() => undefined);
}
}
@@ -0,0 +1,419 @@
import {Message} from "typescript-telegram-bot-api";
import type {
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming,
ChatCompletionTool,
} from "openai/resources/chat/completions";
import {Environment} from "../common/environment.js";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message";
import {createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import type {BoundaryValue} from "../common/boundary-types.js";
import {
AsyncIterableStream,
buildSystemInstruction,
MAX_TOOL_ROUNDS,
OpenAiChatCompletionResponseLike,
OpenAiChatCompletionStreamChunkLike,
RuntimeConfigSnapshot,
safeJsonParseObject,
ToolCallData,
ToolExecutionMemory,
} from "./unified-ai-runner.shared";
import {mergeToolCallChunks, normalizeStreamingTextDelta} from "./provider-adapter-contract.js";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
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 {ensureToolsSelected, getOpenAICompatibleTools} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {logError} from "../util/utils";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import type {AiProviderAdapter} from "./provider-adapters.js";
import {tryToUploadFiles} from "./openai-upload-files.js";
import {buildAssistantToolMessage, openAiResponseMessagesToChatCompletions} from "./openai-chat-completions.js";
function describeOpenAiCompatibleError(error: unknown): Record<string, unknown> {
const err = error as {
message?: unknown;
status?: unknown;
code?: unknown;
type?: unknown;
error?: unknown;
} | undefined;
return {
errorSummary: typeof err?.message === "string" ? err.message : String(error),
httpStatus: err?.status,
errorCode: err?.code,
errorType: err?.type,
};
}
async function executeChatCompletionWithOptionalToolFallback<T>(params: {
openAi: ReturnType<typeof createOpenAiClient>;
request: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming;
signal: AbortSignal;
stream: boolean;
}): Promise<T> {
try {
return await params.openAi.chat.completions.create(params.request as never, {signal: params.signal}) as T;
} catch (error) {
const requestWithTools = params.request as {tools?: unknown[]};
if (!requestWithTools.tools || !Array.isArray(requestWithTools.tools) || requestWithTools.tools.length === 0) {
aiLog("error", "openai_compatible.request.failed", {
stream: params.stream,
hasTools: false,
error: describeOpenAiCompatibleError(error),
});
throw error;
}
aiLog("warn", "openai_compatible.tools.retry_without_tools", {
stream: params.stream,
error: describeOpenAiCompatibleError(error),
});
const retryRequest = {...params.request} as ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming & {tools?: unknown[]};
delete retryRequest.tools;
try {
return await params.openAi.chat.completions.create(retryRequest as never, {signal: params.signal}) as T;
} catch (retryError) {
aiLog("error", "openai_compatible.request.retry_without_tools.failed", {
stream: params.stream,
hasTools: true,
error: describeOpenAiCompatibleError(retryError),
});
throw retryError;
}
}
}
function makeChatCompletionAdapter(): AiProviderAdapter {
const baseAdapter = getProviderAdapter(AiProvider.OPENAI);
return {
...baseAdapter,
callModel: baseAdapter.callModel.bind(baseAdapter),
mapMessages(messages: readonly unknown[]): unknown[] {
return openAiResponseMessagesToChatCompletions(messages as OpenAIChatMessage[]);
},
rankTools(config: RuntimeConfigSnapshot, options?: {forCreator?: boolean; vectorStoreIds?: string[]}): readonly BoundaryValue[] {
void config;
void options?.vectorStoreIds;
return getOpenAICompatibleTools(options?.forCreator) as BoundaryValue[];
},
extractTextDelta(input: unknown): string {
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
return chunk?.choices?.[0]?.delta?.content ?? "";
},
extractToolCalls(input: unknown): ToolCallData[] {
const response = input as OpenAiChatCompletionResponseLike | undefined;
const toolCalls = response?.choices?.[0]?.message?.tool_calls ?? [];
return toolCalls
.map((call, index) => ({
id: typeof call?.id === "string" && call.id.trim().length > 0 ? call.id : `openai_chat_${index}`,
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
argumentsText: typeof call?.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call?.function?.arguments ?? call?.arguments ?? {}),
}))
.filter(call => call.name.length > 0);
},
extractStreamingToolCalls(input: unknown): ToolCallData[] {
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
const toolCalls = chunk?.choices?.[0]?.delta?.tool_calls ?? [];
return toolCalls
.map((call, index) => ({
id: typeof call?.id === "string" && call.id.trim().length > 0
? call.id
: `openai_chat_${typeof call?.index === "number" ? call.index : index}`,
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
argumentsText: typeof call?.function?.arguments === "string"
? call.function.arguments
: call?.function?.arguments
? JSON.stringify(call.function.arguments)
: typeof call?.arguments === "string"
? call.arguments
: "",
}))
.filter(call => call.id.length > 0);
},
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
}
},
finalize: baseAdapter.finalize.bind(baseAdapter),
};
}
export async function runOpenAiCompatible(
msg: Message,
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
sourceMessage: Message,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
downloads: AiDownloadedFile[] = [],
): Promise<void> {
void downloads;
const runnerStartedAt = Date.now();
const openAi = createOpenAiClient(config.openAiChatTarget);
const adapter = makeChatCompletionAdapter();
const systemPrompt = buildSystemInstruction(
config,
DEFAULT_AI_RESPONSE_LANGUAGE,
false,
config.openAiChatTarget.systemPromptAdditions,
await buildUserMemoryPrompt(msg.from?.id),
);
let conversationMessages = [...openAiResponseMessagesToChatCompletions(messages)];
if (systemPrompt.trim().length) {
conversationMessages.unshift({role: "system", content: systemPrompt});
}
const availableTools = getOpenAICompatibleTools(msg.from?.id === Environment.CREATOR_ID) as ChatCompletionTool[];
aiLog("info", "openai_compatible.run.start", {
stream,
target: aiLogProviderTarget(config.openAiChatTarget),
inputMessages: messages.length,
sourceMessage: aiLogMessageIdentity(sourceMessage),
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
backend: config.openAiBackend,
});
const toolMemory: ToolExecutionMemory = new Map();
try {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, inputMessages: conversationMessages.length, stream});
const rankResult = await runToolRankStage({
provider: AiProvider.OPENAI,
model: config.openAiChatTarget.model,
round,
config,
availableTools: availableTools as readonly BoundaryValue[],
messages,
streamMessage,
signal,
});
const requestTools = ensureToolsSelected(
availableTools,
rankResult.filteredTools as ChatCompletionTool[],
MEMORY_TOOL_NAMES,
);
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.openAiChatTarget.model,
messages: conversationMessages,
tools: requestTools.length ? requestTools : undefined,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<OpenAiChatCompletionResponseLike>({
openAi,
request,
signal,
stream: false,
})),
}) as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
const responseText = typeof message?.content === "string" ? message.content : "";
streamMessage.append(responseText);
aiLog("debug", "openai_compatible.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
hasToolCalls: !!message?.tool_calls?.length,
});
const calls = adapter.extractToolCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.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 toolMessages: OpenAICompatibleChatMessage[] = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolMessages],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
if (toolMessage && toolMessage.role === "tool") {
toolMessage.content = "Error: " + uploadFilesResult.error;
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
return {shouldContinue: true};
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.openAiChatTarget.model,
messages: conversationMessages,
stream: true,
tools: requestTools.length ? requestTools : undefined,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>>({
openAi,
request,
signal,
stream: true,
})),
}) as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
let responseText = "";
let toolCallState: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const deltaText = adapter.extractTextDelta(chunk);
if (deltaText) {
const appendedText = normalizeStreamingTextDelta(responseText, deltaText);
responseText += appendedText;
streamMessage.append(appendedText);
}
const streamedCalls = adapter.extractStreamingToolCalls(chunk);
if (streamedCalls.length) {
toolCallState = mergeToolCallChunks(toolCallState, streamedCalls);
const activeCalls = toolCallState.filter(call => call.name.length > 0);
aiLog("info", "openai_compatible.stream.tool_call.added", {
round,
toolCalls: activeCalls.map(aiLogToolCall),
});
streamMessage.setStatus(Environment.getUseToolText(activeCalls));
await streamMessage.flush();
}
}
const calls = toolCallState.filter(call => call.name.length > 0);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.stream.done", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
calls: calls.map(call => ({
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return {shouldContinue: false};
streamMessage.clearStatus();
await streamMessage.flush();
const toolMessages: OpenAICompatibleChatMessage[] = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolMessages],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
if (toolMessage && toolMessage.role === "tool") {
toolMessage.content = "Error: " + uploadFilesResult.error;
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
return {shouldContinue: true};
},
});
} catch (error) {
aiLog("error", "openai_compatible.run.failed", {
duration: aiLogDuration(runnerStartedAt),
error: describeOpenAiCompatibleError(error),
});
throw error;
} finally {
await adapter.finalize().catch(logError);
}
}
+110 -153
View File
@@ -12,16 +12,14 @@ import type {
} from "openai/resources/responses/responses";
import {createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
import {
AsyncIterableStream,
buildSystemInstruction,
collectOpenAiResponseCodeInterpreterCalls,
collectOpenAiResponseFunctionCalls,
collectOpenAiResponseImages,
collectOpenAiResponseText,
executeToolBatch,
getOpenAIResponsesToolsWithImage,
MAX_TOOL_ROUNDS,
OPENAI_IMAGE_PARTIALS,
openAiResponseItemCallId,
@@ -32,20 +30,21 @@ import {
showOpenAiGeneratedImage,
ToolCallData,
ToolExecutionMemory,
errorMessage,
allToolSchemaNames
} from "./unified-ai-runner.shared";
import {bot} from "../index";
import fs from "node:fs";
import path from "node:path";
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 {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {logError} from "../util/utils";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {ToolRanker} from "./unified-ai-runner.tool-ranker";
import {AiProvider} from "../model/ai-provider";
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
import {storeToolRankAudit} from "./tool-rank-audit";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {tryToUploadFiles} from "./openai-upload-files.js";
export async function runOpenAi(
msg: Message,
@@ -60,22 +59,22 @@ export async function runOpenAi(
documentRag?: OpenAiDocumentRagContext,
): Promise<void> {
const runnerStartedAt = Date.now();
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = [...messages] as Array<ResponseInputItem | OpenAiResponseOutputItem>;
const openAi = createOpenAiClient(config.openAiChatTarget);
const ownsDocumentRag = !documentRag;
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
const toolRanker = new ToolRanker(config);
const availableTools = getOpenAIResponsesToolsWithImage(
config,
msg.from?.id === Environment.CREATOR_ID,
preparedDocumentRag?.vectorStoreIds ?? [],
);
const adapter = getProviderAdapter(AiProvider.OPENAI);
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = adapter.mapMessages(messages) as unknown as Array<ResponseInputItem | OpenAiResponseOutputItem>;
const availableTools = adapter.rankTools(config, {
forCreator: msg.from?.id === Environment.CREATOR_ID,
vectorStoreIds: preparedDocumentRag?.vectorStoreIds ?? [],
});
const systemPrompt = buildSystemInstruction(
config,
DEFAULT_AI_RESPONSE_LANGUAGE,
false,
config.openAiChatTarget.systemPromptAdditions,
await buildUserMemoryPrompt(msg.from?.id),
);
aiLog("info", "openai.run.start", {
@@ -90,46 +89,22 @@ export async function runOpenAi(
const toolMemory: ToolExecutionMemory = new Map();
try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now();
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
streamMessage.setStatus(Environment.getSelectingToolsText());
await streamMessage.flush();
const toolRankStartedAt = Date.now();
const toolRankStartedAtIso = new Date().toISOString();
const rankerSelection = await toolRanker.selectTools({
const rankResult = await runToolRankStage({
provider: AiProvider.OPENAI,
userQuery: latestUserTextFromMessages(messages),
model: config.openAiChatTarget.model,
round,
config,
availableTools,
round,
messages,
streamMessage,
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();
await streamMessage.flush();
await storeToolRankAudit({
streamMessage,
provider: AiProvider.OPENAI,
model: config.openAiChatTarget.model,
round,
startedAt: toolRankStartedAt,
startedAtIso: toolRankStartedAtIso,
selectedTools: rankerSelection.toolNames,
});
const filteredTools = filterRankedTools(availableTools, rankerSelection.toolNames);
const filteredTools = rankResult.filteredTools;
const requestTools = preparedDocumentRag?.vectorStoreIds.length
? (() => {
const tools = [...filteredTools];
@@ -140,9 +115,13 @@ export async function runOpenAi(
tools.unshift(fileSearchTool);
}
}
return tools.length ? tools : undefined;
const withMemory = ensureToolsSelected(availableTools, tools, MEMORY_TOOL_NAMES);
return withMemory.length ? withMemory : undefined;
})()
: (filteredTools.length ? filteredTools : undefined);
: (() => {
const withMemory = ensureToolsSelected(availableTools, filteredTools, MEMORY_TOOL_NAMES);
return withMemory.length ? withMemory : undefined;
})();
if (!stream) {
const request: ResponseCreateParamsNonStreaming = {
@@ -151,7 +130,9 @@ export async function runOpenAi(
tools: requestTools as ResponseCreateParamsNonStreaming["tools"],
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 OpenAiResponseLike;
const responseText = collectOpenAiResponseText(response);
streamMessage.append(responseText);
@@ -188,29 +169,37 @@ export async function runOpenAi(
});
}
const calls = collectOpenAiResponseFunctionCalls(response);
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.callId,
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
if (!calls.length) return {shouldContinue: false};
const toolCalls = calls.map(call => ({
id: call.callId,
id: call.id,
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" as const,
call_id: call.callId,
output: toolResults[index] ?? "",
}));
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolOutputs],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
@@ -230,8 +219,20 @@ export async function runOpenAi(
}
}
const continuation = decideToolLoopContinuation({
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];
continue;
return {shouldContinue: true};
}
let completedResponse: OpenAiResponseLike | null = null;
@@ -243,7 +244,9 @@ export async function runOpenAi(
parallel_tool_calls: true,
instructions: systemPrompt
};
const response = await openAi.responses.create(request, {signal}) as AsyncIterableStream<ResponseStreamEvent>;
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
}) as AsyncIterableStream<ResponseStreamEvent>;
aiLog("debug", "openai.stream.open", {round});
@@ -253,7 +256,7 @@ export async function runOpenAi(
switch (event.type) {
case "response.output_text.delta":
streamMessage.append(event.delta ?? "");
streamMessage.append(adapter.extractTextDelta(event));
break;
case "response.image_generation_call.in_progress":
streamMessage.setStatus(Environment.startingImageGenText);
@@ -301,14 +304,11 @@ export async function runOpenAi(
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 ?? "{}",
});
{
const streamedCalls = adapter.extractStreamingToolCalls(event);
if (streamedCalls.length) {
localToolCalls.push(...streamedCalls);
}
aiLog("info", "openai.stream.tool_call.added", {
round,
toolCalls: localToolCalls.map(aiLogToolCall)
@@ -383,29 +383,37 @@ export async function runOpenAi(
});
}
const calls = collectOpenAiResponseFunctionCalls(completedResponse);
const calls = adapter.extractToolCalls(completedResponse);
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.callId,
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
if (!calls.length) return {shouldContinue: false};
const toolCalls = calls.map(call => ({
id: call.callId,
id: call.id,
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 toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolOutputs],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
@@ -425,12 +433,27 @@ export async function runOpenAi(
}
}
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
const continuation = decideToolLoopContinuation({
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, ...(completedResponse.output ?? []), ...toolOutputs];
return {shouldContinue: true};
},
});
} finally {
if (ownsDocumentRag) {
await preparedDocumentRag?.cleanup().catch(logError);
}
await adapter.finalize().catch(logError);
}
}
@@ -493,72 +516,6 @@ async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, f
}
}
async function tryToUploadFiles(
msg: Message,
toolResults: string[]
): Promise<
| { found: false }
| { found: true, uploaded: true }
| { found: boolean, uploaded: false, error: string, toolIndex: number }
> {
let sendFileAttachment: {
result: SendFileAttachmentResult & { success: true },
toolIndex: number
} | null = null;
let found = false;
try {
for (const [index, toolResult] of toolResults.entries()) {
const raw = JSON.parse(toolResult);
const res = SendFileAttachmentResultSchema.safeParse(raw);
if (res.success) {
found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: index};
}
}
}
if (!found) {
return {found: false};
}
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
const attachmentPath = attachmentRoot
? path.join(
attachmentRoot,
String(msg.from?.id),
sendFileAttachment?.result?.attachment?.relativePath ?? "",
)
: "";
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(attachmentPath),
});
return {found: true, uploaded: true};
} catch (e) {
logError(e instanceof Error ? e : String(e));
return {
found: found,
uploaded: false,
error: errorMessage(e instanceof Error ? e : String(e)),
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
}
}
// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): string {
// if (typeof content === "string") return content;
// if (!Array.isArray(content)) return "";
+56 -32
View File
@@ -2,40 +2,39 @@ import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs";
import path from "node:path";
import type {BoundaryValue} from "../common/boundary-types";
import {AiProvider} from "../model/ai-provider";
import {ToolRankerFallbackPolicy} from "../common/policies";
import {Environment} from "../common/environment";
import {photoGenDir} from "../index";
import {delay, logError, replyToMessage} from "../util/utils";
import {MessageStore} from "../common/message-store";
import type {OpenAiResponseTool} from "./tool-mappers";
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers";
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message";
import {AiDownloadedFile} from "./telegram-attachments";
import {getRuntimeCapabilities} from "./provider-model-runtime";
import {StoredAttachment} from "../model/stored-attachment";
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
import {AiProvider} from "../model/ai-provider.js";
import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {Environment, type OpenAiBackend} from "../common/environment.js";
import {delay, logError, replyToMessage} from "../util/utils.js";
import {MessageStore} from "../common/message-store.js";
import type {OpenAiResponseTool} from "./tool-mappers.js";
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers.js";
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message.js";
import {AiDownloadedFile} from "./telegram-attachments.js";
import {getRuntimeCapabilities} from "./provider-model-runtime.js";
import {StoredAttachment} from "../model/stored-attachment.js";
import {AiChatMessage, ChatMessage} from "./chat-messages-types.js";
import {ListResponse, Ollama} from "ollama";
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
import {MessageImagePart, MessagePart} from "../common/message-part";
import {KeyedAsyncLock} from "../util/async-lock";
import {type AiRequestQueueTarget} from "./provider-request-queue";
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings";
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime.js";
import {MessageImagePart, MessagePart} from "../common/message-part.js";
import {KeyedAsyncLock} from "../util/async-lock.js";
import {type AiRequestQueueTarget} from "./provider-request-queue.js";
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator.js";
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings.js";
import {
isTranscribableAudioDownload,
resolveSpeechToTextProviderForUser,
transcribeSpeechDownloads
} from "./speech-to-text";
} from "./speech-to-text.js";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
import {MistralChatMessage} from "./mistral-chat-message";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline";
import {MistralChatMessage} from "./mistral-chat-message.js";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer.js";
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger.js";
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline.js";
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store";
import {filterUserVisibleStoredAttachments} from "../common/stored-attachment-utils";
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store.js";
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
export type {Message} from "typescript-telegram-bot-api";
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 toolResourceLocks = new KeyedAsyncLock();
function photoGenDir(): string {
return path.join(Environment.DATA_PATH, "cache", "photo", "gen");
}
export type UnifiedRunOptions = {
provider: AiProvider;
msg: Message;
requestId?: string;
isGuestMsg?: boolean;
text: string;
stream?: boolean;
@@ -270,6 +274,7 @@ export type RuntimeConfigSnapshot = {
openAiChatTarget: AiRuntimeTarget;
openAiImageTarget: AiRuntimeTarget;
openAiToolRankerTarget?: AiRuntimeTarget;
openAiBackend: OpenAiBackend;
};
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
@@ -303,9 +308,14 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"),
openAiBackend: Environment.OPENAI_BACKEND,
};
}
export function isOpenAiCompatibleBackend(config: RuntimeConfigSnapshot): boolean {
return config.openAiBackend === "compatible";
}
export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
if (part.imageParts?.length) return part.imageParts;
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
@@ -378,11 +388,13 @@ export function buildSystemInstruction(
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string {
return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n");
}
@@ -512,13 +524,13 @@ export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set<A
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 seen = new Set<string>();
let current = await MessageStore.get(msg.chat.id, msg.message_id);
for (let i = 0; current && i < limit; i++) {
for (const attachment of filterUserVisibleStoredAttachments(current?.attachments ?? [])) {
for (const attachment of filterUserInputStoredAttachments(current?.attachments ?? [])) {
const key = [
attachment.kind,
attachment.fileUniqueId || attachment.fileId,
@@ -1113,19 +1125,31 @@ export async function executeTool(
}
}
export function toolResourceKeys(toolCall: ToolCallData): string[] {
export function toolResourceKeys(toolCall: ToolCallData, userId?: number | undefined | null): string[] {
const args = safeJsonParseObject(toolCall.argumentsText);
const pathValue = typeof args.path === "string" ? args.path : undefined;
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined;
const memoryScope = toolCall.name.endsWith("_user_info") ? "user"
: toolCall.name.endsWith("_system_info") ? "system"
: undefined;
switch (toolCall.name) {
case "read_user_info":
case "read_system_info":
case "get_datetime":
case "web_search":
case "get_weather":
case "read_file":
case "list_directory":
return [];
case "add_user_info":
case "add_system_info":
case "remove_user_info":
case "remove_system_info":
case "replace_user_info":
case "replace_system_info":
return userId && memoryScope ? [`memory:${userId}:${memoryScope}`] : [];
case "create_file":
case "create_directory":
case "update_file":
@@ -1158,7 +1182,7 @@ export async function executeScheduledTool(
message: TelegramStreamMessage,
context: ToolRuntimeContext,
): Promise<string> {
const keys = toolResourceKeys(toolCall);
const keys = toolResourceKeys(toolCall, userId);
if (!keys.length) return executeTool(userId, toolCall, message, context);
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
}
@@ -1523,7 +1547,7 @@ export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, l
} {
const buffer = Buffer.from(b64, "base64");
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);
return {buffer, cachePath, fileName};
}
+42 -32
View File
@@ -1,20 +1,21 @@
import {ChatCompletionMessageParam} from "openai/resources/chat/completions";
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam} from "openai/resources/chat/completions";
import {ChatRequest} from "ollama";
import {BoundaryValue} from "../common/boundary-types";
import {ToolRankerFallbackPolicy} from "../common/policies";
import {AiProvider} from "../model/ai-provider";
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared";
import {BoundaryValue} from "../common/boundary-types.js";
import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {AiProvider} from "../model/ai-provider.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target.js";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger.js";
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
import {
buildRankerContext,
buildRankerTarget,
buildToolRankerPrompt,
filterRankedTools,
ToolRankerSelection,
} from "./tool-ranker-pipeline";
import {allToolSchemaNames} from "./unified-ai-runner.shared";
import {sanitizeToolRankerResult} from "./tool-ranker-metadata";
} from "./tool-ranker-pipeline.js";
import {allToolSchemaNames} from "./unified-ai-runner.shared.js";
import {sanitizeToolRankerResult} from "./tool-ranker-metadata.js";
import {resolveToolRankerFallbackSelection} from "./tool-ranker-fallback.js";
export class ToolRanker {
constructor(private readonly config: RuntimeConfigSnapshot) {
@@ -27,8 +28,15 @@ export class ToolRanker {
round: number;
signal: AbortSignal;
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> {
const {availableTools, provider, round, signal, userQuery} = args;
const runRanker = args.runRanker ?? this.runRanker.bind(this);
const availableNames = allToolSchemaNames(availableTools);
const fallbackPolicy = this.config.toolRankerFallbackPolicy;
const configuredTarget = buildRankerTarget(this.config, provider);
@@ -41,11 +49,10 @@ export class ToolRanker {
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
if (!target) {
if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) {
return {toolNames: [], usedRanker: false};
}
return {toolNames: availableNames, usedRanker: false};
return resolveToolRankerFallbackSelection({
fallbackPolicy,
availableToolNames: availableNames,
});
}
const startedAt = Date.now();
@@ -63,7 +70,7 @@ export class ToolRanker {
try {
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");
const selectedNames = sanitizeToolRankerResult({
raw,
@@ -100,13 +107,13 @@ export class ToolRanker {
target: aiLogProviderTarget(target),
fallbackTarget: aiLogProviderTarget(mainModelTarget),
duration: aiLogDuration(startedAt),
error: failureMessage,
errorSummary: failureMessage,
});
const fallbackRanker = buildToolRankerPrompt(
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({
raw,
availableToolNames: availableNames,
@@ -135,7 +142,7 @@ export class ToolRanker {
target: aiLogProviderTarget(target),
fallbackTarget: aiLogProviderTarget(mainModelTarget),
duration: aiLogDuration(startedAt),
error: fallbackErrorMessage,
errorSummary: fallbackErrorMessage,
});
failureMessage = fallbackErrorMessage;
@@ -148,17 +155,13 @@ export class ToolRanker {
target: aiLogProviderTarget(target),
fallbackPolicy,
duration: aiLogDuration(startedAt),
error: failureMessage,
errorSummary: failureMessage,
});
if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) {
return {toolNames: [], usedRanker: false};
}
return {
toolNames: availableNames,
usedRanker: false,
};
return resolveToolRankerFallbackSelection({
fallbackPolicy,
availableToolNames: availableNames,
});
}
}
@@ -224,12 +227,19 @@ export class ToolRanker {
{role: "user", content: userQuery},
] satisfies ChatCompletionMessageParam[];
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
const response = await openAi.chat.completions.create({
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
// only for official OpenAI endpoints.
const request: ChatCompletionCreateParamsNonStreaming = {
model: target.model,
messages,
response_format: {type: "json_object"},
});
};
if (!target.baseUrl) {
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
request.response_format = {type: "json_object"};
}
const response = await openAi.chat.completions.create(request);
return response.choices[0]?.message?.content?.trim() ?? "";
}
+27 -11
View File
@@ -35,6 +35,7 @@ import {persistErrorArtifactAttachment} from "./final-response-artifact-store";
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
import {AiRequestStore} from "../common/ai-request-store";
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 {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
@@ -49,6 +50,7 @@ async function executeUnifiedAiRequest(
const requestStartedAt = Date.now();
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
aiLog("info", "request.execute.start", {
requestId: options.requestId,
provider: providerName(options.provider),
stream: options.stream ?? true,
think: options.think,
@@ -74,6 +76,7 @@ async function executeUnifiedAiRequest(
if (preparedRequest.finishAfterTranscript) return;
aiLog("debug", "request.messages.collected", {
requestId: options.requestId,
provider: providerName(options.provider),
chatMessages: preparedRequest.chatMessages.length,
imageCount: preparedRequest.imageCount,
@@ -91,6 +94,7 @@ async function executeUnifiedAiRequest(
controller,
});
aiLog("success", "request.execute.done", {
requestId: options.requestId,
provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt),
responseChars: streamMessage.getText().length,
@@ -99,6 +103,7 @@ async function executeUnifiedAiRequest(
return;
} catch (e) {
aiLog("error", "request.execute.failed", {
requestId: options.requestId,
provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt),
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);
aiLog("info", "run.start", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider),
model: snapshotModel(options.provider, config),
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)) {
aiLog("warn", "run.rejected.unsupported_attachment", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider),
requestedAttachmentKinds: [...requestedAttachmentKinds],
});
@@ -150,6 +157,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
}).catch(logError);
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})),
});
return;
@@ -166,6 +174,8 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
provider: providerName(options.provider),
controller
});
options.requestId ??= cancel.id;
const requestId = options.requestId;
const streamMessage = new TelegramStreamMessage(
options.msg,
cancel.id,
@@ -180,10 +190,11 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
);
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
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();
recordAiRequestStart();
await AiRequestStore.put({
requestId: cancel.id,
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
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);
responseMessageId = queueMessage.message_id;
await AiRequestStore.put({
requestId: cancel.id,
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
responseMessageId,
@@ -207,8 +218,9 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
status: "running",
startedAt: aiRequestStartedAt,
}).catch(logError);
setAiCancelMessageId(cancel.id, queueMessage.message_id);
setAiCancelMessageId(requestId, queueMessage.message_id);
aiLog("info", "run.queue.enter", {
requestId,
cancelId: cancel.id,
queueMessageId: queueMessage.message_id,
target: aiLogProviderTarget(queueTarget),
@@ -217,15 +229,16 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
await aiProviderRequestQueue.enqueue(queueTarget, {
signal: controller.signal,
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));
await streamMessage.flush();
},
run: async (): Promise<null> => {
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);
aiLog("debug", "run.downloads.ready", {
requestId,
count: downloads.length,
downloads: downloads.map(d => ({
kind: d.kind,
@@ -239,12 +252,13 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
aiRequestStatus = "succeeded";
aiLog("success", "run.queue.task.done", {
requestId,
cancelId: cancel.id,
duration: aiLogDuration(queueWaitFinishedAt),
});
} finally {
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;
},
@@ -253,13 +267,13 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) {
aiRequestStatus = "aborted";
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());
await streamMessage.finish();
} else {
aiRequestStatus = "failed";
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);
await streamMessage.fail(e instanceof Error ? e : String(e));
try {
@@ -279,7 +293,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
} finally {
clearTimeout(timeout);
await AiRequestStore.put({
requestId: cancel.id,
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
responseMessageId,
@@ -291,8 +305,10 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
finishedAt: new Date().toISOString(),
error: aiRequestError,
}).catch(logError);
finishAiRequest(cancel.id);
recordAiRequestFinish(aiRequestStatus);
finishAiRequest(requestId);
aiLog("success", "run.finished", {
requestId,
cancelId: cancel.id,
provider: providerName(options.provider),
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 {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js";
import {raisePipelineRequestFailure} from "./fallback-failure.js";
import type {
PipelineAuditEvent,
PipelineFallbackPolicy,
@@ -66,7 +67,7 @@ export class UserRequestPipeline {
},
}));
if (decision.shouldFailRequest) {
throw new Error(`Required pipeline stage is not registered: ${stageName}`);
raisePipelineRequestFailure(decision, stageName);
}
continue;
}
@@ -112,7 +113,7 @@ export class UserRequestPipeline {
error: error instanceof Error ? error.message : String(error),
}));
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);
}
+82 -12
View File
@@ -3,18 +3,28 @@ import os from "node:os";
import path from "node:path";
import {parse as parseDotEnv} from "dotenv";
import {z} from "zod";
import {appLogger} from "../logging/logger";
import {appLogger} from "../logging/logger.js";
import type {BoundaryValue, ErrorLike} from "./boundary-types";
import {saveData} from "../db/database";
import {Answers} from "../model/answers";
import {ifTrue} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies";
import {ToolRankerFallbackPolicy} from "./policies";
import type {ToolCallData} from "../ai/unified-ai-runner";
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator";
import {Localization, type LocalizationParams} from "./localization";
import {Answers} from "../model/answers.js";
import {AiProvider} from "../model/ai-provider.js";
import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies.js";
import {ToolRankerFallbackPolicy} from "./policies.js";
import type {ToolCallData} from "../ai/unified-ai-runner.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js";
import {Localization, type LocalizationParams} from "./localization.js";
export const OpenAiBackendModes = {
OFFICIAL: "official",
COMPATIBLE: "compatible",
} as const;
export type OpenAiBackend = typeof OpenAiBackendModes[keyof typeof OpenAiBackendModes];
function parseBooleanLike(value: string): boolean {
const normalized = value.trim().toLowerCase();
return ["true", "t", "y", "1"].includes(normalized);
}
type EnvRecord = Record<string, string>;
type StringEnumLike = Record<string, string>;
@@ -53,7 +63,7 @@ function booleanWithDefaultSchema(defaultValue: boolean) {
return defaultValue;
}
return ifTrue(normalized);
return parseBooleanLike(normalized);
}, z.boolean())
.default(defaultValue)
.catch(defaultValue);
@@ -62,7 +72,7 @@ function booleanWithDefaultSchema(defaultValue: boolean) {
const optionalBooleanSchema = z
.preprocess(value => {
const normalized = normalizeString(value as BoundaryValue);
return normalized === undefined ? undefined : ifTrue(normalized);
return normalized === undefined ? undefined : parseBooleanLike(normalized);
}, z.boolean().optional())
.optional()
.catch(undefined);
@@ -211,6 +221,10 @@ const RuntimeEnvSchema = z.object({
SEND_TIME_TOOK: optionalBooleanSchema,
ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema,
DISABLE_LOCAL_TOOLS: optionalBooleanSchema,
LOCAL_TOOL_ALLOWLIST: optionalStringSchema,
LOCAL_TOOL_DENYLIST: optionalStringSchema,
MCP_SERVERS: optionalStringSchema,
OLLAMA_API_KEY: optionalStringSchema,
OLLAMA_ADDRESS: optionalStringSchema,
@@ -238,6 +252,10 @@ const RuntimeEnvSchema = z.object({
OPENAI_BASE_URL: optionalStringSchema,
OPENAI_API_KEY: optionalStringSchema,
OPENAI_BACKEND: enumWithDefaultSchema(
OpenAiBackendModes,
OpenAiBackendModes.OFFICIAL,
),
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
@@ -305,6 +323,10 @@ export class Environment {
static SEND_TIME_TOOK: boolean = false;
static ENABLE_PYTHON_INTERPRETER: boolean = false;
static DISABLE_LOCAL_TOOLS: boolean = false;
static LOCAL_TOOL_ALLOWLIST?: string;
static LOCAL_TOOL_DENYLIST?: string;
static MCP_SERVERS?: string;
static OLLAMA_API_KEY?: string;
static OLLAMA_ADDRESS?: string;
@@ -332,6 +354,7 @@ export class Environment {
static OPENAI_BASE_URL?: string;
static OPENAI_API_KEY?: string;
static OPENAI_BACKEND: OpenAiBackend = OpenAiBackendModes.OFFICIAL;
static OPENAI_MODEL: string = "";
static OPENAI_IMAGE_MODEL: string = "";
static OPENAI_TRANSCRIPTION_MODEL: string = "";
@@ -820,6 +843,34 @@ export class Environment {
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() {
return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData.");
}
@@ -960,6 +1011,9 @@ export class Environment {
choice: "/choice a, b, ..., c",
coin: "/coin",
debug: "/debug",
aiRequests: "/aiRequests",
aiAudit: "/aiAudit [reply|messageId|chatId messageId]",
aiMetrics: "/aiMetrics",
dice: "/dice",
distort: "/distort [amp] [wavelength]",
help: "/help",
@@ -1010,6 +1064,9 @@ export class Environment {
choice: this.text("commandDescriptions.choice", "Choose a random value"),
coin: this.text("commandDescriptions.coin", "Heads or tails"),
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"),
distort: this.text("commandDescriptions.distort", "Distortion of picture"),
help: this.text("commandDescriptions.help", "Show list of commands"),
@@ -1805,6 +1862,10 @@ export class Environment {
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false;
Environment.ENABLE_PYTHON_INTERPRETER = env.ENABLE_PYTHON_INTERPRETER ?? false;
Environment.DISABLE_LOCAL_TOOLS = env.DISABLE_LOCAL_TOOLS ?? false;
Environment.LOCAL_TOOL_ALLOWLIST = env.LOCAL_TOOL_ALLOWLIST;
Environment.LOCAL_TOOL_DENYLIST = env.LOCAL_TOOL_DENYLIST;
Environment.MCP_SERVERS = env.MCP_SERVERS;
Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY;
Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS;
@@ -1832,6 +1893,7 @@ export class Environment {
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
Environment.OPENAI_BACKEND = env.OPENAI_BACKEND;
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
@@ -1939,6 +2001,7 @@ export class Environment {
if (!has) {
this.ADMIN_IDS.add(id);
const {saveData} = await import("../db/database.js");
await saveData();
}
@@ -1950,6 +2013,7 @@ export class Environment {
if (has) {
this.ADMIN_IDS.delete(id);
const {saveData} = await import("../db/database.js");
await saveData();
}
@@ -1966,6 +2030,7 @@ export class Environment {
}
this.MUTED_IDS.add(id);
const {saveData} = await import("../db/database.js");
await saveData();
return true;
}
@@ -1976,6 +2041,7 @@ export class Environment {
}
this.MUTED_IDS.delete(id);
const {saveData} = await import("../db/database.js");
await saveData();
return true;
}
@@ -2028,6 +2094,10 @@ export class Environment {
this.OPENAI_API_KEY = newAIApiKey;
}
static setOpenAIBackend(newBackend: OpenAiBackend): void {
this.OPENAI_BACKEND = newBackend;
}
static setOpenAIModel(newModel: string): void {
this.OPENAI_MODEL = newModel;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import {AsyncLocalStorage} from "node:async_hooks";
import fs from "node:fs";
import path from "node:path";
import {appLogger} from "../logging/logger";
import {appLogger} from "../logging/logger.js";
const logger = appLogger.child("localization");
+3
View File
@@ -20,6 +20,9 @@ export type MessagePart = {
audios?: string[];
audioParts?: MessageAudioPart[];
documents?: string[];
documentNames?: string[];
videos?: string[];
videoNotes?: string[];
videoNames?: string[];
videoNoteNames?: string[];
}
+1 -4
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import {Environment} from "./environment";
import {StoredAttachment} from "../model/stored-attachment";
export {filterUserVisibleStoredAttachments} from "./attachment-visibility";
export function photoCachePathForUniqueId(uniqueId: string): string {
return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`);
@@ -44,7 +45,3 @@ export function uniqueStoredAttachments(attachments: StoredAttachment[]): Stored
return result;
}
export function filterUserVisibleStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
return attachments.filter(attachment => attachment.scope !== "internal_artifact");
}
+18 -1
View File
@@ -2055,7 +2055,24 @@ export class DatabaseManager {
}
private static async migrateLegacyNormalizedTables(): Promise<void> {
const messages = await DatabaseManager.getAllMessages();
// Do not call getAllMessages() here: it awaits DatabaseManager.ready, which
// is the promise currently waiting on ensureSchema(). That creates a
// self-deadlock during startup migrations.
const messages = await DatabaseManager.query<MessageDbRow>(`
SELECT
"id",
"chatId",
"replyToMessageId",
"fromId",
"text",
"quoteText",
"date",
"deletedByBotAt",
"attachments",
"pipelineAudit"
FROM "messages"
ORDER BY "chatId", "id"
`);
const attachments = messages.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message));
const artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message));
const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message));
+4 -4
View File
@@ -1,9 +1,9 @@
import * as fs from "fs";
import {Environment} from "../common/environment";
import {logError} from "../util/utils";
import {Answers} from "../model/answers";
import {Environment} from "../common/environment.js";
import {logError} from "../util/utils.js";
import {Answers} from "../model/answers.js";
import path from "node:path";
import {KeyedAsyncLock} from "../util/async-lock";
import {KeyedAsyncLock} from "../util/async-lock.js";
type DataJsonFile = {
admins: number[]
+97 -65
View File
@@ -1,9 +1,9 @@
import "dotenv/config";
import {appLogger} from "./logging/logger";
import {Environment} from "./common/environment";
import {appLogger} from "./logging/logger.js";
import {Environment} from "./common/environment.js";
import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api";
import {Command} from "./base/command";
import type {LogDetails} from "./logging/logger";
import {Command} from "./base/command.js";
import type {LogDetails} from "./logging/logger.js";
import {
initSystemSpecs,
logError,
@@ -13,68 +13,73 @@ import {
processInlineQuery,
processMyChatMember,
processNewMessage
} from "./util/utils";
import {Ae} from "./commands/ae";
import {Help} from "./commands/help";
import {Ignore} from "./commands/ignore";
import {Unignore} from "./commands/unignore";
import {Ping} from "./commands/ping";
import {RandomString} from "./commands/random-string";
import {SystemInfo} from "./commands/system-info";
import {Test} from "./commands/test";
import {readData, retrieveAnswers} from "./db/database";
import {Uptime} from "./commands/uptime";
import {WhatBetter} from "./commands/what-better";
import {When} from "./commands/when";
import {RandomInt} from "./commands/random-int";
import {Ban} from "./commands/ban";
import {Quote} from "./commands/quote";
import {OllamaSearch} from "./commands/ollama-search";
import {Id} from "./commands/id";
import {AdminsAdd} from "./commands/admins-add";
import {AdminsRemove} from "./commands/admins-remove";
import {Shutdown} from "./commands/shutdown";
import {Leave} from "./commands/leave";
import {OllamaChat} from "./commands/ollama-chat";
import {Start} from "./commands/start";
import {Choice} from "./commands/choice";
import {Coin} from "./commands/coin";
import {Qr} from "./commands/qr";
import {Distort} from "./commands/distort";
import {Dice} from "./commands/dice";
import {Unban} from "./commands/unban";
import {Title} from "./commands/title";
import {MessageDao} from "./db/message-dao";
import {DatabaseManager} from "./db/database-manager";
import {UserDao} from "./db/user-dao";
import {UserStore} from "./common/user-store";
import {CallbackCommand} from "./base/callback-command";
import {AiCancel} from "./callback_commands/ai-cancel";
import {AiRegenerate} from "./callback_commands/ai-regenerate";
import {MistralChat} from "./commands/mistral-chat";
import {Transliteration} from "./commands/transliteration";
import {OllamaListModels} from "./commands/ollama-list-models";
import {OllamaGetModel} from "./commands/ollama-get-model";
import {OllamaSetModel} from "./commands/ollama-set-model";
import {MistralGetModel} from "./commands/mistral-get-model";
import {MistralSetModel} from "./commands/mistral-set-model";
import {MistralListModels} from "./commands/mistral-list-models";
import {Debug} from "./commands/debug";
} from "./util/utils.js";
import {Ae} from "./commands/ae.js";
import {Help} from "./commands/help.js";
import {Ignore} from "./commands/ignore.js";
import {Unignore} from "./commands/unignore.js";
import {Ping} from "./commands/ping.js";
import {RandomString} from "./commands/random-string.js";
import {SystemInfo} from "./commands/system-info.js";
import {Test} from "./commands/test.js";
import {readData, retrieveAnswers} from "./db/database.js";
import {Uptime} from "./commands/uptime.js";
import {WhatBetter} from "./commands/what-better.js";
import {When} from "./commands/when.js";
import {RandomInt} from "./commands/random-int.js";
import {Ban} from "./commands/ban.js";
import {Quote} from "./commands/quote.js";
import {OllamaSearch} from "./commands/ollama-search.js";
import {Id} from "./commands/id.js";
import {AdminsAdd} from "./commands/admins-add.js";
import {AdminsRemove} from "./commands/admins-remove.js";
import {Shutdown} from "./commands/shutdown.js";
import {Leave} from "./commands/leave.js";
import {OllamaChat} from "./commands/ollama-chat.js";
import {Start} from "./commands/start.js";
import {Choice} from "./commands/choice.js";
import {Coin} from "./commands/coin.js";
import {Qr} from "./commands/qr.js";
import {Distort} from "./commands/distort.js";
import {Dice} from "./commands/dice.js";
import {Unban} from "./commands/unban.js";
import {Title} from "./commands/title.js";
import {MessageDao} from "./db/message-dao.js";
import {DatabaseManager} from "./db/database-manager.js";
import {UserDao} from "./db/user-dao.js";
import {UserStore} from "./common/user-store.js";
import {CallbackCommand} from "./base/callback-command.js";
import {AiCancel} from "./callback_commands/ai-cancel.js";
import {AiRegenerate} from "./callback_commands/ai-regenerate.js";
import {MistralChat} from "./commands/mistral-chat.js";
import {Transliteration} from "./commands/transliteration.js";
import {OllamaListModels} from "./commands/ollama-list-models.js";
import {OllamaGetModel} from "./commands/ollama-get-model.js";
import {OllamaSetModel} from "./commands/ollama-set-model.js";
import {MistralGetModel} from "./commands/mistral-get-model.js";
import {MistralSetModel} from "./commands/mistral-set-model.js";
import {MistralListModels} from "./commands/mistral-list-models.js";
import {Debug} from "./commands/debug.js";
import fs from "node:fs";
import path from "node:path";
import {OpenAIChat} from "./commands/openai-chat";
import {OpenAIListModels} from "./commands/openai-list-models";
import {OpenAIGetModel} from "./commands/openai-get-model";
import {OpenAISetModel} from "./commands/openai-set-model";
import {Info} from "./commands/info";
import {AdminsList} from "./commands/admins-list";
import {ExportDb} from "./commands/export-db";
import {ImportDb} from "./commands/import-db";
import {Settings} from "./commands/settings";
import {UserSettingsCallback} from "./callback_commands/user-settings";
import {TextToSpeech} from "./commands/text-to-speech";
import {SpeechToText} from "./commands/speech-to-text";
import {cleanupInternalArtifactCache} from "./ai/internal-artifact-store";
import {OpenAIChat} from "./commands/openai-chat.js";
import {OpenAIListModels} from "./commands/openai-list-models.js";
import {OpenAIGetModel} from "./commands/openai-get-model.js";
import {OpenAISetModel} from "./commands/openai-set-model.js";
import {Info} from "./commands/info.js";
import {AdminsList} from "./commands/admins-list.js";
import {ExportDb} from "./commands/export-db.js";
import {ImportDb} from "./commands/import-db.js";
import {Settings} from "./commands/settings.js";
import {UserSettingsCallback} from "./callback_commands/user-settings.js";
import {TextToSpeech} from "./commands/text-to-speech.js";
import {SpeechToText} from "./commands/speech-to-text.js";
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";
import {initializeMcpTools, shutdownMcpTools} from "./ai/mcp/mcp-registry.js";
process.setUncaughtExceptionCaptureCallback(logError);
@@ -119,6 +124,9 @@ export const commands: Command[] = [
new Settings(),
new TextToSpeech(),
new SpeechToText(),
new AIRequests(),
new AIAudit(),
new AIMetrics(),
new AdminsAdd(),
new AdminsRemove(),
@@ -186,6 +194,7 @@ export const filesDir = path.join(Environment.DATA_PATH, "files");
export const NOTES_HEADER = "## Notes\n";
export const notesDir = path.join(Environment.DATA_PATH, "notes");
export const notesRootFile = path.join(notesDir, "index.md");
export const memoryDir = path.join(Environment.DATA_PATH, "memory");
const logger = appLogger.child("main");
@@ -227,6 +236,11 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
await bot.stopPolling();
} catch (error) {
logError(error instanceof Error ? error : String(error));
} finally {
try {
await shutdownMcpTools();
} catch (error) {
logError(error instanceof Error ? error : String(error));
} finally {
try {
await DatabaseManager.close();
@@ -235,6 +249,7 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
}
process.exit(0);
}
}
}
async function main() {
@@ -248,7 +263,7 @@ async function main() {
});
await measureStartupStep("environment.load", () => Environment.load());
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, filesDir];
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, memoryDir, filesDir];
await measureStartupStep("prepare_directories", () => {
const created: string[] = [];
for (const dir of dirsToCheck) {
@@ -272,6 +287,23 @@ async function main() {
}, () => ({notesRootFilePath}));
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
await measureStartupStep("cleanup_stale_rag_provider_state", () => cleanupStaleRagProviderState(), () => ({retentionDays: 14}));
await measureStartupStep("mcp.initialize", () => initializeMcpTools());
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 => {
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 {createLogger, formatDuration, LogDetails, LogLevel} from "./logger";
import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger.js";
export type AiRunnerLogLevel = LogLevel;
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 = {
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 {
chat: AiCapabilityInfo | undefined;
+79 -36
View File
@@ -1,7 +1,7 @@
import * as si from "systeminformation";
import {appLogger} from "../logging/logger";
import {Command} from "../base/command";
import {CallbackCommand} from "../base/callback-command";
import {appLogger} from "../logging/logger.js";
import {Command} from "../base/command.js";
import {CallbackCommand} from "../base/callback-command.js";
import {
CallbackQuery,
ChatMember,
@@ -15,39 +15,40 @@ import {
TelegramBot,
User
} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors";
import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index";
import {Environment} from "../common/environment.js";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors.js";
import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index.js";
import os from "os";
import axios from "axios";
import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part";
import {StoredMessage} from "../model/stored-message";
import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part.js";
import {StoredMessage} from "../model/stored-message.js";
import sharp from "sharp";
import {UserStore} from "../common/user-store";
import {UserStore} from "../common/user-store.js";
import fs from "node:fs";
import path from "node:path";
import {MessageStore} from "../common/message-store";
import {SystemInfo} from "../commands/system-info";
import {PrefixResponse} from "../commands/prefix-response";
import {ChatCommand} from "../base/chat-command";
import {AiProvider} from "../model/ai-provider";
import {SendOptions} from "../model/send-options";
import {EditOptions} from "../model/edit-options";
import {StoredUser} from "../model/stored-user";
import {StoredAttachment} from "../model/stored-attachment";
import {AiDownloadedFile} from "../ai/telegram-attachments";
import {runUnifiedAi} from "../ai/unified-ai-runner";
import {enqueueTelegramApiCall} from "./telegram-api-queue";
import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock";
import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings";
import {Localization} from "../common/localization";
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
import {RandomUtils} from "./random-utils";
import {HtmlUtils} from "./html-utils";
import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner";
import type {BoundaryValue, ErrorLike} from "../common/boundary-types";
import {createStoredImageAttachment, photoCachePathForUniqueId, uniqueStoredAttachments} from "../common/stored-attachment-utils";
import {runTelegramMessageAttachmentPipeline} from "../ai/user-request-pipeline";
import {MessageStore} from "../common/message-store.js";
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
import {SystemInfo} from "../commands/system-info.js";
import {PrefixResponse} from "../commands/prefix-response.js";
import {ChatCommand} from "../base/chat-command.js";
import {AiProvider} from "../model/ai-provider.js";
import {SendOptions} from "../model/send-options.js";
import {EditOptions} from "../model/edit-options.js";
import {StoredUser} from "../model/stored-user.js";
import {StoredAttachment} from "../model/stored-attachment.js";
import {AiDownloadedFile} from "../ai/telegram-attachments.js";
import {runUnifiedAi} from "../ai/unified-ai-runner.js";
import {enqueueTelegramApiCall} from "./telegram-api-queue.js";
import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock.js";
import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings.js";
import {Localization} from "../common/localization.js";
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target.js";
import {RandomUtils} from "./random-utils.js";
import {HtmlUtils} from "./html-utils.js";
import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner.js";
import type {BoundaryValue, ErrorLike} from "../common/boundary-types.js";
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 fileWriteLocks = new KeyedAsyncLock();
@@ -1487,12 +1488,13 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText;
const imageNames = await loadImagesIfExists(msg);
const messageDownloads = includeDownloads ? downloads : [];
const storedImageAttachments = isStoredMessage(msg)
? (msg.attachments ?? []).filter(attachment => attachment.kind === "image" && fs.existsSync(attachment.cachePath))
const storedAttachments = isStoredMessage(msg)
? 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 && !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 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 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 audioParts: MessageAudioPart[] = [];
const documents: string[] = [];
const documentNames: string[] = [];
const videos: string[] = [];
const videoNames: string[] = [];
const videoNotes: string[] = [];
const videoNoteNames: string[] = [];
if (messageDownloads.length) {
messageDownloads
@@ -1544,21 +1554,51 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
messageDownloads
.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
.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
.filter(d => d.kind === "video-note")
.forEach(v => {
const data = v.buffer.toString("base64");
videoNotes.push(data);
videoNoteNames.push(v.fileName);
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 = [
quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "",
cleanText ?? ""
@@ -1576,8 +1616,11 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
audios: audios.length ? audios : undefined,
audioParts: audioParts.length ? audioParts : undefined,
documents: documents.length ? documents : undefined,
documentNames: documentNames.length ? documentNames : undefined,
videos: videos.length ? videos : undefined,
videoNames: videoNames.length ? videoNames : 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);
});
+14
View File
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
const {Environment} = await import("../dist/common/environment.js");
test("openai backend defaults to official", () => {
assert.equal(Environment.OPENAI_BACKEND, "official");
});
test("openai backend setter updates runtime config", () => {
Environment.setOpenAIBackend("compatible");
assert.equal(Environment.OPENAI_BACKEND, "compatible");
Environment.setOpenAIBackend("official");
});
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import {OpenAI} from "openai";
const {extractOpenAiChatToolCalls} = await import("../dist/ai/provider-adapter-contract.js");
const baseURL = process.env.OPENAI_COMPATIBLE_TEST_BASE_URL;
const model = process.env.OPENAI_COMPATIBLE_TEST_MODEL;
const apiKey = process.env.OPENAI_COMPATIBLE_TEST_API_KEY ?? process.env.OPENAI_API_KEY ?? "test";
test("openai-compatible chat.completions tool loop works on a real server", {skip: !baseURL || !model}, async () => {
const client = new OpenAI({baseURL, apiKey});
const response = await client.chat.completions.create({
model,
temperature: 0,
messages: [
{role: "system", content: "You must call the ping tool exactly once. Do not answer in plain text."},
{role: "user", content: "ping"},
],
tools: [{
type: "function",
function: {
name: "ping",
description: "Return a ping token.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
}],
tool_choice: {
type: "function",
function: {name: "ping"},
},
});
const calls = extractOpenAiChatToolCalls(response);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, "ping");
});
+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);
});
+144
View File
@@ -0,0 +1,144 @@
import test from "node:test";
import assert from "node:assert/strict";
const {
extractOpenAiToolCalls,
extractOpenAiStreamingToolCalls,
extractOpenAiTextDelta,
extractOpenAiChatToolCalls,
extractOpenAiChatStreamingToolCalls,
extractOpenAiChatTextDelta,
mergeToolCallChunks,
normalizeStreamingTextDelta,
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("openai chat contract extracts text delta and tool calls", () => {
assert.equal(extractOpenAiChatTextDelta({choices: [{delta: {content: "hello chat"}}]}), "hello chat");
assert.equal(normalizeStreamingTextDelta("hel", "hello"), "lo");
assert.equal(normalizeStreamingTextDelta("hel", "lo"), "lo");
const calls = extractOpenAiChatToolCalls({
choices: [{
message: {
tool_calls: [{
id: "chat-1",
function: {
name: "read_user_info",
arguments: "{\"userId\":123}",
},
}],
},
}],
});
assert.equal(calls.length, 1);
assert.equal(calls[0].id, "chat-1");
assert.equal(calls[0].name, "read_user_info");
const streamed = extractOpenAiChatStreamingToolCalls({
choices: [{
delta: {
tool_calls: [{
index: 0,
id: "chat-2",
function: {
name: "write_note",
arguments: "{\"text\":",
},
}],
},
}],
});
assert.equal(streamed.length, 1);
assert.equal(streamed[0].id, "chat-2");
assert.equal(streamed[0].name, "write_note");
assert.equal(streamed[0].argumentsText, "{\"text\":");
const merged = mergeToolCallChunks([
{id: "chat-2", name: "", argumentsText: "{\"text\":"},
], [{
id: "chat-2",
name: "write_note",
argumentsText: "\"hello\"}",
}]);
assert.equal(merged.length, 1);
assert.equal(merged[0].name, "write_note");
assert.equal(merged[0].argumentsText, "{\"text\":\"hello\"}");
});
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");
});
+27 -94
View File
@@ -1,32 +1,13 @@
import test, {after} from "node:test";
import test from "node:test";
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-"));
process.env.BOT_TOKEN = process.env.BOT_TOKEN ?? "test-token";
process.env.CREATOR_ID = process.env.CREATOR_ID ?? "1";
process.env.DATA_PATH = tempRoot;
process.env.DB_PATH = `file:${path.join(tempRoot, "test.sqlite")}`;
process.env.TEST_ENVIRONMENT = "true";
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 {
buildRagArtifactPayload,
} = await import("../dist/ai/rag-artifact-payload.js");
const {
filterUserVisibleStoredAttachments,
} = await import("../dist/common/attachment-visibility.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", () => {
const visible = filterUserVisibleStoredAttachments([
@@ -50,65 +31,26 @@ test("internal artifacts are not treated as user-visible attachments", () => {
assert.equal(visible[0].fileId, "visible");
});
test("RAG artifacts persist structured ollama metadata", async () => {
const chatId = 42;
const messageId = 7;
const attachment = await persistRagArtifactAttachment({
test("RAG artifact payload keeps ollama retrieval metadata", () => {
const payload = buildRagArtifactPayload({
provider: AiProvider.OLLAMA,
prepared: {
provider: AiProvider.OLLAMA,
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",
createdAt: "2026-01-01T00:00:00.000Z",
sources: [{
fileId: "file-1",
fileName: "report.txt",
buffer: Buffer.from("hello world"),
path: path.join(tempRoot, "report.txt"),
mimeType: "text/plain",
sizeBytes: 12,
sha256: "abc123",
uploadedFileId: "uploaded-1",
}],
chatId,
messageId,
details: {
providerState: {
provider: AiProvider.OLLAMA,
prepared: true,
embeddingModel: "nomic-embed-text:latest",
topK: 8,
chunkSize: 1400,
chunkOverlap: 220,
maxContextChars: 14000,
artifact: {
query: "What is in the file?",
extractedDocuments: [
{documentIndex: 0, fileName: "report.txt", textChars: 120},
],
@@ -126,29 +68,20 @@ test("RAG artifacts persist structured ollama metadata", async () => {
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,
},
},
query: "What is in the file?",
},
});
assert.equal(attachment?.artifactKind, "rag");
assert.equal(fs.existsSync(attachment.cachePath), true);
const stored = await ArtifactStore.getByMessage(chatId, messageId);
assert.equal(stored.length, 1);
assert.equal(stored[0].kind, "rag");
assert.equal(stored[0].payload.providerState.query, "What is in the file?");
assert.equal(stored[0].payload.providerState.selectedChunks[0].score, 0.91);
assert.equal(stored[0].payload.providerState.skippedDocuments[0].reason, "unsupported format");
assert.equal(stored[0].payload.providerState.ollama.embeddingModel, "nomic-embed-text:latest");
assert.equal(payload.artifactKind, "rag");
assert.equal(payload.provider, AiProvider.OLLAMA);
assert.equal(payload.sources[0].uploadedFileId, "uploaded-1");
assert.equal(payload.providerState.provider, AiProvider.OLLAMA);
assert.equal(payload.providerState.query, "What is in the file?");
assert.equal(payload.providerState.selectedChunks[0].score, 0.91);
assert.equal(payload.providerState.skippedDocuments[0].reason, "unsupported format");
assert.equal(payload.providerState.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"],
});
});

Some files were not shown because too many files have changed in this diff Show More