This commit is contained in:
2026-05-14 20:55:48 +03:00
parent 78932e82af
commit 067bbd0708
25 changed files with 496 additions and 392 deletions
+3 -17
View File
@@ -28,8 +28,8 @@
"@types/fluent-ffmpeg": "^2.1.28", "@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.6.1", "@types/node": "^25.6.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@typescript/native-preview": "^7.0.0-beta",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"typescript": "^6.0.3",
}, },
}, },
}, },
@@ -236,22 +236,6 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260421.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2", "", { "os": "linux", "cpu": "x64" }, "sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2", "", { "os": "win32", "cpu": "x64" }, "sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -498,6 +482,8 @@
"twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="], "twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="], "typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
+1
View File
@@ -128,6 +128,7 @@
"getImageGenDoneText.default": "👨‍🎨 Image generated.", "getImageGenDoneText.default": "👨‍🎨 Image generated.",
"getErrorText.withReason": "{errorText} Reason:\n{reason}", "getErrorText.withReason": "{errorText} Reason:\n{reason}",
"getUseToolText.python": "👨‍💻 Running `Python`", "getUseToolText.python": "👨‍💻 Running `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Running `Code Interpreter`",
"getUseToolText.default": "🔧 Using tool `{name}`", "getUseToolText.default": "🔧 Using tool `{name}`",
"getAnalyzingDocumentText.default": "🔍 Analyzing the document...", "getAnalyzingDocumentText.default": "🔍 Analyzing the document...",
"getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`", "getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`",
+2 -1
View File
@@ -8,7 +8,7 @@
}, },
"providerChoice.default": "По умолчанию", "providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.", "errorText": "⚠️ Произошла ошибка.",
"waitThinkText": "⏳ Думаю...", "waitThinkText": "⏳ Дайте-ка подумать...",
"analyzingPictureText": "🔍 Анализирую изображение...", "analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...", "analyzingPicturesText": "🔍 Анализирую изображения...",
"reasoningText": "🤔 Рассуждаю...", "reasoningText": "🤔 Рассуждаю...",
@@ -154,6 +154,7 @@
"getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.", "getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}", "getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`", "getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`",
"getUseToolText.default": "🔧 Использую инструмент `{name}`", "getUseToolText.default": "🔧 Использую инструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Анализирую документ...", "getAnalyzingDocumentText.default": "🔍 Анализирую документ...",
"getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`", "getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`",
+2 -1
View File
@@ -8,7 +8,7 @@
}, },
"providerChoice.default": "За замовчуванням", "providerChoice.default": "За замовчуванням",
"errorText": "⚠️ Сталася помилка.", "errorText": "⚠️ Сталася помилка.",
"waitThinkText": "⏳ Думаю...", "waitThinkText": "⏳ Дайте-но подумати...",
"analyzingPictureText": "🔍 Аналізую зображення...", "analyzingPictureText": "🔍 Аналізую зображення...",
"analyzingPicturesText": "🔍 Аналізую зображення...", "analyzingPicturesText": "🔍 Аналізую зображення...",
"reasoningText": "🤔 Міркую...", "reasoningText": "🤔 Міркую...",
@@ -153,6 +153,7 @@
"getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.", "getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}", "getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`", "getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`",
"getUseToolText.default": "🔧 Використовую інструмент `{name}`", "getUseToolText.default": "🔧 Використовую інструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Аналізую документ...", "getAnalyzingDocumentText.default": "🔍 Аналізую документ...",
"getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`", "getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`",
+16 -119
View File
@@ -31,8 +31,8 @@
"@types/fluent-ffmpeg": "^2.1.28", "@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.6.1", "@types/node": "^25.6.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@typescript/native-preview": "^7.0.0-beta", "drizzle-kit": "^0.31.10",
"drizzle-kit": "^0.31.10" "typescript": "^6.0.3"
} }
}, },
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
@@ -2025,123 +2025,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20260421.2",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260421.2.tgz",
"integrity": "sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4007,6 +3890,20 @@
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==", "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-telegram-bot-api": { "node_modules/typescript-telegram-bot-api": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/typescript-telegram-bot-api/-/typescript-telegram-bot-api-0.16.0.tgz", "resolved": "https://registry.npmjs.org/typescript-telegram-bot-api/-/typescript-telegram-bot-api-0.16.0.tgz",
+1 -1
View File
@@ -33,6 +33,6 @@
"@types/node": "^25.6.1", "@types/node": "^25.6.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"@typescript/native-preview": "^7.0.0-beta" "typescript": "^6.0.3"
} }
} }
+1
View File
@@ -28,6 +28,7 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
thinking: ["THINKING", "THINK"], thinking: ["THINKING", "THINK"],
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"], extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
tools: ["TOOLS", "CHAT"], tools: ["TOOLS", "CHAT"],
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
audio: ["AUDIO"], audio: ["AUDIO"],
documents: ["DOCUMENTS", "RAG", "EMBEDDING"], documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
outputImages: ["OUTPUT_IMAGES", "IMAGE"], outputImages: ["OUTPUT_IMAGES", "IMAGE"],
+6
View File
@@ -111,6 +111,12 @@ function isOpenAiReasoningModel(model: string): boolean {
return /^o\d/.test(name) || name.startsWith("gpt-5"); return /^o\d/.test(name) || name.startsWith("gpt-5");
} }
// TODO: OpenAI image input rollout
// 1. Keep OpenAI chat payload building `input_image` parts from `MessagePart.imageParts`.
// 2. Replace name-only vision detection with a dedicated OpenAI vision-capability helper.
// 3. Use allowlist/denylist heuristics for `vision` and `ocr`, with a probe/cache fallback later if needed.
// 4. Gate `rejectUnsupportedAttachments()` on the resolved vision capability, not on `OPENAI_IMAGE_MODEL`.
// 5. Add tests for supported/unsupported model names and the resulting `input_image` payload shape.
function isOpenAiVisionModel(model: string): boolean { function isOpenAiVisionModel(model: string): boolean {
const name = lowerModelName(model); const name = lowerModelName(model);
if (!isOpenAiTextModel(model)) return false; if (!isOpenAiTextModel(model)) return false;
+31 -2
View File
@@ -1,6 +1,8 @@
import {AiTool} from "./tool-types"; import {AiTool} from "./tool-types";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {getTools} from "./tools/registry"; import {getTools} from "./tools/registry";
import {WEB_SEARCH_TOOL_NAME} from "./tools/brave-search";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral"; export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
@@ -8,8 +10,17 @@ export function getOllamaTools(): AiTool[] {
return getTools(); return getTools();
} }
const openAiForbiddenTools = [
WEB_SEARCH_TOOL_NAME,
PYTHON_INTERPRETER_TOOL_NAME
]
function allowedOpenAiTool(tool: AiTool): boolean {
return !openAiForbiddenTools.includes(tool.function.name)
}
export function getOpenAITools(): AiTool[] { export function getOpenAITools(): AiTool[] {
return getTools().map(tool => ({ return getTools().filter(allowedOpenAiTool).map(tool => ({
type: "function", type: "function",
function: tool.function, function: tool.function,
})); }));
@@ -23,8 +34,17 @@ export type OpenAiResponseTool = {
strict: false; strict: false;
}; };
export type OpenAiCodeInterpreterTool = {
type: "code_interpreter";
container: {
type: "auto";
file_ids?: string[];
memory_limit?: "1g" | "4g" | "16g" | "64g" | null;
} | string;
};
export function getOpenAIResponsesTools(): OpenAiResponseTool[] { export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
return getTools().map(tool => ({ return getTools().filter(allowedOpenAiTool).map(tool => ({
type: "function", type: "function",
name: tool.function.name, name: tool.function.name,
description: tool.function.description, description: tool.function.description,
@@ -33,6 +53,15 @@ export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
})); }));
} }
export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool {
return {
type: "code_interpreter",
container: {
type: "auto",
},
};
}
export function getMistralTools(): AiTool[] { export function getMistralTools(): AiTool[] {
return getTools().map(tool => ({ return getTools().map(tool => ({
type: "function", type: "function",
+3 -1
View File
@@ -90,10 +90,12 @@ type BraveSearchApiResponse = {
summarizer?: unknown; summarizer?: unknown;
}; };
export const WEB_SEARCH_TOOL_NAME = "web_search";
export const braveSearchTool = { export const braveSearchTool = {
type: "function", type: "function",
function: { function: {
name: "web_search", name: WEB_SEARCH_TOOL_NAME,
description: description:
"Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.", "Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.",
parameters: { parameters: {
+27 -5
View File
@@ -70,7 +70,7 @@ export async function listNotes(): Promise<ListNotesResult> {
const markdownFiles = entries const markdownFiles = entries
.filter((entry) => entry.isFile()) .filter((entry) => entry.isFile())
.map((entry) => entry.name) .map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md")); .filter((fileName) => fileName.endsWith(".md") && !fileName.startsWith("index"));
const notes: NoteListItem[] = await Promise.all( const notes: NoteListItem[] = await Promise.all(
markdownFiles.map(async (fileName) => { markdownFiles.map(async (fileName) => {
@@ -115,6 +115,10 @@ export async function getNoteContent(
return {success: false, error: "No file name provided"}; return {success: false, error: "No file name provided"};
} }
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to access `index.md`"};
}
const noteFilePath = buildSafeNoteFilePath(fileName); const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) { if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"}; return {success: false, error: "Invalid or unsafe file name provided"};
@@ -125,7 +129,12 @@ export async function getNoteContent(
const normalizedFileName = path.basename(noteFilePath); const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath); const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
logger.debug("get_content.done", {fileName: normalizedFileName, relativePath, chars: content.length, duration: logger.duration(startedAt)}); logger.debug("get_content.done", {
fileName: normalizedFileName,
relativePath,
chars: content.length,
duration: logger.duration(startedAt)
});
return { return {
success: true, success: true,
fileName: normalizedFileName, fileName: normalizedFileName,
@@ -210,14 +219,14 @@ export const deleteNoteTool = {
type: "function", type: "function",
function: { function: {
name: "delete_note", name: "delete_note",
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present.", description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present. It is forbidden to delete/edit/rename `index.md` note.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
fileName: { fileName: {
type: "string", type: "string",
description: description:
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.", "The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. It is forbidden to delete/edit/rename `index.md` note.",
}, },
}, },
required: ["fileName"], required: ["fileName"],
@@ -236,6 +245,10 @@ export async function updateNoteContent(
return {success: false, error: "No file name provided"}; return {success: false, error: "No file name provided"};
} }
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to edit `index.md`"};
}
const content = asNonEmptyString(args?.content) ?? ""; const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) { if (!content.trim().length) {
return {success: false, error: "No content provided"}; return {success: false, error: "No content provided"};
@@ -249,7 +262,12 @@ export async function updateNoteContent(
try { try {
await readFile(noteFilePath, "utf-8"); await readFile(noteFilePath, "utf-8");
await writeFile(noteFilePath, content, "utf-8"); await writeFile(noteFilePath, content, "utf-8");
logger.debug("update_content.done", {fileName, filePath: noteFilePath, chars: content.length, duration: logger.duration(startedAt)}); logger.debug("update_content.done", {
fileName,
filePath: noteFilePath,
chars: content.length,
duration: logger.duration(startedAt)
});
return {success: true, filePath: noteFilePath}; return {success: true, filePath: noteFilePath};
} catch (error) { } catch (error) {
@@ -270,6 +288,10 @@ export async function deleteNote(
return {success: false, error: "No file name provided"}; return {success: false, error: "No file name provided"};
} }
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to delete `index.md`"};
}
const noteFilePath = buildSafeNoteFilePath(fileName); const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) { if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"}; return {success: false, error: "Invalid or unsafe file name provided"};
+8 -6
View File
@@ -4,10 +4,12 @@ import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("market-rates"); const logger = toolsLogger.child("market-rates");
export const getMarketRatesTool = { export const GET_FINANCIAL_MARKET_DATA = "get_financial_market_data";
export const getFinancialMarketData = {
type: "function", type: "function",
function: { function: {
name: "get_market_rates", name: GET_FINANCIAL_MARKET_DATA,
description: 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.", "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: { parameters: {
@@ -18,11 +20,11 @@ export const getMarketRatesTool = {
}, },
} satisfies AiTool; } satisfies AiTool;
export const marketRatesToolPrompt = [ export const financialMarketDataToolPrompt = [
"Currency rates tool rules:", "Currency rates tool rules:",
"- Use `get_market_rates` whenever the answer depends on current exchange rates, crypto prices, or gold price.", `- Use \`${GET_FINANCIAL_MARKET_DATA}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`,
"- Use `get_market_rates` when the user asks whether a supported asset went up or down recently.", `- Use \`${GET_FINANCIAL_MARKET_DATA}\` when the user asks whether a supported asset went up or down recently.`,
"- Use `get_market_rates` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.", `- Use \`${GET_FINANCIAL_MARKET_DATA}\` 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.", "- 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 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.", "- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
+13 -4
View File
@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment"; import {Environment} from "../../common/environment";
import {randomUUID} from "node:crypto";
import {toolsLogger} from "./tool-logger"; import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("python-interpreter"); const logger = toolsLogger.child("python-interpreter");
@@ -306,7 +305,8 @@ async function executePythonCode(
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
// const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID()); // const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID());
const tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "ollama-python-temp-" + randomUUID()); // const tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "ollama-python-temp-" + randomUUID());
const tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "");
const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME); const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME);
const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME); const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME);
const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME); const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME);
@@ -350,7 +350,12 @@ async function executePythonCode(
}, },
}); });
logger.debug("process.done", {duration: logger.duration(startedAt), exitCode: result.exitCode, timedOut: result.timedOut, outputTruncated: result.outputTruncated}); logger.debug("process.done", {
duration: logger.duration(startedAt),
exitCode: result.exitCode,
timedOut: result.timedOut,
outputTruncated: result.outputTruncated
});
if (result.timedOut) { if (result.timedOut) {
logger.warn("process.timeout", {duration: logger.duration(startedAt)}); logger.warn("process.timeout", {duration: logger.duration(startedAt)});
@@ -369,7 +374,11 @@ async function executePythonCode(
} }
if (result.outputTruncated) { if (result.outputTruncated) {
logger.warn("process.output_truncated", {duration: logger.duration(startedAt), stdoutChars: result.stdout.length, stderrChars: result.stderr.length}); logger.warn("process.output_truncated", {
duration: logger.duration(startedAt),
stdoutChars: result.stdout.length,
stderrChars: result.stderr.length
});
return { return {
ok: false, ok: false,
+27 -6
View File
@@ -5,7 +5,12 @@ import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell"; import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types"; import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather"; import {getWeather, getWeatherTool} from "./weather";
import {getMarketRates, getMarketRatesTool} from "./market-rates"; import {
financialMarketDataToolPrompt,
GET_FINANCIAL_MARKET_DATA,
getFinancialMarketData,
getMarketRates
} from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator"; import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
import { import {
copyPath, copyPath,
@@ -36,19 +41,19 @@ import {
updateNoteContent, updateNoteContent,
updateNoteContentTool updateNoteContentTool
} from "./list-notes"; } from "./list-notes";
import {getNoteFile, getNoteFileTool} from "./send-note-file"; import {sendNoteAsFileTool, sendNoteAsFile} from "./send-note-as-file";
import {searchNotes, searchNotesTool} from "./search-notes"; import {searchNotes, searchNotesTool} from "./search-notes";
export const getTools = () => { export const getTools = () => {
const tools: AiTool[] = [ const tools: AiTool[] = [
getCurrentDateTimeTool, getCurrentDateTimeTool,
getMarketRatesTool, getFinancialMarketData,
createNoteTool, createNoteTool,
listNotesTool, listNotesTool,
getNoteContentTool, getNoteContentTool,
updateNoteContentTool, updateNoteContentTool,
deleteNoteTool, deleteNoteTool,
getNoteFileTool, sendNoteAsFileTool,
searchNotesTool searchNotesTool
]; ];
@@ -97,13 +102,13 @@ export const getTools = () => {
export const getToolHandlers = () => { export const getToolHandlers = () => {
let handlers: Record<string, ToolHandler> = { let handlers: Record<string, ToolHandler> = {
get_datetime: getCurrentDateTime, get_datetime: getCurrentDateTime,
get_market_rates: getMarketRates, get_financial_market_data: getMarketRates,
create_note: createNote, create_note: createNote,
list_notes: listNotes, list_notes: listNotes,
get_note_content: getNoteContent, get_note_content: getNoteContent,
update_note_content: updateNoteContent, update_note_content: updateNoteContent,
delete_note: deleteNote, delete_note: deleteNote,
get_note_file: getNoteFile, send_note_as_file: sendNoteAsFile,
search_notes: searchNotes search_notes: searchNotes
}; };
@@ -151,3 +156,19 @@ export const getToolHandlers = () => {
return handlers; return handlers;
}; };
export function getToolPrompts(toolNames: string[]): string[] {
const prompts: string[] = [];
for (const toolName of toolNames) {
switch (toolName) {
case GET_FINANCIAL_MARKET_DATA:
prompts.push(financialMarketDataToolPrompt);
break;
default:
break;
}
}
return prompts;
}
@@ -44,10 +44,10 @@ export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
}), }),
]); ]);
export const getNoteFileTool = { export const sendNoteAsFileTool = {
type: "function", type: "function",
function: { function: {
name: "get_note_file", name: "send_note_as_file",
description: 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.", "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: { parameters: {
@@ -64,7 +64,7 @@ export const getNoteFileTool = {
}, },
} satisfies AiTool; } satisfies AiTool;
export async function getNoteFile( export async function sendNoteAsFile(
args?: Record<string, unknown>, args?: Record<string, unknown>,
): Promise<GetNoteFileResult> { ): Promise<GetNoteFileResult> {
logger.debug("start", {args}); logger.debug("start", {args});
+1 -1
View File
@@ -6,7 +6,7 @@ export const shellExecuteTool = {
type: "function", type: "function",
function: { function: {
name: "shell_execute", name: "shell_execute",
description: "Execute command in a shell", description: "Execute NON-Python command in a shell. Do not use if you intend to execute some python.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
-23
View File
@@ -1,5 +1,4 @@
import {Ollama} from "ollama"; import {Ollama} from "ollama";
import {z} from "zod";
import {toolsLogger} from "./tool-logger"; import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("utils"); const logger = toolsLogger.child("utils");
@@ -106,25 +105,3 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
return false; return false;
} }
} }
export type ToolPlanStep = {
t: string;
h: string;
from: string;
};
export type RouterPlan = {
s: ToolPlanStep[];
m: string;
};
export const ToolPlanStepSchema = z.object({
t: z.string(),
h: z.string(),
from: z.string(),
});
export const RouterPlanSchema = z.object({
s: z.array(ToolPlanStepSchema),
m: z.string()
});
+107 -58
View File
@@ -2,27 +2,48 @@
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs"; import * as fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {bot, notesDir} from "../index"; import {bot, notesDir} from "../index";
import {clamp, logError} from "../util/utils"; import {clamp, logError} from "../util/utils";
import {getOllamaTools} from "./tool-mappers"; import {getOllamaTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {getModelCapabilities} from "./provider-model-runtime";
import {ChatMessage} from "./chat-messages-types"; import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama"; import {ChatRequest, Tool} from "ollama";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {getCurrentDateTimeTool} from "./tools/datetime"; import {getCurrentDateTimeTool} from "./tools/datetime";
import {getMarketRatesTool} from "./tools/market-rates"; import {getFinancialMarketData} from "./tools/market-rates";
import {getWeatherTool} from "./tools/weather"; import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file"; import {GetNoteFileResult, GetNoteFileResultSchema, sendNoteAsFileTool} from "./tools/send-note-as-file";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {DEFAULT_OLLAMA_CONTEXT_SIZE, MAX_OLLAMA_CONTEXT_SIZE, MAX_TOOL_ROUNDS, MIN_OLLAMA_CONTEXT_SIZE, RuntimeConfigSnapshot, Think, ToolCallData, ToolExecutionMemory, allToolSchemaNames, appendOllamaToolResults, dedupeToolCalls, executeToolBatch, normalizeOllamaToolCalls, roundStatus, safeJsonParseObject, isRecord, isOllamaModelActive, OllamaToolCallLike} from "./unified-ai-runner.shared"; import {
import {latestUserTextFromOllamaMessages, looksLikeToolRankerJson, OllamaToolRanker} from "./unified-ai-runner.tool-ranker"; allToolSchemaNames,
appendOllamaToolResults,
dedupeToolCalls,
DEFAULT_OLLAMA_CONTEXT_SIZE,
executeToolBatch,
isOllamaModelActive,
isRecord,
MAX_OLLAMA_CONTEXT_SIZE,
MAX_TOOL_ROUNDS,
MIN_OLLAMA_CONTEXT_SIZE,
normalizeOllamaToolCalls,
OllamaToolCallLike,
roundStatus,
RuntimeConfigSnapshot,
safeJsonParseObject,
Think,
ToolCallData,
ToolExecutionMemory
} 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";
export async function runOllama( export async function runOllama(
msg: Message, msg: Message,
@@ -156,46 +177,76 @@ export async function runOllama(
messages: messages, messages: messages,
think: audioCount ? false : think, think: audioCount ? false : think,
options: { options: {
temperature: 0.6, temperature: 0.7,
num_ctx: context, top_p: 0.9,
top_k: 40,
num_ctx: 16384
} }
}; };
let activeToolNames: string[] = []; let activeToolNames: string[] = [];
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { // if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
const availableOllamaTools: Tool[] = fromId !== Environment.CREATOR_ID const availableOllamaTools: Tool[] =
? [getCurrentDateTimeTool, getMarketRatesTool, getWeatherTool] fromId !== Environment.CREATOR_ID ? [
: getOllamaTools() as Tool[]; getCurrentDateTimeTool,
getFinancialMarketData,
getWeatherTool,
createNoteTool,
listNotesTool,
getNoteContentTool,
updateNoteContentTool,
deleteNoteTool,
sendNoteAsFileTool,
searchNotesTool
] : getOllamaTools() as Tool[];
aiLog("debug", "ollama.tools.available", { aiLog("debug", "ollama.tools.available", {
round, round,
tools: allToolSchemaNames(availableOllamaTools), tools: allToolSchemaNames(availableOllamaTools),
rankerEnabled: !!config.ollamaToolRankerTarget, rankerEnabled: !!config.ollamaToolRankerTarget,
}); });
const rankerSelection = await new OllamaToolRanker(config).selectTools({ const rankerSelection = await new OllamaToolRanker(config).selectTools({
userQuery: latestUserTextFromOllamaMessages(messages), userQuery: latestUserTextFromOllamaMessages(messages),
availableTools: availableOllamaTools, availableTools: availableOllamaTools,
round, round,
signal, signal,
}); });
activeToolNames = rankerSelection.selectedNames; activeToolNames = rankerSelection.tools.map(t => t.function.name ?? "");
if (rankerSelection.tools.length > 0) { if (rankerSelection.tools.length > 0) {
request.tools = rankerSelection.tools; request.tools = [...rankerSelection.tools, ...rankerSelection.tools];
} else { request.options = {
delete request.tools; ...request.options,
temperature: 0
} }
aiLog("debug", "ollama.tools.selected", { const newMessage = messages[messages.length - 1];
round, if (newMessage) {
tools: activeToolNames, newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", ");
count: activeToolNames.length, }
usedRanker: rankerSelection.usedRanker,
missing: rankerSelection.missing, const systemMessage = messages.find(m => m.role === "system");
}); if (systemMessage) {
systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n");
}
request.model = config.ollamaToolTarget.model;
} else {
delete request.tools;
} }
// TODO: 14.05.2026, Danil Nikolaev: check if model supports tools
aiLog("debug", "ollama.tools.selected", {
round,
tools: activeToolNames,
count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker,
});
// }
if (!stream) { if (!stream) {
const response = await ollama.chat({ const response = await ollama.chat({
...request, ...request,
@@ -214,14 +265,14 @@ export async function runOllama(
const responseText = rawContent; const responseText = rawContent;
if (looksLikeToolRankerJson(responseText)) { // if (looksLikeToolRankerJson(responseText)) {
aiLog("error", "ollama.response.looks_like_tool_ranker_json", { // aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
round, // round,
preview: responseText.slice(0, 800), // preview: responseText.slice(0, 800),
target: aiLogProviderTarget(target), // target: aiLogProviderTarget(target),
}); // });
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up."); // throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
} // }
streamMessage.append(responseText); streamMessage.append(responseText);
@@ -264,6 +315,7 @@ export async function runOllama(
continue; continue;
} }
console.log("MESSAGES", JSON.stringify(request.messages));
const response = await ollama.chat({ const response = await ollama.chat({
...request, ...request,
stream: true stream: true
@@ -277,6 +329,8 @@ export async function runOllama(
if (signal.aborted) abortOllamaResponse(); if (signal.aborted) abortOllamaResponse();
try { try {
for await (const chunk of response) { for await (const chunk of response) {
console.log("OLLAMA_CHUNK: ", chunk);
const localToolCalls: ToolCallData[] = []; const localToolCalls: ToolCallData[] = [];
localToolCalls.push(...normalizeOllamaToolCalls( localToolCalls.push(...normalizeOllamaToolCalls(
@@ -324,16 +378,16 @@ export async function runOllama(
signal.removeEventListener("abort", abortOllamaResponse); signal.removeEventListener("abort", abortOllamaResponse);
} }
const streamedRoundText = streamMessage.getText().slice(roundTextStart); // const streamedRoundText = streamMessage.getText().slice(roundTextStart);
if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) { // if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) {
streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart)); // streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart));
aiLog("error", "ollama.response.looks_like_tool_ranker_json", { // aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
round, // round,
preview: streamedRoundText.slice(0, 800), // preview: streamedRoundText.slice(0, 800),
target: aiLogProviderTarget(target), // target: aiLogProviderTarget(target),
}); // });
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up."); // throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
} // }
if (!calls.length) { if (!calls.length) {
aiLog("success", "ollama.run.done", { aiLog("success", "ollama.run.done", {
@@ -397,8 +451,3 @@ export async function runOllama(
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
} }
} }
export class OllamaProviderRunner {
static run = runOllama;
}
+50 -3
View File
@@ -21,6 +21,7 @@ import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogTo
import { import {
AsyncIterableStream, AsyncIterableStream,
collectOpenAiResponseFunctionCalls, collectOpenAiResponseFunctionCalls,
collectOpenAiResponseCodeInterpreterCalls,
collectOpenAiResponseImages, collectOpenAiResponseImages,
collectOpenAiResponseText, collectOpenAiResponseText,
executeToolBatch, executeToolBatch,
@@ -44,7 +45,7 @@ import {
ToolCallData, ToolCallData,
ToolExecutionMemory ToolExecutionMemory
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file"; import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-as-file";
import {bot, notesDir} from "../index"; import {bot, notesDir} from "../index";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -60,9 +61,11 @@ export async function runOpenAi(
sourceMessage: Message, sourceMessage: Message,
config: RuntimeConfigSnapshot, config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext, toolContext: ToolRuntimeContext,
think?: boolean
): Promise<void> { ): Promise<void> {
// TODO: 13.05.2026: remove // TODO: 13.05.2026: remove
firstRoundStatus; firstRoundStatus;
think;
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
let responseInput: unknown[] = [...messages]; let responseInput: unknown[] = [...messages];
const openAi = createOpenAiClient(config.openAiChatTarget); const openAi = createOpenAiClient(config.openAiChatTarget);
@@ -111,6 +114,21 @@ export async function runOpenAi(
); );
} }
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response);
if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", {
round,
duration: aiLogDuration(roundStartedAt),
calls: codeInterpreterCalls.map(call => ({
id: call.id,
status: call.status,
containerId: call.containerId,
codeChars: call.code?.length ?? 0,
outputItems: call.outputs.length,
})),
});
}
const calls = collectOpenAiResponseFunctionCalls(response); const calls = collectOpenAiResponseFunctionCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round, round,
@@ -170,6 +188,7 @@ export async function runOpenAi(
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
stream: true, stream: true,
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"], tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
parallel_tool_calls: true
}; };
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>; const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
@@ -207,6 +226,18 @@ export async function runOpenAi(
streamMessage.setStatus(Environment.finalizingImageGenText); streamMessage.setStatus(Environment.finalizingImageGenText);
await streamMessage.flush(); await streamMessage.flush();
break; break;
case "response.code_interpreter_call.in_progress":
case "response.code_interpreter_call.interpreting":
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
await streamMessage.flush();
break;
case "response.code_interpreter_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call_code.delta":
case "response.code_interpreter_call_code.done":
break;
case "response.output_item.added": case "response.output_item.added":
if (event.item.type === "function_call" && event.item.name) { if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string }; const item = event.item as OpenAiResponseOutputItem & { id?: string };
@@ -275,6 +306,21 @@ export async function runOpenAi(
); );
} }
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse);
if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", {
round,
duration: aiLogDuration(roundStartedAt),
calls: codeInterpreterCalls.map(call => ({
id: call.id,
status: call.status,
containerId: call.containerId,
codeChars: call.code?.length ?? 0,
outputItems: call.outputs.length,
})),
});
}
const calls = collectOpenAiResponseFunctionCalls(completedResponse); const calls = collectOpenAiResponseFunctionCalls(completedResponse);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round, round,
@@ -422,7 +468,7 @@ export async function runOpenAiCompatibleChat(
model: config.geminiChatTarget.model, model: config.geminiChatTarget.model,
messages: chatMessages, messages: chatMessages,
tools: getOpenAITools(), tools: getOpenAITools(),
temperature: 0.6, // temperature: 0.6,
}; };
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike; const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message; const message = response.choices?.[0]?.message;
@@ -484,8 +530,9 @@ export async function runOpenAiCompatibleChat(
model: config.geminiChatTarget.model, model: config.geminiChatTarget.model,
messages: chatMessages, messages: chatMessages,
tools: getOpenAITools(), tools: getOpenAITools(),
temperature: 0.6, // temperature: 0.6,
stream: true, stream: true,
parallel_tool_calls: true
}; };
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>; const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
+36 -5
View File
@@ -8,7 +8,7 @@ import {photoGenDir} from "../index";
import {collectReplyChainText, delay, logError, replyToMessage} from "../util/utils"; import {collectReplyChainText, delay, logError, replyToMessage} from "../util/utils";
import {MessageStore} from "../common/message-store"; import {MessageStore} from "../common/message-store";
import type {OpenAiResponseTool} from "./tool-mappers"; import type {OpenAiResponseTool} from "./tool-mappers";
import {AiProviderName, getOpenAIResponsesTools} from "./tool-mappers"; import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers";
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message";
import {AiDownloadedFile} from "./telegram-attachments"; import {AiDownloadedFile} from "./telegram-attachments";
import {getRuntimeCapabilities} from "./provider-model-runtime"; import {getRuntimeCapabilities} from "./provider-model-runtime";
@@ -19,7 +19,8 @@ import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
import {MessageImagePart, MessagePart} from "../common/message-part"; import {MessageImagePart, MessagePart} from "../common/message-part";
import {KeyedAsyncLock} from "../util/async-lock"; import {KeyedAsyncLock} from "../util/async-lock";
import {type AiRequestQueueTarget} from "./provider-request-queue"; import {type AiRequestQueueTarget} from "./provider-request-queue";
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator"; import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
import {pythonInterpreterToolPrompt} from "./tools/python-interpretator";
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings"; import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings";
import { import {
isTranscribableAudioDownload, isTranscribableAudioDownload,
@@ -66,7 +67,7 @@ export type {
export type {GenerateContentParameters} from "@google/genai"; export type {GenerateContentParameters} from "@google/genai";
export const TELEGRAM_LIMIT = 4096; export const TELEGRAM_LIMIT = 4096;
export const MAX_TOOL_ROUNDS = 12; export const MAX_TOOL_ROUNDS = 40;
export const MAX_IDENTICAL_TOOL_CALLS = 1; export const MAX_IDENTICAL_TOOL_CALLS = 1;
export const OPENAI_IMAGE_PARTIALS = 3; export const OPENAI_IMAGE_PARTIALS = 3;
export const AI_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; export const AI_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
@@ -163,11 +164,16 @@ export type OpenAiChatToolCallLike = {
export type OpenAiResponseOutputItem = { export type OpenAiResponseOutputItem = {
type?: string; type?: string;
id?: string;
call_id?: string; call_id?: string;
name?: string; name?: string;
arguments?: string; arguments?: string;
result?: string; result?: string;
content?: Array<{ text?: string; refusal?: string }>; content?: Array<{ text?: string; refusal?: string }>;
code?: string | null;
container_id?: string;
outputs?: Array<{ type?: "logs" | "image"; logs?: string; url?: string }> | null;
status?: string;
}; };
export type OpenAiResponseLike = { export type OpenAiResponseLike = {
@@ -265,6 +271,7 @@ export type RuntimeConfigSnapshot = {
ollamaChatTarget: AiRuntimeTarget; ollamaChatTarget: AiRuntimeTarget;
ollamaToolRankerTarget?: AiRuntimeTarget; ollamaToolRankerTarget?: AiRuntimeTarget;
ollamaToolTarget: AiRuntimeTarget;
ollamaVisionTarget: AiRuntimeTarget; ollamaVisionTarget: AiRuntimeTarget;
ollamaThinkingTarget: AiRuntimeTarget; ollamaThinkingTarget: AiRuntimeTarget;
ollamaAudioTarget: AiRuntimeTarget; ollamaAudioTarget: AiRuntimeTarget;
@@ -296,7 +303,8 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT, rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"), ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "tools"), ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "toolRank"),
ollamaToolTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "tools"),
ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"), ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"),
ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"), ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"),
ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"), ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"),
@@ -369,6 +377,7 @@ export function providerTargets(provider: AiProvider, config: RuntimeConfigSnaps
return [ return [
config.ollamaChatTarget, config.ollamaChatTarget,
config.ollamaToolRankerTarget, config.ollamaToolRankerTarget,
config.ollamaToolTarget,
config.ollamaVisionTarget, config.ollamaVisionTarget,
config.ollamaThinkingTarget, config.ollamaThinkingTarget,
config.ollamaAudioTarget, config.ollamaAudioTarget,
@@ -402,7 +411,7 @@ export function buildSystemInstruction(
includePythonToolPrompt: boolean, includePythonToolPrompt: boolean,
): string { ): string {
return [ return [
getResponseLanguageInstruction(responseLanguage), config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null, config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null, includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n"); ].filter(Boolean).join("\n\n");
@@ -1624,6 +1633,7 @@ export function allToolSchemaNames(tools: readonly unknown[]): string[] {
export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot): Array<OpenAiResponseTool | LooseRecord> { export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot): Array<OpenAiResponseTool | LooseRecord> {
return [ return [
...getOpenAIResponsesTools(), ...getOpenAIResponsesTools(),
getOpenAICodeInterpreterTool(),
{ {
type: "image_generation", type: "image_generation",
model: config.openAiImageTarget.model, model: config.openAiImageTarget.model,
@@ -1632,6 +1642,7 @@ export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot):
output_format: "png", output_format: "png",
partial_images: OPENAI_IMAGE_PARTIALS, partial_images: OPENAI_IMAGE_PARTIALS,
}, },
{type: "web_search"}
]; ];
} }
@@ -1655,6 +1666,26 @@ export function collectOpenAiResponseFunctionCalls(response: OpenAiResponseLike)
})); }));
} }
export type OpenAiCodeInterpreterCall = {
id: string;
code: string | null;
containerId: string;
status: string;
outputs: Array<{type?: "logs" | "image"; logs?: string; url?: string}>;
};
export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] {
return (response.output ?? [])
.filter(item => item.type === "code_interpreter_call" && item.id && item.container_id)
.map(item => ({
id: item.id!,
code: item.code ?? null,
containerId: item.container_id!,
status: item.status ?? "unknown",
outputs: Array.isArray(item.outputs) ? item.outputs : [],
}));
}
export function collectOpenAiResponseImages(response: OpenAiResponseLike): string[] { export function collectOpenAiResponseImages(response: OpenAiResponseLike): string[] {
return (response.output ?? []) return (response.output ?? [])
.filter(item => item.type === "image_generation_call" && typeof item.result === "string") .filter(item => item.type === "image_generation_call" && typeof item.result === "string")
+117 -123
View File
@@ -1,29 +1,23 @@
import {Tool} from "ollama"; import {Tool} from "ollama";
import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared"; import {
allToolSchemaNames,
type RankedToolStep = { isRecord,
t: string | string[]; RuntimeConfigSnapshot,
h?: string; toolSchemaNames
from?: string; } from "./unified-ai-runner.shared";
}; import {z} from "zod";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator";
type RankedToolPlan = {
s?: RankedToolStep[];
m?: string;
};
export type ToolRankerSelection = { export type ToolRankerSelection = {
tools: Tool[]; tools: Tool[];
selectedNames: string[];
missing: string;
raw: string;
usedRanker: boolean; usedRanker: boolean;
}; };
export class OllamaToolRanker { export class OllamaToolRanker {
constructor(private readonly config: RuntimeConfigSnapshot) {} constructor(private readonly config: RuntimeConfigSnapshot) {
}
async selectTools(args: { async selectTools(args: {
userQuery: string; userQuery: string;
@@ -35,27 +29,71 @@ export class OllamaToolRanker {
const target = this.config.ollamaToolRankerTarget; const target = this.config.ollamaToolRankerTarget;
if (!availableTools.length) { if (!availableTools.length) {
return {tools: [], selectedNames: [], missing: "", raw: "", usedRanker: false}; return {tools: [], usedRanker: false};
} }
// Ranker disabled/unconfigured: keep old behavior and expose all allowed tools. // Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
if (!target?.model) { if (!target?.model) {
return { return {
tools: availableTools, tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false, usedRanker: false,
}; };
} }
const startedAt = Date.now(); const startedAt = Date.now();
const availableNames = new Set(allToolSchemaNames(availableTools)); const availableNames = new Set(allToolSchemaNames(availableTools));
const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT; // const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
const toolsForPrompt = availableTools.map(tool => ({
names: toolSchemaNames(tool), const availableToolNames = availableTools.map(t => "- " + (t.function.name ?? ""));
schema: tool,
})); const toolRouterPrompt = () => [
"You are a tool routing model.",
"Select the best tool.",
"Return ONLY valid JSON.",
"",
"Available tools:",
"- no_tool",
// "- ask_clarification",
// "- user_sad",
// "- user_angry",
availableToolNames.join("\n"),
"",
"Never explain reasoning.",
// "If user is sounds aggressive/angry, then pick `user_angry` tool.",
// "If the user's request is unclear, then pick `ask_clarification` tool.",
availableToolNames.find(t => t === "web_search") ?
"If you don't know the answer, then pick `search_web` tool." : null,
availableToolNames.find(t => t == PYTHON_INTERPRETER_TOOL_NAME) ?
"If user asks to write/execute Python code, then use `" + PYTHON_INTERPRETER_TOOL_NAME + "`" : null,
"",
"Return valid JSON ONLY in this format (NO ARGUMENTS): {\"toolNames\": [\"$toolName\", ... \"lastToolName\"]}",
].filter(Boolean).join("\n");
const routerSchema = {
type: "object",
properties: {
name: {
type: "string",
enum: [
"no_tool",
"ask_clarification",
...availableToolNames
],
},
arguments: {
type: "object",
additionalProperties: true,
},
},
required: ["name"],
additionalProperties: false,
} as const;
// const toolsForPrompt = availableTools.map(tool => ({
// names: toolSchemaNames(tool),
// schema: tool,
// }));
aiLog("debug", "ollama.tool_ranker.start", { aiLog("debug", "ollama.tool_ranker.start", {
round, round,
@@ -65,46 +103,86 @@ export class OllamaToolRanker {
}); });
try { try {
const ollama = createOllamaClient(target as AiRuntimeTarget); const ollama = createOllamaClient(target);
// const response = await ollama.chat({
// model: target.model,
// messages: [
// {role: "system", content: prompt},
// {
// role: "user",
// content: JSON.stringify({
// q: userQuery,
// tools: toolsForPrompt,
// }),
// },
// ],
// stream: false,
// options: {
// temperature: 0,
// num_ctx: 8192,
// },
// });
const then = performance.now();
const response = await ollama.chat({ const response = await ollama.chat({
model: target.model, model: target?.model ?? "",
messages: [ messages: [
{role: "system", content: prompt}, {
role: "system",
content: toolRouterPrompt()
},
{ {
role: "user", role: "user",
content: JSON.stringify({ content: userQuery,
q: userQuery, }
tools: toolsForPrompt,
}),
},
], ],
stream: false, stream: false,
think: false,
format: routerSchema,
options: { options: {
temperature: 0, temperature: 0,
top_p: 0.8,
top_k: 20,
repeat_penalty: 1.05,
num_ctx: 8192, num_ctx: 8192,
num_predict: 256
}, },
}); });
const now = performance.now();
const diff = now - then;
console.log("TOOK " + diff + "ms");
console.log("OLLAMA_RESPONSE: ", JSON.stringify(response));
if (signal.aborted) throw new Error("Aborted"); if (signal.aborted) throw new Error("Aborted");
const raw = response.message?.content?.trim() ?? ""; const raw = response.message?.content?.trim() ?? "";
const plan = parseToolRankerPlan(raw); const schema = z.object({
const selectedNames = normalizeToolRankerNames(plan, availableNames); toolNames: z.array(z.string())
});
const res = schema.safeParse(JSON.parse(raw));
const selectedNames: string[] = [];
if (res.success) {
selectedNames.push(...res.data.toolNames);
}
const selectedNameSet = new Set(selectedNames); const selectedNameSet = new Set(selectedNames);
const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name))); const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
const missing = typeof plan?.m === "string" ? plan.m.trim() : "";
aiLog("debug", "ollama.tool_ranker.done", { aiLog("debug", "ollama.tool_ranker.done", {
round, round,
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
selectedNames, selectedNames,
selectedCount: tools.length, selectedCount: tools.length,
missing,
rawPreview: raw.slice(0, 800), rawPreview: raw.slice(0, 800),
}); });
// Important: if the plan is valid and empty, use NO tools. Do not fall back to all tools. // Important: if the plan is valid and empty, use NO tools. Do not fall back to all tools.
return {tools, selectedNames, missing, raw, usedRanker: true}; return {tools, usedRanker: true};
} catch (error) { } catch (error) {
if (String(error).includes("Aborted")) throw error; if (String(error).includes("Aborted")) throw error;
@@ -119,9 +197,6 @@ export class OllamaToolRanker {
// In that case, preserve availability rather than silently disabling tools. // In that case, preserve availability rather than silently disabling tools.
return { return {
tools: availableTools, tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false, usedRanker: false,
}; };
} }
@@ -142,84 +217,3 @@ export function latestUserTextFromOllamaMessages(messages: readonly { role?: str
} }
return ""; return "";
} }
export function looksLikeToolRankerJson(text: string): boolean {
const parsed = safeJsonParseObject(extractJsonObjectText(text) ?? text);
return Array.isArray(parsed.s) && typeof parsed.m === "string";
}
function parseToolRankerPlan(raw: string): RankedToolPlan | undefined {
const jsonText = extractJsonObjectText(raw);
if (!jsonText) return undefined;
const parsed = safeJsonParseObject(jsonText) as JsonObject;
if (!Array.isArray(parsed.s)) return undefined;
return parsed as RankedToolPlan;
}
function normalizeToolRankerNames(plan: RankedToolPlan | undefined, availableNames: Set<string>): string[] {
if (!plan?.s?.length) return [];
const result: string[] = [];
for (const step of plan.s) {
const rawNames = Array.isArray(step.t) ? step.t : [step.t];
for (const rawName of rawNames) {
if (typeof rawName !== "string") continue;
const name = rawName.trim();
if (availableNames.has(name) && !result.includes(name)) {
result.push(name);
}
}
}
return result;
}
function extractJsonObjectText(raw: string): string | undefined {
const text = raw.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const start = text.indexOf("{");
if (start === -1) return undefined;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) {
return text.slice(start, i + 1);
}
}
return undefined;
}
const DEFAULT_TOOL_RANKER_PROMPT = `You are a tool router. Return strict compact JSON only.
Schema: {"s":[{"t":"tool_name","h":"short input hint","from":"previous_tool.output_or_empty"}],"m":""}
Use tools only when they are needed. If no tool is needed, return {"s":[],"m":""}.
Never answer the user. Never explain. Never use markdown.`;
+33 -8
View File
@@ -8,7 +8,13 @@ import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "
import {ChatMessage} from "./chat-messages-types"; import {ChatMessage} from "./chat-messages-types";
import {aiProviderRequestQueue} from "./provider-request-queue"; import {aiProviderRequestQueue} from "./provider-request-queue";
import {prepareOllamaDocumentRag} from "./ollama-rag"; import {prepareOllamaDocumentRag} from "./ollama-rag";
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE, resolveAiContextSizeForUser, resolveAiResponseLanguageForUser, resolveAiVoiceModeForUser} from "../common/user-ai-settings"; import {
AI_VOICE_MODE_TRANSCRIPT,
DEFAULT_AI_RESPONSE_LANGUAGE,
resolveAiContextSizeForUser,
resolveAiResponseLanguageForUser,
resolveAiVoiceModeForUser
} from "../common/user-ai-settings";
import {isTranscribableAudioDownload} from "./speech-to-text"; import {isTranscribableAudioDownload} from "./speech-to-text";
import {OpenAIChatMessage} from "./openai-chat-message"; import {OpenAIChatMessage} from "./openai-chat-message";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message";
@@ -22,7 +28,29 @@ import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai";
import {runOllama} from "./unified-ai-runner.ollama"; import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral"; import {runMistral} from "./unified-ai-runner.mistral";
import {runGemini} from "./unified-ai-runner.gemini"; import {runGemini} from "./unified-ai-runner.gemini";
import {AI_REQUEST_TIMEOUT_MS, TELEGRAM_LIMIT, RuntimeConfigSnapshot, UnifiedRunOptions, appendTranscriptToChatMessages, collectCachedMessageAttachments, collectRequestedAttachmentKinds, collectTextMessages, deleteMistralLibrary, hasAudioAttachmentKind, initialStatus, isAbortError, prepareMistralDocuments, providerName, rejectUnsupportedAttachments, resolveAiRequestQueueTarget, snapshotModel, snapshotRuntimeConfig, stripAudioFromRunnerMessages, toolRuntimeContextFromDownloads, transcribeAudioIfNeeded} from "./unified-ai-runner.shared"; import {
AI_REQUEST_TIMEOUT_MS,
appendTranscriptToChatMessages,
collectCachedMessageAttachments,
collectRequestedAttachmentKinds,
collectTextMessages,
deleteMistralLibrary,
hasAudioAttachmentKind,
initialStatus,
isAbortError,
prepareMistralDocuments,
providerName,
rejectUnsupportedAttachments,
resolveAiRequestQueueTarget,
RuntimeConfigSnapshot,
snapshotModel,
snapshotRuntimeConfig,
stripAudioFromRunnerMessages,
TELEGRAM_LIMIT,
toolRuntimeContextFromDownloads,
transcribeAudioIfNeeded,
UnifiedRunOptions
} from "./unified-ai-runner.shared";
export type {ToolCallData} from "./unified-ai-runner.shared"; export type {ToolCallData} from "./unified-ai-runner.shared";
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
@@ -137,7 +165,8 @@ async function executeUnifiedAiRequest(
switch (options.provider) { switch (options.provider) {
case AiProvider.OPENAI: case AiProvider.OPENAI:
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext); await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext,
!!options.think);
break; break;
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
const currentModel = config.ollamaChatTarget.model; const currentModel = config.ollamaChatTarget.model;
@@ -259,7 +288,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id}); aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
try { try {
const queueMessage = await streamMessage.start(Environment.getAiQueueText(options.provider, 0)); const queueMessage = await streamMessage.start(Environment.waitThinkText);
setAiCancelMessageId(cancel.id, queueMessage.message_id); setAiCancelMessageId(cancel.id, queueMessage.message_id);
aiLog("info", "run.queue.enter", { aiLog("info", "run.queue.enter", {
cancelId: cancel.id, cancelId: cancel.id,
@@ -327,7 +356,3 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
}); });
} }
} }
export class UnifiedAiRunner {
static run = runUnifiedAi;
}
+1 -1
View File
@@ -16,6 +16,6 @@ export class OpenAIChat extends ChatCommand {
description = Environment.commandDescriptions.openAiChat; description = Environment.commandDescriptions.openAiChat;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> { async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
await runUnifiedAi({provider: AiProvider.OPENAI, msg: msg, text: match?.[3] ?? "", stream: true}); await runUnifiedAi({provider: AiProvider.OPENAI, msg: msg, text: match?.[3] ?? "", stream: true, think: true});
} }
} }
+2
View File
@@ -1166,6 +1166,8 @@ export class Environment {
const name = isString(toolCall) ? toolCall : toolCall.name; const name = isString(toolCall) ? toolCall : toolCall.name;
return name === PYTHON_INTERPRETER_TOOL_NAME return name === PYTHON_INTERPRETER_TOOL_NAME
? this.text("getUseToolText.python", "👨‍💻 Running `Python`") ? this.text("getUseToolText.python", "👨‍💻 Running `Python`")
: name === "code_interpreter"
? this.text("getUseToolText.codeInterpreter", "👨‍💻 Running `Code Interpreter`")
: this.text("getUseToolText.default", "🔧 Using tool `{name}`", {name}); : this.text("getUseToolText.default", "🔧 Using tool `{name}`", {name});
}).join("\n"); }).join("\n");
} }
+1
View File
@@ -7,6 +7,7 @@ export class AiModelCapabilities {
thinking: AiCapabilityInfo | undefined; thinking: AiCapabilityInfo | undefined;
extendedThinking: AiCapabilityInfo | undefined; extendedThinking: AiCapabilityInfo | undefined;
tools: AiCapabilityInfo | undefined; tools: AiCapabilityInfo | undefined;
toolRank: AiCapabilityInfo | undefined;
audio: AiCapabilityInfo | undefined; audio: AiCapabilityInfo | undefined;
documents: AiCapabilityInfo | undefined; documents: AiCapabilityInfo | undefined;
outputImages: AiCapabilityInfo | undefined; outputImages: AiCapabilityInfo | undefined;