Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a99605e6 | |||
| 321d185592 | |||
| a3f19f0413 | |||
| c613c636e1 | |||
| 7f5011b871 | |||
| 5b67e23060 | |||
| a143d512ab | |||
| d47e2288d6 | |||
| 7b2bc93bc1 | |||
| 75253534d8 | |||
| 53e9798193 | |||
| df39d89ea8 | |||
| 1773b44edd | |||
| 507b15aa5f | |||
| d163d72a0b | |||
| 57985ce87b | |||
| 9a105caf0b | |||
| 13df2a1c23 | |||
| 9352ade19f | |||
| 9d6cdb008b | |||
| e520c412af | |||
| 58f5a645fd | |||
| c3481dfcfe | |||
| b16c213afb | |||
| 8aede4b053 |
@@ -43,6 +43,18 @@ ONLY_FOR_CREATOR_MODE=false
|
|||||||
# Use user names in AI prompts
|
# Use user names in AI prompts
|
||||||
USE_NAMES_IN_PROMPT=true
|
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)
|
# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md)
|
||||||
SYSTEM_PROMPT=
|
SYSTEM_PROMPT=
|
||||||
|
|
||||||
@@ -91,6 +103,10 @@ OLLAMA_MAX_CONCURRENT_REQUESTS=1
|
|||||||
# OpenAI
|
# OpenAI
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
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_MODEL=gpt-4.1-nano
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1-mini
|
OPENAI_IMAGE_MODEL=gpt-image-1-mini
|
||||||
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
|
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
|
||||||
@@ -99,6 +115,14 @@ OPENAI_TTS_VOICE=alloy
|
|||||||
OPENAI_TTS_INSTRUCTIONS=
|
OPENAI_TTS_INSTRUCTIONS=
|
||||||
OPENAI_MAX_CONCURRENT_REQUESTS=3
|
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
|
# Per-capability AI endpoint overrides
|
||||||
# Pattern:
|
# Pattern:
|
||||||
# <PROVIDER>_<CAPABILITY>_MODEL=
|
# <PROVIDER>_<CAPABILITY>_MODEL=
|
||||||
@@ -113,6 +137,7 @@ OPENAI_MAX_CONCURRENT_REQUESTS=3
|
|||||||
# OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
# OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
||||||
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
|
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
|
||||||
# TRANSCRIPTION, STT, TTS.
|
# TRANSCRIPTION, STT, TTS.
|
||||||
|
# Backend override: OPENAI_BACKEND=official|compatible.
|
||||||
#
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
|
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
|
||||||
|
|||||||
@@ -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 user’s 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.
|
||||||
@@ -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.
|
|
||||||
@@ -7,6 +7,7 @@ Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written i
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (MISTRAL_API_KEY, OPENAI_API_KEY, OLLAMA_ADDRESS)
|
# 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 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.
|
# 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.
|
`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup.
|
||||||
`/importdb` restores the database from the JSON backup format.
|
`/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`:
|
For local Ollama document RAG, install an embedding model locally and set it in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.38.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"systeminformation": "^5.31.6",
|
"systeminformation": "^5.31.6",
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"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/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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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": ["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=="],
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
},
|
},
|
||||||
"providerChoice.default": "Default",
|
"providerChoice.default": "Default",
|
||||||
"errorText": "⚠️ An error occurred.",
|
"errorText": "⚠️ An error occurred.",
|
||||||
|
"pipelineFallback.generic": "⚠️ I had to skip part of the request, but I can continue.",
|
||||||
|
"pipelineFallback.notifyUser": "⚠️ I hit a problem and need to continue with a fallback.",
|
||||||
|
"pipelineFallback.failRequest": "⚠️ I could not finish this request.",
|
||||||
|
"pipelineFallback.documentRag": "⚠️ Document retrieval failed, so I will answer without RAG.",
|
||||||
|
"pipelineFallback.speechToText": "⚠️ Speech transcription failed, so I will continue without the audio transcript.",
|
||||||
|
"pipelineFallback.textToSpeech": "⚠️ Text-to-speech failed, so I will continue without audio output.",
|
||||||
|
"pipelineFallback.toolLoop": "⚠️ Tool execution failed, so I will continue without that tool.",
|
||||||
"waitThinkText": "⏳ Let me think...",
|
"waitThinkText": "⏳ Let me think...",
|
||||||
"analyzingPictureText": "🔍 Analyzing the image...",
|
"analyzingPictureText": "🔍 Analyzing the image...",
|
||||||
"analyzingPicturesText": "🔍 Analyzing the images...",
|
"analyzingPicturesText": "🔍 Analyzing the images...",
|
||||||
@@ -176,6 +183,9 @@
|
|||||||
"getWhenPluralUnitText": "{unit}s",
|
"getWhenPluralUnitText": "{unit}s",
|
||||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||||
"commandDescriptions": {
|
"commandDescriptions": {
|
||||||
|
"aiAudit": "Inspect AI request audit and artifacts",
|
||||||
|
"aiMetrics": "Show AI observability counters",
|
||||||
|
"aiRequests": "Show recent AI requests",
|
||||||
"ae": "evaluation",
|
"ae": "evaluation",
|
||||||
"adminsAdd": "Add user to admins",
|
"adminsAdd": "Add user to admins",
|
||||||
"adminsRemove": "Remove user from admins",
|
"adminsRemove": "Remove user from admins",
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
},
|
},
|
||||||
"providerChoice.default": "По умолчанию",
|
"providerChoice.default": "По умолчанию",
|
||||||
"errorText": "⚠️ Произошла ошибка.",
|
"errorText": "⚠️ Произошла ошибка.",
|
||||||
|
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.",
|
||||||
|
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
|
||||||
|
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
|
||||||
|
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
|
||||||
|
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
|
||||||
|
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
|
||||||
|
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
|
||||||
"waitThinkText": "⏳ Дайте-ка подумать...",
|
"waitThinkText": "⏳ Дайте-ка подумать...",
|
||||||
"analyzingPictureText": "🔍 Анализирую изображение...",
|
"analyzingPictureText": "🔍 Анализирую изображение...",
|
||||||
"analyzingPicturesText": "🔍 Анализирую изображения...",
|
"analyzingPicturesText": "🔍 Анализирую изображения...",
|
||||||
@@ -202,6 +209,9 @@
|
|||||||
"getWhenPluralUnitText": "{unit}",
|
"getWhenPluralUnitText": "{unit}",
|
||||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||||
"commandDescriptions": {
|
"commandDescriptions": {
|
||||||
|
"aiRequests": "Показать последние AI-запросы",
|
||||||
|
"aiAudit": "Показать аудит AI-запроса и артефакты",
|
||||||
|
"aiMetrics": "Показать счётчики AI-обсервабилити",
|
||||||
"ae": "вычисление",
|
"ae": "вычисление",
|
||||||
"adminsAdd": "Добавить пользователя в администраторы",
|
"adminsAdd": "Добавить пользователя в администраторы",
|
||||||
"adminsRemove": "Удалить пользователя из администраторов",
|
"adminsRemove": "Удалить пользователя из администраторов",
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
},
|
},
|
||||||
"providerChoice.default": "За замовчуванням",
|
"providerChoice.default": "За замовчуванням",
|
||||||
"errorText": "⚠️ Сталася помилка.",
|
"errorText": "⚠️ Сталася помилка.",
|
||||||
|
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.",
|
||||||
|
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
|
||||||
|
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
|
||||||
|
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
|
||||||
|
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
|
||||||
|
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
|
||||||
|
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
|
||||||
"waitThinkText": "⏳ Дайте-но подумати...",
|
"waitThinkText": "⏳ Дайте-но подумати...",
|
||||||
"analyzingPictureText": "🔍 Аналізую зображення...",
|
"analyzingPictureText": "🔍 Аналізую зображення...",
|
||||||
"analyzingPicturesText": "🔍 Аналізую зображення...",
|
"analyzingPicturesText": "🔍 Аналізую зображення...",
|
||||||
@@ -201,6 +208,9 @@
|
|||||||
"getWhenPluralUnitText": "{unit}",
|
"getWhenPluralUnitText": "{unit}",
|
||||||
"getWhenDurationText": "{prefix}{value} {unit}",
|
"getWhenDurationText": "{prefix}{value} {unit}",
|
||||||
"commandDescriptions": {
|
"commandDescriptions": {
|
||||||
|
"aiRequests": "Показати останні AI-запити",
|
||||||
|
"aiAudit": "Показати аудит AI-запиту та артефакти",
|
||||||
|
"aiMetrics": "Показати лічильники AI-спостережуваності",
|
||||||
"help": "Показати список команд",
|
"help": "Показати список команд",
|
||||||
"settings": "Налаштування користувача",
|
"settings": "Налаштування користувача",
|
||||||
"start": "Запустити бота",
|
"start": "Запустити бота",
|
||||||
|
|||||||
Generated
+115
-115
@@ -17,8 +17,8 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.38.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"systeminformation": "^5.31.6",
|
"systeminformation": "^5.31.6",
|
||||||
@@ -30,12 +30,12 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.3"
|
"typescript-eslint": "^8.59.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
@@ -1246,9 +1246,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.8.0",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
@@ -1286,17 +1286,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/type-utils": "8.59.3",
|
"@typescript-eslint/type-utils": "8.59.4",
|
||||||
"@typescript-eslint/utils": "8.59.3",
|
"@typescript-eslint/utils": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@@ -1309,7 +1309,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.59.4",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
@@ -1325,16 +1325,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1350,14 +1350,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||||
"@typescript-eslint/types": "^8.59.3",
|
"@typescript-eslint/types": "^8.59.4",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1372,14 +1372,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1390,9 +1390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1407,15 +1407,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||||
"@typescript-eslint/utils": "8.59.3",
|
"@typescript-eslint/utils": "8.59.4",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@@ -1432,9 +1432,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1446,16 +1446,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.59.3",
|
"@typescript-eslint/project-service": "8.59.4",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -1513,16 +1513,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1537,13 +1537,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1590,6 +1590,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -1661,31 +1673,6 @@
|
|||||||
"proxy-from-env": "^2.1.0"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -2575,6 +2562,19 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -2847,9 +2847,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openai": {
|
"node_modules/openai": {
|
||||||
"version": "6.37.0",
|
"version": "6.38.0",
|
||||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz",
|
||||||
"integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==",
|
"integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openai": "bin/cli"
|
"openai": "bin/cli"
|
||||||
@@ -2959,14 +2959,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.13.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.14.0",
|
||||||
"pg-protocol": "^1.13.0",
|
"pg-protocol": "^1.14.0",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -2974,7 +2974,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.3.0"
|
"pg-cloudflare": "^1.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -2986,16 +2986,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.12.0",
|
"version": "2.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -3008,18 +3008,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-pool": {
|
"node_modules/pg-pool": {
|
||||||
"version": "3.13.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg": ">=8.0"
|
"pg": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.13.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
@@ -3455,16 +3455,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
|
||||||
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
|
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.3",
|
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||||
"@typescript-eslint/parser": "8.59.3",
|
"@typescript-eslint/parser": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||||
"@typescript-eslint/utils": "8.59.3"
|
"@typescript-eslint/utils": "8.59.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
|||||||
+4
-4
@@ -22,8 +22,8 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.38.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"systeminformation": "^5.31.6",
|
"systeminformation": "^5.31.6",
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.3"
|
"typescript-eslint": "^8.59.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {Mistral} from "@mistralai/mistralai";
|
import {Mistral} from "@mistralai/mistralai";
|
||||||
import {Ollama} from "ollama";
|
import {Ollama} from "ollama";
|
||||||
import {OpenAI} from "openai";
|
import {OpenAI} from "openai";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment.js";
|
||||||
import {AiModelCapabilities} from "../model/ai-model-capabilities";
|
import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
|
||||||
export type AiCapabilityName = keyof AiModelCapabilities;
|
export type AiCapabilityName = keyof AiModelCapabilities;
|
||||||
export type AiRuntimePurpose = AiCapabilityName | "chat";
|
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
|
||||||
|
|
||||||
export type AiRuntimeTarget = {
|
export type AiRuntimeTarget = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
@@ -24,6 +24,7 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
|
|||||||
thinking: ["THINKING", "THINK"],
|
thinking: ["THINKING", "THINK"],
|
||||||
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
|
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
|
||||||
tools: ["TOOLS", "CHAT"],
|
tools: ["TOOLS", "CHAT"],
|
||||||
|
memoryCompress: ["MEMORY_COMPRESS"],
|
||||||
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
||||||
audio: ["AUDIO"],
|
audio: ["AUDIO"],
|
||||||
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
||||||
@@ -155,6 +156,25 @@ export function resolveAiRuntimeTarget(
|
|||||||
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions};
|
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 {
|
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
|
||||||
return left.provider === right.provider
|
return left.provider === right.provider
|
||||||
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
|
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./uni
|
|||||||
import type {OpenAIChatMessage} from "./openai-chat-message";
|
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
import type {MistralChatMessage} from "./mistral-chat-message";
|
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
import type {OllamaChatMessage} from "./ollama-chat-message";
|
import type {OllamaChatMessage} from "./ollama-chat-message";
|
||||||
|
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||||
|
|
||||||
export type ConversationAttachment = {
|
export type ConversationAttachment = {
|
||||||
kind: AttachmentKind;
|
kind: AttachmentKind;
|
||||||
@@ -32,6 +33,7 @@ export type ConversationTurn = {
|
|||||||
content: string;
|
content: string;
|
||||||
deletedByBotAt?: number | null;
|
deletedByBotAt?: number | null;
|
||||||
attachments: ConversationAttachment[];
|
attachments: ConversationAttachment[];
|
||||||
|
documentNames?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationSnapshot = {
|
export type ConversationSnapshot = {
|
||||||
@@ -123,6 +125,13 @@ function attachmentSummary(attachments: ConversationAttachment[]): string {
|
|||||||
return ["[attachments]:", ...lines].join("\n");
|
return ["[attachments]:", ...lines].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function namesSummary(kind: string, names: string[]): string {
|
||||||
|
const filtered = names.map(name => name.trim()).filter(Boolean);
|
||||||
|
if (!filtered.length) return "";
|
||||||
|
|
||||||
|
return [`[${kind}]:`, ...filtered.map(name => `- ${name}`)].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> {
|
function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> {
|
||||||
if (bot) return new Set<AttachmentKind>();
|
if (bot) return new Set<AttachmentKind>();
|
||||||
|
|
||||||
@@ -160,6 +169,10 @@ function renderContentText(
|
|||||||
parts.push("[message_state]: deleted_by_bot");
|
parts.push("[message_state]: deleted_by_bot");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (turn.documentNames?.length) {
|
||||||
|
parts.push(namesSummary("documents", turn.documentNames));
|
||||||
|
}
|
||||||
|
|
||||||
if (unsupported.length) {
|
if (unsupported.length) {
|
||||||
parts.push(attachmentSummary(unsupported));
|
parts.push(attachmentSummary(unsupported));
|
||||||
}
|
}
|
||||||
@@ -255,11 +268,13 @@ function buildSystemInstruction(
|
|||||||
responseLanguage: UserAiResponseLanguage,
|
responseLanguage: UserAiResponseLanguage,
|
||||||
includePythonToolPrompt: boolean,
|
includePythonToolPrompt: boolean,
|
||||||
additions?: string | null,
|
additions?: string | null,
|
||||||
|
memoryInstruction?: string | null,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||||
additions?.trim() ? additions.trim() : null,
|
additions?.trim() ? additions.trim() : null,
|
||||||
|
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
|
||||||
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
||||||
].filter(Boolean).join("\n\n");
|
].filter(Boolean).join("\n\n");
|
||||||
}
|
}
|
||||||
@@ -291,17 +306,19 @@ export async function buildConversationSnapshot(
|
|||||||
content: part.content,
|
content: part.content,
|
||||||
deletedByBotAt: part.deletedByBotAt,
|
deletedByBotAt: part.deletedByBotAt,
|
||||||
attachments: buildConversationAttachments(part),
|
attachments: buildConversationAttachments(part),
|
||||||
|
documentNames: part.documentNames,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const imageCount = turns.reduce((sum, turn) => {
|
const imageCount = turns.reduce((sum, turn) => {
|
||||||
if (turn.bot) return sum;
|
if (turn.bot) return sum;
|
||||||
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
|
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turns,
|
turns,
|
||||||
imageCount,
|
imageCount,
|
||||||
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions),
|
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export async function prepareDocumentRag(
|
|||||||
const documents = downloads.filter(download => download.kind === "document");
|
const documents = downloads.filter(download => download.kind === "document");
|
||||||
if (!documents.length) return undefined;
|
if (!documents.length) return undefined;
|
||||||
|
|
||||||
|
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OPENAI: {
|
case AiProvider.OPENAI: {
|
||||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] : [];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export async function runSingleModelRequest<T>(params: {
|
||||||
|
execute: () => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
return await params.execute();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ResponseInputMessageContentList,
|
ResponseInputMessageContentList,
|
||||||
ResponseOutputMessage,
|
ResponseOutputMessage,
|
||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
|
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
|
|
||||||
type OpenAIInputChatMessage = {
|
type OpenAIInputChatMessage = {
|
||||||
type: "message";
|
type: "message";
|
||||||
@@ -17,3 +18,5 @@ type OpenAIOutputChatMessage = {
|
|||||||
} & Pick<ResponseOutputMessage, "id" | "status">;
|
} & Pick<ResponseOutputMessage, "id" | "status">;
|
||||||
|
|
||||||
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
|
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
|
||||||
|
|
||||||
|
export type OpenAICompatibleChatMessage = ChatCompletionMessageParam;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,7 +196,8 @@ export async function getRuntimeCapabilities(
|
|||||||
target?: AiRuntimeTarget
|
target?: AiRuntimeTarget
|
||||||
): Promise<AiModelCapabilities> {
|
): Promise<AiModelCapabilities> {
|
||||||
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
|
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) {
|
for (const capabilityName of CAPABILITY_NAMES) {
|
||||||
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
|
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,75 +4,39 @@ import type {AiDownloadedFile} from "./telegram-attachments";
|
|||||||
import type {PreparedDocumentRag} from "./document-rag-pipeline";
|
import type {PreparedDocumentRag} from "./document-rag-pipeline";
|
||||||
import type {OllamaRagArtifactDetails} from "./ollama-rag";
|
import type {OllamaRagArtifactDetails} from "./ollama-rag";
|
||||||
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
|
||||||
|
import {buildRagArtifactPayload, type RagArtifactPayload} from "./rag-artifact-payload";
|
||||||
type RagArtifactPayload = {
|
|
||||||
artifactKind: "rag";
|
|
||||||
provider: AiProvider;
|
|
||||||
createdAt: string;
|
|
||||||
sources: Array<{
|
|
||||||
fileId: string;
|
|
||||||
fileName: string;
|
|
||||||
mimeType?: string;
|
|
||||||
sizeBytes?: number;
|
|
||||||
sha256?: string;
|
|
||||||
uploadedFileId?: string;
|
|
||||||
documentId?: string;
|
|
||||||
}>;
|
|
||||||
providerState: {
|
|
||||||
vectorStoreIds?: string[];
|
|
||||||
libraryId?: string;
|
|
||||||
documentCount?: number;
|
|
||||||
prepared?: boolean;
|
|
||||||
uploadedFileIds?: string[];
|
|
||||||
embeddingModel?: string;
|
|
||||||
topK?: number;
|
|
||||||
chunkSize?: number;
|
|
||||||
chunkOverlap?: number;
|
|
||||||
maxContextChars?: number;
|
|
||||||
extractedDocuments?: Array<{
|
|
||||||
documentIndex: number;
|
|
||||||
fileName: string;
|
|
||||||
textChars: number;
|
|
||||||
}>;
|
|
||||||
selectedChunks?: Array<{
|
|
||||||
sourceId: string;
|
|
||||||
documentIndex: number;
|
|
||||||
documentName: string;
|
|
||||||
chunkIndex: number;
|
|
||||||
chunkCount: number;
|
|
||||||
textChars: number;
|
|
||||||
score?: number;
|
|
||||||
}>;
|
|
||||||
skippedDocuments?: Array<{
|
|
||||||
documentIndex: number;
|
|
||||||
fileName: string;
|
|
||||||
reason: string;
|
|
||||||
}>;
|
|
||||||
query?: string;
|
|
||||||
ollama?: OllamaRagArtifactDetails["providerState"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
|
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
|
||||||
switch (prepared.provider) {
|
switch (prepared.provider) {
|
||||||
case AiProvider.OPENAI:
|
case AiProvider.OPENAI:
|
||||||
return {
|
return {
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
vectorStoreIds: prepared.vectorStoreIds,
|
vectorStoreIds: prepared.vectorStoreIds,
|
||||||
uploadedFileIds: prepared.uploadedFileIds,
|
uploadedFileIds: prepared.uploadedFileIds,
|
||||||
};
|
};
|
||||||
case AiProvider.MISTRAL:
|
case AiProvider.MISTRAL:
|
||||||
return {
|
return {
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
libraryId: prepared.libraryId,
|
libraryId: prepared.libraryId,
|
||||||
documentCount: prepared.documents.length,
|
documentCount: prepared.documents.length,
|
||||||
};
|
};
|
||||||
case AiProvider.OLLAMA:
|
case AiProvider.OLLAMA:
|
||||||
return {
|
return {
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
prepared: prepared.prepared,
|
prepared: prepared.prepared,
|
||||||
embeddingModel: details?.embeddingModel,
|
embeddingModel: details?.embeddingModel,
|
||||||
topK: details?.topK,
|
topK: details?.topK,
|
||||||
chunkSize: details?.chunkSize,
|
chunkSize: details?.chunkSize,
|
||||||
chunkOverlap: details?.chunkOverlap,
|
chunkOverlap: details?.chunkOverlap,
|
||||||
maxContextChars: details?.maxContextChars,
|
maxContextChars: details?.maxContextChars,
|
||||||
|
extractedDocuments: details?.artifact?.extractedDocuments ?? [],
|
||||||
|
selectedChunks: details?.artifact?.selectedChunks ?? [],
|
||||||
|
skippedDocuments: details?.artifact?.skippedDocuments ?? [],
|
||||||
|
query: details?.artifact?.query ?? "",
|
||||||
|
minScore: details?.artifact?.providerState?.minScore ?? 0,
|
||||||
|
maxArchiveFiles: details?.artifact?.providerState?.maxArchiveFiles ?? 0,
|
||||||
|
maxArchiveBytes: details?.artifact?.providerState?.maxArchiveBytes ?? 0,
|
||||||
|
maxArchiveDepth: details?.artifact?.providerState?.maxArchiveDepth ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,22 +81,11 @@ export async function persistRagArtifactAttachment(params: {
|
|||||||
|
|
||||||
if (!sources.length) return Promise.resolve(undefined);
|
if (!sources.length) return Promise.resolve(undefined);
|
||||||
|
|
||||||
const payload: RagArtifactPayload = {
|
const payload = buildRagArtifactPayload({
|
||||||
artifactKind: "rag",
|
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
sources,
|
sources,
|
||||||
providerState: {
|
providerState: providerState(params.prepared, params.details),
|
||||||
...providerState(params.prepared, params.details),
|
});
|
||||||
...(params.details?.artifact ? {
|
|
||||||
extractedDocuments: params.details.artifact.extractedDocuments,
|
|
||||||
selectedChunks: params.details.artifact.selectedChunks,
|
|
||||||
skippedDocuments: params.details.artifact.skippedDocuments,
|
|
||||||
query: params.details.artifact.query,
|
|
||||||
ollama: params.details.artifact.providerState,
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return await persistInternalJsonArtifactAttachment({
|
return await persistInternalJsonArtifactAttachment({
|
||||||
artifactKind: "rag",
|
artifactKind: "rag",
|
||||||
fileNamePrefix: "rag",
|
fileNamePrefix: "rag",
|
||||||
@@ -140,14 +93,8 @@ export async function persistRagArtifactAttachment(params: {
|
|||||||
messageId: params.messageId,
|
messageId: params.messageId,
|
||||||
payload,
|
payload,
|
||||||
metadata: {
|
metadata: {
|
||||||
provider: params.provider,
|
|
||||||
sourceFileNames: sources.map(source => source.fileName),
|
sourceFileNames: sources.map(source => source.fileName),
|
||||||
...payload.providerState,
|
...payload.providerState,
|
||||||
embeddingModel: params.details?.embeddingModel,
|
|
||||||
topK: params.details?.topK,
|
|
||||||
chunkSize: params.details?.chunkSize,
|
|
||||||
chunkOverlap: params.details?.chunkOverlap,
|
|
||||||
maxContextChars: params.details?.maxContextChars,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("этот документ");
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {Environment} from "../common/environment";
|
|||||||
import {MessageStore} from "../common/message-store";
|
import {MessageStore} from "../common/message-store";
|
||||||
import {createQueuedFunction} from "../util/async-lock";
|
import {createQueuedFunction} from "../util/async-lock";
|
||||||
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
||||||
|
import {appLogger} from "../logging/logger";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
|
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
|
||||||
@@ -13,11 +14,13 @@ import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
|||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings";
|
import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings";
|
||||||
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
|
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
|
||||||
|
import {recordToolCall} from "../common/ai-observability.js";
|
||||||
|
|
||||||
const TELEGRAM_LIMIT = 4096;
|
const TELEGRAM_LIMIT = 4096;
|
||||||
const TELEGRAM_CAPTION_LIMIT = 1024;
|
const TELEGRAM_CAPTION_LIMIT = 1024;
|
||||||
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
|
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||||
const EDIT_INTERVAL_MS = 4500;
|
const EDIT_INTERVAL_MS = 4500;
|
||||||
|
const logger = appLogger.child("telegram-stream-message");
|
||||||
|
|
||||||
export type TelegramArtifactFile = {
|
export type TelegramArtifactFile = {
|
||||||
kind: "image" | "file";
|
kind: "image" | "file";
|
||||||
@@ -238,6 +241,13 @@ export class TelegramStreamMessage {
|
|||||||
|
|
||||||
recordToolExecution(record: TelegramToolExecutionRecord): void {
|
recordToolExecution(record: TelegramToolExecutionRecord): void {
|
||||||
this.toolExecutions.push(record);
|
this.toolExecutions.push(record);
|
||||||
|
recordToolCall();
|
||||||
|
logger.debug("tool.execution.recorded", {
|
||||||
|
requestId: this.cancelRequestId,
|
||||||
|
toolName: record.toolName,
|
||||||
|
callId: record.callId,
|
||||||
|
resultChars: record.resultChars,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getToolExecutions(): TelegramToolExecutionRecord[] {
|
getToolExecutions(): TelegramToolExecutionRecord[] {
|
||||||
@@ -246,6 +256,13 @@ export class TelegramStreamMessage {
|
|||||||
|
|
||||||
recordOutputAttachment(record: TelegramOutputAttachmentRecord): void {
|
recordOutputAttachment(record: TelegramOutputAttachmentRecord): void {
|
||||||
this.outputAttachments.push(record);
|
this.outputAttachments.push(record);
|
||||||
|
logger.debug("output_attachment.recorded", {
|
||||||
|
requestId: this.cancelRequestId,
|
||||||
|
artifactKind: record.artifactKind,
|
||||||
|
fileName: record.fileName,
|
||||||
|
sizeBytes: record.sizeBytes,
|
||||||
|
messageId: record.messageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputAttachments(): TelegramOutputAttachmentRecord[] {
|
getOutputAttachments(): TelegramOutputAttachmentRecord[] {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,8 +1,9 @@
|
|||||||
import {AiTool} from "./tool-types";
|
import {AiTool} from "./tool-types";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {getTools} from "./tools/registry";
|
import {getTools} from "./tools/registry.js";
|
||||||
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search";
|
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
|
||||||
|
import {toolSchemaNames} from "./tool-schema-utils.js";
|
||||||
|
|
||||||
export type AiProviderName = "ollama" | "openai" | "mistral";
|
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 = {
|
export type OpenAiResponseTool = {
|
||||||
type: "function";
|
type: "function";
|
||||||
name: string;
|
name: string;
|
||||||
@@ -79,3 +85,20 @@ export function getProviderTools(provider: AiProvider, forCreator?: boolean): Ai
|
|||||||
return getOpenAITools(forCreator);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -102,6 +102,100 @@ export const TOOL_RANKER_TOOL_INFOS = {
|
|||||||
example("где определён BotService?", ["search_files"]),
|
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: tool(
|
||||||
"create_file",
|
"create_file",
|
||||||
"Create a new small file.",
|
"Create a new small file.",
|
||||||
@@ -352,6 +446,20 @@ function toolNamesFromTool(tool: BoundaryValue): string[] {
|
|||||||
return name ? [name] : [];
|
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 {
|
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
|
||||||
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
|
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[] {
|
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
|
||||||
return getToolRankerToolInfos([
|
const infos = new Map<string, ToolRankerToolInfo>();
|
||||||
"no_tool",
|
|
||||||
...availableTools.flatMap(toolNamesFromTool),
|
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 {
|
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("read_file", "known local file path -> read_file");
|
||||||
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
|
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
|
||||||
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
|
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("edit_file_patch", "targeted existing file edit -> edit_file_patch");
|
||||||
pushIfAvailable("update_file", "full existing file replacement -> update_file");
|
pushIfAvailable("update_file", "full existing file replacement -> update_file");
|
||||||
pushIfAvailable("create_file", "small new file -> create_file");
|
pushIfAvailable("create_file", "small new file -> create_file");
|
||||||
@@ -471,7 +604,7 @@ export function buildToolRankerSystemPrompt(params: {
|
|||||||
const includeExamples = params.includeExamples ?? false;
|
const includeExamples = params.includeExamples ?? false;
|
||||||
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
|
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
|
||||||
const compact = params.compact ?? true;
|
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 availableToolNames = availableTools.map(tool => tool.name);
|
||||||
|
|
||||||
const sections: string[] = [
|
const sections: string[] = [
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type {BoundaryValue} from "../common/boundary-types";
|
import type {BoundaryValue} from "../common/boundary-types.js";
|
||||||
import type {AiRuntimeTarget} from "./ai-runtime-target";
|
import type {AiRuntimeTarget} from "./ai-runtime-target.js";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared";
|
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared.js";
|
||||||
import {
|
import {
|
||||||
buildToolRankerSystemPrompt,
|
buildToolRankerSystemPrompt,
|
||||||
getToolRankerAvailableToolInfos,
|
getToolRankerAvailableToolInfos,
|
||||||
type ToolRankerToolInfo,
|
type ToolRankerToolInfo,
|
||||||
} from "./tool-ranker-metadata";
|
} from "./tool-ranker-metadata.js";
|
||||||
|
|
||||||
export type ToolRankerMessage = {
|
export type ToolRankerMessage = {
|
||||||
role?: string;
|
role?: string;
|
||||||
|
|||||||
@@ -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))];
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readFile, writeFile} from "node:fs/promises";
|
import {readFile, writeFile} from "node:fs/promises";
|
||||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
|
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("create-note");
|
const logger = toolsLogger.child("create-note");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types";
|
||||||
|
|
||||||
export const getCurrentDateTimeTool = {
|
export const getCurrentDateTimeTool = {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
|
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||||
import {
|
import {
|
||||||
MAX_COPY_ENTRIES,
|
MAX_COPY_ENTRIES,
|
||||||
MAX_COPY_TOTAL_BYTES,
|
MAX_COPY_TOTAL_BYTES,
|
||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
MAX_PATCH_SEARCH_BYTES,
|
MAX_PATCH_SEARCH_BYTES,
|
||||||
MAX_STREAM_WRITE_IDLE_MS,
|
MAX_STREAM_WRITE_IDLE_MS,
|
||||||
MAX_STREAM_WRITE_SESSIONS,
|
MAX_STREAM_WRITE_SESSIONS,
|
||||||
} from "./limits";
|
} from "./limits.js";
|
||||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Public types and schemas
|
// Public types and schemas
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("market-rates");
|
const logger = toolsLogger.child("market-rates");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
||||||
import {notesDir, notesRootFile} from "../../index";
|
import {notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("notes");
|
const logger = toolsLogger.child("notes");
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import {spawn} from "node:child_process";
|
|||||||
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
|
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("python-interpreter");
|
const logger = toolsLogger.child("python-interpreter");
|
||||||
|
|
||||||
|
|||||||
+117
-51
@@ -1,17 +1,17 @@
|
|||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
|
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
|
||||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
|
||||||
import {shellExecute, shellExecuteTool} from "./shell";
|
import {shellExecute, shellExecuteTool} from "./shell.js";
|
||||||
import {ToolHandler} from "./types";
|
import {ToolHandler} from "./types.js";
|
||||||
import {getWeather, getWeatherTool} from "./weather";
|
import {getWeather, getWeatherTool} from "./weather.js";
|
||||||
import {
|
import {
|
||||||
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||||
getFinancialMarketData,
|
getFinancialMarketData,
|
||||||
getFinancialMarketDataToolPrompt,
|
getFinancialMarketDataToolPrompt,
|
||||||
getMarketRates
|
getMarketRates
|
||||||
} from "./market-rates";
|
} from "./market-rates.js";
|
||||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
|
||||||
import {
|
import {
|
||||||
beginFileWrite,
|
beginFileWrite,
|
||||||
beginFileWriteTool,
|
beginFileWriteTool,
|
||||||
@@ -44,11 +44,14 @@ import {
|
|||||||
updateFileTool,
|
updateFileTool,
|
||||||
writeFileChunk,
|
writeFileChunk,
|
||||||
writeFileChunkTool
|
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[] = [
|
export const defaultTools: AiTool[] = [
|
||||||
getCurrentDateTimeTool,
|
getCurrentDateTimeTool,
|
||||||
getFinancialMarketData,
|
getFinancialMarketData,
|
||||||
|
...memoryTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const fileTools = [
|
export const fileTools = [
|
||||||
@@ -72,44 +75,67 @@ export const fileTools = [
|
|||||||
deletePathTool,
|
deletePathTool,
|
||||||
] satisfies AiTool[];
|
] satisfies AiTool[];
|
||||||
|
|
||||||
// export const notesFileTools: AiTool[] = [
|
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
|
||||||
// createNoteTool,
|
if (!raw?.trim()) return undefined;
|
||||||
// listNotesTool,
|
|
||||||
// getNoteContentTool,
|
const names = raw
|
||||||
// updateNoteContentTool,
|
.split(",")
|
||||||
// deleteNoteTool,
|
.map(item => item.trim().toLowerCase())
|
||||||
// sendNoteAsFileTool,
|
.filter(Boolean);
|
||||||
// searchNotesTool
|
|
||||||
// ]
|
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) => {
|
export const getTools = (forCreator?: boolean) => {
|
||||||
const tools: AiTool[] = [
|
const tools: AiTool[] = [];
|
||||||
...defaultTools,
|
|
||||||
// ...notesFileTools
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
];
|
tools.push(...getMcpTools());
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.push(...filterEnabledTools(defaultTools));
|
||||||
|
|
||||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||||
tools.push(webSearchTool);
|
tools.push(...filterEnabledTools([webSearchTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||||
tools.push(getWeatherTool);
|
tools.push(...filterEnabledTools([getWeatherTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||||
tools.push(...fileTools);
|
tools.push(...filterEnabledTools(fileTools));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forCreator) {
|
if (forCreator) {
|
||||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||||
tools.push(pythonInterpreterTool);
|
tools.push(...filterEnabledTools([pythonInterpreterTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||||
tools.push(shellExecuteTool);
|
tools.push(...filterEnabledTools([shellExecuteTool]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools.push(...getMcpTools());
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,44 +161,83 @@ export const fileToolHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getToolHandlers = () => {
|
export const getToolHandlers = () => {
|
||||||
let handlers: Record<string, ToolHandler> = {
|
const handlers: Record<string, ToolHandler> = {
|
||||||
get_datetime: getCurrentDateTime,
|
...getMcpToolHandlers(),
|
||||||
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,
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
return handlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToolPrompts(toolNames: string[]): string[] {
|
export function getToolPrompts(toolNames: string[]): string[] {
|
||||||
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
|
return getMcpToolPrompts(toolNames);
|
||||||
|
}
|
||||||
|
|
||||||
const prompts: string[] = [];
|
const prompts: string[] = [];
|
||||||
|
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
|
||||||
|
let memoryPromptAdded = false;
|
||||||
|
|
||||||
for (const toolName of toolNames) {
|
for (const toolName of toolNames) {
|
||||||
|
if (!isLocalToolEnabled(toolName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!prompts.includes(fileToolsToolPrompt) &&
|
if (!prompts.includes(fileToolsToolPrompt) &&
|
||||||
fileTools.map(t => t.function.name).includes(toolName)) {
|
fileTools.map(t => t.function.name).includes(toolName)) {
|
||||||
prompts.push(fileToolsToolPrompt);
|
prompts.push(fileToolsToolPrompt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (memoryToolNames.has(toolName)) {
|
||||||
|
if (!memoryPromptAdded) {
|
||||||
|
prompts.push(memoryToolPrompt);
|
||||||
|
memoryPromptAdded = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
||||||
prompts.push(getFinancialMarketDataToolPrompt);
|
prompts.push(getFinancialMarketDataToolPrompt);
|
||||||
@@ -185,5 +250,6 @@ export function getToolPrompts(toolNames: string[]): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prompts.push(...getMcpToolPrompts(toolNames));
|
||||||
return prompts;
|
return prompts;
|
||||||
}
|
}
|
||||||
+12
-7
@@ -1,14 +1,19 @@
|
|||||||
import {getToolHandlers} from "./registry";
|
import {getToolHandlers} from "./registry.js";
|
||||||
import {normalizeToolArguments} from "./utils";
|
import {normalizeToolArguments} from "./utils.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
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");
|
const logger = toolsLogger.child("runtime");
|
||||||
|
|
||||||
export type ToolRuntimeContext = {
|
export type ToolRuntimeContext = {
|
||||||
pythonInputFiles?: PythonInterpreterInputFile[];
|
pythonInputFiles?: PythonInterpreterInputFile[];
|
||||||
};
|
provider?: AiProvider;
|
||||||
|
runtimeTarget?: AiRuntimeTarget;
|
||||||
|
} & MemoryRuntimeContext;
|
||||||
|
|
||||||
function stringifyToolResult(result: AiJsonValue): string {
|
function stringifyToolResult(result: AiJsonValue): string {
|
||||||
if (typeof result === "string") return result;
|
if (typeof result === "string") return result;
|
||||||
@@ -48,7 +53,7 @@ export async function executeToolCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const arguments1 = normalizeToolArguments(args, userId);
|
const arguments1 = normalizeToolArguments(args, userId);
|
||||||
const result = await handler(arguments1);
|
const result = await handler(arguments1, context);
|
||||||
const s = stringifyToolResult(result);
|
const s = stringifyToolResult(result);
|
||||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||||
return s;
|
return s;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readdir, readFile} from "node:fs/promises";
|
import {readdir, readFile} from "node:fs/promises";
|
||||||
import {notesDir, notesRootFile} from "../../index";
|
import {notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("search-notes");
|
const logger = toolsLogger.child("search-notes");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types";
|
||||||
import {runCommand} from "../../util/utils";
|
import {runCommand} from "../../util/utils.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types";
|
||||||
|
|
||||||
export const shellExecuteTool = {
|
export const shellExecuteTool = {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import {appLogger} from "../../logging/logger";
|
import {appLogger} from "../../logging/logger.js";
|
||||||
|
|
||||||
export const toolsLogger = appLogger.child("ai-tools");
|
export const toolsLogger = appLogger.child("ai-tools");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
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;
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import {Ollama} from "ollama";
|
import {Ollama} from "ollama";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||||
import type {BoundaryValue} from "../../common/boundary-types";
|
import type {BoundaryValue} from "../../common/boundary-types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("weather");
|
const logger = toolsLogger.child("weather");
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {logError} from "../../util/utils";
|
import {logError} from "../../util/utils.js";
|
||||||
import {AiJsonObject, AiTool} from "../tool-types";
|
import {AiJsonObject, AiTool} from "../tool-types.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
export const getWeatherTool = {
|
export const getWeatherTool = {
|
||||||
type: "function",
|
type: "function",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("brave-search");
|
const logger = toolsLogger.child("brave-search");
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {logError} from "../../util/utils";
|
import {logError} from "../../util/utils.js";
|
||||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
|
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||||
import {asBoolean, asNonEmptyString} from "./utils";
|
import {asBoolean, asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
type BraveSearchProfile = {
|
type BraveSearchProfile = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import {AiProvider} from "../model/ai-provider";
|
|||||||
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
|
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
|
||||||
import type {AiDownloadedFile} from "./telegram-attachments";
|
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
|
||||||
|
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
|
||||||
|
import {mergeReplyChainDownloads, shouldPreferCurrentDownloads} from "./reply-chain-downloads";
|
||||||
|
import {attachmentsToDownloadedFiles, type AiDownloadedFile} from "./telegram-attachments";
|
||||||
import type {TelegramStreamMessage} from "./telegram-stream-message";
|
import type {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import type {ChatMessage} from "./chat-messages-types";
|
import type {ChatMessage} from "./chat-messages-types";
|
||||||
import type {OpenAIChatMessage} from "./openai-chat-message";
|
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
@@ -12,6 +15,7 @@ import {prepareDocumentRag} from "./document-rag-pipeline";
|
|||||||
import {persistRagArtifactAttachment} from "./rag-artifact-store";
|
import {persistRagArtifactAttachment} from "./rag-artifact-store";
|
||||||
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
|
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
|
||||||
import type {ToolRuntimeContext} from "./tools/runtime";
|
import type {ToolRuntimeContext} from "./tools/runtime";
|
||||||
|
import {recordPipelineFallback, recordRagRun} from "../common/ai-observability.js";
|
||||||
import {
|
import {
|
||||||
appendTranscriptToChatMessages,
|
appendTranscriptToChatMessages,
|
||||||
collectTextMessages,
|
collectTextMessages,
|
||||||
@@ -21,6 +25,7 @@ import {
|
|||||||
stripAudioFromRunnerMessages,
|
stripAudioFromRunnerMessages,
|
||||||
toolRuntimeContextFromDownloads,
|
toolRuntimeContextFromDownloads,
|
||||||
transcribeAudioIfNeeded,
|
transcribeAudioIfNeeded,
|
||||||
|
collectStoredReplyChainAttachments,
|
||||||
UnifiedRunOptions,
|
UnifiedRunOptions,
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {aiLog} from "../logging/ai-logger";
|
import {aiLog} from "../logging/ai-logger";
|
||||||
@@ -60,7 +65,7 @@ function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnaps
|
|||||||
|
|
||||||
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
|
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
|
||||||
return {
|
return {
|
||||||
requestId: `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
|
requestId: options.requestId ?? `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
replyToMessageId: options.msg.reply_to_message?.message_id,
|
replyToMessageId: options.msg.reply_to_message?.message_id,
|
||||||
@@ -90,6 +95,12 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
}): Promise<PreparedUnifiedAiRequest> {
|
}): Promise<PreparedUnifiedAiRequest> {
|
||||||
const {options, config, downloads, streamMessage, controller} = params;
|
const {options, config, downloads, streamMessage, controller} = params;
|
||||||
|
const replyChainDownloads = shouldPreferCurrentDownloads(options.text, downloads)
|
||||||
|
? downloads
|
||||||
|
: mergeReplyChainDownloads(
|
||||||
|
downloads,
|
||||||
|
attachmentsToDownloadedFiles(await collectStoredReplyChainAttachments(options.msg)),
|
||||||
|
);
|
||||||
const prepared: MutablePreparedContext = {
|
const prepared: MutablePreparedContext = {
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
imageCount: 0,
|
imageCount: 0,
|
||||||
@@ -109,7 +120,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
details: {
|
details: {
|
||||||
phase: "ai_request_prepare",
|
phase: "ai_request_prepare",
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
downloads: downloads.map(download => ({
|
downloads: replyChainDownloads.map(download => ({
|
||||||
kind: download.kind,
|
kind: download.kind,
|
||||||
fileName: download.fileName,
|
fileName: download.fileName,
|
||||||
mimeType: download.mimeType,
|
mimeType: download.mimeType,
|
||||||
@@ -126,15 +137,15 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
options.msg,
|
options.msg,
|
||||||
options.text,
|
options.text,
|
||||||
options.provider,
|
options.provider,
|
||||||
downloads,
|
replyChainDownloads,
|
||||||
config,
|
config,
|
||||||
runtimeTargetFor(options, config),
|
runtimeTargetFor(options, config),
|
||||||
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
|
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
);
|
);
|
||||||
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
|
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
|
||||||
prepared.imageCount = collected.imageCount;
|
prepared.imageCount = collected.imageCount;
|
||||||
prepared.firstRoundStatus = initialStatus(downloads, prepared.imageCount);
|
prepared.firstRoundStatus = initialStatus(replyChainDownloads, prepared.imageCount);
|
||||||
prepared.toolContext = toolRuntimeContextFromDownloads(downloads);
|
prepared.toolContext = toolRuntimeContextFromDownloads(replyChainDownloads);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stage: "collect_conversation_context",
|
stage: "collect_conversation_context",
|
||||||
@@ -169,11 +180,11 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
prepared.transcript = await transcribeAudioIfNeeded(
|
prepared.transcript = await transcribeAudioIfNeeded(
|
||||||
options.provider,
|
options.provider,
|
||||||
options.msg.from?.id,
|
options.msg.from?.id,
|
||||||
downloads,
|
replyChainDownloads,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
).catch(error => {
|
).catch(error => {
|
||||||
if (downloads.some(isTranscribableAudioDownload)) throw error;
|
if (replyChainDownloads.some(isTranscribableAudioDownload)) throw error;
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +199,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
const transcriptArtifact = await persistTranscriptArtifactAttachment({
|
const transcriptArtifact = await persistTranscriptArtifactAttachment({
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
transcript,
|
transcript,
|
||||||
downloads,
|
downloads: replyChainDownloads,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
});
|
});
|
||||||
@@ -233,7 +244,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
|
|
||||||
prepared.preparedDocumentRag = await prepareDocumentRag(
|
prepared.preparedDocumentRag = await prepareDocumentRag(
|
||||||
options.provider,
|
options.provider,
|
||||||
downloads,
|
replyChainDownloads,
|
||||||
prepared.chatMessages,
|
prepared.chatMessages,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
config,
|
config,
|
||||||
@@ -244,7 +255,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
const ragArtifact = await persistRagArtifactAttachment({
|
const ragArtifact = await persistRagArtifactAttachment({
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
prepared: prepared.preparedDocumentRag,
|
prepared: prepared.preparedDocumentRag,
|
||||||
downloads,
|
downloads: replyChainDownloads,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
|
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
|
||||||
@@ -264,6 +275,10 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
await streamMessage.storeInternalAttachment(ragArtifact);
|
await streamMessage.storeInternalAttachment(ragArtifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prepared.preparedDocumentRag) {
|
||||||
|
recordRagRun();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stage: "document_rag",
|
stage: "document_rag",
|
||||||
status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
|
status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
|
||||||
@@ -290,6 +305,7 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const state = createAiRequestPipelineState(options);
|
const state = createAiRequestPipelineState(options);
|
||||||
|
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
|
||||||
const pipeline = new UserRequestPipeline({
|
const pipeline = new UserRequestPipeline({
|
||||||
stages,
|
stages,
|
||||||
stageNames: [
|
stageNames: [
|
||||||
@@ -301,6 +317,44 @@ export async function prepareUnifiedAiRequestPipeline(params: {
|
|||||||
"document_rag",
|
"document_rag",
|
||||||
"audit_finish",
|
"audit_finish",
|
||||||
],
|
],
|
||||||
|
onFallback: async decision => {
|
||||||
|
recordPipelineFallback(decision.action);
|
||||||
|
if (decision.action === "use_alternate_target") {
|
||||||
|
aiLog("warn", "request.fallback.use_alternate_target", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
...buildToolRankFallbackTargetDetails(options.provider, config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.action === "fail_request") {
|
||||||
|
aiLog("error", "request.fallback.fail_request", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = await fallbackNotifier.notify(state.requestId, decision);
|
||||||
|
state.audit.push({
|
||||||
|
stage: decision.stage,
|
||||||
|
status: "fallback",
|
||||||
|
startedAt: nowIso(),
|
||||||
|
finishedAt: nowIso(),
|
||||||
|
details: {
|
||||||
|
fallbackAction: decision.action,
|
||||||
|
fallbackNotification: notification.text,
|
||||||
|
fallbackNotified: notification.notified,
|
||||||
|
reason: decision.reason,
|
||||||
|
...(decision.action === "use_alternate_target"
|
||||||
|
? buildToolRankFallbackTargetDetails(options.provider, config)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await pipeline.run(state, controller.signal);
|
await pipeline.run(state, controller.signal);
|
||||||
await streamMessage.storePipelineAudit(state.audit);
|
await streamMessage.storePipelineAudit(state.audit);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {AiProvider} from "../model/ai-provider";
|
|||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {ifTrue, logError} from "../util/utils";
|
import {ifTrue, logError} from "../util/utils";
|
||||||
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
|
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
import type {AiDownloadedFile} from "./telegram-attachments";
|
import type {AiDownloadedFile} from "./telegram-attachments";
|
||||||
import type {TelegramStreamMessage} from "./telegram-stream-message";
|
import type {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
|
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
|
||||||
@@ -9,15 +10,23 @@ import type {OpenAIChatMessage} from "./openai-chat-message";
|
|||||||
import type {MistralChatMessage} from "./mistral-chat-message";
|
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
import type {ChatMessage} from "./chat-messages-types";
|
import type {ChatMessage} from "./chat-messages-types";
|
||||||
import {
|
import {
|
||||||
|
allToolSchemaNames,
|
||||||
providerName,
|
providerName,
|
||||||
RuntimeConfigSnapshot,
|
RuntimeConfigSnapshot,
|
||||||
snapshotModel,
|
snapshotModel,
|
||||||
TELEGRAM_LIMIT,
|
TELEGRAM_LIMIT,
|
||||||
UnifiedRunOptions,
|
UnifiedRunOptions,
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
import {runOpenAi} from "./unified-ai-runner.openai";
|
import {runOpenAi} from "./unified-ai-runner.openai";
|
||||||
|
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
|
||||||
import {runOllama} from "./unified-ai-runner.ollama";
|
import {runOllama} from "./unified-ai-runner.ollama";
|
||||||
import {runMistral} from "./unified-ai-runner.mistral";
|
import {runMistral} from "./unified-ai-runner.mistral";
|
||||||
|
import {summarizeModelOutput} from "./response-model-output";
|
||||||
|
import {summarizeToolLoop} from "./tool-loop-summary";
|
||||||
|
import {persistToolLoopSummaryArtifactAttachment} from "./tool-loop-artifact-store";
|
||||||
|
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
|
||||||
|
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
|
||||||
import {
|
import {
|
||||||
resolveTextToSpeechProviderForUser,
|
resolveTextToSpeechProviderForUser,
|
||||||
sendSynthesizedSpeech,
|
sendSynthesizedSpeech,
|
||||||
@@ -26,6 +35,7 @@ import {
|
|||||||
} from "./text-to-speech";
|
} from "./text-to-speech";
|
||||||
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
|
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
|
||||||
import {aiLog} from "../logging/ai-logger";
|
import {aiLog} from "../logging/ai-logger";
|
||||||
|
import {recordPipelineFallback, recordTtsRun} from "../common/ai-observability.js";
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -33,7 +43,7 @@ function nowIso(): string {
|
|||||||
|
|
||||||
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
|
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
|
||||||
return {
|
return {
|
||||||
requestId: `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
|
requestId: options.requestId ?? `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
replyToMessageId: options.msg.reply_to_message?.message_id,
|
replyToMessageId: options.msg.reply_to_message?.message_id,
|
||||||
@@ -71,6 +81,21 @@ async function runProviderModelCall(params: {
|
|||||||
|
|
||||||
switch (options.provider) {
|
switch (options.provider) {
|
||||||
case AiProvider.OPENAI:
|
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(
|
await runOpenAi(
|
||||||
options.msg,
|
options.msg,
|
||||||
prepared.chatMessages as OpenAIChatMessage[],
|
prepared.chatMessages as OpenAIChatMessage[],
|
||||||
@@ -159,6 +184,10 @@ export async function runUnifiedAiResponsePipeline(params: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const {options, config, downloads, prepared, streamMessage, controller} = params;
|
const {options, config, downloads, prepared, streamMessage, controller} = params;
|
||||||
const state = createResponsePipelineState(options);
|
const state = createResponsePipelineState(options);
|
||||||
|
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
|
||||||
|
const adapter = getProviderAdapter(options.provider);
|
||||||
|
let selectedToolNames: string[] = [];
|
||||||
|
let filteredTools: unknown[] = [];
|
||||||
|
|
||||||
const stages: UserRequestPipelineStage[] = [
|
const stages: UserRequestPipelineStage[] = [
|
||||||
{
|
{
|
||||||
@@ -177,6 +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",
|
name: "model_call",
|
||||||
async run() {
|
async run() {
|
||||||
@@ -192,6 +277,13 @@ export async function runUnifiedAiResponsePipeline(params: {
|
|||||||
return {
|
return {
|
||||||
stage: "model_call",
|
stage: "model_call",
|
||||||
status: "succeeded",
|
status: "succeeded",
|
||||||
|
details: {
|
||||||
|
modelOutput: summarizeModelOutput({
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
toolExecutions: streamMessage.getToolExecutions(),
|
||||||
|
outputAttachments: streamMessage.getOutputAttachments(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -199,33 +291,31 @@ export async function runUnifiedAiResponsePipeline(params: {
|
|||||||
name: "tool_loop",
|
name: "tool_loop",
|
||||||
async run() {
|
async run() {
|
||||||
const executions = streamMessage.getToolExecutions();
|
const executions = streamMessage.getToolExecutions();
|
||||||
|
const outputAttachments = streamMessage.getOutputAttachments();
|
||||||
|
const summary = summarizeToolLoop({
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
executions,
|
||||||
|
outputAttachments,
|
||||||
|
});
|
||||||
|
const persisted = await persistToolLoopSummaryArtifactAttachment({
|
||||||
|
chatId: options.msg.chat.id,
|
||||||
|
messageId: options.msg.message_id,
|
||||||
|
text: streamMessage.getText(),
|
||||||
|
executions,
|
||||||
|
outputAttachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (persisted) {
|
||||||
|
await streamMessage.storeInternalAttachment(persisted);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stage: "tool_loop",
|
stage: "tool_loop",
|
||||||
status: executions.length ? "succeeded" : "skipped",
|
...summary,
|
||||||
fallbackAction: executions.length ? undefined : "continue_without_stage",
|
|
||||||
details: {
|
details: {
|
||||||
count: executions.length,
|
...summary.details,
|
||||||
tools: executions.map(execution => ({
|
persistedSummaryArtifact: !!persisted,
|
||||||
toolName: execution.toolName,
|
|
||||||
callId: execution.callId,
|
|
||||||
resultChars: execution.resultChars,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
artifacts: executions.length ? [{
|
|
||||||
kind: "tool_result",
|
|
||||||
stage: "tool_loop",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
toolName: "summary",
|
|
||||||
callId: "tool_loop_summary",
|
|
||||||
resultText: JSON.stringify({
|
|
||||||
count: executions.length,
|
|
||||||
tools: executions.map(execution => ({
|
|
||||||
toolName: execution.toolName,
|
|
||||||
callId: execution.callId,
|
|
||||||
resultChars: execution.resultChars,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
}] : undefined,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -284,6 +374,7 @@ export async function runUnifiedAiResponsePipeline(params: {
|
|||||||
name: "text_to_speech",
|
name: "text_to_speech",
|
||||||
async run() {
|
async run() {
|
||||||
const status = await synthesizeResponseIfRequested({options, config, streamMessage});
|
const status = await synthesizeResponseIfRequested({options, config, streamMessage});
|
||||||
|
recordTtsRun(status);
|
||||||
return {
|
return {
|
||||||
stage: "text_to_speech",
|
stage: "text_to_speech",
|
||||||
status,
|
status,
|
||||||
@@ -312,6 +403,8 @@ export async function runUnifiedAiResponsePipeline(params: {
|
|||||||
stages,
|
stages,
|
||||||
stageNames: [
|
stageNames: [
|
||||||
"audit_start",
|
"audit_start",
|
||||||
|
"tool_rank",
|
||||||
|
"filter_tools",
|
||||||
"model_call",
|
"model_call",
|
||||||
"tool_loop",
|
"tool_loop",
|
||||||
"output_size_gate",
|
"output_size_gate",
|
||||||
@@ -320,6 +413,44 @@ export async function runUnifiedAiResponsePipeline(params: {
|
|||||||
"persist_output_artifacts",
|
"persist_output_artifacts",
|
||||||
"audit_finish",
|
"audit_finish",
|
||||||
],
|
],
|
||||||
|
onFallback: async decision => {
|
||||||
|
recordPipelineFallback(decision.action);
|
||||||
|
if (decision.action === "use_alternate_target") {
|
||||||
|
aiLog("warn", "response.fallback.use_alternate_target", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
...buildToolRankFallbackTargetDetails(options.provider, config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.action === "fail_request") {
|
||||||
|
aiLog("error", "response.fallback.fail_request", {
|
||||||
|
provider: options.provider,
|
||||||
|
stage: decision.stage,
|
||||||
|
reason: decision.reason,
|
||||||
|
requestId: state.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = await fallbackNotifier.notify(state.requestId, decision);
|
||||||
|
state.audit.push({
|
||||||
|
stage: decision.stage,
|
||||||
|
status: "fallback",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
fallbackAction: decision.action,
|
||||||
|
fallbackNotification: notification.text,
|
||||||
|
fallbackNotified: notification.notified,
|
||||||
|
reason: decision.reason,
|
||||||
|
...(decision.action === "use_alternate_target"
|
||||||
|
? buildToolRankFallbackTargetDetails(options.provider, config)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment";
|
||||||
import {getMistralTools} from "./tool-mappers";
|
|
||||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import {ToolRuntimeContext} from "./tools/runtime";
|
import {ToolRuntimeContext} from "./tools/runtime";
|
||||||
import {MistralChatMessage} from "./mistral-chat-message";
|
import {MistralChatMessage} from "./mistral-chat-message";
|
||||||
import {createMistralClient} from "./ai-runtime-target";
|
import {createMistralClient} from "./ai-runtime-target";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {ToolRanker} from "./unified-ai-runner.tool-ranker";
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
contentFromMistralDelta,
|
|
||||||
executeToolBatch,
|
|
||||||
MAX_TOOL_ROUNDS,
|
MAX_TOOL_ROUNDS,
|
||||||
MistralDeltaLike,
|
|
||||||
MistralDocumentReference,
|
MistralDocumentReference,
|
||||||
mistralToolCalls,
|
|
||||||
normalizeMistralToolCalls,
|
|
||||||
roundStatus,
|
roundStatus,
|
||||||
RuntimeConfigSnapshot,
|
RuntimeConfigSnapshot,
|
||||||
StreamingToolCallAccumulator,
|
StreamingToolCallAccumulator,
|
||||||
ToolCallData,
|
ToolCallData,
|
||||||
ToolExecutionMemory
|
ToolExecutionMemory
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
import {Message} from "typescript-telegram-bot-api";
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
|
|
||||||
import {storeToolRankAudit} from "./tool-rank-audit";
|
|
||||||
|
|
||||||
export async function runMistral(
|
export async function runMistral(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -39,8 +38,9 @@ export async function runMistral(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runnerStartedAt = Date.now();
|
const runnerStartedAt = Date.now();
|
||||||
const mistralAi = createMistralClient(config.mistralChatTarget);
|
const mistralAi = createMistralClient(config.mistralChatTarget);
|
||||||
const toolRanker = new ToolRanker(config);
|
const adapter = getProviderAdapter(AiProvider.MISTRAL);
|
||||||
const availableTools = getMistralTools(msg.from?.id === Environment.CREATOR_ID);
|
const availableTools = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID});
|
||||||
|
const requestMessages = adapter.mapMessages([...messages]) as unknown as MistralChatMessage[];
|
||||||
aiLog("info", "mistral.run.start", {
|
aiLog("info", "mistral.run.start", {
|
||||||
stream,
|
stream,
|
||||||
target: aiLogProviderTarget(config.mistralChatTarget),
|
target: aiLogProviderTarget(config.mistralChatTarget),
|
||||||
@@ -50,49 +50,25 @@ export async function runMistral(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toolMemory: ToolExecutionMemory = new Map();
|
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();
|
const roundStartedAt = Date.now();
|
||||||
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
|
||||||
if (signal.aborted) throw new Error("Aborted");
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
|
|
||||||
streamMessage.setStatus(Environment.getSelectingToolsText());
|
const rankResult = await runToolRankStage({
|
||||||
await streamMessage.flush();
|
|
||||||
const toolRankStartedAt = Date.now();
|
|
||||||
const toolRankStartedAtIso = new Date().toISOString();
|
|
||||||
const rankerSelection = await toolRanker.selectTools({
|
|
||||||
provider: AiProvider.MISTRAL,
|
provider: AiProvider.MISTRAL,
|
||||||
userQuery: latestUserTextFromMessages(messages),
|
model: config.mistralChatTarget.model,
|
||||||
|
round,
|
||||||
|
config,
|
||||||
availableTools,
|
availableTools,
|
||||||
round,
|
messages,
|
||||||
|
streamMessage,
|
||||||
signal,
|
signal,
|
||||||
})
|
|
||||||
.catch(async error => {
|
|
||||||
streamMessage.clearStatus();
|
|
||||||
await streamMessage.flush();
|
|
||||||
await storeToolRankAudit({
|
|
||||||
streamMessage,
|
|
||||||
provider: AiProvider.MISTRAL,
|
|
||||||
model: config.mistralChatTarget.model,
|
|
||||||
round,
|
|
||||||
startedAt: toolRankStartedAt,
|
|
||||||
startedAtIso: toolRankStartedAtIso,
|
|
||||||
error,
|
|
||||||
});
|
});
|
||||||
throw error;
|
const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
|
||||||
});
|
|
||||||
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 requestTools = filteredTools.length ? filteredTools : undefined;
|
const requestTools = filteredTools.length ? filteredTools : undefined;
|
||||||
|
|
||||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||||
@@ -101,22 +77,24 @@ export async function runMistral(
|
|||||||
if (!stream) {
|
if (!stream) {
|
||||||
const request = {
|
const request = {
|
||||||
model: config.mistralChatTarget.model,
|
model: config.mistralChatTarget.model,
|
||||||
messages,
|
messages: requestMessages,
|
||||||
tools: requestTools,
|
tools: requestTools,
|
||||||
documents: documents
|
documents: documents
|
||||||
} as Parameters<typeof mistralAi.chat.complete>[0];
|
} as Parameters<typeof mistralAi.chat.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 message = response.choices?.[0]?.message;
|
||||||
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
|
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
|
||||||
streamMessage.append(text);
|
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", {
|
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||||
round,
|
round,
|
||||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
textChars: text.length,
|
textChars: text.length,
|
||||||
calls: calls.map(aiLogToolCall),
|
calls: calls.map(aiLogToolCall),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: text,
|
content: text,
|
||||||
@@ -125,25 +103,50 @@ export async function runMistral(
|
|||||||
function: {name: call.name, arguments: call.argumentsText},
|
function: {name: call.name, arguments: call.argumentsText},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
|
requestMessages.push({
|
||||||
for (const [index, call] of calls.entries()) {
|
role: "assistant",
|
||||||
messages.push({
|
content: text,
|
||||||
role: "tool",
|
toolCalls: calls.map(call => ({
|
||||||
name: call.name,
|
id: call.id,
|
||||||
toolCallId: call.id,
|
function: {name: call.name, arguments: call.argumentsText},
|
||||||
content: toolResults[index] ?? "",
|
})),
|
||||||
|
});
|
||||||
|
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 = {
|
const request = {
|
||||||
model: config.mistralChatTarget.model,
|
model: config.mistralChatTarget.model,
|
||||||
messages,
|
messages: requestMessages,
|
||||||
tools: requestTools,
|
tools: requestTools,
|
||||||
documents: documents
|
documents: documents
|
||||||
} as Parameters<typeof mistralAi.chat.stream>[0];
|
} 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});
|
aiLog("debug", "mistral.stream.open", {round});
|
||||||
let calls: ToolCallData[] = [];
|
let calls: ToolCallData[] = [];
|
||||||
const roundTextStart = streamMessage.getText().length;
|
const roundTextStart = streamMessage.getText().length;
|
||||||
@@ -154,11 +157,10 @@ export async function runMistral(
|
|||||||
|
|
||||||
const choice = event.data?.choices?.[0];
|
const choice = event.data?.choices?.[0];
|
||||||
const delta = choice?.delta;
|
const delta = choice?.delta;
|
||||||
const mistralDelta = delta as MistralDeltaLike;
|
const mistralDelta = delta;
|
||||||
|
streamMessage.append(adapter.extractTextDelta(mistralDelta));
|
||||||
|
|
||||||
streamMessage.append(contentFromMistralDelta(mistralDelta));
|
const rawDeltaCalls = adapter.extractStreamingToolCalls(mistralDelta);
|
||||||
|
|
||||||
const rawDeltaCalls = mistralToolCalls(mistralDelta);
|
|
||||||
if (rawDeltaCalls.length) {
|
if (rawDeltaCalls.length) {
|
||||||
calls = toolCallAccumulator.add(rawDeltaCalls);
|
calls = toolCallAccumulator.add(rawDeltaCalls);
|
||||||
streamMessage.setStatus(Environment.getUseToolText(calls));
|
streamMessage.setStatus(Environment.getUseToolText(calls));
|
||||||
@@ -171,21 +173,46 @@ export async function runMistral(
|
|||||||
textChars: streamMessage.getText().slice(roundTextStart).length,
|
textChars: streamMessage.getText().slice(roundTextStart).length,
|
||||||
calls: calls.map(aiLogToolCall),
|
calls: calls.map(aiLogToolCall),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
const roundText = streamMessage.getText().slice(roundTextStart);
|
const roundText = streamMessage.getText().slice(roundTextStart);
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: roundText,
|
content: roundText,
|
||||||
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
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);
|
requestMessages.push({
|
||||||
for (const [index, call] of calls.entries()) {
|
role: "assistant",
|
||||||
messages.push({
|
content: roundText,
|
||||||
role: "tool",
|
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
|
||||||
name: call.name,
|
});
|
||||||
toolCallId: call.id,
|
await executeToolBatchWithAdapter({
|
||||||
content: toolResults[index] ?? "",
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {Environment} from "../common/environment";
|
|||||||
import type {BoundaryValue} from "../common/boundary-types";
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
import {bot, notesDir} from "../index";
|
import {bot, notesDir} from "../index";
|
||||||
import {clamp, logError} from "../util/utils";
|
import {clamp, logError} from "../util/utils";
|
||||||
import {getOllamaTools} from "./tool-mappers";
|
|
||||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||||
import {ChatMessage} from "./chat-messages-types";
|
import {ChatMessage} from "./chat-messages-types";
|
||||||
import {ChatRequest, Tool} from "ollama";
|
import {ChatRequest, Tool} from "ollama";
|
||||||
@@ -14,20 +13,20 @@ import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
|
|||||||
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
|
||||||
import {createOllamaClient} from "./ai-runtime-target";
|
import {createOllamaClient} from "./ai-runtime-target";
|
||||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
allToolSchemaNames,
|
allToolSchemaNames,
|
||||||
appendOllamaToolResults,
|
|
||||||
dedupeToolCalls,
|
dedupeToolCalls,
|
||||||
DEFAULT_OLLAMA_CONTEXT_SIZE,
|
DEFAULT_OLLAMA_CONTEXT_SIZE,
|
||||||
executeToolBatch,
|
|
||||||
isOllamaModelActive,
|
isOllamaModelActive,
|
||||||
isRecord,
|
isRecord,
|
||||||
MAX_OLLAMA_CONTEXT_SIZE,
|
MAX_OLLAMA_CONTEXT_SIZE,
|
||||||
MAX_TOOL_ROUNDS,
|
MAX_TOOL_ROUNDS,
|
||||||
MIN_OLLAMA_CONTEXT_SIZE,
|
MIN_OLLAMA_CONTEXT_SIZE,
|
||||||
normalizeOllamaToolCalls,
|
|
||||||
OllamaToolCallLike,
|
|
||||||
roundStatus,
|
roundStatus,
|
||||||
RuntimeConfigSnapshot,
|
RuntimeConfigSnapshot,
|
||||||
safeJsonParseObject,
|
safeJsonParseObject,
|
||||||
@@ -35,14 +34,15 @@ import {
|
|||||||
ToolCallData,
|
ToolCallData,
|
||||||
ToolExecutionMemory
|
ToolExecutionMemory
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {ToolRanker} from "./unified-ai-runner.tool-ranker";
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
import {getToolPrompts} from "./tools/registry";
|
import {getToolPrompts} from "./tools/registry";
|
||||||
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
|
|
||||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
|
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
|
||||||
import {getModelCapabilities} from "./provider-model-runtime";
|
import {getModelCapabilities} from "./provider-model-runtime";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {Message} from "typescript-telegram-bot-api";
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
import {storeToolRankAudit} from "./tool-rank-audit";
|
|
||||||
|
|
||||||
export async function runOllama(
|
export async function runOllama(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -157,9 +157,12 @@ export async function runOllama(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolMemory: ToolExecutionMemory = new Map();
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
const adapter = getProviderAdapter(AiProvider.OLLAMA);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
await runToolLoopRounds({
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
onRound: async (round) => {
|
||||||
const roundStartedAt = Date.now();
|
const roundStartedAt = Date.now();
|
||||||
aiLog("debug", "ollama.round.start", {
|
aiLog("debug", "ollama.round.start", {
|
||||||
round,
|
round,
|
||||||
@@ -183,7 +186,7 @@ export async function runOllama(
|
|||||||
|
|
||||||
let activeToolNames: string[] = [];
|
let activeToolNames: string[] = [];
|
||||||
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||||
const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[];
|
const availableOllamaTools: Tool[] = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID}) as Tool[];
|
||||||
|
|
||||||
aiLog("debug", "ollama.tools.available", {
|
aiLog("debug", "ollama.tools.available", {
|
||||||
round,
|
round,
|
||||||
@@ -191,44 +194,18 @@ export async function runOllama(
|
|||||||
rankerEnabled: !!config.ollamaToolRankerTarget,
|
rankerEnabled: !!config.ollamaToolRankerTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
streamMessage.setStatus(Environment.getSelectingToolsText());
|
const rankResult = await runToolRankStage({
|
||||||
await streamMessage.flush();
|
|
||||||
const toolRankStartedAt = Date.now();
|
|
||||||
const toolRankStartedAtIso = new Date().toISOString();
|
|
||||||
const rankerSelection = await new ToolRanker(config).selectTools({
|
|
||||||
provider: AiProvider.OLLAMA,
|
provider: AiProvider.OLLAMA,
|
||||||
userQuery: latestUserTextFromMessages(messages),
|
model,
|
||||||
|
round,
|
||||||
|
config,
|
||||||
availableTools: availableOllamaTools,
|
availableTools: availableOllamaTools,
|
||||||
round,
|
messages,
|
||||||
|
streamMessage,
|
||||||
signal,
|
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 ?? "");
|
activeToolNames = filteredTools.map(t => t.function.name ?? "");
|
||||||
if (filteredTools.length > 0) {
|
if (filteredTools.length > 0) {
|
||||||
request.tools = [...filteredTools];
|
request.tools = [...filteredTools];
|
||||||
@@ -256,24 +233,23 @@ export async function runOllama(
|
|||||||
round,
|
round,
|
||||||
tools: activeToolNames,
|
tools: activeToolNames,
|
||||||
count: activeToolNames.length,
|
count: activeToolNames.length,
|
||||||
usedRanker: rankerSelection.usedRanker,
|
usedRanker: rankResult.usedRanker,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
const response = await ollama.chat({
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||||
...request,
|
...request,
|
||||||
stream: false
|
stream: false
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = response.message;
|
const message = response.message;
|
||||||
const rawContent = message?.content ?? "";
|
const rawContent = message?.content ?? "";
|
||||||
|
|
||||||
const nativeCalls = dedupeToolCalls(
|
const nativeCalls = dedupeToolCalls(
|
||||||
normalizeOllamaToolCalls(
|
adapter.extractToolCalls(message),
|
||||||
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
|
||||||
round,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseText = rawContent;
|
const responseText = rawContent;
|
||||||
@@ -298,10 +274,10 @@ export async function runOllama(
|
|||||||
|
|
||||||
if (!nativeCalls.length) {
|
if (!nativeCalls.length) {
|
||||||
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
|
||||||
break;
|
return {shouldContinue: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
const calls = nativeCalls;
|
const calls = adapter.extractToolCalls(message).length ? adapter.extractToolCalls(message) : nativeCalls;
|
||||||
|
|
||||||
aiLog("info", "ollama.tool_calls", {
|
aiLog("info", "ollama.tool_calls", {
|
||||||
round,
|
round,
|
||||||
@@ -319,22 +295,44 @@ export async function runOllama(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
appendOllamaToolResults(
|
await executeToolBatchWithAdapter({
|
||||||
messages,
|
userId: msg.from?.id,
|
||||||
calls,
|
toolCalls: calls,
|
||||||
await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory),
|
streamMessage,
|
||||||
);
|
toolContext: {
|
||||||
|
...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", {
|
aiLog("debug", "ollama.stream.messages", {
|
||||||
round,
|
round,
|
||||||
messageCount: request.messages?.length ?? 0,
|
messageCount: request.messages?.length ?? 0,
|
||||||
});
|
});
|
||||||
const response = await ollama.chat({
|
const response = await runSingleModelRequest({
|
||||||
|
execute: () => adapter.callModel(request, () => ollama.chat({
|
||||||
...request,
|
...request,
|
||||||
stream: true
|
stream: true
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
aiLog("debug", "ollama.stream.open", {round});
|
aiLog("debug", "ollama.stream.open", {round});
|
||||||
@@ -354,10 +352,7 @@ export async function runOllama(
|
|||||||
|
|
||||||
const localToolCalls: ToolCallData[] = [];
|
const localToolCalls: ToolCallData[] = [];
|
||||||
|
|
||||||
localToolCalls.push(...normalizeOllamaToolCalls(
|
localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||||
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
|
||||||
round,
|
|
||||||
));
|
|
||||||
|
|
||||||
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
|
||||||
const previousStatus = streamMessage.getStatus();
|
const previousStatus = streamMessage.getStatus();
|
||||||
@@ -377,13 +372,10 @@ export async function runOllama(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
|
||||||
streamMessage.append(chunk.message?.content ?? "");
|
streamMessage.append(adapter.extractTextDelta(chunk));
|
||||||
}
|
}
|
||||||
|
|
||||||
calls.push(...normalizeOllamaToolCalls(
|
calls.push(...adapter.extractStreamingToolCalls(chunk.message));
|
||||||
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
|
|
||||||
round,
|
|
||||||
));
|
|
||||||
|
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
aiLog("debug", "ollama.stream.done", {
|
aiLog("debug", "ollama.stream.done", {
|
||||||
@@ -416,7 +408,7 @@ export async function runOllama(
|
|||||||
duration: aiLogDuration(runnerStartedAt),
|
duration: aiLogDuration(runnerStartedAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
return {shouldContinue: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
|
||||||
@@ -439,7 +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;
|
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||||
|
|
||||||
@@ -471,9 +487,11 @@ export async function runOllama(
|
|||||||
}).catch(logError);
|
}).catch(logError);
|
||||||
}
|
}
|
||||||
|
|
||||||
appendOllamaToolResults(messages, calls, toolResults);
|
return {shouldContinue: true};
|
||||||
}
|
},
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
|
await adapter.finalize().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -12,16 +12,14 @@ import type {
|
|||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
import {createOpenAiClient} from "./ai-runtime-target";
|
import {createOpenAiClient} from "./ai-runtime-target";
|
||||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||||
|
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AsyncIterableStream,
|
AsyncIterableStream,
|
||||||
buildSystemInstruction,
|
buildSystemInstruction,
|
||||||
collectOpenAiResponseCodeInterpreterCalls,
|
collectOpenAiResponseCodeInterpreterCalls,
|
||||||
collectOpenAiResponseFunctionCalls,
|
|
||||||
collectOpenAiResponseImages,
|
collectOpenAiResponseImages,
|
||||||
collectOpenAiResponseText,
|
collectOpenAiResponseText,
|
||||||
executeToolBatch,
|
|
||||||
getOpenAIResponsesToolsWithImage,
|
|
||||||
MAX_TOOL_ROUNDS,
|
MAX_TOOL_ROUNDS,
|
||||||
OPENAI_IMAGE_PARTIALS,
|
OPENAI_IMAGE_PARTIALS,
|
||||||
openAiResponseItemCallId,
|
openAiResponseItemCallId,
|
||||||
@@ -32,20 +30,21 @@ import {
|
|||||||
showOpenAiGeneratedImage,
|
showOpenAiGeneratedImage,
|
||||||
ToolCallData,
|
ToolCallData,
|
||||||
ToolExecutionMemory,
|
ToolExecutionMemory,
|
||||||
errorMessage,
|
|
||||||
allToolSchemaNames
|
allToolSchemaNames
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {bot} from "../index";
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
import fs from "node:fs";
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
import path from "node:path";
|
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 {logError} from "../util/utils";
|
||||||
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
|
|
||||||
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||||
import {AiDownloadedFile} from "./telegram-attachments";
|
import {AiDownloadedFile} from "./telegram-attachments";
|
||||||
import {ToolRanker} from "./unified-ai-runner.tool-ranker";
|
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {filterRankedTools, latestUserTextFromMessages} from "./tool-ranker-pipeline";
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
import {storeToolRankAudit} from "./tool-rank-audit";
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {tryToUploadFiles} from "./openai-upload-files.js";
|
||||||
|
|
||||||
export async function runOpenAi(
|
export async function runOpenAi(
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -60,22 +59,22 @@ export async function runOpenAi(
|
|||||||
documentRag?: OpenAiDocumentRagContext,
|
documentRag?: OpenAiDocumentRagContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runnerStartedAt = Date.now();
|
const runnerStartedAt = Date.now();
|
||||||
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = [...messages] as Array<ResponseInputItem | OpenAiResponseOutputItem>;
|
|
||||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||||
const ownsDocumentRag = !documentRag;
|
const ownsDocumentRag = !documentRag;
|
||||||
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
|
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
|
||||||
const toolRanker = new ToolRanker(config);
|
const adapter = getProviderAdapter(AiProvider.OPENAI);
|
||||||
const availableTools = getOpenAIResponsesToolsWithImage(
|
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = adapter.mapMessages(messages) as unknown as Array<ResponseInputItem | OpenAiResponseOutputItem>;
|
||||||
config,
|
const availableTools = adapter.rankTools(config, {
|
||||||
msg.from?.id === Environment.CREATOR_ID,
|
forCreator: msg.from?.id === Environment.CREATOR_ID,
|
||||||
preparedDocumentRag?.vectorStoreIds ?? [],
|
vectorStoreIds: preparedDocumentRag?.vectorStoreIds ?? [],
|
||||||
);
|
});
|
||||||
|
|
||||||
const systemPrompt = buildSystemInstruction(
|
const systemPrompt = buildSystemInstruction(
|
||||||
config,
|
config,
|
||||||
DEFAULT_AI_RESPONSE_LANGUAGE,
|
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
false,
|
false,
|
||||||
config.openAiChatTarget.systemPromptAdditions,
|
config.openAiChatTarget.systemPromptAdditions,
|
||||||
|
await buildUserMemoryPrompt(msg.from?.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
aiLog("info", "openai.run.start", {
|
aiLog("info", "openai.run.start", {
|
||||||
@@ -90,46 +89,22 @@ export async function runOpenAi(
|
|||||||
const toolMemory: ToolExecutionMemory = new Map();
|
const toolMemory: ToolExecutionMemory = new Map();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
await runToolLoopRounds({
|
||||||
|
maxRounds: MAX_TOOL_ROUNDS,
|
||||||
|
onRound: async (round) => {
|
||||||
const roundStartedAt = Date.now();
|
const roundStartedAt = Date.now();
|
||||||
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
|
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
|
||||||
streamMessage.setStatus(Environment.getSelectingToolsText());
|
const rankResult = await runToolRankStage({
|
||||||
await streamMessage.flush();
|
|
||||||
const toolRankStartedAt = Date.now();
|
|
||||||
const toolRankStartedAtIso = new Date().toISOString();
|
|
||||||
const rankerSelection = await toolRanker.selectTools({
|
|
||||||
provider: AiProvider.OPENAI,
|
provider: AiProvider.OPENAI,
|
||||||
userQuery: latestUserTextFromMessages(messages),
|
model: config.openAiChatTarget.model,
|
||||||
|
round,
|
||||||
|
config,
|
||||||
availableTools,
|
availableTools,
|
||||||
round,
|
messages,
|
||||||
|
streamMessage,
|
||||||
signal,
|
signal,
|
||||||
})
|
|
||||||
.catch(async error => {
|
|
||||||
streamMessage.clearStatus();
|
|
||||||
await streamMessage.flush();
|
|
||||||
await storeToolRankAudit({
|
|
||||||
streamMessage,
|
|
||||||
provider: AiProvider.OPENAI,
|
|
||||||
model: config.openAiChatTarget.model,
|
|
||||||
round,
|
|
||||||
startedAt: toolRankStartedAt,
|
|
||||||
startedAtIso: toolRankStartedAtIso,
|
|
||||||
error,
|
|
||||||
});
|
});
|
||||||
throw error;
|
const filteredTools = rankResult.filteredTools;
|
||||||
});
|
|
||||||
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 requestTools = preparedDocumentRag?.vectorStoreIds.length
|
const requestTools = preparedDocumentRag?.vectorStoreIds.length
|
||||||
? (() => {
|
? (() => {
|
||||||
const tools = [...filteredTools];
|
const tools = [...filteredTools];
|
||||||
@@ -140,9 +115,13 @@ export async function runOpenAi(
|
|||||||
tools.unshift(fileSearchTool);
|
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) {
|
if (!stream) {
|
||||||
const request: ResponseCreateParamsNonStreaming = {
|
const request: ResponseCreateParamsNonStreaming = {
|
||||||
@@ -151,7 +130,9 @@ export async function runOpenAi(
|
|||||||
tools: requestTools as ResponseCreateParamsNonStreaming["tools"],
|
tools: requestTools as ResponseCreateParamsNonStreaming["tools"],
|
||||||
instructions: systemPrompt,
|
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);
|
const responseText = collectOpenAiResponseText(response);
|
||||||
streamMessage.append(responseText);
|
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", {
|
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||||
round,
|
round,
|
||||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
calls: calls.map(call => ({
|
calls: calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
arguments: safeJsonParseObject(call.argumentsText)
|
arguments: safeJsonParseObject(call.argumentsText)
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
|
|
||||||
const toolCalls = calls.map(call => ({
|
const toolCalls = calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
argumentsText: call.argumentsText,
|
argumentsText: call.argumentsText,
|
||||||
}));
|
}));
|
||||||
const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
|
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
|
||||||
const toolOutputs = calls.map((call, index) => ({
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
type: "function_call_output" as const,
|
userId: msg.from?.id,
|
||||||
call_id: call.callId,
|
toolCalls,
|
||||||
output: toolResults[index] ?? "",
|
streamMessage,
|
||||||
}));
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [toolOutputs],
|
||||||
|
});
|
||||||
|
|
||||||
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||||
if (uploadFilesResult.found) {
|
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];
|
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
|
||||||
continue;
|
return {shouldContinue: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
let completedResponse: OpenAiResponseLike | null = null;
|
let completedResponse: OpenAiResponseLike | null = null;
|
||||||
@@ -243,7 +244,9 @@ export async function runOpenAi(
|
|||||||
parallel_tool_calls: true,
|
parallel_tool_calls: true,
|
||||||
instructions: systemPrompt
|
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});
|
aiLog("debug", "openai.stream.open", {round});
|
||||||
|
|
||||||
@@ -253,7 +256,7 @@ export async function runOpenAi(
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
streamMessage.append(event.delta ?? "");
|
streamMessage.append(adapter.extractTextDelta(event));
|
||||||
break;
|
break;
|
||||||
case "response.image_generation_call.in_progress":
|
case "response.image_generation_call.in_progress":
|
||||||
streamMessage.setStatus(Environment.startingImageGenText);
|
streamMessage.setStatus(Environment.startingImageGenText);
|
||||||
@@ -301,14 +304,11 @@ export async function runOpenAi(
|
|||||||
case "response.code_interpreter_call_code.done":
|
case "response.code_interpreter_call_code.done":
|
||||||
break;
|
break;
|
||||||
case "response.output_item.added":
|
case "response.output_item.added":
|
||||||
if (event.item.type === "function_call" && event.item.name) {
|
{
|
||||||
const item = event.item as OpenAiResponseOutputItem & { id?: string };
|
const streamedCalls = adapter.extractStreamingToolCalls(event);
|
||||||
localToolCalls.push({
|
if (streamedCalls.length) {
|
||||||
id: openAiResponseItemCallId(item),
|
localToolCalls.push(...streamedCalls);
|
||||||
name: item.name ?? "",
|
}
|
||||||
argumentsText: item.arguments ?? "{}",
|
|
||||||
});
|
|
||||||
|
|
||||||
aiLog("info", "openai.stream.tool_call.added", {
|
aiLog("info", "openai.stream.tool_call.added", {
|
||||||
round,
|
round,
|
||||||
toolCalls: localToolCalls.map(aiLogToolCall)
|
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", {
|
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
|
||||||
round,
|
round,
|
||||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||||
calls: calls.map(call => ({
|
calls: calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
arguments: safeJsonParseObject(call.argumentsText)
|
arguments: safeJsonParseObject(call.argumentsText)
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
if (!calls.length) return;
|
if (!calls.length) return {shouldContinue: false};
|
||||||
|
|
||||||
const toolCalls = calls.map(call => ({
|
const toolCalls = calls.map(call => ({
|
||||||
id: call.callId,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
argumentsText: call.argumentsText,
|
argumentsText: call.argumentsText,
|
||||||
}));
|
}));
|
||||||
const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
|
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = [];
|
||||||
const toolOutputs = calls.map((call, index) => ({
|
const toolResults = await executeToolBatchWithAdapter({
|
||||||
type: "function_call_output",
|
userId: msg.from?.id,
|
||||||
call_id: call.callId,
|
toolCalls,
|
||||||
output: toolResults[index] ?? "",
|
streamMessage,
|
||||||
}));
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
|
},
|
||||||
|
toolMemory,
|
||||||
|
adapter,
|
||||||
|
appendTargets: [toolOutputs],
|
||||||
|
});
|
||||||
|
|
||||||
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
|
||||||
if (uploadFilesResult.found) {
|
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 {
|
} finally {
|
||||||
if (ownsDocumentRag) {
|
if (ownsDocumentRag) {
|
||||||
await preparedDocumentRag?.cleanup().catch(logError);
|
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 {
|
// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): string {
|
||||||
// if (typeof content === "string") return content;
|
// if (typeof content === "string") return content;
|
||||||
// if (!Array.isArray(content)) return "";
|
// if (!Array.isArray(content)) return "";
|
||||||
|
|||||||
@@ -2,40 +2,39 @@ import {Message} from "typescript-telegram-bot-api";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {BoundaryValue} from "../common/boundary-types";
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {ToolRankerFallbackPolicy} from "../common/policies";
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment, type OpenAiBackend} from "../common/environment.js";
|
||||||
import {photoGenDir} from "../index";
|
import {delay, logError, replyToMessage} from "../util/utils.js";
|
||||||
import {delay, logError, replyToMessage} from "../util/utils";
|
import {MessageStore} from "../common/message-store.js";
|
||||||
import {MessageStore} from "../common/message-store";
|
import type {OpenAiResponseTool} from "./tool-mappers.js";
|
||||||
import type {OpenAiResponseTool} from "./tool-mappers";
|
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers.js";
|
||||||
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers";
|
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message.js";
|
||||||
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message";
|
import {AiDownloadedFile} from "./telegram-attachments.js";
|
||||||
import {AiDownloadedFile} from "./telegram-attachments";
|
import {getRuntimeCapabilities} from "./provider-model-runtime.js";
|
||||||
import {getRuntimeCapabilities} from "./provider-model-runtime";
|
import {StoredAttachment} from "../model/stored-attachment.js";
|
||||||
import {StoredAttachment} from "../model/stored-attachment";
|
import {AiChatMessage, ChatMessage} from "./chat-messages-types.js";
|
||||||
import {AiChatMessage, ChatMessage} from "./chat-messages-types";
|
|
||||||
import {ListResponse, Ollama} from "ollama";
|
import {ListResponse, Ollama} from "ollama";
|
||||||
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
|
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime.js";
|
||||||
import {MessageImagePart, MessagePart} from "../common/message-part";
|
import {MessageImagePart, MessagePart} from "../common/message-part.js";
|
||||||
import {KeyedAsyncLock} from "../util/async-lock";
|
import {KeyedAsyncLock} from "../util/async-lock.js";
|
||||||
import {type AiRequestQueueTarget} from "./provider-request-queue";
|
import {type AiRequestQueueTarget} from "./provider-request-queue.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator.js";
|
||||||
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings";
|
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings.js";
|
||||||
import {
|
import {
|
||||||
isTranscribableAudioDownload,
|
isTranscribableAudioDownload,
|
||||||
resolveSpeechToTextProviderForUser,
|
resolveSpeechToTextProviderForUser,
|
||||||
transcribeSpeechDownloads
|
transcribeSpeechDownloads
|
||||||
} from "./speech-to-text";
|
} from "./speech-to-text.js";
|
||||||
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
import {MistralChatMessage} from "./mistral-chat-message";
|
import {MistralChatMessage} from "./mistral-chat-message.js";
|
||||||
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
|
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer.js";
|
||||||
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
|
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger.js";
|
||||||
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline";
|
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline.js";
|
||||||
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
|
||||||
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store";
|
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store.js";
|
||||||
import {filterUserVisibleStoredAttachments} from "../common/stored-attachment-utils";
|
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
|
||||||
|
|
||||||
export type {Message} from "typescript-telegram-bot-api";
|
export type {Message} from "typescript-telegram-bot-api";
|
||||||
export type {AiRuntimeTarget} from "./ai-runtime-target";
|
export type {AiRuntimeTarget} from "./ai-runtime-target";
|
||||||
@@ -72,9 +71,14 @@ export const MAX_OLLAMA_CONTEXT_SIZE = 262144;
|
|||||||
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
|
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
|
||||||
export const toolResourceLocks = new KeyedAsyncLock();
|
export const toolResourceLocks = new KeyedAsyncLock();
|
||||||
|
|
||||||
|
function photoGenDir(): string {
|
||||||
|
return path.join(Environment.DATA_PATH, "cache", "photo", "gen");
|
||||||
|
}
|
||||||
|
|
||||||
export type UnifiedRunOptions = {
|
export type UnifiedRunOptions = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
msg: Message;
|
msg: Message;
|
||||||
|
requestId?: string;
|
||||||
isGuestMsg?: boolean;
|
isGuestMsg?: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
@@ -270,6 +274,7 @@ export type RuntimeConfigSnapshot = {
|
|||||||
openAiChatTarget: AiRuntimeTarget;
|
openAiChatTarget: AiRuntimeTarget;
|
||||||
openAiImageTarget: AiRuntimeTarget;
|
openAiImageTarget: AiRuntimeTarget;
|
||||||
openAiToolRankerTarget?: AiRuntimeTarget;
|
openAiToolRankerTarget?: AiRuntimeTarget;
|
||||||
|
openAiBackend: OpenAiBackend;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||||
@@ -303,9 +308,14 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
|||||||
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
|
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
|
||||||
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
|
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
|
||||||
openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"),
|
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[] {
|
export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
|
||||||
if (part.imageParts?.length) return part.imageParts;
|
if (part.imageParts?.length) return part.imageParts;
|
||||||
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
|
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
|
||||||
@@ -378,11 +388,13 @@ export function buildSystemInstruction(
|
|||||||
responseLanguage: UserAiResponseLanguage,
|
responseLanguage: UserAiResponseLanguage,
|
||||||
includePythonToolPrompt: boolean,
|
includePythonToolPrompt: boolean,
|
||||||
additions?: string | null,
|
additions?: string | null,
|
||||||
|
memoryInstruction?: string | null,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||||
additions?.trim() ? additions.trim() : null,
|
additions?.trim() ? additions.trim() : null,
|
||||||
|
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
|
||||||
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
||||||
].filter(Boolean).join("\n\n");
|
].filter(Boolean).join("\n\n");
|
||||||
}
|
}
|
||||||
@@ -512,13 +524,13 @@ export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set<A
|
|||||||
if (msg.video) kinds.add("video");
|
if (msg.video) kinds.add("video");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 1): Promise<StoredAttachment[]> {
|
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 40): Promise<StoredAttachment[]> {
|
||||||
const attachments: StoredAttachment[] = [];
|
const attachments: StoredAttachment[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let current = await MessageStore.get(msg.chat.id, msg.message_id);
|
let current = await MessageStore.get(msg.chat.id, msg.message_id);
|
||||||
|
|
||||||
for (let i = 0; current && i < limit; i++) {
|
for (let i = 0; current && i < limit; i++) {
|
||||||
for (const attachment of filterUserVisibleStoredAttachments(current?.attachments ?? [])) {
|
for (const attachment of filterUserInputStoredAttachments(current?.attachments ?? [])) {
|
||||||
const key = [
|
const key = [
|
||||||
attachment.kind,
|
attachment.kind,
|
||||||
attachment.fileUniqueId || attachment.fileId,
|
attachment.fileUniqueId || attachment.fileId,
|
||||||
@@ -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 args = safeJsonParseObject(toolCall.argumentsText);
|
||||||
const pathValue = typeof args.path === "string" ? args.path : undefined;
|
const pathValue = typeof args.path === "string" ? args.path : undefined;
|
||||||
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
|
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
|
||||||
const targetPath = typeof args.targetPath === "string" ? args.targetPath : 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) {
|
switch (toolCall.name) {
|
||||||
|
case "read_user_info":
|
||||||
|
case "read_system_info":
|
||||||
case "get_datetime":
|
case "get_datetime":
|
||||||
case "web_search":
|
case "web_search":
|
||||||
case "get_weather":
|
case "get_weather":
|
||||||
case "read_file":
|
case "read_file":
|
||||||
case "list_directory":
|
case "list_directory":
|
||||||
return [];
|
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_file":
|
||||||
case "create_directory":
|
case "create_directory":
|
||||||
case "update_file":
|
case "update_file":
|
||||||
@@ -1158,7 +1182,7 @@ export async function executeScheduledTool(
|
|||||||
message: TelegramStreamMessage,
|
message: TelegramStreamMessage,
|
||||||
context: ToolRuntimeContext,
|
context: ToolRuntimeContext,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const keys = toolResourceKeys(toolCall);
|
const keys = toolResourceKeys(toolCall, userId);
|
||||||
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
||||||
return runWithToolLocks(keys, () => 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 buffer = Buffer.from(b64, "base64");
|
||||||
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
|
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
|
||||||
const cachePath = path.join(photoGenDir, fileName);
|
const cachePath = path.join(photoGenDir(), fileName);
|
||||||
fs.writeFileSync(cachePath, buffer);
|
fs.writeFileSync(cachePath, buffer);
|
||||||
return {buffer, cachePath, fileName};
|
return {buffer, cachePath, fileName};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {ChatRequest} from "ollama";
|
||||||
import {BoundaryValue} from "../common/boundary-types";
|
import {BoundaryValue} from "../common/boundary-types.js";
|
||||||
import {ToolRankerFallbackPolicy} from "../common/policies";
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target";
|
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target.js";
|
||||||
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
|
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger.js";
|
||||||
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared";
|
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
|
||||||
import {
|
import {
|
||||||
buildRankerContext,
|
buildRankerContext,
|
||||||
buildRankerTarget,
|
buildRankerTarget,
|
||||||
buildToolRankerPrompt,
|
buildToolRankerPrompt,
|
||||||
filterRankedTools,
|
filterRankedTools,
|
||||||
ToolRankerSelection,
|
ToolRankerSelection,
|
||||||
} from "./tool-ranker-pipeline";
|
} from "./tool-ranker-pipeline.js";
|
||||||
import {allToolSchemaNames} from "./unified-ai-runner.shared";
|
import {allToolSchemaNames} from "./unified-ai-runner.shared.js";
|
||||||
import {sanitizeToolRankerResult} from "./tool-ranker-metadata";
|
import {sanitizeToolRankerResult} from "./tool-ranker-metadata.js";
|
||||||
|
import {resolveToolRankerFallbackSelection} from "./tool-ranker-fallback.js";
|
||||||
|
|
||||||
export class ToolRanker {
|
export class ToolRanker {
|
||||||
constructor(private readonly config: RuntimeConfigSnapshot) {
|
constructor(private readonly config: RuntimeConfigSnapshot) {
|
||||||
@@ -27,8 +28,15 @@ export class ToolRanker {
|
|||||||
round: number;
|
round: number;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[];
|
messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[];
|
||||||
|
runRanker?: (
|
||||||
|
provider: AiProvider,
|
||||||
|
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
|
||||||
|
prompt: string,
|
||||||
|
userQuery: string,
|
||||||
|
) => Promise<string>;
|
||||||
}): Promise<ToolRankerSelection> {
|
}): Promise<ToolRankerSelection> {
|
||||||
const {availableTools, provider, round, signal, userQuery} = args;
|
const {availableTools, provider, round, signal, userQuery} = args;
|
||||||
|
const runRanker = args.runRanker ?? this.runRanker.bind(this);
|
||||||
const availableNames = allToolSchemaNames(availableTools);
|
const availableNames = allToolSchemaNames(availableTools);
|
||||||
const fallbackPolicy = this.config.toolRankerFallbackPolicy;
|
const fallbackPolicy = this.config.toolRankerFallbackPolicy;
|
||||||
const configuredTarget = buildRankerTarget(this.config, provider);
|
const configuredTarget = buildRankerTarget(this.config, provider);
|
||||||
@@ -41,11 +49,10 @@ export class ToolRanker {
|
|||||||
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
|
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined);
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) {
|
return resolveToolRankerFallbackSelection({
|
||||||
return {toolNames: [], usedRanker: false};
|
fallbackPolicy,
|
||||||
}
|
availableToolNames: availableNames,
|
||||||
|
});
|
||||||
return {toolNames: availableNames, usedRanker: false};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -63,7 +70,7 @@ export class ToolRanker {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (signal.aborted) throw new Error("Aborted");
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
const raw = await this.runRanker(provider, target, ranker.prompt, userQuery);
|
const raw = await runRanker(provider, target, ranker.prompt, userQuery);
|
||||||
if (signal.aborted) throw new Error("Aborted");
|
if (signal.aborted) throw new Error("Aborted");
|
||||||
const selectedNames = sanitizeToolRankerResult({
|
const selectedNames = sanitizeToolRankerResult({
|
||||||
raw,
|
raw,
|
||||||
@@ -100,13 +107,13 @@ export class ToolRanker {
|
|||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: failureMessage,
|
errorSummary: failureMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fallbackRanker = buildToolRankerPrompt(
|
const fallbackRanker = buildToolRankerPrompt(
|
||||||
buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools),
|
buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools),
|
||||||
);
|
);
|
||||||
const raw = await this.runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery);
|
const raw = await runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery);
|
||||||
const selectedNames = sanitizeToolRankerResult({
|
const selectedNames = sanitizeToolRankerResult({
|
||||||
raw,
|
raw,
|
||||||
availableToolNames: availableNames,
|
availableToolNames: availableNames,
|
||||||
@@ -135,7 +142,7 @@ export class ToolRanker {
|
|||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: fallbackErrorMessage,
|
errorSummary: fallbackErrorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
failureMessage = fallbackErrorMessage;
|
failureMessage = fallbackErrorMessage;
|
||||||
@@ -148,17 +155,13 @@ export class ToolRanker {
|
|||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
fallbackPolicy,
|
fallbackPolicy,
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: failureMessage,
|
errorSummary: failureMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS) {
|
return resolveToolRankerFallbackSelection({
|
||||||
return {toolNames: [], usedRanker: false};
|
fallbackPolicy,
|
||||||
}
|
availableToolNames: availableNames,
|
||||||
|
});
|
||||||
return {
|
|
||||||
toolNames: availableNames,
|
|
||||||
usedRanker: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +227,19 @@ export class ToolRanker {
|
|||||||
{role: "user", content: userQuery},
|
{role: "user", content: userQuery},
|
||||||
] satisfies ChatCompletionMessageParam[];
|
] satisfies ChatCompletionMessageParam[];
|
||||||
|
|
||||||
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
|
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
|
||||||
const response = await openAi.chat.completions.create({
|
// only for official OpenAI endpoints.
|
||||||
|
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||||
model: target.model,
|
model: target.model,
|
||||||
messages,
|
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() ?? "";
|
return response.choices[0]?.message?.content?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-11
@@ -35,6 +35,7 @@ import {persistErrorArtifactAttachment} from "./final-response-artifact-store";
|
|||||||
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
|
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
|
||||||
import {AiRequestStore} from "../common/ai-request-store";
|
import {AiRequestStore} from "../common/ai-request-store";
|
||||||
import type {StoredAiRequestStatus} from "../model/stored-ai-request";
|
import type {StoredAiRequestStatus} from "../model/stored-ai-request";
|
||||||
|
import {recordAiRequestFinish, recordAiRequestStart} from "../common/ai-observability.js";
|
||||||
|
|
||||||
export type {ToolCallData} from "./unified-ai-runner.shared";
|
export type {ToolCallData} from "./unified-ai-runner.shared";
|
||||||
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
|
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
|
||||||
@@ -49,6 +50,7 @@ async function executeUnifiedAiRequest(
|
|||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
|
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
|
||||||
aiLog("info", "request.execute.start", {
|
aiLog("info", "request.execute.start", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
stream: options.stream ?? true,
|
stream: options.stream ?? true,
|
||||||
think: options.think,
|
think: options.think,
|
||||||
@@ -74,6 +76,7 @@ async function executeUnifiedAiRequest(
|
|||||||
if (preparedRequest.finishAfterTranscript) return;
|
if (preparedRequest.finishAfterTranscript) return;
|
||||||
|
|
||||||
aiLog("debug", "request.messages.collected", {
|
aiLog("debug", "request.messages.collected", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
chatMessages: preparedRequest.chatMessages.length,
|
chatMessages: preparedRequest.chatMessages.length,
|
||||||
imageCount: preparedRequest.imageCount,
|
imageCount: preparedRequest.imageCount,
|
||||||
@@ -91,6 +94,7 @@ async function executeUnifiedAiRequest(
|
|||||||
controller,
|
controller,
|
||||||
});
|
});
|
||||||
aiLog("success", "request.execute.done", {
|
aiLog("success", "request.execute.done", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
duration: aiLogDuration(requestStartedAt),
|
duration: aiLogDuration(requestStartedAt),
|
||||||
responseChars: streamMessage.getText().length,
|
responseChars: streamMessage.getText().length,
|
||||||
@@ -99,6 +103,7 @@ async function executeUnifiedAiRequest(
|
|||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiLog("error", "request.execute.failed", {
|
aiLog("error", "request.execute.failed", {
|
||||||
|
requestId: options.requestId,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
duration: aiLogDuration(requestStartedAt),
|
duration: aiLogDuration(requestStartedAt),
|
||||||
error: e instanceof Error ? e : String(e),
|
error: e instanceof Error ? e : String(e),
|
||||||
@@ -117,6 +122,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
|
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
|
||||||
|
|
||||||
aiLog("info", "run.start", {
|
aiLog("info", "run.start", {
|
||||||
|
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
model: snapshotModel(options.provider, config),
|
model: snapshotModel(options.provider, config),
|
||||||
message: aiLogMessageIdentity(options.msg),
|
message: aiLogMessageIdentity(options.msg),
|
||||||
@@ -133,6 +139,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
|
|
||||||
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
|
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
|
||||||
aiLog("warn", "run.rejected.unsupported_attachment", {
|
aiLog("warn", "run.rejected.unsupported_attachment", {
|
||||||
|
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
requestedAttachmentKinds: [...requestedAttachmentKinds],
|
requestedAttachmentKinds: [...requestedAttachmentKinds],
|
||||||
});
|
});
|
||||||
@@ -150,6 +157,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
|
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
|
||||||
}).catch(logError);
|
}).catch(logError);
|
||||||
aiLog("warn", "run.rejected.missing_attachment_cache", {
|
aiLog("warn", "run.rejected.missing_attachment_cache", {
|
||||||
|
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
|
||||||
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
|
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -166,6 +174,8 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
controller
|
controller
|
||||||
});
|
});
|
||||||
|
options.requestId ??= cancel.id;
|
||||||
|
const requestId = options.requestId;
|
||||||
const streamMessage = new TelegramStreamMessage(
|
const streamMessage = new TelegramStreamMessage(
|
||||||
options.msg,
|
options.msg,
|
||||||
cancel.id,
|
cancel.id,
|
||||||
@@ -180,10 +190,11 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
);
|
);
|
||||||
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
|
cancel.onCancel = () => streamMessage.cancel(cancel.provider);
|
||||||
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
|
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
|
||||||
aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
|
aiLog("debug", "run.queue.target", {requestId, target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
|
||||||
const aiRequestStartedAt = new Date().toISOString();
|
const aiRequestStartedAt = new Date().toISOString();
|
||||||
|
recordAiRequestStart();
|
||||||
await AiRequestStore.put({
|
await AiRequestStore.put({
|
||||||
requestId: cancel.id,
|
requestId,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
fromId: options.msg.from?.id ?? 0,
|
fromId: options.msg.from?.id ?? 0,
|
||||||
@@ -197,7 +208,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
const queueMessage = await streamMessage.start(Environment.waitThinkText);
|
const queueMessage = await streamMessage.start(Environment.waitThinkText);
|
||||||
responseMessageId = queueMessage.message_id;
|
responseMessageId = queueMessage.message_id;
|
||||||
await AiRequestStore.put({
|
await AiRequestStore.put({
|
||||||
requestId: cancel.id,
|
requestId,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
responseMessageId,
|
responseMessageId,
|
||||||
@@ -207,8 +218,9 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
status: "running",
|
status: "running",
|
||||||
startedAt: aiRequestStartedAt,
|
startedAt: aiRequestStartedAt,
|
||||||
}).catch(logError);
|
}).catch(logError);
|
||||||
setAiCancelMessageId(cancel.id, queueMessage.message_id);
|
setAiCancelMessageId(requestId, queueMessage.message_id);
|
||||||
aiLog("info", "run.queue.enter", {
|
aiLog("info", "run.queue.enter", {
|
||||||
|
requestId,
|
||||||
cancelId: cancel.id,
|
cancelId: cancel.id,
|
||||||
queueMessageId: queueMessage.message_id,
|
queueMessageId: queueMessage.message_id,
|
||||||
target: aiLogProviderTarget(queueTarget),
|
target: aiLogProviderTarget(queueTarget),
|
||||||
@@ -217,15 +229,16 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
await aiProviderRequestQueue.enqueue(queueTarget, {
|
await aiProviderRequestQueue.enqueue(queueTarget, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onPositionChange: async requestsBefore => {
|
onPositionChange: async requestsBefore => {
|
||||||
aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore});
|
aiLog("debug", "run.queue.position", {requestId, cancelId: cancel.id, requestsBefore});
|
||||||
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
|
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
|
||||||
await streamMessage.flush();
|
await streamMessage.flush();
|
||||||
},
|
},
|
||||||
run: async (): Promise<null> => {
|
run: async (): Promise<null> => {
|
||||||
const queueWaitFinishedAt = Date.now();
|
const queueWaitFinishedAt = Date.now();
|
||||||
aiLog("info", "run.queue.dequeued", {cancelId: cancel.id});
|
aiLog("info", "run.queue.dequeued", {requestId, cancelId: cancel.id});
|
||||||
const downloads = attachmentsToDownloadedFiles(cached.attachments);
|
const downloads = attachmentsToDownloadedFiles(cached.attachments);
|
||||||
aiLog("debug", "run.downloads.ready", {
|
aiLog("debug", "run.downloads.ready", {
|
||||||
|
requestId,
|
||||||
count: downloads.length,
|
count: downloads.length,
|
||||||
downloads: downloads.map(d => ({
|
downloads: downloads.map(d => ({
|
||||||
kind: d.kind,
|
kind: d.kind,
|
||||||
@@ -239,12 +252,13 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
|
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
|
||||||
aiRequestStatus = "succeeded";
|
aiRequestStatus = "succeeded";
|
||||||
aiLog("success", "run.queue.task.done", {
|
aiLog("success", "run.queue.task.done", {
|
||||||
|
requestId,
|
||||||
cancelId: cancel.id,
|
cancelId: cancel.id,
|
||||||
duration: aiLogDuration(queueWaitFinishedAt),
|
duration: aiLogDuration(queueWaitFinishedAt),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
cleanupDownloads(downloads);
|
cleanupDownloads(downloads);
|
||||||
aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length});
|
aiLog("debug", "run.downloads.cleaned", {requestId, cancelId: cancel.id, count: downloads.length});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -253,13 +267,13 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) {
|
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) {
|
||||||
aiRequestStatus = "aborted";
|
aiRequestStatus = "aborted";
|
||||||
aiRequestError = e instanceof Error ? e.message : String(e);
|
aiRequestError = e instanceof Error ? e.message : String(e);
|
||||||
aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
aiLog("warn", "run.aborted", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||||
streamMessage.replaceText(streamMessage.getText());
|
streamMessage.replaceText(streamMessage.getText());
|
||||||
await streamMessage.finish();
|
await streamMessage.finish();
|
||||||
} else {
|
} else {
|
||||||
aiRequestStatus = "failed";
|
aiRequestStatus = "failed";
|
||||||
aiRequestError = e instanceof Error ? e.message : String(e);
|
aiRequestError = e instanceof Error ? e.message : String(e);
|
||||||
aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
aiLog("error", "run.failed", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
await streamMessage.fail(e instanceof Error ? e : String(e));
|
await streamMessage.fail(e instanceof Error ? e : String(e));
|
||||||
try {
|
try {
|
||||||
@@ -279,7 +293,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
await AiRequestStore.put({
|
await AiRequestStore.put({
|
||||||
requestId: cancel.id,
|
requestId,
|
||||||
chatId: options.msg.chat.id,
|
chatId: options.msg.chat.id,
|
||||||
messageId: options.msg.message_id,
|
messageId: options.msg.message_id,
|
||||||
responseMessageId,
|
responseMessageId,
|
||||||
@@ -291,8 +305,10 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
|
|||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
error: aiRequestError,
|
error: aiRequestError,
|
||||||
}).catch(logError);
|
}).catch(logError);
|
||||||
finishAiRequest(cancel.id);
|
recordAiRequestFinish(aiRequestStatus);
|
||||||
|
finishAiRequest(requestId);
|
||||||
aiLog("success", "run.finished", {
|
aiLog("success", "run.finished", {
|
||||||
|
requestId,
|
||||||
cancelId: cancel.id,
|
cancelId: cancel.id,
|
||||||
provider: providerName(options.provider),
|
provider: providerName(options.provider),
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type {PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
|
||||||
|
export class PipelineRequestFailure extends Error {
|
||||||
|
constructor(public readonly decision: PipelineFallbackDecision, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PipelineRequestFailure";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function raisePipelineRequestFailure(decision: PipelineFallbackDecision, stageName: string): never {
|
||||||
|
throw new PipelineRequestFailure(decision, `Pipeline send failed at stage ${stageName} with fallback action ${decision.action}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type {PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
|
||||||
|
export function fallbackNotificationKey(requestId: string, decision: PipelineFallbackDecision): string {
|
||||||
|
return `${requestId}:${decision.stage}:${decision.action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PipelineFallbackNotificationRegistry {
|
||||||
|
private readonly notifiedKeys = new Set<string>();
|
||||||
|
|
||||||
|
claim(requestId: string, decision: PipelineFallbackDecision): boolean {
|
||||||
|
const key = fallbackNotificationKey(requestId, decision);
|
||||||
|
if (this.notifiedKeys.has(key)) return false;
|
||||||
|
this.notifiedKeys.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {Localization} from "../../common/localization.js";
|
||||||
|
import type {PipelineFallbackAction, PipelineStageName} from "./types.js";
|
||||||
|
|
||||||
|
export function resolvePipelineFallbackText(
|
||||||
|
stage: PipelineStageName,
|
||||||
|
action: PipelineFallbackAction,
|
||||||
|
locale?: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (action === "continue_without_stage") return undefined;
|
||||||
|
if (action === "fail_request") return Localization.text("pipelineFallback.failRequest", {}, "⚠️ I could not finish this request.", locale);
|
||||||
|
|
||||||
|
switch (stage) {
|
||||||
|
case "speech_to_text":
|
||||||
|
return Localization.text("pipelineFallback.speechToText", {}, "⚠️ Speech transcription failed, so I will continue without the audio transcript.", locale);
|
||||||
|
case "document_rag":
|
||||||
|
return Localization.text("pipelineFallback.documentRag", {}, "⚠️ Document retrieval failed, so I will answer without RAG.", locale);
|
||||||
|
case "tool_loop":
|
||||||
|
return Localization.text("pipelineFallback.toolLoop", {}, "⚠️ Tool execution failed, so I will continue without that tool.", locale);
|
||||||
|
case "text_to_speech":
|
||||||
|
return Localization.text("pipelineFallback.textToSpeech", {}, "⚠️ Text-to-speech failed, so I will continue without audio output.", locale);
|
||||||
|
default:
|
||||||
|
return action === "notify_user"
|
||||||
|
? Localization.text("pipelineFallback.notifyUser", {}, "⚠️ I hit a problem and need to continue with a fallback.", locale)
|
||||||
|
: Localization.text("pipelineFallback.generic", {}, "⚠️ I had to skip part of the request, but I can continue.", locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type {Message} from "typescript-telegram-bot-api";
|
||||||
|
import {Localization} from "../../common/localization.js";
|
||||||
|
import {replyToMessage, logError} from "../../util/utils.js";
|
||||||
|
import type {PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
import {PipelineFallbackNotificationRegistry} from "./fallback-notifier-registry.js";
|
||||||
|
import {resolvePipelineFallbackText} from "./fallback-notifier-text.js";
|
||||||
|
|
||||||
|
export class PipelineFallbackNotifier {
|
||||||
|
private readonly registry = new PipelineFallbackNotificationRegistry();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sourceMessage: Message,
|
||||||
|
private readonly responseLanguage?: string,
|
||||||
|
private readonly sendFallbackMessage: (text: string) => Promise<void> = async text => {
|
||||||
|
await replyToMessage({
|
||||||
|
message: this.sourceMessage,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async notify(requestId: string, decision: PipelineFallbackDecision): Promise<{notified: boolean; text?: string}> {
|
||||||
|
if (!this.registry.claim(requestId, decision)) {
|
||||||
|
return {notified: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = this.responseLanguage === "default"
|
||||||
|
? Localization.currentLocale()
|
||||||
|
: Localization.normalizeLocale(this.responseLanguage) ?? Localization.currentLocale();
|
||||||
|
const text = resolvePipelineFallbackText(decision.stage, decision.action, locale);
|
||||||
|
if (!text) {
|
||||||
|
return {notified: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sendFallbackMessage(text);
|
||||||
|
return {notified: true, text};
|
||||||
|
} catch (error) {
|
||||||
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
return {notified: false, text};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {AiProvider} from "../../model/ai-provider.js";
|
||||||
|
import type {RuntimeConfigSnapshot} from "../unified-ai-runner.shared.js";
|
||||||
|
import {aiLogProviderTarget} from "../../logging/ai-logger.js";
|
||||||
|
import {buildRankerTarget} from "../tool-ranker-pipeline.js";
|
||||||
|
import {providerChatTarget} from "../unified-ai-runner.shared.js";
|
||||||
|
|
||||||
|
export function buildToolRankFallbackTargetDetails(provider: AiProvider, config: RuntimeConfigSnapshot) {
|
||||||
|
const sourceTarget = buildRankerTarget(config, provider);
|
||||||
|
const alternateTarget = providerChatTarget(provider, config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceTarget: aiLogProviderTarget(sourceTarget),
|
||||||
|
alternateTarget: aiLogProviderTarget(alternateTarget),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js";
|
import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js";
|
||||||
import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js";
|
import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js";
|
||||||
|
import {raisePipelineRequestFailure} from "./fallback-failure.js";
|
||||||
import type {
|
import type {
|
||||||
PipelineAuditEvent,
|
PipelineAuditEvent,
|
||||||
PipelineFallbackPolicy,
|
PipelineFallbackPolicy,
|
||||||
@@ -66,7 +67,7 @@ export class UserRequestPipeline {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
if (decision.shouldFailRequest) {
|
if (decision.shouldFailRequest) {
|
||||||
throw new Error(`Required pipeline stage is not registered: ${stageName}`);
|
raisePipelineRequestFailure(decision, stageName);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ export class UserRequestPipeline {
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
}));
|
}));
|
||||||
if (decision.shouldFailRequest) {
|
if (decision.shouldFailRequest) {
|
||||||
throw error;
|
raisePipelineRequestFailure(decision, stageName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -3,18 +3,28 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {parse as parseDotEnv} from "dotenv";
|
import {parse as parseDotEnv} from "dotenv";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {appLogger} from "../logging/logger";
|
import {appLogger} from "../logging/logger.js";
|
||||||
import type {BoundaryValue, ErrorLike} from "./boundary-types";
|
import type {BoundaryValue, ErrorLike} from "./boundary-types";
|
||||||
|
|
||||||
import {saveData} from "../db/database";
|
import {Answers} from "../model/answers.js";
|
||||||
import {Answers} from "../model/answers";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {ifTrue} from "../util/utils";
|
import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies.js";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {ToolRankerFallbackPolicy} from "./policies.js";
|
||||||
import {ImageHandleFallbackPolicy, ImageHandlePolicy, RateLimitFallbackPolicy} from "./policies";
|
import type {ToolCallData} from "../ai/unified-ai-runner.js";
|
||||||
import {ToolRankerFallbackPolicy} from "./policies";
|
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js";
|
||||||
import type {ToolCallData} from "../ai/unified-ai-runner";
|
import {Localization, type LocalizationParams} from "./localization.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator";
|
|
||||||
import {Localization, type LocalizationParams} from "./localization";
|
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 EnvRecord = Record<string, string>;
|
||||||
type StringEnumLike = Record<string, string>;
|
type StringEnumLike = Record<string, string>;
|
||||||
@@ -53,7 +63,7 @@ function booleanWithDefaultSchema(defaultValue: boolean) {
|
|||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ifTrue(normalized);
|
return parseBooleanLike(normalized);
|
||||||
}, z.boolean())
|
}, z.boolean())
|
||||||
.default(defaultValue)
|
.default(defaultValue)
|
||||||
.catch(defaultValue);
|
.catch(defaultValue);
|
||||||
@@ -62,7 +72,7 @@ function booleanWithDefaultSchema(defaultValue: boolean) {
|
|||||||
const optionalBooleanSchema = z
|
const optionalBooleanSchema = z
|
||||||
.preprocess(value => {
|
.preprocess(value => {
|
||||||
const normalized = normalizeString(value as BoundaryValue);
|
const normalized = normalizeString(value as BoundaryValue);
|
||||||
return normalized === undefined ? undefined : ifTrue(normalized);
|
return normalized === undefined ? undefined : parseBooleanLike(normalized);
|
||||||
}, z.boolean().optional())
|
}, z.boolean().optional())
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined);
|
.catch(undefined);
|
||||||
@@ -211,6 +221,10 @@ const RuntimeEnvSchema = z.object({
|
|||||||
SEND_TIME_TOOK: optionalBooleanSchema,
|
SEND_TIME_TOOK: optionalBooleanSchema,
|
||||||
|
|
||||||
ENABLE_PYTHON_INTERPRETER: 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_API_KEY: optionalStringSchema,
|
||||||
OLLAMA_ADDRESS: optionalStringSchema,
|
OLLAMA_ADDRESS: optionalStringSchema,
|
||||||
@@ -238,6 +252,10 @@ const RuntimeEnvSchema = z.object({
|
|||||||
|
|
||||||
OPENAI_BASE_URL: optionalStringSchema,
|
OPENAI_BASE_URL: optionalStringSchema,
|
||||||
OPENAI_API_KEY: optionalStringSchema,
|
OPENAI_API_KEY: optionalStringSchema,
|
||||||
|
OPENAI_BACKEND: enumWithDefaultSchema(
|
||||||
|
OpenAiBackendModes,
|
||||||
|
OpenAiBackendModes.OFFICIAL,
|
||||||
|
),
|
||||||
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
|
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
|
||||||
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
|
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
|
||||||
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
|
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
|
||||||
@@ -305,6 +323,10 @@ export class Environment {
|
|||||||
static SEND_TIME_TOOK: boolean = false;
|
static SEND_TIME_TOOK: boolean = false;
|
||||||
|
|
||||||
static ENABLE_PYTHON_INTERPRETER: 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_API_KEY?: string;
|
||||||
static OLLAMA_ADDRESS?: string;
|
static OLLAMA_ADDRESS?: string;
|
||||||
@@ -332,6 +354,7 @@ export class Environment {
|
|||||||
|
|
||||||
static OPENAI_BASE_URL?: string;
|
static OPENAI_BASE_URL?: string;
|
||||||
static OPENAI_API_KEY?: string;
|
static OPENAI_API_KEY?: string;
|
||||||
|
static OPENAI_BACKEND: OpenAiBackend = OpenAiBackendModes.OFFICIAL;
|
||||||
static OPENAI_MODEL: string = "";
|
static OPENAI_MODEL: string = "";
|
||||||
static OPENAI_IMAGE_MODEL: string = "";
|
static OPENAI_IMAGE_MODEL: string = "";
|
||||||
static OPENAI_TRANSCRIPTION_MODEL: string = "";
|
static OPENAI_TRANSCRIPTION_MODEL: string = "";
|
||||||
@@ -820,6 +843,34 @@ export class Environment {
|
|||||||
return this.text("noTextToSynthesizeText", "No text to synthesize.");
|
return this.text("noTextToSynthesizeText", "No text to synthesize.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackGenericText() {
|
||||||
|
return this.text("pipelineFallbackGenericText", "⚠️ I had to skip part of the request, but I can continue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackNotifyText() {
|
||||||
|
return this.text("pipelineFallbackNotifyText", "⚠️ I hit a problem and need to continue with a fallback.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackFailText() {
|
||||||
|
return this.text("pipelineFallbackFailText", "⚠️ I could not finish this request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackRagText() {
|
||||||
|
return this.text("pipelineFallbackRagText", "⚠️ Document retrieval failed, so I will answer without RAG.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackSpeechToTextText() {
|
||||||
|
return this.text("pipelineFallbackSpeechToTextText", "⚠️ Speech transcription failed, so I will continue without the audio transcript.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackTextToSpeechText() {
|
||||||
|
return this.text("pipelineFallbackTextToSpeechText", "⚠️ Text-to-speech failed, so I will continue without audio output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pipelineFallbackToolText() {
|
||||||
|
return this.text("pipelineFallbackToolText", "⚠️ Tool execution failed, so I will continue without that tool.");
|
||||||
|
}
|
||||||
|
|
||||||
static get mistralTtsNoAudioDataText() {
|
static get mistralTtsNoAudioDataText() {
|
||||||
return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData.");
|
return this.text("mistralTtsNoAudioDataText", "Mistral TTS did not return audioData.");
|
||||||
}
|
}
|
||||||
@@ -960,6 +1011,9 @@ export class Environment {
|
|||||||
choice: "/choice a, b, ..., c",
|
choice: "/choice a, b, ..., c",
|
||||||
coin: "/coin",
|
coin: "/coin",
|
||||||
debug: "/debug",
|
debug: "/debug",
|
||||||
|
aiRequests: "/aiRequests",
|
||||||
|
aiAudit: "/aiAudit [reply|messageId|chatId messageId]",
|
||||||
|
aiMetrics: "/aiMetrics",
|
||||||
dice: "/dice",
|
dice: "/dice",
|
||||||
distort: "/distort [amp] [wavelength]",
|
distort: "/distort [amp] [wavelength]",
|
||||||
help: "/help",
|
help: "/help",
|
||||||
@@ -1010,6 +1064,9 @@ export class Environment {
|
|||||||
choice: this.text("commandDescriptions.choice", "Choose a random value"),
|
choice: this.text("commandDescriptions.choice", "Choose a random value"),
|
||||||
coin: this.text("commandDescriptions.coin", "Heads or tails"),
|
coin: this.text("commandDescriptions.coin", "Heads or tails"),
|
||||||
debug: this.text("commandDescriptions.debug", "Returns msg (or reply) as json"),
|
debug: this.text("commandDescriptions.debug", "Returns msg (or reply) as json"),
|
||||||
|
aiRequests: this.text("commandDescriptions.aiRequests", "Show recent AI requests"),
|
||||||
|
aiAudit: this.text("commandDescriptions.aiAudit", "Inspect AI request audit and artifacts"),
|
||||||
|
aiMetrics: this.text("commandDescriptions.aiMetrics", "Show AI observability counters"),
|
||||||
dice: this.text("commandDescriptions.dice", "Sends random or specific dice"),
|
dice: this.text("commandDescriptions.dice", "Sends random or specific dice"),
|
||||||
distort: this.text("commandDescriptions.distort", "Distortion of picture"),
|
distort: this.text("commandDescriptions.distort", "Distortion of picture"),
|
||||||
help: this.text("commandDescriptions.help", "Show list of commands"),
|
help: this.text("commandDescriptions.help", "Show list of commands"),
|
||||||
@@ -1805,6 +1862,10 @@ export class Environment {
|
|||||||
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false;
|
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false;
|
||||||
|
|
||||||
Environment.ENABLE_PYTHON_INTERPRETER = env.ENABLE_PYTHON_INTERPRETER ?? 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_API_KEY = env.OLLAMA_API_KEY;
|
||||||
Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS;
|
Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS;
|
||||||
@@ -1832,6 +1893,7 @@ export class Environment {
|
|||||||
|
|
||||||
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
|
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
|
||||||
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
||||||
|
Environment.OPENAI_BACKEND = env.OPENAI_BACKEND;
|
||||||
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
|
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
|
||||||
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
|
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
|
||||||
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
|
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
|
||||||
@@ -1939,6 +2001,7 @@ export class Environment {
|
|||||||
|
|
||||||
if (!has) {
|
if (!has) {
|
||||||
this.ADMIN_IDS.add(id);
|
this.ADMIN_IDS.add(id);
|
||||||
|
const {saveData} = await import("../db/database.js");
|
||||||
await saveData();
|
await saveData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1950,6 +2013,7 @@ export class Environment {
|
|||||||
|
|
||||||
if (has) {
|
if (has) {
|
||||||
this.ADMIN_IDS.delete(id);
|
this.ADMIN_IDS.delete(id);
|
||||||
|
const {saveData} = await import("../db/database.js");
|
||||||
await saveData();
|
await saveData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1966,6 +2030,7 @@ export class Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.MUTED_IDS.add(id);
|
this.MUTED_IDS.add(id);
|
||||||
|
const {saveData} = await import("../db/database.js");
|
||||||
await saveData();
|
await saveData();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1976,6 +2041,7 @@ export class Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.MUTED_IDS.delete(id);
|
this.MUTED_IDS.delete(id);
|
||||||
|
const {saveData} = await import("../db/database.js");
|
||||||
await saveData();
|
await saveData();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2028,6 +2094,10 @@ export class Environment {
|
|||||||
this.OPENAI_API_KEY = newAIApiKey;
|
this.OPENAI_API_KEY = newAIApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static setOpenAIBackend(newBackend: OpenAiBackend): void {
|
||||||
|
this.OPENAI_BACKEND = newBackend;
|
||||||
|
}
|
||||||
|
|
||||||
static setOpenAIModel(newModel: string): void {
|
static setOpenAIModel(newModel: string): void {
|
||||||
this.OPENAI_MODEL = newModel;
|
this.OPENAI_MODEL = newModel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {AsyncLocalStorage} from "node:async_hooks";
|
import {AsyncLocalStorage} from "node:async_hooks";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {appLogger} from "../logging/logger";
|
import {appLogger} from "../logging/logger.js";
|
||||||
|
|
||||||
const logger = appLogger.child("localization");
|
const logger = appLogger.child("localization");
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export type MessagePart = {
|
|||||||
audios?: string[];
|
audios?: string[];
|
||||||
audioParts?: MessageAudioPart[];
|
audioParts?: MessageAudioPart[];
|
||||||
documents?: string[];
|
documents?: string[];
|
||||||
|
documentNames?: string[];
|
||||||
videos?: string[];
|
videos?: string[];
|
||||||
videoNotes?: string[];
|
videoNotes?: string[];
|
||||||
|
videoNames?: string[];
|
||||||
|
videoNoteNames?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {Environment} from "./environment";
|
import {Environment} from "./environment";
|
||||||
import {StoredAttachment} from "../model/stored-attachment";
|
import {StoredAttachment} from "../model/stored-attachment";
|
||||||
|
export {filterUserVisibleStoredAttachments} from "./attachment-visibility";
|
||||||
|
|
||||||
export function photoCachePathForUniqueId(uniqueId: string): string {
|
export function photoCachePathForUniqueId(uniqueId: string): string {
|
||||||
return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`);
|
return path.join(Environment.DATA_PATH, "cache", "photo", `${uniqueId}.jpg`);
|
||||||
@@ -44,7 +45,3 @@ export function uniqueStoredAttachments(attachments: StoredAttachment[]): Stored
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterUserVisibleStoredAttachments(attachments: StoredAttachment[]): StoredAttachment[] {
|
|
||||||
return attachments.filter(attachment => attachment.scope !== "internal_artifact");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2055,7 +2055,24 @@ export class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async migrateLegacyNormalizedTables(): Promise<void> {
|
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 attachments = messages.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message));
|
||||||
const artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message));
|
const artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message));
|
||||||
const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message));
|
const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message));
|
||||||
|
|||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment.js";
|
||||||
import {logError} from "../util/utils";
|
import {logError} from "../util/utils.js";
|
||||||
import {Answers} from "../model/answers";
|
import {Answers} from "../model/answers.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {KeyedAsyncLock} from "../util/async-lock";
|
import {KeyedAsyncLock} from "../util/async-lock.js";
|
||||||
|
|
||||||
type DataJsonFile = {
|
type DataJsonFile = {
|
||||||
admins: number[]
|
admins: number[]
|
||||||
|
|||||||
+97
-65
@@ -1,9 +1,9 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import {appLogger} from "./logging/logger";
|
import {appLogger} from "./logging/logger.js";
|
||||||
import {Environment} from "./common/environment";
|
import {Environment} from "./common/environment.js";
|
||||||
import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api";
|
import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api";
|
||||||
import {Command} from "./base/command";
|
import {Command} from "./base/command.js";
|
||||||
import type {LogDetails} from "./logging/logger";
|
import type {LogDetails} from "./logging/logger.js";
|
||||||
import {
|
import {
|
||||||
initSystemSpecs,
|
initSystemSpecs,
|
||||||
logError,
|
logError,
|
||||||
@@ -13,68 +13,73 @@ import {
|
|||||||
processInlineQuery,
|
processInlineQuery,
|
||||||
processMyChatMember,
|
processMyChatMember,
|
||||||
processNewMessage
|
processNewMessage
|
||||||
} from "./util/utils";
|
} from "./util/utils.js";
|
||||||
import {Ae} from "./commands/ae";
|
import {Ae} from "./commands/ae.js";
|
||||||
import {Help} from "./commands/help";
|
import {Help} from "./commands/help.js";
|
||||||
import {Ignore} from "./commands/ignore";
|
import {Ignore} from "./commands/ignore.js";
|
||||||
import {Unignore} from "./commands/unignore";
|
import {Unignore} from "./commands/unignore.js";
|
||||||
import {Ping} from "./commands/ping";
|
import {Ping} from "./commands/ping.js";
|
||||||
import {RandomString} from "./commands/random-string";
|
import {RandomString} from "./commands/random-string.js";
|
||||||
import {SystemInfo} from "./commands/system-info";
|
import {SystemInfo} from "./commands/system-info.js";
|
||||||
import {Test} from "./commands/test";
|
import {Test} from "./commands/test.js";
|
||||||
import {readData, retrieveAnswers} from "./db/database";
|
import {readData, retrieveAnswers} from "./db/database.js";
|
||||||
import {Uptime} from "./commands/uptime";
|
import {Uptime} from "./commands/uptime.js";
|
||||||
import {WhatBetter} from "./commands/what-better";
|
import {WhatBetter} from "./commands/what-better.js";
|
||||||
import {When} from "./commands/when";
|
import {When} from "./commands/when.js";
|
||||||
import {RandomInt} from "./commands/random-int";
|
import {RandomInt} from "./commands/random-int.js";
|
||||||
import {Ban} from "./commands/ban";
|
import {Ban} from "./commands/ban.js";
|
||||||
import {Quote} from "./commands/quote";
|
import {Quote} from "./commands/quote.js";
|
||||||
import {OllamaSearch} from "./commands/ollama-search";
|
import {OllamaSearch} from "./commands/ollama-search.js";
|
||||||
import {Id} from "./commands/id";
|
import {Id} from "./commands/id.js";
|
||||||
import {AdminsAdd} from "./commands/admins-add";
|
import {AdminsAdd} from "./commands/admins-add.js";
|
||||||
import {AdminsRemove} from "./commands/admins-remove";
|
import {AdminsRemove} from "./commands/admins-remove.js";
|
||||||
import {Shutdown} from "./commands/shutdown";
|
import {Shutdown} from "./commands/shutdown.js";
|
||||||
import {Leave} from "./commands/leave";
|
import {Leave} from "./commands/leave.js";
|
||||||
import {OllamaChat} from "./commands/ollama-chat";
|
import {OllamaChat} from "./commands/ollama-chat.js";
|
||||||
import {Start} from "./commands/start";
|
import {Start} from "./commands/start.js";
|
||||||
import {Choice} from "./commands/choice";
|
import {Choice} from "./commands/choice.js";
|
||||||
import {Coin} from "./commands/coin";
|
import {Coin} from "./commands/coin.js";
|
||||||
import {Qr} from "./commands/qr";
|
import {Qr} from "./commands/qr.js";
|
||||||
import {Distort} from "./commands/distort";
|
import {Distort} from "./commands/distort.js";
|
||||||
import {Dice} from "./commands/dice";
|
import {Dice} from "./commands/dice.js";
|
||||||
import {Unban} from "./commands/unban";
|
import {Unban} from "./commands/unban.js";
|
||||||
import {Title} from "./commands/title";
|
import {Title} from "./commands/title.js";
|
||||||
import {MessageDao} from "./db/message-dao";
|
import {MessageDao} from "./db/message-dao.js";
|
||||||
import {DatabaseManager} from "./db/database-manager";
|
import {DatabaseManager} from "./db/database-manager.js";
|
||||||
import {UserDao} from "./db/user-dao";
|
import {UserDao} from "./db/user-dao.js";
|
||||||
import {UserStore} from "./common/user-store";
|
import {UserStore} from "./common/user-store.js";
|
||||||
import {CallbackCommand} from "./base/callback-command";
|
import {CallbackCommand} from "./base/callback-command.js";
|
||||||
import {AiCancel} from "./callback_commands/ai-cancel";
|
import {AiCancel} from "./callback_commands/ai-cancel.js";
|
||||||
import {AiRegenerate} from "./callback_commands/ai-regenerate";
|
import {AiRegenerate} from "./callback_commands/ai-regenerate.js";
|
||||||
import {MistralChat} from "./commands/mistral-chat";
|
import {MistralChat} from "./commands/mistral-chat.js";
|
||||||
import {Transliteration} from "./commands/transliteration";
|
import {Transliteration} from "./commands/transliteration.js";
|
||||||
import {OllamaListModels} from "./commands/ollama-list-models";
|
import {OllamaListModels} from "./commands/ollama-list-models.js";
|
||||||
import {OllamaGetModel} from "./commands/ollama-get-model";
|
import {OllamaGetModel} from "./commands/ollama-get-model.js";
|
||||||
import {OllamaSetModel} from "./commands/ollama-set-model";
|
import {OllamaSetModel} from "./commands/ollama-set-model.js";
|
||||||
import {MistralGetModel} from "./commands/mistral-get-model";
|
import {MistralGetModel} from "./commands/mistral-get-model.js";
|
||||||
import {MistralSetModel} from "./commands/mistral-set-model";
|
import {MistralSetModel} from "./commands/mistral-set-model.js";
|
||||||
import {MistralListModels} from "./commands/mistral-list-models";
|
import {MistralListModels} from "./commands/mistral-list-models.js";
|
||||||
import {Debug} from "./commands/debug";
|
import {Debug} from "./commands/debug.js";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {OpenAIChat} from "./commands/openai-chat";
|
import {OpenAIChat} from "./commands/openai-chat.js";
|
||||||
import {OpenAIListModels} from "./commands/openai-list-models";
|
import {OpenAIListModels} from "./commands/openai-list-models.js";
|
||||||
import {OpenAIGetModel} from "./commands/openai-get-model";
|
import {OpenAIGetModel} from "./commands/openai-get-model.js";
|
||||||
import {OpenAISetModel} from "./commands/openai-set-model";
|
import {OpenAISetModel} from "./commands/openai-set-model.js";
|
||||||
import {Info} from "./commands/info";
|
import {Info} from "./commands/info.js";
|
||||||
import {AdminsList} from "./commands/admins-list";
|
import {AdminsList} from "./commands/admins-list.js";
|
||||||
import {ExportDb} from "./commands/export-db";
|
import {ExportDb} from "./commands/export-db.js";
|
||||||
import {ImportDb} from "./commands/import-db";
|
import {ImportDb} from "./commands/import-db.js";
|
||||||
import {Settings} from "./commands/settings";
|
import {Settings} from "./commands/settings.js";
|
||||||
import {UserSettingsCallback} from "./callback_commands/user-settings";
|
import {UserSettingsCallback} from "./callback_commands/user-settings.js";
|
||||||
import {TextToSpeech} from "./commands/text-to-speech";
|
import {TextToSpeech} from "./commands/text-to-speech.js";
|
||||||
import {SpeechToText} from "./commands/speech-to-text";
|
import {SpeechToText} from "./commands/speech-to-text.js";
|
||||||
import {cleanupInternalArtifactCache} from "./ai/internal-artifact-store";
|
import {cleanupInternalArtifactCache} from "./ai/internal-artifact-store.js";
|
||||||
|
import {AIAudit} from "./commands/ai-audit.js";
|
||||||
|
import {AIMetrics} from "./commands/ai-metrics.js";
|
||||||
|
import {AIRequests} from "./commands/ai-requests.js";
|
||||||
|
import {cleanupStaleRagProviderState} from "./ai/rag-retention.js";
|
||||||
|
import {initializeMcpTools, shutdownMcpTools} from "./ai/mcp/mcp-registry.js";
|
||||||
|
|
||||||
process.setUncaughtExceptionCaptureCallback(logError);
|
process.setUncaughtExceptionCaptureCallback(logError);
|
||||||
|
|
||||||
@@ -119,6 +124,9 @@ export const commands: Command[] = [
|
|||||||
new Settings(),
|
new Settings(),
|
||||||
new TextToSpeech(),
|
new TextToSpeech(),
|
||||||
new SpeechToText(),
|
new SpeechToText(),
|
||||||
|
new AIRequests(),
|
||||||
|
new AIAudit(),
|
||||||
|
new AIMetrics(),
|
||||||
|
|
||||||
new AdminsAdd(),
|
new AdminsAdd(),
|
||||||
new AdminsRemove(),
|
new AdminsRemove(),
|
||||||
@@ -186,6 +194,7 @@ export const filesDir = path.join(Environment.DATA_PATH, "files");
|
|||||||
export const NOTES_HEADER = "## Notes\n";
|
export const NOTES_HEADER = "## Notes\n";
|
||||||
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
||||||
export const notesRootFile = path.join(notesDir, "index.md");
|
export const notesRootFile = path.join(notesDir, "index.md");
|
||||||
|
export const memoryDir = path.join(Environment.DATA_PATH, "memory");
|
||||||
|
|
||||||
const logger = appLogger.child("main");
|
const logger = appLogger.child("main");
|
||||||
|
|
||||||
@@ -227,6 +236,11 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
|
|||||||
await bot.stopPolling();
|
await bot.stopPolling();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error instanceof Error ? error : String(error));
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await shutdownMcpTools();
|
||||||
|
} catch (error) {
|
||||||
|
logError(error instanceof Error ? error : String(error));
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await DatabaseManager.close();
|
await DatabaseManager.close();
|
||||||
@@ -236,6 +250,7 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -248,7 +263,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await measureStartupStep("environment.load", () => Environment.load());
|
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", () => {
|
await measureStartupStep("prepare_directories", () => {
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
for (const dir of dirsToCheck) {
|
for (const dir of dirsToCheck) {
|
||||||
@@ -272,6 +287,23 @@ async function main() {
|
|||||||
}, () => ({notesRootFilePath}));
|
}, () => ({notesRootFilePath}));
|
||||||
|
|
||||||
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
|
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
|
||||||
|
await measureStartupStep("cleanup_stale_rag_provider_state", () => cleanupStaleRagProviderState(), () => ({retentionDays: 14}));
|
||||||
|
await measureStartupStep("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 => {
|
const cmds = await measureStartupStep("build_commands", () => commands.filter(cmd => {
|
||||||
return cmd.title && cmd.title.startsWith("/") && cmd.title.split(" ").length === 1 && cmd.description;
|
return cmd.title && cmd.title.startsWith("/") && cmd.title.split(" ").length === 1 && cmd.description;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Message} from "typescript-telegram-bot-api";
|
import {Message} from "typescript-telegram-bot-api";
|
||||||
import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger";
|
import {createLogger, formatDuration, LogDetails, LogLevel} from "./logger.js";
|
||||||
|
|
||||||
export type AiRunnerLogLevel = LogLevel;
|
export type AiRunnerLogLevel = LogLevel;
|
||||||
export type AiRunnerLogDetails = LogDetails;
|
export type AiRunnerLogDetails = LogDetails;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AiProvider} from "./ai-provider";
|
import {AiProvider} from "./ai-provider.js";
|
||||||
|
|
||||||
export type AiEndpointInfo = {
|
export type AiEndpointInfo = {
|
||||||
provider?: AiProvider;
|
provider?: AiProvider;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AiCapabilityInfo} from "./ai-capability-info";
|
import {AiCapabilityInfo} from "./ai-capability-info.js";
|
||||||
|
|
||||||
export class AiModelCapabilities {
|
export class AiModelCapabilities {
|
||||||
chat: AiCapabilityInfo | undefined;
|
chat: AiCapabilityInfo | undefined;
|
||||||
|
|||||||
+79
-36
@@ -1,7 +1,7 @@
|
|||||||
import * as si from "systeminformation";
|
import * as si from "systeminformation";
|
||||||
import {appLogger} from "../logging/logger";
|
import {appLogger} from "../logging/logger.js";
|
||||||
import {Command} from "../base/command";
|
import {Command} from "../base/command.js";
|
||||||
import {CallbackCommand} from "../base/callback-command";
|
import {CallbackCommand} from "../base/callback-command.js";
|
||||||
import {
|
import {
|
||||||
CallbackQuery,
|
CallbackQuery,
|
||||||
ChatMember,
|
ChatMember,
|
||||||
@@ -15,39 +15,40 @@ import {
|
|||||||
TelegramBot,
|
TelegramBot,
|
||||||
User
|
User
|
||||||
} from "typescript-telegram-bot-api";
|
} from "typescript-telegram-bot-api";
|
||||||
import {Environment} from "../common/environment";
|
import {Environment} from "../common/environment.js";
|
||||||
import {TelegramError} from "typescript-telegram-bot-api/dist/errors";
|
import {TelegramError} from "typescript-telegram-bot-api/dist/errors.js";
|
||||||
import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index";
|
import {bot, botUser, callbackCommands, commands, messageDao, photoDir} from "../index.js";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part";
|
import {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part.js";
|
||||||
import {StoredMessage} from "../model/stored-message";
|
import {StoredMessage} from "../model/stored-message.js";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import {UserStore} from "../common/user-store";
|
import {UserStore} from "../common/user-store.js";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {MessageStore} from "../common/message-store";
|
import {MessageStore} from "../common/message-store.js";
|
||||||
import {SystemInfo} from "../commands/system-info";
|
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
|
||||||
import {PrefixResponse} from "../commands/prefix-response";
|
import {SystemInfo} from "../commands/system-info.js";
|
||||||
import {ChatCommand} from "../base/chat-command";
|
import {PrefixResponse} from "../commands/prefix-response.js";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {ChatCommand} from "../base/chat-command.js";
|
||||||
import {SendOptions} from "../model/send-options";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {EditOptions} from "../model/edit-options";
|
import {SendOptions} from "../model/send-options.js";
|
||||||
import {StoredUser} from "../model/stored-user";
|
import {EditOptions} from "../model/edit-options.js";
|
||||||
import {StoredAttachment} from "../model/stored-attachment";
|
import {StoredUser} from "../model/stored-user.js";
|
||||||
import {AiDownloadedFile} from "../ai/telegram-attachments";
|
import {StoredAttachment} from "../model/stored-attachment.js";
|
||||||
import {runUnifiedAi} from "../ai/unified-ai-runner";
|
import {AiDownloadedFile} from "../ai/telegram-attachments.js";
|
||||||
import {enqueueTelegramApiCall} from "./telegram-api-queue";
|
import {runUnifiedAi} from "../ai/unified-ai-runner.js";
|
||||||
import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock";
|
import {enqueueTelegramApiCall} from "./telegram-api-queue.js";
|
||||||
import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings";
|
import {AsyncSemaphore, KeyedAsyncLock} from "./async-lock.js";
|
||||||
import {Localization} from "../common/localization";
|
import {resolveEffectiveAiProviderForUser, resolveInterfaceLocaleForUser} from "../common/user-ai-settings.js";
|
||||||
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
|
import {Localization} from "../common/localization.js";
|
||||||
import {RandomUtils} from "./random-utils";
|
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target.js";
|
||||||
import {HtmlUtils} from "./html-utils";
|
import {RandomUtils} from "./random-utils.js";
|
||||||
import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner";
|
import {HtmlUtils} from "./html-utils.js";
|
||||||
import type {BoundaryValue, ErrorLike} from "../common/boundary-types";
|
import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner.js";
|
||||||
import {createStoredImageAttachment, photoCachePathForUniqueId, uniqueStoredAttachments} from "../common/stored-attachment-utils";
|
import type {BoundaryValue, ErrorLike} from "../common/boundary-types.js";
|
||||||
import {runTelegramMessageAttachmentPipeline} from "../ai/user-request-pipeline";
|
import {createStoredImageAttachment, photoCachePathForUniqueId, uniqueStoredAttachments} from "../common/stored-attachment-utils.js";
|
||||||
|
import {runTelegramMessageAttachmentPipeline} from "../ai/user-request-pipeline/index.js";
|
||||||
|
|
||||||
const imageProcessingSemaphore = new AsyncSemaphore(2);
|
const imageProcessingSemaphore = new AsyncSemaphore(2);
|
||||||
const fileWriteLocks = new KeyedAsyncLock();
|
const fileWriteLocks = new KeyedAsyncLock();
|
||||||
@@ -1487,12 +1488,13 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
|
|||||||
const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText;
|
const cleanText = cutPrefix ? cutPrefixes(rawText) : rawText;
|
||||||
const imageNames = await loadImagesIfExists(msg);
|
const imageNames = await loadImagesIfExists(msg);
|
||||||
const messageDownloads = includeDownloads ? downloads : [];
|
const messageDownloads = includeDownloads ? downloads : [];
|
||||||
const storedImageAttachments = isStoredMessage(msg)
|
const storedAttachments = isStoredMessage(msg)
|
||||||
? (msg.attachments ?? []).filter(attachment => attachment.kind === "image" && fs.existsSync(attachment.cachePath))
|
? filterUserInputStoredAttachments(msg.attachments ?? []).filter(attachment => fs.existsSync(attachment.cachePath))
|
||||||
: [];
|
: [];
|
||||||
|
const storedImageAttachments = storedAttachments.filter(attachment => attachment.kind === "image");
|
||||||
|
|
||||||
if (!cleanText && !quoteText && textRequired) return;
|
if (!cleanText && !quoteText && textRequired) return;
|
||||||
if (!cleanText && !quoteText && !imageNames?.length && !storedImageAttachments.length && !messageDownloads.length) return;
|
if (!cleanText && !quoteText && !imageNames?.length && !storedAttachments.length && !messageDownloads.length) return;
|
||||||
|
|
||||||
const fromId = isStoredMessage(msg) ? msg.fromId : msg.from?.id;
|
const fromId = isStoredMessage(msg) ? msg.fromId : msg.from?.id;
|
||||||
const user = await UserStore.get(isStoredMessage(msg) ? msg.fromId : msg.from?.id ?? -1);
|
const user = await UserStore.get(isStoredMessage(msg) ? msg.fromId : msg.from?.id ?? -1);
|
||||||
@@ -1527,11 +1529,19 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
|
|||||||
});
|
});
|
||||||
const imageParts = [...photoImageParts, ...cachedImageParts];
|
const imageParts = [...photoImageParts, ...cachedImageParts];
|
||||||
|
|
||||||
|
const storedDocumentAttachments = storedAttachments.filter(attachment => attachment.kind === "document");
|
||||||
|
const storedVideoAttachments = storedAttachments.filter(attachment => attachment.kind === "video");
|
||||||
|
const storedVideoNoteAttachments = storedAttachments.filter(attachment => attachment.kind === "video-note");
|
||||||
|
const storedAudioAttachments = storedAttachments.filter(attachment => attachment.kind === "audio");
|
||||||
|
|
||||||
const audios: string[] = [];
|
const audios: string[] = [];
|
||||||
const audioParts: MessageAudioPart[] = [];
|
const audioParts: MessageAudioPart[] = [];
|
||||||
const documents: string[] = [];
|
const documents: string[] = [];
|
||||||
|
const documentNames: string[] = [];
|
||||||
const videos: string[] = [];
|
const videos: string[] = [];
|
||||||
|
const videoNames: string[] = [];
|
||||||
const videoNotes: string[] = [];
|
const videoNotes: string[] = [];
|
||||||
|
const videoNoteNames: string[] = [];
|
||||||
|
|
||||||
if (messageDownloads.length) {
|
if (messageDownloads.length) {
|
||||||
messageDownloads
|
messageDownloads
|
||||||
@@ -1544,21 +1554,51 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
|
|||||||
|
|
||||||
messageDownloads
|
messageDownloads
|
||||||
.filter(d => d.kind === "document")
|
.filter(d => d.kind === "document")
|
||||||
.forEach(d => documents.push(d.buffer.toString("base64")));
|
.forEach(d => {
|
||||||
|
documents.push(d.buffer.toString("base64"));
|
||||||
|
documentNames.push(d.fileName);
|
||||||
|
});
|
||||||
|
|
||||||
messageDownloads
|
messageDownloads
|
||||||
.filter(d => d.kind === "video")
|
.filter(d => d.kind === "video")
|
||||||
.forEach(v => videos.push(v.buffer.toString("base64")));
|
.forEach(v => {
|
||||||
|
videos.push(v.buffer.toString("base64"));
|
||||||
|
videoNames.push(v.fileName);
|
||||||
|
});
|
||||||
|
|
||||||
messageDownloads
|
messageDownloads
|
||||||
.filter(d => d.kind === "video-note")
|
.filter(d => d.kind === "video-note")
|
||||||
.forEach(v => {
|
.forEach(v => {
|
||||||
const data = v.buffer.toString("base64");
|
const data = v.buffer.toString("base64");
|
||||||
videoNotes.push(data);
|
videoNotes.push(data);
|
||||||
|
videoNoteNames.push(v.fileName);
|
||||||
audioParts.push({data, mimeType: mimeTypeFromAudioDownload(v)});
|
audioParts.push({data, mimeType: mimeTypeFromAudioDownload(v)});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
storedAudioAttachments.forEach(attachment => {
|
||||||
|
const data = Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64");
|
||||||
|
audios.push(data);
|
||||||
|
audioParts.push({data, mimeType: attachment.mimeType || "audio/ogg"});
|
||||||
|
});
|
||||||
|
|
||||||
|
storedDocumentAttachments.forEach(attachment => {
|
||||||
|
documents.push(Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64"));
|
||||||
|
documentNames.push(attachment.fileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
storedVideoAttachments.forEach(attachment => {
|
||||||
|
videos.push(Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64"));
|
||||||
|
videoNames.push(attachment.fileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
storedVideoNoteAttachments.forEach(attachment => {
|
||||||
|
const data = Buffer.from(fs.readFileSync(attachment.cachePath)).toString("base64");
|
||||||
|
videoNotes.push(data);
|
||||||
|
videoNoteNames.push(attachment.fileName);
|
||||||
|
audioParts.push({data, mimeType: attachment.mimeType || "video/mp4"});
|
||||||
|
});
|
||||||
|
|
||||||
const content = [
|
const content = [
|
||||||
quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "",
|
quoteText ? `[citation]:\n${quoteText}\n\n[message]:\n` : "",
|
||||||
cleanText ?? ""
|
cleanText ?? ""
|
||||||
@@ -1576,8 +1616,11 @@ export async function collectReplyChainText(options: ReplyChainOptions): Promise
|
|||||||
audios: audios.length ? audios : undefined,
|
audios: audios.length ? audios : undefined,
|
||||||
audioParts: audioParts.length ? audioParts : undefined,
|
audioParts: audioParts.length ? audioParts : undefined,
|
||||||
documents: documents.length ? documents : undefined,
|
documents: documents.length ? documents : undefined,
|
||||||
|
documentNames: documentNames.length ? documentNames : undefined,
|
||||||
videos: videos.length ? videos : undefined,
|
videos: videos.length ? videos : undefined,
|
||||||
|
videoNames: videoNames.length ? videoNames : undefined,
|
||||||
videoNotes: videoNotes.length ? videoNotes : undefined,
|
videoNotes: videoNotes.length ? videoNotes : undefined,
|
||||||
|
videoNoteNames: videoNoteNames.length ? videoNoteNames : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -1,32 +1,13 @@
|
|||||||
import test, {after} from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-rag-"));
|
const {
|
||||||
process.env.BOT_TOKEN = process.env.BOT_TOKEN ?? "test-token";
|
buildRagArtifactPayload,
|
||||||
process.env.CREATOR_ID = process.env.CREATOR_ID ?? "1";
|
} = await import("../dist/ai/rag-artifact-payload.js");
|
||||||
process.env.DATA_PATH = tempRoot;
|
const {
|
||||||
process.env.DB_PATH = `file:${path.join(tempRoot, "test.sqlite")}`;
|
filterUserVisibleStoredAttachments,
|
||||||
process.env.TEST_ENVIRONMENT = "true";
|
} = await import("../dist/common/attachment-visibility.js");
|
||||||
|
|
||||||
const {Environment} = await import("../dist/common/environment.js");
|
|
||||||
Environment.load();
|
|
||||||
|
|
||||||
const {DatabaseManager} = await import("../dist/db/database-manager.js");
|
|
||||||
DatabaseManager.init();
|
|
||||||
await DatabaseManager.ready;
|
|
||||||
|
|
||||||
const {ArtifactStore} = await import("../dist/common/artifact-store.js");
|
|
||||||
const {filterUserVisibleStoredAttachments} = await import("../dist/common/stored-attachment-utils.js");
|
|
||||||
const {AiProvider} = await import("../dist/model/ai-provider.js");
|
const {AiProvider} = await import("../dist/model/ai-provider.js");
|
||||||
const {persistRagArtifactAttachment} = await import("../dist/ai/rag-artifact-store.js");
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await DatabaseManager.close().catch(() => undefined);
|
|
||||||
fs.rmSync(tempRoot, {recursive: true, force: true});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("internal artifacts are not treated as user-visible attachments", () => {
|
test("internal artifacts are not treated as user-visible attachments", () => {
|
||||||
const visible = filterUserVisibleStoredAttachments([
|
const visible = filterUserVisibleStoredAttachments([
|
||||||
@@ -50,65 +31,26 @@ test("internal artifacts are not treated as user-visible attachments", () => {
|
|||||||
assert.equal(visible[0].fileId, "visible");
|
assert.equal(visible[0].fileId, "visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("RAG artifacts persist structured ollama metadata", async () => {
|
test("RAG artifact payload keeps ollama retrieval metadata", () => {
|
||||||
const chatId = 42;
|
const payload = buildRagArtifactPayload({
|
||||||
const messageId = 7;
|
|
||||||
|
|
||||||
const attachment = await persistRagArtifactAttachment({
|
|
||||||
provider: AiProvider.OLLAMA,
|
provider: AiProvider.OLLAMA,
|
||||||
prepared: {
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
provider: AiProvider.OLLAMA,
|
sources: [{
|
||||||
prepared: true,
|
|
||||||
cleanup: async () => undefined,
|
|
||||||
artifact: {
|
|
||||||
query: "What is in the file?",
|
|
||||||
extractedDocuments: [
|
|
||||||
{documentIndex: 0, fileName: "report.txt", textChars: 120},
|
|
||||||
],
|
|
||||||
selectedChunks: [
|
|
||||||
{
|
|
||||||
sourceId: "doc1-1",
|
|
||||||
documentIndex: 0,
|
|
||||||
documentName: "report.txt",
|
|
||||||
chunkIndex: 0,
|
|
||||||
chunkCount: 1,
|
|
||||||
textChars: 120,
|
|
||||||
score: 0.91,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skippedDocuments: [
|
|
||||||
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
|
|
||||||
],
|
|
||||||
providerState: {
|
|
||||||
embeddingModel: "nomic-embed-text:latest",
|
|
||||||
topK: 8,
|
|
||||||
chunkSize: 1400,
|
|
||||||
chunkOverlap: 220,
|
|
||||||
maxContextChars: 14000,
|
|
||||||
minScore: 0.12,
|
|
||||||
maxArchiveFiles: 200,
|
|
||||||
maxArchiveBytes: 50 * 1024 * 1024,
|
|
||||||
maxArchiveDepth: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
downloads: [{
|
|
||||||
kind: "document",
|
|
||||||
fileId: "file-1",
|
fileId: "file-1",
|
||||||
fileName: "report.txt",
|
fileName: "report.txt",
|
||||||
buffer: Buffer.from("hello world"),
|
mimeType: "text/plain",
|
||||||
path: path.join(tempRoot, "report.txt"),
|
sizeBytes: 12,
|
||||||
|
sha256: "abc123",
|
||||||
|
uploadedFileId: "uploaded-1",
|
||||||
}],
|
}],
|
||||||
chatId,
|
providerState: {
|
||||||
messageId,
|
provider: AiProvider.OLLAMA,
|
||||||
details: {
|
prepared: true,
|
||||||
embeddingModel: "nomic-embed-text:latest",
|
embeddingModel: "nomic-embed-text:latest",
|
||||||
topK: 8,
|
topK: 8,
|
||||||
chunkSize: 1400,
|
chunkSize: 1400,
|
||||||
chunkOverlap: 220,
|
chunkOverlap: 220,
|
||||||
maxContextChars: 14000,
|
maxContextChars: 14000,
|
||||||
artifact: {
|
|
||||||
query: "What is in the file?",
|
|
||||||
extractedDocuments: [
|
extractedDocuments: [
|
||||||
{documentIndex: 0, fileName: "report.txt", textChars: 120},
|
{documentIndex: 0, fileName: "report.txt", textChars: 120},
|
||||||
],
|
],
|
||||||
@@ -126,29 +68,20 @@ test("RAG artifacts persist structured ollama metadata", async () => {
|
|||||||
skippedDocuments: [
|
skippedDocuments: [
|
||||||
{documentIndex: 1, fileName: "ignored.bin", reason: "unsupported format"},
|
{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,
|
minScore: 0.12,
|
||||||
maxArchiveFiles: 200,
|
maxArchiveFiles: 200,
|
||||||
maxArchiveBytes: 50 * 1024 * 1024,
|
maxArchiveBytes: 50 * 1024 * 1024,
|
||||||
maxArchiveDepth: 2,
|
maxArchiveDepth: 2,
|
||||||
},
|
query: "What is in the file?",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(attachment?.artifactKind, "rag");
|
assert.equal(payload.artifactKind, "rag");
|
||||||
assert.equal(fs.existsSync(attachment.cachePath), true);
|
assert.equal(payload.provider, AiProvider.OLLAMA);
|
||||||
|
assert.equal(payload.sources[0].uploadedFileId, "uploaded-1");
|
||||||
const stored = await ArtifactStore.getByMessage(chatId, messageId);
|
assert.equal(payload.providerState.provider, AiProvider.OLLAMA);
|
||||||
assert.equal(stored.length, 1);
|
assert.equal(payload.providerState.query, "What is in the file?");
|
||||||
assert.equal(stored[0].kind, "rag");
|
assert.equal(payload.providerState.selectedChunks[0].score, 0.91);
|
||||||
assert.equal(stored[0].payload.providerState.query, "What is in the file?");
|
assert.equal(payload.providerState.skippedDocuments[0].reason, "unsupported format");
|
||||||
assert.equal(stored[0].payload.providerState.selectedChunks[0].score, 0.91);
|
assert.equal(payload.providerState.embeddingModel, "nomic-embed-text:latest");
|
||||||
assert.equal(stored[0].payload.providerState.skippedDocuments[0].reason, "unsupported format");
|
|
||||||
assert.equal(stored[0].payload.providerState.ollama.embeddingModel, "nomic-embed-text:latest");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user