Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b367f29c5 | |||
| 2b1940bf4d |
@@ -374,7 +374,7 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
|
||||
reply_parameters: {message_id: sourceMessage.message_id},
|
||||
});
|
||||
} finally {
|
||||
destroyUpload(upload);
|
||||
// destroyUpload(upload);
|
||||
}
|
||||
},
|
||||
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
|
||||
|
||||
+16
-16
@@ -1,13 +1,13 @@
|
||||
import {AiTool} from "./tool-types";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
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";
|
||||
|
||||
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
|
||||
|
||||
export function getOllamaTools(): AiTool[] {
|
||||
return getTools();
|
||||
export function getOllamaTools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator);
|
||||
}
|
||||
|
||||
const openAiForbiddenTools = [
|
||||
@@ -19,8 +19,8 @@ function allowedOpenAiTool(tool: AiTool): boolean {
|
||||
return !openAiForbiddenTools.includes(tool.function.name)
|
||||
}
|
||||
|
||||
export function getOpenAITools(): AiTool[] {
|
||||
return getTools().filter(allowedOpenAiTool).map(tool => ({
|
||||
export function getOpenAITools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
|
||||
type: "function",
|
||||
function: tool.function,
|
||||
}));
|
||||
@@ -43,8 +43,8 @@ export type OpenAiCodeInterpreterTool = {
|
||||
} | string;
|
||||
};
|
||||
|
||||
export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
|
||||
return getTools().filter(allowedOpenAiTool).map(tool => ({
|
||||
export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] {
|
||||
return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
@@ -62,8 +62,8 @@ export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool {
|
||||
};
|
||||
}
|
||||
|
||||
export function getMistralTools(): AiTool[] {
|
||||
return getTools().map(tool => ({
|
||||
export function getMistralTools(forCreator?: boolean): AiTool[] {
|
||||
return getTools(forCreator).map(tool => ({
|
||||
type: "function",
|
||||
function: tool.function,
|
||||
}));
|
||||
@@ -77,8 +77,8 @@ export type GeminiTool = {
|
||||
}>;
|
||||
}
|
||||
|
||||
export function getGeminiTools(): GeminiTool[] {
|
||||
const functionDeclarations = getTools().map(tool => ({
|
||||
export function getGeminiTools(forCreator?: boolean): GeminiTool[] {
|
||||
const functionDeclarations = getTools(forCreator).map(tool => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parametersJsonSchema: tool.function.parameters,
|
||||
@@ -87,15 +87,15 @@ export function getGeminiTools(): GeminiTool[] {
|
||||
return functionDeclarations.length ? [{functionDeclarations}] : [];
|
||||
}
|
||||
|
||||
export function getProviderTools(provider: AiProvider): AiTool[] {
|
||||
export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] {
|
||||
switch (provider) {
|
||||
case AiProvider.OLLAMA:
|
||||
return getOllamaTools();
|
||||
return getOllamaTools(forCreator);
|
||||
case AiProvider.GEMINI:
|
||||
return getTools();
|
||||
return getTools(forCreator);
|
||||
case AiProvider.MISTRAL:
|
||||
return getMistralTools();
|
||||
return getMistralTools(forCreator);
|
||||
case AiProvider.OPENAI:
|
||||
return getOpenAITools();
|
||||
return getOpenAITools(forCreator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -3,3 +3,15 @@ export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_DIRECTORY_ENTRIES = 200;
|
||||
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
|
||||
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;
|
||||
@@ -4,12 +4,12 @@ import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("market-rates");
|
||||
|
||||
export const GET_FINANCIAL_MARKET_DATA = "get_financial_market_data";
|
||||
export const GET_FINANCIAL_MARKET_DATA_TOOL_NAME = "get_financial_market_data";
|
||||
|
||||
export const getFinancialMarketData = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: GET_FINANCIAL_MARKET_DATA,
|
||||
name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||
description:
|
||||
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
|
||||
parameters: {
|
||||
@@ -20,11 +20,11 @@ export const getFinancialMarketData = {
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const financialMarketDataToolPrompt = [
|
||||
export const getFinancialMarketDataToolPrompt = [
|
||||
"Currency rates tool rules:",
|
||||
`- Use \`${GET_FINANCIAL_MARKET_DATA}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
|
||||
`- Use \`${GET_FINANCIAL_MARKET_DATA}\` when the user asks whether a supported asset went up or down recently.`,
|
||||
`- Use \`${GET_FINANCIAL_MARKET_DATA}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`,
|
||||
`- 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 for the 24-hour change, percentage change, or movement direction for a supported pair.`,
|
||||
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
|
||||
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
|
||||
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
|
||||
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 {z} from "zod";
|
||||
|
||||
const logger = toolsLogger.child("notes");
|
||||
|
||||
@@ -338,3 +339,110 @@ async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export type NoteFileAttachment = {
|
||||
type: "local_file";
|
||||
fileName: string;
|
||||
// filePath: string;
|
||||
relativePath: string;
|
||||
mimeType: "text/markdown";
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type GetNoteFileResult =
|
||||
| {
|
||||
success: true;
|
||||
attachment: NoteFileAttachment;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const NoteFileAttachmentSchema = z.object({
|
||||
type: z.literal("local_file"),
|
||||
fileName: z.string(),
|
||||
// filePath: z.string(),
|
||||
relativePath: z.string(),
|
||||
mimeType: z.literal("text/markdown"),
|
||||
sizeBytes: z.number(),
|
||||
});
|
||||
|
||||
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
attachment: NoteFileAttachmentSchema,
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const sendNoteAsFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_note_as_file",
|
||||
description:
|
||||
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function sendNoteAsFile(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteFileResult> {
|
||||
logger.debug("start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, что файл существует и действительно читается.
|
||||
await readFile(noteFilePath, "utf-8");
|
||||
|
||||
const fileStat = await stat(noteFilePath);
|
||||
if (!fileStat.isFile()) {
|
||||
return {success: false, error: "Note path is not a file"};
|
||||
}
|
||||
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
const result: GetNoteFileResult = {
|
||||
success: true,
|
||||
attachment: {
|
||||
type: "local_file",
|
||||
fileName: normalizedFileName,
|
||||
// filePath: noteFilePath,
|
||||
relativePath,
|
||||
mimeType: "text/markdown",
|
||||
sizeBytes: fileStat.size,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug("done", {
|
||||
fileName: result.attachment.fileName,
|
||||
relativePath: result.attachment.relativePath,
|
||||
sizeBytes: result.attachment.sizeBytes
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
+112
-97
@@ -1,18 +1,22 @@
|
||||
import {Environment} from "../../common/environment";
|
||||
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 {shellExecute, shellExecuteTool} from "./shell";
|
||||
import {ToolHandler} from "./types";
|
||||
import {getWeather, getWeatherTool} from "./weather";
|
||||
import {
|
||||
financialMarketDataToolPrompt,
|
||||
GET_FINANCIAL_MARKET_DATA,
|
||||
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||
getFinancialMarketData,
|
||||
getFinancialMarketDataToolPrompt,
|
||||
getMarketRates
|
||||
} from "./market-rates";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
||||
import {
|
||||
beginFileWrite,
|
||||
beginFileWriteTool,
|
||||
cancelFileWrite,
|
||||
cancelFileWriteTool,
|
||||
copyPath,
|
||||
copyPathTool,
|
||||
createDirectory,
|
||||
@@ -21,52 +25,71 @@ import {
|
||||
createFileTool,
|
||||
deletePath,
|
||||
deletePathTool,
|
||||
editFilePatch,
|
||||
editFilePatchTool,
|
||||
fileToolsToolPrompt,
|
||||
finishFileWrite,
|
||||
finishFileWriteTool,
|
||||
listDirectory,
|
||||
listDirectoryTool,
|
||||
readFile,
|
||||
readFileTool,
|
||||
renamePath,
|
||||
renamePathTool,
|
||||
searchFiles,
|
||||
searchFilesTool,
|
||||
sendFileAsAttachment,
|
||||
sendFileAsAttachmentTool,
|
||||
updateFile,
|
||||
updateFileTool
|
||||
} from "./file-system";
|
||||
import {createNote, createNoteTool} from "./create-note";
|
||||
import {
|
||||
deleteNote,
|
||||
deleteNoteTool,
|
||||
getNoteContent,
|
||||
getNoteContentTool,
|
||||
listNotes,
|
||||
listNotesTool,
|
||||
updateNoteContent,
|
||||
updateNoteContentTool
|
||||
} from "./list-notes";
|
||||
import {sendNoteAsFileTool, sendNoteAsFile} from "./send-note-as-file";
|
||||
import {searchNotes, searchNotesTool} from "./search-notes";
|
||||
updateFileTool,
|
||||
writeFileChunk,
|
||||
writeFileChunkTool
|
||||
} from "./files";
|
||||
|
||||
export const getTools = () => {
|
||||
const tools: AiTool[] = [
|
||||
export const defaultTools: AiTool[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getFinancialMarketData,
|
||||
createNoteTool,
|
||||
listNotesTool,
|
||||
getNoteContentTool,
|
||||
updateNoteContentTool,
|
||||
deleteNoteTool,
|
||||
sendNoteAsFileTool,
|
||||
searchNotesTool
|
||||
]
|
||||
|
||||
export const fileTools = [
|
||||
readFileTool,
|
||||
listDirectoryTool,
|
||||
searchFilesTool,
|
||||
|
||||
createFileTool,
|
||||
beginFileWriteTool,
|
||||
writeFileChunkTool,
|
||||
finishFileWriteTool,
|
||||
cancelFileWriteTool,
|
||||
|
||||
sendFileAsAttachmentTool,
|
||||
|
||||
createDirectoryTool,
|
||||
copyPathTool,
|
||||
updateFileTool,
|
||||
editFilePatchTool,
|
||||
renamePathTool,
|
||||
deletePathTool,
|
||||
] satisfies AiTool[];
|
||||
|
||||
// export const notesFileTools: AiTool[] = [
|
||||
// createNoteTool,
|
||||
// listNotesTool,
|
||||
// getNoteContentTool,
|
||||
// updateNoteContentTool,
|
||||
// deleteNoteTool,
|
||||
// sendNoteAsFileTool,
|
||||
// searchNotesTool
|
||||
// ]
|
||||
|
||||
export const getTools = (forCreator?: boolean) => {
|
||||
const tools: AiTool[] = [
|
||||
...defaultTools,
|
||||
// ...notesFileTools
|
||||
];
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
tools.push(pythonInterpreterTool);
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
tools.push(shellExecuteTool);
|
||||
}
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
tools.push(braveSearchTool);
|
||||
tools.push(webSearchTool);
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
@@ -74,85 +97,68 @@ export const getTools = () => {
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
tools.push(
|
||||
readFileTool,
|
||||
listDirectoryTool,
|
||||
createFileTool,
|
||||
createDirectoryTool,
|
||||
updateFileTool,
|
||||
renamePathTool,
|
||||
copyPathTool,
|
||||
deletePathTool,
|
||||
);
|
||||
tools.push(...fileTools);
|
||||
}
|
||||
|
||||
if (forCreator) {
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
tools.push(pythonInterpreterTool);
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
tools.push(shellExecuteTool);
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
// return [
|
||||
// createNoteTool,
|
||||
// listNotesTool,
|
||||
// getNoteContentTool,
|
||||
// updateNoteContentTool,
|
||||
// deleteNoteTool,
|
||||
// getNoteFileTool,
|
||||
// searchNotesTool
|
||||
// ];
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
get_financial_market_data: getMarketRates,
|
||||
create_note: createNote,
|
||||
list_notes: listNotes,
|
||||
get_note_content: getNoteContent,
|
||||
update_note_content: updateNoteContent,
|
||||
delete_note: deleteNote,
|
||||
send_note_as_file: sendNoteAsFile,
|
||||
search_notes: searchNotes
|
||||
};
|
||||
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
handlers = {
|
||||
// create_note: createNote,
|
||||
// list_notes: listNotes,
|
||||
// get_note_content: getNoteContent,
|
||||
// update_note_content: updateNoteContent,
|
||||
// delete_note: deleteNote,
|
||||
// send_note_as_file: sendNoteAsFile,
|
||||
// search_notes: searchNotes,
|
||||
|
||||
...fileToolHandlers,
|
||||
|
||||
|
||||
python_interpreter: runPythonInterpreter,
|
||||
...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;
|
||||
};
|
||||
@@ -161,9 +167,18 @@ export function getToolPrompts(toolNames: string[]): string[] {
|
||||
const prompts: string[] = [];
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
if (!prompts.includes(fileToolsToolPrompt) &&
|
||||
fileTools.map(t => t.function.name).includes(toolName)) {
|
||||
prompts.push(fileToolsToolPrompt);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case GET_FINANCIAL_MARKET_DATA:
|
||||
prompts.push(financialMarketDataToolPrompt);
|
||||
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
||||
prompts.push(getFinancialMarketDataToolPrompt);
|
||||
break;
|
||||
case WEB_SEARCH_TOOL_NAME:
|
||||
prompts.push(webSearchToolPrompt);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -15,6 +15,7 @@ function stringifyToolResult(result: unknown): string {
|
||||
}
|
||||
|
||||
export async function executeToolCall(
|
||||
userId: number | undefined | null,
|
||||
name: string,
|
||||
args?: unknown,
|
||||
context: ToolRuntimeContext = {},
|
||||
@@ -31,7 +32,7 @@ export async function executeToolCall(
|
||||
|
||||
try {
|
||||
if (name === PYTHON_INTERPRETER_TOOL_NAME) {
|
||||
const result = await runPythonInterpreter(normalizeToolArguments(args), {
|
||||
const result = await runPythonInterpreter(normalizeToolArguments(args, userId), {
|
||||
executionTimeoutMs: 8_000,
|
||||
syntaxTimeoutMs: 3_000,
|
||||
maxCodeChars: 100_000,
|
||||
@@ -45,7 +46,8 @@ export async function executeToolCall(
|
||||
return s;
|
||||
}
|
||||
|
||||
const result = await handler(normalizeToolArguments(args));
|
||||
const arguments1 = normalizeToolArguments(args, userId);
|
||||
const result = await handler(arguments1);
|
||||
const s = stringifyToolResult(result);
|
||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||
return s;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import path from "node:path";
|
||||
import {readFile, stat} from "node:fs/promises";
|
||||
import {notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {buildSafeNoteFilePath} from "./list-notes";
|
||||
import z from "zod";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
|
||||
const logger = toolsLogger.child("get-note-file");
|
||||
|
||||
export type NoteFileAttachment = {
|
||||
type: "local_file";
|
||||
fileName: string;
|
||||
// filePath: string;
|
||||
relativePath: string;
|
||||
mimeType: "text/markdown";
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type GetNoteFileResult =
|
||||
| {
|
||||
success: true;
|
||||
attachment: NoteFileAttachment;
|
||||
} | { success: false; error: string };
|
||||
|
||||
export const NoteFileAttachmentSchema = z.object({
|
||||
type: z.literal("local_file"),
|
||||
fileName: z.string(),
|
||||
// filePath: z.string(),
|
||||
relativePath: z.string(),
|
||||
mimeType: z.literal("text/markdown"),
|
||||
sizeBytes: z.number(),
|
||||
});
|
||||
|
||||
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
attachment: NoteFileAttachmentSchema,
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const sendNoteAsFileTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_note_as_file",
|
||||
description:
|
||||
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
|
||||
},
|
||||
},
|
||||
required: ["fileName"],
|
||||
},
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export async function sendNoteAsFile(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<GetNoteFileResult> {
|
||||
logger.debug("start", {args});
|
||||
|
||||
const fileName = asNonEmptyString(args?.fileName) ?? "";
|
||||
if (!fileName.trim().length) {
|
||||
return {success: false, error: "No file name provided"};
|
||||
}
|
||||
|
||||
const noteFilePath = buildSafeNoteFilePath(fileName);
|
||||
if (!noteFilePath) {
|
||||
return {success: false, error: "Invalid or unsafe file name provided"};
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, что файл существует и действительно читается.
|
||||
await readFile(noteFilePath, "utf-8");
|
||||
|
||||
const fileStat = await stat(noteFilePath);
|
||||
if (!fileStat.isFile()) {
|
||||
return {success: false, error: "Note path is not a file"};
|
||||
}
|
||||
|
||||
const normalizedFileName = path.basename(noteFilePath);
|
||||
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
|
||||
|
||||
const result: GetNoteFileResult = {
|
||||
success: true,
|
||||
attachment: {
|
||||
type: "local_file",
|
||||
fileName: normalizedFileName,
|
||||
// filePath: noteFilePath,
|
||||
relativePath,
|
||||
mimeType: "text/markdown",
|
||||
sizeBytes: fileStat.size,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug("done", {fileName: result.attachment.fileName, relativePath: result.attachment.relativePath, sizeBytes: result.attachment.sizeBytes});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function asNonEmptyString(value: unknown): string | 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 (typeof args === "string") {
|
||||
@@ -29,7 +29,11 @@ export function normalizeToolArguments(args: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
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 {};
|
||||
|
||||
@@ -92,7 +92,7 @@ type BraveSearchApiResponse = {
|
||||
|
||||
export const WEB_SEARCH_TOOL_NAME = "web_search";
|
||||
|
||||
export const braveSearchTool = {
|
||||
export const webSearchTool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: WEB_SEARCH_TOOL_NAME,
|
||||
@@ -163,7 +163,7 @@ export const braveSearchTool = {
|
||||
},
|
||||
} satisfies AiTool;
|
||||
|
||||
export const braveSearchToolPrompt = [
|
||||
export const webSearchToolPrompt = [
|
||||
"Brave Search tool rules:",
|
||||
"- 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.",
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ToolCallData,
|
||||
ToolExecutionMemory
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
|
||||
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
|
||||
if (typeof response.text === "string") return response.text;
|
||||
@@ -82,6 +83,7 @@ function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[],
|
||||
}
|
||||
|
||||
export async function runGemini(
|
||||
msg: Message,
|
||||
messages: GeminiMessage[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
@@ -143,7 +145,7 @@ export async function runGemini(
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -166,7 +168,7 @@ export async function runGemini(
|
||||
calls: calls.map(aiLogToolCall),
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Mistral provider runner extracted from unified-ai-runner.ts.
|
||||
import {Environment} from "../common/environment";
|
||||
import {getMistralTools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
@@ -7,9 +6,24 @@ import {MistralChatMessage} from "./mistral-chat-message";
|
||||
import {createMistralClient} from "./ai-runtime-target";
|
||||
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(
|
||||
msg: Message,
|
||||
messages: MistralChatMessage[],
|
||||
documents: MistralDocumentReference[],
|
||||
streamMessage: TelegramStreamMessage,
|
||||
@@ -43,14 +57,14 @@ export async function runMistral(
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages,
|
||||
tools: getMistralTools(),
|
||||
tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID),
|
||||
documents: documents
|
||||
} as unknown as Parameters<typeof mistralAi.chat.complete>[0];
|
||||
const response = await mistralAi.chat.complete(request, {signal});
|
||||
const msg = response.choices?.[0]?.message;
|
||||
const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? "");
|
||||
const message = response.choices?.[0]?.message;
|
||||
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
|
||||
streamMessage.append(text);
|
||||
const calls = normalizeMistralToolCalls(mistralToolCalls(msg));
|
||||
const calls = normalizeMistralToolCalls(mistralToolCalls(message));
|
||||
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
|
||||
round,
|
||||
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
|
||||
@@ -66,7 +80,7 @@ export async function runMistral(
|
||||
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()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
@@ -81,7 +95,7 @@ export async function runMistral(
|
||||
const request = {
|
||||
model: config.mistralChatTarget.model,
|
||||
messages,
|
||||
tools: getMistralTools(),
|
||||
tools: getMistralTools(msg.from?.id === Environment.CREATOR_ID),
|
||||
documents: documents
|
||||
} as unknown as Parameters<typeof mistralAi.chat.stream>[0];
|
||||
const streamResponse = await mistralAi.chat.stream(request, {signal});
|
||||
@@ -119,7 +133,7 @@ export async function runMistral(
|
||||
content: roundText,
|
||||
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()) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
@@ -130,8 +144,3 @@ export async function runMistral(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class MistralProviderRunner {
|
||||
static run = runMistral;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Ollama provider runner extracted from unified-ai-runner.ts.
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Environment} from "../common/environment";
|
||||
@@ -11,12 +10,8 @@ import {ChatMessage} from "./chat-messages-types";
|
||||
import {ChatRequest, Tool} from "ollama";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
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 {createOllamaClient} from "./ai-runtime-target";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema, sendNoteAsFileTool} from "./tools/send-note-as-file";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||
|
||||
import {
|
||||
@@ -41,9 +36,10 @@ import {
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {latestUserTextFromOllamaMessages, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
|
||||
import {getToolPrompts} from "./tools/registry";
|
||||
import {createNoteTool} from "./tools/create-note";
|
||||
import {deleteNoteTool, getNoteContentTool, listNotesTool, updateNoteContentTool} from "./tools/list-notes";
|
||||
import {searchNotesTool} from "./tools/search-notes";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
|
||||
import {getModelCapabilities} from "./provider-model-runtime";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
|
||||
export async function runOllama(
|
||||
msg: Message,
|
||||
@@ -57,7 +53,6 @@ export async function runOllama(
|
||||
toolContext: ToolRuntimeContext,
|
||||
contextSize?: number,
|
||||
): Promise<void> {
|
||||
const fromId = msg.from?.id;
|
||||
const runnerStartedAt = Date.now();
|
||||
|
||||
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
|
||||
@@ -185,20 +180,8 @@ export async function runOllama(
|
||||
};
|
||||
|
||||
let activeToolNames: string[] = [];
|
||||
// if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||
const availableOllamaTools: Tool[] =
|
||||
fromId !== Environment.CREATOR_ID ? [
|
||||
getCurrentDateTimeTool,
|
||||
getFinancialMarketData,
|
||||
getWeatherTool,
|
||||
createNoteTool,
|
||||
listNotesTool,
|
||||
getNoteContentTool,
|
||||
updateNoteContentTool,
|
||||
deleteNoteTool,
|
||||
sendNoteAsFileTool,
|
||||
searchNotesTool
|
||||
] : getOllamaTools() as Tool[];
|
||||
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
|
||||
const availableOllamaTools: Tool[] = getOllamaTools(msg.from?.id === Environment.CREATOR_ID) as Tool[];
|
||||
|
||||
aiLog("debug", "ollama.tools.available", {
|
||||
round,
|
||||
@@ -213,9 +196,10 @@ export async function runOllama(
|
||||
signal,
|
||||
});
|
||||
|
||||
activeToolNames = rankerSelection.tools.map(t => t.function.name ?? "");
|
||||
if (rankerSelection.tools.length > 0) {
|
||||
request.tools = [...rankerSelection.tools, ...rankerSelection.tools];
|
||||
const filteredTools = [...new Set(rankerSelection.tools)];
|
||||
activeToolNames = filteredTools.map(t => t.function.name ?? "");
|
||||
if (filteredTools.length > 0) {
|
||||
request.tools = [...filteredTools];
|
||||
request.options = {
|
||||
...request.options,
|
||||
temperature: 0
|
||||
@@ -245,7 +229,7 @@ export async function runOllama(
|
||||
count: activeToolNames.length,
|
||||
usedRanker: rankerSelection.usedRanker,
|
||||
});
|
||||
// }
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
const response = await ollama.chat({
|
||||
@@ -309,7 +293,7 @@ export async function runOllama(
|
||||
appendOllamaToolResults(
|
||||
messages,
|
||||
calls,
|
||||
await executeToolBatch(calls, streamMessage, toolContext, toolMemory),
|
||||
await executeToolBatch(msg.from?.id, calls, streamMessage, toolContext, toolMemory),
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -418,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;
|
||||
|
||||
|
||||
+305
-290
@@ -1,7 +1,6 @@
|
||||
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
|
||||
import {Message} from "typescript-telegram-bot-api";
|
||||
import {Environment} from "../common/environment";
|
||||
import {getOpenAITools} from "./tool-mappers";
|
||||
import {TelegramStreamMessage} from "./telegram-stream-message";
|
||||
import {ToolRuntimeContext} from "./tools/runtime";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
@@ -11,45 +10,35 @@ import type {
|
||||
ResponseInputItem,
|
||||
ResponseStreamEvent
|
||||
} from "openai/resources/responses/responses";
|
||||
import type {
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionCreateParamsStreaming
|
||||
} from "openai/resources/chat/completions";
|
||||
import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
|
||||
import {createOpenAiClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||
|
||||
import {
|
||||
AsyncIterableStream,
|
||||
collectOpenAiResponseFunctionCalls,
|
||||
buildSystemInstruction,
|
||||
collectOpenAiResponseCodeInterpreterCalls,
|
||||
collectOpenAiResponseFunctionCalls,
|
||||
collectOpenAiResponseImages,
|
||||
collectOpenAiResponseText,
|
||||
executeToolBatch,
|
||||
getOpenAIResponsesToolsWithImage,
|
||||
isRecord,
|
||||
MAX_TOOL_ROUNDS,
|
||||
OPENAI_IMAGE_PARTIALS,
|
||||
OpenAiChatCompletionResponseLike,
|
||||
OpenAiChatCompletionStreamChunkLike,
|
||||
OpenAiChatToolCallLike,
|
||||
OpenAiCompatibleChatMessage,
|
||||
OpenAiCompatibleContentPart,
|
||||
openAiResponseItemCallId,
|
||||
OpenAiResponseLike,
|
||||
OpenAiResponseOutputItem,
|
||||
roundStatus,
|
||||
RuntimeConfigSnapshot,
|
||||
safeJsonParseObject,
|
||||
showOpenAiGeneratedImage,
|
||||
StreamingToolCallAccumulator,
|
||||
ToolCallData,
|
||||
ToolExecutionMemory
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-as-file";
|
||||
import {bot, notesDir} from "../index";
|
||||
import {bot, filesDir} from "../index";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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(
|
||||
msg: Message,
|
||||
@@ -57,19 +46,16 @@ export async function runOpenAi(
|
||||
streamMessage: TelegramStreamMessage,
|
||||
signal: AbortSignal,
|
||||
stream: boolean,
|
||||
firstRoundStatus: string,
|
||||
sourceMessage: Message,
|
||||
config: RuntimeConfigSnapshot,
|
||||
toolContext: ToolRuntimeContext,
|
||||
think?: boolean
|
||||
): Promise<void> {
|
||||
// TODO: 13.05.2026: remove
|
||||
firstRoundStatus;
|
||||
think;
|
||||
const runnerStartedAt = Date.now();
|
||||
let responseInput: unknown[] = [...messages];
|
||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||
|
||||
const systemPrompt = buildSystemInstruction(config, DEFAULT_AI_RESPONSE_LANGUAGE, false);
|
||||
|
||||
aiLog("info", "openai.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.openAiChatTarget),
|
||||
@@ -89,8 +75,8 @@ export async function runOpenAi(
|
||||
const request: ResponseCreateParamsNonStreaming = {
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"],
|
||||
instructions: config.systemPrompt,
|
||||
tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsNonStreaming["tools"],
|
||||
instructions: systemPrompt,
|
||||
};
|
||||
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
|
||||
|
||||
@@ -146,38 +132,26 @@ export async function runOpenAi(
|
||||
name: call.name,
|
||||
argumentsText: call.argumentsText,
|
||||
}));
|
||||
const toolResults = await executeToolBatch(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 toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
|
||||
const toolOutputs = calls.map((call, index) => ({
|
||||
type: "function_call_output" as const,
|
||||
call_id: call.callId,
|
||||
output: toolResults[index] ?? "",
|
||||
}));
|
||||
|
||||
const 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];
|
||||
continue;
|
||||
}
|
||||
@@ -187,8 +161,9 @@ export async function runOpenAi(
|
||||
model: config.openAiChatTarget.model,
|
||||
input: responseInput as ResponseInputItem[],
|
||||
stream: true,
|
||||
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
|
||||
parallel_tool_calls: true
|
||||
tools: getOpenAIResponsesToolsWithImage(config, msg.from?.id === Environment.CREATOR_ID) as ResponseCreateParamsStreaming["tools"],
|
||||
parallel_tool_calls: true,
|
||||
instructions: systemPrompt
|
||||
};
|
||||
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
|
||||
|
||||
@@ -338,273 +313,313 @@ export async function runOpenAi(
|
||||
name: call.name,
|
||||
argumentsText: call.argumentsText,
|
||||
}));
|
||||
const toolResults = await executeToolBatch(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 toolResults = await executeToolBatch(msg.from?.id, toolCalls, streamMessage, toolContext, toolMemory);
|
||||
const toolOutputs = calls.map((call, index) => ({
|
||||
type: "function_call_output",
|
||||
call_id: call.callId,
|
||||
output: toolResults[index] ?? "",
|
||||
}));
|
||||
|
||||
const 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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
async function tryToUploadFiles(
|
||||
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();
|
||||
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;
|
||||
|
||||
aiLog("info", "openai_compatible.run.start", {
|
||||
stream,
|
||||
target: aiLogProviderTarget(config.geminiChatTarget),
|
||||
inputMessages: messages.length,
|
||||
chatMessages: chatMessages.length,
|
||||
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
|
||||
});
|
||||
let found = false;
|
||||
|
||||
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 {
|
||||
for (const toolResult of toolResults) {
|
||||
const raw = JSON.parse(toolResult);
|
||||
const res = GetNoteFileResultSchema.safeParse(raw);
|
||||
const res = SendFileAttachmentResultSchema.safeParse(raw);
|
||||
|
||||
if (res.success && res.data.success) {
|
||||
successGetNoteFileResult = res.data;
|
||||
if (res.success) {
|
||||
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) {
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
document: fs.createReadStream(path.join(filesDir, String(msg.from?.id), sendFileAttachment?.result?.attachment?.relativePath ?? "")),
|
||||
})
|
||||
|
||||
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
|
||||
return {found: true, uploaded: true};
|
||||
} catch (e: unknown) {
|
||||
logError(e);
|
||||
return {
|
||||
found: found,
|
||||
uploaded: false,
|
||||
error: (e as any)?.message ?? "",
|
||||
toolIndex: sendFileAttachment?.toolIndex ?? -1
|
||||
};
|
||||
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;
|
||||
// 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("");
|
||||
// }
|
||||
|
||||
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,
|
||||
},
|
||||
})),
|
||||
});
|
||||
// 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};
|
||||
// });
|
||||
// }
|
||||
|
||||
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
|
||||
// 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);
|
||||
// }
|
||||
|
||||
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
|
||||
// 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] ?? "",
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -1269,6 +1269,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
|
||||
}
|
||||
|
||||
export async function executeTool(
|
||||
userId: number | undefined | null,
|
||||
toolCall: ToolCallData,
|
||||
message: TelegramStreamMessage,
|
||||
context: ToolRuntimeContext,
|
||||
@@ -1298,7 +1299,7 @@ export async function executeTool(
|
||||
}
|
||||
|
||||
try {
|
||||
const rawResult = await executeToolCall(toolCall.name, parsedArgs.args, context);
|
||||
const rawResult = await executeToolCall(userId, toolCall.name, parsedArgs.args, context);
|
||||
const result = stringifyToolExecutionResult(rawResult);
|
||||
|
||||
await sendToolArtifacts(toolCall, result, message);
|
||||
@@ -1367,16 +1368,18 @@ export async function runWithToolLocks<T>(keys: string[], task: () => Promise<T>
|
||||
}
|
||||
|
||||
export async function executeScheduledTool(
|
||||
userId: number | undefined | null,
|
||||
toolCall: ToolCallData,
|
||||
message: TelegramStreamMessage,
|
||||
context: ToolRuntimeContext,
|
||||
): Promise<string> {
|
||||
const keys = toolResourceKeys(toolCall);
|
||||
if (!keys.length) return executeTool(toolCall, message, context);
|
||||
return runWithToolLocks(keys, () => executeTool(toolCall, message, context));
|
||||
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
||||
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
|
||||
}
|
||||
|
||||
export async function executeToolBatch(
|
||||
userId: number | undefined | null,
|
||||
toolCalls: ToolCallData[],
|
||||
message: TelegramStreamMessage,
|
||||
context: ToolRuntimeContext,
|
||||
@@ -1417,7 +1420,7 @@ export async function executeToolBatch(
|
||||
message.setStatus(Environment.getUseToolText(statusCalls));
|
||||
await message.flush();
|
||||
|
||||
const resultText = await executeScheduledTool(toolCall, message, context);
|
||||
const resultText = await executeScheduledTool(userId, toolCall, message, context);
|
||||
|
||||
memory.set(signature, {
|
||||
count: (previous?.count ?? 0) + 1,
|
||||
@@ -1626,9 +1629,9 @@ export function allToolSchemaNames(tools: readonly unknown[]): string[] {
|
||||
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 [
|
||||
...getOpenAIResponsesTools(),
|
||||
...getOpenAIResponsesTools(forCreator),
|
||||
getOpenAICodeInterpreterTool(),
|
||||
{
|
||||
type: "image_generation",
|
||||
|
||||
+26
-12
@@ -19,15 +19,13 @@ import {isTranscribableAudioDownload} from "./speech-to-text";
|
||||
import {OpenAIChatMessage} from "./openai-chat-message";
|
||||
import {MistralChatMessage} from "./mistral-chat-message";
|
||||
import {OllamaChatMessage} from "./ollama-chat-message";
|
||||
import {GeminiMessage} from "./gemini-chat-message";
|
||||
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 {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai";
|
||||
import {runOpenAi} from "./unified-ai-runner.openai";
|
||||
import {runOllama} from "./unified-ai-runner.ollama";
|
||||
import {runMistral} from "./unified-ai-runner.mistral";
|
||||
import {runGemini} from "./unified-ai-runner.gemini";
|
||||
import {
|
||||
AI_REQUEST_TIMEOUT_MS,
|
||||
appendTranscriptToChatMessages,
|
||||
@@ -35,6 +33,7 @@ import {
|
||||
collectRequestedAttachmentKinds,
|
||||
collectTextMessages,
|
||||
deleteMistralLibrary,
|
||||
GeminiMessage,
|
||||
hasAudioAttachmentKind,
|
||||
initialStatus,
|
||||
isAbortError,
|
||||
@@ -51,6 +50,8 @@ import {
|
||||
transcribeAudioIfNeeded,
|
||||
UnifiedRunOptions
|
||||
} 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 {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
|
||||
@@ -165,8 +166,7 @@ async function executeUnifiedAiRequest(
|
||||
|
||||
switch (options.provider) {
|
||||
case AiProvider.OPENAI:
|
||||
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext,
|
||||
!!options.think);
|
||||
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, options.msg, config, toolContext);
|
||||
break;
|
||||
case AiProvider.OLLAMA:
|
||||
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);
|
||||
break;
|
||||
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;
|
||||
case AiProvider.GEMINI:
|
||||
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
|
||||
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);
|
||||
}
|
||||
await runGemini(options.msg, chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
|
||||
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> {
|
||||
const startedAt = Date.now();
|
||||
const config = snapshotRuntimeConfig();
|
||||
|
||||
+8
-2
@@ -190,6 +190,7 @@ export const videoDir = path.join(cacheDir, "video");
|
||||
export const videoNotesDir = path.join(cacheDir, "video-note");
|
||||
export const videoTempDir = path.join(videoDir, "temp");
|
||||
|
||||
export const filesDir = path.join(Environment.DATA_PATH, "files");
|
||||
|
||||
export const NOTES_HEADER = "## Notes\n";
|
||||
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
||||
@@ -224,7 +225,7 @@ async function main() {
|
||||
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 => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
@@ -282,7 +283,12 @@ async function main() {
|
||||
|
||||
const end = Date.now();
|
||||
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) {
|
||||
logError(error);
|
||||
}
|
||||
|
||||
+11
-2
@@ -2122,7 +2122,12 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
|
||||
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];
|
||||
locale = await resolveInterfaceLocaleForUser(from.id, from.language_code);
|
||||
@@ -2202,7 +2207,11 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
|
||||
|| !!msg.video_note;
|
||||
const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user