4 Commits

Author SHA1 Message Date
melod1n 46a99605e6 Add OpenAI compatible chat backend 2026-05-22 20:52:35 +03:00
melod1n 321d185592 bump versions 2026-05-21 17:05:33 +03:00
melod1n a3f19f0413 Fix startup schema migration deadlock 2026-05-19 17:58:09 +03:00
melod1n c613c636e1 Add local tool filtering 2026-05-19 08:33:18 +03:00
45 changed files with 2353 additions and 182 deletions
+14
View File
@@ -46,6 +46,15 @@ USE_NAMES_IN_PROMPT=true
# Disable all built-in local tools and keep only MCP tools # Disable all built-in local tools and keep only MCP tools
DISABLE_LOCAL_TOOLS=false 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=
@@ -94,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
@@ -124,6 +137,7 @@ MCP_SERVERS=
# 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
+314
View File
@@ -0,0 +1,314 @@
# OPENAI Compatible Target Implementation
## Purpose
Add a separate execution path for OpenAI-compatible backends such as `llama.cpp`, while keeping the current official OpenAI path unchanged.
## Checklist
- [x] Add explicit OpenAI backend mode in config
- [x] Route OpenAI requests to separate official and compatible runners
- [x] Keep official OpenAI on `responses.create(...)`
- [x] Add compatible `chat.completions.create(...)` runner
- [x] Add compatible tool-call extractors
- [x] Add backend selection tests
- [x] Add basic memory/config regression coverage
- [x] Normalize compatible streaming tool-call assembly
- [x] Preserve file upload behavior in compatible backend
- [x] Guard unsupported OpenAI-only tools for compatible backend
- [x] Add environment docs and example config entries
- [x] Add real-server integration coverage for compatible backend
- [x] Revisit shared orchestration extraction for further deduplication
## Non-Goals
1. Do not change the current official OpenAI `responses.create(...)` behavior.
2. Do not auto-switch behavior only because `OPENAI_BASE_URL` is set.
3. Do not merge compatible backend quirks into the official OpenAI runner.
4. Do not remove or weaken existing tool ranking, memory, RAG, logging, or upload behavior.
## Current State
1. `src/ai/unified-ai-runner.openai.ts` currently uses the official `responses` API.
2. `src/ai/provider-adapters.ts` already has provider-specific adapters and tool/result mapping.
3. `src/ai/provider-adapter-contract.ts` already contains `responses`-style extractors.
4. `src/ai/openai-chat-message.ts` currently models `responses`-style messages, not `chat.completions` tool messages.
5. `src/ai/unified-ai-request-pipeline.ts` prepares chat context and runtime state before the model call.
6. `src/ai/ai-runtime-target.ts` resolves provider targets, base URLs, models, and keys.
7. `src/ai/unified-ai-runner.tool-ranker.ts` already uses a `chat.completions`-style call path, which is closer to compatible backends.
## Target Architecture
1. Official OpenAI backend stays on `responses.create(...)`.
2. Compatible OpenAI backend uses `chat.completions.create(...)`.
3. Backend selection is explicit through config, for example `OPENAI_BACKEND=official|compatible`.
4. Shared preparation logic remains common.
5. Transport-specific request formatting and response parsing are split.
## Configuration Design
1. Add a new config value `OPENAI_BACKEND`.
2. Allowed values should be `official` and `compatible`.
3. Default must be `official`.
4. Keep `OPENAI_BASE_URL` as a transport setting only.
5. `OPENAI_BASE_URL` must not imply compatible mode by itself.
6. Extend environment schema and runtime config to expose this value.
7. Update env docs and example env files.
## Step 1: Config and Target Selection
1. Update `src/common/environment.ts`.
2. Add a new environment field for backend mode.
3. Add setters if the codebase uses runtime env mutation in tests.
4. Update the startup schema and runtime snapshot.
5. Add tests for default value and explicit `compatible` selection.
Expected result:
- Official OpenAI stays unchanged by default.
- Explicit `OPENAI_BACKEND=compatible` selects the new execution path.
## Step 2: Split Runner Selection
1. Update the unified AI execution entry point.
2. Add a small backend selector for OpenAI targets.
3. Route official mode to the current runner.
4. Route compatible mode to a new compatible runner.
5. Keep other providers untouched.
Expected result:
- One codepath for official OpenAI.
- One codepath for OpenAI-compatible servers.
## Step 3: Shared Orchestration Extraction
1. Identify logic that is identical for both OpenAI branches.
2. Extract common orchestration into a shared helper where possible.
3. Keep these pieces shared:
- memory prompt injection
- tool ranking
- tool loop control
- logging and timing
- cancellation handling
- file upload post-processing
- document RAG preparation and cleanup
4. Keep transport-specific pieces separate:
- request shape
- response parsing
- tool result message shape
- streaming event parsing
Expected result:
- Less duplicate logic.
- Cleaner separation between official and compatible behavior.
## Step 4: Compatible Message Model
1. Update `src/ai/openai-chat-message.ts` or create a sibling type file for compatible chat messages.
2. Model `system`, `user`, `assistant`, and `tool` roles explicitly.
3. Support `tool_calls` on assistant messages.
4. Support `tool_call_id` on tool result messages.
5. Preserve support for text and multimodal user content where the backend supports it.
6. Avoid forcing `responses` output types into `chat.completions`.
Expected result:
- Compatible runner can build valid `chat.completions` message arrays.
## Step 5: Compatible Contract Extractors
1. Extend `src/ai/provider-adapter-contract.ts`.
2. Add extractors for `chat.completions` tool calls.
3. Add extractors for `chat.completions` streaming tool call deltas.
4. Keep existing `responses` extractors intact.
5. Normalize tool call IDs, names, and argument text the same way as existing extractors.
6. Ensure arguments are always represented as JSON text for the tool loop.
Expected result:
- Compatible runner can parse tool calls from both normal and streaming responses.
## Step 6: Compatible Provider Adapter
1. Update `src/ai/provider-adapters.ts`.
2. Add a separate adapter or branch for OpenAI-compatible chat.completions behavior.
3. Reuse existing tool ranking where safe.
4. Make `appendToolResults(...)` emit `role: "tool"` messages with `tool_call_id`.
5. Keep official OpenAI adapter outputting `function_call_output`.
6. Keep Mistral and Ollama unchanged.
Expected result:
- Each backend uses the tool result shape it expects.
## Step 7: Compatible Runner Implementation
1. Create a new file such as `src/ai/unified-ai-runner.openai-compatible.ts`.
2. Use `openai.chat.completions.create(...)`.
3. Pass `messages`, `tools`, `model`, `stream`, and `signal`.
4. Map system prompt and memory prompt into the `messages` array correctly.
5. Keep the tool loop structure from the current runner.
6. Append assistant tool-call messages and tool result messages between rounds.
7. Continue until no tool calls remain or max rounds is reached.
Expected result:
- Compatible backends can complete multi-round tool flows.
## Step 8: Tool Call Loop Semantics
1. Preserve `MAX_TOOL_ROUNDS`.
2. Preserve tool ranking before each round.
3. Preserve memory tool selection.
4. Preserve file search injection when document RAG is active.
5. Preserve file upload post-processing.
6. Preserve max-rounds warnings and continuation decisions.
7. Keep the final text visible in the stream message exactly as today.
Expected result:
- Compatible backend behaves like the current runner from the users perspective.
## Step 9: Streaming Behavior
1. Implement streaming event handling for `chat.completions`.
2. Parse text deltas and append them to `TelegramStreamMessage`.
3. Parse `delta.tool_calls` and keep incremental tool-call state.
4. Update status text when tool usage starts and ends.
5. Keep image generation and file-search status handling if the backend emits compatible signals.
6. Finalize the stream only after the terminal completion event.
Expected result:
- Streaming works without losing tool call state.
## Step 10: Tool Result Handling
1. After each tool execution round, append tool results using the compatible message format.
2. Ensure each tool result keeps the correct `tool_call_id`.
3. Preserve the existing file upload hook.
4. If upload fails, convert the failure into a tool result error string.
5. Preserve the same tool memory map behavior.
Expected result:
- The backend receives a valid message history for the next round.
## Step 11: Prompt and Memory Injection
1. Keep `buildSystemInstruction(...)` as the source of system prompt assembly.
2. Keep `buildUserMemoryPrompt(...)` injected as a separate block.
3. Preserve the explicit separation between assistant memory and user memory.
4. Preserve the `user.md` and `system.md` memory layout.
5. Ensure compatible backend receives the same semantic prompt content.
Expected result:
- Memory behavior stays identical across official and compatible backends.
## Step 12: Tool Ranking Compatibility
1. Review `src/ai/unified-ai-runner.tool-ranker.ts`.
2. Verify whether the current JSON response handling is safe for compatible backends.
3. If a backend cannot guarantee strict JSON mode, add a fallback parser.
4. Keep ranking inputs and outputs consistent across both branches.
5. Do not weaken tool selection heuristics.
Expected result:
- Tool ranking remains deterministic enough for both branches.
## Step 13: File Search and RAG
1. Keep document RAG preparation in the request pipeline.
2. Keep vector store preparation for official OpenAI.
3. Decide whether compatible backend supports file search or needs a no-op fallback.
4. If unsupported, guard the tool list so the compatible backend never receives unsupported tools.
5. Keep cleanup behavior for temporary artifacts.
Expected result:
- Compatible backend does not receive tools it cannot execute.
## Step 14: Error Handling
1. Preserve abort handling.
2. Preserve response failure handling.
3. Preserve stream error handling.
4. Surface backend-specific incompatibilities as explicit errors.
5. Do not silently fall back from compatible to official mode.
6. Keep logs actionable.
Expected result:
- Failures are obvious and debuggable.
## Step 15: Logging and Observability
1. Keep the current AI logs and duration tracking.
2. Add backend mode to log metadata.
3. Log tool calls, tool outputs, and round transitions in both branches.
4. Preserve existing observability hooks.
5. Add explicit labels for official vs compatible runs.
Expected result:
- Debugging remains easy after the split.
## Step 16: Tests
1. Add unit tests for backend selection.
2. Add unit tests for compatible message conversion.
3. Add unit tests for compatible tool call extraction.
4. Add integration tests for a tool-call round trip using mocked `chat.completions`.
5. Add tests proving the official `responses` path is unchanged.
6. Add tests for streaming tool call parsing if the backend supports it.
7. Add tests for fallback behavior in the tool ranker if needed.
Expected result:
- Both branches are covered and regressions are visible quickly.
## Step 17: Suggested File Changes
1. `src/common/environment.ts`
2. `src/ai/ai-runtime-target.ts`
3. `src/ai/unified-ai-request-pipeline.ts`
4. `src/ai/unified-ai-runner.openai.ts`
5. `src/ai/unified-ai-runner.openai-compatible.ts`
6. `src/ai/provider-adapter-contract.ts`
7. `src/ai/provider-adapters.ts`
8. `src/ai/openai-chat-message.ts`
9. `src/ai/unified-ai-runner.tool-ranker.ts`
10. `test/*.test.mjs`
11. `.env.example`
12. Documentation files for backend selection
## Implementation Order
1. [x] Add config flag and wire it through environment parsing.
2. [x] Add backend selection logic.
3. [x] Add compatible message and extractor support.
4. [x] Create the compatible runner.
5. [x] Reuse shared orchestration where possible.
6. [x] Wire tests.
7. [x] Verify official behavior is unchanged.
8. [x] Verify compatible backend works with a real OpenAI-compatible server.
## Verification Plan
1. Run unit tests.
2. Run integration tests.
3. Verify official OpenAI path still uses `responses.create(...)`.
4. Verify compatible path uses `chat.completions.create(...)`.
5. Verify a `llama.cpp`-style server can complete a tool loop.
6. Verify memory tools still work.
7. Verify document RAG and file upload behavior do not regress.
## Risks
1. Some OpenAI-compatible servers do not support every official OpenAI feature.
2. Streaming tool call deltas may differ across providers.
3. JSON-mode assumptions in the ranker may not hold for all compatible servers.
4. Tool schema filtering may need backend-specific allowlists.
5. Message conversion mistakes can break tool loops silently if not tested.
## Acceptance Criteria
1. Official OpenAI behavior is unchanged.
2. Compatible backend can run a full chat loop with tools.
3. Tool calls are correctly extracted and executed.
4. Tool results are appended in the correct format.
5. Memory injection still works.
6. Document RAG and file upload behavior remain functional or fail explicitly.
7. Tests cover both branches.
## Final Note
The key design rule is simple: keep official OpenAI `responses` behavior intact, and introduce OpenAI-compatible `chat.completions` behavior as a separate backend mode with its own parsing and message shape.
+8
View File
@@ -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.
``` ```
@@ -39,6 +40,13 @@ If you want to disable all built-in local tools and use only MCP tools, set:
DISABLE_LOCAL_TOOLS=true 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
+5 -5
View File
@@ -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.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="], "@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=="],
+4 -4
View File
@@ -30,7 +30,7 @@
"@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.9.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",
@@ -1246,9 +1246,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.0", "version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "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"
+1 -1
View File
@@ -35,7 +35,7 @@
"@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.9.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",
+21 -1
View File
@@ -6,7 +6,7 @@ import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
import {AiProvider} from "../model/ai-provider.js"; 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 ?? "")
+5 -1
View File
@@ -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;
@@ -267,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");
} }
@@ -310,11 +313,12 @@ export async function buildConversationSnapshot(
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),
}; };
} }
+4
View File
@@ -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);
+66
View File
@@ -0,0 +1,66 @@
import {isRecord} from "./unified-ai-runner.shared.js";
import type {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message.js";
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export function responseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
export function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAICompatibleChatMessage[] {
return messages.map((message): OpenAICompatibleChatMessage => {
if (message.role === "system") {
return {role: "system", content: responseContentToText(message.content)};
}
if (message.role === "assistant") {
const text = responseContentToText(message.content);
return text.length
? {role: "assistant", content: text}
: {role: "assistant", content: null};
}
const content = Array.isArray(message.content)
? (() => {
const parts = message.content.map((part): {type: "text"; text: string} | {type: "image_url"; image_url: {url: string}} => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
});
return parts.every(part => part.type === "text")
? parts.map(part => part.text).join("")
: parts;
})()
: message.content;
return {role: "user", content};
});
}
export function buildAssistantToolMessage(calls: ToolCallData[], text: string): OpenAICompatibleChatMessage {
return {
role: "assistant",
content: text,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
};
}
+3
View File
@@ -2,6 +2,7 @@ import type {
ResponseInputMessageContentList, 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;
+74
View File
@@ -0,0 +1,74 @@
import {Message} from "typescript-telegram-bot-api";
import fs from "node:fs";
import path from "node:path";
import {bot} from "../index.js";
import {Environment} from "../common/environment.js";
import {logError} from "../util/utils.js";
import {errorMessage} from "./unified-ai-runner.shared.js";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files.js";
export async function tryToUploadFiles(
msg: Message,
toolResults: string[]
): Promise<
| { found: false }
| { found: true, uploaded: true }
| { found: boolean, uploaded: false, error: string, toolIndex: number }
> {
let sendFileAttachment: {
result: SendFileAttachmentResult & { success: true },
toolIndex: number
} | null = null;
let found = false;
try {
for (const [index, toolResult] of toolResults.entries()) {
const raw = JSON.parse(toolResult);
const res = SendFileAttachmentResultSchema.safeParse(raw);
if (res.success) {
found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: index};
}
}
}
if (!found) {
return {found: false};
}
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
const attachmentPath = attachmentRoot
? path.join(
attachmentRoot,
String(msg.from?.id),
sendFileAttachment?.result?.attachment?.relativePath ?? "",
)
: "";
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(attachmentPath),
});
return {found: true, uploaded: true};
} catch (e) {
logError(e instanceof Error ? e : String(e));
return {
found: found,
uploaded: false,
error: errorMessage(e instanceof Error ? e : String(e)),
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
}
}
+86
View File
@@ -14,6 +14,12 @@ function normalizeToolArguments(value: unknown): string {
return JSON.stringify(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[] { export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
const output = isRecord(response) && Array.isArray(response.output) ? response.output : []; const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
@@ -32,6 +38,86 @@ export function extractOpenAiTextDelta(input: unknown): string {
return event?.type === "response.output_text.delta" ? event.delta ?? "" : ""; 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[] { export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
const event = input as ResponseStreamEvent | undefined; const event = input as ResponseStreamEvent | undefined;
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") { if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
+2 -1
View File
@@ -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")) {
+23
View File
@@ -3,6 +3,7 @@ import {AiProvider} from "../model/ai-provider.js";
import {getTools} from "./tools/registry.js"; import {getTools} from "./tools/registry.js";
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js"; import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js"; 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;
}
+104
View File
@@ -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.",
@@ -443,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");
+5 -5
View File
@@ -1,11 +1,11 @@
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types.js";
import path from "node:path"; import 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 -1
View File
@@ -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 = {
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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");
+5 -5
View File
@@ -1,11 +1,11 @@
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types.js";
import path from "node:path"; import 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");
+97 -32
View File
@@ -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,12 +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"; 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 = [
@@ -73,35 +75,62 @@ export const fileTools = [
deletePathTool, deletePathTool,
] satisfies AiTool[]; ] satisfies AiTool[];
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
if (!raw?.trim()) return undefined;
const names = raw
.split(",")
.map(item => item.trim().toLowerCase())
.filter(Boolean);
return names.length ? new Set(names) : undefined;
}
function isLocalToolEnabled(toolName: string): boolean {
if (Environment.DISABLE_LOCAL_TOOLS) return false;
const allowlist = parseToolNameSet(Environment.LOCAL_TOOL_ALLOWLIST);
if (allowlist && !allowlist.has(toolName.toLowerCase())) return false;
const denylist = parseToolNameSet(Environment.LOCAL_TOOL_DENYLIST);
if (denylist && denylist.has(toolName.toLowerCase())) return false;
return true;
}
function filterEnabledTools(tools: AiTool[]): AiTool[] {
return tools.filter(tool => isLocalToolEnabled(tool.function.name));
}
export const getTools = (forCreator?: boolean) => { export const getTools = (forCreator?: boolean) => {
const tools: AiTool[] = Environment.DISABLE_LOCAL_TOOLS ? [] : [ const tools: AiTool[] = [];
...defaultTools,
];
if (Environment.DISABLE_LOCAL_TOOLS) { if (Environment.DISABLE_LOCAL_TOOLS) {
tools.push(...getMcpTools()); tools.push(...getMcpTools());
return tools; 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]));
} }
} }
@@ -132,7 +161,7 @@ export const fileToolHandlers = {
}; };
export const getToolHandlers = () => { export const getToolHandlers = () => {
let handlers: Record<string, ToolHandler> = { const handlers: Record<string, ToolHandler> = {
...getMcpToolHandlers(), ...getMcpToolHandlers(),
}; };
@@ -140,21 +169,43 @@ export const getToolHandlers = () => {
return handlers; return handlers;
} }
handlers = { if (isLocalToolEnabled("get_datetime")) handlers.get_datetime = getCurrentDateTime;
...handlers, if (isLocalToolEnabled("get_financial_market_data")) handlers.get_financial_market_data = getMarketRates;
get_datetime: getCurrentDateTime, for (const tool of memoryTools) {
get_financial_market_data: getMarketRates, 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"};
}
...fileToolHandlers, return executeMemoryTool(tool.function.name as MemoryToolName, {
userId,
python_interpreter: runPythonInterpreter, content: typeof args?.content === "string" ? args.content : undefined,
}, context);
shell_execute: shellExecute,
web_search: webSearch,
get_weather: getWeather,
}; };
}
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;
}; };
@@ -165,14 +216,28 @@ export function getToolPrompts(toolNames: string[]): string[] {
} }
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);
+12 -7
View File
@@ -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;
+5 -5
View File
@@ -1,10 +1,10 @@
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types.js";
import path from "node:path"; import 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");
+2 -2
View File
@@ -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 = {
+2 -1
View File
@@ -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;
+582
View File
@@ -0,0 +1,582 @@
import path from "node:path";
import {readFile, rename, writeFile, mkdir, rm} from "node:fs/promises";
import {AiProvider} from "../../model/ai-provider.js";
import {Environment} from "../../common/environment.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveOptionalAiRuntimeTarget, type AiRuntimeTarget} from "../ai-runtime-target.js";
import {AiTool} from "../tool-types.js";
import {toolsLogger} from "./tool-logger.js";
import {asNonEmptyString} from "./utils.js";
const logger = toolsLogger.child("user-memory");
function memoryDir(): string {
return path.join(Environment.DATA_PATH, "memory");
}
export const USER_MEMORY_MAX_CHARS = 1000;
export type MemoryScope = "user" | "system";
export type MemoryAction = "add" | "replace" | "remove";
export type MemoryRuntimeContext = {
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
};
export type MemoryOperationResult =
| {success: true; scope: MemoryScope; filePath: string; content: string; chars: number; compressed: boolean}
| {success: false; scope: MemoryScope; error: string};
type CompressionRunResult = {
content: string;
};
export type MemoryCompressionRunner = (params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}) => Promise<string>;
function extractMistralText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part && typeof (part as {text?: unknown}).text === "string") {
return (part as {text: string}).text;
}
return "";
})
.join("");
}
export type MemoryToolName =
| "read_user_info"
| "read_system_info"
| "add_user_info"
| "add_system_info"
| "remove_user_info"
| "remove_system_info"
| "replace_user_info"
| "replace_system_info"
| "delete_user_info"
| "delete_system_info";
export const MEMORY_TOOL_NAMES: MemoryToolName[] = [
"read_user_info",
"read_system_info",
"add_user_info",
"add_system_info",
"remove_user_info",
"remove_system_info",
"replace_user_info",
"replace_system_info",
"delete_user_info",
"delete_system_info",
];
type MemoryToolSpec = {
name: MemoryToolName;
scope: MemoryScope;
kind: "read" | "write" | "delete";
action?: MemoryAction;
description: string;
prompt: string;
};
const MEMORY_TOOL_SPECS: MemoryToolSpec[] = [
{
name: "read_user_info",
scope: "user",
kind: "read",
description: "Read persistent user memory from user.md.",
prompt: `Use when you need to inspect remembered user facts before editing or answering.`,
},
{
name: "read_system_info",
scope: "system",
kind: "read",
description: "Read persistent assistant memory from system.md.",
prompt: `Use when you need to inspect remembered assistant instructions before editing or answering.`,
},
{
name: "add_user_info",
scope: "user",
kind: "write",
action: "add",
description: "Append a durable fact about the user to user.md.",
prompt: `Use for new user facts, preferences, identity details, and profile information. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "add_system_info",
scope: "system",
kind: "write",
action: "add",
description: "Append a durable instruction about the assistant to system.md.",
prompt: `Use for new assistant identity, style, or behavior instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_user_info",
scope: "user",
kind: "write",
action: "remove",
description: "Remove a specific user fact or fragment from user.md.",
prompt: `Use when the user asks to forget something about themselves. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_system_info",
scope: "system",
kind: "write",
action: "remove",
description: "Remove a specific assistant instruction or fragment from system.md.",
prompt: `Use when the user asks to forget something about the assistant. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_user_info",
scope: "user",
kind: "write",
action: "replace",
description: "Replace user.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered user info, such as "forget everything about me and remember only this". Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_system_info",
scope: "system",
kind: "write",
action: "replace",
description: "Replace system.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered assistant info or instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "delete_user_info",
scope: "user",
kind: "delete",
description: "Delete the user memory file user.md.",
prompt: `Use when the user asks to delete all remembered user info and remove the memory file entirely.`,
},
{
name: "delete_system_info",
scope: "system",
kind: "delete",
description: "Delete the assistant memory file system.md.",
prompt: `Use when the user asks to delete all remembered assistant info and remove the memory file entirely.`,
},
];
export const memoryToolPrompt = [
"Use the memory tools to manage persistent per-user memory.",
"- `read_*` shows the current file content before editing.",
"- `user.md` stores durable facts about the user.",
"- `system.md` stores durable facts/instructions about the assistant itself.",
"- `add_*` appends a new fact or instruction.",
"- `remove_*` removes a specific fact or fragment.",
"- `replace_*` rewrites the whole file when the user wants to overwrite memory.",
"- `delete_*` removes the file entirely.",
`- Keep each file at or below ${USER_MEMORY_MAX_CHARS} characters.`,
].join("\n");
function createMemoryTool(spec: MemoryToolSpec): AiTool {
return {
type: "function",
function: {
name: spec.name,
description: spec.description,
parameters: {
type: "object",
properties: spec.kind === "read" || spec.kind === "delete" ? {} : {
content: {
type: "string",
description: spec.action === "remove"
? "Exact text or fragment to remove from memory."
: "Text to append or replace in memory.",
},
},
required: spec.kind === "read" || spec.kind === "delete" ? [] : ["content"],
},
},
} satisfies AiTool;
}
export const memoryTools = MEMORY_TOOL_SPECS.map(createMemoryTool);
function normalizeUserId(userId: number): number | null {
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
}
function normalizeMemoryText(value: string): string {
return value.replaceAll("\r\n", "\n");
}
function getMemoryUserDir(userId: number): string {
return path.join(memoryDir(), String(userId));
}
export function getMemoryFilePath(userId: number, scope: MemoryScope): string {
return path.join(getMemoryUserDir(userId), `${scope}.md`);
}
async function ensureMemoryDir(userId: number): Promise<string> {
const dir = getMemoryUserDir(userId);
await mkdir(dir, {recursive: true});
return dir;
}
async function readMemoryFile(userId: number, scope: MemoryScope): Promise<string> {
const filePath = getMemoryFilePath(userId, scope);
try {
return normalizeMemoryText(await readFile(filePath, "utf-8"));
} catch (error) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
}
}
async function writeMemoryFile(userId: number, scope: MemoryScope, content: string): Promise<string> {
const normalized = normalizeMemoryText(content);
const filePath = getMemoryFilePath(userId, scope);
await ensureMemoryDir(userId);
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, normalized, "utf-8");
await rename(tempPath, filePath);
return filePath;
}
function trimToLimit(content: string, limit = USER_MEMORY_MAX_CHARS): string {
if (content.length <= limit) return content;
return content.slice(0, limit).trimEnd();
}
function stripCodeFences(content: string): string {
const trimmed = content.trim();
const fenced = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) return fenced[1].trim();
return trimmed;
}
function sameTarget(left: AiRuntimeTarget | undefined, right: AiRuntimeTarget | undefined): boolean {
if (!left || !right) return false;
return left.provider === right.provider
&& left.model === right.model
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
async function compressWithTarget(params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}): Promise<CompressionRunResult> {
const {target, scope, currentText, limit} = params;
const systemPrompt = [
"You compress persistent memory for a chat bot.",
"Return only the rewritten Markdown text.",
"Preserve important facts, preferences, identities, instructions, and durable context.",
"Remove noise, duplication, stale details, and low-value filler.",
`Keep the result at or below ${limit} characters.`,
"Do not add explanations, bullet labels, or code fences.",
].join("\n");
const userPrompt = [
`Memory scope: ${scope}`,
`Character limit: ${limit}`,
"Current memory:",
currentText.trim() || "(empty)",
"",
"Rewrite it as compact Markdown only.",
].join("\n");
logger.info("compress.start", {provider: target.provider, model: target.model, scope, chars: currentText.length});
switch (target.provider) {
case AiProvider.OPENAI: {
const client = createOpenAiClient(target);
const response = await client.chat.completions.create({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = response.choices[0]?.message?.content ?? "";
return {content: stripCodeFences(text)};
}
case AiProvider.MISTRAL: {
const client = createMistralClient(target);
const response = await client.chat.complete({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
} as Parameters<typeof client.chat.complete>[0]);
const text = extractMistralText(response.choices?.[0]?.message?.content);
return {content: stripCodeFences(text)};
}
case AiProvider.OLLAMA: {
const client = createOllamaClient(target);
const response = await client.chat({
model: target.model,
stream: false,
options: {temperature: 0},
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = typeof response.message?.content === "string" ? response.message.content : "";
return {content: stripCodeFences(text)};
}
}
}
export async function compressMemoryWithFallback(params: {
provider?: AiProvider;
currentTarget?: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit?: number;
}, runner: MemoryCompressionRunner = async (input) => (await compressWithTarget(input)).content): Promise<{content: string; compressed: boolean; usedTarget?: AiRuntimeTarget}> {
const limit = params.limit ?? USER_MEMORY_MAX_CHARS;
const trimmed = normalizeMemoryText(params.currentText);
if (trimmed.length <= limit) {
return {content: trimmed, compressed: false};
}
const explicitTarget = params.provider ? resolveOptionalAiRuntimeTarget(params.provider, "memoryCompress") : undefined;
const targets = [explicitTarget, params.currentTarget].filter((target, index, list): target is AiRuntimeTarget => !!target && list.findIndex(item => sameTarget(item, target)) === index);
for (const target of targets) {
try {
const content = trimToLimit(await runner({target, scope: params.scope, currentText: trimmed, limit}), limit);
if (content.length <= limit) {
return {content, compressed: true, usedTarget: target};
}
} catch (error) {
logger.warn("compress.failed", {
provider: params.provider,
scope: params.scope,
target: target.model,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {content: trimToLimit(trimmed, limit), compressed: true};
}
async function compressMemoryIfNeeded(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
limit?: number;
}): Promise<{content: string; compressed: boolean}> {
const {scope, context, limit = USER_MEMORY_MAX_CHARS} = params;
const result = await compressMemoryWithFallback({
provider: context?.provider,
currentTarget: context?.runtimeTarget,
scope,
currentText: params.content,
limit,
});
if (!result.compressed) {
return result;
}
if (result.content.length > limit) {
return {content: trimToLimit(result.content, limit), compressed: true};
}
return {content: result.content, compressed: true};
}
async function finalizeMemoryWrite(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
}): Promise<{filePath: string; content: string; compressed: boolean}> {
const {userId, scope, context} = params;
const compressed = await compressMemoryIfNeeded({userId, scope, content: params.content, context});
const filePath = await writeMemoryFile(userId, scope, compressed.content);
return {filePath, content: compressed.content, compressed: compressed.compressed};
}
function findMemoryToolSpec(toolName: string): MemoryToolSpec | undefined {
return MEMORY_TOOL_SPECS.find(spec => spec.name === toolName);
}
function isMemoryWriteTool(spec: MemoryToolSpec): spec is MemoryToolSpec & {kind: "write"; action: MemoryAction} {
return spec.kind === "write";
}
export async function buildUserMemoryPrompt(userId: number | undefined | null): Promise<string | undefined> {
const normalizedUserId = typeof userId === "number" ? normalizeUserId(userId) : null;
if (!normalizedUserId) return undefined;
const [userMemoryResult, systemMemoryResult] = await Promise.all([
readUserMemory(normalizedUserId, "user"),
readUserMemory(normalizedUserId, "system"),
]);
const userMemory = userMemoryResult.success ? userMemoryResult.content : "";
const systemMemory = systemMemoryResult.success ? systemMemoryResult.content : "";
const blocks: string[] = [];
if (systemMemory.trim()) {
blocks.push([
"## Assistant memory (system.md)",
"This is information about the assistant and its behavior.",
systemMemory.trim(),
].join("\n"));
}
if (userMemory.trim()) {
blocks.push([
"## User memory (user.md)",
"This is information about the user.",
userMemory.trim(),
].join("\n"));
}
return blocks.length ? blocks.join("\n\n") : undefined;
}
export async function readUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
try {
const content = await readMemoryFile(normalizedUserId, scope);
return {
success: true,
scope,
filePath: getMemoryFilePath(normalizedUserId, scope),
content,
chars: content.length,
compressed: false,
};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function updateUserMemory(args: {
userId: number;
scope: MemoryScope;
action: MemoryAction;
content?: string;
context?: MemoryRuntimeContext;
}): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(args.userId);
if (!normalizedUserId) {
return {success: false, scope: args.scope, error: "Invalid userId"};
}
try {
const current = await readMemoryFile(normalizedUserId, args.scope);
let next = current;
switch (args.action) {
case "add": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!content.trim()) {
return {success: false, scope: args.scope, error: "No content provided"};
}
next = [current.trimEnd(), content.trim()].filter(Boolean).join(current.trim() ? "\n\n" : "");
break;
}
case "replace": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
next = content;
break;
}
case "remove": {
const needle = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!needle.trim()) {
return {success: false, scope: args.scope, error: "No text to remove provided"};
}
if (!current.includes(needle)) {
return {success: false, scope: args.scope, error: "Text not found in memory"};
}
next = current.split(needle).join("").trim();
break;
}
}
const finalized = await finalizeMemoryWrite({userId: normalizedUserId, scope: args.scope, content: next, context: args.context});
logger.debug("write.done", {
userId: normalizedUserId,
scope: args.scope,
chars: finalized.content.length,
compressed: finalized.compressed,
filePath: finalized.filePath,
});
return {
success: true,
scope: args.scope,
filePath: finalized.filePath,
content: finalized.content,
chars: finalized.content.length,
compressed: finalized.compressed,
};
} catch (error) {
return {success: false, scope: args.scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function executeMemoryTool(toolName: MemoryToolName, args: {userId: number; content?: string}, context?: MemoryRuntimeContext): Promise<MemoryOperationResult> {
const spec = findMemoryToolSpec(toolName);
if (!spec) {
return {success: false, scope: "user", error: `Unknown memory tool: ${toolName}`};
}
if (spec.kind === "read") {
return readUserMemory(args.userId, spec.scope);
}
if (spec.kind === "delete") {
return deleteUserMemory(args.userId, spec.scope);
}
if (!isMemoryWriteTool(spec)) {
return {success: false, scope: spec.scope, error: `Unsupported memory tool: ${toolName}`};
}
return updateUserMemory({
userId: args.userId,
scope: spec.scope,
action: spec.action,
content: args.content,
context,
});
}
export async function deleteUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
const filePath = getMemoryFilePath(normalizedUserId, scope);
try {
await rm(filePath, {force: true});
return {success: true, scope, filePath, content: "", chars: 0, compressed: false};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {Ollama} from "ollama"; import {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";
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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;
+16
View File
@@ -19,6 +19,7 @@ import {
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {runToolRankStage} from "./tool-rank-stage"; 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 {summarizeModelOutput} from "./response-model-output";
@@ -80,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[],
+13 -3
View File
@@ -7,6 +7,8 @@ import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../loggi
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters"; import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage"; import {runToolRankStage} from "./tool-rank-stage";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import { import {
MAX_TOOL_ROUNDS, MAX_TOOL_ROUNDS,
@@ -66,7 +68,7 @@ export async function runMistral(
streamMessage, streamMessage,
signal, signal,
}); });
const filteredTools = rankResult.filteredTools; const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
const requestTools = filteredTools.length ? filteredTools : undefined; const requestTools = filteredTools.length ? filteredTools : undefined;
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? ""); streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
@@ -113,7 +115,11 @@ export async function runMistral(
userId: msg.from?.id, userId: msg.from?.id,
toolCalls: calls, toolCalls: calls,
streamMessage, streamMessage,
toolContext, toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
toolMemory, toolMemory,
adapter, adapter,
appendTargets: [messages, requestMessages], appendTargets: [messages, requestMessages],
@@ -183,7 +189,11 @@ export async function runMistral(
userId: msg.from?.id, userId: msg.from?.id,
toolCalls: calls, toolCalls: calls,
streamMessage, streamMessage,
toolContext, toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
toolMemory, toolMemory,
adapter, adapter,
appendTargets: [messages, requestMessages], appendTargets: [messages, requestMessages],
+13 -3
View File
@@ -15,6 +15,8 @@ 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 {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage"; 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,
@@ -203,7 +205,7 @@ export async function runOllama(
signal, signal,
}); });
const filteredTools = [...new Set(rankResult.filteredTools as Tool[])]; 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];
@@ -297,7 +299,11 @@ export async function runOllama(
userId: msg.from?.id, userId: msg.from?.id,
toolCalls: calls, toolCalls: calls,
streamMessage, streamMessage,
toolContext, toolContext: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
toolMemory, toolMemory,
adapter, adapter,
appendTargets: [messages], appendTargets: [messages],
@@ -429,7 +435,11 @@ export async function runOllama(
userId: msg.from?.id, userId: msg.from?.id,
toolCalls: calls, toolCalls: calls,
streamMessage, streamMessage,
toolContext, toolContext: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
toolMemory, toolMemory,
adapter, adapter,
appendTargets: [messages], appendTargets: [messages],
@@ -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);
}
}
+21 -75
View File
@@ -12,6 +12,7 @@ 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,
@@ -29,23 +30,21 @@ import {
showOpenAiGeneratedImage, showOpenAiGeneratedImage,
ToolCallData, ToolCallData,
ToolExecutionMemory, ToolExecutionMemory,
errorMessage,
allToolSchemaNames allToolSchemaNames
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner"; import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control"; import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner"; import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage"; import {runSingleModelRequest} from "./model-call-stage";
import {bot} from "../index"; import {ensureToolsSelected} from "./tool-mappers.js";
import fs from "node:fs"; import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import path from "node:path";
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 {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters"; import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage"; 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,
@@ -75,6 +74,7 @@ export async function runOpenAi(
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", {
@@ -115,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 = {
@@ -187,7 +191,11 @@ export async function runOpenAi(
userId: msg.from?.id, userId: msg.from?.id,
toolCalls, toolCalls,
streamMessage, streamMessage,
toolContext, toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory, toolMemory,
adapter, adapter,
appendTargets: [toolOutputs], appendTargets: [toolOutputs],
@@ -397,7 +405,11 @@ export async function runOpenAi(
userId: msg.from?.id, userId: msg.from?.id,
toolCalls, toolCalls,
streamMessage, streamMessage,
toolContext, toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory, toolMemory,
adapter, adapter,
appendTargets: [toolOutputs], appendTargets: [toolOutputs],
@@ -504,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 "";
+23 -3
View File
@@ -4,7 +4,7 @@ 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.js"; import {AiProvider} from "../model/ai-provider.js";
import {ToolRankerFallbackPolicy} from "../common/policies.js"; import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {Environment} from "../common/environment.js"; import {Environment, type OpenAiBackend} from "../common/environment.js";
import {delay, logError, replyToMessage} from "../util/utils.js"; import {delay, logError, replyToMessage} from "../util/utils.js";
import {MessageStore} from "../common/message-store.js"; import {MessageStore} from "../common/message-store.js";
import type {OpenAiResponseTool} from "./tool-mappers.js"; import type {OpenAiResponseTool} from "./tool-mappers.js";
@@ -274,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 {
@@ -307,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"}));
@@ -382,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");
} }
@@ -1117,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":
@@ -1162,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));
} }
+15 -8
View File
@@ -1,4 +1,4 @@
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.js"; import {BoundaryValue} from "../common/boundary-types.js";
import {ToolRankerFallbackPolicy} from "../common/policies.js"; import {ToolRankerFallbackPolicy} from "../common/policies.js";
@@ -107,7 +107,7 @@ 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(
@@ -142,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;
@@ -155,7 +155,7 @@ export class ToolRanker {
target: aiLogProviderTarget(target), target: aiLogProviderTarget(target),
fallbackPolicy, fallbackPolicy,
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
error: failureMessage, errorSummary: failureMessage,
}); });
return resolveToolRankerFallbackSelection({ return resolveToolRankerFallbackSelection({
@@ -227,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() ?? "";
} }
+23
View File
@@ -14,6 +14,13 @@ import type {ToolCallData} from "../ai/unified-ai-runner.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js"; import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js";
import {Localization, type LocalizationParams} from "./localization.js"; import {Localization, type LocalizationParams} from "./localization.js";
export const OpenAiBackendModes = {
OFFICIAL: "official",
COMPATIBLE: "compatible",
} as const;
export type OpenAiBackend = typeof OpenAiBackendModes[keyof typeof OpenAiBackendModes];
function parseBooleanLike(value: string): boolean { function parseBooleanLike(value: string): boolean {
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
return ["true", "t", "y", "1"].includes(normalized); return ["true", "t", "y", "1"].includes(normalized);
@@ -215,6 +222,8 @@ const RuntimeEnvSchema = z.object({
ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema, ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema,
DISABLE_LOCAL_TOOLS: optionalBooleanSchema, DISABLE_LOCAL_TOOLS: optionalBooleanSchema,
LOCAL_TOOL_ALLOWLIST: optionalStringSchema,
LOCAL_TOOL_DENYLIST: optionalStringSchema,
MCP_SERVERS: optionalStringSchema, MCP_SERVERS: optionalStringSchema,
OLLAMA_API_KEY: optionalStringSchema, OLLAMA_API_KEY: optionalStringSchema,
@@ -243,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"),
@@ -311,6 +324,8 @@ export class Environment {
static ENABLE_PYTHON_INTERPRETER: boolean = false; static ENABLE_PYTHON_INTERPRETER: boolean = false;
static DISABLE_LOCAL_TOOLS: boolean = false; static DISABLE_LOCAL_TOOLS: boolean = false;
static LOCAL_TOOL_ALLOWLIST?: string;
static LOCAL_TOOL_DENYLIST?: string;
static MCP_SERVERS?: string; static MCP_SERVERS?: string;
static OLLAMA_API_KEY?: string; static OLLAMA_API_KEY?: string;
@@ -339,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 = "";
@@ -1847,6 +1863,8 @@ export class Environment {
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.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.MCP_SERVERS = env.MCP_SERVERS;
Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY; Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY;
@@ -1875,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;
@@ -2075,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;
} }
+18 -1
View File
@@ -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));
+2 -1
View File
@@ -194,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");
@@ -262,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) {
+14
View File
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
const {Environment} = await import("../dist/common/environment.js");
test("openai backend defaults to official", () => {
assert.equal(Environment.OPENAI_BACKEND, "official");
});
test("openai backend setter updates runtime config", () => {
Environment.setOpenAIBackend("compatible");
assert.equal(Environment.OPENAI_BACKEND, "compatible");
Environment.setOpenAIBackend("official");
});
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import {OpenAI} from "openai";
const {extractOpenAiChatToolCalls} = await import("../dist/ai/provider-adapter-contract.js");
const baseURL = process.env.OPENAI_COMPATIBLE_TEST_BASE_URL;
const model = process.env.OPENAI_COMPATIBLE_TEST_MODEL;
const apiKey = process.env.OPENAI_COMPATIBLE_TEST_API_KEY ?? process.env.OPENAI_API_KEY ?? "test";
test("openai-compatible chat.completions tool loop works on a real server", {skip: !baseURL || !model}, async () => {
const client = new OpenAI({baseURL, apiKey});
const response = await client.chat.completions.create({
model,
temperature: 0,
messages: [
{role: "system", content: "You must call the ping tool exactly once. Do not answer in plain text."},
{role: "user", content: "ping"},
],
tools: [{
type: "function",
function: {
name: "ping",
description: "Return a ping token.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
}],
tool_choice: {
type: "function",
function: {name: "ping"},
},
});
const calls = extractOpenAiChatToolCalls(response);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, "ping");
});
+61
View File
@@ -5,6 +5,11 @@ const {
extractOpenAiToolCalls, extractOpenAiToolCalls,
extractOpenAiStreamingToolCalls, extractOpenAiStreamingToolCalls,
extractOpenAiTextDelta, extractOpenAiTextDelta,
extractOpenAiChatToolCalls,
extractOpenAiChatStreamingToolCalls,
extractOpenAiChatTextDelta,
mergeToolCallChunks,
normalizeStreamingTextDelta,
extractMistralToolCalls, extractMistralToolCalls,
extractMistralTextDelta, extractMistralTextDelta,
extractOllamaToolCalls, extractOllamaToolCalls,
@@ -42,6 +47,62 @@ test("openai contract extracts text delta and function calls", () => {
assert.equal(streamed[0].name, "search_files"); 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", () => { test("mistral contract extracts content and tool calls", () => {
assert.equal(extractMistralTextDelta({ assert.equal(extractMistralTextDelta({
content: [{text: "hello"}, {text: " world"}], content: [{text: "hello"}, {text: " world"}],
+13
View File
@@ -86,6 +86,19 @@ test("prompt includes search files routing example for usage search", () => {
assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]}))); assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]})));
}); });
test("prompt includes memory routing examples for remember requests", () => {
const prompt = promptFor("no_tool", "read_user_info", "add_user_info", "remove_user_info", "replace_user_info", "delete_user_info");
assert.ok(prompt.includes("что ты помнишь обо мне?"));
assert.ok(prompt.includes("запомни, что меня зовут Иван"));
assert.ok(prompt.includes("забудь, что я люблю кофе"));
assert.ok(prompt.includes("забудь всё обо мне и запиши только это"));
assert.ok(prompt.includes("удали всю память обо мне"));
assert.ok(prompt.includes("inspect remembered user info -> read_user_info"));
assert.ok(prompt.includes("remember a new user fact -> add_user_info"));
assert.ok(prompt.includes(JSON.stringify({toolNames: ["add_user_info"]})));
});
test("prompt includes edit file patch routing example for targeted edits", () => { test("prompt includes edit file patch routing example for targeted edits", () => {
const prompt = promptFor("no_tool", "edit_file_patch"); const prompt = promptFor("no_tool", "edit_file_patch");
+197
View File
@@ -0,0 +1,197 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const {Environment} = await import("../dist/common/environment.js");
const {
buildUserMemoryPrompt,
compressMemoryWithFallback,
deleteUserMemory,
getMemoryFilePath,
readUserMemory,
updateUserMemory,
} = await import("../dist/ai/tools/user-memory.js");
const {AiProvider} = await import("../dist/model/ai-provider.js");
function makeTempDataPath() {
return fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-memory-"));
}
function withEnv(vars, fn) {
const snapshot = new Map();
for (const [key, value] of Object.entries(vars)) {
snapshot.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
return Promise.resolve(fn()).finally(() => {
for (const [key, value] of snapshot.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
test("memory storage supports append replace and remove", async () => {
const oldDataPath = Environment.DATA_PATH;
Environment.DATA_PATH = makeTempDataPath();
try {
const userId = 475823381;
let result = await updateUserMemory({
userId,
scope: "user",
action: "replace",
content: "# Profile\nLikes tea",
});
assert.equal(result.success, true);
result = await updateUserMemory({
userId,
scope: "user",
action: "add",
content: "Prefers concise answers",
});
assert.equal(result.success, true);
assert.match(result.content, /Likes tea/);
assert.match(result.content, /Prefers concise answers/);
result = await updateUserMemory({
userId,
scope: "user",
action: "remove",
content: "Prefers concise answers",
});
assert.equal(result.success, true);
assert.doesNotMatch(result.content, /Prefers concise answers/);
const readback = await readUserMemory(userId, "user");
assert.equal(readback.success, true);
assert.equal(readback.filePath, getMemoryFilePath(userId, "user"));
assert.match(readback.content, /Likes tea/);
} finally {
Environment.DATA_PATH = oldDataPath;
}
});
test("memory delete removes the file", async () => {
const oldDataPath = Environment.DATA_PATH;
Environment.DATA_PATH = makeTempDataPath();
try {
const userId = 999;
await updateUserMemory({userId, scope: "user", action: "replace", content: "hello"});
const deleted = await deleteUserMemory(userId, "user");
assert.equal(deleted.success, true);
const readback = await readUserMemory(userId, "user");
assert.equal(readback.success, true);
assert.equal(readback.content, "");
} finally {
Environment.DATA_PATH = oldDataPath;
}
});
test("memory prompt combines system and user files", async () => {
const oldDataPath = Environment.DATA_PATH;
Environment.DATA_PATH = makeTempDataPath();
try {
const userId = 1234;
await updateUserMemory({
userId,
scope: "system",
action: "replace",
content: "Ты зовешься Евлампий.",
});
await updateUserMemory({
userId,
scope: "user",
action: "replace",
content: "Пользователь любит короткие ответы.",
});
const prompt = await buildUserMemoryPrompt(userId);
assert(prompt);
assert.equal(prompt?.includes("## Assistant memory (system.md)"), true);
assert.equal(prompt?.includes("This is information about the assistant and its behavior."), true);
assert.equal(prompt?.includes("## User memory (user.md)"), true);
assert.equal(prompt?.includes("This is information about the user."), true);
assert(prompt.indexOf("## Assistant memory (system.md)") < prompt.indexOf("## User memory (user.md)"));
} finally {
Environment.DATA_PATH = oldDataPath;
}
});
test("memory compression falls back to current target when explicit target fails", async () => {
await withEnv({
OLLAMA_MEMORY_COMPRESS_MODEL: "memory-compress-model",
OLLAMA_CHAT_MODEL: "chat-model",
}, async () => {
const calls = [];
const result = await compressMemoryWithFallback(
{
provider: AiProvider.OLLAMA,
currentTarget: {
provider: AiProvider.OLLAMA,
purpose: "chat",
model: "chat-model",
},
scope: "system",
currentText: "x".repeat(1200),
limit: 1000,
},
async ({target}) => {
calls.push(target.model);
if (target.model === "memory-compress-model") {
throw new Error("boom");
}
return "short summary";
},
);
assert.deepEqual(calls, ["memory-compress-model", "chat-model"]);
assert.equal(result.content, "short summary");
assert.equal(result.compressed, true);
});
});
test("memory compression uses current target when no separate target exists", async () => {
await withEnv({
OLLAMA_MEMORY_COMPRESS_MODEL: undefined,
OLLAMA_CHAT_MODEL: "chat-model",
}, async () => {
const calls = [];
const result = await compressMemoryWithFallback(
{
provider: AiProvider.OLLAMA,
currentTarget: {
provider: AiProvider.OLLAMA,
purpose: "chat",
model: "chat-model",
},
scope: "user",
currentText: "x".repeat(1200),
limit: 1000,
},
async ({target}) => {
calls.push(target.model);
return "summary";
},
);
assert.deepEqual(calls, ["chat-model"]);
assert.equal(result.content, "summary");
assert.equal(result.compressed, true);
});
});