This commit is contained in:
2026-05-15 01:25:51 +03:00
parent 2b1940bf4d
commit 9b367f29c5
18 changed files with 3027 additions and 1381 deletions
+1 -1
View File
@@ -374,7 +374,7 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
reply_parameters: {message_id: sourceMessage.message_id}, reply_parameters: {message_id: sourceMessage.message_id},
}); });
} finally { } finally {
destroyUpload(upload); // destroyUpload(upload);
} }
}, },
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type} {method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
+16 -16
View File
@@ -1,13 +1,13 @@
import {AiTool} from "./tool-types"; import {AiTool} from "./tool-types";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {getTools} from "./tools/registry"; import {getTools} from "./tools/registry";
import {WEB_SEARCH_TOOL_NAME} from "./tools/brave-search"; import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator"; import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral"; export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
export function getOllamaTools(): AiTool[] { export function getOllamaTools(forCreator?: boolean): AiTool[] {
return getTools(); return getTools(forCreator);
} }
const openAiForbiddenTools = [ const openAiForbiddenTools = [
@@ -19,8 +19,8 @@ function allowedOpenAiTool(tool: AiTool): boolean {
return !openAiForbiddenTools.includes(tool.function.name) return !openAiForbiddenTools.includes(tool.function.name)
} }
export function getOpenAITools(): AiTool[] { export function getOpenAITools(forCreator?: boolean): AiTool[] {
return getTools().filter(allowedOpenAiTool).map(tool => ({ return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
type: "function", type: "function",
function: tool.function, function: tool.function,
})); }));
@@ -43,8 +43,8 @@ export type OpenAiCodeInterpreterTool = {
} | string; } | string;
}; };
export function getOpenAIResponsesTools(): OpenAiResponseTool[] { export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] {
return getTools().filter(allowedOpenAiTool).map(tool => ({ return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
type: "function", type: "function",
name: tool.function.name, name: tool.function.name,
description: tool.function.description, description: tool.function.description,
@@ -62,8 +62,8 @@ export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool {
}; };
} }
export function getMistralTools(): AiTool[] { export function getMistralTools(forCreator?: boolean): AiTool[] {
return getTools().map(tool => ({ return getTools(forCreator).map(tool => ({
type: "function", type: "function",
function: tool.function, function: tool.function,
})); }));
@@ -77,8 +77,8 @@ export type GeminiTool = {
}>; }>;
} }
export function getGeminiTools(): GeminiTool[] { export function getGeminiTools(forCreator?: boolean): GeminiTool[] {
const functionDeclarations = getTools().map(tool => ({ const functionDeclarations = getTools(forCreator).map(tool => ({
name: tool.function.name, name: tool.function.name,
description: tool.function.description, description: tool.function.description,
parametersJsonSchema: tool.function.parameters, parametersJsonSchema: tool.function.parameters,
@@ -87,15 +87,15 @@ export function getGeminiTools(): GeminiTool[] {
return functionDeclarations.length ? [{functionDeclarations}] : []; return functionDeclarations.length ? [{functionDeclarations}] : [];
} }
export function getProviderTools(provider: AiProvider): AiTool[] { export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return getOllamaTools(); return getOllamaTools(forCreator);
case AiProvider.GEMINI: case AiProvider.GEMINI:
return getTools(); return getTools(forCreator);
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return getMistralTools(); return getMistralTools(forCreator);
case AiProvider.OPENAI: case AiProvider.OPENAI:
return getOpenAITools(); return getOpenAITools(forCreator);
} }
} }
-852
View File
@@ -1,852 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
export const readFileTool = {
type: "function",
function: {
name: "read_file",
description:
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory, for example notes/task.txt.",
},
maxBytes: {
type: "number",
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const listDirectoryTool = {
type: "function",
function: {
name: "list_directory",
description:
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory. Use . for root.",
},
},
required: [],
},
},
} satisfies AiTool;
export const createFileTool = {
type: "function",
function: {
name: "create_file",
description:
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "File content.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite the file if it already exists. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const createDirectoryTool = {
type: "function",
function: {
name: "create_directory",
description:
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const copyPathTool = {
type: "function",
function: {
name: "copy_path",
description:
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source file or directory path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Required for copying directories. Default is false.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create target parent directories automatically. Default is true.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const updateFileTool = {
type: "function",
function: {
name: "update_file",
description:
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "Content to write.",
},
mode: {
type: "string",
enum: ["replace", "append", "prepend"],
description: "Update mode. Default is replace.",
},
createIfMissing: {
type: "boolean",
description: "Whether to create the file if it does not exist. Default is false.",
},
},
required: ["path", "content"],
},
},
} satisfies AiTool;
export const renamePathTool = {
type: "function",
function: {
name: "rename_path",
description:
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target path inside the root directory.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create target parent directories automatically. Default is false.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const deletePathTool = {
type: "function",
function: {
name: "delete_path",
description:
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Whether to delete non-empty directories recursively. Default is false.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const fileToolsToolPrompt = [
"Filesystem tool rules:",
"- You have access to filesystem tools working only inside the hardcoded root directory.",
"- All filesystem paths must be relative to the root directory.",
"- You may go into child directories.",
"- You must never go up to parent directories.",
"- Do not use ../ paths.",
"- Do not use absolute paths.",
"- Do not try to access symlinks.",
"- Use read_file for reading files.",
"- Use list_directory for reading directories.",
"- Use create_file for creating files.",
"- Use create_directory for creating directories.",
"- Use update_file for replacing, appending or prepending file content.",
"- Use rename_path for renaming or moving files/directories inside the root.",
"- Use delete_path for deleting files/directories inside the root.",
""
].join("\n");
const requireFileToolsRootDir = () => <string>Environment.FILE_TOOLS_ROOT_DIR;
async function ensureFileToolsRootExists(): Promise<void> {
await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true});
const stat = await fs.promises.stat(requireFileToolsRootDir());
if (!stat.isDirectory()) {
throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`);
}
}
function resolveSafeToolPath(inputPath: unknown, fallback = "."): {
absolutePath: string;
relativePath: string;
} {
const rawPath = asNonEmptyString(inputPath) ?? fallback;
if (rawPath.includes("\0")) {
throw new Error("Path must not contain null bytes.");
}
if (
path.isAbsolute(rawPath) ||
path.win32.isAbsolute(rawPath) ||
path.posix.isAbsolute(rawPath)
) {
throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory.");
}
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath);
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
if (
relativePath.startsWith("..") ||
path.isAbsolute(relativePath)
) {
throw new Error("Path escapes the root directory. Going up is not allowed.");
}
return {
absolutePath,
relativePath: relativePath || ".",
};
}
function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void {
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
throw new Error("Cannot copy a directory into itself.");
}
}
async function assertNoSymlinkInPath(
absolutePath: string,
options?: {
allowMissingTail?: boolean;
}
): Promise<void> {
await ensureFileToolsRootExists();
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
if (!relativePath || relativePath === ".") {
return;
}
const parts = relativePath.split(path.sep).filter(Boolean);
let currentPath = requireFileToolsRootDir();
for (const part of parts) {
currentPath = path.join(currentPath, part);
try {
const stat = await fs.promises.lstat(currentPath);
if (stat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in file tool paths.");
}
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) {
return;
}
throw e;
}
}
}
async function pathExists(absolutePath: string): Promise<boolean> {
try {
await fs.promises.lstat(absolutePath);
return true;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
throw e;
}
}
function assertNotRoot(relativePath: string): void {
if (relativePath === ".") {
throw new Error("Operation on the root directory itself is not allowed.");
}
}
function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" {
if (stat.isSymbolicLink()) return "symlink";
if (stat.isFile()) return "file";
if (stat.isDirectory()) return "directory";
return "other";
}
export async function readFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
if (stat.size > maxBytes) {
throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`);
}
const buffer = await fs.promises.readFile(absolutePath);
if (buffer.includes(0)) {
throw new Error("Binary files are not supported.");
}
return {
ok: true,
path: relativePath,
sizeBytes: stat.size,
content: buffer.toString("utf8"),
};
}
export async function listDirectory(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, ".");
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${relativePath}`);
}
const dirEntries = await fs.promises.readdir(absolutePath, {
withFileTypes: true,
});
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
const entries = await Promise.all(limitedEntries.map(async entry => {
const entryAbsolutePath = path.join(absolutePath, entry.name);
const entryRelativePath = relativePath === "."
? entry.name
: path.join(relativePath, entry.name);
const entryStat = await fs.promises.lstat(entryAbsolutePath);
return {
name: entry.name,
path: entryRelativePath,
type: getEntryType(entryStat),
sizeBytes: entryStat.isFile() ? entryStat.size : null,
modifiedAt: entryStat.mtime.toISOString(),
};
}));
return {
ok: true,
path: relativePath,
entries,
totalEntries: dirEntries.length,
returnedEntries: entries.length,
truncated: dirEntries.length > entries.length,
};
}
export async function createFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
const parentPath = path.dirname(absolutePath);
if (createParents) {
await assertNoSymlinkInPath(parentPath, {allowMissingTail: true});
await fs.promises.mkdir(parentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(parentPath);
}
if (await pathExists(absolutePath)) {
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (stat.isDirectory()) {
throw new Error(`Path is a directory, not a file: ${relativePath}`);
}
if (!overwrite) {
throw new Error(`File already exists: ${relativePath}`);
}
}
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: overwrite ? "w" : "wx",
});
return {
ok: true,
path: relativePath,
sizeBytes: contentSizeBytes,
overwritten: overwrite,
};
}
type CopyPathStats = {
entries: number;
totalBytes: number;
};
async function copyPathRecursive(params: {
sourceAbsolutePath: string;
targetAbsolutePath: string;
overwrite: boolean;
stats: CopyPathStats;
}): Promise<void> {
const {
sourceAbsolutePath,
targetAbsolutePath,
overwrite,
stats,
} = params;
if (stats.entries >= MAX_COPY_ENTRIES) {
throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`);
}
stats.entries++;
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in copied paths.");
}
if (sourceStat.isFile()) {
stats.totalBytes += sourceStat.size;
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`);
}
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (targetStat.isDirectory()) {
throw new Error("Cannot overwrite a directory with a file.");
}
if (!overwrite) {
throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`);
}
}
await fs.promises.copyFile(
sourceAbsolutePath,
targetAbsolutePath,
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
);
return;
}
if (sourceStat.isDirectory()) {
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!targetStat.isDirectory()) {
throw new Error("Cannot overwrite a file with a directory.");
}
} else {
await fs.promises.mkdir(targetAbsolutePath);
}
const entries = await fs.promises.readdir(sourceAbsolutePath);
for (const entry of entries) {
const childSourcePath = path.join(sourceAbsolutePath, entry);
const childTargetPath = path.join(targetAbsolutePath, entry);
await copyPathRecursive({
sourceAbsolutePath: childSourcePath,
targetAbsolutePath: childTargetPath,
overwrite,
stats,
});
}
return;
}
throw new Error("Only files and directories can be copied.");
}
export async function copyPath(args?: Record<string, unknown>) {
const source = resolveSafeToolPath(args?.sourcePath);
const target = resolveSafeToolPath(args?.targetPath);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink sources are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
if (sourceStat.isDirectory() && !recursive) {
throw new Error("Source is a directory. Set recursive=true to copy directories.");
}
if (sourceStat.isDirectory()) {
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
}
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, {
allowMissingTail: true,
});
await fs.promises.mkdir(targetParentPath, {
recursive: true,
});
await assertNoSymlinkInPath(targetParentPath);
} else {
await assertNoSymlinkInPath(targetParentPath);
}
const stats: CopyPathStats = {
entries: 0,
totalBytes: 0,
};
await copyPathRecursive({
sourceAbsolutePath: source.absolutePath,
targetAbsolutePath: target.absolutePath,
overwrite,
stats,
});
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
recursive,
overwrite,
entriesCopied: stats.entries,
bytesCopied: stats.totalBytes,
};
}
export async function createDirectory(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
const recursive = asBoolean(args?.recursive, true);
await assertNoSymlinkInPath(absolutePath, {
allowMissingTail: true,
});
await fs.promises.mkdir(absolutePath, {
recursive,
});
return {
ok: true,
path: relativePath,
recursive,
};
}
export async function updateFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
const createIfMissing = asBoolean(args?.createIfMissing, false);
if (!["replace", "append", "prepend"].includes(mode)) {
throw new Error(`Unsupported update mode: ${mode}`);
}
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
const parentPath = path.dirname(absolutePath);
await assertNoSymlinkInPath(parentPath);
const exists = await pathExists(absolutePath);
if (!exists && !createIfMissing) {
throw new Error(`File does not exist: ${relativePath}`);
}
if (exists) {
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
}
if (mode === "replace") {
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: "w",
});
} else if (mode === "append") {
await fs.promises.appendFile(absolutePath, content, {
encoding: "utf8",
});
} else {
const oldContent = exists
? await fs.promises.readFile(absolutePath, "utf8")
: "";
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
await fs.promises.writeFile(absolutePath, content + oldContent, {
encoding: "utf8",
flag: "w",
});
}
const newStat = await fs.promises.stat(absolutePath);
return {
ok: true,
path: relativePath,
mode,
sizeBytes: newStat.size,
created: !exists,
};
}
export async function renamePath(args?: Record<string, unknown>) {
const source = resolveSafeToolPath(args?.sourcePath);
const target = resolveSafeToolPath(args?.targetPath);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath);
if (
relativeTargetInsideSource === "" ||
(!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource))
) {
throw new Error("Cannot move a directory into itself.");
}
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, false);
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true});
await fs.promises.mkdir(targetParentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(targetParentPath);
}
if (await pathExists(target.absolutePath)) {
const targetStat = await fs.promises.lstat(target.absolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!overwrite) {
throw new Error(`Target already exists: ${target.relativePath}`);
}
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
throw new Error("Overwrite for directories is not supported.");
}
await fs.promises.rm(target.absolutePath, {
force: false,
});
}
await fs.promises.rename(source.absolutePath, target.absolutePath);
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
overwrite,
};
}
export async function deletePath(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
if (stat.isDirectory()) {
if (recursive) {
await fs.promises.rm(absolutePath, {
recursive: true,
force: false,
});
} else {
await fs.promises.rmdir(absolutePath);
}
} else {
await fs.promises.rm(absolutePath, {
force: false,
});
}
return {
ok: true,
path: relativePath,
recursive,
deleted: true,
};
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -3,3 +3,15 @@ export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
export const MAX_DIRECTORY_ENTRIES = 200; export const MAX_DIRECTORY_ENTRIES = 200;
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024; export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
export const MAX_COPY_ENTRIES = 500; export const MAX_COPY_ENTRIES = 500;
export const MAX_PATCH_OPERATIONS = 20;
export const MAX_PATCH_SEARCH_BYTES = 64 * 1024;
export const MAX_PATCH_REPLACE_BYTES = 256 * 1024;
export const MAX_PATCH_PREVIEW_CHARS = 6000;
export const MAX_FILE_SEARCH_ENTRIES = 5000;
export const MAX_FILE_SEARCH_RESULTS = 100;
export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024;
export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300;
export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024;
export const MAX_STREAM_WRITE_SESSIONS = 20;
export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000;
export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
+1 -1
View File
@@ -20,7 +20,7 @@ export const getFinancialMarketData = {
}, },
} satisfies AiTool; } satisfies AiTool;
export const financialMarketDataToolPrompt = [ export const getFinancialMarketDataToolPrompt = [
"Currency rates tool rules:", "Currency rates tool rules:",
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`, `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`, `- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`,
+105 -93
View File
@@ -1,18 +1,22 @@
import {Environment} from "../../common/environment"; import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
import {braveSearchTool, webSearch} from "./brave-search"; import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime"; import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell"; import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types"; import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather"; import {getWeather, getWeatherTool} from "./weather";
import { import {
financialMarketDataToolPrompt,
GET_FINANCIAL_MARKET_DATA_TOOL_NAME, GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
getFinancialMarketData, getFinancialMarketData,
getFinancialMarketDataToolPrompt,
getMarketRates getMarketRates
} from "./market-rates"; } from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator"; import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
import { import {
beginFileWrite,
beginFileWriteTool,
cancelFileWrite,
cancelFileWriteTool,
copyPath, copyPath,
copyPathTool, copyPathTool,
createDirectory, createDirectory,
@@ -21,70 +25,81 @@ import {
createFileTool, createFileTool,
deletePath, deletePath,
deletePathTool, deletePathTool,
editFilePatch,
editFilePatchTool,
fileToolsToolPrompt,
finishFileWrite,
finishFileWriteTool,
listDirectory, listDirectory,
listDirectoryTool, listDirectoryTool,
readFile, readFile,
readFileTool, readFileTool,
renamePath, renamePath,
renamePathTool, renamePathTool,
searchFiles,
searchFilesTool,
sendFileAsAttachment,
sendFileAsAttachmentTool,
updateFile, updateFile,
updateFileTool updateFileTool,
} from "./file-system"; writeFileChunk,
import {createNote, createNoteTool} from "./create-note"; writeFileChunkTool
import { } from "./files";
deleteNote,
deleteNoteTool,
getNoteContent,
getNoteContentTool,
listNotes,
listNotesTool,
sendNoteAsFile,
sendNoteAsFileTool,
updateNoteContent,
updateNoteContentTool
} from "./notes";
import {searchNotes, searchNotesTool} from "./search-notes";
export const defaultFileTools: AiTool[] = [ export const defaultTools: AiTool[] = [
getCurrentDateTimeTool, getCurrentDateTimeTool,
getFinancialMarketData, getFinancialMarketData,
] ]
export const fileSystemTools: AiTool[] = [ export const fileTools = [
readFileTool, readFileTool,
listDirectoryTool, listDirectoryTool,
createFileTool, searchFilesTool,
createDirectoryTool,
updateFileTool,
renamePathTool,
copyPathTool,
deletePathTool,
];
export const notesFileTools: AiTool[] = [ createFileTool,
createNoteTool, beginFileWriteTool,
listNotesTool, writeFileChunkTool,
getNoteContentTool, finishFileWriteTool,
updateNoteContentTool, cancelFileWriteTool,
deleteNoteTool,
sendNoteAsFileTool, sendFileAsAttachmentTool,
searchNotesTool
] createDirectoryTool,
copyPathTool,
updateFileTool,
editFilePatchTool,
renamePathTool,
deletePathTool,
] satisfies AiTool[];
// export const notesFileTools: AiTool[] = [
// createNoteTool,
// listNotesTool,
// getNoteContentTool,
// updateNoteContentTool,
// deleteNoteTool,
// sendNoteAsFileTool,
// searchNotesTool
// ]
export const getTools = (forCreator?: boolean) => { export const getTools = (forCreator?: boolean) => {
const tools: AiTool[] = [ const tools: AiTool[] = [
...defaultFileTools, ...defaultTools,
...notesFileTools // ...notesFileTools
]; ];
if (Environment.BRAVE_SEARCH_API_KEY) { if (Environment.BRAVE_SEARCH_API_KEY) {
tools.push(braveSearchTool); tools.push(webSearchTool);
} }
if (Environment.OPEN_WEATHER_MAP_API_KEY) { if (Environment.OPEN_WEATHER_MAP_API_KEY) {
tools.push(getWeatherTool); tools.push(getWeatherTool);
} }
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(...fileTools);
}
if (forCreator) { if (forCreator) {
if (Environment.ENABLE_PYTHON_INTERPRETER) { if (Environment.ENABLE_PYTHON_INTERPRETER) {
tools.push(pythonInterpreterTool); tools.push(pythonInterpreterTool);
@@ -93,70 +108,58 @@ export const getTools = (forCreator?: boolean) => {
if (Environment.ENABLE_UNSAFE_EVAL) { if (Environment.ENABLE_UNSAFE_EVAL) {
tools.push(shellExecuteTool); tools.push(shellExecuteTool);
} }
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(...fileSystemTools);
}
} }
return tools; return tools;
}; };
export const fileToolHandlers = {
read_file: readFile,
list_directory: listDirectory,
search_files: searchFiles,
create_file: createFile,
begin_file_write: beginFileWrite,
write_file_chunk: writeFileChunk,
finish_file_write: finishFileWrite,
cancel_file_write: cancelFileWrite,
send_file_as_attachment: sendFileAsAttachment,
create_directory: createDirectory,
copy_path: copyPath,
update_file: updateFile,
edit_file_patch: editFilePatch,
rename_path: renamePath,
delete_path: deletePath,
};
export const getToolHandlers = () => { export const getToolHandlers = () => {
let handlers: Record<string, ToolHandler> = { let handlers: Record<string, ToolHandler> = {
get_datetime: getCurrentDateTime, get_datetime: getCurrentDateTime,
get_financial_market_data: getMarketRates, get_financial_market_data: getMarketRates,
create_note: createNote,
list_notes: listNotes, // create_note: createNote,
get_note_content: getNoteContent, // list_notes: listNotes,
update_note_content: updateNoteContent, // get_note_content: getNoteContent,
delete_note: deleteNote, // update_note_content: updateNoteContent,
send_note_as_file: sendNoteAsFile, // delete_note: deleteNote,
search_notes: searchNotes // send_note_as_file: sendNoteAsFile,
// search_notes: searchNotes,
...fileToolHandlers,
python_interpreter: runPythonInterpreter,
shell_execute: shellExecute,
web_search: webSearch,
get_weather: getWeather,
}; };
if (Environment.ENABLE_PYTHON_INTERPRETER) {
handlers = {
python_interpreter: runPythonInterpreter,
...handlers
};
}
if (Environment.ENABLE_UNSAFE_EVAL) {
handlers = {
shell_execute: shellExecute,
...handlers,
};
}
if (Environment.BRAVE_SEARCH_API_KEY) {
handlers = {
web_search: webSearch,
...handlers,
};
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
handlers = {
get_weather: getWeather,
...handlers,
};
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
handlers = {
read_file: readFile,
list_directory: listDirectory,
create_file: createFile,
create_directory: createDirectory,
update_file: updateFile,
rename_path: renamePath,
copy_path: copyPath,
delete_path: deletePath,
...handlers,
};
}
return handlers; return handlers;
}; };
@@ -164,9 +167,18 @@ export function getToolPrompts(toolNames: string[]): string[] {
const prompts: string[] = []; const prompts: string[] = [];
for (const toolName of toolNames) { for (const toolName of toolNames) {
if (!prompts.includes(fileToolsToolPrompt) &&
fileTools.map(t => t.function.name).includes(toolName)) {
prompts.push(fileToolsToolPrompt);
continue;
}
switch (toolName) { switch (toolName) {
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME: case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
prompts.push(financialMarketDataToolPrompt); prompts.push(getFinancialMarketDataToolPrompt);
break;
case WEB_SEARCH_TOOL_NAME:
prompts.push(webSearchToolPrompt);
break; break;
default: default:
break; break;
+4 -2
View File
@@ -15,6 +15,7 @@ function stringifyToolResult(result: unknown): string {
} }
export async function executeToolCall( export async function executeToolCall(
userId: number | undefined | null,
name: string, name: string,
args?: unknown, args?: unknown,
context: ToolRuntimeContext = {}, context: ToolRuntimeContext = {},
@@ -31,7 +32,7 @@ export async function executeToolCall(
try { try {
if (name === PYTHON_INTERPRETER_TOOL_NAME) { if (name === PYTHON_INTERPRETER_TOOL_NAME) {
const result = await runPythonInterpreter(normalizeToolArguments(args), { const result = await runPythonInterpreter(normalizeToolArguments(args, userId), {
executionTimeoutMs: 8_000, executionTimeoutMs: 8_000,
syntaxTimeoutMs: 3_000, syntaxTimeoutMs: 3_000,
maxCodeChars: 100_000, maxCodeChars: 100_000,
@@ -45,7 +46,8 @@ export async function executeToolCall(
return s; return s;
} }
const result = await handler(normalizeToolArguments(args)); const arguments1 = normalizeToolArguments(args, userId);
const result = await handler(arguments1);
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;
+6 -2
View File
@@ -9,7 +9,7 @@ export function asNonEmptyString(value: unknown): string | undefined {
: undefined; : undefined;
} }
export function normalizeToolArguments(args: unknown): Record<string, unknown> { export function normalizeToolArguments(args: unknown, userId?: number | null): Record<string, unknown> {
if (!args) return {}; if (!args) return {};
if (typeof args === "string") { if (typeof args === "string") {
@@ -29,7 +29,11 @@ export function normalizeToolArguments(args: unknown): Record<string, unknown> {
} }
if (typeof args === "object" && !Array.isArray(args)) { if (typeof args === "object" && !Array.isArray(args)) {
return args as Record<string, unknown>; const userIdObject = userId ? {"userId": userId} : {};
return {
...args as Record<string, unknown>,
...userIdObject as Record<string, unknown>
}
} }
return {}; return {};
@@ -92,7 +92,7 @@ type BraveSearchApiResponse = {
export const WEB_SEARCH_TOOL_NAME = "web_search"; export const WEB_SEARCH_TOOL_NAME = "web_search";
export const braveSearchTool = { export const webSearchTool = {
type: "function", type: "function",
function: { function: {
name: WEB_SEARCH_TOOL_NAME, name: WEB_SEARCH_TOOL_NAME,
@@ -163,7 +163,7 @@ export const braveSearchTool = {
}, },
} satisfies AiTool; } satisfies AiTool;
export const braveSearchToolPrompt = [ export const webSearchToolPrompt = [
"Brave Search tool rules:", "Brave Search tool rules:",
"- You have access to `web_search`.", "- You have access to `web_search`.",
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.", "- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
+4 -2
View File
@@ -19,6 +19,7 @@ import {
ToolCallData, ToolCallData,
ToolExecutionMemory ToolExecutionMemory
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {Message} from "typescript-telegram-bot-api";
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string { function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
if (typeof response.text === "string") return response.text; if (typeof response.text === "string") return response.text;
@@ -82,6 +83,7 @@ function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[],
} }
export async function runGemini( export async function runGemini(
msg: Message,
messages: GeminiMessage[], messages: GeminiMessage[],
streamMessage: TelegramStreamMessage, streamMessage: TelegramStreamMessage,
signal: AbortSignal, signal: AbortSignal,
@@ -143,7 +145,7 @@ export async function runGemini(
}); });
if (!calls.length) return; if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory)); appendGeminiToolRound(messages, calls, await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory));
continue; continue;
} }
@@ -166,7 +168,7 @@ export async function runGemini(
calls: calls.map(aiLogToolCall), calls: calls.map(aiLogToolCall),
}); });
if (!calls.length) return; if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory)); appendGeminiToolRound(messages, calls, await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory));
} }
} }
+24 -15
View File
@@ -1,4 +1,3 @@
// Mistral provider runner extracted from unified-ai-runner.ts.
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {getMistralTools} from "./tool-mappers"; import {getMistralTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
@@ -7,9 +6,24 @@ import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target"; import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared"; import {
contentFromMistralDelta,
executeToolBatch,
MAX_TOOL_ROUNDS,
MistralDeltaLike,
MistralDocumentReference,
mistralToolCalls,
normalizeMistralToolCalls,
roundStatus,
RuntimeConfigSnapshot,
StreamingToolCallAccumulator,
ToolCallData,
ToolExecutionMemory
} from "./unified-ai-runner.shared";
import {Message} from "typescript-telegram-bot-api";
export async function runMistral( export async function runMistral(
msg: Message,
messages: MistralChatMessage[], messages: MistralChatMessage[],
documents: MistralDocumentReference[], documents: MistralDocumentReference[],
streamMessage: TelegramStreamMessage, streamMessage: TelegramStreamMessage,
@@ -43,14 +57,14 @@ export async function runMistral(
const request = { const request = {
model: config.mistralChatTarget.model, model: config.mistralChatTarget.model,
messages, messages,
tools: getMistralTools(), tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID),
documents: documents documents: documents
} as unknown as Parameters<typeof mistralAi.chat.complete>[0]; } as unknown as Parameters<typeof mistralAi.chat.complete>[0];
const response = await mistralAi.chat.complete(request, {signal}); const response = await mistralAi.chat.complete(request, {signal});
const msg = response.choices?.[0]?.message; const message = response.choices?.[0]?.message;
const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? ""); const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
streamMessage.append(text); streamMessage.append(text);
const calls = normalizeMistralToolCalls(mistralToolCalls(msg)); const calls = normalizeMistralToolCalls(mistralToolCalls(message));
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round, round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
@@ -66,7 +80,7 @@ export async function runMistral(
function: {name: call.name, arguments: call.argumentsText}, function: {name: call.name, arguments: call.argumentsText},
})), })),
}); });
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) { for (const [index, call] of calls.entries()) {
messages.push({ messages.push({
role: "tool", role: "tool",
@@ -81,7 +95,7 @@ export async function runMistral(
const request = { const request = {
model: config.mistralChatTarget.model, model: config.mistralChatTarget.model,
messages, messages,
tools: getMistralTools(), tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID),
documents: documents documents: documents
} as unknown as Parameters<typeof mistralAi.chat.stream>[0]; } as unknown as Parameters<typeof mistralAi.chat.stream>[0];
const streamResponse = await mistralAi.chat.stream(request, {signal}); const streamResponse = await mistralAi.chat.stream(request, {signal});
@@ -119,7 +133,7 @@ export async function runMistral(
content: roundText, content: roundText,
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}})) toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
}); });
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) { for (const [index, call] of calls.entries()) {
messages.push({ messages.push({
role: "tool", role: "tool",
@@ -129,9 +143,4 @@ export async function runMistral(
}); });
} }
} }
} }
export class MistralProviderRunner {
static run = runMistral;
}
+48 -71
View File
@@ -1,5 +1,4 @@
// Ollama provider runner extracted from unified-ai-runner.ts. // Ollama provider runner extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs"; import * as fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
@@ -11,9 +10,6 @@ import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama"; import {ChatRequest, Tool} from "ollama";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {getCurrentDateTimeTool} from "./tools/datetime";
import {getFinancialMarketData} from "./tools/market-rates";
import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
@@ -40,17 +36,10 @@ import {
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {latestUserTextFromOllamaMessages, OllamaToolRanker} from "./unified-ai-runner.tool-ranker"; import {latestUserTextFromOllamaMessages, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
import {getToolPrompts} from "./tools/registry"; import {getToolPrompts} from "./tools/registry";
import {createNoteTool} from "./tools/create-note"; import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
import { import {getModelCapabilities} from "./provider-model-runtime";
deleteNoteTool, import {AiProvider} from "../model/ai-provider";
getNoteContentTool, import {Message} from "typescript-telegram-bot-api";
GetNoteFileResult,
GetNoteFileResultSchema,
listNotesTool,
sendNoteAsFileTool,
updateNoteContentTool
} from "./tools/notes";
import {searchNotesTool} from "./tools/search-notes";
export async function runOllama( export async function runOllama(
msg: Message, msg: Message,
@@ -64,7 +53,6 @@ export async function runOllama(
toolContext: ToolRuntimeContext, toolContext: ToolRuntimeContext,
contextSize?: number, contextSize?: number,
): Promise<void> { ): Promise<void> {
const fromId = msg.from?.id;
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0); const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
@@ -192,68 +180,57 @@ export async function runOllama(
}; };
let activeToolNames: string[] = []; let activeToolNames: string[] = [];
// if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
const availableOllamaTools: Tool[] = const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[];
fromId !== Environment.CREATOR_ID ? [
getCurrentDateTimeTool,
getFinancialMarketData,
getWeatherTool,
createNoteTool,
listNotesTool,
getNoteContentTool,
updateNoteContentTool,
deleteNoteTool,
sendNoteAsFileTool,
searchNotesTool
] : getOllamaTools() as Tool[];
aiLog("debug", "ollama.tools.available", { aiLog("debug", "ollama.tools.available", {
round, round,
tools: allToolSchemaNames(availableOllamaTools), tools: allToolSchemaNames(availableOllamaTools),
rankerEnabled: !!config.ollamaToolRankerTarget, rankerEnabled: !!config.ollamaToolRankerTarget,
}); });
const rankerSelection = await new OllamaToolRanker(config).selectTools({ const rankerSelection = await new OllamaToolRanker(config).selectTools({
userQuery: latestUserTextFromOllamaMessages(messages), userQuery: latestUserTextFromOllamaMessages(messages),
availableTools: availableOllamaTools, availableTools: availableOllamaTools,
round, round,
signal, signal,
}); });
activeToolNames = rankerSelection.tools.map(t => t.function.name ?? ""); const filteredTools = [...new Set(rankerSelection.tools)];
if (rankerSelection.tools.length > 0) { activeToolNames = filteredTools.map(t => t.function.name ?? "");
request.tools = [...rankerSelection.tools, ...rankerSelection.tools]; if (filteredTools.length > 0) {
request.options = { request.tools = [...filteredTools];
...request.options, request.options = {
temperature: 0 ...request.options,
temperature: 0
}
const newMessage = messages[messages.length - 1];
if (newMessage) {
newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", ");
}
const systemMessage = messages.find(m => m.role === "system");
if (systemMessage) {
systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n");
}
request.model = config.ollamaToolTarget.model;
} else {
delete request.tools;
} }
const newMessage = messages[messages.length - 1]; // TODO: 14.05.2026, Danil Nikolaev: check if model supports tools
if (newMessage) {
newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", ");
}
const systemMessage = messages.find(m => m.role === "system");
if (systemMessage) {
systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n");
}
request.model = config.ollamaToolTarget.model; aiLog("debug", "ollama.tools.selected", {
} else { round,
delete request.tools; tools: activeToolNames,
count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker,
});
} }
// TODO: 14.05.2026, Danil Nikolaev: check if model supports tools
aiLog("debug", "ollama.tools.selected", {
round,
tools: activeToolNames,
count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker,
});
// }
if (!stream) { if (!stream) {
const response = await ollama.chat({ const response = await ollama.chat({
...request, ...request,
@@ -316,7 +293,7 @@ export async function runOllama(
appendOllamaToolResults( appendOllamaToolResults(
messages, messages,
calls, calls,
await executeToolBatch(calls, streamMessage, toolContext, toolMemory), await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory),
); );
continue; continue;
@@ -425,7 +402,7 @@ export async function runOllama(
})), })),
}); });
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory); const toolResults = await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
+316 -301
View File
@@ -1,7 +1,6 @@
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts. // OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {getOpenAITools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage} from "./openai-chat-message"; import {OpenAIChatMessage} from "./openai-chat-message";
@@ -11,45 +10,35 @@ import type {
ResponseInputItem, ResponseInputItem,
ResponseStreamEvent ResponseStreamEvent
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
import type { import {createOpenAiClient} from "./ai-runtime-target";
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming
} from "openai/resources/chat/completions";
import {createGeminiOpenAiClient, 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 { import {
AsyncIterableStream, AsyncIterableStream,
collectOpenAiResponseFunctionCalls, buildSystemInstruction,
collectOpenAiResponseCodeInterpreterCalls, collectOpenAiResponseCodeInterpreterCalls,
collectOpenAiResponseFunctionCalls,
collectOpenAiResponseImages, collectOpenAiResponseImages,
collectOpenAiResponseText, collectOpenAiResponseText,
executeToolBatch, executeToolBatch,
getOpenAIResponsesToolsWithImage, getOpenAIResponsesToolsWithImage,
isRecord,
MAX_TOOL_ROUNDS, MAX_TOOL_ROUNDS,
OPENAI_IMAGE_PARTIALS, OPENAI_IMAGE_PARTIALS,
OpenAiChatCompletionResponseLike,
OpenAiChatCompletionStreamChunkLike,
OpenAiChatToolCallLike,
OpenAiCompatibleChatMessage,
OpenAiCompatibleContentPart,
openAiResponseItemCallId, openAiResponseItemCallId,
OpenAiResponseLike, OpenAiResponseLike,
OpenAiResponseOutputItem, OpenAiResponseOutputItem,
roundStatus,
RuntimeConfigSnapshot, RuntimeConfigSnapshot,
safeJsonParseObject, safeJsonParseObject,
showOpenAiGeneratedImage, showOpenAiGeneratedImage,
StreamingToolCallAccumulator,
ToolCallData, ToolCallData,
ToolExecutionMemory ToolExecutionMemory
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-as-file"; import {bot, filesDir} from "../index";
import {bot, notesDir} from "../index";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; 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";
export async function runOpenAi( export async function runOpenAi(
msg: Message, msg: Message,
@@ -57,19 +46,16 @@ export async function runOpenAi(
streamMessage: TelegramStreamMessage, streamMessage: TelegramStreamMessage,
signal: AbortSignal, signal: AbortSignal,
stream: boolean, stream: boolean,
firstRoundStatus: string,
sourceMessage: Message, sourceMessage: Message,
config: RuntimeConfigSnapshot, config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext, toolContext: ToolRuntimeContext,
think?: boolean
): Promise<void> { ): Promise<void> {
// TODO: 13.05.2026: remove
firstRoundStatus;
think;
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
let responseInput: unknown[] = [...messages]; let responseInput: unknown[] = [...messages];
const openAi = createOpenAiClient(config.openAiChatTarget); const openAi = createOpenAiClient(config.openAiChatTarget);
const systemPrompt = buildSystemInstruction(config, DEFAULT_AI_RESPONSE_LANGUAGE, false);
aiLog("info", "openai.run.start", { aiLog("info", "openai.run.start", {
stream, stream,
target: aiLogProviderTarget(config.openAiChatTarget), target: aiLogProviderTarget(config.openAiChatTarget),
@@ -89,8 +75,8 @@ export async function runOpenAi(
const request: ResponseCreateParamsNonStreaming = { const request: ResponseCreateParamsNonStreaming = {
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"], tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsNonStreaming["tools"],
instructions: config.systemPrompt, instructions: systemPrompt,
}; };
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike; const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
@@ -146,38 +132,26 @@ export async function runOpenAi(
name: call.name, name: call.name,
argumentsText: call.argumentsText, argumentsText: call.argumentsText,
})); }));
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory); const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
const toolOutputs = calls.map((call, index) => ({ const toolOutputs = calls.map((call, index) => ({
type: "function_call_output" as const, type: "function_call_output" as const,
call_id: call.callId, call_id: call.callId,
output: toolResults[index] ?? "", output: toolResults[index] ?? "",
})); }));
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
if (!uploadFilesResult.uploaded) {
const old = toolOutputs[uploadFilesResult.toolIndex];
delete toolOutputs[uploadFilesResult.toolIndex];
toolOutputs.push({
type: "function_call_output" as const,
call_id: old.call_id,
output: "Error: " + uploadFilesResult.error
});
}
}
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs]; responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
continue; continue;
} }
@@ -187,8 +161,9 @@ export async function runOpenAi(
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
stream: true, stream: true,
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"], tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsStreaming["tools"],
parallel_tool_calls: true parallel_tool_calls: true,
instructions: systemPrompt
}; };
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>; const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
@@ -338,273 +313,313 @@ export async function runOpenAi(
name: call.name, name: call.name,
argumentsText: call.argumentsText, argumentsText: call.argumentsText,
})); }));
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory); const toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
const toolOutputs = calls.map((call, index) => ({ const toolOutputs = calls.map((call, index) => ({
type: "function_call_output", type: "function_call_output",
call_id: call.callId, call_id: call.callId,
output: toolResults[index] ?? "", output: toolResults[index] ?? "",
})); }));
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
if (!uploadFilesResult.uploaded) {
const old = toolOutputs[uploadFilesResult.toolIndex];
delete toolOutputs[uploadFilesResult.toolIndex];
toolOutputs.push({
type: "function_call_output" as const,
call_id: old.call_id,
output: "Error: " + uploadFilesResult.error
});
}
}
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs]; responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
} }
} }
async function tryToUploadFiles(
function openAiResponseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
}
function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
return messages.map((message): OpenAiCompatibleChatMessage => {
if (message.role === "system" || message.role === "assistant") {
return {
role: message.role,
content: openAiResponseContentToText(message.content),
};
}
const content = Array.isArray(message.content)
? message.content.map((part): OpenAiCompatibleContentPart => {
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 : "",
};
})
: message.content;
return {role: "user", content};
});
}
function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
return toolCalls.map((call, i) => ({
id: call.id || `openai_chat_${Date.now()}_${i}`,
name: call.function?.name || call.name || "",
argumentsText: typeof call.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
})).filter(call => call.name);
}
async function appendOpenAiChatToolResults(
messages: OpenAiCompatibleChatMessage[],
calls: ToolCallData[],
results: string[],
): Promise<void> {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
}
}
export async function runOpenAiCompatibleChat(
msg: Message, msg: Message,
messages: OpenAIChatMessage[], toolResults: string[]
streamMessage: TelegramStreamMessage, ): Promise<
signal: AbortSignal, | { found: false }
stream: boolean, | { found: true, uploaded: true }
firstRoundStatus: string, | { found: boolean, uploaded: false, error: string, toolIndex: number }
config: RuntimeConfigSnapshot, > {
toolContext: ToolRuntimeContext, let sendFileAttachment: {
): Promise<void> { result: SendFileAttachmentResult & { success: true },
const runnerStartedAt = Date.now(); toolIndex: number
const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget); } | null = null;
const chatMessages = openAiResponseMessagesToChatCompletions(messages);
const toolMemory: ToolExecutionMemory = new Map();
aiLog("info", "openai_compatible.run.start", { let found = false;
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
chatMessages: chatMessages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream});
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
// temperature: 0.6,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
streamMessage.append(message?.content ?? "");
const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: message?.content?.length ?? 0,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
chatMessages.push({
role: "assistant",
content: message?.content ?? "",
tool_calls: calls.map(call => ({
id: call.id,
type: "function" as const,
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
continue;
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
// temperature: 0.6,
stream: true,
parallel_tool_calls: true
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
// const streamToolCalls: OpenAiChatToolCallLike[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
let calls: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const delta = chunk.choices?.[0]?.delta;
streamMessage.append(delta?.content ?? "");
if (delta?.tool_calls?.length) {
calls = toolCallAccumulator.add(delta.tool_calls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
// const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
chatMessages.push({
role: "assistant",
content: roundText,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
try {
for (const toolResult of toolResults) { for (const toolResult of toolResults) {
try { const raw = JSON.parse(toolResult);
const raw = JSON.parse(toolResult); const res = SendFileAttachmentResultSchema.safeParse(raw);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) { if (res.success) {
successGetNoteFileResult = res.data; found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: toolResults.indexOf(toolResult)};
} }
} catch {
// Not every tool result is JSON.
} }
} }
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { if (!found) {
await bot.sendDocument({ return {found: false};
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
} }
await appendOpenAiChatToolResults(chatMessages, calls, toolResults); await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(filesDir, String(msg.from?.id), sendFileAttachment?.result?.attachment?.relativePath ?? "")),
})
return {found: true, uploaded: true};
} catch (e: unknown) {
logError(e);
return {
found: found,
uploaded: false,
error: (e as any)?.message ?? "",
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
} }
} }
// function openAiResponseContentToText(content: unknown): string {
// if (typeof content === "string") return content;
// if (!Array.isArray(content)) return "";
// return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
// }
// function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
// return messages.map((message): OpenAiCompatibleChatMessage => {
// if (message.role === "system" || message.role === "assistant") {
// return {
// role: message.role,
// content: openAiResponseContentToText(message.content),
// };
// }
//
// const content = Array.isArray(message.content)
// ? message.content.map((part): OpenAiCompatibleContentPart => {
// 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 : "",
// };
// })
// : message.content;
//
// return {role: "user", content};
// });
// }
// function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
// return toolCalls.map((call, i) => ({
// id: call.id || `openai_chat_${Date.now()}_${i}`,
// name: call.function?.name || call.name || "",
// argumentsText: typeof call.function?.arguments === "string"
// ? call.function.arguments
// : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
// })).filter(call => call.name);
// }
// async function appendOpenAiChatToolResults(
// messages: OpenAiCompatibleChatMessage[],
// calls: ToolCallData[],
// results: string[],
// ): Promise<void> {
// for (const [index, call] of calls.entries()) {
// messages.push({
// role: "tool",
// tool_call_id: call.id,
// content: results[index] ?? "",
// });
// }
// }
// export async function runOpenAiCompatibleChat(
// msg: Message,
// messages: OpenAIChatMessage[],
// streamMessage: TelegramStreamMessage,
// signal: AbortSignal,
// stream: boolean,
// firstRoundStatus: string,
// config: RuntimeConfigSnapshot,
// toolContext: ToolRuntimeContext,
// ): Promise<void> {
// const runnerStartedAt = Date.now();
// const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget);
// const chatMessages = openAiResponseMessagesToChatCompletions(messages);
// const toolMemory: ToolExecutionMemory = new Map();
//
// aiLog("info", "openai_compatible.run.start", {
// stream,
// target: aiLogProviderTarget(config.geminiChatTarget),
// inputMessages: messages.length,
// chatMessages: chatMessages.length,
// hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
// });
//
// for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
// const roundStartedAt = Date.now();
// aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream});
// streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
// await streamMessage.flush();
//
// if (!stream) {
// const request: ChatCompletionCreateParamsNonStreaming = {
// model: config.geminiChatTarget.model,
// messages: chatMessages,
// tools: getOpenAITools(msg.from?.id === Environment.CREATOR_ID),
// // temperature: 0.6,
// };
// const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
// const message = response.choices?.[0]?.message;
// streamMessage.append(message?.content ?? "");
// const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []);
// aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
// round,
// duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
// textChars: message?.content?.length ?? 0,
// calls: calls.map(aiLogToolCall),
// });
// if (!calls.length) return;
//
// chatMessages.push({
// role: "assistant",
// content: message?.content ?? "",
// tool_calls: calls.map(call => ({
// id: call.id,
// type: "function" as const,
// function: {
// name: call.name,
// arguments: call.argumentsText,
// },
// })),
// });
//
// const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
//
// let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
//
// for (const toolResult of toolResults) {
// try {
// const raw = JSON.parse(toolResult);
// const res = GetNoteFileResultSchema.safeParse(raw);
//
// if (res.success && res.data.success) {
// successGetNoteFileResult = res.data;
// }
// } catch {
// // Not every tool result is JSON.
// }
// }
//
// if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
// await bot.sendDocument({
// chat_id: msg.chat.id,
// reply_parameters: {
// message_id: msg.message_id,
// },
// document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
// }).catch(logError);
// }
//
// await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
// continue;
// }
//
// const request: ChatCompletionCreateParamsStreaming = {
// model: config.geminiChatTarget.model,
// messages: chatMessages,
// tools: getOpenAITools(msg.from?.id === Environment.CREATOR_ID),
// // temperature: 0.6,
// stream: true,
// parallel_tool_calls: true
// };
// const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
//
// aiLog("debug", "openai_compatible.stream.open", {round});
// // const streamToolCalls: OpenAiChatToolCallLike[] = [];
// const roundTextStart = streamMessage.getText().length;
// const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
// let calls: ToolCallData[] = [];
//
// for await (const chunk of response) {
// if (signal.aborted) throw new Error("Aborted");
//
// const delta = chunk.choices?.[0]?.delta;
// streamMessage.append(delta?.content ?? "");
//
// if (delta?.tool_calls?.length) {
// calls = toolCallAccumulator.add(delta.tool_calls);
// streamMessage.setStatus(Environment.getUseToolText(calls));
// await streamMessage.flush();
// }
// }
//
// // const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
// aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
// round,
// duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
// textChars: streamMessage.getText().slice(roundTextStart).length,
// calls: calls.map(aiLogToolCall),
// });
// if (!calls.length) return;
//
// const roundText = streamMessage.getText().slice(roundTextStart);
// chatMessages.push({
// role: "assistant",
// content: roundText,
// tool_calls: calls.map(call => ({
// id: call.id,
// type: "function",
// function: {
// name: call.name,
// arguments: call.argumentsText,
// },
// })),
// });
//
// const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
//
// let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
//
// for (const toolResult of toolResults) {
// try {
// const raw = JSON.parse(toolResult);
// const res = GetNoteFileResultSchema.safeParse(raw);
//
// if (res.success && res.data.success) {
// successGetNoteFileResult = res.data;
// }
// } catch {
// // Not every tool result is JSON.
// }
// }
//
// if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
// await bot.sendDocument({
// chat_id: msg.chat.id,
// reply_parameters: {
// message_id: msg.message_id,
// },
// document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
// }).catch(logError);
// }
//
// await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
// }
// }
+10 -7
View File
@@ -1269,6 +1269,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
} }
export async function executeTool( export async function executeTool(
userId: number | undefined | null,
toolCall: ToolCallData, toolCall: ToolCallData,
message: TelegramStreamMessage, message: TelegramStreamMessage,
context: ToolRuntimeContext, context: ToolRuntimeContext,
@@ -1298,7 +1299,7 @@ export async function executeTool(
} }
try { try {
const rawResult = await executeToolCall(toolCall.name, parsedArgs.args, context); const rawResult = await executeToolCall(userId, toolCall.name, parsedArgs.args, context);
const result = stringifyToolExecutionResult(rawResult); const result = stringifyToolExecutionResult(rawResult);
await sendToolArtifacts(toolCall, result, message); await sendToolArtifacts(toolCall, result, message);
@@ -1367,16 +1368,18 @@ export async function runWithToolLocks<T>(keys: string[], task: () => Promise<T>
} }
export async function executeScheduledTool( export async function executeScheduledTool(
userId: number | undefined | null,
toolCall: ToolCallData, toolCall: ToolCallData,
message: TelegramStreamMessage, message: TelegramStreamMessage,
context: ToolRuntimeContext, context: ToolRuntimeContext,
): Promise<string> { ): Promise<string> {
const keys = toolResourceKeys(toolCall); const keys = toolResourceKeys(toolCall);
if (!keys.length) return executeTool(toolCall, message, context); if (!keys.length) return executeTool(userId, toolCall, message, context);
return runWithToolLocks(keys, () => executeTool(toolCall, message, context)); return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
} }
export async function executeToolBatch( export async function executeToolBatch(
userId: number | undefined | null,
toolCalls: ToolCallData[], toolCalls: ToolCallData[],
message: TelegramStreamMessage, message: TelegramStreamMessage,
context: ToolRuntimeContext, context: ToolRuntimeContext,
@@ -1417,7 +1420,7 @@ export async function executeToolBatch(
message.setStatus(Environment.getUseToolText(statusCalls)); message.setStatus(Environment.getUseToolText(statusCalls));
await message.flush(); await message.flush();
const resultText = await executeScheduledTool(toolCall, message, context); const resultText = await executeScheduledTool(userId, toolCall, message, context);
memory.set(signature, { memory.set(signature, {
count: (previous?.count ?? 0) + 1, count: (previous?.count ?? 0) + 1,
@@ -1626,9 +1629,9 @@ export function allToolSchemaNames(tools: readonly unknown[]): string[] {
return [...new Set(tools.flatMap(toolSchemaNames))]; return [...new Set(tools.flatMap(toolSchemaNames))];
} }
export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot): Array<OpenAiResponseTool | LooseRecord> { export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot, forCreator?: boolean): Array<OpenAiResponseTool | LooseRecord> {
return [ return [
...getOpenAIResponsesTools(), ...getOpenAIResponsesTools(forCreator),
getOpenAICodeInterpreterTool(), getOpenAICodeInterpreterTool(),
{ {
type: "image_generation", type: "image_generation",
@@ -1667,7 +1670,7 @@ export type OpenAiCodeInterpreterCall = {
code: string | null; code: string | null;
containerId: string; containerId: string;
status: string; status: string;
outputs: Array<{type?: "logs" | "image"; logs?: string; url?: string}>; outputs: Array<{ type?: "logs" | "image"; logs?: string; url?: string }>;
}; };
export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] { export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] {
+26 -12
View File
@@ -19,15 +19,13 @@ import {isTranscribableAudioDownload} from "./speech-to-text";
import {OpenAIChatMessage} from "./openai-chat-message"; import {OpenAIChatMessage} from "./openai-chat-message";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message";
import {OllamaChatMessage} from "./ollama-chat-message"; import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message";
import {buildAiRegenerateCallbackData} from "./regenerate-callback"; import {buildAiRegenerateCallbackData} from "./regenerate-callback";
import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai"; import {runOpenAi} from "./unified-ai-runner.openai";
import {runOllama} from "./unified-ai-runner.ollama"; import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral"; import {runMistral} from "./unified-ai-runner.mistral";
import {runGemini} from "./unified-ai-runner.gemini";
import { import {
AI_REQUEST_TIMEOUT_MS, AI_REQUEST_TIMEOUT_MS,
appendTranscriptToChatMessages, appendTranscriptToChatMessages,
@@ -35,6 +33,7 @@ import {
collectRequestedAttachmentKinds, collectRequestedAttachmentKinds,
collectTextMessages, collectTextMessages,
deleteMistralLibrary, deleteMistralLibrary,
GeminiMessage,
hasAudioAttachmentKind, hasAudioAttachmentKind,
initialStatus, initialStatus,
isAbortError, isAbortError,
@@ -51,6 +50,8 @@ import {
transcribeAudioIfNeeded, transcribeAudioIfNeeded,
UnifiedRunOptions UnifiedRunOptions
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {runGemini} from "./unified-ai-runner.gemini";
import {resolveTextToSpeechProviderForUser, sendSynthesizedSpeech, synthesizeSpeech} from "./text-to-speech";
export type {ToolCallData} from "./unified-ai-runner.shared"; export type {ToolCallData} from "./unified-ai-runner.shared";
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
@@ -165,8 +166,7 @@ async function executeUnifiedAiRequest(
switch (options.provider) { switch (options.provider) {
case AiProvider.OPENAI: case AiProvider.OPENAI:
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext, await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, options.msg, config, toolContext);
!!options.think);
break; break;
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
const currentModel = config.ollamaChatTarget.model; const currentModel = config.ollamaChatTarget.model;
@@ -179,14 +179,10 @@ async function executeUnifiedAiRequest(
await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize); await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize);
break; break;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
await runMistral(chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext); await runMistral(options.msg, chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
break; break;
case AiProvider.GEMINI: case AiProvider.GEMINI:
if (getGeminiApiMode(config.geminiChatTarget) === "openai") { await runGemini(options.msg, chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
} else {
await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
}
break; break;
} }
@@ -216,6 +212,24 @@ async function executeUnifiedAiRequest(
} }
} }
// noinspection JSUnusedLocalSymbols
// @ts-ignore
async function sendVoiceResponseIfNeeded(options: UnifiedRunOptions, downloads: AiDownloadedFile[], text: string): Promise<void> {
if (!downloads.some(isTranscribableAudioDownload)) return;
if (!options.msg.from?.id) return;
const trimmed = text.trim();
if (!trimmed) return;
try {
const provider = (await resolveTextToSpeechProviderForUser(options.msg.from.id)).provider;
const speech = await synthesizeSpeech({provider, text: trimmed});
await sendSynthesizedSpeech(options.msg, speech);
} catch (e) {
logError(e);
}
}
export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> { export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
const config = snapshotRuntimeConfig(); const config = snapshotRuntimeConfig();
+8 -2
View File
@@ -190,6 +190,7 @@ export const videoDir = path.join(cacheDir, "video");
export const videoNotesDir = path.join(cacheDir, "video-note"); export const videoNotesDir = path.join(cacheDir, "video-note");
export const videoTempDir = path.join(videoDir, "temp"); export const videoTempDir = path.join(videoDir, "temp");
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");
@@ -224,7 +225,7 @@ async function main() {
dbPath: Environment.DB_PATH, dbPath: Environment.DB_PATH,
}); });
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir]; const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, filesDir];
dirsToCheck.forEach(dir => { dirsToCheck.forEach(dir => {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {recursive: true}); fs.mkdirSync(dir, {recursive: true});
@@ -282,7 +283,12 @@ async function main() {
const end = Date.now(); const end = Date.now();
const diff = Math.abs(end - start); const diff = Math.abs(end - start);
logger.success("startup.ready", {duration: `${diff}ms`, commands: cmds.length, botId: botUser.id, botUsername: botUser.username}); logger.success("startup.ready", {
duration: `${diff}ms`,
commands: cmds.length,
botId: botUser.id,
botUsername: botUser.username
});
} catch (error) { } catch (error) {
logError(error); logError(error);
} }
+11 -2
View File
@@ -2122,7 +2122,12 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
UserStore.put(from) UserStore.put(from)
] ]
); );
messageLogger.debug("message.persisted", {chatId: msg.chat.id, messageId: msg.message_id, fromId: from.id, duration: logger.duration(startedAt)}); messageLogger.debug("message.persisted", {
chatId: msg.chat.id,
messageId: msg.message_id,
fromId: from.id,
duration: logger.duration(startedAt)
});
storedMsg = results[0]; storedMsg = results[0];
locale = await resolveInterfaceLocaleForUser(from.id, from.language_code); locale = await resolveInterfaceLocaleForUser(from.id, from.language_code);
@@ -2202,7 +2207,11 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
|| !!msg.video_note; || !!msg.video_note;
const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/"); const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/");
if (executed) { if (executed) {
messageLogger.debug("message.command_executed", {chatId: msg.chat.id, messageId: msg.message_id, command: cmd?.title}); messageLogger.debug("message.command_executed", {
chatId: msg.chat.id,
messageId: msg.message_id,
command: cmd?.title
});
return; return;
} }