Add OpenAI compatible chat backend

This commit is contained in:
2026-05-22 20:52:35 +03:00
parent 321d185592
commit 46a99605e6
41 changed files with 2244 additions and 151 deletions
+21 -1
View File
@@ -6,7 +6,7 @@ import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
import {AiProvider} from "../model/ai-provider.js";
export type AiCapabilityName = keyof AiModelCapabilities;
export type AiRuntimePurpose = AiCapabilityName | "chat";
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
export type AiRuntimeTarget = {
provider: AiProvider;
@@ -24,6 +24,7 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
thinking: ["THINKING", "THINK"],
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
tools: ["TOOLS", "CHAT"],
memoryCompress: ["MEMORY_COMPRESS"],
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
audio: ["AUDIO"],
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
@@ -155,6 +156,25 @@ export function resolveAiRuntimeTarget(
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions};
}
function hasExplicitTargetConfig(provider: AiProvider, purpose: AiRuntimePurpose): boolean {
const prefix = providerPrefix(provider);
return [
...endpointEnvNames(provider, purpose),
...apiKeyEnvNames(provider, purpose),
...modelEnvNames(provider, purpose),
...systemPromptEnvNames(provider, purpose),
].some(name => !!env(name)) || !!env(`${prefix}_${PURPOSE_SUFFIXES[purpose][0]}_MODEL`);
}
export function resolveOptionalAiRuntimeTarget(
provider: AiProvider,
purpose: AiRuntimePurpose,
modelOverride?: string,
): AiRuntimeTarget | undefined {
if (!hasExplicitTargetConfig(provider, purpose)) return undefined;
return resolveAiRuntimeTarget(provider, purpose, modelOverride);
}
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
return left.provider === right.provider
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
+5 -1
View File
@@ -16,6 +16,7 @@ import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./uni
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {OllamaChatMessage} from "./ollama-chat-message";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
export type ConversationAttachment = {
kind: AttachmentKind;
@@ -267,11 +268,13 @@ function buildSystemInstruction(
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string {
return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n");
}
@@ -310,11 +313,12 @@ export async function buildConversationSnapshot(
if (turn.bot) return sum;
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
}, 0);
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
return {
turns,
imageCount,
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions),
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
};
}
+4
View File
@@ -42,6 +42,10 @@ export async function prepareDocumentRag(
const documents = downloads.filter(download => download.kind === "document");
if (!documents.length) return undefined;
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
return undefined;
}
switch (provider) {
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(config.openAiChatTarget);
+66
View File
@@ -0,0 +1,66 @@
import {isRecord} from "./unified-ai-runner.shared.js";
import type {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message.js";
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export function responseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
export function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAICompatibleChatMessage[] {
return messages.map((message): OpenAICompatibleChatMessage => {
if (message.role === "system") {
return {role: "system", content: responseContentToText(message.content)};
}
if (message.role === "assistant") {
const text = responseContentToText(message.content);
return text.length
? {role: "assistant", content: text}
: {role: "assistant", content: null};
}
const content = Array.isArray(message.content)
? (() => {
const parts = message.content.map((part): {type: "text"; text: string} | {type: "image_url"; image_url: {url: string}} => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
});
return parts.every(part => part.type === "text")
? parts.map(part => part.text).join("")
: parts;
})()
: message.content;
return {role: "user", content};
});
}
export function buildAssistantToolMessage(calls: ToolCallData[], text: string): OpenAICompatibleChatMessage {
return {
role: "assistant",
content: text,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
};
}
+3
View File
@@ -2,6 +2,7 @@ import type {
ResponseInputMessageContentList,
ResponseOutputMessage,
} from "openai/resources/responses/responses";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
type OpenAIInputChatMessage = {
type: "message";
@@ -17,3 +18,5 @@ type OpenAIOutputChatMessage = {
} & Pick<ResponseOutputMessage, "id" | "status">;
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
export type OpenAICompatibleChatMessage = ChatCompletionMessageParam;
+74
View File
@@ -0,0 +1,74 @@
import {Message} from "typescript-telegram-bot-api";
import fs from "node:fs";
import path from "node:path";
import {bot} from "../index.js";
import {Environment} from "../common/environment.js";
import {logError} from "../util/utils.js";
import {errorMessage} from "./unified-ai-runner.shared.js";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files.js";
export async function tryToUploadFiles(
msg: Message,
toolResults: string[]
): Promise<
| { found: false }
| { found: true, uploaded: true }
| { found: boolean, uploaded: false, error: string, toolIndex: number }
> {
let sendFileAttachment: {
result: SendFileAttachmentResult & { success: true },
toolIndex: number
} | null = null;
let found = false;
try {
for (const [index, toolResult] of toolResults.entries()) {
const raw = JSON.parse(toolResult);
const res = SendFileAttachmentResultSchema.safeParse(raw);
if (res.success) {
found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: index};
}
}
}
if (!found) {
return {found: false};
}
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
const attachmentPath = attachmentRoot
? path.join(
attachmentRoot,
String(msg.from?.id),
sendFileAttachment?.result?.attachment?.relativePath ?? "",
)
: "";
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(attachmentPath),
});
return {found: true, uploaded: true};
} catch (e) {
logError(e instanceof Error ? e : String(e));
return {
found: found,
uploaded: false,
error: errorMessage(e instanceof Error ? e : String(e)),
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
}
}
+86
View File
@@ -14,6 +14,12 @@ function normalizeToolArguments(value: unknown): string {
return JSON.stringify(value ?? {});
}
function normalizeToolArgumentsChunk(value: unknown): string {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
return JSON.stringify(value);
}
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
@@ -32,6 +38,86 @@ export function extractOpenAiTextDelta(input: unknown): string {
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
}
export function extractOpenAiChatTextDelta(input: unknown): string {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const content = delta && typeof delta.content === "string" ? delta.content : "";
return content;
}
export function normalizeStreamingTextDelta(existingText: string, deltaText: string): string {
if (!deltaText) return "";
if (!existingText) return deltaText;
if (deltaText.startsWith(existingText)) {
return deltaText.slice(existingText.length);
}
return deltaText;
}
export function extractOpenAiChatToolCalls(response: unknown): ToolCallData[] {
const record = isRecord(response) ? response : undefined;
const choice = record && Array.isArray(record.choices) && isRecord(record.choices[0]) ? record.choices[0] : undefined;
const message = isRecord(choice?.message) ? choice.message : undefined;
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
return toolCalls
.filter((item, index) => isRecord(item) && ((typeof item.id === "string") || typeof item.index === "number" || index >= 0))
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOpenAiChatStreamingToolCalls(input: unknown): ToolCallData[] {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
return toolCalls
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArgumentsChunk(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.id.length > 0);
}
export function mergeToolCallChunks(existing: ToolCallData[], chunks: ToolCallData[]): ToolCallData[] {
const merged = new Map<string, ToolCallData>(existing.map(call => [call.id, {...call}]));
for (const chunk of chunks) {
const current = merged.get(chunk.id);
if (!current) {
merged.set(chunk.id, {...chunk});
continue;
}
merged.set(chunk.id, {
id: current.id,
name: current.name || chunk.name,
argumentsText: current.argumentsText + (chunk.argumentsText ?? ""),
});
}
return [...merged.values()];
}
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
const event = input as ResponseStreamEvent | undefined;
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
+2 -1
View File
@@ -196,7 +196,8 @@ export async function getRuntimeCapabilities(
target?: AiRuntimeTarget
): Promise<AiModelCapabilities> {
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
const targetPurpose = target?.purpose && target.purpose !== "memoryCompress" ? target.purpose : "chat";
const result = await getModelCapabilities(provider, runtimeTarget.model, targetPurpose) ?? buildCapabilities({});
for (const capabilityName of CAPABILITY_NAMES) {
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
+23
View File
@@ -3,6 +3,7 @@ import {AiProvider} from "../model/ai-provider.js";
import {getTools} from "./tools/registry.js";
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
import {toolSchemaNames} from "./tool-schema-utils.js";
export type AiProviderName = "ollama" | "openai" | "mistral";
@@ -26,6 +27,11 @@ export function getOpenAITools(forCreator?: boolean): AiTool[] {
}));
}
export function getOpenAICompatibleTools(forCreator?: boolean): AiTool[] {
// The compatible chat.completions backend only accepts plain function tools.
return getOpenAITools(forCreator);
}
export type OpenAiResponseTool = {
type: "function";
name: string;
@@ -79,3 +85,20 @@ export function getProviderTools(provider: AiProvider, forCreator?: boolean): Ai
return getOpenAITools(forCreator);
}
}
export function ensureToolsSelected<T>(availableTools: readonly T[], selectedTools: readonly T[], toolNames: readonly string[]): T[] {
const selected = [...selectedTools];
const selectedNames = new Set(selected.flatMap(tool => toolSchemaNames(tool as never)));
for (const toolName of toolNames) {
if (selectedNames.has(toolName)) continue;
const extraTool = availableTools.find(tool => toolSchemaNames(tool as never).includes(toolName));
if (extraTool) {
selected.unshift(extraTool);
selectedNames.add(toolName);
}
}
return selected;
}
+104
View File
@@ -102,6 +102,100 @@ export const TOOL_RANKER_TOOL_INFOS = {
example("где определён BotService?", ["search_files"]),
],
),
read_user_info: tool(
"read_user_info",
"Read persistent user memory from user.md.",
"Use before editing or when the user asks what you remember about them.",
[
example("что ты помнишь обо мне?", ["read_user_info"]),
example("покажи мою память", ["read_user_info"]),
],
),
read_system_info: tool(
"read_system_info",
"Read persistent assistant memory from system.md.",
"Use before editing or when the user asks what instructions you remember about yourself.",
[
example("что ты помнишь о себе?", ["read_system_info"]),
example("покажи память о тебе", ["read_system_info"]),
],
),
add_user_info: tool(
"add_user_info",
"Append a durable fact about the user to persistent memory.",
"Use when the user asks to remember a new fact, preference, identity detail, or profile information about themselves.",
[
example("запомни, что меня зовут Иван", ["add_user_info"]),
example("запомни, что я люблю чай", ["add_user_info"]),
example("remember that I like short answers", ["add_user_info"]),
],
),
add_system_info: tool(
"add_system_info",
"Append a durable instruction about the assistant to persistent memory.",
"Use when the user asks to remember a new assistant identity, style, or behavior instruction.",
[
example("тебя зовут Евлампий", ["add_system_info"]),
example("ты ИИ помощник", ["add_system_info"]),
example("remember you are a concise assistant", ["add_system_info"]),
],
),
remove_user_info: tool(
"remove_user_info",
"Remove a specific user fact from persistent memory.",
"Use when the user asks to forget, delete, or remove a specific fact about themselves.",
[
example("забудь, что я люблю кофе", ["remove_user_info"]),
example("удали из памяти, что я живу в Москве", ["remove_user_info"]),
example("forget that I work at ACME", ["remove_user_info"]),
],
),
remove_system_info: tool(
"remove_system_info",
"Remove a specific assistant instruction from persistent memory.",
"Use when the user asks to forget or remove a specific instruction about the assistant.",
[
example("забудь, что тебя зовут Евлампий", ["remove_system_info"]),
example("убери правило отвечать коротко", ["remove_system_info"]),
example("forget that you are a concise assistant", ["remove_system_info"]),
],
),
replace_user_info: tool(
"replace_user_info",
"Replace the full user memory with a new compact version.",
"Use when the user wants to overwrite all remembered user info, for example when they say to forget everything and keep only the new fact.",
[
example("забудь всё обо мне и запиши только это: меня зовут Иван", ["replace_user_info"]),
example("замени всю память обо мне на: люблю чай и короткие ответы", ["replace_user_info"]),
],
),
replace_system_info: tool(
"replace_system_info",
"Replace the full assistant memory with a new compact version.",
"Use when the user wants to overwrite all remembered assistant info or instructions.",
[
example("забудь всё о себе и запиши только это: тебя зовут Евлампий", ["replace_system_info"]),
example("замени инструкцию о себе на: ты краткий ИИ помощник", ["replace_system_info"]),
],
),
delete_user_info: tool(
"delete_user_info",
"Delete user.md entirely.",
"Use when the user explicitly asks to delete all remembered user info, not just a fragment.",
[
example("удали всю память обо мне", ["delete_user_info"]),
example("forget all user memory", ["delete_user_info"]),
],
),
delete_system_info: tool(
"delete_system_info",
"Delete system.md entirely.",
"Use when the user explicitly asks to delete all remembered assistant info, not just a fragment.",
[
example("удали всю память о себе", ["delete_system_info"]),
example("forget all assistant memory", ["delete_system_info"]),
],
),
create_file: tool(
"create_file",
"Create a new small file.",
@@ -443,6 +537,16 @@ function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
pushIfAvailable("read_file", "known local file path -> read_file");
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
pushIfAvailable("read_user_info", "inspect remembered user info -> read_user_info");
pushIfAvailable("read_system_info", "inspect remembered assistant info -> read_system_info");
pushIfAvailable("add_user_info", "remember a new user fact -> add_user_info");
pushIfAvailable("add_system_info", "remember a new assistant instruction -> add_system_info");
pushIfAvailable("remove_user_info", "forget a user fact -> remove_user_info");
pushIfAvailable("remove_system_info", "forget an assistant instruction -> remove_system_info");
pushIfAvailable("replace_user_info", "overwrite all user memory -> replace_user_info");
pushIfAvailable("replace_system_info", "overwrite all assistant memory -> replace_system_info");
pushIfAvailable("delete_user_info", "delete all user memory -> delete_user_info");
pushIfAvailable("delete_system_info", "delete all assistant memory -> delete_system_info");
pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch");
pushIfAvailable("update_file", "full existing file replacement -> update_file");
pushIfAvailable("create_file", "small new file -> create_file");
+5 -5
View File
@@ -1,11 +1,11 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import path from "node:path";
import {readFile, writeFile} from "node:fs/promises";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
import {asNonEmptyString} from "./utils.js";
import fs from "node:fs";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("create-note");
+1 -1
View File
@@ -1,5 +1,5 @@
import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
import {asNonEmptyString} from "./utils.js";
import {AiJsonObject} from "../tool-types";
export const getCurrentDateTimeTool = {
+4 -4
View File
@@ -3,8 +3,8 @@ import fs from "node:fs";
import path from "node:path";
import {z} from "zod";
import {Environment} from "../../common/environment";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
import {Environment} from "../../common/environment.js";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
import {
MAX_COPY_ENTRIES,
MAX_COPY_TOTAL_BYTES,
@@ -23,8 +23,8 @@ import {
MAX_PATCH_SEARCH_BYTES,
MAX_STREAM_WRITE_IDLE_MS,
MAX_STREAM_WRITE_SESSIONS,
} from "./limits";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
} from "./limits.js";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils.js";
// =============================================================================
// Public types and schemas
+3 -3
View File
@@ -1,7 +1,7 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("market-rates");
+5 -5
View File
@@ -1,11 +1,11 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import path from "node:path";
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
import {notesDir, notesRootFile} from "../../index.js";
import {asNonEmptyString} from "./utils.js";
import {toolsLogger} from "./tool-logger.js";
import {z} from "zod";
import {AiJsonObject} from "../tool-types";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("notes");
+37 -11
View File
@@ -1,17 +1,17 @@
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather";
import {Environment} from "../../common/environment.js";
import {AiTool} from "../tool-types.js";
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
import {shellExecute, shellExecuteTool} from "./shell.js";
import {ToolHandler} from "./types.js";
import {getWeather, getWeatherTool} from "./weather.js";
import {
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
getFinancialMarketData,
getFinancialMarketDataToolPrompt,
getMarketRates
} from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
} from "./market-rates.js";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
import {
beginFileWrite,
beginFileWriteTool,
@@ -44,12 +44,14 @@ import {
updateFileTool,
writeFileChunk,
writeFileChunkTool
} from "./files";
} from "./files.js";
import {executeMemoryTool, memoryToolPrompt, memoryTools, type MemoryToolName} from "./user-memory.js";
import {getMcpToolHandlers, getMcpToolPrompts, getMcpTools} from "../mcp/mcp-registry.js";
export const defaultTools: AiTool[] = [
getCurrentDateTimeTool,
getFinancialMarketData,
...memoryTools,
];
export const fileTools = [
@@ -169,6 +171,20 @@ export const getToolHandlers = () => {
if (isLocalToolEnabled("get_datetime")) handlers.get_datetime = getCurrentDateTime;
if (isLocalToolEnabled("get_financial_market_data")) handlers.get_financial_market_data = getMarketRates;
for (const tool of memoryTools) {
if (!isLocalToolEnabled(tool.function.name)) continue;
handlers[tool.function.name] = async (args, context) => {
const userId = typeof args?.userId === "number" ? args.userId : undefined;
if (!userId) {
return {success: false, error: "Missing userId"};
}
return executeMemoryTool(tool.function.name as MemoryToolName, {
userId,
content: typeof args?.content === "string" ? args.content : undefined,
}, context);
};
}
if (isLocalToolEnabled("read_file")) handlers.read_file = readFile;
if (isLocalToolEnabled("list_directory")) handlers.list_directory = listDirectory;
@@ -186,7 +202,7 @@ export const getToolHandlers = () => {
if (isLocalToolEnabled("rename_path")) handlers.rename_path = renamePath;
if (isLocalToolEnabled("delete_path")) handlers.delete_path = deletePath;
if (isLocalToolEnabled("python_interpreter")) handlers.python_interpreter = runPythonInterpreter;
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;
@@ -200,6 +216,8 @@ export function getToolPrompts(toolNames: string[]): string[] {
}
const prompts: string[] = [];
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
let memoryPromptAdded = false;
for (const toolName of toolNames) {
if (!isLocalToolEnabled(toolName)) {
@@ -212,6 +230,14 @@ export function getToolPrompts(toolNames: string[]): string[] {
continue;
}
if (memoryToolNames.has(toolName)) {
if (!memoryPromptAdded) {
prompts.push(memoryToolPrompt);
memoryPromptAdded = true;
}
continue;
}
switch (toolName) {
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
prompts.push(getFinancialMarketDataToolPrompt);
+12 -7
View File
@@ -1,14 +1,19 @@
import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import {getToolHandlers} from "./registry.js";
import {normalizeToolArguments} from "./utils.js";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
import type {MemoryRuntimeContext} from "./user-memory.js";
import type {AiRuntimeTarget} from "../ai-runtime-target.js";
import type {AiProvider} from "../../model/ai-provider.js";
const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[];
};
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
} & MemoryRuntimeContext;
function stringifyToolResult(result: AiJsonValue): string {
if (typeof result === "string") return result;
@@ -48,7 +53,7 @@ export async function executeToolCall(
}
const arguments1 = normalizeToolArguments(args, userId);
const result = await handler(arguments1);
const result = await handler(arguments1, context);
const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
+5 -5
View File
@@ -1,10 +1,10 @@
import {AiTool} from "../tool-types";
import {AiTool} from "../tool-types.js";
import path from "node:path";
import {readdir, readFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import {notesDir, notesRootFile} from "../../index.js";
import {asNonEmptyString} from "./utils.js";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
const logger = toolsLogger.child("search-notes");
+2 -2
View File
@@ -1,6 +1,6 @@
import {AiTool} from "../tool-types";
import {runCommand} from "../../util/utils";
import {asNonEmptyString} from "./utils";
import {runCommand} from "../../util/utils.js";
import {asNonEmptyString} from "./utils.js";
import {AiJsonObject} from "../tool-types";
export const shellExecuteTool = {
+2 -1
View File
@@ -1,3 +1,4 @@
import {AiJsonObject, AiJsonValue} from "../tool-types";
import type {ToolRuntimeContext} from "./runtime.js";
export type ToolHandler = (args?: AiJsonObject) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
export type ToolHandler = (args?: AiJsonObject, context?: ToolRuntimeContext) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
+582
View File
@@ -0,0 +1,582 @@
import path from "node:path";
import {readFile, rename, writeFile, mkdir, rm} from "node:fs/promises";
import {AiProvider} from "../../model/ai-provider.js";
import {Environment} from "../../common/environment.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveOptionalAiRuntimeTarget, type AiRuntimeTarget} from "../ai-runtime-target.js";
import {AiTool} from "../tool-types.js";
import {toolsLogger} from "./tool-logger.js";
import {asNonEmptyString} from "./utils.js";
const logger = toolsLogger.child("user-memory");
function memoryDir(): string {
return path.join(Environment.DATA_PATH, "memory");
}
export const USER_MEMORY_MAX_CHARS = 1000;
export type MemoryScope = "user" | "system";
export type MemoryAction = "add" | "replace" | "remove";
export type MemoryRuntimeContext = {
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
};
export type MemoryOperationResult =
| {success: true; scope: MemoryScope; filePath: string; content: string; chars: number; compressed: boolean}
| {success: false; scope: MemoryScope; error: string};
type CompressionRunResult = {
content: string;
};
export type MemoryCompressionRunner = (params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}) => Promise<string>;
function extractMistralText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part && typeof (part as {text?: unknown}).text === "string") {
return (part as {text: string}).text;
}
return "";
})
.join("");
}
export type MemoryToolName =
| "read_user_info"
| "read_system_info"
| "add_user_info"
| "add_system_info"
| "remove_user_info"
| "remove_system_info"
| "replace_user_info"
| "replace_system_info"
| "delete_user_info"
| "delete_system_info";
export const MEMORY_TOOL_NAMES: MemoryToolName[] = [
"read_user_info",
"read_system_info",
"add_user_info",
"add_system_info",
"remove_user_info",
"remove_system_info",
"replace_user_info",
"replace_system_info",
"delete_user_info",
"delete_system_info",
];
type MemoryToolSpec = {
name: MemoryToolName;
scope: MemoryScope;
kind: "read" | "write" | "delete";
action?: MemoryAction;
description: string;
prompt: string;
};
const MEMORY_TOOL_SPECS: MemoryToolSpec[] = [
{
name: "read_user_info",
scope: "user",
kind: "read",
description: "Read persistent user memory from user.md.",
prompt: `Use when you need to inspect remembered user facts before editing or answering.`,
},
{
name: "read_system_info",
scope: "system",
kind: "read",
description: "Read persistent assistant memory from system.md.",
prompt: `Use when you need to inspect remembered assistant instructions before editing or answering.`,
},
{
name: "add_user_info",
scope: "user",
kind: "write",
action: "add",
description: "Append a durable fact about the user to user.md.",
prompt: `Use for new user facts, preferences, identity details, and profile information. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "add_system_info",
scope: "system",
kind: "write",
action: "add",
description: "Append a durable instruction about the assistant to system.md.",
prompt: `Use for new assistant identity, style, or behavior instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_user_info",
scope: "user",
kind: "write",
action: "remove",
description: "Remove a specific user fact or fragment from user.md.",
prompt: `Use when the user asks to forget something about themselves. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_system_info",
scope: "system",
kind: "write",
action: "remove",
description: "Remove a specific assistant instruction or fragment from system.md.",
prompt: `Use when the user asks to forget something about the assistant. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_user_info",
scope: "user",
kind: "write",
action: "replace",
description: "Replace user.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered user info, such as "forget everything about me and remember only this". Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_system_info",
scope: "system",
kind: "write",
action: "replace",
description: "Replace system.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered assistant info or instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "delete_user_info",
scope: "user",
kind: "delete",
description: "Delete the user memory file user.md.",
prompt: `Use when the user asks to delete all remembered user info and remove the memory file entirely.`,
},
{
name: "delete_system_info",
scope: "system",
kind: "delete",
description: "Delete the assistant memory file system.md.",
prompt: `Use when the user asks to delete all remembered assistant info and remove the memory file entirely.`,
},
];
export const memoryToolPrompt = [
"Use the memory tools to manage persistent per-user memory.",
"- `read_*` shows the current file content before editing.",
"- `user.md` stores durable facts about the user.",
"- `system.md` stores durable facts/instructions about the assistant itself.",
"- `add_*` appends a new fact or instruction.",
"- `remove_*` removes a specific fact or fragment.",
"- `replace_*` rewrites the whole file when the user wants to overwrite memory.",
"- `delete_*` removes the file entirely.",
`- Keep each file at or below ${USER_MEMORY_MAX_CHARS} characters.`,
].join("\n");
function createMemoryTool(spec: MemoryToolSpec): AiTool {
return {
type: "function",
function: {
name: spec.name,
description: spec.description,
parameters: {
type: "object",
properties: spec.kind === "read" || spec.kind === "delete" ? {} : {
content: {
type: "string",
description: spec.action === "remove"
? "Exact text or fragment to remove from memory."
: "Text to append or replace in memory.",
},
},
required: spec.kind === "read" || spec.kind === "delete" ? [] : ["content"],
},
},
} satisfies AiTool;
}
export const memoryTools = MEMORY_TOOL_SPECS.map(createMemoryTool);
function normalizeUserId(userId: number): number | null {
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
}
function normalizeMemoryText(value: string): string {
return value.replaceAll("\r\n", "\n");
}
function getMemoryUserDir(userId: number): string {
return path.join(memoryDir(), String(userId));
}
export function getMemoryFilePath(userId: number, scope: MemoryScope): string {
return path.join(getMemoryUserDir(userId), `${scope}.md`);
}
async function ensureMemoryDir(userId: number): Promise<string> {
const dir = getMemoryUserDir(userId);
await mkdir(dir, {recursive: true});
return dir;
}
async function readMemoryFile(userId: number, scope: MemoryScope): Promise<string> {
const filePath = getMemoryFilePath(userId, scope);
try {
return normalizeMemoryText(await readFile(filePath, "utf-8"));
} catch (error) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
}
}
async function writeMemoryFile(userId: number, scope: MemoryScope, content: string): Promise<string> {
const normalized = normalizeMemoryText(content);
const filePath = getMemoryFilePath(userId, scope);
await ensureMemoryDir(userId);
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, normalized, "utf-8");
await rename(tempPath, filePath);
return filePath;
}
function trimToLimit(content: string, limit = USER_MEMORY_MAX_CHARS): string {
if (content.length <= limit) return content;
return content.slice(0, limit).trimEnd();
}
function stripCodeFences(content: string): string {
const trimmed = content.trim();
const fenced = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) return fenced[1].trim();
return trimmed;
}
function sameTarget(left: AiRuntimeTarget | undefined, right: AiRuntimeTarget | undefined): boolean {
if (!left || !right) return false;
return left.provider === right.provider
&& left.model === right.model
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
async function compressWithTarget(params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}): Promise<CompressionRunResult> {
const {target, scope, currentText, limit} = params;
const systemPrompt = [
"You compress persistent memory for a chat bot.",
"Return only the rewritten Markdown text.",
"Preserve important facts, preferences, identities, instructions, and durable context.",
"Remove noise, duplication, stale details, and low-value filler.",
`Keep the result at or below ${limit} characters.`,
"Do not add explanations, bullet labels, or code fences.",
].join("\n");
const userPrompt = [
`Memory scope: ${scope}`,
`Character limit: ${limit}`,
"Current memory:",
currentText.trim() || "(empty)",
"",
"Rewrite it as compact Markdown only.",
].join("\n");
logger.info("compress.start", {provider: target.provider, model: target.model, scope, chars: currentText.length});
switch (target.provider) {
case AiProvider.OPENAI: {
const client = createOpenAiClient(target);
const response = await client.chat.completions.create({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = response.choices[0]?.message?.content ?? "";
return {content: stripCodeFences(text)};
}
case AiProvider.MISTRAL: {
const client = createMistralClient(target);
const response = await client.chat.complete({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
} as Parameters<typeof client.chat.complete>[0]);
const text = extractMistralText(response.choices?.[0]?.message?.content);
return {content: stripCodeFences(text)};
}
case AiProvider.OLLAMA: {
const client = createOllamaClient(target);
const response = await client.chat({
model: target.model,
stream: false,
options: {temperature: 0},
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = typeof response.message?.content === "string" ? response.message.content : "";
return {content: stripCodeFences(text)};
}
}
}
export async function compressMemoryWithFallback(params: {
provider?: AiProvider;
currentTarget?: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit?: number;
}, runner: MemoryCompressionRunner = async (input) => (await compressWithTarget(input)).content): Promise<{content: string; compressed: boolean; usedTarget?: AiRuntimeTarget}> {
const limit = params.limit ?? USER_MEMORY_MAX_CHARS;
const trimmed = normalizeMemoryText(params.currentText);
if (trimmed.length <= limit) {
return {content: trimmed, compressed: false};
}
const explicitTarget = params.provider ? resolveOptionalAiRuntimeTarget(params.provider, "memoryCompress") : undefined;
const targets = [explicitTarget, params.currentTarget].filter((target, index, list): target is AiRuntimeTarget => !!target && list.findIndex(item => sameTarget(item, target)) === index);
for (const target of targets) {
try {
const content = trimToLimit(await runner({target, scope: params.scope, currentText: trimmed, limit}), limit);
if (content.length <= limit) {
return {content, compressed: true, usedTarget: target};
}
} catch (error) {
logger.warn("compress.failed", {
provider: params.provider,
scope: params.scope,
target: target.model,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {content: trimToLimit(trimmed, limit), compressed: true};
}
async function compressMemoryIfNeeded(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
limit?: number;
}): Promise<{content: string; compressed: boolean}> {
const {scope, context, limit = USER_MEMORY_MAX_CHARS} = params;
const result = await compressMemoryWithFallback({
provider: context?.provider,
currentTarget: context?.runtimeTarget,
scope,
currentText: params.content,
limit,
});
if (!result.compressed) {
return result;
}
if (result.content.length > limit) {
return {content: trimToLimit(result.content, limit), compressed: true};
}
return {content: result.content, compressed: true};
}
async function finalizeMemoryWrite(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
}): Promise<{filePath: string; content: string; compressed: boolean}> {
const {userId, scope, context} = params;
const compressed = await compressMemoryIfNeeded({userId, scope, content: params.content, context});
const filePath = await writeMemoryFile(userId, scope, compressed.content);
return {filePath, content: compressed.content, compressed: compressed.compressed};
}
function findMemoryToolSpec(toolName: string): MemoryToolSpec | undefined {
return MEMORY_TOOL_SPECS.find(spec => spec.name === toolName);
}
function isMemoryWriteTool(spec: MemoryToolSpec): spec is MemoryToolSpec & {kind: "write"; action: MemoryAction} {
return spec.kind === "write";
}
export async function buildUserMemoryPrompt(userId: number | undefined | null): Promise<string | undefined> {
const normalizedUserId = typeof userId === "number" ? normalizeUserId(userId) : null;
if (!normalizedUserId) return undefined;
const [userMemoryResult, systemMemoryResult] = await Promise.all([
readUserMemory(normalizedUserId, "user"),
readUserMemory(normalizedUserId, "system"),
]);
const userMemory = userMemoryResult.success ? userMemoryResult.content : "";
const systemMemory = systemMemoryResult.success ? systemMemoryResult.content : "";
const blocks: string[] = [];
if (systemMemory.trim()) {
blocks.push([
"## Assistant memory (system.md)",
"This is information about the assistant and its behavior.",
systemMemory.trim(),
].join("\n"));
}
if (userMemory.trim()) {
blocks.push([
"## User memory (user.md)",
"This is information about the user.",
userMemory.trim(),
].join("\n"));
}
return blocks.length ? blocks.join("\n\n") : undefined;
}
export async function readUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
try {
const content = await readMemoryFile(normalizedUserId, scope);
return {
success: true,
scope,
filePath: getMemoryFilePath(normalizedUserId, scope),
content,
chars: content.length,
compressed: false,
};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function updateUserMemory(args: {
userId: number;
scope: MemoryScope;
action: MemoryAction;
content?: string;
context?: MemoryRuntimeContext;
}): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(args.userId);
if (!normalizedUserId) {
return {success: false, scope: args.scope, error: "Invalid userId"};
}
try {
const current = await readMemoryFile(normalizedUserId, args.scope);
let next = current;
switch (args.action) {
case "add": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!content.trim()) {
return {success: false, scope: args.scope, error: "No content provided"};
}
next = [current.trimEnd(), content.trim()].filter(Boolean).join(current.trim() ? "\n\n" : "");
break;
}
case "replace": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
next = content;
break;
}
case "remove": {
const needle = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!needle.trim()) {
return {success: false, scope: args.scope, error: "No text to remove provided"};
}
if (!current.includes(needle)) {
return {success: false, scope: args.scope, error: "Text not found in memory"};
}
next = current.split(needle).join("").trim();
break;
}
}
const finalized = await finalizeMemoryWrite({userId: normalizedUserId, scope: args.scope, content: next, context: args.context});
logger.debug("write.done", {
userId: normalizedUserId,
scope: args.scope,
chars: finalized.content.length,
compressed: finalized.compressed,
filePath: finalized.filePath,
});
return {
success: true,
scope: args.scope,
filePath: finalized.filePath,
content: finalized.content,
chars: finalized.content.length,
compressed: finalized.compressed,
};
} catch (error) {
return {success: false, scope: args.scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function executeMemoryTool(toolName: MemoryToolName, args: {userId: number; content?: string}, context?: MemoryRuntimeContext): Promise<MemoryOperationResult> {
const spec = findMemoryToolSpec(toolName);
if (!spec) {
return {success: false, scope: "user", error: `Unknown memory tool: ${toolName}`};
}
if (spec.kind === "read") {
return readUserMemory(args.userId, spec.scope);
}
if (spec.kind === "delete") {
return deleteUserMemory(args.userId, spec.scope);
}
if (!isMemoryWriteTool(spec)) {
return {success: false, scope: spec.scope, error: `Unsupported memory tool: ${toolName}`};
}
return updateUserMemory({
userId: args.userId,
scope: spec.scope,
action: spec.action,
content: args.content,
context,
});
}
export async function deleteUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
const filePath = getMemoryFilePath(normalizedUserId, scope);
try {
await rm(filePath, {force: true});
return {success: true, scope, filePath, content: "", chars: 0, compressed: false};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {Ollama} from "ollama";
import {toolsLogger} from "./tool-logger";
import {toolsLogger} from "./tool-logger.js";
import {AiJsonObject, AiJsonValue} from "../tool-types";
import type {BoundaryValue} from "../../common/boundary-types";
+5 -5
View File
@@ -1,11 +1,11 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {toolsLogger} from "./tool-logger.js";
const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiJsonObject, AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils";
import {Environment} from "../../common/environment.js";
import {logError} from "../../util/utils.js";
import {AiJsonObject, AiTool} from "../tool-types.js";
import {asNonEmptyString} from "./utils.js";
export const getWeatherTool = {
type: "function",
+5 -5
View File
@@ -1,11 +1,11 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
import {toolsLogger} from "./tool-logger.js";
const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
import {asBoolean, asNonEmptyString} from "./utils";
import {Environment} from "../../common/environment.js";
import {logError} from "../../util/utils.js";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
import {asBoolean, asNonEmptyString} from "./utils.js";
type BraveSearchProfile = {
name?: string;
+16
View File
@@ -19,6 +19,7 @@ import {
} from "./unified-ai-runner.shared";
import {runToolRankStage} from "./tool-rank-stage";
import {runOpenAi} from "./unified-ai-runner.openai";
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral";
import {summarizeModelOutput} from "./response-model-output";
@@ -80,6 +81,21 @@ async function runProviderModelCall(params: {
switch (options.provider) {
case AiProvider.OPENAI:
if (config.openAiBackend === "compatible") {
await runOpenAiCompatible(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
streamMessage,
signal,
options.stream ?? true,
options.msg,
config,
prepared.toolContext,
downloads,
);
return;
}
await runOpenAi(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
+13 -3
View File
@@ -7,6 +7,8 @@ import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../loggi
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {
MAX_TOOL_ROUNDS,
@@ -66,7 +68,7 @@ export async function runMistral(
streamMessage,
signal,
});
const filteredTools = rankResult.filteredTools;
const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
const requestTools = filteredTools.length ? filteredTools : undefined;
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
@@ -113,7 +115,11 @@ export async function runMistral(
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
toolMemory,
adapter,
appendTargets: [messages, requestMessages],
@@ -183,7 +189,11 @@ export async function runMistral(
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
toolMemory,
adapter,
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 {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {
allToolSchemaNames,
@@ -203,7 +205,7 @@ export async function runOllama(
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 ?? "");
if (filteredTools.length > 0) {
request.tools = [...filteredTools];
@@ -297,7 +299,11 @@ export async function runOllama(
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolContext: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
toolMemory,
adapter,
appendTargets: [messages],
@@ -429,7 +435,11 @@ export async function runOllama(
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext,
toolContext: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
toolMemory,
adapter,
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";
import {createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
import {
AsyncIterableStream,
@@ -29,23 +30,21 @@ import {
showOpenAiGeneratedImage,
ToolCallData,
ToolExecutionMemory,
errorMessage,
allToolSchemaNames
} from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {bot} from "../index";
import fs from "node:fs";
import path from "node:path";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {logError} from "../util/utils";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {tryToUploadFiles} from "./openai-upload-files.js";
export async function runOpenAi(
msg: Message,
@@ -75,6 +74,7 @@ export async function runOpenAi(
DEFAULT_AI_RESPONSE_LANGUAGE,
false,
config.openAiChatTarget.systemPromptAdditions,
await buildUserMemoryPrompt(msg.from?.id),
);
aiLog("info", "openai.run.start", {
@@ -115,9 +115,13 @@ export async function runOpenAi(
tools.unshift(fileSearchTool);
}
}
return tools.length ? tools : undefined;
const withMemory = ensureToolsSelected(availableTools, tools, MEMORY_TOOL_NAMES);
return withMemory.length ? withMemory : undefined;
})()
: (filteredTools.length ? filteredTools : undefined);
: (() => {
const withMemory = ensureToolsSelected(availableTools, filteredTools, MEMORY_TOOL_NAMES);
return withMemory.length ? withMemory : undefined;
})();
if (!stream) {
const request: ResponseCreateParamsNonStreaming = {
@@ -187,7 +191,11 @@ export async function runOpenAi(
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolOutputs],
@@ -397,7 +405,11 @@ export async function runOpenAi(
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
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 {
// if (typeof content === "string") return content;
// 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 {AiProvider} from "../model/ai-provider.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 {MessageStore} from "../common/message-store.js";
import type {OpenAiResponseTool} from "./tool-mappers.js";
@@ -274,6 +274,7 @@ export type RuntimeConfigSnapshot = {
openAiChatTarget: AiRuntimeTarget;
openAiImageTarget: AiRuntimeTarget;
openAiToolRankerTarget?: AiRuntimeTarget;
openAiBackend: OpenAiBackend;
};
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
@@ -307,9 +308,14 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"),
openAiBackend: Environment.OPENAI_BACKEND,
};
}
export function isOpenAiCompatibleBackend(config: RuntimeConfigSnapshot): boolean {
return config.openAiBackend === "compatible";
}
export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
if (part.imageParts?.length) return part.imageParts;
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
@@ -382,11 +388,13 @@ export function buildSystemInstruction(
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string {
return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n");
}
@@ -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 pathValue = typeof args.path === "string" ? args.path : undefined;
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined;
const memoryScope = toolCall.name.endsWith("_user_info") ? "user"
: toolCall.name.endsWith("_system_info") ? "system"
: undefined;
switch (toolCall.name) {
case "read_user_info":
case "read_system_info":
case "get_datetime":
case "web_search":
case "get_weather":
case "read_file":
case "list_directory":
return [];
case "add_user_info":
case "add_system_info":
case "remove_user_info":
case "remove_system_info":
case "replace_user_info":
case "replace_system_info":
return userId && memoryScope ? [`memory:${userId}:${memoryScope}`] : [];
case "create_file":
case "create_directory":
case "update_file":
@@ -1162,7 +1182,7 @@ export async function executeScheduledTool(
message: TelegramStreamMessage,
context: ToolRuntimeContext,
): Promise<string> {
const keys = toolResourceKeys(toolCall);
const keys = toolResourceKeys(toolCall, userId);
if (!keys.length) return executeTool(userId, toolCall, message, context);
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
}
+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 {BoundaryValue} from "../common/boundary-types.js";
import {ToolRankerFallbackPolicy} from "../common/policies.js";
@@ -107,7 +107,7 @@ export class ToolRanker {
target: aiLogProviderTarget(target),
fallbackTarget: aiLogProviderTarget(mainModelTarget),
duration: aiLogDuration(startedAt),
error: failureMessage,
errorSummary: failureMessage,
});
const fallbackRanker = buildToolRankerPrompt(
@@ -142,7 +142,7 @@ export class ToolRanker {
target: aiLogProviderTarget(target),
fallbackTarget: aiLogProviderTarget(mainModelTarget),
duration: aiLogDuration(startedAt),
error: fallbackErrorMessage,
errorSummary: fallbackErrorMessage,
});
failureMessage = fallbackErrorMessage;
@@ -155,7 +155,7 @@ export class ToolRanker {
target: aiLogProviderTarget(target),
fallbackPolicy,
duration: aiLogDuration(startedAt),
error: failureMessage,
errorSummary: failureMessage,
});
return resolveToolRankerFallbackSelection({
@@ -227,12 +227,19 @@ export class ToolRanker {
{role: "user", content: userQuery},
] satisfies ChatCompletionMessageParam[];
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
const response = await openAi.chat.completions.create({
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
// only for official OpenAI endpoints.
const request: ChatCompletionCreateParamsNonStreaming = {
model: target.model,
messages,
response_format: {type: "json_object"},
});
};
if (!target.baseUrl) {
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
request.response_format = {type: "json_object"};
}
const response = await openAi.chat.completions.create(request);
return response.choices[0]?.message?.content?.trim() ?? "";
}
+17
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 {Localization, type LocalizationParams} from "./localization.js";
export const OpenAiBackendModes = {
OFFICIAL: "official",
COMPATIBLE: "compatible",
} as const;
export type OpenAiBackend = typeof OpenAiBackendModes[keyof typeof OpenAiBackendModes];
function parseBooleanLike(value: string): boolean {
const normalized = value.trim().toLowerCase();
return ["true", "t", "y", "1"].includes(normalized);
@@ -245,6 +252,10 @@ const RuntimeEnvSchema = z.object({
OPENAI_BASE_URL: optionalStringSchema,
OPENAI_API_KEY: optionalStringSchema,
OPENAI_BACKEND: enumWithDefaultSchema(
OpenAiBackendModes,
OpenAiBackendModes.OFFICIAL,
),
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
@@ -343,6 +354,7 @@ export class Environment {
static OPENAI_BASE_URL?: string;
static OPENAI_API_KEY?: string;
static OPENAI_BACKEND: OpenAiBackend = OpenAiBackendModes.OFFICIAL;
static OPENAI_MODEL: string = "";
static OPENAI_IMAGE_MODEL: string = "";
static OPENAI_TRANSCRIPTION_MODEL: string = "";
@@ -1881,6 +1893,7 @@ export class Environment {
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
Environment.OPENAI_BACKEND = env.OPENAI_BACKEND;
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
@@ -2081,6 +2094,10 @@ export class Environment {
this.OPENAI_API_KEY = newAIApiKey;
}
static setOpenAIBackend(newBackend: OpenAiBackend): void {
this.OPENAI_BACKEND = newBackend;
}
static setOpenAIModel(newModel: string): void {
this.OPENAI_MODEL = newModel;
}
+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 notesDir = path.join(Environment.DATA_PATH, "notes");
export const notesRootFile = path.join(notesDir, "index.md");
export const memoryDir = path.join(Environment.DATA_PATH, "memory");
const logger = appLogger.child("main");
@@ -262,7 +263,7 @@ async function main() {
});
await measureStartupStep("environment.load", () => Environment.load());
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, filesDir];
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, memoryDir, filesDir];
await measureStartupStep("prepare_directories", () => {
const created: string[] = [];
for (const dir of dirsToCheck) {