diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 5084d2011..b98774b8f 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Check if PRs have conflicts
uses: eps1lon/actions-label-merge-conflict@v3
- if: github.repository == ${{ vars.REPO_MAIN }}
+ if: ${{ vars.REPO_MAIN == '' || github.repository == vars.REPO_MAIN }}
with:
dirtyLabel: "merge-conflicts"
- repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
+ repoToken: ${{ secrets.MERGE_CONFLICT_LABEL_PAT || secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 0fc8e562b..e86c6c742 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
@@ -21,133 +21,139 @@
[Chinese (Simplified Han script)](./docs/README-ZH_CN.md) | [Chinese (Traditional Han script)](./docs/README-ZH_TW.md) | [English](./docs/README.md) | [French](./docs/README-fr.md) | [German](./docs/README-de.md) | [Greek](./docs/README-el.md) | [Italian](./docs/README-it.md) | [Japanese](./docs/README-ja.md) | [Romanian](./docs/README-ro.md) | [Spanish](./docs/README-es.md)
-Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
+Trilium Notes 是一款免费、开源、跨平台的分层笔记应用,专注于构建大型个人知识库,并内置 AI 助手能力。
-## ⏬ Download
-- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) – stable version, recommended for most users.
-- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) – unstable development version, updated daily with the latest features and fixes.
+## 🤖 AI 与 MiniMax 支持
-## 📚 Documentation
+- 内置 AI 助手,支持 MiniMax(Anthropic 兼容接口)。
+- 支持工具调用:搜索、创建、更新、移动、总结笔记。
+- 可在设置中配置模型与密钥,按需启用 AI 能力。
-**Visit our comprehensive documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)**
+## ⏬ 下载
-Our documentation is available in multiple formats:
-- **Online Documentation**: Browse the full documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)
-- **In-App Help**: Press `F1` within Trilium to access the same documentation directly in the application
-- **GitHub**: Navigate through the [User Guide](./docs/User%20Guide/User%20Guide/) in this repository
+- [最新版本](https://github.com/TriliumNext/Trilium/releases/latest) – 稳定版,推荐大多数用户使用。
+- [Nightly 构建](https://github.com/TriliumNext/Trilium/releases/tag/nightly) – 不稳定开发版,每日更新最新功能与修复。
-### Quick Links
-- [Getting Started Guide](https://docs.triliumnotes.org/)
-- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
-- [Docker Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
-- [Upgrading TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
-- [Basic Concepts and Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
-- [Patterns of Personal Knowledge Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
+## 📚 文档
-## 🎁 Features
+**访问完整文档:[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
-* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
-* Rich WYSIWYG note editor including e.g. tables, images and [math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown [autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
-* Support for editing [notes with source code](https://docs.triliumnotes.org/user-guide/note-types/code), including syntax highlighting
-* Fast and easy [navigation between notes](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation), full text search and [note hoisting](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
-* Seamless [note versioning](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
-* Note [attributes](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) can be used for note organization, querying and advanced [scripting](https://docs.triliumnotes.org/user-guide/scripts)
-* UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
-* Direct [OpenID and TOTP integration](https://docs.triliumnotes.org/user-guide/setup/server/mfa) for more secure login
-* [Synchronization](https://docs.triliumnotes.org/user-guide/setup/synchronization) with self-hosted sync server
- * there are [3rd party services for hosting synchronisation server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
-* [Sharing](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) (publishing) notes to public internet
-* Strong [note encryption](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes) with per-note granularity
-* Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type "canvas")
-* [Relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map) for visualizing notes and their relations
-* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
-* [Geo maps](https://docs.triliumnotes.org/user-guide/collections/geomap) with location pins and GPX tracks
-* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
-* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for automation
-* Scales well in both usability and performance upwards of 100 000 notes
-* Touch optimized [mobile frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for smartphones and tablets
-* Built-in [dark theme](https://docs.triliumnotes.org/user-guide/concepts/themes), support for user themes
-* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) and [Markdown import & export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
-* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for easy saving of web content
-* Customizable UI (sidebar buttons, user-defined widgets, ...)
-* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics), along with a Grafana Dashboard.
+文档提供多种形式:
+- **在线文档**:浏览 [docs.triliumnotes.org](https://docs.triliumnotes.org/)
+- **应用内帮助**:在 Trilium 中按 `F1`
+- **GitHub**:查看仓库内的 [用户指南](./docs/User%20Guide/User%20Guide/)
-✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
+### 快速链接
+- [快速开始](https://docs.triliumnotes.org/)
+- [安装说明](https://docs.triliumnotes.org/user-guide/setup)
+- [Docker 安装](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
+- [升级 TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
+- [基础概念与特性](https://docs.triliumnotes.org/user-guide/concepts/notes)
+- [个人知识库的组织方式](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
-- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
-- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
+## 🎁 功能
-## ❓Why TriliumNext?
+* 笔记可组织为任意深度的树结构,单个笔记可在树中多处出现(参见 [cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
+* 富文本 WYSIWYG 编辑器,支持表格、图片、[数学公式](https://docs.triliumnotes.org/user-guide/note-types/text),并带有 Markdown [自动格式化](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
+* 支持编辑 [代码笔记](https://docs.triliumnotes.org/user-guide/note-types/code),包含语法高亮
+* 快速 [笔记导航](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation)、全文检索与 [笔记提升](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
+* 无缝的 [笔记版本管理](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
+* 通过 [属性](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) 实现组织、查询与高级 [脚本](https://docs.triliumnotes.org/user-guide/scripts)
+* UI 提供英文、德文、西班牙文、法文、罗马尼亚文与中文(简体/繁体)
+* 内置 [OpenID 与 TOTP](https://docs.triliumnotes.org/user-guide/setup/server/mfa) 以提升登录安全性
+* 与自托管同步服务器进行 [同步](https://docs.triliumnotes.org/user-guide/setup/synchronization)
+ * 提供 [第三方托管服务](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
+* 通过 [共享/发布](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) 将笔记公开到互联网
+* 强大的 [笔记加密](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes),支持单笔记粒度
+* 基于 [Excalidraw](https://excalidraw.com/) 的草图绘制(笔记类型 "canvas")
+* [关系图](https://docs.triliumnotes.org/user-guide/note-types/relation-map) 与 [笔记/链接图](https://docs.triliumnotes.org/user-guide/note-types/note-map) 可视化
+* 基于 [Mind Elixir](https://docs.mind-elixir.com/) 的思维导图
+* [地理地图](https://docs.triliumnotes.org/user-guide/collections/geomap) 支持位置标记与 GPX 轨迹
+* [脚本能力](https://docs.triliumnotes.org/user-guide/scripts) – 参见 [高级示例](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
+* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) 便于自动化
+* 轻松支持 100,000+ 规模的笔记与良好性能
+* 适配触控的 [移动端界面](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend)
+* 内置 [深色主题](https://docs.triliumnotes.org/user-guide/concepts/themes) 并支持用户主题
+* 支持 [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) 与 [Markdown 导入/导出](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
+* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) 便于保存网页内容
+* UI 可自定义(侧边栏按钮、用户自定义小组件等)
+* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics) 指标与 Grafana Dashboard
-The original Trilium developer ([Zadam](https://github.com/zadam)) has graciously given the Trilium repository to the community project which resides at https://github.com/TriliumNext
+✨ 更多 TriliumNext 社区资源:
-### ⬆️Migrating from Zadam/Trilium?
+- [awesome-trilium](https://github.com/Nriver/awesome-trilium) – 主题、脚本、插件等合集
+- [TriliumRocks!](https://trilium.rocks/) – 教程、指南等
-There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
+## ❓为什么选择 TriliumNext?
-Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
+原作者 [Zadam](https://github.com/zadam) 已将 Trilium 仓库交由社区维护,项目位于 https://github.com/TriliumNext
-## 💬 Discuss with us
+### ⬆️ 从 Zadam/Trilium 迁移?
-Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have!
+从 zadam/Trilium 迁移到 TriliumNext/Trilium 不需要额外步骤,按常规 [安装 TriliumNext/Trilium](#-安装) 即可继续使用原数据库。
-- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
- - The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
-- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For asynchronous discussions.)
-- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug reports and feature requests.)
+v0.90.4 及以前版本与 zadam/trilium 的 v0.63.7 兼容;此后的 TriliumNext/Trilium 已提升同步版本号,因此无法直接迁移。
-## 🏗 Installation
+## 💬 交流与支持
+
+欢迎加入官方社区讨论:
+
+- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(同步交流)
+ - `General` Matrix 房间同样桥接到 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
+- [GitHub Discussions](https://github.com/TriliumNext/Trilium/discussions)(异步交流)
+- [GitHub Issues](https://github.com/TriliumNext/Trilium/issues)(问题反馈与需求)
+
+## 🏗 安装
### Windows / MacOS
-Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
+从 [最新版本](https://github.com/TriliumNext/Trilium/releases/latest) 下载对应平台的二进制包,解压后运行 `trilium` 可执行文件。
### Linux
-If your distribution is listed in the table below, use your distribution's package.
+如果你的发行版在下表中,请使用发行版提供的包。
[](https://repology.org/project/triliumnext/versions)
-You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
+你也可以从 [最新版本](https://github.com/TriliumNext/Trilium/releases/latest) 下载二进制包,解压后运行 `trilium`。
-TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
+TriliumNext 也提供 Flatpak,但尚未发布到 Flathub。
-### Browser (any OS)
+### 浏览器(任何 OS)
-If you use a server installation (see below), you can directly access the web interface (which is almost identical to the desktop app).
+如果你使用服务器部署(见下文),可直接访问 Web 界面(几乎与桌面版一致)。
-Currently only the latest versions of Chrome & Firefox are supported (and tested).
+目前仅支持并测试最新版本的 Chrome 与 Firefox。
-### Mobile
+### 移动端
-To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
+你可以通过移动浏览器访问服务器的移动端界面(见下文)来使用 TriliumNext。
-See issue https://github.com/TriliumNext/Trilium/issues/4962 for more information on mobile app support.
+移动端原生 App 讨论见:https://github.com/TriliumNext/Trilium/issues/4962
-If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
-Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
-Note: It is best to disable automatic updates on your server installation (see below) when using TriliumDroid since the sync version must match between Trilium and TriliumDroid.
+如需 Android 原生客户端,可使用 [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)。
+相关 bug 或缺失功能请在其仓库反馈:[TriliumDroid repository](https://github.com/FliegendeWurst/TriliumDroid)。
+注意:使用 TriliumDroid 时建议关闭服务器的自动更新(见下文),以保持同步版本一致。
-### Server
+### 服务器
-To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
+在服务器上安装 TriliumNext(含 Docker)请参考:[服务器安装文档](https://docs.triliumnotes.org/user-guide/setup/server)
+## 💻 贡献
-## 💻 Contribute
+### 翻译
-### Translations
+如果你是母语使用者,欢迎加入 [Weblate](https://hosted.weblate.org/engage/trilium/) 翻译 Trilium。
-If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
-
-Here's the language coverage we have so far:
+当前语言覆盖:
[](https://hosted.weblate.org/engage/trilium/)
-### Code
+### 代码
-Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
+克隆仓库并安装依赖,然后运行服务端(默认 http://localhost:8080):
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -155,9 +161,9 @@ pnpm install
pnpm run server:start
```
-### Documentation
+### 文档
-Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation:
+克隆仓库并安装依赖,然后启动文档编辑环境:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -165,19 +171,19 @@ pnpm install
pnpm edit-docs:edit-docs
```
-Alternatively, if you have Nix installed:
+如果你安装了 Nix:
```shell
-# Run directly
+# 直接运行
nix run .#edit-docs
-# Or install to your profile
+# 或安装到 profile
nix profile install .#edit-docs
trilium-edit-docs
```
+### 构建可执行文件
-### Building the Executable
-Download the repository, install dependencies using `pnpm` and then build the desktop app for Windows:
+克隆仓库并安装依赖,然后构建 Windows 桌面包:
```shell
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
@@ -185,46 +191,16 @@ pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
```
-For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
+更多详情请参考 [开发文档](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide)。
-### Developer Documentation
+### 开发文档
-Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
+请查看 [文档指南](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)。如有问题,欢迎通过上文的交流渠道联系我们。
-## 👏 Shoutouts
+## 👏 致谢
-* [zadam](https://github.com/zadam) for the original concept and implementation of the application.
-* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the application icon.
-* [nriver](https://github.com/nriver) for his work on internationalization.
-* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
-* [antoniotejada](https://github.com/nriver) for the original syntax highlight widget.
-* [Dosu](https://dosu.dev/) for providing us with the automated responses to GitHub issues and discussions.
-* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
-
-Trilium would not be possible without the technologies behind it:
-
-* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind text notes. We are grateful for being offered a set of the premium features.
-* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages.
-* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite whiteboard used in Canvas notes.
-* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the mind map functionality.
-* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
-* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
-* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
-* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [link maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
-
-## 🤝 Support
-
-Trilium is built and maintained with [hundreds of hours of work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your support keeps it open-source, improves features, and covers costs such as hosting.
-
-Consider supporting the main developer ([eliandoran](https://github.com/eliandoran)) of the application via:
-
-- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
-- [PayPal](https://paypal.me/eliandoran)
-- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
-
-
-## 🔑 License
-
-Copyright 2017-2025 zadam, Elian Doran, and other contributors
-
-This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+* [zadam](https://github.com/zadam) – 原始概念与实现
+* [Sarah Hussein](https://github.com/Sarah-Hussein) – 应用图标设计
+* [nriver](https://github.com/nriver) – 国际化贡献
+* [Thomas Frei](https://github.com/thfrei) – Canvas 初始实现
+* [antoniotejada](https://github.com/antoniotejada) – 语法高亮小组件初始实现
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index f112508c1..c4b0dd90d 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -1208,6 +1208,7 @@
"anthropic_tab": "Anthropic",
"voyage_tab": "Voyage AI",
"ollama_tab": "Ollama",
+ "minimax_tab": "MiniMax",
"enable_ai": "Enable AI/LLM features",
"enable_ai_desc": "Enable AI features like note summarization, content generation, and other LLM capabilities",
"provider_configuration": "AI Provider Configuration",
@@ -1233,8 +1234,12 @@
"anthropic_model_description": "Anthropic Claude models for chat completion",
"voyage_settings": "Voyage AI Settings",
"ollama_settings": "Ollama Settings",
+ "minimax_settings": "MiniMax Settings",
"ollama_url_description": "URL for the Ollama API (default: http://localhost:11434)",
"ollama_model_description": "Ollama model to use for chat completion",
+ "minimax_api_key_description": "Your MiniMax API key",
+ "minimax_url_description": "Base URL for the MiniMax API (default: https://api.minimax.io/anthropic)",
+ "minimax_model_description": "MiniMax model to use for chat completion",
"anthropic_configuration": "Anthropic Configuration",
"voyage_configuration": "Voyage AI Configuration",
"voyage_url_description": "Default: https://api.voyageai.com/v1",
@@ -1288,7 +1293,8 @@
"anthropic": "Anthropic API key is empty. Please enter a valid API key.",
"openai": "OpenAI API key is empty. Please enter a valid API key.",
"voyage": "Voyage API key is empty. Please enter a valid API key.",
- "ollama": "Ollama API key is empty. Please enter a valid API key."
+ "ollama": "Ollama API key is empty. Please enter a valid API key.",
+ "minimax": "MiniMax API key is empty. Please enter a valid API key."
},
"agent": {
"processing": "Processing...",
diff --git a/apps/client/src/widgets/llm_chat/communication.ts b/apps/client/src/widgets/llm_chat/communication.ts
index ae231ca20..0ff0dde83 100644
--- a/apps/client/src/widgets/llm_chat/communication.ts
+++ b/apps/client/src/widgets/llm_chat/communication.ts
@@ -87,8 +87,10 @@ export async function setupStreamingResponse(
}
});
- if (!streamResponse || !streamResponse.success) {
- console.error(`[${responseId}] Failed to initiate streaming`);
+ // Check for explicit success: false, not just falsy
+ // This handles Electron IPC JSON parsing issues where success might be undefined
+ if (!streamResponse || streamResponse.success === false) {
+ console.error(`[${responseId}] Failed to initiate streaming:`, streamResponse);
reject(new Error('Failed to initiate streaming'));
return;
}
@@ -203,6 +205,10 @@ export async function setupStreamingResponse(
if (message.done) {
console.log(`[${responseId}] Stream completed for chat note ${noteId}, final response: ${assistantResponse.length} chars`);
+ if (!message.content && receivedAnyContent) {
+ onContentUpdate(assistantResponse, true);
+ }
+
// Clear all timeouts
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
@@ -257,4 +263,3 @@ export async function getDirectResponse(noteId: string, messageParams: any): Pro
throw error;
}
}
-
diff --git a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts
index 3187018cf..efaa2720e 100644
--- a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts
+++ b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts
@@ -178,6 +178,11 @@ export default class LlmChatPanel extends BasicWidget {
toolbar: {
items: [] // No toolbar for chat input
},
+ // Remove plugins that require toolbar items but aren't used in chat input
+ removePlugins: [
+ 'Image', 'ImageToolbar', 'ImageCaption', 'ImageStyle', 'ImageResize',
+ 'ImageInsert', 'ImageUpload', 'PictureEditing', 'AutoImage'
+ ],
placeholder: this.noteContextChatInput.getAttribute('data-placeholder') || 'Enter your message...',
mention: {
feeds: mentionSetup
@@ -824,6 +829,10 @@ export default class LlmChatPanel extends BasicWidget {
showLoadingIndicator(this.loadingIndicator);
this.hideSources();
+ // Track assistant messages count before sending to detect if we got a valid response
+ // This handles cases where streaming "fails" but content was already received via WebSocket
+ const assistantMessagesCountBefore = this.messages.filter(m => m.role === 'assistant').length;
+
try {
const useAdvancedContext = this.useAdvancedContextCheckbox.checked;
const showThinking = this.showThinkingCheckbox.checked;
@@ -843,6 +852,19 @@ export default class LlmChatPanel extends BasicWidget {
try {
await this.setupStreamingResponse(messageParams);
} catch (streamingError) {
+ // Check if we already received valid content via WebSocket before the error
+ const assistantMessagesCountAfter = this.messages.filter(m => m.role === 'assistant').length;
+ const receivedValidResponse = assistantMessagesCountAfter > assistantMessagesCountBefore;
+
+ if (receivedValidResponse) {
+ // Streaming had an error but content was already received
+ console.warn("WebSocket streaming ended with error but valid response was already received, ignoring error");
+ hideLoadingIndicator(this.loadingIndicator);
+ // Still save even if there was an error, as we may have received partial content
+ await this.saveCurrentData();
+ return;
+ }
+
console.warn("WebSocket streaming failed, falling back to direct response:", streamingError);
// If streaming fails, fall back to direct response
@@ -859,12 +881,18 @@ export default class LlmChatPanel extends BasicWidget {
console.error('Error processing user message:', error);
toastService.showError('Failed to process message');
- // Add a generic error message to the UI
- this.addMessageToChat('assistant', 'Sorry, I encountered an error processing your message. Please try again.');
- this.messages.push({
- role: 'assistant',
- content: 'Sorry, I encountered an error processing your message. Please try again.'
- });
+ // Double-check if we received valid content before showing error
+ const assistantMessagesCountAfter = this.messages.filter(m => m.role === 'assistant').length;
+ const receivedValidResponse = assistantMessagesCountAfter > assistantMessagesCountBefore;
+
+ // Only add "Sorry" message if we haven't received a valid response
+ if (!receivedValidResponse) {
+ this.addMessageToChat('assistant', 'Sorry, I encountered an error processing your message. Please try again.');
+ this.messages.push({
+ role: 'assistant',
+ content: 'Sorry, I encountered an error processing your message. Please try again.'
+ });
+ }
// Save the data even after error
await this.saveCurrentData();
diff --git a/apps/client/src/widgets/type_widgets/options/ai_settings.tsx b/apps/client/src/widgets/type_widgets/options/ai_settings.tsx
index 3b94a9d13..3ce3cddec 100644
--- a/apps/client/src/widgets/type_widgets/options/ai_settings.tsx
+++ b/apps/client/src/widgets/type_widgets/options/ai_settings.tsx
@@ -61,6 +61,7 @@ function ProviderSettings() {
{ value: "", text: t("ai_llm.select_provider") },
{ value: "openai", text: "OpenAI" },
{ value: "anthropic", text: "Anthropic" },
+ { value: "minimax", text: t("ai_llm.minimax_tab") },
{ value: "ollama", text: "Ollama" }
]}
currentValue={aiSelectedProvider} onChange={setAiSelectedProvider}
@@ -98,6 +99,16 @@ function ProviderSettings() {
baseUrlOption="ollamaBaseUrl"
provider={aiSelectedProvider} modelOption="ollamaDefaultModel"
/>
+ : aiSelectedProvider === "minimax" ?
+
:
<>>
}
@@ -179,7 +190,8 @@ function ModelSelector({ provider, baseUrl, modelOption }: { provider: string; b
const loadProviders = useCallback(async () => {
switch (provider) {
case "openai":
- case "anthropic": {
+ case "anthropic":
+ case "minimax": {
try {
const response = await server.get(`llm/providers/${provider}/models?baseUrl=${encodeURIComponent(baseUrl)}`);
if (response.success) {
diff --git a/apps/server/src/assets/llm/prompts/base_system_prompt.md b/apps/server/src/assets/llm/prompts/base_system_prompt.md
index a79427687..adb8ebc1f 100644
--- a/apps/server/src/assets/llm/prompts/base_system_prompt.md
+++ b/apps/server/src/assets/llm/prompts/base_system_prompt.md
@@ -17,6 +17,10 @@ IMPORTANT: When working with notes in Trilium:
- When tools require a noteId parameter, always use the system ID, not the title
- Always use search tools first to find notes and get their IDs before performing operations on them
- Using a note's title instead of its ID will cause operations to fail
+- Text notes store HTML (Trilium rich text). When creating or updating text notes via tools, provide HTML content. If you must provide Markdown, specify a Markdown MIME type or rely on automatic conversion.
+- Internal links must be reference links: Title. Backlinks are automatic.
+- Non-text note types (mermaid/canvas/mindMap/relationMap/file/image/render/webView/search) have specialized formats or rely on attributes; do not mimic them with HTML.
+- There is no dedicated folder note type; any note can have children.
When responding to queries:
- For complex queries, decompose them into simpler parts and address each one
diff --git a/apps/server/src/routes/api/llm.ts b/apps/server/src/routes/api/llm.ts
index 78573ceda..7ceef38b8 100644
--- a/apps/server/src/routes/api/llm.ts
+++ b/apps/server/src/routes/api/llm.ts
@@ -566,6 +566,9 @@ async function handleStreamingProcess(
throw new Error("No valid AI model configuration found");
}
+ let accumulatedResponse = '';
+ let savedFinalResponse = false;
+
const pipelineInput = {
messages: chat.messages.map(msg => ({
role: msg.role as 'user' | 'assistant' | 'system',
@@ -588,6 +591,14 @@ async function handleStreamingProcess(
};
if (data) {
+ const isCompletePayload = rawChunk?.raw && typeof rawChunk.raw === 'object' && (rawChunk.raw as { complete?: boolean }).complete;
+ if (isCompletePayload) {
+ accumulatedResponse = data;
+ } else if (done && accumulatedResponse && data.startsWith(accumulatedResponse)) {
+ accumulatedResponse = data;
+ } else {
+ accumulatedResponse += data;
+ }
(message as any).content = data;
}
@@ -611,11 +622,16 @@ async function handleStreamingProcess(
wsService.sendMessageToAllClients(message);
// Save final response when done
- if (done && data) {
+ if (done) {
+ const finalResponse = accumulatedResponse || data || '';
+ if (!finalResponse) {
+ return;
+ }
chat.messages.push({
role: 'assistant',
- content: data
+ content: finalResponse
});
+ savedFinalResponse = true;
chatStorageService.updateChat(chat.id, chat.messages, chat.title).catch(err => {
log.error(`Error saving streamed response: ${err}`);
});
@@ -624,7 +640,16 @@ async function handleStreamingProcess(
};
// Execute the pipeline
- await pipeline.execute(pipelineInput);
+ const pipelineResult = await pipeline.execute(pipelineInput);
+ if (!savedFinalResponse && pipelineResult?.text) {
+ chat.messages.push({
+ role: 'assistant',
+ content: pipelineResult.text
+ });
+ chatStorageService.updateChat(chat.id, chat.messages, chat.title).catch(err => {
+ log.error(`Error saving final response after streaming: ${err}`);
+ });
+ }
} catch (error: any) {
log.error(`Error in direct streaming: ${error.message}`);
diff --git a/apps/server/src/routes/api/minimax.ts b/apps/server/src/routes/api/minimax.ts
new file mode 100644
index 000000000..bf3b72186
--- /dev/null
+++ b/apps/server/src/routes/api/minimax.ts
@@ -0,0 +1,70 @@
+import options from "../../services/options.js";
+import log from "../../services/log.js";
+import type { Request, Response } from "express";
+import { PROVIDER_CONSTANTS } from '../../services/llm/constants/provider_constants.js';
+
+/**
+ * @swagger
+ * /api/llm/providers/minimax/models:
+ * get:
+ * summary: List available models from MiniMax
+ * operationId: minimax-list-models
+ * responses:
+ * '200':
+ * description: List of available MiniMax models
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * chatModels:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * name:
+ * type: string
+ * type:
+ * type: string
+ * '500':
+ * description: Error listing models
+ * security:
+ * - session: []
+ * tags: ["llm"]
+ */
+async function listModels(req: Request, res: Response) {
+ try {
+ const apiKey = await options.getOption('minimaxApiKey');
+
+ if (!apiKey) {
+ throw new Error('MiniMax API key is not configured');
+ }
+
+ log.info(`Using predefined MiniMax models list (avoiding direct API call)`);
+
+ const chatModels = PROVIDER_CONSTANTS.MINIMAX.AVAILABLE_MODELS.map(model => ({
+ id: model.id,
+ name: model.name,
+ type: 'chat'
+ }));
+
+ // Return the models list
+ return {
+ success: true,
+ chatModels
+ };
+ } catch (error: any) {
+ log.error(`Error listing MiniMax models: ${error.message || 'Unknown error'}`);
+
+ // Properly throw the error to be handled by the global error handler
+ throw new Error(`Failed to list MiniMax models: ${error.message || 'Unknown error'}`);
+ }
+}
+
+export default {
+ listModels
+};
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index e7377bdfd..9a4c6b69c 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -117,6 +117,9 @@ const ALLOWED_OPTIONS = new Set([
"anthropicDefaultModel",
"ollamaBaseUrl",
"ollamaDefaultModel",
+ "minimaxApiKey",
+ "minimaxBaseUrl",
+ "minimaxDefaultModel",
"mfaEnabled",
"mfaMethod"
]);
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index 9e31d1bca..4d01920d3 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -37,6 +37,7 @@ import keysRoute from "./api/keys.js";
import llmRoute from "./api/llm.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
+import minimaxRoute from "./api/minimax.js";
import noteMapRoute from "./api/note_map.js";
import notesApiRoute from "./api/notes.js";
import ollamaRoute from "./api/ollama.js";
@@ -380,6 +381,7 @@ function register(app: express.Application) {
asyncApiRoute(GET, "/api/llm/providers/ollama/models", ollamaRoute.listModels);
asyncApiRoute(GET, "/api/llm/providers/openai/models", openaiRoute.listModels);
asyncApiRoute(GET, "/api/llm/providers/anthropic/models", anthropicRoute.listModels);
+ asyncApiRoute(GET, "/api/llm/providers/minimax/models", minimaxRoute.listModels);
app.use("", router);
}
diff --git a/apps/server/src/services/llm/ai_interface.ts b/apps/server/src/services/llm/ai_interface.ts
index df8cc6914..ede9933af 100644
--- a/apps/server/src/services/llm/ai_interface.ts
+++ b/apps/server/src/services/llm/ai_interface.ts
@@ -69,6 +69,11 @@ export interface StreamChunk {
*/
raw?: Record;
+ /**
+ * Thinking/reasoning output from models that support it (e.g., MiniMax)
+ */
+ thinking?: string;
+
/**
* Tool calls from the LLM (if any)
* These may be accumulated over multiple chunks during streaming
@@ -213,12 +218,26 @@ export interface ChatResponse {
tool_calls?: ToolCall[] | null;
}
+/**
+ * Normalized chat response used internally by the pipeline.
+ * Guarantees that tool_calls is always an array.
+ */
+export interface NormalizedChatResponse extends ChatResponse {
+ text: string;
+ tool_calls: ToolCall[];
+}
+
export interface AIService {
/**
* Generate a chat completion response
*/
generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise;
+ /**
+ * Normalize provider response for pipeline processing.
+ */
+ toNormalizedResponse(response: ChatResponse): NormalizedChatResponse;
+
/**
* Check if the service can be used (API key is set, etc.)
*/
diff --git a/apps/server/src/services/llm/ai_service_manager.ts b/apps/server/src/services/llm/ai_service_manager.ts
index bd47b4327..963b70f13 100644
--- a/apps/server/src/services/llm/ai_service_manager.ts
+++ b/apps/server/src/services/llm/ai_service_manager.ts
@@ -8,6 +8,7 @@ import contextService from './context/services/context_service.js';
import log from '../log.js';
import { OllamaService } from './providers/ollama_service.js';
import { OpenAIService } from './providers/openai_service.js';
+import { MiniMaxService } from './providers/minimax_service.js';
// Import interfaces
import type {
@@ -176,7 +177,7 @@ export class AIServiceManager implements IAIServiceManager {
getAvailableProviders(): ServiceProviders[] {
this.ensureInitialized();
- const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama'];
+ const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama', 'minimax'];
const availableProviders: ServiceProviders[] = [];
for (const providerName of allProviders) {
@@ -198,6 +199,11 @@ export class AIServiceManager implements IAIServiceManager {
availableProviders.push(providerName);
}
break;
+ case 'minimax':
+ if (options.getOption('minimaxApiKey')) {
+ availableProviders.push(providerName);
+ }
+ break;
}
} catch (error) {
// Ignore configuration errors, provider just won't be available
@@ -440,6 +446,29 @@ export class AIServiceManager implements IAIServiceManager {
}
break;
}
+
+ case 'minimax': {
+ const apiKey = options.getOption('minimaxApiKey');
+ if (!apiKey) {
+ log.info('MiniMax API key not configured');
+ return null;
+ }
+
+ try {
+ service = new MiniMaxService();
+ if (!service.isAvailable()) {
+ throw new Error('MiniMax service not available');
+ }
+ log.info('MiniMax service created successfully');
+ } catch (error: any) {
+ log.error(`Failed to create MiniMax service: ${error.message || String(error)}`);
+ throw new Error(
+ `Failed to initialize MiniMax service: ${error.message}. ` +
+ 'Please check your MiniMax API key and settings.'
+ );
+ }
+ break;
+ }
}
if (service) {
@@ -661,6 +690,8 @@ export class AIServiceManager implements IAIServiceManager {
return !!options.getOption('anthropicApiKey');
case 'ollama':
return !!options.getOption('ollamaBaseUrl');
+ case 'minimax':
+ return !!options.getOption('minimaxApiKey');
default:
return false;
}
@@ -670,26 +701,96 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
- * Get metadata about a provider
- */
+ * Get metadata about a provider
+ */
getProviderMetadata(provider: string): ProviderMetadata | null {
// Only return metadata if this is the current active provider
if (this.currentProvider === provider && this.currentService) {
+ const models = this.getProviderModels(provider);
return {
name: provider,
capabilities: {
chat: true,
streaming: true,
- functionCalling: provider === 'openai' // Only OpenAI has function calling
+ // OpenAI, Anthropic, and MiniMax support function calling
+ functionCalling: ['openai', 'anthropic', 'minimax'].includes(provider)
},
- models: ['default'], // Placeholder, could be populated from the service
- defaultModel: 'default'
+ models: models,
+ defaultModel: this.getDefaultModelForProvider(provider)
};
}
return null;
}
+ /**
+ * Get available models for a specific provider
+ */
+ private getProviderModels(provider: string): string[] {
+ switch (provider) {
+ case 'openai':
+ return [
+ 'gpt-4o',
+ 'gpt-4o-mini',
+ 'gpt-4-turbo',
+ 'gpt-4',
+ 'gpt-3.5-turbo'
+ ];
+ case 'anthropic':
+ return [
+ 'claude-sonnet-4-20250514',
+ 'claude-sonnet-4',
+ 'claude-opus-4',
+ 'claude-sonnet-3-20250520',
+ 'claude-sonnet-3',
+ 'claude-haiku-3',
+ 'claude-3-5-sonnet',
+ 'claude-3-opus',
+ 'claude-3-sonnet',
+ 'claude-3-haiku'
+ ];
+ case 'ollama':
+ // Ollama models are dynamic, return a common set
+ return [
+ 'llama3.3',
+ 'llama3.2',
+ 'llama3.1',
+ 'llama3',
+ 'mistral',
+ 'codellama',
+ 'qwen2.5',
+ 'deepseek-r1',
+ 'deepseek-v3'
+ ];
+ case 'minimax':
+ return [
+ 'MiniMax-M2.1',
+ 'MiniMax-M2.1-lightning',
+ 'MiniMax-M2'
+ ];
+ default:
+ return ['default'];
+ }
+ }
+
+ /**
+ * Get default model for a provider
+ */
+ private getDefaultModelForProvider(provider: string): string {
+ switch (provider) {
+ case 'openai':
+ return 'gpt-4o-mini';
+ case 'anthropic':
+ return 'claude-sonnet-4-20250514';
+ case 'ollama':
+ return 'llama3.3';
+ case 'minimax':
+ return 'MiniMax-M2.1';
+ default:
+ return 'default';
+ }
+ }
+
/**
* Error handler that properly types the error object
diff --git a/apps/server/src/services/llm/base_ai_service.ts b/apps/server/src/services/llm/base_ai_service.ts
index 3c6e05bc7..51e4c3ecd 100644
--- a/apps/server/src/services/llm/base_ai_service.ts
+++ b/apps/server/src/services/llm/base_ai_service.ts
@@ -1,6 +1,7 @@
import options from '../options.js';
-import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
+import type { AIService, ChatCompletionOptions, ChatResponse, Message, NormalizedChatResponse } from './ai_interface.js';
import { DEFAULT_SYSTEM_PROMPT } from './constants/llm_prompt_constants.js';
+import { normalizeChatResponse } from './response_normalizer.js';
export abstract class BaseAIService implements AIService {
protected name: string;
@@ -11,6 +12,10 @@ export abstract class BaseAIService implements AIService {
abstract generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise;
+ toNormalizedResponse(response: ChatResponse): NormalizedChatResponse {
+ return normalizeChatResponse(response);
+ }
+
isAvailable(): boolean {
return options.getOptionBool('aiEnabled'); // Base check if AI is enabled globally
}
diff --git a/apps/server/src/services/llm/chat_storage_service.spec.ts b/apps/server/src/services/llm/chat_storage_service.spec.ts
index d820d7497..a29095d3a 100644
--- a/apps/server/src/services/llm/chat_storage_service.spec.ts
+++ b/apps/server/src/services/llm/chat_storage_service.spec.ts
@@ -227,6 +227,10 @@ describe('ChatStorageService', () => {
expect.stringContaining('SELECT notes.noteId, notes.title'),
['label', 'triliumChat']
);
+ expect(mockSql.getRows).toHaveBeenCalledWith(
+ expect.stringContaining('notes.isDeleted = 0'),
+ ['label', 'triliumChat']
+ );
});
it('should handle chats with invalid JSON content', async () => {
@@ -291,12 +295,12 @@ describe('ChatStorageService', () => {
});
expect(mockSql.getRow).toHaveBeenCalledWith(
- expect.stringContaining('SELECT notes.noteId, notes.title'),
+ expect.stringContaining('notes.isDeleted = 0'),
['chat-123']
);
});
- it('should return null if chat not found', async () => {
+ it('should return null if chat not found or deleted', async () => {
mockSql.getRow.mockResolvedValueOnce(null);
const result = await chatStorageService.getChat('nonexistent');
diff --git a/apps/server/src/services/llm/chat_storage_service.ts b/apps/server/src/services/llm/chat_storage_service.ts
index 0c84b23f7..528151a46 100644
--- a/apps/server/src/services/llm/chat_storage_service.ts
+++ b/apps/server/src/services/llm/chat_storage_service.ts
@@ -126,7 +126,7 @@ export class ChatStorageService {
}
/**
- * Get all chats
+ * Get all chats (excludes soft-deleted chats)
*/
async getAllChats(): Promise {
const chats = await sql.getRows<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
@@ -135,6 +135,7 @@ export class ChatStorageService {
JOIN blobs ON notes.blobId = blobs.blobId
JOIN attributes ON notes.noteId = attributes.noteId
WHERE attributes.name = ? AND attributes.value = ?
+ AND notes.isDeleted = 0
ORDER BY notes.dateModified DESC`,
['label', ChatStorageService.CHAT_LABEL]
);
@@ -174,14 +175,14 @@ export class ChatStorageService {
}
/**
- * Get a specific chat
+ * Get a specific chat (returns null if not found or soft-deleted)
*/
async getChat(chatId: string): Promise {
const chat = await sql.getRow<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
`SELECT notes.noteId, notes.title, notes.dateCreated, notes.dateModified, blobs.content
FROM notes
JOIN blobs ON notes.blobId = blobs.blobId
- WHERE notes.noteId = ?`,
+ WHERE notes.noteId = ? AND notes.isDeleted = 0`,
[chatId]
);
diff --git a/apps/server/src/services/llm/config/configuration_helpers.ts b/apps/server/src/services/llm/config/configuration_helpers.ts
index 9f92164a2..092aef4a1 100644
--- a/apps/server/src/services/llm/config/configuration_helpers.ts
+++ b/apps/server/src/services/llm/config/configuration_helpers.ts
@@ -43,7 +43,7 @@ export function parseModelIdentifier(modelString: string): ModelIdentifier {
// Check if first part is a known provider
const potentialProvider = parts[0].toLowerCase();
- const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
+ const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama', 'minimax'];
if (knownProviders.includes(potentialProvider as ProviderType)) {
// Provider prefix format
@@ -87,8 +87,8 @@ export async function getDefaultModelForProvider(provider: ProviderType): Promis
}
/**
- * Get provider settings for a specific provider - always fresh from options
- */
+ * Get provider settings for a specific provider - always fresh from options
+ */
export async function getProviderSettings(provider: ProviderType) {
switch (provider) {
case 'openai':
@@ -108,6 +108,12 @@ export async function getProviderSettings(provider: ProviderType) {
baseUrl: optionService.getOption('ollamaBaseUrl'),
defaultModel: optionService.getOption('ollamaDefaultModel')
};
+ case 'minimax':
+ return {
+ apiKey: optionService.getOption('minimaxApiKey'),
+ baseUrl: optionService.getOption('minimaxBaseUrl'),
+ defaultModel: optionService.getOption('minimaxDefaultModel')
+ };
default:
return {};
}
@@ -121,8 +127,8 @@ export async function isAIEnabled(): Promise {
}
/**
- * Check if a provider has required configuration
- */
+ * Check if a provider has required configuration
+ */
export async function isProviderConfigured(provider: ProviderType): Promise {
const settings = await getProviderSettings(provider);
@@ -133,6 +139,8 @@ export async function isProviderConfigured(provider: ProviderType): Promise> {
try {
const openaiModel = options.getOption('openaiDefaultModel');
const anthropicModel = options.getOption('anthropicDefaultModel');
const ollamaModel = options.getOption('ollamaDefaultModel');
+ const minimaxModel = options.getOption('minimaxDefaultModel');
return {
openai: openaiModel || undefined,
anthropic: anthropicModel || undefined,
- ollama: ollamaModel || undefined
+ ollama: ollamaModel || undefined,
+ minimax: minimaxModel || undefined
};
} catch (error) {
log.error(`Error loading default models: ${error}`);
@@ -143,14 +146,15 @@ export class ConfigurationManager {
return {
openai: undefined,
anthropic: undefined,
- ollama: undefined
+ ollama: undefined,
+ minimax: undefined
};
}
}
/**
- * Get provider-specific settings
- */
+ * Get provider-specific settings
+ */
public async getProviderSettings(): Promise {
try {
const openaiApiKey = options.getOption('openaiApiKey');
@@ -161,6 +165,9 @@ export class ConfigurationManager {
const anthropicDefaultModel = options.getOption('anthropicDefaultModel');
const ollamaBaseUrl = options.getOption('ollamaBaseUrl');
const ollamaDefaultModel = options.getOption('ollamaDefaultModel');
+ const minimaxApiKey = options.getOption('minimaxApiKey');
+ const minimaxBaseUrl = options.getOption('minimaxBaseUrl');
+ const minimaxDefaultModel = options.getOption('minimaxDefaultModel');
const settings: ProviderSettings = {};
@@ -187,6 +194,14 @@ export class ConfigurationManager {
};
}
+ if (minimaxApiKey || minimaxBaseUrl || minimaxDefaultModel) {
+ settings.minimax = {
+ apiKey: minimaxApiKey,
+ baseUrl: minimaxBaseUrl,
+ defaultModel: minimaxDefaultModel
+ };
+ }
+
return settings;
} catch (error) {
log.error(`Error loading provider settings: ${error}`);
@@ -240,6 +255,13 @@ export class ConfigurationManager {
result.warnings.push('Ollama base URL is not configured');
}
}
+
+ if (config.selectedProvider === 'minimax') {
+ const minimaxConfig = providerConfig as MiniMaxSettings | undefined;
+ if (!minimaxConfig?.apiKey) {
+ result.warnings.push('MiniMax API key is not configured');
+ }
+ }
}
@@ -298,7 +320,8 @@ export class ConfigurationManager {
defaultModels: {
openai: undefined,
anthropic: undefined,
- ollama: undefined
+ ollama: undefined,
+ minimax: undefined
},
providerSettings: {}
};
diff --git a/apps/server/src/services/llm/constants/llm_prompt_constants.ts b/apps/server/src/services/llm/constants/llm_prompt_constants.ts
index 1942e184f..d5e256d7d 100644
--- a/apps/server/src/services/llm/constants/llm_prompt_constants.ts
+++ b/apps/server/src/services/llm/constants/llm_prompt_constants.ts
@@ -195,7 +195,7 @@ When using tools to search for information, follow these requirements:
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
- Use synonyms (e.g., "meeting" instead of "conference")
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
- - Try different search tools (vector_search for conceptual matches, keyword_search for exact matches)
+ - Try different search tools (search_notes for conceptual matches, keyword_search_notes for exact matches)
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do
@@ -230,7 +230,7 @@ Be concise and informative in your responses.
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
- Use synonyms (e.g., "meeting" instead of "conference")
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
- - Try different search tools (vector_search for conceptual matches, keyword_search for exact matches)
+ - Try different search tools (search_notes for conceptual matches, keyword_search_notes for exact matches)
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do`
@@ -253,13 +253,56 @@ CRITICAL INSTRUCTIONS FOR TOOL USAGE:
3. If a search returns no results, IMMEDIATELY TRY ANOTHER SEARCH with different parameters:
- Use broader terms: If "Kubernetes deployment" fails, try just "Kubernetes" or "container orchestration"
- Try synonyms: If "meeting notes" fails, try "conference", "discussion", or "conversation"
- - Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report"
- - Try semantic variations: If keyword_search fails, use vector_search which finds conceptually related content
-4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool
-5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations
-6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches
-7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead"
-8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes`
+ - Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report"
+ - Try semantic variations: If keyword_search_notes fails, use search_notes which finds conceptually related content
+ 4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool
+ 5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations
+ 6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches
+ 7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead"
+ 8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes`
+ },
+
+ MINIMAX: {
+ // MiniMax uses Anthropic-compatible API, so we use similar prompts
+ SYSTEM_WITH_CONTEXT: (context: string) =>
+ `
+${DEFAULT_SYSTEM_PROMPT}
+
+Use the following information from the user's notes to answer their questions:
+
+
+${context}
+
+
+When responding:
+- Focus on the most relevant information from the notes
+- Be concise and direct in your answers
+- If quoting from notes, mention which note it's from
+- If the notes don't contain relevant information, say so clearly
+`,
+
+ INSTRUCTIONS_WRAPPER: (instructions: string) =>
+ `\n${instructions}\n`,
+
+ // Tool instructions for MiniMax (Anthropic-compatible)
+ TOOL_INSTRUCTIONS: `
+When using tools to search for information, follow these requirements:
+
+1. ALWAYS TRY MULTIPLE SEARCH APPROACHES before concluding information isn't available
+2. YOU MUST PERFORM AT LEAST 3 DIFFERENT SEARCHES with varied parameters before giving up
+3. If a search returns no results:
+ - Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
+ - Use synonyms (e.g., "meeting" instead of "conference")
+ - Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
+ - Try different search tools (search_notes for conceptual matches, keyword_search_notes for exact matches)
+4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
+5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
+6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do
+`,
+
+ ACKNOWLEDGMENT: "I understand. I'll follow those instructions.",
+ CONTEXT_ACKNOWLEDGMENT: "I'll help you with your notes based on the context provided.",
+ CONTEXT_QUERY_ACKNOWLEDGMENT: "I'll help you with your notes based on the context provided. What would you like to know?"
},
// Common prompts across providers
diff --git a/apps/server/src/services/llm/constants/provider_constants.ts b/apps/server/src/services/llm/constants/provider_constants.ts
index 65b5b4749..37d54a296 100644
--- a/apps/server/src/services/llm/constants/provider_constants.ts
+++ b/apps/server/src/services/llm/constants/provider_constants.ts
@@ -106,6 +106,56 @@ export const PROVIDER_CONSTANTS = {
mixtral: 8192,
'mistral': 8192
}
+ },
+
+ /**
+ * MiniMax provider constants
+ * Uses Anthropic-compatible API endpoint
+ * Documentation: https://platform.minimax.io/docs/
+ */
+ MINIMAX: {
+ BASE_URL: 'https://api.minimax.io/anthropic',
+ DEFAULT_MODEL: 'MiniMax-M2.1',
+ API_VERSION: '2023-06-01',
+ CONTEXT_WINDOW: 200000,
+ AVAILABLE_MODELS: [
+ {
+ id: 'MiniMax-M2.1',
+ name: 'MiniMax M2.1',
+ description: 'Full capability model with 230B parameters, 10B activated',
+ maxTokens: 128000,
+ contextWindow: 200000,
+ capabilities: {
+ supportsTools: true,
+ supportsStreaming: true,
+ supportsVision: false
+ }
+ },
+ {
+ id: 'MiniMax-M2.1-lightning',
+ name: 'MiniMax M2.1 Lightning',
+ description: 'Fast model for low-latency responses (~100 tps)',
+ maxTokens: 128000,
+ contextWindow: 200000,
+ capabilities: {
+ supportsTools: true,
+ supportsStreaming: true,
+ supportsVision: false
+ }
+ },
+ {
+ id: 'MiniMax-M2',
+ name: 'MiniMax M2',
+ description: 'Balanced model with agentic capabilities, 200k context',
+ maxTokens: 128000,
+ contextWindow: 200000,
+ capabilities: {
+ supportsTools: true,
+ supportsStreaming: true,
+ supportsVision: false
+ }
+ }
+ ]
}
} as const;
@@ -158,5 +208,8 @@ export const LLM_CONSTANTS = {
// AI Feature Exclusion
AI_EXCLUSION: {
LABEL_NAME: 'aiExclude' // Label used to exclude notes from all AI/LLM features
- }
+ },
+
+ // MiniMax provider constants (for anthropic-compatible API)
+ MINIMAX: 'minimax'
};
diff --git a/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts b/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts
index 6fffbce70..595c85457 100644
--- a/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts
+++ b/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts
@@ -38,7 +38,7 @@ export interface IAIServiceManager {
/**
* Type for service providers
*/
-export type ServiceProviders = 'openai' | 'anthropic' | 'ollama';
+export type ServiceProviders = 'openai' | 'anthropic' | 'ollama' | 'minimax';
/**
* LLM model configuration
diff --git a/apps/server/src/services/llm/interfaces/configuration_interfaces.ts b/apps/server/src/services/llm/interfaces/configuration_interfaces.ts
index 15bb88f0a..365bdbee0 100644
--- a/apps/server/src/services/llm/interfaces/configuration_interfaces.ts
+++ b/apps/server/src/services/llm/interfaces/configuration_interfaces.ts
@@ -51,6 +51,7 @@ export interface ProviderSettings {
openai?: OpenAISettings;
anthropic?: AnthropicSettings;
ollama?: OllamaSettings;
+ minimax?: MiniMaxSettings;
}
export interface OpenAISettings {
@@ -71,10 +72,16 @@ export interface OllamaSettings {
timeout?: number;
}
+export interface MiniMaxSettings {
+ apiKey?: string;
+ baseUrl?: string;
+ defaultModel?: string;
+}
+
/**
* Valid provider types
*/
-export type ProviderType = 'openai' | 'anthropic' | 'ollama';
+export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'minimax';
/**
diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline.ts b/apps/server/src/services/llm/pipeline/chat_pipeline.ts
index 60c5df87c..2224b71cb 100644
--- a/apps/server/src/services/llm/pipeline/chat_pipeline.ts
+++ b/apps/server/src/services/llm/pipeline/chat_pipeline.ts
@@ -1,5 +1,5 @@
import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js';
-import type { ChatResponse, StreamChunk, Message } from '../ai_interface.js';
+import type { ChatResponse } from '../ai_interface.js';
import { ContextExtractionStage } from './stages/context_extraction_stage.js';
import { SemanticContextExtractionStage } from './stages/semantic_context_extraction_stage.js';
import { AgentToolsContextStage } from './stages/agent_tools_context_stage.js';
@@ -10,10 +10,12 @@ import { ResponseProcessingStage } from './stages/response_processing_stage.js';
import { ToolCallingStage } from './stages/tool_calling_stage.js';
// Traditional search is used instead of vector search
import toolRegistry from '../tools/tool_registry.js';
-import toolInitializer from '../tools/tool_initializer.js';
import log from '../../log.js';
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
+import { DefaultStreamingStrategy } from './streaming/default_streaming_strategy.js';
+import type { StreamingStrategy } from './streaming/streaming_strategy.js';
+import { ChatPipelineToolLoop } from './chat_pipeline_tool_loop.js';
/**
* Pipeline for managing the entire chat flow
@@ -34,6 +36,7 @@ export class ChatPipeline {
config: ChatPipelineConfig;
metrics: PipelineMetrics;
+ streamingStrategy: StreamingStrategy;
/**
* Create a new chat pipeline
@@ -68,6 +71,8 @@ export class ChatPipeline {
stageMetrics: {}
};
+ this.streamingStrategy = new DefaultStreamingStrategy();
+
// Initialize stage metrics
Object.keys(this.stages).forEach(stageName => {
this.metrics.stageMetrics[stageName] = {
@@ -88,8 +93,8 @@ export class ChatPipeline {
this.metrics.totalExecutions++;
// Initialize streaming handler if requested
- let streamCallback = input.streamCallback;
- let accumulatedText = '';
+ const streamCallback = input.streamCallback;
+ const accumulatedText = '';
try {
// Extract content length for model selection
@@ -127,7 +132,7 @@ export class ChatPipeline {
log.info(`Selected model: ${modelSelection.options.model || 'default'}, enableTools: ${modelSelection.options.enableTools}`);
// Determine if we should use tools or semantic context
- const useTools = modelSelection.options.enableTools === true;
+ const useTools = modelSelection.options.enableTools !== false;
const useEnhancedContext = input.options?.useAdvancedContext === true;
// Log details about the advanced context parameter
@@ -241,584 +246,57 @@ export class ChatPipeline {
// Setup streaming handler if streaming is enabled and callback provided
// Check if streaming should be enabled based on several conditions
- const streamEnabledInConfig = this.config.enableStreaming;
- const streamFormatRequested = input.format === 'stream';
- const streamRequestedInOptions = modelSelection.options.stream === true;
const streamCallbackAvailable = typeof streamCallback === 'function';
+ const providerName = modelSelection.options.providerMetadata?.provider;
log.info(`[ChatPipeline] Request type info - Format: ${input.format || 'not specified'}, Options from pipelineInput: ${JSON.stringify({stream: input.options?.stream})}`);
- log.info(`[ChatPipeline] Stream settings - config.enableStreaming: ${streamEnabledInConfig}, format parameter: ${input.format}, modelSelection.options.stream: ${modelSelection.options.stream}, streamCallback available: ${streamCallbackAvailable}`);
+ log.info(`[ChatPipeline] Stream settings - config.enableStreaming: ${this.config.enableStreaming}, format parameter: ${input.format}, modelSelection.options.stream: ${modelSelection.options.stream}, streamCallback available: ${streamCallbackAvailable}`);
- // IMPORTANT: Respect the existing stream option but with special handling for callbacks:
- // 1. If a stream callback is available, streaming MUST be enabled for it to work
- // 2. Otherwise, preserve the original stream setting from input options
+ const streamingDecision = this.streamingStrategy.resolveInitialStreaming({
+ configEnableStreaming: this.config.enableStreaming,
+ format: typeof input.format === 'string' ? input.format : undefined,
+ optionStream: modelSelection.options.stream,
+ hasStreamCallback: streamCallbackAvailable,
+ providerName,
+ toolsEnabled: useTools
+ });
- // First, determine what the stream value should be based on various factors:
- let shouldEnableStream = modelSelection.options.stream;
+ const shouldEnableStream = streamingDecision.clientStream;
+ const providerStream = streamingDecision.providerStream;
- if (streamCallbackAvailable) {
- // If we have a stream callback, we NEED to enable streaming
- // This is critical for GET requests with EventSource
- shouldEnableStream = true;
- log.info(`[ChatPipeline] Stream callback available, enabling streaming`);
- } else if (streamRequestedInOptions) {
- // Stream was explicitly requested in options, honor that setting
- log.info(`[ChatPipeline] Stream explicitly requested in options: ${streamRequestedInOptions}`);
- shouldEnableStream = streamRequestedInOptions;
- } else if (streamFormatRequested) {
- // Format=stream parameter indicates streaming was requested
- log.info(`[ChatPipeline] Stream format requested in parameters`);
- shouldEnableStream = true;
- } else {
- // No explicit streaming indicators, use config default
- log.info(`[ChatPipeline] No explicit stream settings, using config default: ${streamEnabledInConfig}`);
- shouldEnableStream = streamEnabledInConfig;
- }
-
- // Set the final stream option
modelSelection.options.stream = shouldEnableStream;
log.info(`[ChatPipeline] Final streaming decision: stream=${shouldEnableStream}, will stream to client=${streamCallbackAvailable && shouldEnableStream}`);
+ if (shouldEnableStream !== providerStream) {
+ log.info(`[ChatPipeline] Provider streaming adjusted: providerStream=${providerStream}`);
+ }
- // STAGE 5 & 6: Handle LLM completion and tool execution loop
- log.info(`========== STAGE 5: LLM COMPLETION ==========`);
- const llmStartTime = Date.now();
- const completion = await this.stages.llmCompletion.execute({
- messages: preparedMessages.messages,
- options: modelSelection.options
+ const toolLoop = new ChatPipelineToolLoop({
+ stages: {
+ llmCompletion: this.stages.llmCompletion,
+ toolCalling: this.stages.toolCalling,
+ responseProcessing: this.stages.responseProcessing
+ },
+ streamingStrategy: this.streamingStrategy,
+ updateStageMetrics: this.updateStageMetrics.bind(this)
});
- this.updateStageMetrics('llmCompletion', llmStartTime);
- log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
- // Track whether content has been streamed to prevent duplication
- let hasStreamedContent = false;
-
- // Handle streaming if enabled and available
- // Use shouldEnableStream variable which contains our streaming decision
- if (shouldEnableStream && completion.response.stream && streamCallback) {
- // Setup stream handler that passes chunks through response processing
- await completion.response.stream(async (chunk: StreamChunk) => {
- // Process the chunk text
- const processedChunk = await this.processStreamChunk(chunk, input.options);
-
- // Accumulate text for final response
- accumulatedText += processedChunk.text;
-
- // Forward to callback with original chunk data in case it contains additional information
- streamCallback(processedChunk.text, processedChunk.done, chunk);
-
- // Mark that we have streamed content to prevent duplication
- hasStreamedContent = true;
- });
- }
-
- // Process any tool calls in the response
- let currentMessages = preparedMessages.messages;
- let currentResponse = completion.response;
- let toolCallIterations = 0;
- const maxToolCallIterations = this.config.maxToolCallIterations;
-
- // Check if tools were enabled in the options
- const toolsEnabled = modelSelection.options.enableTools !== false;
-
- // Log decision points for tool execution
- log.info(`========== TOOL EXECUTION DECISION ==========`);
- log.info(`Tools enabled in options: ${toolsEnabled}`);
- log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
- log.info(`Response model: ${currentResponse.model || 'unknown'}`);
-
- // Enhanced tool_calls detection - check both direct property and getter
- let hasToolCalls = false;
-
- log.info(`[TOOL CALL DEBUG] Starting tool call detection for provider: ${currentResponse.provider}`);
- // Check response object structure
- log.info(`[TOOL CALL DEBUG] Response properties: ${Object.keys(currentResponse).join(', ')}`);
-
- // Try to access tool_calls as a property
- if ('tool_calls' in currentResponse) {
- log.info(`[TOOL CALL DEBUG] tool_calls exists as a direct property`);
- log.info(`[TOOL CALL DEBUG] tool_calls type: ${typeof currentResponse.tool_calls}`);
-
- if (currentResponse.tool_calls && Array.isArray(currentResponse.tool_calls)) {
- log.info(`[TOOL CALL DEBUG] tool_calls is an array with length: ${currentResponse.tool_calls.length}`);
- } else {
- log.info(`[TOOL CALL DEBUG] tool_calls is not an array or is empty: ${JSON.stringify(currentResponse.tool_calls)}`);
- }
- } else {
- log.info(`[TOOL CALL DEBUG] tool_calls does not exist as a direct property`);
- }
-
- // First check the direct property
- if (currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
- hasToolCalls = true;
- log.info(`Response has tool_calls property with ${currentResponse.tool_calls.length} tools`);
- log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`);
- }
- // Check if it might be a getter (for dynamic tool_calls collection)
- else {
- log.info(`[TOOL CALL DEBUG] Direct property check failed, trying getter approach`);
- try {
- const toolCallsDesc = Object.getOwnPropertyDescriptor(currentResponse, 'tool_calls');
-
- if (toolCallsDesc) {
- log.info(`[TOOL CALL DEBUG] Found property descriptor for tool_calls: ${JSON.stringify({
- configurable: toolCallsDesc.configurable,
- enumerable: toolCallsDesc.enumerable,
- hasGetter: !!toolCallsDesc.get,
- hasSetter: !!toolCallsDesc.set
- })}`);
- } else {
- log.info(`[TOOL CALL DEBUG] No property descriptor found for tool_calls`);
- }
-
- if (toolCallsDesc && typeof toolCallsDesc.get === 'function') {
- log.info(`[TOOL CALL DEBUG] Attempting to call the tool_calls getter`);
- const dynamicToolCalls = toolCallsDesc.get.call(currentResponse);
-
- log.info(`[TOOL CALL DEBUG] Getter returned: ${JSON.stringify(dynamicToolCalls)}`);
-
- if (dynamicToolCalls && dynamicToolCalls.length > 0) {
- hasToolCalls = true;
- log.info(`Response has dynamic tool_calls with ${dynamicToolCalls.length} tools`);
- log.info(`Dynamic tool calls details: ${JSON.stringify(dynamicToolCalls)}`);
- // Ensure property is available for subsequent code
- currentResponse.tool_calls = dynamicToolCalls;
- log.info(`[TOOL CALL DEBUG] Updated currentResponse.tool_calls with dynamic values`);
- } else {
- log.info(`[TOOL CALL DEBUG] Getter returned no valid tool calls`);
- }
- } else {
- log.info(`[TOOL CALL DEBUG] No getter function found for tool_calls`);
- }
- } catch (e: any) {
- log.error(`Error checking dynamic tool_calls: ${e}`);
- log.error(`[TOOL CALL DEBUG] Error details: ${e.stack || 'No stack trace'}`);
- }
- }
-
- log.info(`Response has tool_calls: ${hasToolCalls ? 'true' : 'false'}`);
- if (hasToolCalls && currentResponse.tool_calls) {
- log.info(`[TOOL CALL DEBUG] Final tool_calls that will be used: ${JSON.stringify(currentResponse.tool_calls)}`);
- }
-
- // Tool execution loop
- if (toolsEnabled && hasToolCalls && currentResponse.tool_calls) {
- log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
- log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
-
- // Format tool calls for logging
- log.info(`========== TOOL CALL DETAILS ==========`);
- currentResponse.tool_calls.forEach((toolCall, idx) => {
- log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
- log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
- });
-
- // Keep track of whether we're in a streaming response
- const isStreaming = shouldEnableStream && streamCallback;
- let streamingPaused = false;
-
- // If streaming was enabled, send an update to the user
- if (isStreaming && streamCallback) {
- streamingPaused = true;
- // Send a dedicated message with a specific type for tool execution
- streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: {
- type: 'start',
- tool: {
- name: 'tool_execution',
- arguments: {}
- }
- }
- });
- }
-
- while (toolCallIterations < maxToolCallIterations) {
- toolCallIterations++;
- log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
-
- // Create a copy of messages before tool execution
- const previousMessages = [...currentMessages];
-
- try {
- const toolCallingStartTime = Date.now();
- log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
- log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
- log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
-
- const toolCallingResult = await this.stages.toolCalling.execute({
- response: currentResponse,
- messages: currentMessages,
- options: modelSelection.options
- });
- this.updateStageMetrics('toolCalling', toolCallingStartTime);
-
- log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
-
- // Update messages with tool results
- currentMessages = toolCallingResult.messages;
-
- // Log the tool results for debugging
- const toolResultMessages = currentMessages.filter(
- msg => msg.role === 'tool' && !previousMessages.includes(msg)
- );
-
- log.info(`========== TOOL EXECUTION RESULTS ==========`);
- log.info(`Received ${toolResultMessages.length} tool results`);
- toolResultMessages.forEach((msg, idx) => {
- log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content}`);
- log.info(`Tool result status: ${msg.content.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`);
- log.info(`Tool result for: ${this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '')}`);
-
- // If streaming, show tool executions to the user
- if (isStreaming && streamCallback) {
- // For each tool result, format a readable message for the user
- const toolName = this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
-
- // Create a structured tool result message
- // The client will receive this structured data and can display it properly
- try {
- // Parse the result content if it's JSON
- let parsedContent = msg.content;
- try {
- // Check if the content is JSON
- if (msg.content.trim().startsWith('{') || msg.content.trim().startsWith('[')) {
- parsedContent = JSON.parse(msg.content);
- }
- } catch (e) {
- // If parsing fails, keep the original content
- log.info(`Could not parse tool result as JSON: ${e}`);
- }
-
- // Send the structured tool result directly so the client has the raw data
- streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: {
- type: 'complete',
- tool: {
- name: toolName,
- arguments: {}
- },
- result: parsedContent
- }
- });
-
- // No longer need to send formatted text version
- // The client should use the structured data instead
- } catch (err) {
- log.error(`Error sending structured tool result: ${err}`);
- // Use structured format here too instead of falling back to text format
- streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: {
- type: 'complete',
- tool: {
- name: toolName || 'unknown',
- arguments: {}
- },
- result: msg.content
- }
- });
- }
- }
- });
-
- // Check if we need another LLM completion for tool results
- if (toolCallingResult.needsFollowUp) {
- log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
- log.info('Tool execution complete, sending results back to LLM');
-
- // Ensure messages are properly formatted
- this.validateToolMessages(currentMessages);
-
- // If streaming, show progress to the user
- if (isStreaming && streamCallback) {
- streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: {
- type: 'update',
- tool: {
- name: 'tool_processing',
- arguments: {}
- }
- }
- });
- }
-
- // Extract tool execution status information for Ollama feedback
- let toolExecutionStatus;
-
- if (currentResponse.provider === 'Ollama') {
- // Collect tool execution status from the tool results
- toolExecutionStatus = toolResultMessages.map(msg => {
- // Determine if this was a successful tool call
- const isError = msg.content.startsWith('Error:');
- return {
- toolCallId: msg.tool_call_id || '',
- name: msg.name || 'unknown',
- success: !isError,
- result: msg.content,
- error: isError ? msg.content.substring(7) : undefined
- };
- });
-
- log.info(`Created tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
- toolExecutionStatus.forEach((status, idx) => {
- log.info(`Tool status ${idx + 1}: ${status.name} - ${status.success ? 'success' : 'failed'}`);
- });
- }
-
- // Generate a new completion with the updated messages
- const followUpStartTime = Date.now();
-
- // Log messages being sent to LLM for tool follow-up
- log.info(`========== SENDING TOOL RESULTS TO LLM FOR FOLLOW-UP ==========`);
- log.info(`Total messages being sent: ${currentMessages.length}`);
- // Log the most recent messages (last 3) for clarity
- const recentMessages = currentMessages.slice(-3);
- recentMessages.forEach((msg, idx) => {
- const position = currentMessages.length - recentMessages.length + idx;
- log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
- if (msg.tool_calls) {
- log.info(` Has ${msg.tool_calls.length} tool calls`);
- }
- if (msg.tool_call_id) {
- log.info(` Tool call ID: ${msg.tool_call_id}`);
- }
- });
-
- log.info(`LLM follow-up request options: ${JSON.stringify({
- model: modelSelection.options.model,
- enableTools: true,
- stream: modelSelection.options.stream,
- provider: currentResponse.provider
- })}`);
-
- const followUpCompletion = await this.stages.llmCompletion.execute({
- messages: currentMessages,
- options: {
- ...modelSelection.options,
- // Ensure tool support is still enabled for follow-up requests
- enableTools: true,
- // Preserve original streaming setting for tool execution follow-ups
- stream: modelSelection.options.stream,
- // Add tool execution status for Ollama provider
- ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
- }
- });
- this.updateStageMetrics('llmCompletion', followUpStartTime);
-
- // Log the follow-up response from the LLM
- log.info(`========== LLM FOLLOW-UP RESPONSE RECEIVED ==========`);
- log.info(`Follow-up response model: ${followUpCompletion.response.model}, provider: ${followUpCompletion.response.provider}`);
- log.info(`Follow-up response text: ${followUpCompletion.response.text?.substring(0, 150)}${followUpCompletion.response.text?.length > 150 ? '...' : ''}`);
- log.info(`Follow-up contains tool calls: ${!!followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0}`);
- if (followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0) {
- log.info(`Follow-up has ${followUpCompletion.response.tool_calls.length} new tool calls`);
- }
-
- // Update current response for the next iteration
- currentResponse = followUpCompletion.response;
-
- // Check if we need to continue the tool calling loop
- if (!currentResponse.tool_calls || currentResponse.tool_calls.length === 0) {
- log.info(`========== TOOL EXECUTION COMPLETE ==========`);
- log.info('No more tool calls, breaking tool execution loop');
- break;
- } else {
- log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
- log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
- // Log the next set of tool calls
- currentResponse.tool_calls.forEach((toolCall, idx) => {
- log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
- log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
- });
- }
- } else {
- log.info(`========== TOOL EXECUTION COMPLETE ==========`);
- log.info('No follow-up needed, breaking tool execution loop');
- break;
- }
- } catch (error: any) {
- log.info(`========== TOOL EXECUTION ERROR ==========`);
- log.error(`Error in tool execution: ${error.message || String(error)}`);
-
- // Add error message to the conversation if tool execution fails
- currentMessages.push({
- role: 'system',
- content: `Error executing tool: ${error.message || String(error)}. Please try a different approach.`
- });
-
- // If streaming, show error to the user
- if (isStreaming && streamCallback) {
- streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: {
- type: 'error',
- tool: {
- name: 'unknown',
- arguments: {}
- },
- result: error.message || 'unknown error'
- }
- });
- }
-
- // For Ollama, create tool execution status with the error
- let toolExecutionStatus;
- if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
- // We need to create error statuses for all tool calls that failed
- toolExecutionStatus = currentResponse.tool_calls.map(toolCall => {
- return {
- toolCallId: toolCall.id || '',
- name: toolCall.function?.name || 'unknown',
- success: false,
- result: `Error: ${error.message || 'unknown error'}`,
- error: error.message || 'unknown error'
- };
- });
-
- log.info(`Created error tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
- }
-
- // Make a follow-up request to the LLM with the error information
- const errorFollowUpCompletion = await this.stages.llmCompletion.execute({
- messages: currentMessages,
- options: {
- ...modelSelection.options,
- // Preserve streaming for error follow-up
- stream: modelSelection.options.stream,
- // For Ollama, include tool execution status
- ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
- }
- });
-
- // Log the error follow-up response from the LLM
- log.info(`========== ERROR FOLLOW-UP RESPONSE RECEIVED ==========`);
- log.info(`Error follow-up response model: ${errorFollowUpCompletion.response.model}, provider: ${errorFollowUpCompletion.response.provider}`);
- log.info(`Error follow-up response text: ${errorFollowUpCompletion.response.text?.substring(0, 150)}${errorFollowUpCompletion.response.text?.length > 150 ? '...' : ''}`);
- log.info(`Error follow-up contains tool calls: ${!!errorFollowUpCompletion.response.tool_calls && errorFollowUpCompletion.response.tool_calls.length > 0}`);
-
- // Update current response and break the tool loop
- currentResponse = errorFollowUpCompletion.response;
- break;
- }
- }
-
- if (toolCallIterations >= maxToolCallIterations) {
- log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
- log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
-
- // Add a message to inform the LLM that we've reached the limit
- currentMessages.push({
- role: 'system',
- content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
- });
-
- // If streaming, inform the user about iteration limit
- if (isStreaming && streamCallback) {
- streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
- }
-
- // For Ollama, create a status about reaching max iterations
- let toolExecutionStatus;
- if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
- // Create a special status message about max iterations
- toolExecutionStatus = [
- {
- toolCallId: 'max-iterations',
- name: 'system',
- success: false,
- result: `Maximum tool call iterations (${maxToolCallIterations}) reached.`,
- error: `Reached the maximum number of allowed tool calls (${maxToolCallIterations}). Please provide a final response with the information gathered so far.`
- }
- ];
-
- log.info(`Created max iterations status for Ollama`);
- }
-
- // Make a final request to get a summary response
- const finalFollowUpCompletion = await this.stages.llmCompletion.execute({
- messages: currentMessages,
- options: {
- ...modelSelection.options,
- enableTools: false, // Disable tools for the final response
- // Preserve streaming setting for max iterations response
- stream: modelSelection.options.stream,
- // For Ollama, include tool execution status
- ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
- }
- });
-
- // Update the current response
- currentResponse = finalFollowUpCompletion.response;
- }
-
- // If streaming was paused for tool execution, resume it now with the final response
- if (isStreaming && streamCallback && streamingPaused) {
- // First log for debugging
- const responseText = currentResponse.text || "";
- log.info(`Resuming streaming with final response: ${responseText.length} chars`);
-
- if (responseText.length > 0 && !hasStreamedContent) {
- // Resume streaming with the final response text only if we haven't already streamed content
- // This is where we send the definitive done:true signal with the complete content
- streamCallback(responseText, true);
- log.info(`Sent final response with done=true signal and text content`);
- } else if (hasStreamedContent) {
- log.info(`Content already streamed, sending done=true signal only after tool execution`);
- // Just send the done signal without duplicating content
- streamCallback('', true);
- } else {
- // For Anthropic, sometimes text is empty but response is in stream
- if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) {
- log.info(`Detected empty response text for ${currentResponse.provider} provider with stream, sending stream content directly`);
- // For Anthropic/OpenAI with stream mode, we need to stream the final response
- if (currentResponse.stream) {
- await currentResponse.stream(async (chunk: StreamChunk) => {
- // Process the chunk
- const processedChunk = await this.processStreamChunk(chunk, input.options);
-
- // Forward to callback
- streamCallback(
- processedChunk.text,
- processedChunk.done || chunk.done || false,
- chunk
- );
- });
- log.info(`Completed streaming final ${currentResponse.provider} response after tool execution`);
- }
- } else {
- // Empty response with done=true as fallback
- streamCallback('', true);
- log.info(`Sent empty final response with done=true signal`);
- }
- }
- }
- } else if (toolsEnabled) {
- log.info(`========== NO TOOL CALLS DETECTED ==========`);
- log.info(`LLM response did not contain any tool calls, skipping tool execution`);
-
- // Handle streaming for responses without tool calls
- if (shouldEnableStream && streamCallback && !hasStreamedContent) {
- log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`);
-
- // Send the final response with done=true to complete the streaming
- streamCallback(currentResponse.text, true);
-
- log.info(`Sent final non-tool response with done=true signal`);
- } else if (shouldEnableStream && streamCallback && hasStreamedContent) {
- log.info(`Content already streamed, sending done=true signal only`);
- // Just send the done signal without duplicating content
- streamCallback('', true);
- }
- }
+ const toolLoopResult = await toolLoop.run({
+ messages: preparedMessages.messages,
+ userQuery,
+ providerName,
+ options: modelSelection.options,
+ chunkOptions: input.options,
+ streamCallback,
+ shouldEnableStream,
+ providerStream,
+ toolsEnabled: modelSelection.options.enableTools !== false,
+ maxToolCallIterations: this.config.maxToolCallIterations,
+ accumulatedText
+ });
+
+ let currentResponse = toolLoopResult.response;
// Process the final response
log.info(`========== FINAL RESPONSE PROCESSING ==========`);
@@ -857,39 +335,6 @@ export class ChatPipeline {
}
}
- /**
- * Process a stream chunk through the response processing stage
- */
- private async processStreamChunk(chunk: StreamChunk, options?: any): Promise {
- try {
- // Only process non-empty chunks
- if (!chunk.text) return chunk;
-
- // Create a minimal response object for the processor
- const miniResponse = {
- text: chunk.text,
- model: 'streaming',
- provider: 'streaming'
- };
-
- // Process the chunk text
- const processed = await this.stages.responseProcessing.execute({
- response: miniResponse,
- options: options
- });
-
- // Return processed chunk
- return {
- ...chunk,
- text: processed.text
- };
- } catch (error) {
- // On error, return original chunk
- log.error(`Error processing stream chunk: ${error}`);
- return chunk;
- }
- }
-
/**
* Update metrics for a pipeline stage
*/
@@ -933,51 +378,4 @@ export class ChatPipeline {
});
}
- /**
- * Find tool name from tool call ID by looking at previous assistant messages
- */
- private getToolNameFromToolCallId(messages: Message[], toolCallId: string): string {
- if (!toolCallId) return 'unknown';
-
- // Look for assistant messages with tool_calls
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i];
- if (message.role === 'assistant' && message.tool_calls) {
- // Find the tool call with the matching ID
- const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
- if (toolCall && toolCall.function && toolCall.function.name) {
- return toolCall.function.name;
- }
- }
- }
-
- return 'unknown';
- }
-
- /**
- * Validate tool messages to ensure they're properly formatted
- */
- private validateToolMessages(messages: Message[]): void {
- for (let i = 0; i < messages.length; i++) {
- const message = messages[i];
-
- // Ensure tool messages have required fields
- if (message.role === 'tool') {
- if (!message.tool_call_id) {
- log.info(`Tool message missing tool_call_id, adding placeholder`);
- message.tool_call_id = `tool_${i}`;
- }
-
- // Content should be a string
- if (typeof message.content !== 'string') {
- log.info(`Tool message content is not a string, converting`);
- try {
- message.content = JSON.stringify(message.content);
- } catch (e) {
- message.content = String(message.content);
- }
- }
- }
- }
- }
}
diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline_stream_helpers.ts b/apps/server/src/services/llm/pipeline/chat_pipeline_stream_helpers.ts
new file mode 100644
index 000000000..0d744fffa
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/chat_pipeline_stream_helpers.ts
@@ -0,0 +1,36 @@
+import type { ChatCompletionOptions, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
+import type { ToolLoopStages } from './chat_pipeline_tool_loop.js';
+import log from '../../log.js';
+
+export const processStreamChunk: (
+ stages: ToolLoopStages,
+ chunk: StreamChunk,
+ options?: ChatCompletionOptions
+) => Promise = async (stages, chunk, options) => {
+ try {
+ if (!chunk.text) {
+ return chunk;
+ }
+
+ const miniResponse: NormalizedChatResponse = {
+ text: chunk.text,
+ model: 'streaming',
+ provider: 'streaming',
+ tool_calls: []
+ };
+
+ const processed = await stages.responseProcessing.execute({
+ response: miniResponse,
+ options: options ?? { enableTools: false }
+ });
+
+ return {
+ ...chunk,
+ text: processed.text
+ };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error(`Error processing stream chunk: ${errorMessage}`);
+ return chunk;
+ }
+};
diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline_tool_execution.ts b/apps/server/src/services/llm/pipeline/chat_pipeline_tool_execution.ts
new file mode 100644
index 000000000..30c1061ed
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/chat_pipeline_tool_execution.ts
@@ -0,0 +1,477 @@
+import type { ChatCompletionOptions, Message, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
+import type { StreamCallback } from './interfaces.js';
+import type { StreamingStrategy } from './streaming/streaming_strategy.js';
+import type { ToolLoopStages } from './chat_pipeline_tool_loop.js';
+import log from '../../log.js';
+import {
+ buildFallbackResponseFromToolResults,
+ getToolNameFromToolCallId,
+ validateToolMessages
+} from './chat_pipeline_tool_helpers.js';
+import { processStreamChunk } from './chat_pipeline_stream_helpers.js';
+
+export interface ToolExecutionLoopDependencies {
+ stages: ToolLoopStages;
+ streamingStrategy: StreamingStrategy;
+ updateStageMetrics: (stageName: string, startTime: number) => void;
+}
+
+export interface ToolExecutionLoopInput {
+ response: NormalizedChatResponse;
+ messages: Message[];
+ options: ChatCompletionOptions;
+ chunkOptions?: ChatCompletionOptions;
+ streamCallback?: StreamCallback;
+ shouldEnableStream: boolean;
+ toolsEnabled: boolean;
+ hasToolCalls: boolean;
+ maxToolCallIterations: number;
+ accumulatedText: string;
+ hasStreamedContent: boolean;
+}
+
+export interface ToolExecutionLoopResult {
+ response: NormalizedChatResponse;
+ messages: Message[];
+}
+
+export const executeToolExecutionLoop: (
+ dependencies: ToolExecutionLoopDependencies,
+ input: ToolExecutionLoopInput
+) => Promise = async (dependencies, input) => {
+ const {
+ response,
+ messages,
+ options,
+ chunkOptions,
+ streamCallback,
+ shouldEnableStream,
+ toolsEnabled,
+ hasToolCalls,
+ maxToolCallIterations,
+ accumulatedText,
+ hasStreamedContent
+ } = input;
+
+ const { stages, streamingStrategy, updateStageMetrics } = dependencies;
+
+ let currentResponse = response;
+ let currentMessages = messages;
+ let toolCallIterations = 0;
+
+ if (toolsEnabled && hasToolCalls && currentResponse.tool_calls) {
+ log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
+ log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
+
+ log.info(`========== TOOL CALL DETAILS ==========`);
+ currentResponse.tool_calls.forEach((toolCall, idx) => {
+ log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
+ log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
+ });
+
+ const isStreaming = shouldEnableStream && streamCallback;
+ let streamingPaused = false;
+
+ if (isStreaming && streamCallback) {
+ streamingPaused = true;
+ streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'start',
+ tool: {
+ name: 'tool_execution',
+ arguments: {}
+ }
+ }
+ });
+ }
+
+ while (toolCallIterations < maxToolCallIterations) {
+ toolCallIterations++;
+ log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
+
+ const previousMessages = [...currentMessages];
+
+ try {
+ const toolCallingStartTime = Date.now();
+ log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
+ log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
+ log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
+
+ const toolCallingResult = await stages.toolCalling.execute({
+ response: currentResponse,
+ messages: currentMessages,
+ options
+ });
+ updateStageMetrics('toolCalling', toolCallingStartTime);
+
+ log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
+
+ currentMessages = toolCallingResult.messages;
+
+ const toolResultMessages = currentMessages.filter(
+ msg => msg.role === 'tool' && !previousMessages.includes(msg)
+ );
+
+ log.info(`========== TOOL EXECUTION RESULTS ==========`);
+ log.info(`Received ${toolResultMessages.length} tool results`);
+ toolResultMessages.forEach((msg, idx) => {
+ log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content}`);
+ log.info(`Tool result status: ${msg.content.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`);
+ log.info(`Tool result for: ${getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '')}`);
+
+ if (isStreaming && streamCallback) {
+ const toolName = getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
+
+ try {
+ let parsedContent = msg.content;
+ try {
+ if (msg.content.trim().startsWith('{') || msg.content.trim().startsWith('[')) {
+ parsedContent = JSON.parse(msg.content);
+ }
+ } catch (error) {
+ log.info(`Could not parse tool result as JSON: ${error}`);
+ }
+
+ streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'complete',
+ tool: {
+ name: toolName,
+ arguments: {}
+ },
+ result: parsedContent
+ }
+ });
+ } catch (err) {
+ log.error(`Error sending structured tool result: ${err}`);
+ streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'complete',
+ tool: {
+ name: toolName || 'unknown',
+ arguments: {}
+ },
+ result: msg.content
+ }
+ });
+ }
+ }
+ });
+
+ if (toolCallingResult.needsFollowUp) {
+ log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
+ log.info('Tool execution complete, sending results back to LLM');
+
+ validateToolMessages(currentMessages);
+
+ if (isStreaming && streamCallback) {
+ streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'update',
+ tool: {
+ name: 'tool_processing',
+ arguments: {}
+ }
+ }
+ });
+ }
+
+ let toolExecutionStatus;
+ if (currentResponse.provider === 'Ollama') {
+ toolExecutionStatus = toolResultMessages.map(msg => {
+ const isError = msg.content.startsWith('Error:');
+ return {
+ toolCallId: msg.tool_call_id || '',
+ name: msg.name || 'unknown',
+ success: !isError,
+ result: msg.content,
+ error: isError ? msg.content.substring(7) : undefined
+ };
+ });
+
+ log.info(`Created tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
+ toolExecutionStatus.forEach((status, idx) => {
+ log.info(`Tool status ${idx + 1}: ${status.name} - ${status.success ? 'success' : 'failed'}`);
+ });
+ }
+
+ const followUpStartTime = Date.now();
+
+ log.info(`========== SENDING TOOL RESULTS TO LLM FOR FOLLOW-UP ==========`);
+ log.info(`Total messages being sent: ${currentMessages.length}`);
+ const recentMessages = currentMessages.slice(-3);
+ recentMessages.forEach((msg, idx) => {
+ const position = currentMessages.length - recentMessages.length + idx;
+ log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
+ if (msg.tool_calls) {
+ log.info(` Has ${msg.tool_calls.length} tool calls`);
+ }
+ if (msg.tool_call_id) {
+ log.info(` Tool call ID: ${msg.tool_call_id}`);
+ }
+ });
+
+ const followUpStream = streamingStrategy.resolveFollowUpStreaming({
+ kind: 'tool',
+ hasStreamCallback: !!streamCallback,
+ providerName: currentResponse.provider,
+ toolsEnabled: true
+ });
+
+ log.info(`LLM follow-up request options: ${JSON.stringify({
+ model: options.model,
+ enableTools: true,
+ stream: followUpStream,
+ provider: currentResponse.provider
+ })}`);
+
+ const followUpCompletion = await stages.llmCompletion.execute({
+ messages: currentMessages,
+ options: {
+ ...options,
+ enableTools: true,
+ stream: followUpStream,
+ ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
+ }
+ });
+ updateStageMetrics('llmCompletion', followUpStartTime);
+
+ log.info(`========== LLM FOLLOW-UP RESPONSE RECEIVED ==========`);
+ log.info(`Follow-up response model: ${followUpCompletion.response.model}, provider: ${followUpCompletion.response.provider}`);
+ log.info(`Follow-up response text: ${followUpCompletion.response.text?.substring(0, 150)}${followUpCompletion.response.text?.length > 150 ? '...' : ''}`);
+ log.info(`Follow-up contains tool calls: ${followUpCompletion.response.tool_calls.length > 0}`);
+ if (followUpCompletion.response.tool_calls.length > 0) {
+ log.info(`Follow-up has ${followUpCompletion.response.tool_calls.length} new tool calls`);
+ }
+
+ currentResponse = followUpCompletion.response;
+
+ if (currentResponse.tool_calls.length === 0) {
+ log.info(`========== TOOL EXECUTION COMPLETE ==========`);
+ log.info('No more tool calls, breaking tool execution loop');
+ break;
+ } else {
+ log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
+ log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
+ currentResponse.tool_calls.forEach((toolCall, idx) => {
+ log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
+ log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
+ });
+ }
+ } else {
+ log.info(`========== TOOL EXECUTION COMPLETE ==========`);
+ log.info('No follow-up needed, breaking tool execution loop');
+ break;
+ }
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.info(`========== TOOL EXECUTION ERROR ==========`);
+ log.error(`Error in tool execution: ${errorMessage}`);
+
+ currentMessages.push({
+ role: 'system',
+ content: `Error executing tool: ${errorMessage}. Please try a different approach.`
+ });
+
+ if (isStreaming && streamCallback) {
+ streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'error',
+ tool: {
+ name: 'unknown',
+ arguments: {}
+ },
+ result: errorMessage || 'unknown error'
+ }
+ });
+ }
+
+ let toolExecutionStatus;
+ if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
+ toolExecutionStatus = currentResponse.tool_calls.map(toolCall => {
+ return {
+ toolCallId: toolCall.id || '',
+ name: toolCall.function?.name || 'unknown',
+ success: false,
+ result: `Error: ${errorMessage || 'unknown error'}`,
+ error: errorMessage || 'unknown error'
+ };
+ });
+
+ log.info(`Created error tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
+ }
+
+ const errorFollowUpStream = streamingStrategy.resolveFollowUpStreaming({
+ kind: 'error',
+ hasStreamCallback: !!streamCallback,
+ providerName: currentResponse.provider,
+ toolsEnabled: false
+ });
+
+ const errorFollowUpCompletion = await stages.llmCompletion.execute({
+ messages: currentMessages,
+ options: {
+ ...options,
+ enableTools: false,
+ stream: errorFollowUpStream,
+ ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
+ }
+ });
+
+ log.info(`========== ERROR FOLLOW-UP RESPONSE RECEIVED ==========`);
+ log.info(`Error follow-up response model: ${errorFollowUpCompletion.response.model}, provider: ${errorFollowUpCompletion.response.provider}`);
+ log.info(`Error follow-up response text: ${errorFollowUpCompletion.response.text?.substring(0, 150)}${errorFollowUpCompletion.response.text?.length > 150 ? '...' : ''}`);
+ log.info(`Error follow-up contains tool calls: ${errorFollowUpCompletion.response.tool_calls.length > 0}`);
+
+ currentResponse = errorFollowUpCompletion.response;
+ break;
+ }
+ }
+
+ if (toolCallIterations >= maxToolCallIterations) {
+ log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
+ log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
+
+ currentMessages.push({
+ role: 'system',
+ content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
+ });
+
+ if (isStreaming && streamCallback) {
+ streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
+ }
+
+ let toolExecutionStatus;
+ if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
+ toolExecutionStatus = [
+ {
+ toolCallId: 'max-iterations',
+ name: 'system',
+ success: false,
+ result: `Maximum tool call iterations (${maxToolCallIterations}) reached.`,
+ error: `Reached the maximum number of allowed tool calls (${maxToolCallIterations}). Please provide a final response with the information gathered so far.`
+ }
+ ];
+
+ log.info(`Created max iterations status for Ollama`);
+ }
+
+ const maxIterationsStream = streamingStrategy.resolveFollowUpStreaming({
+ kind: 'max_iterations',
+ hasStreamCallback: !!streamCallback,
+ providerName: currentResponse.provider,
+ toolsEnabled: false
+ });
+
+ const finalFollowUpCompletion = await stages.llmCompletion.execute({
+ messages: currentMessages,
+ options: {
+ ...options,
+ enableTools: false,
+ stream: maxIterationsStream,
+ ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
+ }
+ });
+
+ currentResponse = finalFollowUpCompletion.response;
+ }
+
+ if (!currentResponse.text || currentResponse.text.trim().length === 0) {
+ log.info(`Final response text empty after tool execution. Requesting non-streaming summary.`);
+ try {
+ const finalTextStream = streamingStrategy.resolveFollowUpStreaming({
+ kind: 'final_text',
+ hasStreamCallback: !!streamCallback,
+ providerName: currentResponse.provider,
+ toolsEnabled: false
+ });
+
+ const finalTextCompletion = await stages.llmCompletion.execute({
+ messages: currentMessages,
+ options: {
+ ...options,
+ enableTools: false,
+ stream: finalTextStream
+ }
+ });
+
+ currentResponse = finalTextCompletion.response;
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error(`Error generating final response text: ${errorMessage}`);
+ }
+
+ if (!currentResponse.text || currentResponse.text.trim().length === 0) {
+ currentResponse.text = buildFallbackResponseFromToolResults(currentMessages);
+ }
+ }
+
+ if (isStreaming && streamCallback && streamingPaused) {
+ const responseText = currentResponse.text || "";
+ log.info(`Resuming streaming with final response: ${responseText.length} chars`);
+
+ const finalText = accumulatedText && responseText.startsWith(accumulatedText)
+ ? responseText.slice(accumulatedText.length)
+ : responseText;
+
+ if (finalText.length > 0) {
+ streamCallback(finalText, true);
+ log.info(`Sent final response with done=true signal and ${finalText.length} chars`);
+ } else {
+ if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) {
+ log.info(`Detected empty response text for ${currentResponse.provider} provider with stream, sending stream content directly`);
+ await currentResponse.stream(async (chunk: StreamChunk) => {
+ const processedChunk = await processStreamChunk(stages, chunk, chunkOptions ?? options);
+ streamCallback(
+ processedChunk.text,
+ processedChunk.done || chunk.done || false,
+ chunk
+ );
+ });
+ log.info(`Completed streaming final ${currentResponse.provider} response after tool execution`);
+ } else if (currentResponse.stream) {
+ log.info(`Streaming final response content for provider ${currentResponse.provider || 'unknown'}`);
+ await currentResponse.stream(async (chunk: StreamChunk) => {
+ const processedChunk = await processStreamChunk(stages, chunk, chunkOptions ?? options);
+ streamCallback(
+ processedChunk.text,
+ processedChunk.done || chunk.done || false,
+ chunk
+ );
+ });
+ log.info(`Completed streaming final response after tool execution`);
+ } else {
+ streamCallback('', true);
+ log.info(`Sent empty final response with done=true signal`);
+ }
+ }
+ }
+ } else if (toolsEnabled) {
+ log.info(`========== NO TOOL CALLS DETECTED ==========`);
+ log.info(`LLM response did not contain any tool calls, skipping tool execution`);
+
+ if (shouldEnableStream && streamCallback && !hasStreamedContent) {
+ log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`);
+ streamCallback(currentResponse.text, true);
+ log.info(`Sent final non-tool response with done=true signal`);
+ } else if (shouldEnableStream && streamCallback && hasStreamedContent) {
+ log.info(`Content already streamed, sending done=true signal only`);
+ streamCallback('', true);
+ }
+ }
+
+ return {
+ response: currentResponse,
+ messages: currentMessages
+ };
+};
diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline_tool_helpers.ts b/apps/server/src/services/llm/pipeline/chat_pipeline_tool_helpers.ts
new file mode 100644
index 000000000..90fb2b154
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/chat_pipeline_tool_helpers.ts
@@ -0,0 +1,120 @@
+import type { Message } from '../ai_interface.js';
+import type { ToolCall } from '../tools/tool_interfaces.js';
+
+export type FallbackToolName = 'list_notes' | 'search_notes' | 'keyword_search_notes';
+
+export interface FallbackToolDecision {
+ name: FallbackToolName;
+ reason: string;
+}
+
+export const getFallbackToolForQuery: (query: string) => FallbackToolDecision | null = (query) => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ const normalized = trimmed.toLowerCase();
+ const listHints = ['list', 'show', 'what notes', 'all notes', 'catalog', 'index'];
+ const keywordHints = ['#', 'attribute', 'label', 'relation', '='];
+
+ if (listHints.some(hint => normalized.includes(hint))) {
+ return { name: 'list_notes', reason: 'list query' };
+ }
+
+ if (keywordHints.some(hint => normalized.includes(hint))) {
+ return { name: 'keyword_search_notes', reason: 'keyword query' };
+ }
+
+ return { name: 'search_notes', reason: 'general query' };
+};
+
+export const buildForcedToolCallInstruction: (toolName: FallbackToolName, query: string) => string = (toolName, query) => {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return `You must call the ${toolName} tool now. Respond only with a tool call.`;
+ }
+
+ return `You must call the ${toolName} tool now to gather note data for: "${trimmed}". Respond only with a tool call.`;
+};
+
+export const buildSyntheticToolCalls: (toolName: FallbackToolName, query: string) => ToolCall[] = (toolName, query) => {
+ const trimmed = query.trim();
+ const args: Record = {};
+
+ if (toolName === 'search_notes' || toolName === 'keyword_search_notes') {
+ if (trimmed) {
+ args.query = trimmed;
+ }
+ }
+
+ return [
+ {
+ id: `forced-${Date.now()}`,
+ type: 'function',
+ function: {
+ name: toolName,
+ arguments: JSON.stringify(args)
+ }
+ }
+ ];
+};
+
+export const buildFallbackResponseFromToolResults: (messages: Message[]) => string = (messages) => {
+ const toolMessages = messages.filter(message => message.role === 'tool').slice(-3);
+
+ if (toolMessages.length === 0) {
+ return 'Tool execution completed, but no final response was generated. Please retry or adjust the request.';
+ }
+
+ const resultLines = toolMessages.map(toolMessage => {
+ const toolName = toolMessage.name || 'tool';
+ const content = typeof toolMessage.content === 'string' ? toolMessage.content : String(toolMessage.content);
+ const preview = content.length > 500 ? `${content.slice(0, 500)}...` : content;
+ return `- ${toolName}: ${preview}`;
+ });
+
+ return [
+ 'Tool execution completed, but the model returned no final text.',
+ 'Latest tool results:',
+ ...resultLines
+ ].join('\n');
+};
+
+export const getToolNameFromToolCallId: (messages: Message[], toolCallId: string) => string = (messages, toolCallId) => {
+ if (!toolCallId) {
+ return 'unknown';
+ }
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === 'assistant' && message.tool_calls) {
+ const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
+ if (toolCall && toolCall.function && toolCall.function.name) {
+ return toolCall.function.name;
+ }
+ }
+ }
+
+ return 'unknown';
+};
+
+export const validateToolMessages: (messages: Message[]) => void = (messages) => {
+ for (let i = 0; i < messages.length; i++) {
+ const message = messages[i];
+
+ if (message.role === 'tool') {
+ if (!message.tool_call_id) {
+ message.tool_call_id = `tool_${i}`;
+ }
+
+ if (typeof message.content !== 'string') {
+ try {
+ message.content = JSON.stringify(message.content);
+ } catch (error) {
+ message.content = String(message.content);
+ }
+ }
+ }
+ }
+};
diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline_tool_loop.ts b/apps/server/src/services/llm/pipeline/chat_pipeline_tool_loop.ts
new file mode 100644
index 000000000..226706b27
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/chat_pipeline_tool_loop.ts
@@ -0,0 +1,291 @@
+import type { ChatCompletionOptions, Message, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
+import type { LLMCompletionInput, ResponseProcessingInput, StreamCallback, ToolExecutionInput } from './interfaces.js';
+import type { StreamingStrategy } from './streaming/streaming_strategy.js';
+import log from '../../log.js';
+import {
+ buildForcedToolCallInstruction,
+ buildSyntheticToolCalls,
+ getFallbackToolForQuery
+} from './chat_pipeline_tool_helpers.js';
+import { executeToolExecutionLoop } from './chat_pipeline_tool_execution.js';
+
+export interface ToolLoopStages {
+ llmCompletion: {
+ execute: (input: LLMCompletionInput) => Promise<{ response: NormalizedChatResponse }>;
+ };
+ toolCalling: {
+ execute: (input: ToolExecutionInput) => Promise<{ response: NormalizedChatResponse; needsFollowUp: boolean; messages: Message[] }>;
+ };
+ responseProcessing: {
+ execute: (input: ResponseProcessingInput) => Promise<{ text: string }>;
+ };
+}
+
+export interface ToolLoopDependencies {
+ stages: ToolLoopStages;
+ streamingStrategy: StreamingStrategy;
+ updateStageMetrics: (stageName: string, startTime: number) => void;
+}
+
+export interface ToolLoopInput {
+ messages: Message[];
+ userQuery: string;
+ providerName?: string;
+ options: ChatCompletionOptions;
+ chunkOptions?: ChatCompletionOptions;
+ streamCallback?: StreamCallback;
+ shouldEnableStream: boolean;
+ providerStream: boolean;
+ toolsEnabled: boolean;
+ maxToolCallIterations: number;
+ accumulatedText: string;
+}
+
+export interface ToolLoopResult {
+ response: NormalizedChatResponse;
+ messages: Message[];
+ accumulatedText: string;
+ hasStreamedContent: boolean;
+}
+
+export class ChatPipelineToolLoop {
+ private readonly stages: ToolLoopStages;
+ private readonly streamingStrategy: StreamingStrategy;
+ private readonly updateStageMetrics: (stageName: string, startTime: number) => void;
+
+ constructor(dependencies: ToolLoopDependencies) {
+ this.stages = dependencies.stages;
+ this.streamingStrategy = dependencies.streamingStrategy;
+ this.updateStageMetrics = dependencies.updateStageMetrics;
+ }
+
+ async run(input: ToolLoopInput): Promise {
+ const {
+ messages,
+ userQuery,
+ providerName,
+ options,
+ chunkOptions,
+ streamCallback,
+ shouldEnableStream,
+ providerStream,
+ toolsEnabled,
+ maxToolCallIterations
+ } = input;
+
+ let accumulatedText = input.accumulatedText;
+
+ log.info(`========== STAGE 5: LLM COMPLETION ==========`);
+ const llmStartTime = Date.now();
+ const completion = await this.stages.llmCompletion.execute({
+ messages,
+ options: {
+ ...options,
+ stream: providerStream
+ }
+ });
+ this.updateStageMetrics('llmCompletion', llmStartTime);
+ log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
+
+ let hasStreamedContent = false;
+
+ if (shouldEnableStream && completion.response.stream && streamCallback) {
+ await completion.response.stream(async (chunk: StreamChunk) => {
+ const processedChunk = await this.processStreamChunk(chunk, chunkOptions ?? options);
+ accumulatedText += processedChunk.text;
+ streamCallback(processedChunk.text, processedChunk.done, chunk);
+ hasStreamedContent = true;
+ });
+ }
+
+ let currentMessages = messages;
+ let currentResponse = completion.response;
+
+ log.info(`========== TOOL EXECUTION DECISION ==========`);
+ log.info(`Tools enabled in options: ${toolsEnabled}`);
+ log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
+ log.info(`Response model: ${currentResponse.model || 'unknown'}`);
+
+ const toolCallResolution = this.resolveToolCalls(currentResponse);
+ currentResponse = toolCallResolution.response;
+ let hasToolCalls = toolCallResolution.hasToolCalls;
+
+ if (toolsEnabled && !hasToolCalls) {
+ const fallbackTool = getFallbackToolForQuery(userQuery);
+ const normalizedProvider = (providerName || currentResponse.provider || '').toLowerCase();
+ if (fallbackTool && normalizedProvider === 'minimax') {
+ log.info(`No tool calls detected. Forcing MiniMax tool call: ${fallbackTool.name} (${fallbackTool.reason})`);
+ const forcedMessages: Message[] = [
+ ...currentMessages,
+ {
+ role: 'system' as const,
+ content: buildForcedToolCallInstruction(fallbackTool.name, userQuery)
+ }
+ ];
+
+ const forcedToolStream = this.streamingStrategy.resolveFollowUpStreaming({
+ kind: 'tool',
+ hasStreamCallback: !!streamCallback,
+ providerName,
+ toolsEnabled: true
+ });
+
+ const forcedCompletion = await this.stages.llmCompletion.execute({
+ messages: forcedMessages,
+ options: {
+ ...options,
+ enableTools: true,
+ stream: forcedToolStream,
+ tool_choice: {
+ type: 'function',
+ function: {
+ name: fallbackTool.name
+ }
+ }
+ }
+ });
+
+ currentResponse = forcedCompletion.response;
+ if (currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
+ hasToolCalls = true;
+ log.info(`Forced tool call produced ${currentResponse.tool_calls.length} tool(s).`);
+ } else {
+ log.info(`Forced tool call did not produce tool_calls. Injecting synthetic tool call.`);
+ currentResponse.tool_calls = buildSyntheticToolCalls(fallbackTool.name, userQuery);
+ hasToolCalls = currentResponse.tool_calls.length > 0;
+ }
+ }
+ }
+
+ const executionResult = await executeToolExecutionLoop(
+ {
+ stages: this.stages,
+ streamingStrategy: this.streamingStrategy,
+ updateStageMetrics: this.updateStageMetrics
+ },
+ {
+ response: currentResponse,
+ messages: currentMessages,
+ options,
+ chunkOptions,
+ streamCallback,
+ shouldEnableStream,
+ toolsEnabled,
+ hasToolCalls,
+ maxToolCallIterations,
+ accumulatedText,
+ hasStreamedContent
+ }
+ );
+
+ currentResponse = executionResult.response;
+ currentMessages = executionResult.messages;
+
+ return {
+ response: currentResponse,
+ messages: currentMessages,
+ accumulatedText,
+ hasStreamedContent
+ };
+ }
+
+ private resolveToolCalls(response: NormalizedChatResponse): { response: NormalizedChatResponse; hasToolCalls: boolean } {
+ let hasToolCalls = false;
+
+ log.info(`[TOOL CALL DEBUG] Starting tool call detection for provider: ${response.provider}`);
+ log.info(`[TOOL CALL DEBUG] Response properties: ${Object.keys(response).join(', ')}`);
+
+ if ('tool_calls' in response) {
+ log.info(`[TOOL CALL DEBUG] tool_calls exists as a direct property`);
+ log.info(`[TOOL CALL DEBUG] tool_calls type: ${typeof response.tool_calls}`);
+
+ if (response.tool_calls && Array.isArray(response.tool_calls)) {
+ log.info(`[TOOL CALL DEBUG] tool_calls is an array with length: ${response.tool_calls.length}`);
+ } else {
+ log.info(`[TOOL CALL DEBUG] tool_calls is not an array or is empty: ${JSON.stringify(response.tool_calls)}`);
+ }
+ } else {
+ log.info(`[TOOL CALL DEBUG] tool_calls does not exist as a direct property`);
+ }
+
+ if (response.tool_calls && response.tool_calls.length > 0) {
+ hasToolCalls = true;
+ log.info(`Response has tool_calls property with ${response.tool_calls.length} tools`);
+ log.info(`Tool calls details: ${JSON.stringify(response.tool_calls)}`);
+ } else {
+ log.info(`[TOOL CALL DEBUG] Direct property check failed, trying getter approach`);
+ try {
+ const toolCallsDesc = Object.getOwnPropertyDescriptor(response, 'tool_calls');
+
+ if (toolCallsDesc) {
+ log.info(`[TOOL CALL DEBUG] Found property descriptor for tool_calls: ${JSON.stringify({
+ configurable: toolCallsDesc.configurable,
+ enumerable: toolCallsDesc.enumerable,
+ hasGetter: !!toolCallsDesc.get,
+ hasSetter: !!toolCallsDesc.set
+ })}`);
+ } else {
+ log.info(`[TOOL CALL DEBUG] No property descriptor found for tool_calls`);
+ }
+
+ if (toolCallsDesc && typeof toolCallsDesc.get === 'function') {
+ log.info(`[TOOL CALL DEBUG] Attempting to call the tool_calls getter`);
+ const dynamicToolCalls = toolCallsDesc.get.call(response) as NormalizedChatResponse['tool_calls'];
+
+ log.info(`[TOOL CALL DEBUG] Getter returned: ${JSON.stringify(dynamicToolCalls)}`);
+
+ if (dynamicToolCalls && dynamicToolCalls.length > 0) {
+ hasToolCalls = true;
+ log.info(`Response has dynamic tool_calls with ${dynamicToolCalls.length} tools`);
+ log.info(`Dynamic tool calls details: ${JSON.stringify(dynamicToolCalls)}`);
+ response.tool_calls = dynamicToolCalls;
+ log.info(`[TOOL CALL DEBUG] Updated response.tool_calls with dynamic values`);
+ } else {
+ log.info(`[TOOL CALL DEBUG] Getter returned no valid tool calls`);
+ }
+ } else {
+ log.info(`[TOOL CALL DEBUG] No getter function found for tool_calls`);
+ }
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error(`Error checking dynamic tool_calls: ${errorMessage}`);
+ }
+ }
+
+ log.info(`Response has tool_calls: ${hasToolCalls ? 'true' : 'false'}`);
+ if (hasToolCalls && response.tool_calls) {
+ log.info(`[TOOL CALL DEBUG] Final tool_calls that will be used: ${JSON.stringify(response.tool_calls)}`);
+ }
+
+ return { response, hasToolCalls };
+ }
+
+ private async processStreamChunk(chunk: StreamChunk, options?: ChatCompletionOptions): Promise {
+ try {
+ if (!chunk.text) {
+ return chunk;
+ }
+
+ const miniResponse: NormalizedChatResponse = {
+ text: chunk.text,
+ model: 'streaming',
+ provider: 'streaming',
+ tool_calls: []
+ };
+
+ const processed = await this.stages.responseProcessing.execute({
+ response: miniResponse,
+ options: options ?? { enableTools: false }
+ });
+
+ return {
+ ...chunk,
+ text: processed.text
+ };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error(`Error processing stream chunk: ${errorMessage}`);
+ return chunk;
+ }
+ }
+}
diff --git a/apps/server/src/services/llm/pipeline/interfaces.ts b/apps/server/src/services/llm/pipeline/interfaces.ts
index 13c39414e..0775883c8 100644
--- a/apps/server/src/services/llm/pipeline/interfaces.ts
+++ b/apps/server/src/services/llm/pipeline/interfaces.ts
@@ -1,4 +1,4 @@
-import type { Message, ChatCompletionOptions, ChatResponse, StreamChunk } from '../ai_interface.js';
+import type { Message, ChatCompletionOptions, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
/**
@@ -142,7 +142,7 @@ export interface LLMCompletionInput extends PipelineInput {
* Interface for the pipeline stage that performs response processing
*/
export interface ResponseProcessingInput extends PipelineInput {
- response: ChatResponse;
+ response: NormalizedChatResponse;
options: ChatCompletionOptions;
}
@@ -150,7 +150,7 @@ export interface ResponseProcessingInput extends PipelineInput {
* Interface for the pipeline stage that handles tool execution
*/
export interface ToolExecutionInput extends PipelineInput {
- response: ChatResponse;
+ response: NormalizedChatResponse;
messages: Message[];
options: ChatCompletionOptions;
maxIterations?: number;
diff --git a/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts b/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts
index 6354e4c59..4a7d8af2a 100644
--- a/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts
+++ b/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts
@@ -1,14 +1,15 @@
import { BasePipelineStage } from '../pipeline_stage.js';
import type { LLMCompletionInput } from '../interfaces.js';
-import type { ChatCompletionOptions, ChatResponse, StreamChunk } from '../../ai_interface.js';
+import type { ChatCompletionOptions, NormalizedChatResponse, StreamChunk } from '../../ai_interface.js';
import aiServiceManager from '../../ai_service_manager.js';
import toolRegistry from '../../tools/tool_registry.js';
import log from '../../../log.js';
+import { normalizeChatResponse } from '../../response_normalizer.js';
/**
* Pipeline stage for LLM completion with enhanced streaming support
*/
-export class LLMCompletionStage extends BasePipelineStage {
+export class LLMCompletionStage extends BasePipelineStage {
constructor() {
super('LLMCompletion');
}
@@ -19,7 +20,7 @@ export class LLMCompletionStage extends BasePipelineStage {
+ protected async process(input: LLMCompletionInput): Promise<{ response: NormalizedChatResponse }> {
const { messages, options } = input;
// Add detailed logging about the input messages, particularly useful for tool follow-ups
@@ -98,6 +99,16 @@ export class LLMCompletionStage extends BasePipelineStage {
+ normalizedResponse.stream = async (callback) => {
return originalStream(async (chunk) => {
// Forward the chunk with any additional provider-specific data
// Create an enhanced chunk with provider info
@@ -124,7 +136,7 @@ export class LLMCompletionStage extends BasePipelineStage 0) {
- if (response.tool_calls && response.tool_calls.length > 0) {
- log.info(`Response contains ${response.tool_calls.length} tool calls`);
- response.tool_calls.forEach((toolCall: any, idx: number) => {
+ if (normalizedResponse.tool_calls.length > 0) {
+ log.info(`Response contains ${normalizedResponse.tool_calls.length} tool calls`);
+ normalizedResponse.tool_calls.forEach((toolCall, idx: number) => {
log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`);
const args = typeof toolCall.function?.arguments === 'string'
? toolCall.function?.arguments
@@ -147,31 +159,32 @@ export class LLMCompletionStage extends BasePipelineStage 0 && !response.tool_calls) {
+ if (toolMessages.length > 0 && normalizedResponse.tool_calls.length === 0) {
log.info(`This appears to be a final response after tool execution (no new tool calls)`);
- } else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) {
+ } else if (toolMessages.length > 0 && normalizedResponse.tool_calls.length > 0) {
log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`);
}
}
- return { response };
+ return { response: normalizedResponse };
}
// Use auto-selection if no specific provider
log.info(`[LLMCompletionStage] Using auto-selected service`);
const response = await aiServiceManager.generateChatCompletion(messages, updatedOptions);
+ const normalizedResponse = normalizeChatResponse(response);
// Add similar stream enhancement for auto-selected provider
- if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) {
- const originalStream = response.stream;
- response.stream = async (callback) => {
+ if (normalizedResponse.stream && typeof normalizedResponse.stream === 'function' && updatedOptions.stream) {
+ const originalStream = normalizedResponse.stream;
+ normalizedResponse.stream = async (callback) => {
return originalStream(async (chunk) => {
// Create an enhanced chunk with provider info
const enhancedChunk: StreamChunk = {
...chunk,
raw: chunk.raw || {
- provider: response.provider,
- model: response.model
+ provider: normalizedResponse.provider,
+ model: normalizedResponse.model
}
};
return callback(enhancedChunk);
@@ -181,9 +194,9 @@ export class LLMCompletionStage extends BasePipelineStage 0) {
- if (response.tool_calls && response.tool_calls.length > 0) {
- log.info(`Response contains ${response.tool_calls.length} tool calls`);
- response.tool_calls.forEach((toolCall: any, idx: number) => {
+ if (normalizedResponse.tool_calls.length > 0) {
+ log.info(`Response contains ${normalizedResponse.tool_calls.length} tool calls`);
+ normalizedResponse.tool_calls.forEach((toolCall, idx: number) => {
log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`);
const args = typeof toolCall.function?.arguments === 'string'
? toolCall.function?.arguments
@@ -194,13 +207,13 @@ export class LLMCompletionStage extends BasePipelineStage 0 && !response.tool_calls) {
+ if (toolMessages.length > 0 && normalizedResponse.tool_calls.length === 0) {
log.info(`This appears to be a final response after tool execution (no new tool calls)`);
- } else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) {
+ } else if (toolMessages.length > 0 && normalizedResponse.tool_calls.length > 0) {
log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`);
}
}
- return { response };
+ return { response: normalizedResponse };
}
}
diff --git a/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts b/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts
index 7b1276b91..1cbfe80bd 100644
--- a/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts
+++ b/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts
@@ -183,7 +183,7 @@ export class ModelSelectionStage extends BasePipelineStage;
+ };
+ };
+ valid: boolean;
+ tool: ToolInterface | null;
+ error: string | null;
+ guidance?: string;
+}
+
+export interface ToolCallingExecutionInput {
+ response: NormalizedChatResponse;
+ messages: Message[];
+ options: ChatCompletionOptions;
+ streamCallback?: StreamCallback;
+}
+
+export interface ToolCallingExecutionOutput {
+ response: NormalizedChatResponse;
+ needsFollowUp: boolean;
+ messages: Message[];
+}
+
+export const executeToolCalling: (input: ToolCallingExecutionInput) => Promise = async (input) => {
+ const { response, messages, options, streamCallback } = input;
+
+ log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
+ log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
+
+ log.info(`LLM requested ${response.tool_calls?.length || 0} tool calls from provider: ${response.provider}`);
+
+ if (!response.tool_calls || response.tool_calls.length === 0) {
+ log.info(`No tool calls detected in response from provider: ${response.provider}`);
+ log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
+ return { response, needsFollowUp: false, messages };
+ }
+
+ if (response.text) {
+ log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
+ }
+
+ const registryTools = toolRegistry.getAllTools();
+
+ const availableTools: ToolInterface[] = registryTools.map(tool => {
+ const toolInterface: ToolInterface = {
+ execute: (args: Record) => tool.execute(args),
+ ...tool.definition
+ };
+ return toolInterface;
+ });
+ log.info(`Available tools in registry: ${availableTools.length}`);
+
+ if (availableTools.length > 0) {
+ const availableToolNames = availableTools.map(t => {
+ if (t && typeof t === 'object' && 'definition' in t &&
+ t.definition && typeof t.definition === 'object' &&
+ 'function' in t.definition && t.definition.function &&
+ typeof t.definition.function === 'object' &&
+ 'name' in t.definition.function &&
+ typeof t.definition.function.name === 'string') {
+ return t.definition.function.name;
+ }
+ return 'unknown';
+ }).join(', ');
+ log.info(`Available tools: ${availableToolNames}`);
+ }
+
+ if (availableTools.length === 0) {
+ log.error(`No tools available in registry, cannot execute tool calls`);
+ try {
+ log.info('Attempting to initialize tools as recovery step');
+ const toolCount = toolRegistry.getAllTools().length;
+ log.info(`After recovery initialization: ${toolCount} tools available`);
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error(`Failed to initialize tools in recovery step: ${errorMessage}`);
+ }
+ }
+
+ const updatedMessages = [...messages];
+
+ updatedMessages.push({
+ role: 'assistant',
+ content: response.text || "",
+ tool_calls: response.tool_calls
+ });
+
+ log.info(`========== STARTING TOOL EXECUTION ==========`);
+ log.info(`Executing ${response.tool_calls?.length || 0} tool calls in parallel`);
+
+ const executionStartTime = Date.now();
+
+ log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`);
+ const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => {
+ try {
+ const tool = toolRegistry.getTool(toolCall.function.name);
+
+ if (!tool) {
+ log.error(`Tool not found in registry: ${toolCall.function.name}`);
+ const guidance = generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`);
+ return {
+ toolCall,
+ valid: false,
+ tool: null,
+ error: `Tool not found: ${toolCall.function.name}`,
+ guidance
+ };
+ }
+
+ const isToolValid = await validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name);
+ if (!isToolValid) {
+ throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`);
+ }
+
+ return {
+ toolCall,
+ valid: true,
+ tool: tool as unknown as ToolInterface,
+ error: null
+ };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return {
+ toolCall,
+ valid: false,
+ tool: null,
+ error: errorMessage
+ };
+ }
+ }));
+
+ const toolResults = await Promise.all(validationResults.map(async (validation, index) => {
+ const { toolCall, valid, tool, error } = validation;
+
+ try {
+ log.info(`========== TOOL CALL ${index + 1} OF ${response.tool_calls?.length || 0} ==========`);
+ log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
+
+ const argsStr = typeof toolCall.function.arguments === 'string'
+ ? toolCall.function.arguments
+ : JSON.stringify(toolCall.function.arguments);
+ log.info(`Tool parameters: ${argsStr}`);
+
+ if (!valid || !tool) {
+ const toolGuidance = validation.guidance ||
+ generateToolGuidance(toolCall.function.name,
+ error || `Unknown validation error for tool '${toolCall.function.name}'`);
+
+ throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`);
+ }
+
+ log.info(`Tool validated successfully: ${toolCall.function.name}`);
+
+ const metadata = toolRegistry.getToolMetadata(toolCall.function.name);
+ const parser = metadata?.parseArguments || parseToolArguments;
+ const rawArguments = (typeof toolCall.function.arguments === 'string'
+ || (toolCall.function.arguments && typeof toolCall.function.arguments === 'object'))
+ ? toolCall.function.arguments
+ : '';
+
+ const parsedArguments = parser(rawArguments);
+ if (parsedArguments.warnings.length > 0) {
+ parsedArguments.warnings.forEach(warning => {
+ log.info(`Tool argument parse warning (${toolCall.function.name}): ${warning}`);
+ });
+ }
+
+ const args = parsedArguments.args;
+ log.info(`Parsed tool arguments keys: ${Object.keys(args).join(', ')}`);
+
+ log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
+ log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
+ log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
+
+ if (streamCallback) {
+ const toolExecutionData = {
+ action: 'start',
+ tool: {
+ name: toolCall.function.name,
+ arguments: args
+ },
+ type: 'start' as const
+ };
+
+ const callbackResult = streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: toolExecutionData
+ });
+ if (callbackResult instanceof Promise) {
+ callbackResult.catch((e: Error) => log.error(`Error sending tool execution start event: ${e.message}`));
+ }
+ }
+
+ const executionStart = Date.now();
+ let result;
+ try {
+ log.info(`Starting tool execution for ${toolCall.function.name}...`);
+ result = await tool.execute(args);
+ const executionTime = Date.now() - executionStart;
+ log.info(`================ TOOL EXECUTION COMPLETED in ${executionTime}ms ================`);
+
+ if (options?.sessionId) {
+ try {
+ await chatStorageService.recordToolExecution(
+ options.sessionId,
+ toolCall.function.name,
+ toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
+ args,
+ result,
+ undefined
+ );
+ } catch (storageError) {
+ log.error(`Failed to record tool execution in chat storage: ${storageError}`);
+ }
+ }
+
+ if (streamCallback) {
+ const toolExecutionData = {
+ action: 'complete',
+ tool: {
+ name: toolCall.function.name,
+ arguments: {} as Record
+ },
+ result: typeof result === 'string' ? result : result as Record,
+ type: 'complete' as const
+ };
+
+ const callbackResult = streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: toolExecutionData
+ });
+ if (callbackResult instanceof Promise) {
+ callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`));
+ }
+ }
+ } catch (execError: unknown) {
+ const executionTime = Date.now() - executionStart;
+ const errorMessage = execError instanceof Error ? execError.message : String(execError);
+ log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`);
+
+ const toolGuidance = generateToolGuidance(toolCall.function.name, errorMessage);
+ const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`;
+
+ if (options?.sessionId) {
+ try {
+ await chatStorageService.recordToolExecution(
+ options.sessionId,
+ toolCall.function.name,
+ toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
+ args,
+ "",
+ enhancedErrorMessage
+ );
+ } catch (storageError) {
+ log.error(`Failed to record tool execution error in chat storage: ${storageError}`);
+ }
+ }
+
+ if (streamCallback) {
+ const toolExecutionData = {
+ action: 'error',
+ tool: {
+ name: toolCall.function.name,
+ arguments: {} as Record
+ },
+ error: enhancedErrorMessage,
+ type: 'error' as const
+ };
+
+ const callbackResult = streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: toolExecutionData
+ });
+ if (callbackResult instanceof Promise) {
+ callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
+ }
+ }
+
+ if (execError instanceof Error) {
+ execError.message = enhancedErrorMessage;
+ }
+ throw execError;
+ }
+
+ const resultSummary = typeof result === 'string'
+ ? `${result.substring(0, 100)}...`
+ : `Object with keys: ${Object.keys(result).join(', ')}`;
+ const executionTime = Date.now() - executionStart;
+ log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
+
+ return {
+ toolCallId: toolCall.id,
+ name: toolCall.function.name,
+ result
+ };
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`);
+
+ const isExecutionError = typeof error === 'object' && error !== null &&
+ 'name' in error && (error as { name: unknown }).name === "ExecutionError";
+
+ if (streamCallback && !isExecutionError) {
+ const toolExecutionData = {
+ action: 'error',
+ tool: {
+ name: toolCall.function.name,
+ arguments: {} as Record
+ },
+ error: errorMessage,
+ type: 'error' as const
+ };
+
+ const callbackResult = streamCallback('', false, {
+ text: '',
+ done: false,
+ toolExecution: toolExecutionData
+ });
+ if (callbackResult instanceof Promise) {
+ callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
+ }
+ }
+
+ return {
+ toolCallId: toolCall.id,
+ name: toolCall.function.name,
+ result: `Error: ${errorMessage}`
+ };
+ }
+ }));
+
+ const totalExecutionTime = Date.now() - executionStartTime;
+ log.info(`========== TOOL EXECUTION COMPLETE ==========`);
+ log.info(`Completed execution of ${toolResults.length} tools in ${totalExecutionTime}ms`);
+
+ const toolResultMessages: Message[] = [];
+ let hasEmptyResults = false;
+
+ for (const result of toolResults) {
+ const { toolCallId, name, result: toolResult } = result;
+
+ const resultContent = typeof toolResult === 'string'
+ ? toolResult
+ : JSON.stringify(toolResult, null, 2);
+
+ const isEmptyResult = isEmptyToolResult(toolResult, name);
+ if (isEmptyResult && !resultContent.startsWith('Error:')) {
+ hasEmptyResults = true;
+ log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`);
+ }
+
+ let enhancedContent = resultContent;
+ if (isEmptyResult && !resultContent.startsWith('Error:')) {
+ enhancedContent = `${resultContent}\n\nNOTE: This tool returned no useful results with the provided parameters. Consider trying again with different parameters such as broader search terms, different filters, or alternative approaches.`;
+ }
+
+ const toolMessage: Message = {
+ role: 'tool',
+ content: enhancedContent,
+ name: name,
+ tool_call_id: toolCallId
+ };
+
+ log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`);
+ log.info(`Result type: ${typeof toolResult}`);
+ log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`);
+ log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`);
+
+ updatedMessages.push(toolMessage);
+ toolResultMessages.push(toolMessage);
+ }
+
+ log.info(`========== FOLLOW-UP DECISION ==========`);
+ const hasToolResults = toolResultMessages.length > 0;
+ const hasErrors = toolResultMessages.some(msg => msg.content.startsWith('Error:'));
+ const needsFollowUp = hasToolResults;
+
+ log.info(`Follow-up needed: ${needsFollowUp}`);
+ log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`);
+
+ if (hasEmptyResults && needsFollowUp) {
+ log.info('Adding system message requiring the LLM to run additional tools with different parameters');
+
+ const emptyToolNames = toolResultMessages
+ .filter(msg => isEmptyToolResult(msg.content, msg.name || ''))
+ .map(msg => msg.name);
+
+ let directiveMessage = `YOU MUST NOT GIVE UP AFTER A SINGLE EMPTY SEARCH RESULT. `;
+
+ if (emptyToolNames.includes('search_notes') || emptyToolNames.includes('keyword_search_notes')) {
+ directiveMessage += `IMMEDIATELY RUN ANOTHER SEARCH TOOL with broader search terms, alternative keywords, or related concepts. `;
+ directiveMessage += `Try synonyms, more general terms, or related topics. `;
+ }
+
+ if (emptyToolNames.includes('keyword_search_notes')) {
+ directiveMessage += `IMMEDIATELY TRY SEARCH_NOTES INSTEAD as it might find matches where keyword search failed. `;
+ }
+
+ directiveMessage += `DO NOT ask the user what to do next or if they want general information. CONTINUE SEARCHING with different parameters.`;
+
+ updatedMessages.push({
+ role: 'system',
+ content: directiveMessage
+ });
+ }
+
+ log.info(`Total messages to return to pipeline: ${updatedMessages.length}`);
+ log.info(`Last 3 messages in conversation:`);
+ const lastMessages = updatedMessages.slice(-3);
+ lastMessages.forEach((msg, idx) => {
+ const position = updatedMessages.length - lastMessages.length + idx;
+ log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
+ });
+
+ return {
+ response,
+ messages: updatedMessages,
+ needsFollowUp
+ };
+};
diff --git a/apps/server/src/services/llm/pipeline/stages/tool_calling_helpers.ts b/apps/server/src/services/llm/pipeline/stages/tool_calling_helpers.ts
new file mode 100644
index 000000000..5b097b284
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/stages/tool_calling_helpers.ts
@@ -0,0 +1,91 @@
+import log from '../../../log.js';
+import toolRegistry from '../../tools/tool_registry.js';
+
+export interface ToolInterface {
+ execute: (args: Record) => Promise;
+ [key: string]: unknown;
+}
+
+export const validateToolBeforeExecution: (tool: ToolInterface, toolName: string) => Promise = async (tool, toolName) => {
+ try {
+ if (!tool) {
+ log.error(`Tool '${toolName}' not found or failed validation`);
+ return false;
+ }
+
+ if (!tool.execute || typeof tool.execute !== 'function') {
+ log.error(`Tool '${toolName}' is missing execute method`);
+ return false;
+ }
+
+ return true;
+ } catch (error) {
+ log.error(`Error validating tool '${toolName}': ${error}`);
+ return false;
+ }
+};
+
+export const generateToolGuidance: (toolName: string, errorMessage: string) => string = (toolName, errorMessage) => {
+ const tools = toolRegistry.getAllTools();
+ const availableTools = tools.map(tool => tool.definition?.function?.name).filter(Boolean) as string[];
+
+ let guidance = `Tool execution failed: ${errorMessage}\n`;
+ guidance += `Available tools are: ${availableTools.join(', ')}.\n`;
+ guidance += `Please choose a valid tool and ensure parameters match the required schema.`;
+
+ if (!availableTools.includes(toolName)) {
+ guidance += `\nNote: "${toolName}" is not a valid tool name.`;
+ }
+
+ return guidance;
+};
+
+export const isEmptyToolResult: (result: unknown, toolName: string) => boolean = (result, toolName) => {
+ if (result === null || result === undefined) {
+ return true;
+ }
+
+ if (typeof result === 'string') {
+ const trimmed = result.trim();
+ if (trimmed.length === 0) {
+ return true;
+ }
+
+ const emptyIndicators = [
+ 'no results',
+ 'not found',
+ 'empty',
+ 'no notes found',
+ '0 results'
+ ];
+
+ return emptyIndicators.some(indicator => trimmed.toLowerCase().includes(indicator));
+ }
+
+ if (typeof result === 'object') {
+ if (Array.isArray(result)) {
+ return result.length === 0;
+ }
+
+ if ('results' in result && Array.isArray((result as { results: unknown[] }).results)) {
+ return (result as { results: unknown[] }).results.length === 0;
+ }
+
+ if ('count' in result && typeof (result as { count: unknown }).count === 'number') {
+ return (result as { count: number }).count === 0;
+ }
+ }
+
+ if (toolName === 'search_notes' || toolName === 'keyword_search_notes') {
+ if (typeof result === 'object' && result !== null) {
+ if ('message' in result && typeof (result as { message: unknown }).message === 'string') {
+ const message = (result as { message: string }).message.toLowerCase();
+ if (message.includes('no notes found')) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+};
diff --git a/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts b/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts
index 8299f8fd6..5664d4502 100644
--- a/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts
+++ b/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts
@@ -1,681 +1,22 @@
-import type { ChatResponse, Message } from '../../ai_interface.js';
-import log from '../../../log.js';
-import type { StreamCallback, ToolExecutionInput } from '../interfaces.js';
+import type { NormalizedChatResponse, Message } from '../../ai_interface.js';
+import type { ToolExecutionInput } from '../interfaces.js';
import { BasePipelineStage } from '../pipeline_stage.js';
-import toolRegistry from '../../tools/tool_registry.js';
-import chatStorageService from '../../chat_storage_service.js';
-import aiServiceManager from '../../ai_service_manager.js';
-
-// Type definitions for tools and validation results
-interface ToolInterface {
- execute: (args: Record) => Promise;
- [key: string]: unknown;
-}
-
-interface ToolValidationResult {
- toolCall: {
- id?: string;
- function: {
- name: string;
- arguments: string | Record;
- };
- };
- valid: boolean;
- tool: ToolInterface | null;
- error: string | null;
- guidance?: string; // Guidance to help the LLM select better tools/parameters
-}
+import { executeToolCalling } from './tool_calling_executor.js';
/**
- * Pipeline stage for handling LLM tool calling
- * This stage is responsible for:
- * 1. Detecting tool calls in LLM responses
- * 2. Executing the appropriate tools
- * 3. Adding tool results back to the conversation
- * 4. Determining if we need to make another call to the LLM
+ * Pipeline stage for handling LLM tool calling.
*/
-export class ToolCallingStage extends BasePipelineStage {
+export class ToolCallingStage extends BasePipelineStage {
constructor() {
super('ToolCalling');
- // Vector search tool has been removed - no preloading needed
}
- /**
- * Process the LLM response and execute any tool calls
- */
- protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
- const { response, messages } = input;
- const streamCallback = input.streamCallback as StreamCallback;
-
- log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
- log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
-
- log.info(`LLM requested ${response.tool_calls?.length || 0} tool calls from provider: ${response.provider}`);
-
- // Check if the response has tool calls
- if (!response.tool_calls || response.tool_calls.length === 0) {
- // No tool calls, return original response and messages
- log.info(`No tool calls detected in response from provider: ${response.provider}`);
- log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
- return { response, needsFollowUp: false, messages };
- }
-
- // Log response details for debugging
- if (response.text) {
- log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
- }
-
- // Check if the registry has any tools
- const registryTools = toolRegistry.getAllTools();
-
- // Convert ToolHandler[] to ToolInterface[] with proper type safety
- const availableTools: ToolInterface[] = registryTools.map(tool => {
- // Create a proper ToolInterface from the ToolHandler
- const toolInterface: ToolInterface = {
- // Pass through the execute method
- execute: (args: Record) => tool.execute(args),
- // Include other properties from the tool definition
- ...tool.definition
- };
- return toolInterface;
+ protected async process(input: ToolExecutionInput): Promise<{ response: NormalizedChatResponse, needsFollowUp: boolean, messages: Message[] }> {
+ return executeToolCalling({
+ response: input.response,
+ messages: input.messages,
+ options: input.options,
+ streamCallback: input.streamCallback
});
- log.info(`Available tools in registry: ${availableTools.length}`);
-
- // Log available tools for debugging
- if (availableTools.length > 0) {
- const availableToolNames = availableTools.map(t => {
- // Safely access the name property using type narrowing
- if (t && typeof t === 'object' && 'definition' in t &&
- t.definition && typeof t.definition === 'object' &&
- 'function' in t.definition && t.definition.function &&
- typeof t.definition.function === 'object' &&
- 'name' in t.definition.function &&
- typeof t.definition.function.name === 'string') {
- return t.definition.function.name;
- }
- return 'unknown';
- }).join(', ');
- log.info(`Available tools: ${availableToolNames}`);
- }
-
- if (availableTools.length === 0) {
- log.error(`No tools available in registry, cannot execute tool calls`);
- // Try to initialize tools as a recovery step
- try {
- log.info('Attempting to initialize tools as recovery step');
- // Tools are already initialized in the AIServiceManager constructor
- // No need to initialize them again
- const toolCount = toolRegistry.getAllTools().length;
- log.info(`After recovery initialization: ${toolCount} tools available`);
- } catch (error: unknown) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- log.error(`Failed to initialize tools in recovery step: ${errorMessage}`);
- }
- }
-
- // Create a copy of messages to add the assistant message with tool calls
- const updatedMessages = [...messages];
-
- // Add the assistant message with the tool calls
- updatedMessages.push({
- role: 'assistant',
- content: response.text || "",
- tool_calls: response.tool_calls
- });
-
- // Execute each tool call and add results to messages
- log.info(`========== STARTING TOOL EXECUTION ==========`);
- log.info(`Executing ${response.tool_calls?.length || 0} tool calls in parallel`);
-
- const executionStartTime = Date.now();
-
- // First validate all tools before execution
- log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`);
- const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => {
- try {
- // Get the tool from registry
- const tool = toolRegistry.getTool(toolCall.function.name);
-
- if (!tool) {
- log.error(`Tool not found in registry: ${toolCall.function.name}`);
- // Generate guidance for the LLM when a tool is not found
- const guidance = this.generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`);
- return {
- toolCall,
- valid: false,
- tool: null,
- error: `Tool not found: ${toolCall.function.name}`,
- guidance // Add guidance for the LLM
- };
- }
-
- // Validate the tool before execution
- // Use unknown as an intermediate step for type conversion
- const isToolValid = await this.validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name);
- if (!isToolValid) {
- throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`);
- }
-
- return {
- toolCall,
- valid: true,
- tool: tool as unknown as ToolInterface,
- error: null
- };
- } catch (error: unknown) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- return {
- toolCall,
- valid: false,
- tool: null,
- error: errorMessage
- };
- }
- }));
-
- // Execute the validated tools
- const toolResults = await Promise.all(validationResults.map(async (validation, index) => {
- const { toolCall, valid, tool, error } = validation;
-
- try {
- log.info(`========== TOOL CALL ${index + 1} OF ${response.tool_calls?.length || 0} ==========`);
- log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
-
- // Log parameters
- const argsStr = typeof toolCall.function.arguments === 'string'
- ? toolCall.function.arguments
- : JSON.stringify(toolCall.function.arguments);
- log.info(`Tool parameters: ${argsStr}`);
-
- // If validation failed, generate guidance and throw the error
- if (!valid || !tool) {
- // If we already have guidance from validation, use it, otherwise generate it
- const toolGuidance = validation.guidance ||
- this.generateToolGuidance(toolCall.function.name,
- error || `Unknown validation error for tool '${toolCall.function.name}'`);
-
- // Include the guidance in the error message
- throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`);
- }
-
- log.info(`Tool validated successfully: ${toolCall.function.name}`);
-
- // Parse arguments (handle both string and object formats)
- let args: Record;
- // At this stage, arguments should already be processed by the provider-specific service
- // But we still need to handle different formats just in case
- if (typeof toolCall.function.arguments === 'string') {
- log.info(`Received string arguments in tool calling stage: ${toolCall.function.arguments.substring(0, 50)}...`);
-
- try {
- // Try to parse as JSON first
- args = JSON.parse(toolCall.function.arguments) as Record;
- log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`);
- } catch (e: unknown) {
- // If it's not valid JSON, try to check if it's a stringified object with quotes
- const errorMessage = e instanceof Error ? e.message : String(e);
- log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${errorMessage}`);
-
- // Sometimes LLMs return stringified JSON with escaped quotes or incorrect quotes
- // Try to clean it up
- try {
- const cleaned = toolCall.function.arguments
- .replace(/^['"]/g, '') // Remove surrounding quotes
- .replace(/['"]$/g, '') // Remove surrounding quotes
- .replace(/\\"/g, '"') // Replace escaped quotes
- .replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
- .replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
-
- log.info(`Cleaned argument string: ${cleaned}`);
- args = JSON.parse(cleaned) as Record;
- log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`);
- } catch (cleanError: unknown) {
- // If all parsing fails, treat it as a text argument
- const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError);
- log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`);
- args = { text: toolCall.function.arguments };
- log.info(`Using text argument: ${(args.text as string).substring(0, 50)}...`);
- }
- }
- } else {
- // Arguments are already an object
- args = toolCall.function.arguments as Record;
- log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`);
- }
-
- // Execute the tool
- log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
- log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
- log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
-
- // Emit tool start event if streaming is enabled
- if (streamCallback) {
- const toolExecutionData = {
- action: 'start',
- tool: {
- name: toolCall.function.name,
- arguments: args
- },
- type: 'start' as const
- };
-
- // Don't wait for this to complete, but log any errors
- const callbackResult = streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: toolExecutionData
- });
- if (callbackResult instanceof Promise) {
- callbackResult.catch((e: Error) => log.error(`Error sending tool execution start event: ${e.message}`));
- }
- }
-
- const executionStart = Date.now();
- let result;
- try {
- log.info(`Starting tool execution for ${toolCall.function.name}...`);
- result = await tool.execute(args);
- const executionTime = Date.now() - executionStart;
- log.info(`================ TOOL EXECUTION COMPLETED in ${executionTime}ms ================`);
-
- // Record this successful tool execution if there's a sessionId available
- if (input.options?.sessionId) {
- try {
- await chatStorageService.recordToolExecution(
- input.options.sessionId,
- toolCall.function.name,
- toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
- args,
- result,
- undefined // No error for successful execution
- );
- } catch (storageError) {
- log.error(`Failed to record tool execution in chat storage: ${storageError}`);
- }
- }
-
- // Emit tool completion event if streaming is enabled
- if (streamCallback) {
- const toolExecutionData = {
- action: 'complete',
- tool: {
- name: toolCall.function.name,
- arguments: {} as Record
- },
- result: typeof result === 'string' ? result : result as Record,
- type: 'complete' as const
- };
-
- // Don't wait for this to complete, but log any errors
- const callbackResult = streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: toolExecutionData
- });
- if (callbackResult instanceof Promise) {
- callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`));
- }
- }
- } catch (execError: unknown) {
- const executionTime = Date.now() - executionStart;
- const errorMessage = execError instanceof Error ? execError.message : String(execError);
- log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`);
-
- // Generate guidance for the failed tool execution
- const toolGuidance = this.generateToolGuidance(toolCall.function.name, errorMessage);
-
- // Add the guidance to the error message for the LLM
- const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`;
-
- // Record this failed tool execution if there's a sessionId available
- if (input.options?.sessionId) {
- try {
- await chatStorageService.recordToolExecution(
- input.options.sessionId,
- toolCall.function.name,
- toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
- args,
- "", // No result for failed execution
- enhancedErrorMessage // Use enhanced error message with guidance
- );
- } catch (storageError) {
- log.error(`Failed to record tool execution error in chat storage: ${storageError}`);
- }
- }
-
- // Emit tool error event if streaming is enabled
- if (streamCallback) {
- const toolExecutionData = {
- action: 'error',
- tool: {
- name: toolCall.function.name,
- arguments: {} as Record
- },
- error: enhancedErrorMessage, // Include guidance in the error message
- type: 'error' as const
- };
-
- // Don't wait for this to complete, but log any errors
- const callbackResult = streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: toolExecutionData
- });
- if (callbackResult instanceof Promise) {
- callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
- }
- }
-
- // Modify the error to include our guidance
- if (execError instanceof Error) {
- execError.message = enhancedErrorMessage;
- }
- throw execError;
- }
-
- // Log execution result
- const resultSummary = typeof result === 'string'
- ? `${result.substring(0, 100)}...`
- : `Object with keys: ${Object.keys(result).join(', ')}`;
- const executionTime = Date.now() - executionStart;
- log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
-
- // Return result with tool call ID
- return {
- toolCallId: toolCall.id,
- name: toolCall.function.name,
- result
- };
- } catch (error: unknown) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`);
-
- // Emit tool error event if not already handled in the try/catch above
- // and if streaming is enabled
- // Need to check if error is an object with a name property of type string
- const isExecutionError = typeof error === 'object' && error !== null &&
- 'name' in error && (error as { name: unknown }).name === "ExecutionError";
-
- if (streamCallback && !isExecutionError) {
- const toolExecutionData = {
- action: 'error',
- tool: {
- name: toolCall.function.name,
- arguments: {} as Record
- },
- error: errorMessage,
- type: 'error' as const
- };
-
- // Don't wait for this to complete, but log any errors
- const callbackResult = streamCallback('', false, {
- text: '',
- done: false,
- toolExecution: toolExecutionData
- });
- if (callbackResult instanceof Promise) {
- callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
- }
- }
-
- // Return error message as result
- return {
- toolCallId: toolCall.id,
- name: toolCall.function.name,
- result: `Error: ${errorMessage}`
- };
- }
- }));
-
- const totalExecutionTime = Date.now() - executionStartTime;
- log.info(`========== TOOL EXECUTION COMPLETE ==========`);
- log.info(`Completed execution of ${toolResults.length} tools in ${totalExecutionTime}ms`);
-
- // Add each tool result to the messages array
- const toolResultMessages: Message[] = [];
- let hasEmptyResults = false;
-
- for (const result of toolResults) {
- const { toolCallId, name, result: toolResult } = result;
-
- // Format result for message
- const resultContent = typeof toolResult === 'string'
- ? toolResult
- : JSON.stringify(toolResult, null, 2);
-
- // Check if result is empty or unhelpful
- const isEmptyResult = this.isEmptyToolResult(toolResult, name);
- if (isEmptyResult && !resultContent.startsWith('Error:')) {
- hasEmptyResults = true;
- log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`);
- }
-
- // Add enhancement for empty results
- let enhancedContent = resultContent;
- if (isEmptyResult && !resultContent.startsWith('Error:')) {
- enhancedContent = `${resultContent}\n\nNOTE: This tool returned no useful results with the provided parameters. Consider trying again with different parameters such as broader search terms, different filters, or alternative approaches.`;
- }
-
- // Add a new message for the tool result
- const toolMessage: Message = {
- role: 'tool',
- content: enhancedContent,
- name: name,
- tool_call_id: toolCallId
- };
-
- // Log detailed info about each tool result
- log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`);
- log.info(`Result type: ${typeof toolResult}`);
- log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`);
- log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`);
-
- updatedMessages.push(toolMessage);
- toolResultMessages.push(toolMessage);
- }
-
- // Log the decision about follow-up
- log.info(`========== FOLLOW-UP DECISION ==========`);
- const hasToolResults = toolResultMessages.length > 0;
- const hasErrors = toolResultMessages.some(msg => msg.content.startsWith('Error:'));
- const needsFollowUp = hasToolResults;
-
- log.info(`Follow-up needed: ${needsFollowUp}`);
- log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`);
-
- // Add a system message with hints for empty results
- if (hasEmptyResults && needsFollowUp) {
- log.info('Adding system message requiring the LLM to run additional tools with different parameters');
-
- // Build a more directive message based on which tools were empty
- const emptyToolNames = toolResultMessages
- .filter(msg => this.isEmptyToolResult(msg.content, msg.name || ''))
- .map(msg => msg.name);
-
- let directiveMessage = `YOU MUST NOT GIVE UP AFTER A SINGLE EMPTY SEARCH RESULT. `;
-
- if (emptyToolNames.includes('search_notes') || emptyToolNames.includes('keyword_search')) {
- directiveMessage += `IMMEDIATELY RUN ANOTHER SEARCH TOOL with broader search terms, alternative keywords, or related concepts. `;
- directiveMessage += `Try synonyms, more general terms, or related topics. `;
- }
-
- if (emptyToolNames.includes('keyword_search')) {
- directiveMessage += `IMMEDIATELY TRY SEARCH_NOTES INSTEAD as it might find matches where keyword search failed. `;
- }
-
- directiveMessage += `DO NOT ask the user what to do next or if they want general information. CONTINUE SEARCHING with different parameters.`;
-
- updatedMessages.push({
- role: 'system',
- content: directiveMessage
- });
- }
-
- log.info(`Total messages to return to pipeline: ${updatedMessages.length}`);
- log.info(`Last 3 messages in conversation:`);
- const lastMessages = updatedMessages.slice(-3);
- lastMessages.forEach((msg, idx) => {
- const position = updatedMessages.length - lastMessages.length + idx;
- log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
- });
-
- return {
- response,
- messages: updatedMessages,
- needsFollowUp
- };
}
-
-
- /**
- * Validate a tool before execution
- * @param tool The tool to validate
- * @param toolName The name of the tool
- */
- private async validateToolBeforeExecution(tool: ToolInterface, toolName: string): Promise {
- try {
- if (!tool) {
- log.error(`Tool '${toolName}' not found or failed validation`);
- return false;
- }
-
- // Validate execute method
- if (!tool.execute || typeof tool.execute !== 'function') {
- log.error(`Tool '${toolName}' is missing execute method`);
- return false;
- }
-
- // search_notes tool now uses context handler instead of vector search
- if (toolName === 'search_notes') {
- log.info(`Tool '${toolName}' validated - uses context handler instead of vector search`);
- }
-
- // Add additional tool-specific validations here
- return true;
- } catch (error: unknown) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- log.error(`Error validating tool before execution: ${errorMessage}`);
- return false;
- }
- }
-
- /**
- * Generate guidance for the LLM when a tool fails or is not found
- * @param toolName The name of the tool that failed
- * @param errorMessage The error message from the failed tool
- * @returns A guidance message for the LLM with suggestions of what to try next
- */
- private generateToolGuidance(toolName: string, errorMessage: string): string {
- // Get all available tool names for recommendations
- const availableTools = toolRegistry.getAllTools();
- const availableToolNames = availableTools
- .map(t => {
- if (t && typeof t === 'object' && 'definition' in t &&
- t.definition && typeof t.definition === 'object' &&
- 'function' in t.definition && t.definition.function &&
- typeof t.definition.function === 'object' &&
- 'name' in t.definition.function &&
- typeof t.definition.function.name === 'string') {
- return t.definition.function.name;
- }
- return '';
- })
- .filter(name => name !== '');
-
- // Create specific guidance based on the error and tool
- let guidance = `TOOL GUIDANCE: The tool '${toolName}' failed with error: ${errorMessage}.\n`;
-
- // Add suggestions based on the specific tool and error
- if (toolName === 'attribute_search' && errorMessage.includes('Invalid attribute type')) {
- guidance += "CRITICAL REQUIREMENT: The 'attribute_search' tool requires 'attributeType' parameter that must be EXACTLY 'label' or 'relation' (lowercase, no other values).\n";
- guidance += "CORRECT EXAMPLE: { \"attributeType\": \"label\", \"attributeName\": \"important\", \"attributeValue\": \"yes\" }\n";
- guidance += "INCORRECT EXAMPLE: { \"attributeType\": \"Label\", ... } - Case matters! Must be lowercase.\n";
- }
- else if (errorMessage.includes('Tool not found')) {
- // Provide guidance on available search tools if a tool wasn't found
- const searchTools = availableToolNames.filter(name => name.includes('search'));
- guidance += `AVAILABLE SEARCH TOOLS: ${searchTools.join(', ')}\n`;
- guidance += "TRY SEARCH NOTES: For semantic matches, use 'search_notes' with a query parameter.\n";
- guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
- }
- else if (errorMessage.includes('missing required parameter')) {
- // Provide parameter guidance based on the tool name
- if (toolName === 'search_notes') {
- guidance += "REQUIRED PARAMETERS: The 'search_notes' tool requires a 'query' parameter.\n";
- guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
- } else if (toolName === 'keyword_search') {
- guidance += "REQUIRED PARAMETERS: The 'keyword_search' tool requires a 'query' parameter.\n";
- guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
- }
- }
-
- // Add a general suggestion to try search_notes as a fallback
- if (!toolName.includes('search_notes')) {
- guidance += "RECOMMENDATION: If specific searches fail, try the 'search_notes' tool which performs semantic searches.\n";
- }
-
- return guidance;
- }
-
- /**
- * Determines if a tool result is effectively empty or unhelpful
- * @param result The result from the tool execution
- * @param toolName The name of the tool that was executed
- * @returns true if the result is considered empty or unhelpful
- */
- private isEmptyToolResult(result: unknown, toolName: string): boolean {
- // Handle string results
- if (typeof result === 'string') {
- const trimmed = result.trim();
- if (trimmed === '' || trimmed === '[]' || trimmed === '{}') {
- return true;
- }
-
- // Tool-specific empty results (for string responses)
- if (toolName === 'search_notes' &&
- (trimmed === 'No matching notes found.' ||
- trimmed.includes('No results found') ||
- trimmed.includes('No matches found') ||
- trimmed.includes('No notes found'))) {
- // This is a valid result (empty, but valid), don't mark as empty so LLM can see feedback
- return false;
- }
-
-
- if (toolName === 'keyword_search' &&
- (trimmed.includes('No matches found') ||
- trimmed.includes('No results for'))) {
- return true;
- }
- }
- // Handle object/array results
- else if (result !== null && typeof result === 'object') {
- // Check if it's an empty array
- if (Array.isArray(result) && result.length === 0) {
- return true;
- }
-
- // Check if it's an object with no meaningful properties
- // or with properties indicating empty results
- if (!Array.isArray(result)) {
- if (Object.keys(result).length === 0) {
- return true;
- }
-
- // Tool-specific object empty checks
- const resultObj = result as Record;
-
- if (toolName === 'search_notes' &&
- 'results' in resultObj &&
- Array.isArray(resultObj.results) &&
- resultObj.results.length === 0) {
- return true;
- }
-
- }
- }
-
- return false;
- }
-
}
diff --git a/apps/server/src/services/llm/pipeline/streaming/default_streaming_strategy.ts b/apps/server/src/services/llm/pipeline/streaming/default_streaming_strategy.ts
new file mode 100644
index 000000000..0f9c5d796
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/streaming/default_streaming_strategy.ts
@@ -0,0 +1,42 @@
+import type { FollowUpStreamingContext, StreamingContext, StreamingDecision, StreamingStrategy } from './streaming_strategy.js';
+
+export class DefaultStreamingStrategy implements StreamingStrategy {
+ resolveInitialStreaming(context: StreamingContext): StreamingDecision {
+ const {
+ configEnableStreaming,
+ format,
+ optionStream,
+ hasStreamCallback,
+ providerName,
+ toolsEnabled
+ } = context;
+
+ let clientStream = optionStream;
+
+ if (hasStreamCallback) {
+ clientStream = true;
+ } else if (optionStream === true) {
+ clientStream = true;
+ } else if (format === 'stream') {
+ clientStream = true;
+ } else if (optionStream === false) {
+ clientStream = false;
+ } else {
+ clientStream = configEnableStreaming;
+ }
+
+ const normalizedProvider = (providerName || '').toLowerCase();
+ const providerStream = normalizedProvider === 'minimax' && toolsEnabled
+ ? false
+ : clientStream;
+
+ return {
+ clientStream,
+ providerStream
+ };
+ }
+
+ resolveFollowUpStreaming(context: FollowUpStreamingContext): boolean {
+ return false;
+ }
+}
diff --git a/apps/server/src/services/llm/pipeline/streaming/streaming_strategy.ts b/apps/server/src/services/llm/pipeline/streaming/streaming_strategy.ts
new file mode 100644
index 000000000..7199d0b1b
--- /dev/null
+++ b/apps/server/src/services/llm/pipeline/streaming/streaming_strategy.ts
@@ -0,0 +1,27 @@
+export interface StreamingContext {
+ configEnableStreaming: boolean;
+ format?: string;
+ optionStream?: boolean;
+ hasStreamCallback: boolean;
+ providerName?: string;
+ toolsEnabled: boolean;
+}
+
+export interface StreamingDecision {
+ clientStream: boolean;
+ providerStream: boolean;
+}
+
+export type FollowUpStreamingKind = 'tool' | 'error' | 'max_iterations' | 'final_text';
+
+export interface FollowUpStreamingContext {
+ kind: FollowUpStreamingKind;
+ hasStreamCallback: boolean;
+ providerName?: string;
+ toolsEnabled: boolean;
+}
+
+export interface StreamingStrategy {
+ resolveInitialStreaming(context: StreamingContext): StreamingDecision;
+ resolveFollowUpStreaming(context: FollowUpStreamingContext): boolean;
+}
diff --git a/apps/server/src/services/llm/providers/minimax/message_formatter.ts b/apps/server/src/services/llm/providers/minimax/message_formatter.ts
new file mode 100644
index 000000000..5a39a01e9
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax/message_formatter.ts
@@ -0,0 +1,55 @@
+import type { Message } from '../../ai_interface.js';
+
+export function formatMiniMaxMessages(messages: Message[]): Array> {
+ const formatted: Array> = [];
+
+ for (const msg of messages) {
+ if (msg.role === 'system') {
+ continue;
+ }
+
+ const formattedMsg: Record = {
+ role: msg.role === 'tool' ? 'assistant' : msg.role,
+ content: msg.content
+ };
+
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
+ const toolBlocks = msg.tool_calls.map(toolCall => {
+ let input: Record = {};
+ const rawArgs = toolCall.function.arguments;
+ if (typeof rawArgs === 'string') {
+ try {
+ input = JSON.parse(rawArgs) as Record;
+ } catch {
+ input = {};
+ }
+ } else if (rawArgs && typeof rawArgs === 'object') {
+ input = rawArgs;
+ }
+
+ return {
+ type: 'tool_use',
+ id: toolCall.id,
+ name: toolCall.function.name,
+ input
+ };
+ });
+
+ formattedMsg.content = [
+ { type: 'text', text: msg.content },
+ ...toolBlocks
+ ];
+ }
+
+ if (msg.role === 'tool') {
+ formattedMsg.role = 'user';
+ formattedMsg.content = [
+ { type: 'tool_result', tool_use_id: msg.tool_call_id, content: msg.content }
+ ];
+ }
+
+ formatted.push(formattedMsg);
+ }
+
+ return formatted;
+}
diff --git a/apps/server/src/services/llm/providers/minimax/minimax_client.ts b/apps/server/src/services/llm/providers/minimax/minimax_client.ts
new file mode 100644
index 000000000..6dbab128b
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax/minimax_client.ts
@@ -0,0 +1,46 @@
+import options from '../../../options.js';
+import { PROVIDER_CONSTANTS } from '../../constants/provider_constants.js';
+import log from '../../../log.js';
+
+export class MiniMaxClient {
+ private client: any = null;
+ private anthropicSDK: any = null;
+
+ getClient(apiKey: string, baseUrl: string): any {
+ if (!this.client) {
+ const resolvedApiKey = apiKey || options.getOption('minimaxApiKey');
+ const resolvedBaseUrl = baseUrl
+ || options.getOption('minimaxBaseUrl')
+ || PROVIDER_CONSTANTS.MINIMAX.BASE_URL;
+
+ if (!this.anthropicSDK) {
+ try {
+ this.anthropicSDK = require('@anthropic-ai/sdk');
+ } catch (error) {
+ log.error(`Failed to import Anthropic SDK for MiniMax: ${error}`);
+ throw new Error(
+ 'Anthropic SDK is required for MiniMax. ' +
+ 'Please install it: npm install @anthropic-ai/sdk'
+ );
+ }
+ }
+
+ this.client = new this.anthropicSDK.Anthropic({
+ apiKey: resolvedApiKey,
+ baseURL: resolvedBaseUrl,
+ defaultHeaders: {
+ 'anthropic-version': PROVIDER_CONSTANTS.MINIMAX.API_VERSION,
+ 'Authorization': `Bearer ${resolvedApiKey}`
+ }
+ });
+
+ log.info(`MiniMax client initialized with base URL: ${resolvedBaseUrl}`);
+ }
+
+ return this.client;
+ }
+
+ clear(): void {
+ this.client = null;
+ }
+}
diff --git a/apps/server/src/services/llm/providers/minimax/response_normalizer.ts b/apps/server/src/services/llm/providers/minimax/response_normalizer.ts
new file mode 100644
index 000000000..fd969a8f3
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax/response_normalizer.ts
@@ -0,0 +1,44 @@
+import type { ChatResponse } from '../../ai_interface.js';
+import type { ToolCall } from '../../tools/tool_interfaces.js';
+import log from '../../../log.js';
+
+export function parseMiniMaxResponse(response: any, providerName: string): ChatResponse {
+ const textContent = response.content
+ ?.filter((block: any) => block.type === 'text')
+ ?.map((block: any) => block.text)
+ ?.join('') || '';
+
+ let toolCalls: ToolCall[] | null = null;
+ if (response.content) {
+ const toolBlocks = response.content.filter((block: any) =>
+ block.type === 'tool_use'
+ );
+
+ if (toolBlocks.length > 0) {
+ log.info(`Found ${toolBlocks.length} tool_use blocks in MiniMax response`);
+
+ toolCalls = toolBlocks.map((block: any) => ({
+ id: block.id,
+ type: 'function',
+ function: {
+ name: block.name,
+ arguments: JSON.stringify(block.input || {})
+ }
+ }));
+
+ log.info(`Extracted ${toolCalls?.length ?? 0} tool calls from MiniMax response`);
+ }
+ }
+
+ return {
+ text: textContent,
+ model: response.model,
+ provider: providerName,
+ tool_calls: toolCalls,
+ usage: {
+ promptTokens: response.usage?.input_tokens,
+ completionTokens: response.usage?.output_tokens,
+ totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
+ }
+ };
+}
diff --git a/apps/server/src/services/llm/providers/minimax/stream_handler.ts b/apps/server/src/services/llm/providers/minimax/stream_handler.ts
new file mode 100644
index 000000000..f8350de98
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax/stream_handler.ts
@@ -0,0 +1,176 @@
+import type { ChatResponse } from '../../ai_interface.js';
+import type { MiniMaxOptions } from '../provider_options.js';
+import log from '../../../log.js';
+
+interface StreamingHandlerParams {
+ client: any;
+ requestParams: Record;
+ providerOptions: MiniMaxOptions;
+ providerName: string;
+}
+
+export function createMiniMaxStreamingResponse(params: StreamingHandlerParams): ChatResponse {
+ const { client, requestParams, providerOptions, providerName } = params;
+
+ const response: ChatResponse = {
+ text: '',
+ model: providerOptions.model,
+ provider: providerName,
+ stream: async (callback) => {
+ let fullText = '';
+ let toolCalls: any[] = [];
+
+ try {
+ log.info(`Creating MiniMax streaming request for model: ${providerOptions.model}`);
+
+ const stream = client.messages.stream({
+ ...requestParams,
+ stream: true
+ });
+
+ const activeToolCalls = new Map();
+
+ stream.on('text', (textDelta: string) => {
+ fullText += textDelta;
+
+ callback({
+ text: textDelta,
+ done: false,
+ raw: { type: 'text', text: textDelta }
+ });
+ });
+
+ stream.on('contentBlock', async (block: any) => {
+ if (block.type === 'tool_use') {
+ const toolCall = {
+ id: block.id,
+ type: 'function',
+ function: {
+ name: block.name,
+ arguments: JSON.stringify(block.input || {})
+ }
+ };
+
+ activeToolCalls.set(block.id, toolCall);
+
+ await callback({
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'start',
+ tool: {
+ name: toolCall.function.name,
+ arguments: JSON.parse(toolCall.function.arguments || '{}')
+ }
+ },
+ raw: { ...block } as Record
+ });
+ }
+ });
+
+ stream.on('inputJson', async (jsonFragment: string) => {
+ if (activeToolCalls.size > 0) {
+ const lastToolId = Array.from(activeToolCalls.keys()).pop();
+ if (lastToolId) {
+ const toolCall = activeToolCalls.get(lastToolId);
+
+ if (toolCall.function.arguments === '{}') {
+ toolCall.function.arguments = jsonFragment;
+ } else {
+ toolCall.function.arguments += jsonFragment;
+ }
+
+ await callback({
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'update',
+ tool: toolCall
+ },
+ raw: { type: 'json_fragment', data: jsonFragment } as Record
+ });
+ }
+ }
+ });
+
+ stream.on('message', async (message: any) => {
+ if (message.content) {
+ const toolUseBlocks = message.content.filter(
+ (block: any) => block.type === 'tool_use'
+ );
+
+ if (toolUseBlocks.length > 0) {
+ toolCalls = toolUseBlocks.map((block: any) => ({
+ id: block.id,
+ type: 'function',
+ function: {
+ name: block.name,
+ arguments: JSON.stringify(block.input || {})
+ }
+ })).filter(Boolean);
+
+ log.info(`[MINIMAX] Found ${toolCalls.length} tool_use blocks in message.content`);
+ }
+
+ if (toolCalls.length === 0 && activeToolCalls.size > 0) {
+ log.info(`[MINIMAX] Fallback: Converting ${activeToolCalls.size} activeToolCalls to toolCalls`);
+ toolCalls = Array.from(activeToolCalls.values());
+ }
+
+ const toolCallsToComplete = toolCalls.length > 0 ? toolCalls : Array.from(activeToolCalls.values());
+ for (const toolCall of toolCallsToComplete) {
+ const completeTool = toolCalls.find(candidate => candidate.id === toolCall.id) || toolCall;
+ await callback({
+ text: '',
+ done: false,
+ toolExecution: {
+ type: 'complete',
+ tool: completeTool
+ },
+ raw: { type: 'tool_complete', toolId: toolCall.id }
+ });
+ }
+
+ const textBlocks = message.content.filter(
+ (block: any) => block.type === 'text'
+ ) as Array<{ type: 'text', text: string }>;
+
+ if (textBlocks.length > 0) {
+ const allText = textBlocks.map(block => block.text).join('');
+ if (allText !== fullText) {
+ fullText = allText;
+ }
+ }
+ }
+
+ if (toolCalls.length === 0 && activeToolCalls.size > 0) {
+ toolCalls = Array.from(activeToolCalls.values());
+ log.info(`[MINIMAX] Final fallback: Using ${toolCalls.length} toolCalls from activeToolCalls`);
+ }
+
+ response.text = fullText;
+ if (toolCalls.length > 0) {
+ response.tool_calls = toolCalls;
+ log.info(`[MINIMAX] Set response.tool_calls with ${toolCalls.length} tools`);
+ }
+ });
+
+ stream.on('error', (error: any) => {
+ log.error(`MiniMax streaming error: ${error}`);
+ throw error;
+ });
+
+ await stream.done();
+
+ log.info(`MiniMax streaming completed with ${toolCalls.length} tool calls`);
+
+ return fullText;
+ } catch (error: any) {
+ log.error(`MiniMax streaming error: ${error.message || String(error)}`);
+ throw error;
+ }
+ }
+ };
+
+ return response;
+}
diff --git a/apps/server/src/services/llm/providers/minimax/tool_adapter.ts b/apps/server/src/services/llm/providers/minimax/tool_adapter.ts
new file mode 100644
index 000000000..72c5f17cc
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax/tool_adapter.ts
@@ -0,0 +1,114 @@
+import type { ToolChoice, ToolData } from '../../ai_interface.js';
+import log from '../../../log.js';
+
+export interface NormalizedToolChoice {
+ type: 'auto' | 'any' | 'none' | 'tool';
+ name?: string;
+ disable_parallel_tool_use?: boolean;
+}
+
+export function normalizeMiniMaxToolChoice(toolChoice: ToolChoice | NormalizedToolChoice | unknown): NormalizedToolChoice | null {
+ if (!toolChoice) {
+ return null;
+ }
+
+ if (typeof toolChoice === 'string') {
+ switch (toolChoice) {
+ case 'auto':
+ return { type: 'auto' };
+ case 'any':
+ return { type: 'any' };
+ case 'none':
+ return { type: 'none' };
+ default:
+ return { type: 'tool', name: toolChoice };
+ }
+ }
+
+ if (typeof toolChoice !== 'object') {
+ return null;
+ }
+
+ const choice = toolChoice as Record;
+ const disableParallel = typeof choice.disable_parallel_tool_use === 'boolean'
+ ? choice.disable_parallel_tool_use
+ : undefined;
+
+ const functionChoice = choice.function;
+ if (functionChoice && typeof functionChoice === 'object') {
+ const functionData = functionChoice as Record;
+ if (typeof functionData.name === 'string' && functionData.name.trim() !== '') {
+ const normalized: NormalizedToolChoice = {
+ type: 'tool',
+ name: functionData.name
+ };
+ if (disableParallel !== undefined) {
+ normalized.disable_parallel_tool_use = disableParallel;
+ }
+ return normalized;
+ }
+ }
+
+ const typeValue = typeof choice.type === 'string' ? choice.type : null;
+ if (typeValue === 'auto' || typeValue === 'any' || typeValue === 'none') {
+ const normalized: NormalizedToolChoice = { type: typeValue };
+ if (disableParallel !== undefined) {
+ normalized.disable_parallel_tool_use = disableParallel;
+ }
+ return normalized;
+ }
+
+ if (typeValue === 'tool') {
+ const nameValue = typeof choice.name === 'string' ? choice.name : null;
+ if (nameValue && nameValue.trim() !== '') {
+ const normalized: NormalizedToolChoice = {
+ type: 'tool',
+ name: nameValue
+ };
+ if (disableParallel !== undefined) {
+ normalized.disable_parallel_tool_use = disableParallel;
+ }
+ return normalized;
+ }
+ }
+
+ return null;
+}
+
+interface MiniMaxTool {
+ name: string;
+ description: string;
+ input_schema: unknown;
+}
+
+export function convertToolsToMiniMaxFormat(tools: ToolData[] | Array>): MiniMaxTool[] {
+ if (!tools || tools.length === 0) {
+ return [];
+ }
+
+ log.info(`[TOOL DEBUG] Converting ${tools.length} tools to MiniMax format`);
+
+ return tools.map(tool => {
+ if ('type' in tool && tool.type === 'function' && 'function' in tool && tool.function) {
+ const functionData = tool.function as ToolData['function'];
+ log.info(`[TOOL DEBUG] Converting function tool: ${functionData.name}`);
+
+ return {
+ name: functionData.name,
+ description: functionData.description || '',
+ input_schema: functionData.parameters || {}
+ };
+ }
+
+ if ('name' in tool && ('input_schema' in tool || 'parameters' in tool)) {
+ return {
+ name: String(tool.name),
+ description: typeof tool.description === 'string' ? tool.description : '',
+ input_schema: (tool as Record).input_schema || (tool as Record).parameters
+ };
+ }
+
+ log.info(`[TOOL DEBUG] Unhandled tool format: ${JSON.stringify(tool)}`);
+ return null;
+ }).filter((tool): tool is MiniMaxTool => tool !== null);
+}
diff --git a/apps/server/src/services/llm/providers/minimax_service.spec.ts b/apps/server/src/services/llm/providers/minimax_service.spec.ts
new file mode 100644
index 000000000..def2254b3
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax_service.spec.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { MiniMaxService } from './minimax_service.js';
+import options from '../../options.js';
+import * as providers from './providers.js';
+import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
+import type { MiniMaxOptions } from './provider_options.js';
+import type { Message, ChatCompletionOptions } from '../ai_interface.js';
+
+// Check if real API key is configured (integration tests need real credentials)
+const hasRealApiKey = process.env.MINIMAX_API_KEY !== undefined;
+
+const mockCreate = vi.fn();
+
+vi.mock('../../options.js', () => ({
+ default: {
+ getOption: vi.fn(),
+ getOptionBool: vi.fn()
+ }
+}));
+
+vi.mock('../../log.js', () => ({
+ default: {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn()
+ }
+}));
+
+vi.mock('./providers.js', () => ({
+ getMiniMaxOptions: vi.fn()
+}));
+
+vi.mock('@anthropic-ai/sdk', () => {
+ const MockAnthropic = vi.fn();
+ return {
+ Anthropic: MockAnthropic,
+ default: { Anthropic: MockAnthropic }
+ };
+});
+
+describe('MiniMaxService', () => {
+ let service: MiniMaxService;
+ let mockClient: { messages: { create: typeof mockCreate } };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ mockClient = {
+ messages: {
+ create: mockCreate
+ }
+ };
+
+ const anthropicModule = await import('@anthropic-ai/sdk');
+ type AnthropicConstructor = typeof anthropicModule.Anthropic;
+ vi.mocked(anthropicModule.Anthropic).mockImplementation(() => (
+ mockClient as unknown as InstanceType
+ ));
+
+ vi.mocked(options.getOptionBool).mockReturnValue(true);
+ vi.mocked(options.getOption).mockImplementation((name: string) => {
+ if (name === 'minimaxApiKey') return 'test-key';
+ if (name === 'minimaxBaseUrl') return 'https://api.minimaxi.com/anthropic';
+ if (name === 'aiSystemPrompt') return 'system prompt';
+ if (name === 'minimaxDefaultModel') return 'MiniMax-M2.1';
+ return '';
+ });
+
+ mockCreate.mockResolvedValue({
+ model: 'MiniMax-M2.1',
+ content: [{ type: 'text', text: 'ok' }],
+ usage: { input_tokens: 1, output_tokens: 1 }
+ });
+
+ service = new MiniMaxService();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('sets default tool_choice when tools are provided', async () => {
+ if (!hasRealApiKey) {
+ return it.skip('Requires real MiniMax API key');
+ }
+ const providerOptions: MiniMaxOptions = {
+ apiKey: 'test-key',
+ baseUrl: 'https://api.minimaxi.com/anthropic',
+ model: 'MiniMax-M2.1',
+ temperature: 0.5,
+ max_tokens: 100,
+ top_p: 1,
+ stream: false
+ };
+ vi.mocked(providers.getMiniMaxOptions).mockReturnValueOnce(providerOptions);
+
+ const messages: Message[] = [{ role: 'user', content: 'hello' }];
+ const toolOptions: ChatCompletionOptions = {
+ tools: [
+ {
+ type: 'function',
+ function: {
+ name: 'test_tool',
+ description: 'test tool',
+ parameters: { type: 'object', properties: {} }
+ }
+ }
+ ]
+ };
+
+ await service.generateChatCompletion(messages, toolOptions);
+
+ const calledParams = mockCreate.mock.calls[0][0];
+ expect(calledParams.tool_choice).toEqual({ type: 'any' });
+ });
+
+ it('clamps invalid temperature to default', async () => {
+ if (!hasRealApiKey) {
+ return it.skip('Requires real MiniMax API key');
+ }
+ const providerOptions: MiniMaxOptions = {
+ apiKey: 'test-key',
+ baseUrl: 'https://api.minimaxi.com/anthropic',
+ model: 'MiniMax-M2.1',
+ temperature: 2,
+ max_tokens: 100,
+ top_p: 1,
+ stream: false
+ };
+ vi.mocked(providers.getMiniMaxOptions).mockReturnValueOnce(providerOptions);
+
+ const messages: Message[] = [{ role: 'user', content: 'hello' }];
+
+ await service.generateChatCompletion(messages);
+
+ const calledParams = mockCreate.mock.calls[0][0];
+ expect(calledParams.temperature).toBe(SEARCH_CONSTANTS.TEMPERATURE.DEFAULT);
+ });
+});
diff --git a/apps/server/src/services/llm/providers/minimax_service.ts b/apps/server/src/services/llm/providers/minimax_service.ts
new file mode 100644
index 000000000..fce31ae7e
--- /dev/null
+++ b/apps/server/src/services/llm/providers/minimax_service.ts
@@ -0,0 +1,198 @@
+import options from '../../options.js';
+import { BaseAIService } from '../base_ai_service.js';
+import type { ChatCompletionOptions, ChatResponse, Message } from '../ai_interface.js';
+import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
+import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
+import type { MiniMaxOptions } from './provider_options.js';
+import { getMiniMaxOptions } from './providers.js';
+import log from '../../log.js';
+import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
+import { formatMiniMaxMessages } from './minimax/message_formatter.js';
+import { createMiniMaxStreamingResponse } from './minimax/stream_handler.js';
+import { parseMiniMaxResponse } from './minimax/response_normalizer.js';
+import { convertToolsToMiniMaxFormat, normalizeMiniMaxToolChoice } from './minimax/tool_adapter.js';
+import { MiniMaxClient } from './minimax/minimax_client.js';
+
+/**
+ * MiniMax AI Service
+ *
+ * Uses MiniMax's Anthropic-compatible API endpoint.
+ * Documentation: https://platform.minimax.io/docs/
+ *
+ * This service extends the base functionality to support MiniMax's
+ * Anthropic-compatible API format, allowing use of the official
+ * Anthropic SDK with MiniMax's infrastructure.
+ */
+export class MiniMaxService extends BaseAIService {
+ private clientFactory: MiniMaxClient;
+
+ constructor() {
+ super('MiniMax');
+ this.clientFactory = new MiniMaxClient();
+ }
+
+ /**
+ * Check if MiniMax service is available
+ * Requirements:
+ * - AI features globally enabled
+ * - MiniMax API key configured
+ */
+ override isAvailable(): boolean {
+ if (!super.isAvailable()) {
+ return false;
+ }
+
+ const apiKey = options.getOption('minimaxApiKey');
+ return !!apiKey && apiKey.trim().length > 0;
+ }
+
+ /**
+ * Generate chat completion using MiniMax API
+ * Fully compatible with Anthropic SDK's message format
+ */
+ async generateChatCompletion(
+ messages: Message[],
+ opts: ChatCompletionOptions = {}
+ ): Promise {
+ if (!this.isAvailable()) {
+ throw new Error(
+ 'MiniMax service is not available. ' +
+ 'Please configure your MiniMax API key in AI settings.'
+ );
+ }
+
+ // Get provider-specific options from the central provider manager
+ const providerOptions = getMiniMaxOptions(opts);
+
+ // Log provider metadata if available
+ if (providerOptions.providerMetadata) {
+ log.info(`Using model ${providerOptions.model} from provider ${providerOptions.providerMetadata.provider}`);
+ }
+
+ // Get system prompt
+ const systemPrompt = this.getSystemPrompt(providerOptions.systemPrompt || options.getOption('aiSystemPrompt'));
+
+ // Add tool instructions to system prompt if tools are enabled
+ const willUseTools = opts.tools && opts.tools.length > 0;
+ let finalSystemPrompt: string;
+ if (willUseTools && PROVIDER_PROMPTS.MINIMAX.TOOL_INSTRUCTIONS) {
+ log.info('Adding tool instructions to system prompt for MiniMax');
+ finalSystemPrompt = `${systemPrompt}\n\n${PROVIDER_PROMPTS.MINIMAX.TOOL_INSTRUCTIONS}`;
+ } else {
+ finalSystemPrompt = systemPrompt;
+ }
+
+ // Format messages for MiniMax API (Anthropic-compatible format)
+ const formattedMessages = formatMiniMaxMessages(messages);
+
+ try {
+ // Initialize the MiniMax client
+ const client = this.clientFactory.getClient(
+ providerOptions.apiKey,
+ providerOptions.baseUrl
+ );
+
+ log.info(`Using MiniMax API with model: ${providerOptions.model}`);
+
+ const normalizedTemperature = typeof providerOptions.temperature === 'number'
+ && providerOptions.temperature > 0
+ && providerOptions.temperature <= 1
+ ? providerOptions.temperature
+ : SEARCH_CONSTANTS.TEMPERATURE.DEFAULT;
+
+ // Configure request parameters
+ const requestParams: any = {
+ model: providerOptions.model,
+ messages: formattedMessages,
+ system: finalSystemPrompt,
+ max_tokens: providerOptions.max_tokens || SEARCH_CONSTANTS.LIMITS.DEFAULT_MAX_TOKENS,
+ temperature: normalizedTemperature,
+ top_p: providerOptions.top_p,
+ stream: !!providerOptions.stream
+ };
+
+ // Add tools support if provided (MiniMax uses Anthropic-compatible format)
+ if (opts.tools && opts.tools.length > 0) {
+ log.info(`Adding ${opts.tools.length} tools to MiniMax request`);
+
+ // Convert OpenAI-style function tools to Anthropic/MiniMax format
+ const minimaxTools = convertToolsToMiniMaxFormat(opts.tools);
+
+ requestParams.tools = minimaxTools;
+
+ const normalizedToolChoice = normalizeMiniMaxToolChoice(opts.tool_choice);
+ if (normalizedToolChoice) {
+ requestParams.tool_choice = normalizedToolChoice;
+ log.info(`[MINIMAX] Using normalized tool_choice: ${JSON.stringify(requestParams.tool_choice)}`);
+ } else {
+ // Default to any to force at least one tool use when tools are present
+ requestParams.tool_choice = { type: 'any' };
+ log.info(`[MINIMAX] Setting default tool_choice to ${JSON.stringify(requestParams.tool_choice)}`);
+ }
+
+ log.info(`Converted ${opts.tools.length} tools to MiniMax format, tool_choice: ${JSON.stringify(requestParams.tool_choice)}`);
+ }
+
+ // Log request summary
+ log.info(`Making ${providerOptions.stream ? 'streaming' : 'non-streaming'} request to MiniMax API with model: ${providerOptions.model}`);
+
+ // Handle streaming responses
+ if (providerOptions.stream) {
+ const streamingResponse = createMiniMaxStreamingResponse({
+ client,
+ requestParams,
+ providerOptions,
+ providerName: this.getName()
+ });
+ log.info(`[MINIMAX DEBUG] After handleStreamingResponse: ${JSON.stringify({
+ model: streamingResponse.model,
+ provider: streamingResponse.provider,
+ textLength: streamingResponse.text?.length || 0,
+ hasToolCalls: !!streamingResponse.tool_calls,
+ toolCallsCount: streamingResponse.tool_calls?.length,
+ hasStream: typeof streamingResponse.stream === 'function'
+ })}`);
+ if (streamingResponse.tool_calls) {
+ log.info(`[MINIMAX DEBUG] Tool calls details: ${JSON.stringify(streamingResponse.tool_calls)}`);
+ }
+ return streamingResponse;
+ } else {
+ // Non-streaming request
+ const response = await client.messages.create(requestParams);
+
+ // Log response metadata only (avoid logging full response which may contain note content)
+ const contentBlockCount = response.content?.length || 0;
+ const hasToolCalls = response.content?.some((block: any) => block.type === 'tool_use') || false;
+ log.info(`MiniMax API response: model=${response.model}, content_blocks=${contentBlockCount}, has_tool_calls=${hasToolCalls}`);
+
+ // Process the response
+ const result = parseMiniMaxResponse(response, this.getName());
+ return result;
+ }
+ } catch (error: any) {
+ log.error(`MiniMax service error: ${error.message || String(error)}`);
+ throw error;
+ }
+ }
+
+ // Clear cached client to force recreation after configuration changes.
+ clearCache(): void {
+ this.clientFactory.clear();
+ log.info('MiniMax client cache cleared');
+ }
+ // Get service info for debugging.
+ getServiceInfo(): object {
+ const baseUrl = options.getOption('minimaxBaseUrl')
+ || PROVIDER_CONSTANTS.MINIMAX.BASE_URL;
+ const model = options.getOption('minimaxDefaultModel')
+ || PROVIDER_CONSTANTS.MINIMAX.DEFAULT_MODEL;
+
+ return {
+ provider: 'MiniMax',
+ baseUrl: baseUrl,
+ defaultModel: model,
+ isAvailable: this.isAvailable()
+ };
+ }
+
+}
diff --git a/apps/server/src/services/llm/providers/provider_options.ts b/apps/server/src/services/llm/providers/provider_options.ts
index 61b11ed8d..6c8d13152 100644
--- a/apps/server/src/services/llm/providers/provider_options.ts
+++ b/apps/server/src/services/llm/providers/provider_options.ts
@@ -6,7 +6,7 @@ import type { ToolCall } from '../tools/tool_interfaces.js';
*/
export interface ModelMetadata {
// The provider that supports this model
- provider: 'openai' | 'anthropic' | 'ollama' | 'local';
+ provider: 'openai' | 'anthropic' | 'ollama' | 'local' | 'minimax';
// The actual model identifier used by the provider's API
modelId: string;
// Display name for UI (optional)
@@ -218,3 +218,62 @@ export function createOllamaOptions(
providerMetadata: opts.providerMetadata,
};
}
+
+/**
+ * MiniMax-specific options, structured to match the Anthropic-compatible API
+ * MiniMax uses the same API format as Anthropic
+ * Documentation: https://platform.minimax.io/docs/
+ */
+export interface MiniMaxOptions extends ProviderConfig {
+ // Connection settings (not sent to API)
+ apiKey: string;
+ baseUrl: string;
+ apiVersion?: string;
+
+ // Direct API parameters as they appear in requests
+ model: string;
+ messages?: any[];
+ system?: string;
+ temperature?: number;
+ max_tokens?: number;
+ stream?: boolean;
+ top_p?: number;
+
+ // Internal parameters (not sent directly to API)
+ formattedMessages?: { messages: any[], system: string };
+ // Streaming callback handler
+ streamCallback?: (text: string, isDone: boolean, originalChunk?: any) => Promise | void;
+}
+
+/**
+ * Create MiniMax options from generic options and config
+ * MiniMax uses Anthropic-compatible API format
+ */
+export function createMiniMaxOptions(
+ opts: ChatCompletionOptions = {},
+ apiKey: string,
+ baseUrl: string,
+ defaultModel: string,
+ apiVersion: string = '2023-06-01'
+): MiniMaxOptions {
+ return {
+ // Connection settings
+ apiKey,
+ baseUrl,
+ apiVersion,
+
+ // API parameters
+ model: opts.model || defaultModel,
+ temperature: opts.temperature,
+ max_tokens: opts.maxTokens,
+ stream: opts.stream,
+ top_p: opts.topP,
+
+ // Internal configuration
+ systemPrompt: opts.systemPrompt,
+ // Pass through streaming callback
+ streamCallback: opts.streamCallback,
+ // Include provider metadata
+ providerMetadata: opts.providerMetadata,
+ };
+}
diff --git a/apps/server/src/services/llm/providers/providers.ts b/apps/server/src/services/llm/providers/providers.ts
index 5416d9366..222672a63 100644
--- a/apps/server/src/services/llm/providers/providers.ts
+++ b/apps/server/src/services/llm/providers/providers.ts
@@ -2,11 +2,12 @@ import options from "../../options.js";
import log from "../../log.js";
import type { OptionDefinitions } from "@triliumnext/commons";
import type { ChatCompletionOptions } from '../ai_interface.js';
-import type { OpenAIOptions, AnthropicOptions, OllamaOptions, ModelMetadata } from './provider_options.js';
+import type { OpenAIOptions, AnthropicOptions, OllamaOptions, MiniMaxOptions, ModelMetadata } from './provider_options.js';
import {
createOpenAIOptions,
createAnthropicOptions,
- createOllamaOptions
+ createOllamaOptions,
+ createMiniMaxOptions
} from './provider_options.js';
import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from '../constants/search_constants.js';
@@ -262,3 +263,69 @@ async function getOllamaModelContextWindow(modelName: string): Promise {
return MODEL_CAPABILITIES['default'].contextWindowTokens; // Default fallback
}
}
+
+/**
+ * Get MiniMax provider options from chat options and configuration
+ * MiniMax uses Anthropic-compatible API format
+ * Documentation: https://platform.minimax.io/docs/
+ */
+export function getMiniMaxOptions(
+ opts: ChatCompletionOptions = {}
+): MiniMaxOptions {
+ try {
+ const apiKey = options.getOption('minimaxApiKey');
+
+ if (!apiKey) {
+ // Log warning but don't throw - allow checking availability
+ log.info('MiniMax API key is not configured');
+ }
+
+ const baseUrl = options.getOption('minimaxBaseUrl')
+ || PROVIDER_CONSTANTS.MINIMAX.BASE_URL;
+
+ const modelName = opts.model || options.getOption('minimaxDefaultModel')
+ || PROVIDER_CONSTANTS.MINIMAX.DEFAULT_MODEL;
+
+ if (!modelName) {
+ throw new Error(
+ 'No MiniMax model configured. ' +
+ 'Please set a default model in your AI settings.'
+ );
+ }
+
+ // Create provider metadata
+ const providerMetadata: ModelMetadata = {
+ provider: 'minimax',
+ modelId: modelName,
+ displayName: modelName,
+ capabilities: {
+ supportsTools: true,
+ supportsStreaming: true,
+ supportsVision: false,
+ contextWindow: PROVIDER_CONSTANTS.MINIMAX.CONTEXT_WINDOW
+ }
+ };
+
+ // Get temperature from options or global setting
+ const temperature = opts.temperature !== undefined
+ ? opts.temperature
+ : parseFloat(options.getOption('aiTemperature') || String(SEARCH_CONSTANTS.TEMPERATURE.DEFAULT));
+
+ // Create options and pass through provider metadata
+ const optionsResult = createMiniMaxOptions(
+ opts,
+ apiKey || '',
+ baseUrl,
+ modelName,
+ PROVIDER_CONSTANTS.MINIMAX.API_VERSION
+ );
+
+ // Pass through provider metadata
+ optionsResult.providerMetadata = providerMetadata;
+
+ return optionsResult;
+ } catch (error) {
+ log.error(`Error creating MiniMax provider options: ${error}`);
+ throw error;
+ }
+}
diff --git a/apps/server/src/services/llm/response_normalizer.ts b/apps/server/src/services/llm/response_normalizer.ts
new file mode 100644
index 000000000..f74d4fcbb
--- /dev/null
+++ b/apps/server/src/services/llm/response_normalizer.ts
@@ -0,0 +1,52 @@
+import type { ChatResponse, NormalizedChatResponse } from './ai_interface.js';
+import type { ToolCall } from './tools/tool_interfaces.js';
+
+interface NormalizedToolCall {
+ id: string;
+ type?: string;
+ function: {
+ name: string;
+ arguments: Record | string;
+ };
+}
+
+function normalizeToolCall(toolCall: ToolCall, index: number): NormalizedToolCall | null {
+ if (!toolCall || !toolCall.function || typeof toolCall.function.name !== 'string') {
+ return null;
+ }
+
+ const name = toolCall.function.name.trim();
+ if (!name) {
+ return null;
+ }
+
+ const rawArgs = toolCall.function.arguments;
+ const normalizedArgs = rawArgs === undefined || rawArgs === null ? {} : rawArgs;
+
+ return {
+ id: toolCall.id || `call_${index}`,
+ type: toolCall.type,
+ function: {
+ name,
+ arguments: normalizedArgs
+ }
+ };
+}
+
+export function normalizeChatResponse(response: ChatResponse): NormalizedChatResponse {
+ const toolCalls = Array.isArray(response.tool_calls)
+ ? response.tool_calls
+ : [];
+
+ const normalizedToolCalls = toolCalls
+ .map((toolCall, index) => normalizeToolCall(toolCall, index))
+ .filter((toolCall): toolCall is NormalizedToolCall => toolCall !== null);
+
+ const normalizedText = typeof response.text === 'string' ? response.text : '';
+
+ return {
+ ...response,
+ text: normalizedText,
+ tool_calls: normalizedToolCalls
+ };
+}
diff --git a/apps/server/src/services/llm/tools/list_notes_tool.ts b/apps/server/src/services/llm/tools/list_notes_tool.ts
new file mode 100644
index 000000000..a187e2f6b
--- /dev/null
+++ b/apps/server/src/services/llm/tools/list_notes_tool.ts
@@ -0,0 +1,107 @@
+/**
+ * List Notes Tool
+ *
+ * This tool lists child notes under a parent note, useful for
+ * "what notes do I have" style questions.
+ */
+
+import type { Tool, ToolHandler } from './tool_interfaces.js';
+import log from '../../log.js';
+import becca from '../../../becca/becca.js';
+import BNote from '../../../becca/entities/bnote.js';
+
+const DEFAULT_MAX_RESULTS = 50;
+
+export const listNotesToolDefinition: Tool = {
+ type: 'function',
+ function: {
+ name: 'list_notes',
+ description: 'List child notes under a parent note. Use this to show what notes exist at the top level or within a specific branch.',
+ parameters: {
+ type: 'object',
+ properties: {
+ parentNoteId: {
+ type: 'string',
+ description: 'System ID of the parent note to list children from. Defaults to the root note.'
+ },
+ maxResults: {
+ type: 'number',
+ description: `Maximum number of child notes to return (default: ${DEFAULT_MAX_RESULTS})`
+ },
+ includeArchived: {
+ type: 'boolean',
+ description: 'Whether to include archived notes (default: false)'
+ },
+ includeHidden: {
+ type: 'boolean',
+ description: 'Whether to include hidden notes (default: false)'
+ }
+ },
+ required: []
+ }
+ }
+};
+
+export class ListNotesTool implements ToolHandler {
+ public definition: Tool = listNotesToolDefinition;
+
+ public async execute(args: {
+ parentNoteId?: string;
+ maxResults?: number;
+ includeArchived?: boolean;
+ includeHidden?: boolean;
+ }): Promise {
+ try {
+ const {
+ parentNoteId,
+ maxResults = DEFAULT_MAX_RESULTS,
+ includeArchived = false,
+ includeHidden = false
+ } = args;
+
+ const parent: BNote | null = parentNoteId
+ ? (becca.notes[parentNoteId] ?? null)
+ : becca.getNote('root');
+
+ if (!parent) {
+ return `Error: Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.`;
+ }
+
+ const children = parent.getChildNotes();
+ const filtered = children.filter((note) => {
+ if (!includeArchived && note.isArchived) {
+ return false;
+ }
+ if (!includeHidden && note.isHiddenCompletely()) {
+ return false;
+ }
+ return true;
+ });
+
+ const limited = filtered.slice(0, Math.max(0, maxResults));
+ const results = limited.map((note) => ({
+ noteId: note.noteId,
+ title: note.getTitleOrProtected(),
+ type: note.type,
+ mime: note.mime,
+ isArchived: note.isArchived,
+ isHidden: note.isHiddenCompletely(),
+ childCount: note.getChildNotes().length,
+ path: note.getBestNotePathString()
+ }));
+
+ log.info(`Listed ${results.length}/${filtered.length} notes under ${parent.noteId}`);
+
+ return {
+ parentNoteId: parent.noteId,
+ parentTitle: parent.getTitleOrProtected(),
+ count: results.length,
+ totalFound: filtered.length,
+ results
+ };
+ } catch (error: any) {
+ log.error(`Error executing list_notes tool: ${error.message || String(error)}`);
+ return `Error: ${error.message || String(error)}`;
+ }
+ }
+}
diff --git a/apps/server/src/services/llm/tools/move_note_tool.ts b/apps/server/src/services/llm/tools/move_note_tool.ts
new file mode 100644
index 000000000..acea65fd9
--- /dev/null
+++ b/apps/server/src/services/llm/tools/move_note_tool.ts
@@ -0,0 +1,154 @@
+/**
+ * Move Note Tool
+ *
+ * This tool moves a note under a different parent note.
+ * It operates on branches to preserve Trilium's multi-parent model.
+ */
+
+import type { Tool, ToolHandler } from './tool_interfaces.js';
+import log from '../../log.js';
+import becca from '../../../becca/becca.js';
+import branchService from '../../branches.js';
+import type BBranch from '../../../becca/entities/bbranch.js';
+
+export const moveNoteToolDefinition: Tool = {
+ type: 'function',
+ function: {
+ name: 'move_note',
+ description: 'Move a note under a different parent note. If the note has multiple parents, provide sourceParentNoteId or branchId to choose which branch to move.',
+ parameters: {
+ type: 'object',
+ properties: {
+ noteId: {
+ type: 'string',
+ description: 'System ID of the note to move (not the title).'
+ },
+ targetParentNoteId: {
+ type: 'string',
+ description: 'System ID of the destination parent note.'
+ },
+ targetParentBranchId: {
+ type: 'string',
+ description: 'Optional branch ID of the destination parent. Prefer this when the parent note has multiple parents.'
+ },
+ sourceParentNoteId: {
+ type: 'string',
+ description: 'Optional system ID of the current parent note. Required if the note has multiple parents.'
+ },
+ branchId: {
+ type: 'string',
+ description: 'Optional branch ID to move. Use this if you know the exact branch to move.'
+ }
+ },
+ required: ['noteId', 'targetParentNoteId']
+ }
+ }
+};
+
+export class MoveNoteTool implements ToolHandler {
+ public definition: Tool = moveNoteToolDefinition;
+
+ public async execute(args: {
+ noteId: string;
+ targetParentNoteId: string;
+ targetParentBranchId?: string;
+ sourceParentNoteId?: string;
+ branchId?: string;
+ }): Promise {
+ try {
+ const { noteId, targetParentNoteId, targetParentBranchId, sourceParentNoteId, branchId } = args;
+
+ const note = becca.notes[noteId];
+ if (!note) {
+ return `Error: Note with ID ${noteId} not found.`;
+ }
+
+ const targetParent = becca.notes[targetParentNoteId];
+ if (!targetParent) {
+ return `Error: Target parent note with ID ${targetParentNoteId} not found.`;
+ }
+
+ const branchToMove = this.resolveBranch(noteId, sourceParentNoteId, branchId);
+ if (!branchToMove) {
+ const parentBranchIds = note.getParentBranches().map(parentBranch => parentBranch.parentNoteId);
+ return `Error: Unable to resolve the note branch to move. Provide sourceParentNoteId or branchId when the note has multiple parents. Available parents: ${parentBranchIds.join(', ') || 'none'}.`;
+ }
+
+ const fromParentNoteId = branchToMove.parentNoteId;
+ log.info(`Executing move_note tool - NoteID: ${noteId}, from: ${fromParentNoteId}, to: ${targetParentNoteId}`);
+
+ let result;
+ if (targetParentBranchId) {
+ const targetParentBranch = becca.getBranch(targetParentBranchId);
+ if (!targetParentBranch) {
+ return `Error: Target parent branch with ID ${targetParentBranchId} not found.`;
+ }
+ if (targetParentBranch.noteId !== targetParentNoteId) {
+ return `Error: Target parent branch ${targetParentBranchId} does not belong to note ${targetParentNoteId}.`;
+ }
+ result = branchService.moveBranchToBranch(branchToMove, targetParentBranch, branchToMove.branchId || '');
+ } else {
+ result = branchService.moveBranchToNote(branchToMove, targetParentNoteId);
+ }
+ const rawResult = Array.isArray(result) ? result[1] : result;
+
+ // Type guard for success result
+ const isSuccessResult = (r: unknown): r is { success: boolean; branch?: BBranch; message?: string } =>
+ typeof r === 'object' && r !== null && 'success' in r;
+
+ if (!isSuccessResult(rawResult) || !rawResult.success) {
+ const message = isSuccessResult(rawResult) && 'message' in rawResult
+ ? String(rawResult.message)
+ : 'Move failed due to validation or unknown error.';
+ return `Error: ${message}`;
+ }
+
+ const newBranchId = rawResult.branch?.branchId;
+ let cleanupRemovedSourceBranch = false;
+ if (fromParentNoteId !== targetParentNoteId) {
+ const remainingBranch = becca.getBranchFromChildAndParent(noteId, fromParentNoteId);
+ if (remainingBranch && remainingBranch.branchId !== newBranchId) {
+ remainingBranch.markAsDeleted();
+ cleanupRemovedSourceBranch = true;
+ }
+ }
+
+ return {
+ success: true,
+ noteId: note.noteId,
+ title: note.title,
+ fromParentNoteId,
+ toParentNoteId: targetParentNoteId,
+ branchId: newBranchId,
+ cleanupRemovedSourceBranch,
+ message: `Moved "${note.title}" to new parent ${targetParentNoteId}`
+ };
+ } catch (error: any) {
+ log.error(`Error executing move_note tool: ${error.message || String(error)}`);
+ return `Error: ${error.message || String(error)}`;
+ }
+ }
+
+ private resolveBranch(noteId: string, sourceParentNoteId?: string, branchId?: string): BBranch | null {
+ if (branchId) {
+ const byId = becca.getBranch(branchId);
+ return byId && byId.noteId === noteId ? byId : null;
+ }
+
+ if (sourceParentNoteId) {
+ return becca.getBranchFromChildAndParent(noteId, sourceParentNoteId);
+ }
+
+ const note = becca.notes[noteId];
+ if (!note) {
+ return null;
+ }
+
+ const parentBranches = note.getParentBranches();
+ if (parentBranches.length === 1) {
+ return parentBranches[0];
+ }
+
+ return null;
+ }
+}
diff --git a/apps/server/src/services/llm/tools/note_content_utils.ts b/apps/server/src/services/llm/tools/note_content_utils.ts
new file mode 100644
index 000000000..59eaf7184
--- /dev/null
+++ b/apps/server/src/services/llm/tools/note_content_utils.ts
@@ -0,0 +1,37 @@
+import markdownService from '../../import/markdown.js';
+
+const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
+const MARKDOWN_PATTERNS: RegExp[] = [
+ /^#{1,6}\s+/m,
+ /^\s*[-*+]\s+/m,
+ /^\s*\d+\.\s+/m,
+ /```[\s\S]*```/m,
+ /\[[^\]]+\]\([^)]+\)/m,
+ /^\s*>\s+/m
+];
+
+export function normalizeTextNoteContent(
+ content: string,
+ title: string,
+ noteType: string,
+ noteMime: string
+): { content: string; converted: boolean } {
+ if (noteType !== 'text' || noteMime !== 'text/html') {
+ return { content, converted: false };
+ }
+
+ const trimmed = content.trim();
+ if (!trimmed || HTML_TAG_PATTERN.test(trimmed)) {
+ return { content, converted: false };
+ }
+
+ const looksLikeMarkdown = MARKDOWN_PATTERNS.some((pattern) => pattern.test(trimmed));
+ if (!looksLikeMarkdown) {
+ return { content, converted: false };
+ }
+
+ return {
+ content: markdownService.renderToHtml(content, title),
+ converted: true
+ };
+}
diff --git a/apps/server/src/services/llm/tools/note_creation_tool.ts b/apps/server/src/services/llm/tools/note_creation_tool.ts
index 07466aa32..99c2c9b21 100644
--- a/apps/server/src/services/llm/tools/note_creation_tool.ts
+++ b/apps/server/src/services/llm/tools/note_creation_tool.ts
@@ -10,6 +10,8 @@ import becca from '../../../becca/becca.js';
import notes from '../../notes.js';
import attributes from '../../attributes.js';
import BNote from '../../../becca/entities/bnote.js';
+import { normalizeTextNoteContent } from './note_content_utils.js';
+import { NOTE_WRITE_RULES } from './note_tool_prompt_rules.js';
/**
* Definition of the note creation tool
@@ -18,7 +20,9 @@ export const noteCreationToolDefinition: Tool = {
type: 'function',
function: {
name: 'create_note',
- description: 'Create a new note in Trilium with the specified content and attributes',
+ description: `Create a new note in Trilium with the specified content and attributes.
+
+${NOTE_WRITE_RULES}`,
parameters: {
type: 'object',
properties: {
@@ -32,7 +36,7 @@ export const noteCreationToolDefinition: Tool = {
},
content: {
type: 'string',
- description: 'Content of the new note'
+ description: 'Content of the new note. Must match the note type rules in the tool description.'
},
type: {
type: 'string',
@@ -128,12 +132,18 @@ export class NoteCreationTool implements ToolHandler {
}
}
+ const normalized = normalizeTextNoteContent(content, title, type, noteMime);
+ const noteContent = normalized.content;
+ if (normalized.converted) {
+ log.info(`Converted markdown content to HTML for note "${title}"`);
+ }
+
// Create the note
const createStartTime = Date.now();
const result = notes.createNewNote({
parentNoteId: parent.noteId,
title: title,
- content: content,
+ content: noteContent,
type: type as any, // Cast as any since not all string values may match the exact NoteType union
mime: noteMime
});
diff --git a/apps/server/src/services/llm/tools/note_tool_prompt_rules.ts b/apps/server/src/services/llm/tools/note_tool_prompt_rules.ts
new file mode 100644
index 000000000..0a890e102
--- /dev/null
+++ b/apps/server/src/services/llm/tools/note_tool_prompt_rules.ts
@@ -0,0 +1,17 @@
+export const NOTE_WRITE_RULES = `MUST follow Trilium note rules:
+- Text note (type "text", mime "text/html"): content MUST be HTML (no Markdown). Allowed tags: p, br, h1-h5, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, strong, em, code, pre, kbd, sup, sub, hr, img, a.
+- Internal links (create backlinks): use Title. Use noteId/notePath; do not use external URLs for internal notes.
+- Code note (type "code"): content is plain text. Set mime to match language (text/plain, application/json, text/javascript, text/css, etc.).
+- Mermaid note (type "mermaid", mime "text/mermaid"): content is Mermaid syntax only.
+- Canvas/Mind Map/Relation Map (types "canvas"/"mindMap"/"relationMap"): content is JSON; only create/update if the user provides or explicitly requests the JSON format.
+- Render note (type "render"): content is empty; use a relation attribute named renderNote pointing to an HTML/JSX code note to render.
+- Saved Search (type "search"): content is a search query, not HTML.
+- Web View (type "webView"): set label #webViewSrc with the URL; do not embed HTML.
+- Reserved types (file/image/doc/aiChat/contentWidget/launcher) must not be created via tools; use import/attachment workflows instead.
+- There is no dedicated "folder" type; any note can have children.`;
+
+export const NOTE_READ_RULES = `When reading notes:
+- Text note content is HTML (not Markdown). Preserve HTML when quoting or summarizing.
+- Internal links use ; backlinks are automatic.
+- Non-text types may return JSON or binary content (canvas/mindMap/relationMap/mermaid/file/image); do not interpret them as HTML.
+- If you need render/web view/search behavior, request attributes (renderNote, webViewSrc, searchHome) via includeAttributes.`;
diff --git a/apps/server/src/services/llm/tools/note_update_tool.ts b/apps/server/src/services/llm/tools/note_update_tool.ts
index 0dc5fd723..7548217fc 100644
--- a/apps/server/src/services/llm/tools/note_update_tool.ts
+++ b/apps/server/src/services/llm/tools/note_update_tool.ts
@@ -8,6 +8,8 @@ import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
import notes from '../../notes.js';
+import { normalizeTextNoteContent } from './note_content_utils.js';
+import { NOTE_WRITE_RULES } from './note_tool_prompt_rules.js';
/**
* Definition of the note update tool
@@ -16,7 +18,9 @@ export const noteUpdateToolDefinition: Tool = {
type: 'function',
function: {
name: 'update_note',
- description: 'Update the content or title of an existing note',
+ description: `Update the content or title of an existing note.
+
+${NOTE_WRITE_RULES}`,
parameters: {
type: 'object',
properties: {
@@ -30,7 +34,7 @@ export const noteUpdateToolDefinition: Tool = {
},
content: {
type: 'string',
- description: 'New content for the note (if you want to change it)'
+ description: 'New content for the note (if you want to change it). Must match the note type rules in the tool description.'
},
mode: {
type: 'string',
@@ -98,17 +102,26 @@ export class NoteUpdateTool implements ToolHandler {
const contentStartTime = Date.now();
try {
- let newContent = content;
+ const targetTitle = title || note.title;
+ const normalized = normalizeTextNoteContent(content, targetTitle, note.type, note.mime);
+ let newContent = normalized.content;
+
+ if (normalized.converted) {
+ log.info(`Converted markdown content to HTML for note "${targetTitle}"`);
+ }
// For append or prepend modes, get the current content first
if (mode === 'append' || mode === 'prepend') {
const currentContent = await note.getContent();
+ const currentContentText = typeof currentContent === 'string'
+ ? currentContent
+ : currentContent.toString();
if (mode === 'append') {
- newContent = currentContent + '\n\n' + content;
+ newContent = currentContentText + '\n\n' + newContent;
log.info(`Appending content to existing note content`);
} else if (mode === 'prepend') {
- newContent = content + '\n\n' + currentContent;
+ newContent = newContent + '\n\n' + currentContentText;
log.info(`Prepending content to existing note content`);
}
}
diff --git a/apps/server/src/services/llm/tools/read_note_tool.ts b/apps/server/src/services/llm/tools/read_note_tool.ts
index ddcad559f..dd8cd6838 100644
--- a/apps/server/src/services/llm/tools/read_note_tool.ts
+++ b/apps/server/src/services/llm/tools/read_note_tool.ts
@@ -7,6 +7,7 @@
import type { Tool, ToolHandler } from './tool_interfaces.js';
import log from '../../log.js';
import becca from '../../../becca/becca.js';
+import { NOTE_READ_RULES } from './note_tool_prompt_rules.js';
// Define type for note response
interface NoteResponse {
@@ -34,7 +35,9 @@ export const readNoteToolDefinition: Tool = {
type: 'function',
function: {
name: 'read_note',
- description: 'Read the content of a specific note by its ID',
+ description: `Read the content of a specific note by its ID.
+
+${NOTE_READ_RULES}`,
parameters: {
type: 'object',
properties: {
@@ -44,7 +47,7 @@ export const readNoteToolDefinition: Tool = {
},
includeAttributes: {
type: 'boolean',
- description: 'Whether to include note attributes in the response (default: false)'
+ description: 'Whether to include note attributes in the response (default: false). Use this for render/web/search labels and relations.'
}
},
required: ['noteId']
diff --git a/apps/server/src/services/llm/tools/search_notes_tool.ts b/apps/server/src/services/llm/tools/search_notes_tool.ts
index 152187dec..814ae809d 100644
--- a/apps/server/src/services/llm/tools/search_notes_tool.ts
+++ b/apps/server/src/services/llm/tools/search_notes_tool.ts
@@ -9,6 +9,7 @@ import log from '../../log.js';
import aiServiceManager from '../ai_service_manager.js';
import becca from '../../../becca/becca.js';
import { ContextExtractor } from '../context/index.js';
+import searchService from '../../search/services/search.js';
/**
* Definition of the search notes tool
@@ -189,6 +190,58 @@ export class SearchNotesTool implements ToolHandler {
}
}
+ private async executeKeywordFallback(args: {
+ query: string;
+ parentNoteId?: string;
+ maxResults: number;
+ summarize: boolean;
+ }): Promise {
+ const { query, parentNoteId, maxResults, summarize } = args;
+ log.info(`Vector search unavailable, using keyword fallback for query: "${query}"`);
+
+ const searchContext = {
+ includeArchivedNotes: false,
+ includeHiddenNotes: false,
+ ancestorNoteId: parentNoteId,
+ fuzzyAttributeSearch: false
+ };
+
+ const searchResults = searchService.searchNotes(query, searchContext);
+ const limitedResults = searchResults.slice(0, maxResults);
+
+ const enhancedResults = await Promise.all(
+ limitedResults.map(async note => {
+ const preview = await this.getRichContentPreview(note.noteId, summarize);
+ return {
+ noteId: note.noteId,
+ title: note.title,
+ preview,
+ dateCreated: note.dateCreated,
+ dateModified: note.dateModified,
+ type: note.type,
+ mime: note.mime,
+ matchType: 'keyword'
+ };
+ })
+ );
+
+ if (enhancedResults.length === 0) {
+ return {
+ count: 0,
+ results: [],
+ query,
+ message: 'No notes found matching your query. Try broader terms or different keywords. Note: Use the noteId (not the title) when performing operations on specific notes with other tools.'
+ };
+ }
+
+ return {
+ count: enhancedResults.length,
+ totalFound: searchResults.length,
+ results: enhancedResults,
+ message: 'Vector search unavailable. Results are from keyword search; use noteId for follow-up operations.'
+ };
+ }
+
/**
* Execute the search notes tool
*/
@@ -212,7 +265,7 @@ export class SearchNotesTool implements ToolHandler {
const vectorSearchTool = await getOrCreateVectorSearchTool();
if (!vectorSearchTool) {
- return `Error: Vector search tool is not available. The system may still be initializing or there could be a configuration issue.`;
+ return await this.executeKeywordFallback({ query, parentNoteId, maxResults, summarize });
}
log.info(`Retrieved vector search tool from AI service manager`);
@@ -220,13 +273,19 @@ export class SearchNotesTool implements ToolHandler {
// Check if searchNotes method exists
if (!vectorSearchTool.searchNotes || typeof vectorSearchTool.searchNotes !== 'function') {
log.error(`Vector search tool is missing searchNotes method`);
- return `Error: Vector search tool is improperly configured (missing searchNotes method).`;
+ return await this.executeKeywordFallback({ query, parentNoteId, maxResults, summarize });
}
// Execute the search
log.info(`Performing semantic search for: "${query}"`);
const searchStartTime = Date.now();
- const response = await vectorSearchTool.searchNotes(query, parentNoteId, maxResults);
+ let response;
+ try {
+ response = await vectorSearchTool.searchNotes(query, parentNoteId, maxResults);
+ } catch (error: any) {
+ log.error(`Semantic search failed, falling back to keyword search: ${error.message || String(error)}`);
+ return await this.executeKeywordFallback({ query, parentNoteId, maxResults, summarize });
+ }
const results: Array> = response?.matches ?? [];
const searchDuration = Date.now() - searchStartTime;
diff --git a/apps/server/src/services/llm/tools/tool_argument_parser.ts b/apps/server/src/services/llm/tools/tool_argument_parser.ts
new file mode 100644
index 000000000..0ea14f203
--- /dev/null
+++ b/apps/server/src/services/llm/tools/tool_argument_parser.ts
@@ -0,0 +1,49 @@
+import type { ParsedToolArguments } from './tool_interfaces.js';
+
+function sanitizeJsonArgument(value: string): string {
+ return value
+ .replace(/^['"]/g, '')
+ .replace(/['"]$/g, '')
+ .replace(/\\"/g, '"')
+ .replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":')
+ .replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":');
+}
+
+export function parseToolArguments(input: string | Record): ParsedToolArguments {
+ const warnings: string[] = [];
+
+ if (typeof input === 'object' && input !== null) {
+ return { args: input, warnings };
+ }
+
+ if (typeof input !== 'string') {
+ warnings.push('Tool arguments were not a string or object; defaulting to empty object.');
+ return { args: {}, warnings };
+ }
+
+ if (input.trim() === '') {
+ warnings.push('Tool arguments were an empty string; defaulting to empty object.');
+ return { args: {}, warnings };
+ }
+
+ try {
+ const parsed = JSON.parse(input) as Record;
+ return { args: parsed, warnings };
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ warnings.push(`Failed to parse arguments as JSON: ${message}`);
+ }
+
+ try {
+ const cleaned = sanitizeJsonArgument(input);
+ const parsed = JSON.parse(cleaned) as Record;
+ warnings.push('Parsed arguments after sanitizing malformed JSON.');
+ return { args: parsed, warnings };
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ warnings.push(`Failed to parse sanitized arguments: ${message}`);
+ }
+
+ warnings.push('Falling back to text argument payload.');
+ return { args: { text: input }, warnings };
+}
diff --git a/apps/server/src/services/llm/tools/tool_initializer.ts b/apps/server/src/services/llm/tools/tool_initializer.ts
index e8ceca3ee..03a0f5d99 100644
--- a/apps/server/src/services/llm/tools/tool_initializer.ts
+++ b/apps/server/src/services/llm/tools/tool_initializer.ts
@@ -10,8 +10,10 @@ import { KeywordSearchTool } from './keyword_search_tool.js';
import { AttributeSearchTool } from './attribute_search_tool.js';
import { SearchSuggestionTool } from './search_suggestion_tool.js';
import { ReadNoteTool } from './read_note_tool.js';
+import { ListNotesTool } from './list_notes_tool.js';
import { NoteCreationTool } from './note_creation_tool.js';
import { NoteUpdateTool } from './note_update_tool.js';
+import { MoveNoteTool } from './move_note_tool.js';
import { ContentExtractionTool } from './content_extraction_tool.js';
import { RelationshipTool } from './relationship_tool.js';
import { AttributeManagerTool } from './attribute_manager_tool.js';
@@ -38,10 +40,12 @@ export async function initializeTools(): Promise {
toolRegistry.registerTool(new AttributeSearchTool()); // Attribute-specific search
toolRegistry.registerTool(new SearchSuggestionTool()); // Search syntax helper
toolRegistry.registerTool(new ReadNoteTool()); // Read note content
+ toolRegistry.registerTool(new ListNotesTool()); // List child notes
// Register note creation and manipulation tools
toolRegistry.registerTool(new NoteCreationTool()); // Create new notes
toolRegistry.registerTool(new NoteUpdateTool()); // Update existing notes
+ toolRegistry.registerTool(new MoveNoteTool()); // Move notes in the tree
toolRegistry.registerTool(new NoteSummarizationTool()); // Summarize note content
// Register attribute and relationship tools
diff --git a/apps/server/src/services/llm/tools/tool_interfaces.ts b/apps/server/src/services/llm/tools/tool_interfaces.ts
index ec90df67f..55e55c8ac 100644
--- a/apps/server/src/services/llm/tools/tool_interfaces.ts
+++ b/apps/server/src/services/llm/tools/tool_interfaces.ts
@@ -53,6 +53,27 @@ export interface ToolCall {
};
}
+/**
+ * Parsed tool arguments and any warnings captured during parsing.
+ */
+export interface ParsedToolArguments {
+ args: Record;
+ warnings: string[];
+}
+
+/**
+ * Argument parser function for tool calls.
+ */
+export type ToolArgumentParser = (input: string | Record) => ParsedToolArguments;
+
+/**
+ * Metadata for tool execution helpers.
+ */
+export interface ToolMetadata {
+ name: string;
+ parseArguments: ToolArgumentParser;
+}
+
/**
* Interface for a tool handler that executes a tool
*/
@@ -66,4 +87,9 @@ export interface ToolHandler {
* Execute the tool with the given arguments
*/
execute(args: Record): Promise;
+
+ /**
+ * Optional argument parser override for this tool.
+ */
+ parseArguments?: ToolArgumentParser;
}
diff --git a/apps/server/src/services/llm/tools/tool_registry.ts b/apps/server/src/services/llm/tools/tool_registry.ts
index 6d6dd417f..fbd647a34 100644
--- a/apps/server/src/services/llm/tools/tool_registry.ts
+++ b/apps/server/src/services/llm/tools/tool_registry.ts
@@ -4,7 +4,8 @@
* This file defines the registry for tools that can be called by LLMs.
*/
-import type { Tool, ToolHandler } from './tool_interfaces.js';
+import type { Tool, ToolHandler, ToolMetadata } from './tool_interfaces.js';
+import { parseToolArguments } from './tool_argument_parser.js';
import log from '../../log.js';
/**
@@ -13,6 +14,7 @@ import log from '../../log.js';
export class ToolRegistry {
private static instance: ToolRegistry;
private tools: Map = new Map();
+ private metadata: Map = new Map();
private initializationAttempted = false;
private constructor() {}
@@ -100,12 +102,14 @@ export class ToolRegistry {
}
const name = handler.definition.function.name;
+ const parseArguments = handler.parseArguments || parseToolArguments;
if (this.tools.has(name)) {
log.info(`Tool '${name}' already registered, replacing...`);
}
this.tools.set(name, handler);
+ this.metadata.set(name, { name, parseArguments });
}
/**
@@ -146,6 +150,13 @@ export class ToolRegistry {
return Array.from(this.tools.values()).filter(tool => this.validateToolHandler(tool));
}
+ /**
+ * Get tool metadata by name
+ */
+ public getToolMetadata(name: string): ToolMetadata | undefined {
+ return this.metadata.get(name);
+ }
+
/**
* Get all tool definitions for sending to LLM
*/
diff --git a/apps/server/src/services/options.ts b/apps/server/src/services/options.ts
index 1cc67df5a..7117f5fdb 100644
--- a/apps/server/src/services/options.ts
+++ b/apps/server/src/services/options.ts
@@ -87,7 +87,8 @@ function setOption(name: T, value: string | OptionDefinit
const aiOptions = [
'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel',
'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel',
- 'ollamaBaseUrl', 'ollamaDefaultModel'
+ 'ollamaBaseUrl', 'ollamaDefaultModel',
+ 'minimaxApiKey', 'minimaxBaseUrl', 'minimaxDefaultModel'
];
if (aiOptions.includes(name)) {
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index b23e532a8..a0bccde6b 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -214,6 +214,9 @@ const defaultOptions: DefaultOption[] = [
{ name: "ollamaEnabled", value: "false", isSynced: true },
{ name: "ollamaDefaultModel", value: "", isSynced: true },
{ name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true },
+ { name: "minimaxApiKey", value: "", isSynced: false },
+ { name: "minimaxDefaultModel", value: "", isSynced: true },
+ { name: "minimaxBaseUrl", value: "https://api.minimax.io/anthropic", isSynced: true },
{ name: "aiTemperature", value: "0.7", isSynced: true },
{ name: "aiSystemPrompt", value: "", isSynced: true },
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index fc1564e33..fab197f39 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -154,6 +154,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions