feat: add MiniMax provider and LLM tooling

This commit is contained in:
zhangyangrui 2026-01-23 09:47:45 +08:00
parent 17f3ffd00c
commit a3a7034c9e
60 changed files with 3661 additions and 1548 deletions

234
README.md
View File

@ -1,10 +1,10 @@
<div align="center">
<sup>Special thanks to:</sup><br />
<sup>特别感谢:</sup><br />
<a href="https://go.warp.dev/Trilium" target="_blank">
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png"><br />
Warp, built for coding with multiple AI agents<br />
Warp,为多 AI 智能体编程而生<br />
</a>
<sup>Available for macOS, Linux and Windows</sup>
<sup>适用于 macOS、Linux 和 Windows</sup>
</div>
<hr />
@ -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)
<!-- translate:on -->
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 助手能力。
<img src="./docs/app.png" alt="Trilium Screenshot" width="1000">
## ⏬ 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 助手,支持 MiniMaxAnthropic 兼容接口)。
- 支持工具调用:搜索、创建、更新、移动、总结笔记。
- 可在设置中配置模型与密钥,按需启用 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.
如果你的发行版在下表中,请使用发行版提供的包。
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](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:
当前语言覆盖:
[![Translation status](https://hosted.weblate.org/widget/trilium/multi-auto.svg)](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/nriver) 语法高亮小组件初始实现

View File

@ -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...",

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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" ?
<SingleProviderSettings
title={t("ai_llm.minimax_settings")}
apiKeyDescription={t("ai_llm.minimax_api_key_description")}
baseUrlDescription={t("ai_llm.minimax_url_description")}
modelDescription={t("ai_llm.minimax_model_description")}
validationErrorMessage={t("ai_llm.empty_key_warning.minimax")}
apiKeyOption="minimaxApiKey" baseUrlOption="minimaxBaseUrl" modelOption="minimaxDefaultModel"
provider={aiSelectedProvider}
/>
:
<></>
}
@ -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<OpenAiOrAnthropicModelResponse>(`llm/providers/${provider}/models?baseUrl=${encodeURIComponent(baseUrl)}`);
if (response.success) {

View File

@ -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: <a class="reference-link" href="#root/<notePath>">Title</a>. 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

View File

@ -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}`);

View File

@ -0,0 +1,72 @@
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 { baseUrl } = req.query;
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
};

View File

@ -117,6 +117,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"anthropicDefaultModel",
"ollamaBaseUrl",
"ollamaDefaultModel",
"minimaxApiKey",
"minimaxBaseUrl",
"minimaxDefaultModel",
"mfaEnabled",
"mfaMethod"
]);

View File

@ -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);
}

View File

@ -69,6 +69,11 @@ export interface StreamChunk {
*/
raw?: Record<string, unknown>;
/**
* 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<ChatResponse>;
/**
* Normalize provider response for pipeline processing.
*/
toNormalizedResponse(response: ChatResponse): NormalizedChatResponse;
/**
* Check if the service can be used (API key is set, etc.)
*/

View File

@ -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

View File

@ -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<ChatResponse>;
toNormalizedResponse(response: ChatResponse): NormalizedChatResponse {
return normalizeChatResponse(response);
}
isAvailable(): boolean {
return options.getOptionBool('aiEnabled'); // Base check if AI is enabled globally
}

View File

@ -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');

View File

@ -126,7 +126,7 @@ export class ChatStorageService {
}
/**
* Get all chats
* Get all chats (excludes soft-deleted chats)
*/
async getAllChats(): Promise<StoredChat[]> {
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<StoredChat | null> {
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]
);

View File

@ -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<boolean> {
}
/**
* Check if a provider has required configuration
*/
* Check if a provider has required configuration
*/
export async function isProviderConfigured(provider: ProviderType): Promise<boolean> {
const settings = await getProviderSettings(provider);
@ -133,6 +139,8 @@ export async function isProviderConfigured(provider: ProviderType): Promise<bool
return Boolean((settings as any)?.apiKey);
case 'ollama':
return Boolean((settings as any)?.baseUrl);
case 'minimax':
return Boolean((settings as any)?.apiKey);
default:
return false;
}
@ -193,6 +201,10 @@ export async function validateConfiguration() {
result.warnings.push('Ollama base URL is not configured');
}
if (selectedProvider === 'minimax' && !(settings as any)?.apiKey) {
result.warnings.push('MiniMax API key is not configured');
}
return result;
}

View File

@ -10,7 +10,8 @@ import type {
ProviderSettings,
OpenAISettings,
AnthropicSettings,
OllamaSettings
OllamaSettings,
MiniMaxSettings
} from '../interfaces/configuration_interfaces.js';
/**
@ -88,7 +89,7 @@ export class ConfigurationManager {
// 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
@ -124,18 +125,20 @@ export class ConfigurationManager {
}
/**
* Get default models for each provider - ONLY from user configuration
*/
* Get default models for each provider - ONLY from user configuration
*/
public async getDefaultModels(): Promise<Record<ProviderType, string | undefined>> {
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<ProviderSettings> {
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: {}
};

View File

@ -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) =>
`<instructions>
${DEFAULT_SYSTEM_PROMPT}
Use the following information from the user's notes to answer their questions:
<user_notes>
${context}
</user_notes>
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>`,
INSTRUCTIONS_WRAPPER: (instructions: string) =>
`<instructions>\n${instructions}\n</instructions>`,
// Tool instructions for MiniMax (Anthropic-compatible)
TOOL_INSTRUCTIONS: `<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
</instructions>`,
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

View File

@ -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'
};

View File

@ -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

View File

@ -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';
/**

View File

@ -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<StreamChunk> {
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);
}
}
}
}
}
}

View File

@ -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<StreamChunk> = 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;
}
};

View File

@ -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<ToolExecutionLoopResult> = 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
};
};

View File

@ -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<string, unknown> = {};
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);
}
}
}
}
};

View File

@ -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<ToolLoopResult> {
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<StreamChunk> {
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;
}
}
}

View File

@ -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;

View File

@ -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<LLMCompletionInput, { response: ChatResponse }> {
export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, { response: NormalizedChatResponse }> {
constructor() {
super('LLMCompletion');
}
@ -19,7 +20,7 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
* This enhanced version supports better streaming by forwarding raw provider data
* and ensuring consistent handling of stream options.
*/
protected async process(input: LLMCompletionInput): Promise<{ response: ChatResponse }> {
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<LLMCompletionInput, {
if (updatedOptions.providerMetadata?.provider) {
selectedProvider = updatedOptions.providerMetadata.provider;
log.info(`Using provider ${selectedProvider} from metadata for model ${updatedOptions.model}`);
} else {
try {
const configuredProvider = aiServiceManager.getSelectedProvider();
if (configuredProvider) {
selectedProvider = configuredProvider;
log.info(`Using configured provider ${selectedProvider} for model ${updatedOptions.model}`);
}
} catch (error: unknown) {
log.error(`Failed to resolve configured provider: ${error}`);
}
}
log.info(`Generating LLM completion, provider: ${selectedProvider || 'auto'}, model: ${updatedOptions?.model || 'default'}`);
@ -109,13 +120,14 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
// Generate completion and wrap with enhanced stream handling
const response = await service.generateChatCompletion(messages, updatedOptions);
const normalizedResponse = service.toNormalizedResponse(response);
// If streaming is enabled, enhance the stream method
if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) {
const originalStream = response.stream;
if (normalizedResponse.stream && typeof normalizedResponse.stream === 'function' && updatedOptions.stream) {
const originalStream = normalizedResponse.stream;
// Replace the stream method with an enhanced version that captures and forwards raw data
response.stream = async (callback) => {
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<LLMCompletionInput, {
// If the provider didn't include raw data, add minimal info
raw: chunk.raw || {
provider: selectedProvider,
model: response.model
model: normalizedResponse.model
}
};
return callback(enhancedChunk);
@ -134,9 +146,9 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
// Add enhanced logging for debugging tool execution follow-ups
if (toolMessages.length > 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<LLMCompletionInput, {
log.info(`Response contains no tool calls - plain text response`);
}
if (toolMessages.length > 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<LLMCompletionInput, {
// Add enhanced logging for debugging tool execution follow-ups
if (toolMessages.length > 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<LLMCompletionInput, {
log.info(`Response contains no tool calls - plain text response`);
}
if (toolMessages.length > 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 };
}
}

View File

@ -183,7 +183,7 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
// Set the provider metadata in the options
if (selectedProvider) {
// Ensure the provider is one of the valid types
const validProvider = selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local';
const validProvider = selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local' | 'minimax';
options.providerMetadata = {
provider: validProvider,

View File

@ -1,6 +1,5 @@
import { BasePipelineStage } from '../pipeline_stage.js';
import type { ResponseProcessingInput } from '../interfaces.js';
import type { ChatResponse } from '../../ai_interface.js';
import log from '../../../log.js';
/**

View File

@ -0,0 +1,440 @@
import type { ChatCompletionOptions, Message, NormalizedChatResponse } from '../../ai_interface.js';
import type { StreamCallback } from '../interfaces.js';
import log from '../../../log.js';
import toolRegistry from '../../tools/tool_registry.js';
import { parseToolArguments } from '../../tools/tool_argument_parser.js';
import chatStorageService from '../../chat_storage_service.js';
import {
generateToolGuidance,
isEmptyToolResult,
validateToolBeforeExecution,
type ToolInterface
} from './tool_calling_helpers.js';
interface ToolValidationResult {
toolCall: {
id?: string;
function: {
name: string;
arguments: string | Record<string, unknown>;
};
};
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<ToolCallingExecutionOutput> = 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<string, unknown>) => 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<string, unknown>
},
result: typeof result === 'string' ? result : result as Record<string, unknown>,
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<string, unknown>
},
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<string, unknown>
},
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
};
};

View File

@ -0,0 +1,91 @@
import log from '../../../log.js';
import toolRegistry from '../../tools/tool_registry.js';
export interface ToolInterface {
execute: (args: Record<string, unknown>) => Promise<unknown>;
[key: string]: unknown;
}
export const validateToolBeforeExecution: (tool: ToolInterface, toolName: string) => Promise<boolean> = 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;
};

View File

@ -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<string, unknown>) => Promise<unknown>;
[key: string]: unknown;
}
interface ToolValidationResult {
toolCall: {
id?: string;
function: {
name: string;
arguments: string | Record<string, unknown>;
};
};
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<ToolExecutionInput, { response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { response: NormalizedChatResponse, needsFollowUp: boolean, messages: Message[] }> {
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<string, unknown>) => 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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>
},
result: typeof result === 'string' ? result : result as Record<string, unknown>,
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<string, unknown>
},
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<string, unknown>
},
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<boolean> {
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<string, unknown>;
if (toolName === 'search_notes' &&
'results' in resultObj &&
Array.isArray(resultObj.results) &&
resultObj.results.length === 0) {
return true;
}
}
}
return false;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,55 @@
import type { Message } from '../../ai_interface.js';
export function formatMiniMaxMessages(messages: Message[]): Array<Record<string, unknown>> {
const formatted: Array<Record<string, unknown>> = [];
for (const msg of messages) {
if (msg.role === 'system') {
continue;
}
const formattedMsg: Record<string, unknown> = {
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<string, unknown> = {};
const rawArgs = toolCall.function.arguments;
if (typeof rawArgs === 'string') {
try {
input = JSON.parse(rawArgs) as Record<string, unknown>;
} 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;
}

View File

@ -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;
}
}

View File

@ -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)
}
};
}

View File

@ -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<string, unknown>;
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<string, any>();
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<string, unknown>
});
}
});
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<string, unknown>
});
}
}
});
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;
}

View File

@ -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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>>): 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<string, unknown>).input_schema || (tool as Record<string, unknown>).parameters
};
}
log.info(`[TOOL DEBUG] Unhandled tool format: ${JSON.stringify(tool)}`);
return null;
}).filter((tool): tool is MiniMaxTool => tool !== null);
}

View File

@ -0,0 +1,130 @@
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';
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<AnthropicConstructor>
));
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 () => {
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 () => {
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);
});
});

View File

@ -0,0 +1,197 @@
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<ChatResponse> {
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;
if (willUseTools && PROVIDER_PROMPTS.MINIMAX.TOOL_INSTRUCTIONS) {
log.info('Adding tool instructions to system prompt for MiniMax');
var finalSystemPrompt = `${systemPrompt}\n\n${PROVIDER_PROMPTS.MINIMAX.TOOL_INSTRUCTIONS}`;
} else {
var 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()
};
}
}

View File

@ -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> | 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,
};
}

View File

@ -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<number> {
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;
}
}

View File

@ -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, unknown> | 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
};
}

View File

@ -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<string | object> {
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)}`;
}
}
}

View File

@ -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<string | object> {
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;
}
}

View File

@ -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
};
}

View File

@ -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
});

View File

@ -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 <a class="reference-link" href="#root/<notePath>">Title</a>. 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 <a class="reference-link" href="#root/<notePath>">; 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.`;

View File

@ -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`);
}
}

View File

@ -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']

View File

@ -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<string | object> {
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<Record<string, unknown>> = response?.matches ?? [];
const searchDuration = Date.now() - searchStartTime;

View File

@ -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<string, unknown>): 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<string, unknown>;
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<string, unknown>;
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 };
}

View File

@ -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<void> {
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

View File

@ -53,6 +53,27 @@ export interface ToolCall {
};
}
/**
* Parsed tool arguments and any warnings captured during parsing.
*/
export interface ParsedToolArguments {
args: Record<string, unknown>;
warnings: string[];
}
/**
* Argument parser function for tool calls.
*/
export type ToolArgumentParser = (input: string | Record<string, unknown>) => 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<string, unknown>): Promise<string | object>;
/**
* Optional argument parser override for this tool.
*/
parseArguments?: ToolArgumentParser;
}

View File

@ -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<string, ToolHandler> = new Map();
private metadata: Map<string, ToolMetadata> = 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
*/

View File

@ -87,7 +87,8 @@ function setOption<T extends OptionNames>(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)) {

View File

@ -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 },

View File

@ -154,6 +154,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
ollamaEnabled: boolean;
ollamaBaseUrl: string;
ollamaDefaultModel: string;
minimaxApiKey: string;
minimaxDefaultModel: string;
minimaxBaseUrl: string;
codeOpenAiModel: string;
aiSelectedProvider: string;
seenCallToActions: string;