Compare commits

..

37 Commits

Author SHA1 Message Date
fdc310 1bdf371b5d feat: Add pipeline_uuid handling across multiple adapters and update related tests 2026-07-12 19:40:59 +08:00
fdc310 cdfde83e39 feat: Add missing completed input lines handling in DingTalk adapter 2026-07-12 18:53:13 +08:00
fdc310 4a753d98f5 Merge remote-tracking branch 'origin/master' into feat/card_dify_human_input 2026-07-12 18:39:48 +08:00
fdc310 354aadfce0 feat: Enhance select input handling and validation in Dify API runner and Telegram adapter 2026-07-12 00:31:12 +08:00
fdc310 4b6931d294 feat: Enhance error handling and caching for Dify and WeCom interactions 2026-07-11 22:40:08 +08:00
fdc310 5eb2b90ae6 feat: Refactor pending form handling to isolate by bot and pipeline 2026-07-11 20:46:42 +08:00
fdc310 d8c14fc969 feat: Update the human-computer interaction template cards, and optimize the prompt information and content display. 2026-07-11 20:05:05 +08:00
fdc310 6cae954a67 feat: Refactor input handling and feedback messages across multiple adapters 2026-07-11 19:23:42 +08:00
fdc310 183fb87649 Add tests for DingTalk adapter content processing and markdown formatting
- Updated the assertion in `test_dingtalk_completed_input_lines_include_text_and_select_values` to remove unnecessary markdown formatting.
- Added new tests to verify that `_dingtalk_clean_form_content` maintains the order of prompts and completed values in various scenarios.
- Introduced `test_dingtalk_card_markdown_preserves_internal_line_breaks` to ensure internal line breaks are correctly converted to HTML line breaks.
2026-07-11 17:28:31 +08:00
fdc310 56b3c15213 feat(lark): add functions for current input definitions and visible form content handling
feat(qqofficial): update fallback text handling for non-streaming scenarios
feat(difysvapi): enhance form content processing for interactive fields and actions
test: add unit tests for Lark and QQ Official adapter functionalities
2026-07-11 15:31:29 +08:00
fdc310 0cde4c71cd feat: Enhance Telegram and QQ Official adapters with select field handling and form action processing
- Added support for select fields in Telegram adapter, including option extraction and callback handling.
- Implemented form action processing for Telegram callbacks, improving user interaction feedback.
- Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter.
- Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes.
- Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions.
2026-07-11 11:16:59 +08:00
fdc310 98a633c712 Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners
- Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines.
- Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines.
- Add tests for WeComBot template card functionalities, including event extraction and payload building for human input.
- Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction.
2026-07-10 18:25:24 +08:00
fdc310 776d48380e feat(telegram): enhance message handling with group stream deletion and form placeholder detection 2026-06-24 14:14:14 +08:00
fdc310 5e6b3cc550 feat(dingtalk): add download link for human input card template and enhance dynamic form configuration 2026-06-24 11:23:02 +08:00
fdc310 ea4cd80939 feat(qqofficial): add method to identify invisible form placeholder chunks in messages 2026-06-23 16:34:38 +08:00
fdc310 ff7fd2f5e0 feat(qqofficial): synchronize passive-reply counter with inbound message sequence 2026-06-23 16:04:29 +08:00
fdc310 3b3aa93cf8 feat(wecom): add functions for template card action extraction and update, enhance button interaction handling 2026-06-23 15:25:39 +08:00
fdc310 f658f0ede5 feat(wecom): add optional source block to interactive template cards for enhanced branding 2026-06-23 00:02:37 +08:00
fdc310 47d75082e8 fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction 2026-06-22 20:16:10 +08:00
fdc310 8849fb172e feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons
- Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons.
- Enhanced card rendering to include feedback buttons when appropriate.
- Registered feedback listeners to handle feedback events and update card states accordingly.
- Updated the card template to support dynamic button rendering for feedback actions.
- Improved error handling and logging for feedback actions and card updates.
2026-06-22 15:48:35 +08:00
fdc310 6818ae2783 feat(dingtalk): update display formatting for card content with HTML line breaks 2026-06-21 17:57:39 +08:00
fdc310 e7f1059194 feat(lark): enhance LarkAdapter to store form content for resume notices 2026-06-21 17:17:59 +08:00
fdc310 537677044e feat(telegram): add action title feedback for user selections in Telegram messages 2026-06-21 15:12:47 +08:00
fdc310 321891441c feat(dify): add support for chatflow app type and enhance human input handling 2026-06-21 13:50:50 +08:00
fdc310 7f36103aa4 fix(telegram): correct group chat type check and handle oversized callback data for Telegram actions
fix(difysvapi): ensure safe access to remove-think configuration in pipeline settings
2026-06-18 11:53:50 +08:00
fdc310 b1750882ed feat(discord): implement Discord form view with button interactions for Dify actions 2026-06-17 11:25:59 +08:00
fdc310 b55f073e62 feat(qqofficial): implement one-click QR binding and enhance localization support 2026-06-17 00:31:29 +08:00
fdc310 e6cfee541f feat(qqofficial): implement Dify human input button interaction handling and markdown keyboard support 2026-06-16 19:37:16 +08:00
fdc310 f10ea82418 feat(wecom): implement Dify human input pause handling with button interaction support 2026-06-16 00:07:11 +08:00
fdc310 a32c4d152f feat(dingtalk): enhance human input card functionality with streaming support and active turn management
- Updated the DingTalk card template to enable streaming mode and multi-update configuration.
- Removed the obsolete delete_card method from DingTalkClient to streamline card management.
- Enhanced DingTalkAdapter to manage active turn cards and accumulated streaming text, ensuring a seamless user experience during human input prompts.
- Modified the create_message_card method to utilize existing active cards for resumed workflows, preventing duplication.
- Improved the _paint_form_on_card method to update existing cards with human input prompts and buttons dynamically.
- Updated the dingtalk_human_input_card.json template to reflect the new streaming capabilities and configuration options.
2026-06-15 17:45:09 +08:00
fdc310 83b0d26e99 feat(dingtalk): implement human input card support and card action handling
- Add a new module `card_callback.py` to handle card action button clicks from DingTalk.
- Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters.
- Update `DingTalkAdapter` to manage card state and handle form input through a single card template.
- Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input.
- Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons.
2026-06-15 15:51:52 +08:00
fdc310 e08b5db625 feat: Add the function for formatting human input text to support adapters without rich UI. 2026-06-10 15:41:18 +08:00
fdc310 d0f65b17ec feat: Improve TelegramAdapter message handling with enhanced error management and draft message support 2026-06-02 01:14:25 +08:00
fdc310 2b533c4a00 feat: Enhance TelegramAdapter to handle form action buttons and message threading 2026-06-02 00:25:52 +08:00
fdc310 f663d87a60 feat: Enhance Lark and Telegram adapters with new form handling for paused workflows 2026-06-01 23:48:59 +08:00
fdc310 60e5b873ee feat: Add '_routed_by_rule' variable to form action in Lark and Telegram adapters 2026-05-30 12:22:00 +08:00
fdc310 b96f209b98 feat: Implement workflow form handling for paused workflows
- Added module-level storage for pending forms to manage state across sessions.
- Introduced functions to set, get, and clear pending forms with expiration handling.
- Enhanced DifyServiceAPIRunner to support resuming paused workflows via form actions.
- Implemented logic to yield human input requests and display appropriate messages.
- Updated workflow submission methods to handle paused states and resume actions.
- Ensured proper merging of pending form actions with user inputs for seamless interaction.
2026-05-28 23:32:46 +08:00
98 changed files with 621 additions and 4031 deletions
-4
View File
@@ -37,10 +37,6 @@ jobs:
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run frontend unit tests
working-directory: web
run: pnpm test:unit
- name: Install Playwright browsers
working-directory: web
run: pnpm exec playwright install --with-deps chromium
+17 -11
View File
@@ -92,17 +92,6 @@ docker compose --profile all up -d
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
---
## Supported Platforms
| Platform | Status | Notes |
@@ -178,6 +167,17 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
---
## Community
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87)
@@ -186,6 +186,12 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Contributors
Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:
+16 -10
View File
@@ -92,16 +92,6 @@ docker compose --profile all up -d
---
## 在线演示
**立即体验:** https://demo.langbot.dev/
- 邮箱:`demo@langbot.app`
- 密码:`langbot123456`
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
---
## 支持的平台
| 平台 | 状态 | 备注 |
@@ -180,6 +170,16 @@ docker compose --profile all up -d
---
## 在线演示
**立即体验:** https://demo.langbot.dev/
- 邮箱:`demo@langbot.app`
- 密码:`langbot123456`
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
---
## 为 AI Agent 而生 🤖
LangBot **从设计上就对 Agent 友好** —— 你的编码 AgentClaude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot:
@@ -203,6 +203,12 @@ LangBot **从设计上就对 Agent 友好** —— 你的编码 AgentClaude C
---
## Star 趋势
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## 贡献者
感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助:
+14 -10
View File
@@ -91,16 +91,6 @@ docker compose --profile all up -d
---
## Demo en Vivo
**Pruébelo ahora:** https://demo.langbot.dev/
- Correo electrónico: `demo@langbot.app`
- Contraseña: `langbot123456`
*Nota: Entorno de demostración público. No ingrese información confidencial.*
---
## Plataformas Soportadas
| Plataforma | Estado | Notas |
@@ -163,6 +153,14 @@ docker compose --profile all up -d
---
## Demo en Vivo
**Pruébelo ahora:** https://demo.langbot.dev/
- Correo electrónico: `demo@langbot.app`
- Contraseña: `langbot123456`
*Nota: Entorno de demostración público. No ingrese información confidencial.*
## Diseñado para Agentes de IA 🤖
LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase:
@@ -184,6 +182,12 @@ LangBot es **agent-friendly por diseño** —— tus agentes de codificación (C
---
## Historial de Stars
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Colaboradores
Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:
+14 -10
View File
@@ -91,16 +91,6 @@ docker compose --profile all up -d
---
## Démo en Ligne
**Essayez maintenant :** https://demo.langbot.dev/
- Email : `demo@langbot.app`
- Mot de passe : `langbot123456`
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
---
## Plateformes Supportées
| Plateforme | Statut | Notes |
@@ -163,6 +153,14 @@ docker compose --profile all up -d
---
## Démo en Ligne
**Essayez maintenant :** https://demo.langbot.dev/
- Email : `demo@langbot.app`
- Mot de passe : `langbot123456`
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
## Conçu pour les agents IA 🤖
LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre :
@@ -184,6 +182,12 @@ LangBot est **agent-friendly par conception** —— vos agents de codage (Claud
---
## Historique des Stars
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Contributeurs
Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :
+14 -10
View File
@@ -91,16 +91,6 @@ docker compose --profile all up -d
---
## ライブデモ
**今すぐ試す:** https://demo.langbot.dev/
- メール: `demo@langbot.app`
- パスワード: `langbot123456`
*注意: 公開デモ環境です。機密情報を入力しないでください。*
---
## 対応プラットフォーム
| プラットフォーム | ステータス | 備考 |
@@ -163,6 +153,14 @@ docker compose --profile all up -d
---
## ライブデモ
**今すぐ試す:** https://demo.langbot.dev/
- メール: `demo@langbot.app`
- パスワード: `langbot123456`
*注意: 公開デモ環境です。機密情報を入力しないでください。*
## AI エージェントのために 🤖
LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます:
@@ -184,6 +182,12 @@ LangBot は **設計段階からエージェントフレンドリー** です。
---
## Star 推移
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## コントリビューター
LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:
+14 -10
View File
@@ -91,16 +91,6 @@ docker compose --profile all up -d
---
## 라이브 데모
**지금 체험:** https://demo.langbot.dev/
- 이메일: `demo@langbot.app`
- 비밀번호: `langbot123456`
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
---
## 지원 플랫폼
| 플랫폼 | 상태 | 비고 |
@@ -163,6 +153,14 @@ docker compose --profile all up -d
---
## 라이브 데모
**지금 체험:** https://demo.langbot.dev/
- 이메일: `demo@langbot.app`
- 비밀번호: `langbot123456`
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
## AI 에이전트를 위한 설계 🤖
LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다:
@@ -184,6 +182,12 @@ LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩
---
## Star 추이
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## 기여자
LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:
+14 -10
View File
@@ -91,16 +91,6 @@ docker compose --profile all up -d
---
## Демо
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Пароль: `langbot123456`
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
---
## Поддерживаемые платформы
| Платформа | Статус | Примечания |
@@ -163,6 +153,14 @@ docker compose --profile all up -d
---
## Демо
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Пароль: `langbot123456`
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
## Создано для ИИ-агентов 🤖
LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой:
@@ -184,6 +182,12 @@ LangBot **дружелюбен к агентам по своей архитек
---
## История Stars
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Участники
Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:
+14 -10
View File
@@ -93,16 +93,6 @@ docker compose --profile all up -d
---
## 線上演示
**立即體驗:** https://demo.langbot.dev/
- 信箱:`demo@langbot.app`
- 密碼:`langbot123456`
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
---
## 支援的平台
| 平台 | 狀態 | 備註 |
@@ -179,6 +169,14 @@ docker compose --profile all up -d
---
## 線上演示
**立即體驗:** https://demo.langbot.dev/
- 信箱:`demo@langbot.app`
- 密碼:`langbot123456`
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
## 為 AI Agent 而生 🤖
LangBot **從設計上就對 Agent 友善** —— 你的編碼 AgentClaude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot:
@@ -202,6 +200,12 @@ LangBot **從設計上就對 Agent 友善** —— 你的編碼 AgentClaude C
---
## Star 趨勢
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## 貢獻者
感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助:
+14 -10
View File
@@ -91,16 +91,6 @@ docker compose --profile all up -d
---
## Demo trực tuyến
**Thử ngay:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Mật khẩu: `langbot123456`
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
---
## Nền tảng được hỗ trợ
| Nền tảng | Trạng thái | Ghi chú |
@@ -163,6 +153,14 @@ docker compose --profile all up -d
---
## Demo trực tuyến
**Thử ngay:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Mật khẩu: `langbot123456`
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
## Được xây dựng cho AI Agent 🤖
LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất:
@@ -184,6 +182,12 @@ LangBot **thân thiện với agent ngay từ thiết kế** —— các coding
---
## Lịch sử Star
[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date)
---
## Người đóng góp
Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:
+4 -12
View File
@@ -139,8 +139,7 @@ spec:
cpu: "1000m"
# Liveness probe to restart container if it becomes unresponsive
livenessProbe:
httpGet:
path: /healthz
tcpSocket:
port: 5400
initialDelaySeconds: 30
periodSeconds: 10
@@ -148,8 +147,7 @@ spec:
failureThreshold: 3
# Readiness probe to know when container is ready to accept traffic
readinessProbe:
httpGet:
path: /healthz
tcpSocket:
port: 5400
initialDelaySeconds: 10
periodSeconds: 5
@@ -267,16 +265,14 @@ spec:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /healthz
tcpSocket:
port: 5410
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
tcpSocket:
port: 5410
initialDelaySeconds: 10
periodSeconds: 5
@@ -323,10 +319,6 @@ metadata:
app: langbot
spec:
replicas: 1
# Plugin Runtime has a single active LangBot control owner. Recreate avoids
# two LangBot pods fighting over that connection during a rolling update.
strategy:
type: Recreate
selector:
matchLabels:
app: langbot
+2 -4
View File
@@ -31,10 +31,8 @@ Both are compatible with LangBot.
## Installation
Valkey Search support is included automatically on Linux and macOS. The official `valkey-glide`
client does not currently publish a Windows package, so LangBot skips this optional dependency on
Windows; LangBot remains usable there, but the Valkey Search backend is unavailable. To install the
client manually on a supported platform:
Valkey Search support is included when you install LangBot — the `valkey-glide` dependency is
declared in `pyproject.toml`. To install manually:
```bash
pip install 'valkey-glide>=2.4.1,<3.0.0'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.6"
version = "4.10.5"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.4.17",
"langbot-plugin==0.4.13",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -80,7 +80,7 @@ dependencies = [
"pgvector>=0.4.1",
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
"valkey-glide>=2.4.1,<3.0.0",
]
keywords = [
"bot",
+1 -4
View File
@@ -885,10 +885,7 @@ class DingTalkClient:
while not self._stopped:
try:
# open_connection performs blocking network I/O in the DingTalk SDK.
# Run it off the event loop so connection stalls do not block the
# LangBot HTTP server and other async tasks.
connection = await asyncio.to_thread(self.client.open_connection)
connection = self.client.open_connection()
if not connection:
if self.logger:
@@ -21,7 +21,7 @@ import quart
from ... import group
from ......utils import paths
from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
from ......platform.sources.websocket_manager import ws_connection_manager
logger = logging.getLogger(__name__)
@@ -203,15 +203,11 @@ class EmbedRouterGroup(group.RouterGroup):
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
session_id = quart.request.args.get('session_id', '')
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type, session_id)
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
return self.success(data={'messages': messages})
except Exception as e:
@@ -231,15 +227,11 @@ class EmbedRouterGroup(group.RouterGroup):
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
session_id = quart.request.args.get('session_id', '')
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
websocket_adapter.reset_session(pipeline_uuid, session_type, session_id)
websocket_adapter.reset_session(pipeline_uuid, session_type)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
@@ -302,11 +294,6 @@ class EmbedRouterGroup(group.RouterGroup):
)
return
session_id = quart.websocket.args.get('session_id', '')
if not is_valid_session_id(session_id):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
return
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
@@ -317,7 +304,6 @@ class EmbedRouterGroup(group.RouterGroup):
websocket=quart.websocket._get_current_object(),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
)
@@ -232,13 +232,8 @@ class ModelProviderService:
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
rerank_service = getattr(self.ap, 'rerank_models_service', None)
rerank_models = (
await rerank_service.get_rerank_models_by_provider(provider_uuid) if rerank_service is not None else []
)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
existing_rerank_names = {model['name'] for model in rerank_models}
filtered_models = []
for model in scanned_models:
@@ -265,8 +260,6 @@ class ModelProviderService:
'already_added': (
model_name in existing_embedding_names
if scanned_type == 'embedding'
else model_name in existing_rerank_names
if scanned_type == 'rerank'
else model_name in existing_llm_names
),
}
+28 -134
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import sys
@@ -29,7 +28,6 @@ _DOCKER_BOX_HOST = 'langbot_box'
_DEFAULT_PORT = 5410
_HEARTBEAT_INTERVAL_SEC = 20
_HEARTBEAT_FAILURE_THRESHOLD = 3
# Top-level keys under ``box`` that are LangBot-internal and should not be
# forwarded to the Box runtime.
@@ -115,8 +113,6 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._handler_task: asyncio.Task | None = None
self._ctrl_task: asyncio.Task | None = None
self._heartbeat_task: asyncio.Task | None = None
self._ctrl = None
self._generation = 0
# Parse the relay URL once for reuse.
parsed = urlparse(self.ws_relay_base_url)
@@ -149,67 +145,29 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
return self.uses_websocket()
async def initialize(self) -> None:
async with self._lifecycle_lock:
if self._closing:
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
self._generation += 1
await self._stop_transport()
try:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
else:
await self._start_local_stdio()
except BaseException:
await self._stop_transport()
await self._close_managed_subprocess()
raise
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
else:
await self._start_local_stdio()
if self._heartbeat_task is None or self._heartbeat_task.done():
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
async def reconnect(self) -> None:
async with self._lifecycle_lock:
if self._closing:
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
self._generation += 1
await self._stop_transport()
try:
if self._uses_websocket():
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
await self._start_subprocess_then_ws()
else:
await self._connect_remote_ws()
else:
await self._start_local_stdio()
except BaseException:
await self._stop_transport()
await self._close_managed_subprocess()
raise
if self._heartbeat_task is None or self._heartbeat_task.done():
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
# Start heartbeat after successful connection
if self._heartbeat_task is None:
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
# -- heartbeat -----------------------------------------------------------
async def _heartbeat_loop(self) -> None:
"""Periodically ping the Box runtime to detect silent disconnections."""
failures = 0
while not self._closing:
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
try:
await self.ping()
failures = 0
self.ap.logger.debug('Heartbeat to Box runtime success.')
except Exception as e:
failures += 1
self.ap.logger.warning(f'Box runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}')
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
failures = 0
if self.runtime_disconnect_callback is not None:
await self.runtime_disconnect_callback(self)
self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}')
async def ping(self) -> None:
if self._handler is None:
@@ -238,11 +196,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
# mirroring `rt -s`.
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
env=env,
capture_stderr=False,
)
self._ctrl = ctrl
self._ctrl_task = asyncio.create_task(
ctrl.run(self._make_connection_callback('stdio', connected, connect_error, self._generation))
ctrl.run(self._make_connection_callback('stdio', connected, connect_error))
)
try:
@@ -326,9 +282,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
await self.runtime_disconnect_callback(self)
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
self._ctrl = ctrl
self._ctrl_task = asyncio.create_task(
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error, self._generation))
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error))
)
try:
@@ -344,29 +299,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
transport_name: str,
connected: asyncio.Event,
connect_error: list[Exception],
generation: int,
):
async def new_connection_callback(connection: Connection) -> None:
if generation != self._generation or self._closing:
await connection.close()
return
handler = Handler(connection)
connection_ready = False
disconnect_notified = False
async def notify_disconnect() -> None:
nonlocal disconnect_notified
if (
connection_ready
and not disconnect_notified
and generation == self._generation
and not self._closing
and self.runtime_disconnect_callback is not None
):
disconnect_notified = True
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
await self.runtime_disconnect_callback(self)
self._handler = handler
self.client.set_handler(handler)
self._handler_task = asyncio.create_task(handler.run())
@@ -376,74 +311,33 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config)
self.ap.logger.debug('Sent box configuration to Box runtime via INIT.')
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
connection_ready = True
connected.set()
await self._handler_task
except asyncio.CancelledError:
raise
except Exception as exc:
if not connected.is_set():
connect_error.append(exc)
connected.set()
return
finally:
if getattr(self, '_handler', None) is handler:
self._handler = None
self.client.set_handler(None)
await notify_disconnect()
# If we reach here, handler.run() returned normally (connection
# closed) or raised after the initial handshake succeeded.
# Either way, treat it as a disconnect.
if connected.is_set():
if self._uses_websocket():
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
if self.runtime_disconnect_callback is not None:
await self.runtime_disconnect_callback(self)
else:
self.ap.logger.error(
'Disconnected from Box runtime via stdio. '
'Cannot automatically reconnect — please restart LangBot.'
)
return new_connection_callback
# -- lifecycle -----------------------------------------------------------
async def _stop_transport(self) -> None:
if self._handler is not None:
with contextlib.suppress(Exception):
await self._handler.close()
self.client.set_handler(None)
tasks = [
task
for task in (self._handler_task, self._ctrl_task)
if task is not None and task is not asyncio.current_task()
]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
close_ctrl = getattr(self._ctrl, 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
await close_ctrl()
self._handler = None
self._handler_task = None
self._ctrl_task = None
self._ctrl = None
async def aclose(self) -> None:
self._closing = True
self._generation += 1
if self._heartbeat_task is not None:
self._heartbeat_task.cancel()
await asyncio.gather(self._heartbeat_task, return_exceptions=True)
self._heartbeat_task = None
await self._stop_transport()
process = getattr(self, '_subprocess', None)
if process is not None and process.returncode is None:
with contextlib.suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
await process.wait()
self._subprocess = None
await self._close_managed_subprocess()
def dispose(self) -> None:
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
self._closing = True
if self._heartbeat_task is not None:
self._heartbeat_task.cancel()
self._heartbeat_task = None
+9 -34
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import collections
import contextlib
import datetime as _dt
import enum
import json
@@ -66,8 +65,6 @@ class BoxService:
self.workspace_quota_mb = self._load_workspace_quota_mb()
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
self._shutdown_task = None
self._reconnect_task: asyncio.Task | None = None
self._closing = False
self._available = False
self._connector_error: str = ''
self._reconnecting = False
@@ -113,8 +110,6 @@ class BoxService:
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
self._available = False
self._connector_error = str(exc)
if self._runtime_connector is not None:
await self._on_runtime_disconnect(self._runtime_connector)
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
"""Called by the connector when the Box runtime connection drops.
@@ -123,34 +118,29 @@ class BoxService:
Skipped entirely when Box is disabled by config — that path should
never have connected in the first place.
"""
if not self._enabled or self._closing:
if not self._enabled:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
if self._reconnecting:
return # Another reconnect loop is already running
self._reconnecting = True
self._available = False
self._connector_error = 'Disconnected from Box runtime'
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
self._reconnect_task = asyncio.create_task(self._reconnect_loop(connector))
asyncio.create_task(self._reconnect_loop(connector))
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
"""Retry reconnection with exponential backoff (3s → 60s max)."""
delay = 3
max_delay = 60
try:
while not self._closing:
while True:
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
await asyncio.sleep(delay)
try:
await connector.reconnect()
self._ensure_default_workspace()
await self._purge_attachment_dirs()
connector.dispose()
await connector.initialize()
self._available = True
self._connector_error = ''
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
if callable(reload_skills):
await reload_skills()
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
return
except Exception as exc:
@@ -159,7 +149,6 @@ class BoxService:
delay = min(delay * 2, max_delay)
finally:
self._reconnecting = False
self._reconnect_task = None
@property
def available(self) -> bool:
@@ -849,28 +838,14 @@ class BoxService:
return attachments
async def shutdown(self):
if self._closing:
return
self._closing = True
self._available = False
reconnect_task = self._reconnect_task
self._reconnect_task = None
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
reconnect_task.cancel()
await asyncio.gather(reconnect_task, return_exceptions=True)
# The runtime may already be offline. A failed best-effort SHUTDOWN RPC
# must not prevent us from cancelling transports and reaping children.
with contextlib.suppress(Exception):
await self.client.shutdown()
if self._runtime_connector is not None:
await self._runtime_connector.aclose()
await self.client.shutdown()
def dispose(self):
if self._runtime_connector is not None:
self._runtime_connector.dispose()
loop = getattr(self.ap, 'event_loop', None)
if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()):
self._shutdown_task = loop.create_task(self.shutdown())
elif self._runtime_connector is not None:
self._runtime_connector.dispose()
async def get_sessions(self) -> list[dict]:
if not self._available:
+1 -35
View File
@@ -4,7 +4,6 @@ import logging
import asyncio
import traceback
import os
import contextlib
from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
@@ -167,8 +166,7 @@ class Application:
maintenance_service: maintenance_service.MaintenanceService = None
def __init__(self):
self._shutdown_lock = asyncio.Lock()
self._shutdown_complete = False
pass
async def initialize(self):
pass
@@ -320,39 +318,7 @@ class Application:
return default
return parsed
async def shutdown(self):
"""Stop application work and deterministically release runtime resources."""
async with self._shutdown_lock:
if self._shutdown_complete:
return
if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
if self.platform_mgr is not None:
with contextlib.suppress(Exception):
await self.platform_mgr.shutdown()
if self.tool_mgr is not None:
with contextlib.suppress(Exception):
await self.tool_mgr.shutdown()
if self.box_service is not None:
with contextlib.suppress(Exception):
await self.box_service.shutdown()
if self.plugin_connector is not None:
with contextlib.suppress(Exception):
await self.plugin_connector.aclose()
if self.task_mgr is not None:
tasks = [wrapper.task for wrapper in self.task_mgr.tasks if not wrapper.task.done()]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._shutdown_complete = True
def dispose(self):
"""Compatibility wrapper for callers that cannot await shutdown."""
loop = self.event_loop
if loop is not None and not loop.is_closed():
loop.create_task(self.shutdown())
return
if self.plugin_connector is not None:
self.plugin_connector.dispose()
if self.box_service is not None:
+7 -24
View File
@@ -46,38 +46,21 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
async def main(loop: asyncio.AbstractEventLoop):
app_inst: app.Application | None = None
runtime_loop = asyncio.get_running_loop()
shutdown_requested = asyncio.Event()
run_task: asyncio.Task | None = None
try:
# Hang system signal processing
import signal
def signal_handler(sig, frame):
if app_inst is not None:
app_inst.dispose()
print('[Signal] Program exit.')
runtime_loop.call_soon_threadsafe(shutdown_requested.set)
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
if hasattr(signal, 'SIGTERM'):
signal.signal(signal.SIGTERM, signal_handler)
app_inst = await make_app(loop)
if app_inst is None:
return
run_task = asyncio.create_task(app_inst.run())
shutdown_task = asyncio.create_task(shutdown_requested.wait())
done, pending = await asyncio.wait((run_task, shutdown_task), return_when=asyncio.FIRST_COMPLETED)
if shutdown_task in done:
await app_inst.shutdown()
if not run_task.done():
run_task.cancel()
for task in pending:
task.cancel()
results = await asyncio.gather(run_task, shutdown_task, return_exceptions=True)
run_result = results[0]
if isinstance(run_result, BaseException) and not isinstance(run_result, asyncio.CancelledError):
raise run_result
await app_inst.run()
except Exception:
traceback.print_exc()
finally:
if app_inst is not None:
await app_inst.shutdown()
app_inst.dispose()
traceback.print_exc()
+5 -8
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import asyncio
from .. import stage, app
from ...utils import version, proxy
from ...pipeline import pool, controller, pipelinemgr
@@ -185,16 +187,11 @@ class BuildAppStage(stage.BootingStage):
ap.maintenance_service = maintenance_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
connector.schedule_reconnect()
await asyncio.sleep(3)
await plugin_connector_inst.initialize()
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
try:
await plugin_connector_inst.initialize()
except Exception as exc:
# Keep the API/UI available while an external or managed runtime is
# starting, then recover in the background with bounded backoff.
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
plugin_connector_inst.schedule_reconnect()
await plugin_connector_inst.initialize()
ap.plugin_connector = plugin_connector_inst
ctrl = controller.Controller(ap)
@@ -1,12 +1,10 @@
from __future__ import annotations
import typing
import sqlalchemy
from .. import handler
from ... import entities
from ... import plugin_diagnostics
from ....entity.persistence.bot import BotAdmin
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -26,14 +24,7 @@ class CommandHandler(handler.MessageHandler):
privilege = 1
admins = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(BotAdmin).where(
BotAdmin.bot_uuid == (query.bot_uuid or ''),
BotAdmin.launcher_type == query.launcher_type.value,
BotAdmin.launcher_id == str(query.launcher_id),
)
)
if admins.first() is not None:
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
privilege = 2
spt = command_text.split(' ')
+14 -32
View File
@@ -240,11 +240,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
async def process_message_data(msg_data, reply_list):
if msg_data['type'] == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
reply_list.append(
platform_message.Image(
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
)
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
elif msg_data['type'] == 'text':
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
@@ -289,9 +285,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
else:
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
image_msg = platform_message.Image(
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
yiri_msg_list.append(image_msg)
elif msg.type == 'forward':
# 暂时不太合理
@@ -497,11 +491,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
on_websocket_connection_event_cache: list[aiocqhttp.Event] = []
_listener_wrappers: dict[
tuple[typing.Type[platform_events.Event], typing.Callable],
tuple[str, typing.Callable],
] = {}
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
super().__init__(
@@ -516,7 +506,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
self.on_websocket_connection_event_cache = []
self._listener_wrappers = {}
if 'access-token' in config:
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
@@ -524,16 +513,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
else:
self.bot = aiocqhttp.CQHttp()
self.bot.on_websocket_connection(self._on_websocket_connection)
async def _on_websocket_connection(self, event: aiocqhttp.Event):
for cached_event in self.on_websocket_connection_event_cache:
if cached_event.self_id == event.self_id and cached_event.time == event.time:
return
self.on_websocket_connection_event_cache.append(event)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
# Check if message contains a Forward component
forward_msg = message.get_first(platform_message.Forward)
@@ -669,14 +648,22 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if event_type == platform_events.GroupMessage:
self.bot.on_message('group')(on_message)
self._listener_wrappers[(event_type, callback)] = ('message.group', on_message)
# self.bot.on_notice()(on_message)
elif event_type == platform_events.FriendMessage:
self.bot.on_message('private')(on_message)
self._listener_wrappers[(event_type, callback)] = ('message.private', on_message)
# self.bot.on_notice()(on_message)
# print(event_type)
async def on_websocket_connection(event: aiocqhttp.Event):
for event in self.on_websocket_connection_event_cache:
if event.self_id == event.self_id and event.time == event.time:
return
self.on_websocket_connection_event_cache.append(event)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
self.bot.on_websocket_connection(on_websocket_connection)
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -684,12 +671,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
listener = self._listener_wrappers.pop((event_type, callback), None)
if listener is None:
return
event_name, wrapper = listener
self.bot._bus.unsubscribe(event_name, wrapper)
return super().unregister_listener(event_type, callback)
async def run_async(self):
await self.bot._server_app.run_task(**self.config)
+1 -3
View File
@@ -783,9 +783,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
image_data = await response.read()
image_base64 = base64.b64encode(image_data).decode('utf-8')
image_format = response.headers['Content-Type']
element_list.append(
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
)
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
return platform_message.MessageChain(element_list)
+5 -59
View File
@@ -242,33 +242,6 @@ def _lark_extract_action_form_inputs(action: typing.Any, action_value_obj: dict)
return form_inputs
class NonBlockingLarkWSClient(lark_oapi.ws.Client):
"""Keep the SDK's synchronous connection lookup off LangBot's event loop.
lark-oapi performs ``requests.post`` inside its async ``_connect`` method.
A stalled TLS handshake therefore freezes Quart and every other adapter in
the process. Pre-fetch the URL in a worker thread, then let the SDK finish
the WebSocket setup on the main loop with the already-resolved URL.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._langbot_connect_lock = asyncio.Lock()
async def _connect(self) -> None:
async with self._langbot_connect_lock:
if self._conn is not None:
return
conn_url = await asyncio.to_thread(self._get_conn_url)
original_get_conn_url = self._get_conn_url
self._get_conn_url = lambda: conn_url
try:
await super()._connect()
finally:
self._get_conn_url = original_get_conn_url
class AESCipher(object):
def __init__(self, key):
self.bs = AES.block_size
@@ -1267,9 +1240,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id = config['bot_name']
domain = self._resolve_domain(config)
bot = NonBlockingLarkWSClient(
config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain
)
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain)
api_client = self.build_api_client(config)
cipher = AESCipher(config.get('encrypt-key', ''))
self.request_app_ticket(api_client, config)
@@ -1436,27 +1407,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
return api_client
@staticmethod
def _has_markdown_table(text_elements: list) -> bool:
"""Check if text elements contain markdown table syntax (|...|).
A markdown table requires:
- At least one line with pipe characters (|)
- A separator line with pipes and dashes (|---|)
"""
# Regex to detect markdown table: lines with pipes and separator lines with dashes
table_pattern = re.compile(r'^\s*\|.+\|\s*$', re.MULTILINE)
separator_pattern = re.compile(r'^\s*\|\s*-+(\s*\|\s*-+)*\s*\|\s*$', re.MULTILINE)
for paragraph in text_elements:
for ele in paragraph:
if ele.get('tag') == 'md':
text = ele.get('text', '')
# Quick check: if has pipes and separator line, it's likely a table
if '|' in text and table_pattern.search(text) and separator_pattern.search(text):
return True
return False
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
@@ -1470,10 +1420,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Send text message if there are text elements
if text_elements:
# Use 'post' format if: has @mentions OR has markdown tables
has_at = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
has_table = self._has_markdown_table(text_elements)
needs_post = has_at or has_table
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
if needs_post:
msg_type = 'post'
@@ -1967,10 +1914,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Send text message if there are text elements
if text_elements:
# Use 'post' format if: has @mentions OR has markdown tables
has_at = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
has_table = self._has_markdown_table(text_elements)
needs_post = has_at or has_table
# Determine msg_type based on content: use 'post' if at mentions
# are present (requires post paragraph structure), otherwise 'text'
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
if needs_post:
msg_type = 'post'
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+1 -1
View File
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+1 -2
View File
@@ -157,8 +157,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
message_components.append(
platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}'
)
)
@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from ...core import app
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
from .websocket_manager import ws_connection_manager, WebSocketConnection
logger = logging.getLogger(__name__)
@@ -91,59 +91,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
self.outbound_message_queue = asyncio.Queue()
self.stream_enabled = True
@staticmethod
def _conversation_key(pipeline_uuid: str, session_id: str | None = None) -> str:
"""Return the history key for a pipeline/client conversation."""
return f'{pipeline_uuid}:{session_id}' if session_id else pipeline_uuid
@staticmethod
def _parse_embed_target(target_id: str) -> tuple[str, str] | None:
"""Extract pipeline and session identifiers from a stable embed launcher."""
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
target = target_value[len(prefix) :]
break
else:
return None
if ':' not in target:
return None
pipeline_uuid, session_id = target.rsplit(':', 1)
if not pipeline_uuid or not is_valid_session_id(session_id):
return None
return pipeline_uuid, session_id
@classmethod
async def _get_connection_from_target(cls, target_id: str):
"""Resolve a person or group WebSocket launcher to its connection."""
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
target = target_value[len(prefix) :]
break
else:
return None
connection = await ws_connection_manager.get_connection(target)
if connection is not None:
return connection
embed_target = cls._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
return await ws_connection_manager.get_connection_by_session_id(target)
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
"""Resolve the originating pipeline and browser session for a reply."""
sender = getattr(message_source, 'sender', None)
sender_id = getattr(sender, 'id', '')
connection = await self._get_connection_from_target(sender_id)
if connection is not None:
return connection.pipeline_uuid, connection.session_id
embed_target = self._parse_embed_target(sender_id)
if embed_target is not None:
return embed_target
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
async def send_message(
self,
target_type: str,
@@ -156,26 +103,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
target_id 可能是 launcher_id websocket_xxx pipeline_uuid
我们需要尝试两种方式来确保消息能够送达
"""
connection = await self._get_connection_from_target(target_id)
if connection is not None:
pipeline_uuid = connection.pipeline_uuid
session_id = connection.session_id
else:
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
else:
pipeline_uuid = typing.cast(
str,
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
)
session_id = None
# 获取当前的 pipeline_uuid
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if target_type == 'group' else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
# 选择会话
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
msg_id = len(session.get_message_list(conversation_key)) + 1
# 生成唯一消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
message_data = WebSocketMessage(
id=msg_id,
@@ -186,8 +122,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
session.get_message_list(conversation_key).append(message_data)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
# 直接广播到当前pipeline的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -196,7 +134,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': message_data.model_dump(),
},
session_type=session_type,
session_id=session_id,
)
return message_data.model_dump()
@@ -215,11 +152,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
else self.websocket_person_session
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
# 从message_source获取pipeline_uuid和connection_id
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
msg_id = len(session.get_message_list(conversation_key)) + 1
# 生成新的消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
message_data = WebSocketMessage(
id=msg_id,
@@ -230,8 +168,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
session.get_message_list(conversation_key).append(message_data)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
# 直接广播到所有该pipeline的连接,包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -240,7 +180,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': message_data.model_dump(),
},
session_type=session_type,
session_id=session_id,
)
return message_data.model_dump()
@@ -261,11 +200,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
else self.websocket_person_session
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
message_list = session.get_message_list(conversation_key)
stream_message_indexes = session.get_stream_message_indexes(conversation_key)
message_list = session.get_message_list(pipeline_uuid)
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
# Use it as the primary key to avoid overwriting an old card from a previous reply.
@@ -309,6 +247,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if message_is_final and resp_message_id:
stream_message_indexes.pop(resp_message_id, None)
# 直接广播到所有该pipeline的连接,包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -317,7 +256,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': message_data.model_dump(),
},
session_type=session_type,
session_id=session_id,
)
return message_data.model_dump()
@@ -443,19 +381,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
conversation_key = self._conversation_key(pipeline_uuid, connection.session_id)
# 获取stream参数,默认为True
self.stream_enabled = message_data.get('stream', True)
# 选择会话
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
# 解析消息链
message_chain_obj = message_data.get('message', [])
# 处理图片组件:将path转换为base64
await self._process_image_components(message_chain_obj)
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
message_id = len(use_session.get_message_list(conversation_key)) + 1
# 生成消息ID
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
# 保存用户消息
user_message = WebSocketMessage(
@@ -467,8 +409,9 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
connection_id=connection.connection_id,
is_final=True, # 用户消息始终是完整的,非流式
)
use_session.get_message_list(conversation_key).append(user_message)
use_session.get_message_list(pipeline_uuid).append(user_message)
# 广播用户消息到所有连接(包括发送者),包含session_type信息
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
@@ -477,27 +420,25 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'data': user_message.model_dump(),
},
session_type=session_type,
session_id=connection.session_id,
)
# 添加消息源
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
# 创建事件
launcher_id = f'{pipeline_uuid}:{connection.session_id}' if connection.session_id else connection.connection_id
if session_type == 'person':
sender = platform_entities.Friend(id=f'websocket_{launcher_id}', nickname='User', remark='User')
sender = platform_entities.Friend(
id=f'websocket_{connection.connection_id}', nickname='User', remark='User'
)
event = platform_events.FriendMessage(
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
else:
group = platform_entities.Group(
id=f'websocketgroup_{launcher_id}' if connection.session_id else 'websocketgroup',
name='Group',
permission=platform_entities.Permission.Member,
id='websocketgroup', name='Group', permission=platform_entities.Permission.Member
)
sender = platform_entities.GroupMember(
id=f'websocket_{launcher_id}',
id=f'websocket_{connection.connection_id}',
member_name='User',
group=group,
permission=platform_entities.Permission.Member,
@@ -527,47 +468,22 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if event.__class__ in listeners:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
def get_websocket_messages(
self,
pipeline_uuid: str,
session_type: str,
session_id: str | None = None,
) -> list[dict]:
"""Return history for one pipeline/client conversation."""
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
return [message.model_dump() for message in session.message_lists.get(conversation_key, [])]
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
"""获取消息历史"""
if session_type == 'person':
return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]
else:
return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]
def reset_session(
self,
pipeline_uuid: str,
session_type: str,
session_id: str | None = None,
):
"""Reset one pipeline/client conversation."""
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
if conversation_key in session.message_lists:
session.message_lists[conversation_key] = []
if conversation_key in session.stream_message_indexes:
session.stream_message_indexes[conversation_key] = {}
if session_id:
launcher_id = (
f'websocketgroup_{pipeline_uuid}:{session_id}'
if session_type == 'group'
else f'websocket_{pipeline_uuid}:{session_id}'
)
self.ap.sess_mgr.session_list = [
candidate_session
for candidate_session in self.ap.sess_mgr.session_list
if not (
str(
candidate_session.launcher_type.value
if hasattr(candidate_session.launcher_type, 'value')
else candidate_session.launcher_type
)
== session_type
and str(candidate_session.launcher_id) == launcher_id
)
]
def reset_session(self, pipeline_uuid: str, session_type: str):
"""重置会话"""
if session_type == 'person':
if pipeline_uuid in self.websocket_person_session.message_lists:
self.websocket_person_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
else:
if pipeline_uuid in self.websocket_group_session.message_lists:
self.websocket_group_session.message_lists[pipeline_uuid] = []
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
@@ -9,16 +9,6 @@ from datetime import datetime
import pydantic
logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object()
def is_valid_session_id(value: str) -> bool:
"""Accept only canonical random UUIDs for client conversation identifiers."""
try:
parsed = uuid.UUID(value)
except (ValueError, TypeError, AttributeError):
return False
return parsed.version == 4 and str(parsed) == value
class WebSocketConnection(pydantic.BaseModel):
@@ -35,9 +25,6 @@ class WebSocketConnection(pydantic.BaseModel):
session_type: str # 'person' or 'group'
"""会话类型"""
session_id: str | None = None
"""Optional client conversation identifier used by embed widgets."""
websocket: typing.Any = pydantic.Field(exclude=True)
"""WebSocket连接对象 (quart.websocket)"""
@@ -78,15 +65,13 @@ class WebSocketConnectionManager:
websocket: typing.Any,
pipeline_uuid: str,
session_type: str,
metadata: dict | None = None,
session_id: str | None = None,
metadata: dict = None,
) -> WebSocketConnection:
"""Register a WebSocket connection and its optional embed session."""
"""添加新的WebSocket连接"""
async with self._lock:
connection = WebSocketConnection(
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
websocket=websocket,
metadata=metadata or {},
)
@@ -135,25 +120,10 @@ class WebSocketConnectionManager:
logger.debug(f'WebSocket connection disconnected: {connection_id}')
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
"""Get a connection by its transport identifier."""
async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:
"""获取指定连接"""
return self.connections.get(connection_id)
async def get_connection_by_session_id(
self,
session_id: str,
pipeline_uuid: str | None = None,
) -> WebSocketConnection | None:
"""Get an active embed connection by its stable browser session identifier."""
for connection in self.connections.values():
if (
connection.session_id == session_id
and connection.is_active
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
):
return connection
return None
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
"""获取指定流水线的所有连接"""
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
@@ -164,30 +134,20 @@ class WebSocketConnectionManager:
connection_ids = self.session_connections.get(session_type, set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def broadcast_to_pipeline(
self,
pipeline_uuid: str,
message: dict,
session_type: str | None = None,
session_id: typing.Any = _SESSION_FILTER_UNSET,
):
"""Broadcast a message to matching connections for one pipeline.
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):
"""向指定流水线的所有连接广播消息
Args:
pipeline_uuid: Pipeline identifier.
message: Serialized message to enqueue.
session_type: Optional session-type filter.
session_id: Embed conversation filter. Omit it to broadcast across
conversations; pass ``None`` to target non-embed connections.
pipeline_uuid: 流水线UUID
message: 要广播的消息
session_type: 可选的会话类型过滤器如果提供则只向匹配的session_type连接广播
"""
connections = await self.get_connections_by_pipeline(pipeline_uuid)
# 如果指定了session_type,只向匹配的连接广播
if session_type is not None:
connections = [conn for conn in connections if conn.session_type == session_type]
if session_id is not _SESSION_FILTER_UNSET:
connections = [conn for conn in connections if conn.session_id == session_id]
tasks = []
for conn in connections:
tasks.append(self.send_to_connection(conn.connection_id, message))
+1 -3
View File
@@ -133,9 +133,7 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list = []
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
yiri_msg_list.append(
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
)
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
+153 -281
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import contextlib
import io
import time
import zipfile
@@ -37,12 +36,6 @@ from ..core import taskmgr
from ..entity.persistence import plugin as persistence_plugin
_CONNECT_TIMEOUT_SEC = 30.0
_HEARTBEAT_INTERVAL_SEC = 20.0
_HEARTBEAT_FAILURE_THRESHOLD = 3
_RECONNECT_MAX_DELAY_SEC = 60.0
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
@@ -77,235 +70,128 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
self._transport_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._generation = 0
self._connected = asyncio.Event()
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
return runtime_handler
def _runtime_available(self) -> bool:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
return False
# Unit-level and explicitly injected handlers don't own a transport.
# A managed transport must also have completed its handshake.
return self._transport_task is None or self._connected.is_set()
async def heartbeat_loop(self):
failures = 0
while not self._closing:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
while True:
await asyncio.sleep(20)
try:
await self.ping_plugin_runtime()
failures = 0
self.ap.logger.debug('Heartbeat to plugin runtime success.')
except Exception as e:
failures += 1
self.ap.logger.warning(
f'Plugin runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}'
)
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
self._connected.clear()
self.schedule_reconnect()
failures = 0
self.ap.logger.debug(f'Failed to heartbeat to plugin runtime: {e}')
async def initialize(self):
if not self.is_enable_plugin:
self.ap.logger.info('Plugin system is disabled.')
return
async with self._lifecycle_lock:
if self._closing:
raise PluginRuntimeNotConnectedError('Plugin runtime connector is shutting down')
if self._connected.is_set() and hasattr(self, 'handler'):
return
self._generation += 1
generation = self._generation
await self._stop_transport()
self._connected = asyncio.Event()
connect_errors: list[Exception] = []
async def new_connection_callback(
connection: base_connection.Connection,
):
if generation != self._generation or self._closing:
await connection.close()
return
connection_ready = False
disconnect_notified = False
async def notify_disconnect() -> None:
nonlocal disconnect_notified
if (
connection_ready
and not disconnect_notified
and generation == self._generation
and not self._closing
):
disconnect_notified = True
self._connected.clear()
await self.runtime_disconnect_callback(self)
async def disconnect_callback(
rchandler: handler.RuntimeConnectionHandler,
) -> bool:
await notify_disconnect()
async def new_connection_callback(connection: base_connection.Connection):
async def disconnect_callback(
rchandler: handler.RuntimeConnectionHandler,
) -> bool:
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
await self.runtime_disconnect_callback(self)
return False
else:
self.ap.logger.error(
'Disconnected from plugin runtime, cannot automatically reconnect while LangBot connects to plugin runtime via stdio, please restart LangBot.'
)
return False
runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
self.handler = runtime_handler
self.handler_task = asyncio.create_task(runtime_handler.run())
self.handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
self.handler_task = asyncio.create_task(self.handler.run())
_ = await self.handler.ping()
# Push the configured marketplace (Space) URL to the runtime so it
# downloads plugins from the same Space LangBot is bound to, rather
# than relying on the runtime's own env/default.
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
if space_url:
try:
await runtime_handler.ping()
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
if space_url:
await runtime_handler.set_runtime_config(cloud_service_url=space_url)
if generation == self._generation and not self._closing:
connection_ready = True
self._connected.set()
self.ap.logger.info('Connected to plugin runtime.')
await self.handler_task
except asyncio.CancelledError:
raise
except Exception as exc:
if not self._connected.is_set():
connect_errors.append(exc)
self._connected.set()
finally:
if generation == self._generation and not self._closing:
self._connected.clear()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
await notify_disconnect()
await self.handler.set_runtime_config(cloud_service_url=space_url)
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
except Exception as e:
self.ap.logger.warning(f'Failed to push runtime config: {e}')
self.ap.logger.info('Connected to plugin runtime.')
await self.handler_task
task_coro: typing.Coroutine
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url',
'ws://langbot_plugin_runtime:5400/control/ws',
)
task: asyncio.Task | None = None
async def connection_failed(ctrl, exc=None):
error = exc or RuntimeError('WebSocket connection failed')
connect_errors.append(error)
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
)
task_coro = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
ws_url = 'ws://localhost:5400/control/ws'
async def connection_failed(ctrl, exc=None):
error = exc or RuntimeError('WebSocket connection failed')
connect_errors.append(error)
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
)
task_coro = self.ctrl.run(new_connection_callback)
else:
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=os.environ.copy(),
capture_stderr=False,
)
task_coro = self.ctrl.run(new_connection_callback)
self._transport_task = asyncio.create_task(task_coro)
try:
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
except asyncio.TimeoutError as exc:
await self._stop_transport()
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
if connect_errors:
await self._stop_transport()
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
if self.heartbeat_task is None or self.heartbeat_task.done():
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
def schedule_reconnect(self) -> None:
if self._closing or not self.is_enable_plugin:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
return
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
delay = 1.0
try:
while not self._closing:
try:
await self.initialize()
return
except Exception as exc:
self.ap.logger.warning(f'Plugin runtime reconnection failed: {exc}; retrying in {delay:.0f}s')
await asyncio.sleep(delay)
delay = min(delay * 2, _RECONNECT_MAX_DELAY_SEC)
finally:
self._reconnect_task = None
async def _stop_transport(self) -> None:
self._connected.clear()
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
with contextlib.suppress(Exception):
await runtime_handler.close()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
tasks = [
task
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): # use websocket
self.ap.logger.info('use websocket to connect to plugin runtime')
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
)
if task is not None and task is not asyncio.current_task()
]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._transport_task = None
if hasattr(self, 'handler_task'):
del self.handler_task
close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
await close_ctrl()
async def aclose(self) -> None:
self._closing = True
self._generation += 1
reconnect_task = self._reconnect_task
self._reconnect_task = None
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
reconnect_task.cancel()
await asyncio.gather(reconnect_task, return_exceptions=True)
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
self.heartbeat_task = None
await self._stop_transport()
await self._close_managed_subprocess()
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
else:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
await self.runtime_disconnect_callback(self)
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=make_connection_failed_callback,
)
task = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
# Due to Windows's lack of supports for both stdio and subprocess:
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
# We have to launch runtime via cmd but communicate via ws.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
ws_url = 'ws://localhost:5400/control/ws'
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
else:
self.ap.logger.error(
f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'
)
await self.runtime_disconnect_callback(self)
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=make_connection_failed_callback,
)
task = self.ctrl.run(new_connection_callback)
else: # stdio
self.ap.logger.info('use stdio to connect to plugin runtime')
# cmd: lbp rt -s
python_path = sys.executable
env = os.environ.copy()
self.ctrl = stdio_client_controller.StdioClientController(
command=python_path,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
env=env,
)
task = self.ctrl.run(new_connection_callback)
if self.heartbeat_task is None:
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
asyncio.create_task(task)
async def initialize_plugins(self):
pass
async def ping_plugin_runtime(self):
return await self._runtime_handler().ping()
if not hasattr(self, 'handler'):
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
return await self.handler.ping()
def _inspect_plugin_package(
self,
@@ -587,7 +473,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
file_bytes = download_resp.content
self._inspect_plugin_package(file_bytes, task_context)
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
# Continue to install via runtime
@@ -610,7 +496,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
del install_info['plugin_file']
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -649,14 +535,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
except Exception as e:
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
raise Exception(f'Failed to download file from GitHub: {e}')
async for ret in self._runtime_handler().install_plugin(install_source.value, install_info):
async for ret in self.handler.install_plugin(install_source.value, install_info):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
@@ -680,7 +566,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name: str,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
async for ret in self._runtime_handler().upgrade_plugin(plugin_author, plugin_name):
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
@@ -698,7 +584,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
delete_data: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
async for ret in self._runtime_handler().delete_plugin(plugin_author, plugin_name):
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
@@ -713,7 +599,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if delete_data:
if task_context is not None:
task_context.trace('Cleaning up plugin configuration and storage...')
await self._runtime_handler().cleanup_plugin_data(plugin_author, plugin_name)
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
"""List plugins, optionally filtered by component kinds.
@@ -724,10 +610,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
component of the specified kinds will be returned.
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
"""
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return []
plugins = await self._runtime_handler().list_plugins()
plugins = await self.handler.list_plugins()
# Filter plugins by component kinds if specified
if component_kinds is not None:
@@ -799,18 +685,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
return await self._runtime_handler().get_plugin_info(author, plugin_name)
return await self.handler.get_plugin_info(author, plugin_name)
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
return await self._runtime_handler().set_plugin_config(plugin_author, plugin_name, config)
return await self.handler.set_plugin_config(plugin_author, plugin_name, config)
@alru_cache(ttl=5 * 60) # 5 minutes
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
return await self._runtime_handler().get_plugin_icon(plugin_author, plugin_name)
return await self.handler.get_plugin_icon(plugin_author, plugin_name)
@alru_cache(ttl=5 * 60) # 5 minutes
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
return await self._runtime_handler().get_plugin_readme(plugin_author, plugin_name, language)
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
async def get_plugin_logs(
self,
@@ -820,11 +706,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
level: str | None = None,
) -> list[dict[str, Any]]:
# Not cached: logs are live and change constantly.
return await self._runtime_handler().get_plugin_logs(plugin_author, plugin_name, limit, level)
return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
@alru_cache(ttl=5 * 60)
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
return await self._runtime_handler().get_plugin_assets(plugin_author, plugin_name, filepath)
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def handle_page_api(
self,
@@ -835,15 +721,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
method: str,
body: Any = None,
) -> dict[str, Any]:
return await self._runtime_handler().handle_page_api(
plugin_author, plugin_name, page_id, endpoint, method, body
)
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return {}
return await self._runtime_handler().get_debug_info()
return await self.handler.get_debug_info()
async def emit_event(
self,
@@ -852,13 +736,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
) -> context.EventContext:
event_ctx = context.EventContext.from_event(event)
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
event_ctx._emitted_plugins = []
event_ctx._response_sources = []
return event_ctx
# Pass include_plugins to runtime for filtering
event_ctx_result = await self._runtime_handler().emit_event(
event_ctx_result = await self.handler.emit_event(
event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins
)
@@ -871,19 +755,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
"""Best-effort diagnostic forwarding to the plugin runtime."""
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return
try:
await self._runtime_handler().notify_plugin_diagnostic(diagnostic)
await self.handler.notify_plugin_diagnostic(diagnostic)
except Exception as e:
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return []
# Pass include_plugins to runtime for filtering
list_tools_data = await self._runtime_handler().list_tools(include_plugins=bound_plugins)
list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)
tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
@@ -901,19 +785,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return {'error': 'Tool not found: plugin system is disabled'}
# Pass include_plugins to runtime for validation
if not self._runtime_available():
return {'error': 'Plugin runtime is temporarily unavailable'}
return await self._runtime_handler().call_tool(
return await self.handler.call_tool(
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
)
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return []
# Pass include_plugins to runtime for filtering
list_commands_data = await self._runtime_handler().list_commands(include_plugins=bound_plugins)
list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
@@ -922,15 +803,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def execute_command(
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
return
# Pass include_plugins to runtime for validation
gen = self._runtime_handler().execute_command(
command_ctx.model_dump(serialize_as_any=True),
include_plugins=bound_plugins,
)
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)
async for ret in gen:
cmd_ret = command_context.CommandReturn.model_validate(ret)
@@ -945,33 +823,27 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
retrieval_context: dict[str, Any],
) -> dict[str, Any]:
"""Retrieve knowledge using a KnowledgeEngine instance."""
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return {'results': []}
return await self._runtime_handler().retrieve_knowledge(
plugin_author, plugin_name, retriever_name, retrieval_context
)
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context)
def dispose(self):
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
self._closing = True
# On non-Windows stdio mode, terminate via the controller's process handle.
# On Windows, the managed subprocess is cleaned up by the base class.
if (
self.is_enable_plugin
and hasattr(self, 'ctrl')
and isinstance(self.ctrl, stdio_client_controller.StdioClientController)
):
self.ap.logger.info('Terminating plugin runtime process...')
self.ctrl.process.terminate()
self._dispose_subprocess()
if self.heartbeat_task is not None:
self.heartbeat_task.cancel()
self.heartbeat_task = None
if self._reconnect_task is not None:
self._reconnect_task.cancel()
self._reconnect_task = None
for task in (
getattr(self, 'handler_task', None),
self._transport_task,
):
if task is not None:
task.cancel()
ctrl = getattr(self, 'ctrl', None)
process = getattr(ctrl, 'process', None)
if process is not None and process.returncode is None:
process.terminate()
self._dispose_subprocess()
@staticmethod
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
@@ -1001,29 +873,29 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
context_data: IngestionContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().rag_ingest_document(plugin_author, plugin_name, context_data)
return await self.handler.rag_ingest_document(plugin_author, plugin_name, context_data)
async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
return await self.handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().get_rag_creation_schema(plugin_author, plugin_name)
return await self.handler.get_rag_creation_schema(plugin_author, plugin_name)
async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().get_rag_retrieval_schema(plugin_author, plugin_name)
return await self.handler.get_rag_retrieval_schema(plugin_author, plugin_name)
async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]:
"""Notify plugin about KB creation."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
return await self.handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:
"""Notify plugin about KB deletion."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().rag_on_kb_delete(plugin_author, plugin_name, kb_id)
return await self.handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id)
async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:
"""Call plugin to retrieve knowledge.
@@ -1033,25 +905,25 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
retrieval_context: RetrievalContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
"""List all available Knowledge Engines from plugins.
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
"""
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return []
return await self._runtime_handler().list_knowledge_engines()
return await self.handler.list_knowledge_engines()
async def list_parsers(self) -> list[dict[str, Any]]:
"""List all available parsers from plugins."""
if not self.is_enable_plugin or not self._runtime_available():
if not self.is_enable_plugin:
return []
return await self._runtime_handler().list_parsers()
return await self.handler.list_parsers()
async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]:
"""Call plugin to parse a document."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
return await self._runtime_handler().parse_document(plugin_author, plugin_name, context_data, file_bytes)
return await self.handler.parse_document(plugin_author, plugin_name, context_data, file_bytes)
@@ -193,7 +193,6 @@ class ModelManager:
existing_embedding_models = {
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
}
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
created = 0
updated = 0
@@ -259,33 +258,6 @@ class ModelManager:
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
updated += 1
elif space_model.category == 'rerank':
existing = existing_rerank_models.get(space_model.uuid)
if existing is None:
await self.ap.rerank_models_service.create_rerank_model(
{
'uuid': space_model.uuid,
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
created += 1
elif existing.get('provider_uuid') == space_model_provider.uuid:
desired = {
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'prefered_ranking': space_model.featured_order,
}
if (
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
updated += 1
if created or updated:
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
@@ -13,151 +13,6 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
class _ThinkStripState:
"""Stateful filter that drops think blocks across chunks."""
_THINK_OPEN = '<think>'
_THINK_CLOSE = '</think>'
_LEGACY_OPEN = 'CRETIRE_REASONING_BEGINk'
_LEGACY_CLOSE = 'CRETIRE_REASONING_ENDk'
def __init__(self) -> None:
self._pairs: tuple[tuple[str, str], ...] = (
(self._THINK_OPEN, self._THINK_CLOSE),
(self._LEGACY_OPEN, self._LEGACY_CLOSE),
)
self._open_tags = tuple(open_tag for open_tag, _close_tag in self._pairs)
self._buf = ''
self._close_tag: str | None = None
self._pending_initial = True
def feed(self, chunk: str) -> str:
"""Feed a streaming delta and return user-visible content."""
if not chunk:
return chunk
text = self._buf + chunk
if self._close_tag is not None:
return self._consume_think_body(text)
return self._process_visible_text(text)
def flush(self) -> str:
"""Release buffered visible content when the stream ends."""
if self._close_tag is not None:
self._buf = ''
self._close_tag = None
return ''
pending, self._buf = self._buf, ''
self._close_tag = None
return pending
def _consume_think_body(self, text: str) -> str:
close_tag = self._close_tag
if close_tag is None:
return text
close_idx = text.find(close_tag)
if close_idx != -1:
self._close_tag = None
self._buf = ''
self._pending_initial = False
return self._process_visible_text(text[close_idx + len(close_tag) :])
self._buf = self._close_prefix(text, close_tag)
return ''
def _process_visible_text(self, text: str) -> str:
out: list[str] = []
index = 0
while index < len(text):
if self._pending_initial:
open_idx, open_tag, close_tag = self._find_next_open(text, index)
orphan_close_idx, orphan_close_tag = self._find_next_close(text, index)
if orphan_close_idx != -1 and (open_idx == -1 or orphan_close_idx < open_idx):
self._pending_initial = False
index = orphan_close_idx + len(orphan_close_tag)
continue
if open_idx == -1:
self._buf = text[index:]
return ''.join(out)
if open_idx > index:
self._pending_initial = False
out.append(text[index:open_idx])
index = open_idx
continue
open_idx, open_tag, close_tag = self._find_next_open(text, index)
if open_idx == -1:
emit_end = self._visible_emit_end(text, index)
out.append(text[index:emit_end])
if emit_end > index:
self._pending_initial = False
self._buf = text[emit_end:]
return ''.join(out)
out.append(text[index:open_idx])
if open_idx > index:
self._pending_initial = False
body_start = open_idx + len(open_tag)
close_idx = text.find(close_tag, body_start)
if close_idx == -1:
self._close_tag = close_tag
self._buf = self._close_prefix(text[body_start:], close_tag)
return ''.join(out)
self._pending_initial = False
index = close_idx + len(close_tag)
self._buf = ''
return ''.join(out)
def _find_next_open(self, text: str, start: int) -> tuple[int, str, str]:
best_idx = -1
best_open = ''
best_close = ''
for open_tag, close_tag in self._pairs:
idx = text.find(open_tag, start)
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_open = open_tag
best_close = close_tag
return best_idx, best_open, best_close
def _find_next_close(self, text: str, start: int) -> tuple[int, str]:
best_idx = -1
best_close = ''
for _open_tag, close_tag in self._pairs:
idx = text.find(close_tag, start)
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_close = close_tag
return best_idx, best_close
def _visible_emit_end(self, text: str, start: int) -> int:
visible = text[start:]
limit = min(len(visible), max(len(open_tag) for open_tag in self._open_tags) - 1)
for keep in range(limit, 0, -1):
suffix = visible[-keep:]
if any(open_tag.startswith(suffix) for open_tag in self._open_tags):
return len(text) - keep
return len(text)
@staticmethod
def _close_prefix(text: str, close_tag: str) -> str:
limit = min(len(text), len(close_tag) - 1)
for keep in range(limit, 0, -1):
suffix = text[-keep:]
if close_tag.startswith(suffix):
return suffix
return ''
class LiteLLMRequester(requester.ProviderAPIRequester):
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
@@ -382,25 +237,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return req_messages
_THINK_PATTERNS: tuple[str, ...] = (
r'^\s*(?:(?!<think>).)*?</think>\s*',
r'^\s*(?:(?!CRETIRE_REASONING_BEGINk).)*?CRETIRE_REASONING_ENDk\s*',
r'<think>.*?</think>',
r'CRETIRE_REASONING_BEGINk.*?CRETIRE_REASONING_ENDk',
)
@classmethod
def _strip_think(cls, content: str) -> str:
"""Strip chain-of-thought blocks from ``content``."""
if not content:
return content
import re
for pattern in cls._THINK_PATTERNS:
content = re.sub(pattern, '', content, flags=re.DOTALL)
return content.strip()
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
"""Process thinking/reasoning content.
@@ -412,12 +248,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
Returns:
Processed content string
"""
if remove_think and content:
content = self._strip_think(content)
# Extract and handle thinking tags
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
import re
if reasoning_content and not remove_think:
content = f'<think>\n{reasoning_content}\n</think>\n{content or ""}'.strip()
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
if remove_think:
# Remove thinking tags and their content from output
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
# else: preserve thinking content as-is
# Handle separate reasoning_content field
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
# because it's typically internal model reasoning, not user-visible thinking
return content or ''
@staticmethod
@@ -726,7 +570,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
chunk_idx = 0
role = 'assistant'
tool_call_state: dict[int, dict[str, typing.Any]] = {}
think_state = _ThinkStripState() if remove_think else None
try:
response = await acompletion(**args)
@@ -770,12 +613,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
# Use reasoning_content as the displayed content
delta_content = reasoning_content
if think_state is not None and delta_content:
delta_content = think_state.feed(delta_content)
if not delta_content:
chunk_idx += 1
continue
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
if chunk_idx == 0 and not delta_content and not tool_calls:
@@ -797,15 +634,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
if think_state is not None:
pending_content = think_state.flush()
if pending_content:
yield provider_message.MessageChunk(
role=role,
content=pending_content,
is_final=True,
)
except Exception as e:
self._handle_litellm_error(e)
@@ -943,21 +771,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
request_args = dict(extra_args)
rerank_url = request_args.pop('rerank_url', None)
rerank_path = request_args.pop('rerank_path', 'rerank')
payload: dict[str, typing.Any] = {
'model': model_name,
'query': query,
'documents': documents,
'top_n': top_n,
}
if request_args:
payload.update(request_args)
if extra_args:
payload.update(extra_args)
if not rerank_url:
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
rerank_url = f'{base_url}/rerank'
try:
async with httpx.AsyncClient(timeout=timeout) as client:
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
<rect width="64" height="64" rx="14" fill="#111827"/>
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

@@ -1,29 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: tokenlab-chat-completions
label:
en_US: TokenLab
zh_Hans: TokenLab
icon: tokenlab.svg
spec:
litellm_provider: openai
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.tokenlab.sh/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
alias: "tokenlab TokenLab gpt claude gemini deepseek qwen kimi minimax glm grok openai-compatible"
support_type:
- llm
provider_category: maas
+8 -45
View File
@@ -49,23 +49,12 @@ def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str)
class _StreamAccumulator:
"""Accumulate streamed content and fragmented OpenAI-style tool calls."""
def __init__(
self,
msg_sequence: int = 0,
initial_content: str | None = None,
remove_think: bool = False,
):
def __init__(self, msg_sequence: int = 0, initial_content: str | None = None):
self.tool_calls_map: dict[str, provider_message.ToolCall] = {}
self.msg_idx = 0
self.accumulated_content = initial_content or ''
self.last_role = 'assistant'
self.msg_sequence = msg_sequence
self.remove_think = remove_think
self._think_state = None
if remove_think:
from ..modelmgr.requesters.litellmchat import _ThinkStripState
self._think_state = _ThinkStripState()
def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None:
self.msg_idx += 1
@@ -74,10 +63,7 @@ class _StreamAccumulator:
self.last_role = msg.role
if msg.content:
content = msg.content
if self._think_state is not None:
content = self._think_state.feed(content)
self.accumulated_content += content
self.accumulated_content += msg.content
if msg.tool_calls:
for tool_call in msg.tool_calls:
@@ -93,14 +79,11 @@ class _StreamAccumulator:
if tool_call.function and tool_call.function.arguments:
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
if msg.is_final:
self._flush_think_state()
if self.msg_idx % 8 == 0 or msg.is_final:
self.msg_sequence += 1
return provider_message.MessageChunk(
role=self.last_role,
content=self._maybe_strip_think(self.accumulated_content),
content=self.accumulated_content,
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
is_final=msg.is_final,
msg_sequence=self.msg_sequence,
@@ -109,29 +92,13 @@ class _StreamAccumulator:
return None
def final_message(self) -> provider_message.MessageChunk:
self._flush_think_state()
return provider_message.MessageChunk(
role=self.last_role,
content=self._maybe_strip_think(self.accumulated_content),
content=self.accumulated_content,
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
msg_sequence=self.msg_sequence,
)
def _maybe_strip_think(self, content: str) -> str:
if not self.remove_think or not content:
return content
from ..modelmgr.requesters.litellmchat import LiteLLMRequester
return LiteLLMRequester._strip_think(content)
def _flush_think_state(self) -> None:
if self._think_state is None:
return
pending = self._think_state.flush()
if pending:
self.accumulated_content += pending
@runner.runner_class('local-agent')
class LocalAgentRunner(runner.RequestRunner):
@@ -481,7 +448,7 @@ class LocalAgentRunner(runner.RequestRunner):
except AttributeError:
is_stream = False
remove_think = ((query.pipeline_config.get('output') or {}).get('misc') or {}).get('remove-think', False)
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
# Build ordered candidate list (primary + fallbacks)
candidates = await self._get_model_candidates(query)
@@ -505,7 +472,7 @@ class LocalAgentRunner(runner.RequestRunner):
final_msg = msg
else:
# Streaming: invoke with fallback
stream_accumulator = _StreamAccumulator(msg_sequence=1, remove_think=remove_think)
stream_accumulator = _StreamAccumulator(msg_sequence=1)
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
query,
@@ -523,6 +490,7 @@ class LocalAgentRunner(runner.RequestRunner):
final_msg = stream_accumulator.final_message()
pending_tool_calls = final_msg.tool_calls
first_content = final_msg.content
if isinstance(final_msg, provider_message.MessageChunk):
first_end_sequence = final_msg.msg_sequence
@@ -605,14 +573,9 @@ class LocalAgentRunner(runner.RequestRunner):
)
if is_stream:
# Do NOT re-seed the accumulator with first_content:
# the previous round's text was already pushed to the
# platform adapter. Re-seeding would cause every
# subsequent round to repeat the entire opening line,
# which the platform then forwards as a duplicate message.
stream_accumulator = _StreamAccumulator(
msg_sequence=first_end_sequence,
remove_think=remove_think,
initial_content=first_content,
)
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
+38 -281
View File
@@ -3,17 +3,14 @@ from __future__ import annotations
import base64
import enum
import json
import math
import re
import time
import typing
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import timedelta
import traceback
from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy
import asyncio
import hashlib
import httpx
import uuid as uuid_module
@@ -29,13 +26,7 @@ from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
from .mcp_stdio import (
BoxStdioSessionRuntime,
MCPServerBoxConfig as MCPServerBoxConfig, # noqa: F401 - public re-export
MCPSessionErrorPhase,
_ColdStartRetry,
_get_default_memory_mb,
) # noqa: F401
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
# Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
@@ -53,7 +44,6 @@ MCP_RESOURCE_CONTEXT_MAX_BYTES = 96 * 1024
MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads'
MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links'
MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context'
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS = 300.0
TEXT_LIKE_MIME_TYPES = {
'application/json',
@@ -206,20 +196,6 @@ class _TransportReconnect(Exception):
"""
class _CallerReconnect(Exception):
"""Internal signal: a tool/resource call hit a server-expired session
(e.g. a UDP/HTTP MCP server's own session timeout) and asked the owning
lifecycle loop to rebuild the connection.
Like _TransportReconnect, this does NOT consume the fatal retry budget
the server-side timeout is expected, recurring behavior, not a failure.
"""
class MCPToolCallTimeoutError(TimeoutError):
"""An MCP tool call exceeded its configured per-server deadline."""
class RuntimeMCPSession:
"""运行时 MCP 会话"""
@@ -269,9 +245,6 @@ class RuntimeMCPSession:
self.ap = ap
self.enable = enable
self.session = None
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
server_config.get('tool_call_timeout_sec', MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS)
)
# Transient test sessions (created from the config page "test" button,
# which carry no persisted server UUID) must NOT share the live
@@ -292,12 +265,6 @@ class RuntimeMCPSession:
self._lifecycle_task = None
self._shutdown_event = asyncio.Event()
self._ready_event = asyncio.Event()
# Signaled by callers (invoke_mcp_tool / read_resource_envelope) when
# they see a server-expired session; the lifecycle loop reconnects and
# sets _reconnected_event so all callers waiting on this cycle resume
# together, instead of each racing to rebuild the session itself.
self._reconnect_event = asyncio.Event()
self._reconnected_event: asyncio.Event | None = None
# Set transiently when a WS transport drop should NOT stop the managed
# process (it will be re-attached on the next initialize()).
self._preserve_managed_process = False
@@ -312,39 +279,21 @@ class RuntimeMCPSession:
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
self.box_config = self._box_stdio_runtime.config
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
"""Return a safe tool-call timeout; zero explicitly disables it."""
try:
timeout = -1 if isinstance(value, bool) else float(value)
if timeout > 0:
# Validate the exact conversion used for each call here, so a
# finite-but-enormous manual config cannot fail at invocation.
timedelta(seconds=timeout)
except (TypeError, ValueError, OverflowError):
timeout = -1
if not math.isfinite(timeout) or timeout < 0:
self.ap.logger.warning(
f'Invalid MCP tool call timeout {value!r} for {self.server_name}; '
f'using {MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS:g} seconds'
)
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
return timeout
async def _init_stdio_python_server(self):
if self._uses_box_stdio():
await self._box_stdio_runtime.initialize()
return
# Box is configured but explicitly disabled. Refuse stdio MCP rather
# than silently falling through to host-stdio — the operator asked for
# the sandbox and the failure mode should be visible. An enabled Box
# that is reconnecting is handled above and waits for availability.
# Box is configured (ap.box_service exists) but currently unavailable
# (disabled by config or connection failed). Refuse stdio MCP rather
# than silently falling through to host-stdio — the operator asked
# for the sandbox and the failure mode should be visible.
#
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
# wrapper can distinguish a deliberately disabled Box from an enabled
# runtime that is still reconnecting. Keep the message itself short —
# the frontend ignores it for this phase.
# wrapper can short-circuit (retrying is pointless when Box is
# deliberately off) and the frontend can render a localized,
# actionable message instead of this raw RuntimeError. Keep the
# message itself short — the frontend ignores it for this phase.
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and not getattr(box_service, 'available', False):
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
@@ -480,19 +429,12 @@ class RuntimeMCPSession:
if self._uses_box_stdio():
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
done, pending = await asyncio.wait(
[shutdown_task, monitor_task, reconnect_task],
[shutdown_task, monitor_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
f'MCP session {self.server_name}: caller requested reconnect (server session expired)'
)
raise _CallerReconnect('Caller requested reconnect after session expiry')
for task in done:
if task is monitor_task and not self._shutdown_event.is_set():
# The monitor completed. This is EITHER the managed
@@ -520,20 +462,7 @@ class RuntimeMCPSession:
self.error_phase = MCPSessionErrorPhase.RUNTIME
raise Exception('Box managed process exited unexpectedly')
else:
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
done, pending = await asyncio.wait(
[shutdown_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
f'MCP session {self.server_name}: caller requested reconnect (server session expired)'
)
raise _CallerReconnect('Caller requested reconnect after session expiry')
await self._shutdown_event.wait()
except _ColdStartRetry:
# Cold-start in progress: set the preserve flag BEFORE the finally
@@ -592,43 +521,6 @@ class RuntimeMCPSession:
self.error_phase = None
await asyncio.sleep(1)
continue
except _CallerReconnect:
# A tool/resource call hit a server-expired session and asked us
# to rebuild the connection. Same treatment as _TransportReconnect:
# reconnect immediately WITHOUT consuming the fatal retry budget.
# Wake any callers waiting in _trigger_reconnect() regardless of
# outcome so they don't block for the full timeout.
reconnected_event = self._reconnected_event
if self._shutdown_event.is_set():
if reconnected_event is not None:
reconnected_event.set()
return
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
try:
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'remote':
await self._init_remote_server()
elif self.server_config['mode'] == 'sse':
await self._init_sse_server()
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
await self.refresh()
self.status = MCPSessionStatus.CONNECTED
self.ap.logger.info(f'MCP session {self.server_name} reconnected successfully after session expiry')
except Exception as reconnect_err:
self.status = MCPSessionStatus.ERROR
self.error_message = str(reconnect_err)
self.ap.logger.error(
f'MCP session {self.server_name}: reconnect after session expiry failed: '
f'{self._describe_exception(reconnect_err)}'
)
finally:
if reconnected_event is not None:
reconnected_event.set()
continue
except _ColdStartRetry as e:
# The managed process is alive but still cold-starting (e.g.
# `npx -y <pkg>` is still installing) and cannot yet answer the
@@ -648,29 +540,17 @@ class RuntimeMCPSession:
await asyncio.sleep(2)
continue
except Exception as e:
self.retry_count = attempt + 1
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
# BOX_UNAVAILABLE is a deliberate refusal, not a transient
# failure — retrying produces log spam and a misleading
# "Failed after N attempts" message. Surface it immediately.
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and getattr(box_service, 'enabled', True):
# Box is configured and may recover independently of
# this MCP session. Keep retrying without consuming the
# fatal budget; _wait_for_box_runtime() rate-limits the
# loop to one warning per startup timeout.
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(1)
continue
# Explicitly disabled Box is a deliberate refusal, not a
# transient failure. Surface it immediately without log
# spam or a misleading "Failed after N attempts" message.
self.retry_count = attempt + 1
self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self._ready_event.set()
return
self.retry_count = attempt + 1
if attempt >= self._MAX_RETRIES:
self.status = MCPSessionStatus.ERROR
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
@@ -743,47 +623,6 @@ class RuntimeMCPSession:
return True
return False
@staticmethod
def _is_session_terminated(exc: BaseException) -> bool:
"""Whether exc indicates the server-side session expired.
Long-lived MCP servers (notably UDP/HTTP transports) commonly enforce
their own session timeout (e.g. ~30 minutes); once it fires, any call
on the cached session raises this rather than a transport-level error.
"""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
msg = str(leaf).lower()
if 'session terminated' in msg or 'session expired' in msg:
return True
return False
_RECONNECT_WAIT_TIMEOUT = 30.0
async def _trigger_reconnect(self) -> bool:
"""Ask the owning lifecycle loop to rebuild the session and wait for it.
Concurrent callers that hit the timeout at the same time all signal
the same _reconnect_event and await the same _reconnected_event, so
the lifecycle loop reconnects once and every caller resumes together
instead of each caller racing to rebuild the session itself.
Returns True if reconnection succeeded within the timeout.
"""
if self._shutdown_event.is_set():
return False
if self._reconnected_event is None or self._reconnected_event.is_set():
self._reconnected_event = asyncio.Event()
reconnected_event = self._reconnected_event
self._reconnect_event.set()
try:
await asyncio.wait_for(reconnected_event.wait(), timeout=self._RECONNECT_WAIT_TIMEOUT)
return self.status == MCPSessionStatus.CONNECTED
except asyncio.TimeoutError:
self.ap.logger.warning(f'MCP session {self.server_name} reconnect timed out')
return False
_MONITOR_POLL_INTERVAL = 5
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
@@ -992,58 +831,21 @@ class RuntimeMCPSession:
arguments: dict,
query: pipeline_query.Query | None = None,
) -> list[provider_message.ContentElement]:
for attempt in range(2):
if not self.session:
raise Exception('MCP session is not connected')
if not self.session:
raise Exception('MCP session is not connected')
try:
read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
result = await self.session.call_tool(
tool_name,
arguments,
read_timeout_seconds=read_timeout,
)
except Exception as e:
if self._is_tool_call_timeout(e):
self.ap.logger.warning(
f'MCP tool {tool_name} on {self.server_name} timed out after '
f'{self.tool_call_timeout_sec:g} seconds'
)
raise MCPToolCallTimeoutError(
f"MCP tool '{tool_name}' on server '{self.server_name}' timed out after "
f'{self.tool_call_timeout_sec:g} seconds'
) from e
if attempt == 0 and self._is_session_terminated(e):
self.ap.logger.warning(
f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...'
)
if await self._trigger_reconnect():
continue
raise
if result.isError:
error_texts = []
for content in result.content:
if getattr(content, 'type', '') == 'text':
error_texts.append(content.text)
raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool')
result_contents: list[provider_message.ContentElement] = []
result = await self.session.call_tool(tool_name, arguments)
if result.isError:
error_texts = []
for content in result.content:
result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name))
return result_contents
if getattr(content, 'type', '') == 'text':
error_texts.append(content.text)
raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool')
raise Exception('MCP session is not connected')
@staticmethod
def _is_tool_call_timeout(exc: BaseException) -> bool:
"""Recognize the MCP SDK's per-request timeout without retrying it."""
return any(
isinstance(leaf, McpError)
and leaf.error.code == httpx.codes.REQUEST_TIMEOUT
and leaf.error.message.startswith('Timed out while waiting for response')
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
)
result_contents: list[provider_message.ContentElement] = []
for content in result.content:
result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name))
return result_contents
def get_tools(self) -> list[resource_tool.LLMTool]:
return self.functions
@@ -1108,21 +910,7 @@ class RuntimeMCPSession:
self._record_resource_read_trace(query, envelope)
return envelope
result = None
for attempt in range(2):
if not self.session:
raise Exception('MCP session is not connected')
try:
result = await self.session.read_resource(AnyUrl(uri))
break
except Exception as e:
if attempt == 0 and self._is_session_terminated(e):
self.ap.logger.warning(
f'MCP resource read on {self.server_name} got session terminated, triggering reconnect...'
)
if await self._trigger_reconnect():
continue
raise
result = await self.session.read_resource(AnyUrl(uri))
contents: list[dict] = []
total_bytes = 0
truncated_any = False
@@ -1277,34 +1065,15 @@ class RuntimeMCPSession:
return self._box_stdio_runtime.uses_box_stdio()
def _build_box_session_id(self) -> str:
# Compatible MCP servers share a session and remain isolated by
# process_id. A server with a different immutable resource profile gets
# another session; Docker/E2B cannot change memory/image/etc. after a
# session has been created.
config = self._box_stdio_runtime.config
default_memory = _get_default_memory_mb(self.ap)
profile = {
'image': config.image,
'network': config.network,
'host_path_mode': config.host_path_mode,
'cpus': config.cpus,
'memory_mb': config.memory_mb or default_memory,
'pids_limit': config.pids_limit,
'read_only_rootfs': (config.read_only_rootfs if config.read_only_rootfs is not None else False),
}
default_profile = {
'image': None,
'network': 'on',
'host_path_mode': 'ro',
'cpus': None,
'memory_mb': default_memory,
'pids_limit': None,
'read_only_rootfs': False,
}
if profile == default_profile:
return 'mcp-shared'
digest = hashlib.sha256(json.dumps(profile, sort_keys=True).encode('utf-8')).hexdigest()[:12]
return f'mcp-shared-{digest}'
# Both live servers and transient config-page tests share ONE Box
# session ('mcp-shared'). A test therefore reuses the already-running
# container (and, for an existing server, its live managed process)
# instead of paying a full per-test session cold-start + dependency
# bootstrap. Isolation between a test and the live servers is provided
# at the *process* level: each server/test has its own process_id and a
# test only ever stops its own process_id (see cleanup_session), so it
# never disturbs another server's process or the shared session itself.
return 'mcp-shared'
def _rewrite_path(self, path: str, host_path: str | None) -> str:
return self._box_stdio_runtime.rewrite_path(path, host_path)
@@ -1887,23 +1656,11 @@ class MCPLoader(loader.ToolLoader):
async def shutdown(self):
"""关闭所有工具"""
self.ap.logger.info('Shutting down all MCP sessions...')
hosted_tasks = [task for task in self._hosted_mcp_tasks if not task.done()]
for task in hosted_tasks:
task.cancel()
if hosted_tasks:
await asyncio.gather(*hosted_tasks, return_exceptions=True)
self._hosted_mcp_tasks.clear()
async def shutdown_session(server_name: str, session: RuntimeMCPSession) -> None:
for server_name, session in list(self.sessions.items()):
try:
await session.shutdown()
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
except Exception as e:
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
await asyncio.gather(
*(shutdown_session(server_name, session) for server_name, session in list(self.sessions.items()))
)
self.sessions.clear()
self.ap.logger.info('All MCP sessions shutdown complete')
@@ -185,10 +185,11 @@ class BoxStdioSessionRuntime:
box_service = getattr(self.ap, 'box_service', None)
if box_service is None:
return False
# An enabled Box service remains the required transport while it is
# reconnecting. initialize() waits for availability instead of
# permanently failing the MCP server or falling through to host stdio.
return bool(getattr(box_service, 'enabled', True))
# When Box is configured but currently unavailable (disabled or
# connection failed), do NOT silently fall through to host-stdio —
# that would bypass the sandbox the operator asked for. The caller
# is expected to refuse the stdio MCP server with a clear error.
return bool(getattr(box_service, 'available', False))
async def initialize(self) -> None:
await self._wait_for_box_runtime()
@@ -487,7 +488,7 @@ class BoxStdioSessionRuntime:
)
warned = True
if asyncio.get_running_loop().time() >= deadline:
self.owner.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
self.owner.error_phase = MCPSessionErrorPhase.SESSION_CREATE
raise Exception(f'Box runtime is not available after {int(timeout_sec)} seconds')
await asyncio.sleep(1)
@@ -57,7 +57,7 @@ class NativeToolLoader(loader.ToolLoader):
return await is_box_backend_available(self.ap)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not await self._is_sandbox_available():
if not self._is_sandbox_available():
return []
if self._tools is None:
self._tools = [
@@ -71,7 +71,7 @@ class NativeToolLoader(loader.ToolLoader):
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
return name in _ALL_TOOL_NAMES and self._is_sandbox_available()
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
if name == EXEC_TOOL_NAME:
@@ -652,9 +652,12 @@ else:
if callable(refresh_skill):
refresh_skill(selected_skill.get('name', ''))
async def _is_sandbox_available(self) -> bool:
"""Refresh backend availability so Box reconnects restore tool exposure."""
self._backend_available = await self._check_backend_available()
def _is_sandbox_available(self) -> bool:
"""Check if sandbox backend is available.
This checks the cached backend availability from initialization,
not just whether the box_service process is running.
"""
return bool(self._backend_available)
def _build_exec_tool(self) -> resource_tool.LLMTool:
@@ -49,27 +49,19 @@ class SkillToolLoader(loader.ToolLoader):
return await is_box_backend_available(self.ap)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not await self._is_available():
if not self._is_available():
return []
if not self._tools:
self._tools = [
self._build_activate_skill_tool(),
self._build_register_skill_tool(),
]
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return await self._is_available() and name in SKILL_TOOL_NAMES
return self._is_available() and name in SKILL_TOOL_NAMES
async def _is_available(self) -> bool:
def _is_available(self) -> bool:
"""Check if skill tools should be available.
Skill tools require both a skill manager and a sandbox backend.
"""
if not self._has_skill_manager():
return False
self._sandbox_available = await self._check_sandbox_available()
return self._sandbox_available
return self._has_skill_manager() and self._sandbox_available
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
if name == ACTIVATE_SKILL_TOOL_NAME:
-23
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import contextlib
import os
import sys
from typing import TYPE_CHECKING, Awaitable, Callable
@@ -28,8 +27,6 @@ class ManagedRuntimeConnector:
self.ap = ap
self.runtime_subprocess = None
self.runtime_subprocess_task = None
self._lifecycle_lock = asyncio.Lock()
self._closing = False
async def _start_runtime_subprocess(self, *args: str) -> None:
"""Launch a local runtime as a subprocess of the current Python interpreter.
@@ -89,23 +86,3 @@ class ManagedRuntimeConnector:
if self.runtime_subprocess_task is not None:
self.runtime_subprocess_task.cancel()
self.runtime_subprocess_task = None
async def _close_managed_subprocess(self) -> None:
"""Terminate, escalate, and reap the optional owned subprocess."""
process = self.runtime_subprocess
wait_task = self.runtime_subprocess_task
if process is not None and process.returncode is None:
with contextlib.suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
await process.wait()
if wait_task is not None and wait_task is not asyncio.current_task():
if not wait_task.done():
wait_task.cancel()
await asyncio.gather(wait_task, return_exceptions=True)
self.runtime_subprocess = None
self.runtime_subprocess_task = None
+1 -2
View File
@@ -119,8 +119,7 @@ class ValkeySearchVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application):
if not VALKEY_SEARCH_AVAILABLE:
raise ImportError(
'valkey-glide is not installed or is unavailable on this platform. '
"On Linux or macOS, install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'"
"valkey-glide is not installed. Install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'"
)
self.ap = ap
-5
View File
@@ -143,11 +143,6 @@ box:
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64 # Includes persistent sessions. New sessions fail explicitly when this cap is reached.
max_managed_processes: 64 # Maximum concurrently running stdio MCP / managed processes.
max_completed_processes: 256 # Global cap for retained exited-process diagnostics.
completed_process_retention_sec: 300 # Keep exited-process diagnostics before releasing memory.
local:
profile: 'default'
image: '' # Custom local sandbox image. Leave empty to use the profile default.
+5 -87
View File
@@ -303,60 +303,11 @@
'<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 17l2.5-3.5L13 17l2-2.5L18 17H8z"/></svg>';
// ========== State ==========
function createSessionId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
var bytes = new Uint8Array(16);
if (!window.crypto || typeof window.crypto.getRandomValues !== "function") {
throw new Error("Secure random number generation is unavailable");
}
window.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
var hex = Array.prototype.map
.call(bytes, function (value) {
return value.toString(16).padStart(2, "0");
})
.join("");
return (
hex.slice(0, 8) +
"-" +
hex.slice(8, 12) +
"-" +
hex.slice(12, 16) +
"-" +
hex.slice(16, 20) +
"-" +
hex.slice(20)
);
}
function getOrCreateSessionId() {
var storageKey = "langbot_embed_session_" + CONFIG.botUuid;
try {
var stored = window.sessionStorage.getItem(storageKey);
if (
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(
stored || "",
)
)
return stored;
var created = createSessionId();
window.sessionStorage.setItem(storageKey, created);
return created;
} catch (e) {
return createSessionId();
}
}
var state = {
isOpen: false,
isConnected: false,
ws: null,
connectionId: null,
sessionId: getOrCreateSessionId(),
reconnectAttempts: 0,
heartbeatTimer: null,
messages: [],
@@ -364,9 +315,6 @@
isStreaming: false,
streamingMsgId: null,
historyLoaded: false,
hasConnected: false,
messageVersion: 0,
historyReloadTimer: null,
pendingImage: null,
feedbackState: {},
};
@@ -525,9 +473,7 @@
"/api/v1/embed/" +
CONFIG.botUuid +
"/ws/connect?session_type=" +
CONFIG.sessionType +
"&session_id=" +
encodeURIComponent(state.sessionId);
CONFIG.sessionType;
try {
state.ws = new WebSocket(url);
@@ -574,8 +520,6 @@
case "connected":
state.isConnected = true;
state.connectionId = data.connection_id;
if (state.hasConnected) loadHistory(true);
state.hasConnected = true;
updateStatusDot();
updateSendBtn();
break;
@@ -643,7 +587,6 @@
if (existingIdx >= 0) {
state.messages[existingIdx] = msg;
state.messageVersion++;
updateMessageEl(existingIdx, msg);
} else {
addMessage(msg);
@@ -713,27 +656,16 @@
}
// ========== Message History ==========
function scheduleHistoryReload() {
if (state.historyReloadTimer) clearTimeout(state.historyReloadTimer);
state.historyReloadTimer = setTimeout(function () {
state.historyReloadTimer = null;
loadHistory(true);
}, 100);
}
function loadHistory(force) {
if (state.historyLoaded && !force) return;
function loadHistory() {
if (state.historyLoaded) return;
state.historyLoaded = true;
var messageVersion = state.messageVersion;
var url =
CONFIG.baseUrl +
"/api/v1/embed/" +
CONFIG.botUuid +
"/messages/" +
CONFIG.sessionType +
"?session_id=" +
encodeURIComponent(state.sessionId);
CONFIG.sessionType;
var headers = {};
if (state.sessionToken)
headers["Authorization"] = "Bearer " + state.sessionToken;
@@ -743,16 +675,6 @@
})
.then(function (json) {
if (json.code === 0 && json.data && json.data.messages) {
if (force && messageVersion !== state.messageVersion) {
scheduleHistoryReload();
return;
}
if (force) {
state.messages = [];
state.isStreaming = false;
state.streamingMsgId = null;
renderMessages();
}
var msgs = json.data.messages;
for (var i = 0; i < msgs.length; i++) {
addMessage(msgs[i], true);
@@ -771,16 +693,13 @@
"/api/v1/embed/" +
CONFIG.botUuid +
"/reset/" +
CONFIG.sessionType +
"?session_id=" +
encodeURIComponent(state.sessionId);
CONFIG.sessionType;
var headers = {};
if (state.sessionToken)
headers["Authorization"] = "Bearer " + state.sessionToken;
fetch(url, { method: "POST", headers: headers })
.then(function () {
state.messages = [];
state.messageVersion++;
state.isStreaming = false;
state.streamingMsgId = null;
state.historyLoaded = true;
@@ -794,7 +713,6 @@
// ========== UI Rendering ==========
function addMessage(msg, silent) {
state.messages.push(msg);
if (!silent) state.messageVersion++;
var el = createMessageEl(msg);
if (els.welcome) {
els.welcome.style.display = "none";
+5 -32
View File
@@ -15,7 +15,6 @@ from tests.factories import FakeApp
pytestmark = pytest.mark.integration
SESSION_ID = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
@pytest.fixture(scope='module')
@@ -192,10 +191,10 @@ class TestEmbedMessagesEndpoint:
"""Tests for messages endpoint."""
@pytest.mark.asyncio
async def test_get_messages_person_success(self, quart_test_client, fake_embed_app):
async def test_get_messages_person_success(self, quart_test_client):
"""GET messages/person returns messages."""
response = await quart_test_client.get(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person?session_id={SESSION_ID}',
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
@@ -203,30 +202,17 @@ class TestEmbedMessagesEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.get_websocket_messages.assert_called_with(
'test-pipeline-uuid', 'person', SESSION_ID
)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
"""GET messages/group returns messages."""
response = await quart_test_client.get(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_messages_requires_session_id(self, quart_test_client):
"""GET messages without a client session identifier returns 400."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_get_messages_invalid_session_type(self, quart_test_client):
"""GET messages with invalid session_type returns 400."""
@@ -243,29 +229,16 @@ class TestEmbedResetEndpoint:
"""Tests for session reset endpoint."""
@pytest.mark.asyncio
async def test_reset_session_person_success(self, quart_test_client, fake_embed_app):
async def test_reset_session_person_success(self, quart_test_client):
"""POST reset/person resets session."""
response = await quart_test_client.post(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person?session_id={SESSION_ID}',
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.reset_session.assert_called_with(
'test-pipeline-uuid', 'person', SESSION_ID
)
@pytest.mark.asyncio
async def test_reset_session_requires_session_id(self, quart_test_client):
"""POST reset without a client session identifier returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_reset_session_invalid_uuid(self, quart_test_client):
@@ -77,12 +77,11 @@ async def backend(valkey_config):
ValkeySearchVectorDatabase,
VALKEY_SEARCH_AVAILABLE,
)
from glide import ft
if not VALKEY_SEARCH_AVAILABLE:
pytest.skip('valkey-glide not installed')
from glide import ft
ap = _make_ap(valkey_config)
db = ValkeySearchVectorDatabase(ap)
client = await db._ensure_client()
@@ -777,44 +777,6 @@ class TestModelProviderServiceScanProviderModels:
assert len(result['models']) == 1
assert result['models'][0]['type'] == 'llm'
async def test_scan_provider_marks_existing_rerank_model(self):
"""Rerank scan results use the rerank service when computing already_added."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
ap.rerank_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'rerank-scan-uuid',
'name': 'New API',
'requester': 'new-api-chat-completions',
'base_url': 'https://new-api.example.com/v1',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.token_mgr.get_token.return_value = 'token'
runtime_provider.requester.scan_models = AsyncMock(
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
return_value=[{'name': 'Qwen3-Reranker-8B'}]
)
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
assert result['models'][0]['type'] == 'rerank'
assert result['models'][0]['already_added'] is True
async def test_scan_provider_not_implemented_raises_error(self):
"""Raises ValueError when scan not implemented."""
# Setup
@@ -693,7 +693,7 @@ class TestSpaceServiceGetModels:
'uuid': 'uuid-2',
'model_id': 'model-2',
'provider': 'provider-2',
'category': 'rerank',
'category': 'chat',
'status': 'active',
},
]
@@ -714,7 +714,6 @@ class TestSpaceServiceGetModels:
# Verify
assert len(result) == 2
assert result[1].category == 'rerank'
async def test_get_models_api_error(self):
"""Raises ValueError on API error."""
+1 -136
View File
@@ -1,13 +1,11 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from unittest.mock import Mock
import pytest
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot.pkg.box import connector as connector_module
from langbot.pkg.box.connector import BoxRuntimeConnector
@@ -106,136 +104,3 @@ def test_box_runtime_connector_dispose_terminates_subprocess(monkeypatch: pytest
ctrl_task.cancel.assert_called_once()
assert connector._handler_task is None
assert connector._ctrl_task is None
@pytest.mark.asyncio
async def test_box_runtime_connector_cleans_partial_transport_on_connect_failure(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock()))
connector._start_local_stdio = AsyncMock(side_effect=RuntimeError('bind failed'))
connector._stop_transport = AsyncMock()
connector._close_managed_subprocess = AsyncMock()
with pytest.raises(RuntimeError, match='bind failed'):
await connector.initialize()
assert connector._stop_transport.await_count == 2
connector._close_managed_subprocess.assert_awaited_once()
@pytest.mark.asyncio
async def test_box_runtime_connector_starts_heartbeat_after_reconnect(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
connector = BoxRuntimeConnector(make_app(Mock()))
connector._start_local_stdio = AsyncMock(side_effect=[RuntimeError('bind failed'), None])
connector._stop_transport = AsyncMock()
connector._close_managed_subprocess = AsyncMock()
with pytest.raises(RuntimeError, match='bind failed'):
await connector.initialize()
assert connector._heartbeat_task is None
await connector.reconnect()
assert connector._heartbeat_task is not None
assert not connector._heartbeat_task.done()
await connector.aclose()
@pytest.mark.asyncio
async def test_box_stdio_connection_does_not_capture_unconsumed_stderr(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
created = {}
class FakeHandler:
def __init__(self, connection):
self.release = asyncio.Event()
async def call_action(self, action, data):
return None
async def run(self):
await self.release.wait()
async def close(self):
self.release.set()
class FakeController:
def __init__(self, **kwargs):
created.update(kwargs)
self.process = SimpleNamespace(returncode=0)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module, 'Handler', FakeHandler)
monkeypatch.setattr(
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
FakeController,
)
connector = BoxRuntimeConnector(make_app(Mock()))
await connector.initialize()
assert created['capture_stderr'] is False
assert connector._handler is not None
await connector.aclose()
@pytest.mark.asyncio
async def test_box_disconnect_notifies_once_and_clears_handler(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
disconnect = AsyncMock()
class FakeHandler:
def __init__(self, connection):
pass
async def call_action(self, action, data):
return None
async def run(self):
return None
async def close(self):
return None
class FakeController:
def __init__(self, **kwargs):
self.process = SimpleNamespace(returncode=0)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module, 'Handler', FakeHandler)
monkeypatch.setattr(
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
FakeController,
)
connector = BoxRuntimeConnector(make_app(Mock()), runtime_disconnect_callback=disconnect)
await connector.initialize()
await asyncio.sleep(0)
disconnect.assert_awaited_once_with(connector)
assert connector._handler is None
await connector.aclose()
+1 -41
View File
@@ -325,50 +325,10 @@ async def test_box_service_dispose_schedules_shutdown_on_event_loop(monkeypatch:
service.dispose()
await asyncio.sleep(0)
connector.dispose.assert_not_called()
connector.dispose.assert_called_once()
service.shutdown.assert_awaited_once()
@pytest.mark.asyncio
async def test_box_service_shutdown_reaps_connector_when_runtime_rpc_is_offline(
monkeypatch: pytest.MonkeyPatch,
):
connector = Mock()
connector.client = Mock()
connector.client.shutdown = AsyncMock(side_effect=RuntimeError('offline'))
connector.aclose = AsyncMock()
monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector))
service = BoxService(make_app(Mock()))
await service.shutdown()
connector.client.shutdown.assert_awaited_once()
connector.aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
monkeypatch: pytest.MonkeyPatch,
):
app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
connector = Mock()
connector.reconnect = AsyncMock()
service._ensure_default_workspace = Mock()
service._purge_attachment_dirs = AsyncMock()
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
await service._reconnect_loop(connector)
connector.reconnect.assert_awaited_once()
service._ensure_default_workspace.assert_called_once()
service._purge_attachment_dirs.assert_awaited_once()
app.skill_mgr.reload_skills.assert_awaited_once()
assert service.available is True
@pytest.mark.asyncio
async def test_box_runtime_reuses_request_session():
logger = Mock()
+20 -34
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import signal
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
@@ -19,60 +18,47 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch
async def fake_make_app(loop):
captured_handler[signal.SIGINT](signal.SIGINT, None)
def fake_exit(code):
raise SystemExit(code)
monkeypatch.setattr(signal, 'signal', fake_signal)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.os, '_exit', fake_exit)
await boot.main(SimpleNamespace())
with pytest.raises(SystemExit) as exc_info:
await boot.main(SimpleNamespace())
assert exc_info.value.code == 0
@pytest.mark.asyncio
async def test_main_signal_handler_disposes_created_app(monkeypatch):
captured_handler = {}
app_inst = SimpleNamespace(shutdown_called=False)
app_inst = SimpleNamespace(disposed=False)
def fake_signal(sig, handler):
captured_handler[sig] = handler
async def shutdown():
app_inst.shutdown_called = True
def dispose():
app_inst.disposed = True
async def run():
captured_handler[signal.SIGINT](signal.SIGINT, None)
async def fake_make_app(loop):
app_inst.shutdown = shutdown
app_inst.dispose = dispose
app_inst.run = run
return app_inst
def fake_exit(code):
raise SystemExit(code)
monkeypatch.setattr(signal, 'signal', fake_signal)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.os, '_exit', fake_exit)
await boot.main(SimpleNamespace())
with pytest.raises(SystemExit) as exc_info:
await boot.main(SimpleNamespace())
assert app_inst.shutdown_called is True
@pytest.mark.asyncio
async def test_main_reports_app_run_failure_and_still_shuts_down(monkeypatch):
app_inst = SimpleNamespace(shutdown_called=False)
async def shutdown():
app_inst.shutdown_called = True
async def run():
raise RuntimeError('run failed')
async def fake_make_app(loop):
app_inst.shutdown = shutdown
app_inst.run = run
return app_inst
print_exc = Mock()
monkeypatch.setattr(signal, 'signal', lambda *_args: None)
monkeypatch.setattr(boot, 'make_app', fake_make_app)
monkeypatch.setattr(boot.traceback, 'print_exc', print_exc)
await boot.main(SimpleNamespace())
print_exc.assert_called_once()
assert app_inst.shutdown_called is True
assert exc_info.value.code == 0
assert app_inst.disposed is True
@@ -158,21 +158,17 @@ class TestCommandHandlerReal:
@pytest.mark.asyncio
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""A per-bot admin from the database is marked as admin in command events."""
"""Admin users get privilege level 2."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
admin_result = Mock()
admin_result.first.return_value = Mock()
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.instance_config.data = {'admins': ['person_12345']}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 12345
@@ -180,28 +176,23 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is True
@pytest.mark.asyncio
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""A launcher absent from the per-bot admin table is not an admin."""
"""Non-admin users get privilege level 1."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
admin_result = Mock()
admin_result.first.return_value = None
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.instance_config.data = {'admins': ['person_12345']}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 67890
@@ -209,7 +200,6 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is False
@@ -2,7 +2,6 @@ import pytest
import aiocqhttp
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from langbot.pkg.platform.sources.aiocqhttp import (
AiocqhttpAdapter,
AiocqhttpEventConverter,
@@ -16,72 +15,6 @@ async def _convert_single(component: platform_message.MessageComponent):
return message[0]
class _TestLogger:
def __init__(self):
self.messages = []
async def info(self, message):
self.messages.append(message)
def _make_adapter():
logger = _TestLogger()
adapter = AiocqhttpAdapter.model_construct(
config={},
logger=logger,
bot=aiocqhttp.CQHttp(),
on_websocket_connection_event_cache=[],
_listener_wrappers={},
)
adapter.bot.on_websocket_connection(adapter._on_websocket_connection)
return adapter, logger
def test_connection_listener_is_registered_once_for_multiple_message_listeners():
adapter, _ = _make_adapter()
async def callback(event, source_adapter):
return None
adapter.register_listener(platform_events.FriendMessage, callback)
adapter.register_listener(platform_events.GroupMessage, callback)
adapter.register_listener(platform_events.FeedbackEvent, callback)
assert len(adapter.bot._bus._subscribers['meta_event.lifecycle.connect']) == 1
@pytest.mark.asyncio
async def test_connection_listener_only_suppresses_exact_duplicates():
adapter, logger = _make_adapter()
first = aiocqhttp.Event({'self_id': 1001, 'time': 10})
duplicate = aiocqhttp.Event({'self_id': 1001, 'time': 10})
second = aiocqhttp.Event({'self_id': 2002, 'time': 20})
await adapter._on_websocket_connection(first)
await adapter._on_websocket_connection(duplicate)
await adapter._on_websocket_connection(second)
assert adapter.on_websocket_connection_event_cache == [first, second]
assert logger.messages == [
'WebSocket connection established, bot id: 1001',
'WebSocket connection established, bot id: 2002',
]
def test_unregister_listener_removes_registered_wrapper():
adapter, _ = _make_adapter()
async def callback(event, source_adapter):
return None
adapter.register_listener(platform_events.GroupMessage, callback)
assert len(adapter.bot._bus._subscribers['message.group']) == 1
adapter.unregister_listener(platform_events.GroupMessage, callback)
assert not adapter.bot._bus._subscribers['message.group']
@pytest.mark.asyncio
@pytest.mark.parametrize(
('payload', 'expected'),
@@ -1,39 +0,0 @@
import asyncio
import threading
import pytest
from langbot.pkg.platform.sources.lark import NonBlockingLarkWSClient
@pytest.mark.asyncio
async def test_lark_connection_url_lookup_does_not_block_main_event_loop(monkeypatch):
client = NonBlockingLarkWSClient('app-id', 'app-secret')
lookup_started = threading.Event()
release_lookup = threading.Event()
base_connect_urls: list[str] = []
def blocking_get_conn_url() -> str:
lookup_started.set()
if not release_lookup.wait(timeout=2):
raise TimeoutError('test did not release the connection lookup')
return 'wss://example.invalid/connect?device_id=device&service_id=service'
async def fake_base_connect(self):
base_connect_urls.append(self._get_conn_url())
monkeypatch.setattr(client, '_get_conn_url', blocking_get_conn_url)
monkeypatch.setattr('lark_oapi.ws.Client._connect', fake_base_connect)
connect_task = asyncio.create_task(client._connect())
await asyncio.wait_for(asyncio.to_thread(lookup_started.wait, 1), timeout=1)
# If the SDK's synchronous requests.post still runs on the event-loop
# thread, this sleep cannot complete until release_lookup is set.
await asyncio.wait_for(asyncio.sleep(0.01), timeout=0.1)
assert not connect_task.done()
release_lookup.set()
await asyncio.wait_for(connect_task, timeout=1)
assert base_connect_urls == ['wss://example.invalid/connect?device_id=device&service_id=service']
@@ -1,267 +0,0 @@
"""Regression tests for isolated embed-widget conversations."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
from langbot.pkg.platform.sources.websocket_manager import WebSocketConnectionManager, is_valid_session_id
@pytest.mark.asyncio
async def test_broadcast_only_reaches_connections_in_same_browser_session():
manager = WebSocketConnectionManager()
first = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='person',
session_id='session-a',
)
second = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='person',
session_id='session-b',
)
dashboard = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='person',
)
await manager.broadcast_to_pipeline(
'pipeline-1',
{'type': 'response'},
session_type='person',
session_id='session-a',
)
assert await first.send_queue.get() == {'type': 'response'}
assert second.send_queue.empty()
assert dashboard.send_queue.empty()
await manager.broadcast_to_pipeline(
'pipeline-1',
{'type': 'dashboard-response'},
session_type='person',
session_id=None,
)
assert await dashboard.send_queue.get() == {'type': 'dashboard-response'}
assert first.send_queue.empty()
assert second.send_queue.empty()
@pytest.mark.asyncio
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
connection = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='person',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
received = []
async def listener(event, _callback_adapter):
received.append(event)
adapter.listeners = {platform_events.FriendMessage: listener}
await adapter.handle_websocket_message(
connection,
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
)
await asyncio.sleep(0)
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
@pytest.mark.asyncio
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
connection = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='group',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
received = []
async def listener(event, _callback_adapter):
received.append(event)
adapter.listeners = {platform_events.GroupMessage: listener}
await adapter.handle_websocket_message(
connection,
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
)
await asyncio.sleep(0)
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
assert received[0].sender.group.id == f'websocketgroup_pipeline-1:{session_id}'
dashboard = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='group',
)
await adapter.handle_websocket_message(
dashboard,
{'stream': False, 'message': [{'type': 'Plain', 'text': 'dashboard'}]},
None,
)
await asyncio.sleep(0)
assert received[1].sender.id == f'websocket_{dashboard.connection_id}'
assert received[1].sender.group.id == 'websocketgroup'
@pytest.mark.asyncio
async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch):
manager = WebSocketConnectionManager()
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-2',
session_type='person',
session_id=session_id,
)
connection = await manager.add_connection(
websocket=Mock(),
pipeline_uuid='pipeline-1',
session_type='person',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
message_source = Mock()
message_source.sender.id = f'websocket_pipeline-1:{session_id}'
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
assert await adapter._get_connection_from_target(f'websocketgroup_pipeline-1:{session_id}') is connection
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is connection
await manager.remove_connection(connection.connection_id)
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is None
def test_session_ids_must_be_canonical_random_uuids():
assert is_valid_session_id('31c0f2e9-b115-4ee6-8f15-3e624d6456b1')
assert not is_valid_session_id('session-a')
assert not is_valid_session_id('00000000-0000-0000-0000-000000000000')
def test_history_read_does_not_allocate_unknown_session():
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
assert adapter.get_websocket_messages('pipeline-1', 'person', 'missing-session') == []
assert adapter.websocket_person_session.message_lists == {}
def test_history_and_reset_are_scoped_to_browser_session():
matching_provider_session = Mock(
launcher_type=Mock(value='person'),
launcher_id='websocket_pipeline-1:session-a',
)
matching_group_provider_session = Mock(
launcher_type=Mock(value='group'),
launcher_id='websocketgroup_pipeline-1:session-a',
)
other_session = Mock(
launcher_type=Mock(value='person'),
launcher_id='websocket_pipeline-1:session-b',
)
ap = Mock()
ap.sess_mgr.session_list = [
matching_provider_session,
matching_group_provider_session,
other_session,
]
adapter = WebSocketAdapter.model_construct(
ap=ap,
logger=AsyncMock(),
)
adapter.websocket_person_session = Mock()
adapter.websocket_group_session = Mock()
session_a = [
WebSocketMessage(
id=1,
role='user',
content='private-a',
message_chain=[],
timestamp='2026-07-13T00:00:00',
)
]
session_b = [
WebSocketMessage(
id=1,
role='user',
content='private-b',
message_chain=[],
timestamp='2026-07-13T00:00:00',
)
]
histories = {
'pipeline-1:session-a': session_a,
'pipeline-1:session-b': session_b,
}
stream_indexes = {
'pipeline-1:session-a': {'response-a': 0},
'pipeline-1:session-b': {'response-b': 0},
}
adapter.websocket_person_session.get_message_list.side_effect = histories.__getitem__
adapter.websocket_person_session.message_lists = histories
adapter.websocket_person_session.stream_message_indexes = stream_indexes
adapter.websocket_group_session.message_lists = {}
adapter.websocket_group_session.stream_message_indexes = {}
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-a')[0]['content'] == 'private-a'
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-b')[0]['content'] == 'private-b'
adapter.reset_session('pipeline-1', 'person', 'session-a')
assert histories['pipeline-1:session-a'] == []
assert stream_indexes['pipeline-1:session-a'] == {}
assert histories['pipeline-1:session-b'] == session_b
assert stream_indexes['pipeline-1:session-b'] == {'response-b': 0}
assert ap.sess_mgr.session_list == [matching_group_provider_session, other_session]
adapter.reset_session('pipeline-1', 'group', 'session-a')
assert ap.sess_mgr.session_list == [other_session]
def test_widget_sends_stable_session_id_to_all_conversation_endpoints():
widget_path = Path(__file__).parents[3] / 'src/langbot/templates/embed/widget.js'
widget = widget_path.read_text(encoding='utf-8')
assert 'langbot_embed_session_' in widget
assert 'window.sessionStorage' in widget
assert 'window.localStorage' not in widget
assert 'session_id=' in widget
assert widget.count('encodeURIComponent(state.sessionId)') >= 3
assert 'loadHistory(true)' in widget
assert 'messageVersion !== state.messageVersion' in widget
assert 'scheduleHistoryReload();' in widget
@@ -62,25 +62,6 @@ class TestListPlugins:
assert result == []
@pytest.mark.asyncio
async def test_returns_empty_while_runtime_is_disconnected(self):
connector = create_mock_connector()
result = await connector.list_plugins()
assert result == []
@pytest.mark.asyncio
async def test_returns_empty_after_managed_transport_disconnects(self):
connector = create_mock_connector()
connector.handler = AsyncMock()
connector._transport_task = Mock()
result = await connector.list_plugins()
assert result == []
connector.handler.list_plugins.assert_not_called()
@pytest.mark.asyncio
async def test_calls_handler_list_plugins(self):
"""Test that handler.list_plugins is called."""
@@ -313,12 +294,6 @@ class TestListKnowledgeEngines:
connector.handler.list_knowledge_engines.assert_called_once()
assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
@pytest.mark.asyncio
async def test_returns_empty_while_runtime_is_disconnected(self):
connector = create_mock_connector()
assert await connector.list_knowledge_engines() == []
class TestListParsers:
"""Tests for list_parsers method."""
@@ -356,12 +331,6 @@ class TestListParsers:
connector.handler.list_parsers.assert_called_once()
assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
@pytest.mark.asyncio
async def test_returns_empty_while_runtime_is_disconnected(self):
connector = create_mock_connector()
assert await connector.list_parsers() == []
class TestCallParser:
"""Tests for call_parser method."""
+2 -137
View File
@@ -1,20 +1,15 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.plugin import connector as connector_module
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(data={'plugin': {'enable': True}, 'space': {'url': ''}}),
)
app = SimpleNamespace(instance_config=SimpleNamespace(data={'plugin': {'enable': True}}))
return PluginRuntimeConnector(app, AsyncMock())
@@ -35,133 +30,3 @@ async def test_ping_plugin_runtime_delegates_to_connected_handler():
assert result == 'pong'
connector.handler.ping.assert_awaited_once()
@pytest.mark.asyncio
async def test_stop_transport_tolerates_handler_callback_removing_attribute():
connector = make_connector()
class Handler:
async def close(self):
del connector.handler
connector.handler = Handler()
await connector._stop_transport()
assert not hasattr(connector, 'handler')
@pytest.mark.asyncio
async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
monkeypatch: pytest.MonkeyPatch,
):
connector = make_connector()
created = {}
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
self.release = asyncio.Event()
async def ping(self):
return None
async def set_runtime_config(self, **kwargs):
return None
async def run(self):
await self.release.wait()
async def close(self):
self.release.set()
class FakeController:
def __init__(self, **kwargs):
created.update(kwargs)
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
monkeypatch.setattr(
connector_module.platform,
'use_websocket_to_connect_plugin_runtime',
lambda: False,
)
monkeypatch.setattr(
connector_module.handler,
'RuntimeConnectionHandler',
FakeRuntimeHandler,
)
monkeypatch.setattr(
connector_module.stdio_client_controller,
'StdioClientController',
FakeController,
)
await connector.initialize()
assert created['capture_stderr'] is False
assert connector._connected.is_set()
await connector.aclose()
@pytest.mark.asyncio
async def test_runtime_disconnect_notifies_once_and_clears_handler(
monkeypatch: pytest.MonkeyPatch,
):
disconnect = AsyncMock()
connector = PluginRuntimeConnector(make_connector().ap, disconnect)
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
self.disconnect_callback = disconnect_callback
async def ping(self):
return None
async def set_runtime_config(self, **kwargs):
return None
async def run(self):
await self.disconnect_callback(self)
async def close(self):
return None
class FakeController:
def __init__(self, **kwargs):
pass
async def run(self, callback):
await callback(object())
async def close(self):
return None
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
monkeypatch.setattr(
connector_module.platform,
'use_websocket_to_connect_plugin_runtime',
lambda: False,
)
monkeypatch.setattr(
connector_module.handler,
'RuntimeConnectionHandler',
FakeRuntimeHandler,
)
monkeypatch.setattr(
connector_module.stdio_client_controller,
'StdioClientController',
FakeController,
)
await connector.initialize()
await asyncio.sleep(0)
disconnect.assert_awaited_once_with(connector)
assert not hasattr(connector, 'handler')
await connector.aclose()
@@ -279,122 +279,6 @@ class TestInvokeLLMStreamUsage:
assert query.variables['_stream_usage']['total_tokens'] == 12
@pytest.mark.asyncio
async def test_stream_removes_leading_think_across_chunks(self):
"""A leading think block split across chunks must be removed."""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('minimax-m3', 'test-api-key')
chunks = [
self._make_chunk(content='<thi'),
self._make_chunk(content='nk>hidden'),
self._make_chunk(content=' reasoning</thi'),
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
]
async def _aiter(*args, **kwargs):
for c in chunks:
yield c
query = Mock(spec=pipeline_query.Query)
query.variables = {}
messages = [provider_message.Message(role='user', content='Hi')]
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
collected = [
chunk
async for chunk in requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
remove_think=True,
)
]
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
@pytest.mark.asyncio
async def test_stream_removes_initial_orphan_think_close(self):
"""Initial reasoning content without an open tag is removed until </think>."""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('minimax-m3', 'test-api-key')
chunks = [
self._make_chunk(content='hidden reasoning'),
self._make_chunk(content=' still hidden</thi'),
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
]
async def _aiter(*args, **kwargs):
for c in chunks:
yield c
query = Mock(spec=pipeline_query.Query)
query.variables = {}
messages = [provider_message.Message(role='user', content='Hi')]
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
collected = [
chunk
async for chunk in requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
remove_think=True,
)
]
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
@pytest.mark.asyncio
async def test_stream_removes_non_leading_think_content(self):
"""A think block in the answer body is removed with its content."""
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('gpt-4o', 'test-api-key')
chunks = [
self._make_chunk(content='Use <think>x</think> as an XML-like example.', finish_reason='stop'),
]
async def _aiter(*args, **kwargs):
for c in chunks:
yield c
query = Mock(spec=pipeline_query.Query)
query.variables = {}
messages = [provider_message.Message(role='user', content='Hi')]
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
collected = [
chunk
async for chunk in requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
remove_think=True,
)
]
assert ''.join(chunk.content or '' for chunk in collected) == 'Use as an XML-like example.'
@pytest.mark.asyncio
async def test_stream_tool_call_delta_missing_id_and_name(self):
"""LiteLLM may stream tool-call argument deltas with id/name set to None."""
@@ -598,38 +482,6 @@ class TestProcessThinkingContent:
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'The answer is 42.'
def test_remove_leading_think_tag(self):
"""Test removing a leading <think> block when remove_think=True"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = '<think>Let me think...</think> The answer is 42.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'The answer is 42.'
def test_remove_non_leading_think_tag(self):
"""Test removing <think> and its content in the answer body"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = 'Use <think>example</think> in the document.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'Use in the document.'
def test_remove_initial_orphan_think_close(self):
"""Test removing leading reasoning content when only </think> is visible"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = 'hidden reasoning</think> Visible answer.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'Visible answer.'
def test_remove_multiple_think_tags(self):
"""Test removing multiple <think> blocks"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = '<think>hidden</think> Keep <think>example</think>.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'Keep .'
def test_preserve_thinking_markers(self):
"""Test preserving thinking markers when remove_think=False"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
@@ -639,20 +491,6 @@ class TestProcessThinkingContent:
assert 'CRETIRE_REASONING_BEGINk' in result
assert 'The answer is 42.' in result
def test_preserve_reasoning_content_when_remove_think_false(self):
"""Test showing separate reasoning_content when remove_think=False"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=False)
assert result == '<think>\nLet me think...\n</think>\nThe answer is 42.'
def test_hide_reasoning_content_when_remove_think_true(self):
"""Test hiding separate reasoning_content when remove_think=True"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=True)
assert result == 'The answer is 42.'
def test_empty_content(self):
"""Test empty content"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
@@ -1147,46 +985,6 @@ class TestInvokeRerank:
assert results[0]['relevance_score'] == 1.0
assert results[1]['relevance_score'] == 0.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
('model_extra_args', 'expected_url'),
[
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
],
)
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://gateway.example.com/v1/',
'custom_llm_provider': 'openai',
},
)
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
model.model_entity.extra_args = model_extra_args
mock_resp = Mock()
mock_resp.raise_for_status = Mock()
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch('httpx.AsyncClient', return_value=mock_client):
await requester.invoke_rerank(model=model, query='query', documents=['document'])
assert mock_client.post.call_args.args[0] == expected_url
payload = mock_client.post.call_args.kwargs['json']
assert payload == {
'model': 'Qwen3-Reranker-8B',
'query': 'query',
'documents': ['document'],
'top_n': 1,
}
class TestConvertMessages:
"""Test _convert_messages method"""
@@ -1,217 +0,0 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
class RecordingProvider:
"""Non-streaming provider that returns a tool-call on round 1 and plain text on round 2."""
def __init__(self):
self.requests: list[dict] = []
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
self.requests.append(
{
'messages': list(messages),
'funcs': list(funcs),
'remove_think': remove_think,
}
)
if len(self.requests) == 1:
return provider_message.Message(
role='assistant',
content='Let me check that.',
tool_calls=[
provider_message.ToolCall(
id='call-1',
type='function',
function=provider_message.FunctionCall(
name='exec',
arguments=json.dumps({'command': "python -c 'print(42)'"}),
),
)
],
)
return provider_message.Message(
role='assistant',
content='The result is 42.',
)
class RecordingStreamProvider:
"""Streaming provider that returns a tool-call on round 1 and plain text on round 2."""
def __init__(self):
self.stream_requests: list[dict] = []
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
self.stream_requests.append(
{
'messages': list(messages),
'funcs': list(funcs),
'remove_think': remove_think,
}
)
async def _stream():
if len(self.stream_requests) == 1:
yield provider_message.MessageChunk(
role='assistant',
content='Let me check that.',
tool_calls=[
provider_message.ToolCall(
id='call-1',
type='function',
function=provider_message.FunctionCall(
name='exec',
arguments=json.dumps({'command': "python -c 'print(42)'"}),
),
)
],
is_final=True,
)
return
yield provider_message.MessageChunk(
role='assistant',
content='The result is 42.',
is_final=True,
)
return _stream()
def make_query() -> pipeline_query.Query:
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=False)
return pipeline_query.Query.model_construct(
query_id='no-dup-query',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_chain=[],
message_event=None,
adapter=adapter,
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config={
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
},
'output': {'misc': {'remove-think': False}},
},
prompt=SimpleNamespace(messages=[]),
messages=[],
user_message=provider_message.Message(
role='user',
content='What is the answer?',
),
use_funcs=[SimpleNamespace(name='exec')],
use_llm_model_uuid='test-model-uuid',
variables={},
)
def _make_app(provider) -> SimpleNamespace:
model = SimpleNamespace(
provider=provider,
model_entity=SimpleNamespace(
uuid='test-model-uuid',
name='test-model',
abilities=['func_call'],
extra_args={},
),
)
return SimpleNamespace(
logger=Mock(),
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
tool_mgr=SimpleNamespace(
execute_func_call=AsyncMock(
return_value={
'session_id': 'no-dup-query',
'backend': 'podman',
'status': 'completed',
'ok': True,
'exit_code': 0,
'stdout': '42',
'stderr': '',
'duration_ms': 10,
}
)
),
rag_mgr=SimpleNamespace(),
box_service=SimpleNamespace(
get_system_guidance=Mock(return_value='sandbox guidance'),
),
skill_mgr=SimpleNamespace(
get_skills_for_pipeline=AsyncMock(return_value=[]),
detect_skill_activation=AsyncMock(return_value=None),
build_activation_prompt=Mock(return_value=None),
),
)
@pytest.mark.asyncio
async def test_localagent_non_streaming_no_duplicate():
"""Non-streaming: round-2 content must not contain round-1 text."""
provider = RecordingProvider()
app = _make_app(provider)
runner = LocalAgentRunner(app, pipeline_config={})
query = make_query()
results = [message async for message in runner.run(query)]
# Expect: assistant (tool call) -> tool -> assistant (final answer)
assert [message.role for message in results] == ['assistant', 'tool', 'assistant']
final_message = results[-1]
assert final_message.content == 'The result is 42.'
assert 'Let me check that.' not in final_message.content
@pytest.mark.asyncio
async def test_localagent_streaming_no_duplicate():
"""Streaming: round-2 content must not be re-seeded with round-1 text.
Regression test for the bug where _StreamAccumulator was initialized with
initial_content=first_content, causing every subsequent round to repeat
the entire opening line.
"""
provider = RecordingStreamProvider()
app = _make_app(provider)
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=True)
query = make_query()
query.adapter = adapter
runner = LocalAgentRunner(app, pipeline_config={})
results = [message async for message in runner.run(query)]
# All yielded messages should be MessageChunk in streaming mode
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
# The last assistant chunk is the final answer for round 2
assistant_chunks = [m for m in results if m.role == 'assistant']
assert len(assistant_chunks) >= 2
final_chunk = assistant_chunks[-1]
assert final_chunk.content == 'The result is 42.'
assert 'Let me check that.' not in final_chunk.content
@@ -163,51 +163,6 @@ def test_stream_accumulator_merges_fragmented_tool_call_arguments():
assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}'
def test_stream_accumulator_strips_leading_think_from_tool_round_content():
accumulator = _StreamAccumulator(
msg_sequence=3,
initial_content='I will search for LangBot.',
remove_think=True,
)
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='<thi')) is None
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='nk>hidden')) is None
emitted = accumulator.add(
provider_message.MessageChunk(
role='assistant',
content=' reasoning</think>Here is the answer.',
is_final=True,
)
)
assert emitted is not None
assert emitted.content == 'I will search for LangBot.Here is the answer.'
assert '<think>' not in emitted.content
assert 'hidden reasoning' not in emitted.content
def test_stream_accumulator_strips_initial_orphan_think_close_from_tool_round_content():
accumulator = _StreamAccumulator(
msg_sequence=3,
initial_content='I will search for LangBot.',
remove_think=True,
)
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='hidden reasoning')) is None
emitted = accumulator.add(
provider_message.MessageChunk(
role='assistant',
content=' still hidden</think>Here is the answer.',
is_final=True,
)
)
assert emitted is not None
assert emitted.content == 'I will search for LangBot.Here is the answer.'
assert '</think>' not in emitted.content
assert 'hidden reasoning' not in emitted.content
@pytest.mark.asyncio
async def test_localagent_uses_exec_for_exact_calculation():
provider = RecordingProvider()
@@ -417,7 +417,7 @@ class TestBuildBoxSessionPayload:
payload = s._build_box_session_payload('session-123')
assert payload['image'] == 'node:20'
assert payload['cpus'] == 2.0
assert payload['memory_mb'] == 1024
assert payload["memory_mb"] == 1024
assert payload['pids_limit'] == 256
def test_none_fields_excluded(self, mcp_module):
@@ -680,55 +680,14 @@ class TestGetRuntimeInfoDict:
# ... but are isolated by distinct process_ids within that session.
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
def test_different_resource_profiles_use_different_box_sessions(self, mcp_module):
ap = _make_ap()
ap.box_service.available = True
default = _make_session(
mcp_module,
{
'name': 'default',
'uuid': 'default-uuid',
'mode': 'stdio',
'command': 'uvx',
'args': ['mcp-server-time'],
},
ap=ap,
)
constrained = _make_session(
mcp_module,
{
'name': 'constrained',
'uuid': 'constrained-uuid',
'mode': 'stdio',
'command': 'uvx',
'args': ['mcp-server-time'],
'box': {'memory_mb': 2048},
},
ap=ap,
)
assert default._build_box_session_id() == 'mcp-shared'
assert constrained._build_box_session_id().startswith('mcp-shared-')
assert constrained._build_box_session_id() != default._build_box_session_id()
writable = _make_session(
mcp_module,
{
'name': 'writable',
'uuid': 'writable-uuid',
'mode': 'stdio',
'command': 'uvx',
'args': ['mcp-server-time'],
'box': {'host_path_mode': 'rw'},
},
ap=ap,
)
assert writable._build_box_session_id() != default._build_box_session_id()
def test_stdio_session_waits_for_enabled_box_when_temporarily_unavailable(self, mcp_module):
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
"""Policy: when Box is configured but unavailable (disabled in config
OR connection failed), stdio MCP servers are NOT treated as box-stdio.
``_init_stdio_python_server`` will raise a clear refusal at start
time; until then, the runtime info simply omits box_session_id so the
UI can render the disabled state cleanly."""
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
s = _make_session(
mcp_module,
{
@@ -741,125 +700,9 @@ class TestGetRuntimeInfoDict:
ap=ap,
)
info = s.get_runtime_info_dict()
assert info['box_session_id'] == 'mcp-shared'
assert info['box_enabled'] is True
def test_stdio_session_refuses_when_box_is_disabled(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = False
s = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
info = s.get_runtime_info_dict()
assert 'box_session_id' not in info
assert 'box_enabled' not in info
@pytest.mark.asyncio
async def test_stdio_session_waits_until_box_reconnects(self, mcp_module, monkeypatch):
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
'box': {'startup_timeout_sec': 1},
},
ap=ap,
)
async def reconnect_box(_delay):
ap.box_service.available = True
monkeypatch.setattr(mcp_stdio_module.asyncio, 'sleep', reconnect_box)
await session._box_stdio_runtime._wait_for_box_runtime()
assert ap.box_service.available is True
@pytest.mark.asyncio
async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
attempts = 0
async def lifecycle():
nonlocal attempts
attempts += 1
if attempts == 1:
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
raise RuntimeError('Box runtime is not available after 1 seconds')
session._lifecycle_loop = lifecycle
sleep = AsyncMock()
monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
await session._lifecycle_loop_with_retry()
assert attempts == 2
assert session.retry_count == 0
sleep.assert_awaited_once_with(1)
@pytest.mark.asyncio
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = False
session = _make_session(
mcp_module,
{
'name': 'test',
'uuid': 'test-uuid',
'mode': 'stdio',
'command': 'python',
'args': [],
},
ap=ap,
)
attempts = 0
async def lifecycle():
nonlocal attempts
attempts += 1
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
raise RuntimeError('box_disabled_in_config')
session._lifecycle_loop = lifecycle
await session._lifecycle_loop_with_retry()
assert attempts == 1
assert session.status == mcp_module.MCPSessionStatus.ERROR
assert session.retry_count == 1
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
ap = _make_ap()
del ap.box_service
@@ -12,7 +12,7 @@ import pytest
from aiohttp import web
from mcp import types as mcp_types
from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
class _TransportProbe:
@@ -65,25 +65,6 @@ class _TransportProbe:
},
}
)
if method == 'tools/call':
tool_name = message.get('params', {}).get('name')
if tool_name == 'hang':
return web.Response(status=202)
return web.json_response(
{
'jsonrpc': '2.0',
'id': message['id'],
'result': {
'content': [
{
'type': 'text',
'text': 'healthy',
}
],
'isError': False,
},
}
)
return web.Response(status=202)
return web.Response(status=self.streamable_status)
@@ -145,22 +126,11 @@ async def _transport_server(streamable_status: int | None):
await runner.cleanup()
def _session(
url: str,
*,
timeout: float = 2,
tool_call_timeout_sec: float = 300,
) -> RuntimeMCPSession:
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
app = cast(Any, SimpleNamespace(logger=Mock()))
return RuntimeMCPSession(
'remote-transport-test',
{
'uuid': 'srv-1',
'mode': 'remote',
'url': url,
'timeout': timeout,
'tool_call_timeout_sec': tool_call_timeout_sec,
},
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
True,
app,
)
@@ -194,24 +164,6 @@ async def test_remote_transport_real_streamable_http_success_keeps_session_usabl
await _close_session(session)
@pytest.mark.asyncio
async def test_remote_transport_tool_timeout_does_not_poison_session():
async with _transport_server(200) as (probe, url):
session = _session(url, tool_call_timeout_sec=0.05)
try:
await session._init_remote_server()
with pytest.raises(MCPToolCallTimeoutError, match='timed out after 0.05 seconds'):
await session.invoke_mcp_tool('hang', {})
result = await session.invoke_mcp_tool('health_check', {})
assert result[0].text == 'healthy'
assert probe.streamable_messages.count('tools/call') == 2
finally:
await _close_session(session)
@pytest.mark.asyncio
@pytest.mark.parametrize('status_code', [400, 404, 405])
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
@@ -1,25 +1,20 @@
from __future__ import annotations
import asyncio
import base64
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from langbot.pkg.provider.tools.loaders.mcp import (
MCP_RESOURCE_CONTEXT_QUERY_KEY,
MCP_RESOURCE_TRACE_QUERY_KEY,
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS,
MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE,
MCPLoader,
MCPSessionStatus,
MCPToolCallTimeoutError,
RuntimeMCPSession,
)
from langbot.pkg.telemetry import features as telemetry_features
@@ -66,111 +61,6 @@ def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
def _tool_result(text: str = 'ok') -> mcp_types.CallToolResult:
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type='text', text=text)],
isError=False,
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_uses_configurable_request_timeout():
session = RuntimeMCPSession(
'slow-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 900,
},
True,
_app(),
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
result = await session.invoke_mcp_tool('render_video', {'quality': 'high'})
assert result[0].text == 'ok'
session.session.call_tool.assert_awaited_once_with(
'render_video',
{'quality': 'high'},
read_timeout_seconds=timedelta(seconds=900),
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_zero_timeout_disables_request_deadline():
session = RuntimeMCPSession(
'unbounded-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 0,
},
True,
_app(),
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
await session.invoke_mcp_tool('long_job', {})
session.session.call_tool.assert_awaited_once_with(
'long_job',
{},
read_timeout_seconds=None,
)
@pytest.mark.asyncio
async def test_invoke_mcp_tool_timeout_is_not_retried_and_session_remains_usable():
session = RuntimeMCPSession(
'recoverable-tools',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': 5,
},
True,
_app(),
)
timeout = McpError(
mcp_types.ErrorData(
code=httpx.codes.REQUEST_TIMEOUT,
message='Timed out while waiting for response to ClientRequest. Waited 5 seconds.',
)
)
call_tool = AsyncMock(side_effect=[timeout, _tool_result('recovered')])
session.session = SimpleNamespace(call_tool=call_tool)
with pytest.raises(
MCPToolCallTimeoutError,
match="MCP tool 'long_job' on server 'recoverable-tools' timed out after 5 seconds",
):
await session.invoke_mcp_tool('long_job', {})
assert call_tool.await_count == 1
second_result = await session.invoke_mcp_tool('health_check', {})
assert second_result[0].text == 'recovered'
assert call_tool.await_count == 2
@pytest.mark.parametrize('invalid_timeout', [-1, float('inf'), 1e300, True, 'not-a-number'])
def test_invalid_tool_call_timeout_falls_back_to_default(invalid_timeout):
ap = _app()
session = RuntimeMCPSession(
'invalid-timeout',
{
'uuid': 'srv-1',
'mode': 'remote',
'tool_call_timeout_sec': invalid_timeout,
},
True,
ap,
)
assert session.tool_call_timeout_sec == MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
ap.logger.warning.assert_called_once()
@pytest.mark.asyncio
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
session = RuntimeMCPSession(
@@ -431,42 +321,3 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
docs.session.read_resource.assert_awaited_once()
other.session.read_resource.assert_not_called()
@pytest.mark.asyncio
async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_concurrently():
loader = MCPLoader(_app())
hosted_cancelled = asyncio.Event()
async def pending_host():
try:
await asyncio.Event().wait()
finally:
hosted_cancelled.set()
hosted_task = asyncio.create_task(pending_host())
await asyncio.sleep(0)
loader._hosted_mcp_tasks = [hosted_task]
started: set[str] = set()
all_started = asyncio.Event()
class Session:
def __init__(self, name: str):
self.name = name
async def shutdown(self):
started.add(self.name)
if len(started) == 2:
all_started.set()
await all_started.wait()
loader.sessions = {'one': Session('one'), 'two': Session('two')}
await asyncio.wait_for(loader.shutdown(), timeout=1)
assert hosted_cancelled.is_set()
assert hosted_task.cancelled()
assert started == {'one', 'two'}
assert loader._hosted_mcp_tasks == []
assert loader.sessions == {}
@@ -8,8 +8,7 @@ and error handling without calling real LLM APIs.
from __future__ import annotations
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from unittest.mock import Mock
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr import requester
@@ -63,48 +62,6 @@ async def test_model_manager_skips_space_sync_when_disabled(mock_app_for_modelmg
app.space_service.get_models.assert_not_called()
@pytest.mark.asyncio
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
"""Space rerank entries are discovered and persisted under the shared provider."""
app = mock_app_for_modelmgr
provider = persistence_model.ModelProvider(
uuid='space-provider',
name='LangBot Space',
requester='space-chat-completions',
base_url='https://api.langbot.cloud/v1',
api_keys=['space-key'],
)
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
app.space_service.get_models = AsyncMock(
return_value=[
SimpleNamespace(
uuid='rerank-model-uuid',
model_id='Qwen3-Reranker-8B',
category='rerank',
featured_order=10,
)
]
)
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.rerank_models_service = AsyncMock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.sync_new_models_from_space()
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
{
'uuid': 'rerank-model-uuid',
'name': 'Qwen3-Reranker-8B',
'provider_uuid': 'space-provider',
'extra_args': {},
'prefered_ranking': 10,
},
preserve_uuid=True,
)
# ============================================================================
# Model Loading Tests
# ============================================================================
@@ -458,25 +458,6 @@ class TestSkillToolLoader:
assert await loader.has_tool('activate') is True
assert await loader.has_tool('register_skill') is True
@pytest.mark.asyncio
async def test_tools_reappear_after_box_backend_recovers(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap()
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=False,
get_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = SkillToolLoader(ap)
await loader.initialize()
assert await loader.get_tools() == []
ap.box_service.available = True
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill']
class TestNativeToolLoaderSkillPaths:
@pytest.mark.asyncio
@@ -151,21 +151,6 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
assert await loader.has_tool(tool_name) is True
@pytest.mark.asyncio
async def test_native_tool_loader_refreshes_after_box_recovers():
box_service = SimpleNamespace(
available=False,
get_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
await loader.initialize()
assert await loader.get_tools() == []
box_service.available = True
assert [tool.name for tool in await loader.get_tools()] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
# ── read/write/edit file tool tests ─────────────────────────────
@@ -357,13 +357,10 @@ class TestCredentialsBuild:
cred_calls.append(kwargs)
return ('CRED', kwargs)
# These names are absent when the optional valkey-glide dependency is
# unavailable (for example, on Windows), so allow the test doubles to
# create them on the module.
monkeypatch.setattr(mod, 'GlideClient', _FakeClient, raising=False)
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials, raising=False)
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw, raising=False)
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k), raising=False)
monkeypatch.setattr(mod, 'GlideClient', _FakeClient)
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials)
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw)
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k))
return backend, created, cred_calls, warnings
async def test_username_without_password_fails_closed(self, monkeypatch):
Generated
+10 -10
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.6"
version = "4.10.5"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2084,7 +2084,7 @@ dependencies = [
{ name = "tiktoken" },
{ name = "urllib3" },
{ name = "uv" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
{ name = "valkey-glide" },
{ name = "websockets" },
]
@@ -2124,7 +2124,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.4.17" },
{ name = "langbot-plugin", specifier = "==0.4.13" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2173,7 +2173,7 @@ requires-dist = [
{ name = "tiktoken", specifier = ">=0.9.0" },
{ name = "urllib3", specifier = ">=2.7.0" },
{ name = "uv", specifier = ">=0.11.15" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "valkey-glide", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
@@ -2189,7 +2189,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.4.17"
version = "0.4.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2210,9 +2210,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/96/336d7ac97ff5a9c413e7aaf0c329a7047ec91dec5fa4f9e0b1d0abb57d0e/langbot_plugin-0.4.17.tar.gz", hash = "sha256:b1539444f16568c0b3244f68c498ac9498c56e2c20f12e220e6ee735b8d6b5c2", size = 347080, upload-time = "2026-07-23T09:23:55.941Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/98/ac372a238e59894ac54189cb220a3fb75da48f319881eeb49d583712549a/langbot_plugin-0.4.17-py3-none-any.whl", hash = "sha256:6fd6c9d7e0583f04659f023a233c4201cb4c450d1ef696e79fa1e338933ee156", size = 229881, upload-time = "2026-07-23T09:23:54.582Z" },
{ url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" },
]
[[package]]
@@ -5991,9 +5991,9 @@ name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
{ name = "anyio" },
{ name = "protobuf" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
-2
View File
@@ -6,7 +6,6 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test:unit": "node --test tests/unit/*.test.mjs",
"test:e2e": "playwright test",
"lint": "eslint .",
"format": "prettier --write ."
@@ -32,7 +31,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
-9
View File
@@ -24,9 +24,6 @@ dependencies:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.1)
'@fingerprintjs/fingerprintjs':
specifier: ^4.6.2
version: 4.6.2
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.71.1)
@@ -425,12 +422,6 @@ packages:
levn: 0.4.1
dev: true
/@fingerprintjs/fingerprintjs@4.6.2:
resolution: {integrity: sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw==}
dependencies:
tslib: 2.8.1
dev: false
/@floating-ui/core@1.7.4:
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
dependencies:
@@ -15,7 +15,6 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -632,9 +631,12 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record<string, unknown>,
const initialFinalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
);
onSubmitRef.current?.(initialFinalValues);
@@ -649,9 +651,12 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record<string, unknown>,
const finalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, object>;
@@ -1,25 +0,0 @@
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
export type DynamicFormSaveValueSpec = Pick<
IDynamicFormItemSchema,
'default' | 'name' | 'type'
>;
/**
* Build the value snapshot emitted to parent forms for persistence.
* Only single-line string fields trim surrounding whitespace; multiline text
* and every other dynamic form field type preserve their original values.
*/
export function normalizeDynamicFormValuesForSave(
specs: readonly DynamicFormSaveValueSpec[],
formValues: Record<string, unknown>,
): Record<string, unknown> {
return specs.reduce<Record<string, unknown>>((values, spec) => {
const value = formValues[spec.name] ?? spec.default;
values[spec.name] =
spec.type === 'string' && typeof value === 'string'
? value.trim()
: value;
return values;
}, {});
}
@@ -12,7 +12,6 @@ import {
} from '@/components/ui/form';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import { extractI18nObject } from '@/i18n/I18nProvider';
/**
@@ -151,9 +150,12 @@ export default function N8nAuthFormComponent({
// Emit initial form values on mount so the parent form's
// initializedStagesRef registers this stage (matches DynamicFormComponent).
const formValues = form.getValues();
const initialFinalValues = normalizeDynamicFormValuesForSave(
itemConfigList,
formValues as Record<string, unknown>,
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
);
onSubmitRef.current?.(initialFinalValues);
previousInitialValues.current = initialFinalValues as Record<
@@ -169,9 +171,12 @@ export default function N8nAuthFormComponent({
// 获取完整的表单值,确保包含所有默认值
const formValues = form.getValues();
const finalValues = normalizeDynamicFormValuesForSave(
itemConfigList,
formValues as Record<string, unknown>,
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
);
onSubmitRef.current?.(finalValues);
@@ -435,10 +435,6 @@ const getFormSchema = (t: TFunction) =>
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(30),
tool_call_timeout_sec: z
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.nonnegative({ message: t('mcp.timeoutNonNegative') })
.default(300),
ssereadtimeout: z
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
@@ -478,7 +474,6 @@ const getFormSchema = (t: TFunction) =>
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number;
tool_call_timeout_sec: number;
ssereadtimeout: number;
};
@@ -540,7 +535,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '',
args: [],
timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300,
extra_args: [],
...initialDraftRef.current,
@@ -615,7 +609,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '',
args: [],
timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300,
extra_args: [],
...initialDraftRef.current,
@@ -694,16 +687,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: '',
args: [],
timeout: 30,
tool_call_timeout_sec: 300,
ssereadtimeout: 300,
extra_args: [],
};
if (typeof server.extra_args.tool_call_timeout_sec === 'number') {
formValues.tool_call_timeout_sec =
server.extra_args.tool_call_timeout_sec;
}
let newExtraArgs: {
key: string;
type: 'string' | 'number' | 'boolean';
@@ -783,7 +770,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
url: value.url!,
headers,
timeout: value.timeout,
tool_call_timeout_sec: value.tool_call_timeout_sec,
},
};
} else {
@@ -800,7 +786,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
command: value.command!,
args: value.args?.map((arg) => arg.value) || [],
env,
tool_call_timeout_sec: value.tool_call_timeout_sec,
},
};
}
@@ -850,7 +835,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
headers: Object.fromEntries(
formExtraArgs.map((arg) => [arg.key, arg.value]),
),
@@ -862,7 +846,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
env: Object.fromEntries(
formExtraArgs.map((arg) => [arg.key, arg.value]),
),
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
};
}
@@ -1064,30 +1047,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
)}
/>
<FormField
control={form.control}
name="tool_call_timeout_sec"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.toolCallTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step={1}
placeholder="300"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
{t('mcp.toolCallTimeoutDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'remote' && (
<>
<FormField
@@ -1,18 +0,0 @@
import {
AppWindow,
AudioWaveform,
Book,
FileText,
Hash,
Wrench,
type LucideIcon,
} from 'lucide-react';
export const pluginComponentIconMap: Record<string, LucideIcon> = {
Tool: Wrench,
EventListener: AudioWaveform,
Command: Hash,
KnowledgeEngine: Book,
Parser: FileText,
Page: AppWindow,
};
@@ -1,7 +1,7 @@
import { Fragment } from 'react';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { pluginComponentIconMap } from './PluginComponentIcons';
export default function PluginComponentList({
components,
@@ -18,6 +18,14 @@ export default function PluginComponentList({
t: TFunction;
responsive?: boolean;
}) {
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
return (
@@ -26,12 +34,11 @@ export default function PluginComponentList({
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
const ComponentIcon = pluginComponentIconMap[kind];
return (
<Fragment key={kind}>
{useBadge && (
<Badge variant="outline" className="flex items-center gap-1">
{ComponentIcon && <ComponentIcon className="w-5 h-5" />}
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
@@ -45,7 +52,7 @@ export default function PluginComponentList({
{!useBadge && (
<div className="flex flex-row items-center justify-start gap-[0.2rem]">
{ComponentIcon && <ComponentIcon className="w-5 h-5" />}
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
@@ -127,7 +127,7 @@ function MarketPageContent({
// Per-format extension counts shown next to the type filter options.
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
const [sortOption, setSortOption] = useState<string>(
() => loadMarketFilters().sortOption ?? 'hot_score_desc',
() => loadMarketFilters().sortOption ?? 'install_count_desc',
);
// Persist filter conditions so they survive navigation / reload.
@@ -154,12 +154,6 @@ function MarketPageContent({
// 排序选项
const sortOptions: SortOption[] = [
{
value: 'hot_score_desc',
label: t('market.sort.hottest'),
sortBy: 'hot_score',
sortOrder: 'DESC',
},
{
value: 'created_at_desc',
label: t('market.sort.recentlyAdded'),
@@ -217,7 +211,7 @@ function MarketPageContent({
const option = sortOptions.find((opt) => opt.value === sortOption);
return option
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
: { sortBy: 'hot_score', sortOrder: 'DESC' };
: { sortBy: 'install_count', sortOrder: 'DESC' };
}, [sortOption]);
// 将API响应转换为VO对象
@@ -239,7 +233,6 @@ function MarketPageContent({
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count || 0,
likeCount: plugin.like_count || 0,
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
@@ -40,7 +40,6 @@ function pluginToVO(
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
likeCount: plugin.like_count || 0,
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
@@ -1,118 +0,0 @@
import FingerprintJS, { type LoadOptions } from '@fingerprintjs/fingerprintjs';
import { getCloudServiceClient } from '@/app/infra/http';
const MARKETPLACE_LIKE_CHANGED_EVENT = 'langbot-marketplace-like-changed';
export interface MarketplaceLikeChange {
key: string;
liked: boolean;
likeCount: number;
}
let fingerprintPromise: Promise<string> | undefined;
let likedKeysPromise: Promise<Set<string>> | undefined;
export function marketplaceExtensionKey(
type: string | undefined,
author: string,
name: string,
): string {
return `${type || 'plugin'}:${author}/${name}`;
}
export function getMarketplaceFingerprint(): Promise<string> {
if (!fingerprintPromise) {
fingerprintPromise = FingerprintJS.load({
monitoring: false,
} as LoadOptions & { monitoring: boolean })
.then((agent) => agent.get())
.then((result) => result.visitorId)
.catch((error) => {
fingerprintPromise = undefined;
throw error;
});
}
return fingerprintPromise;
}
async function loadLikedKeys(): Promise<Set<string>> {
if (!likedKeysPromise) {
likedKeysPromise = Promise.all([
getMarketplaceFingerprint(),
getCloudServiceClient(),
])
.then(([fingerprint, client]) =>
client.getMarketplaceLikedExtensions(fingerprint),
)
.then((data) => {
const keys = new Set<string>();
for (const ref of data.extensions || []) {
keys.add(`${ref.type}:${ref.extension_id}`);
}
return keys;
})
.catch((error) => {
likedKeysPromise = undefined;
throw error;
});
}
return likedKeysPromise;
}
export async function getMarketplaceExtensionLiked(
type: string | undefined,
author: string,
name: string,
): Promise<boolean> {
const likedKeys = await loadLikedKeys();
return likedKeys.has(marketplaceExtensionKey(type, author, name));
}
export async function toggleMarketplaceExtensionLike(
type: string | undefined,
author: string,
name: string,
liked: boolean,
): Promise<MarketplaceLikeChange> {
const extensionType = type || 'plugin';
const [fingerprint, likedKeys, client] = await Promise.all([
getMarketplaceFingerprint(),
loadLikedKeys(),
getCloudServiceClient(),
]);
const result = await client.setMarketplaceExtensionLike(
extensionType,
author,
name,
fingerprint,
liked,
);
const key = marketplaceExtensionKey(extensionType, author, name);
if (result.liked) {
likedKeys.add(key);
} else {
likedKeys.delete(key);
}
const change: MarketplaceLikeChange = {
key,
liked: result.liked,
likeCount: result.like_count,
};
window.dispatchEvent(
new CustomEvent<MarketplaceLikeChange>(MARKETPLACE_LIKE_CHANGED_EVENT, {
detail: change,
}),
);
return change;
}
export function subscribeMarketplaceLikeChanges(
listener: (change: MarketplaceLikeChange) => void,
): () => void {
const handler = (event: Event) => {
listener((event as CustomEvent<MarketplaceLikeChange>).detail);
};
window.addEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
return () =>
window.removeEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
}
@@ -3,7 +3,7 @@ import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
import { Info, Package, ExternalLink } from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -11,13 +11,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import {
getMarketplaceExtensionLiked,
marketplaceExtensionKey,
subscribeMarketplaceLikeChanges,
toggleMarketplaceExtensionLike,
} from '../marketplace-likes';
export default function PluginMarketCardComponent({
cardVO,
@@ -32,9 +25,6 @@ export default function PluginMarketCardComponent({
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(cardVO.likeCount);
const [isLiking, setIsLiking] = useState(false);
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
@@ -62,37 +52,6 @@ export default function PluginMarketCardComponent({
setIconFailed(!cardVO.iconURL);
}, [cardVO.iconURL]);
useEffect(() => {
setLikeCount(cardVO.likeCount);
}, [cardVO.likeCount]);
useEffect(() => {
let cancelled = false;
getMarketplaceExtensionLiked(cardVO.type, cardVO.author, cardVO.pluginName)
.then((value) => {
if (!cancelled) setLiked(value);
})
.catch(() => {
// The count still renders if the viewer-state request is unavailable.
});
return () => {
cancelled = true;
};
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
useEffect(() => {
const key = marketplaceExtensionKey(
cardVO.type,
cardVO.author,
cardVO.pluginName,
);
return subscribeMarketplaceLikeChanges((change) => {
if (change.key !== key) return;
setLiked(change.liked);
setLikeCount(change.likeCount);
});
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
@@ -130,29 +89,6 @@ export default function PluginMarketCardComponent({
onInstall?.(cardVO);
};
const handleLikeClick = async (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (isLiking) return;
setIsLiking(true);
try {
const change = await toggleMarketplaceExtensionLike(
cardVO.type,
cardVO.author,
cardVO.pluginName,
!liked,
);
setLiked(change.liked);
setLikeCount(change.likeCount);
} catch {
toast.error(t('market.likeFailed'));
} finally {
setIsLiking(false);
}
};
return (
<div
role="button"
@@ -161,10 +97,7 @@ export default function PluginMarketCardComponent({
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
onClick={handleInstallClick}
onKeyDown={(event) => {
if (
event.target === event.currentTarget &&
(event.key === 'Enter' || event.key === ' ')
) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleInstallClick();
}
@@ -244,26 +177,6 @@ export default function PluginMarketCardComponent({
</div>
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
title={t(liked ? 'market.unlike' : 'market.like')}
aria-label={t(liked ? 'market.unlike' : 'market.like')}
aria-pressed={liked}
disabled={isLiking}
className="h-7 min-w-7 gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30"
onClick={handleLikeClick}
>
{isLiking ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Heart
className={`h-3.5 w-3.5 ${liked ? 'fill-red-500 text-red-500' : ''}`}
/>
)}
<span className="text-xs tabular-nums">{likeCount}</span>
</Button>
<Button
type="button"
variant="ghost"
@@ -5,7 +5,6 @@ export interface IPluginMarketCardVO {
label: string;
description: string;
installCount: number;
likeCount?: number;
iconURL: string;
githubURL: string;
version: string;
@@ -23,7 +22,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
iconURL: string;
githubURL: string;
installCount: number;
likeCount: number;
version: string;
components?: Record<string, number>;
tags?: string[];
@@ -37,7 +35,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.iconURL = prop.iconURL;
this.githubURL = prop.githubURL;
this.installCount = prop.installCount;
this.likeCount = prop.likeCount ?? 0;
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
-4
View File
@@ -519,21 +519,18 @@ export interface MCPServerExtraArgsSSE {
headers: Record<string, string>;
timeout: number;
ssereadtimeout: number;
tool_call_timeout_sec?: number;
}
export interface MCPServerExtraArgsStdio {
command: string;
args: string[];
env: Record<string, string>;
tool_call_timeout_sec?: number;
}
export interface MCPServerExtraArgsHttp {
url: string;
headers: Record<string, string>;
timeout: number;
tool_call_timeout_sec?: number;
}
// "remote" mode: the user only supplies a URL; the backend auto-detects the
@@ -543,7 +540,6 @@ export interface MCPServerExtraArgsRemote {
url: string;
headers?: Record<string, string>;
timeout?: number;
tool_call_timeout_sec?: number;
}
export enum MCPSessionStatus {
@@ -48,8 +48,6 @@ export interface PluginV4 {
repository: string;
tags: string[];
install_count: number;
like_count?: number;
hot_score?: number;
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
+4 -32
View File
@@ -117,32 +117,6 @@ export class CloudServiceClient extends BaseHttpClient {
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
}
public getMarketplaceLikedExtensions(
fingerprint: string,
): Promise<{ extensions: Array<{ type: string; extension_id: string }> }> {
return this.post('/api/v1/marketplace/extensions/likes/status', {
fingerprint,
});
}
public setMarketplaceExtensionLike(
type: string,
author: string,
name: string,
fingerprint: string,
liked: boolean,
): Promise<{
type: string;
extension_id: string;
liked: boolean;
like_count: number;
}> {
return this.put(
`/api/v1/marketplace/extensions/${encodeURIComponent(type)}/${encodeURIComponent(author)}/${encodeURIComponent(name)}/like`,
{ fingerprint, liked },
);
}
private async searchMarketplaceExtensionsLegacy(data: {
query?: string;
page: number;
@@ -154,8 +128,6 @@ export class CloudServiceClient extends BaseHttpClient {
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
const query = data.query || '';
const legacySortBy =
data.sort_by === 'hot_score' ? 'install_count' : data.sort_by;
if (
data.type_filter === 'plugin' ||
@@ -167,7 +139,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
legacySortBy,
data.sort_by,
data.sort_order,
data.component_filter,
data.tags_filter,
@@ -185,7 +157,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
legacySortBy,
data.sort_by,
data.sort_order,
undefined,
data.tags_filter,
@@ -195,7 +167,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
legacySortBy,
data.sort_by,
data.sort_order,
undefined,
data.tags_filter,
@@ -205,7 +177,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
legacySortBy,
data.sort_by,
data.sort_order,
undefined,
data.tags_filter,
-7
View File
@@ -700,7 +700,6 @@ const enUS = {
notFound: 'Plugin information not found',
sortBy: 'Sort by',
sort: {
hottest: 'Most Popular',
recentlyAdded: 'Recently Added',
recentlyUpdated: 'Recently Updated',
mostDownloads: 'Most Downloads',
@@ -708,9 +707,6 @@ const enUS = {
},
downloads: 'downloads',
download: 'Download',
like: 'Like',
unlike: 'Unlike',
likeFailed: 'Failed to update like. Please try again.',
repository: 'Repository',
downloadFailed: 'Download failed',
noReadme: 'This plugin does not provide README documentation',
@@ -801,9 +797,6 @@ const enUS = {
url: 'URL',
headers: 'Headers',
timeout: 'Timeout',
toolCallTimeout: 'Tool call timeout (seconds)',
toolCallTimeoutDescription:
'Maximum wait for one tool call. Set to 0 for no timeout. Defaults to 300 seconds.',
addArgument: 'Add Argument',
addEnvVar: 'Add Environment Variable',
addHeader: 'Add Header',
-7
View File
@@ -713,7 +713,6 @@ const esES = {
notFound: 'No se encontró la información del plugin',
sortBy: 'Ordenar por',
sort: {
hottest: 'Más populares',
recentlyAdded: 'Añadidos recientemente',
recentlyUpdated: 'Actualizados recientemente',
mostDownloads: 'Más descargas',
@@ -721,9 +720,6 @@ const esES = {
},
downloads: 'descargas',
download: 'Descargar',
like: 'Me gusta',
unlike: 'Ya no me gusta',
likeFailed: 'No se pudo actualizar el Me gusta. Inténtalo de nuevo.',
repository: 'Repositorio',
downloadFailed: 'Error en la descarga',
noReadme: 'Este plugin no proporciona documentación README',
@@ -815,9 +811,6 @@ const esES = {
url: 'URL',
headers: 'Encabezados',
timeout: 'Tiempo de espera',
toolCallTimeout: 'Tiempo de espera de herramienta (segundos)',
toolCallTimeoutDescription:
'Espera máxima para una llamada de herramienta. Use 0 para no limitar. El valor predeterminado es 300 segundos.',
addArgument: 'Añadir argumento',
addEnvVar: 'Añadir variable de entorno',
addHeader: 'Añadir encabezado',
-7
View File
@@ -706,7 +706,6 @@ const jaJP = {
notFound: 'プラグイン情報が見つかりません',
sortBy: '並び順',
sort: {
hottest: '人気順',
recentlyAdded: '最近追加',
recentlyUpdated: '最近更新',
mostDownloads: 'ダウンロード数多',
@@ -714,9 +713,6 @@ const jaJP = {
},
downloads: '回ダウンロード',
download: 'ダウンロード',
like: 'いいね',
unlike: 'いいねを解除',
likeFailed: 'いいねを更新できませんでした。もう一度お試しください。',
repository: 'リポジトリ',
downloadFailed: 'ダウンロード失敗',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
@@ -807,9 +803,6 @@ const jaJP = {
url: 'URL',
headers: 'ヘッダー',
timeout: 'タイムアウト',
toolCallTimeout: 'ツール呼び出しタイムアウト(秒)',
toolCallTimeoutDescription:
'1 回のツール呼び出しの最大待機時間です。0 で無制限、既定値は 300 秒です。',
addArgument: '引数を追加',
addEnvVar: '環境変数を追加',
addHeader: 'ヘッダーを追加',
-7
View File
@@ -711,7 +711,6 @@ const ruRU = {
notFound: 'Информация о плагине не найдена',
sortBy: 'Сортировать по',
sort: {
hottest: 'По популярности',
recentlyAdded: 'Недавно добавленные',
recentlyUpdated: 'Недавно обновлённые',
mostDownloads: 'Больше всего загрузок',
@@ -719,9 +718,6 @@ const ruRU = {
},
downloads: 'загрузок',
download: 'Скачать',
like: 'Нравится',
unlike: 'Убрать отметку',
likeFailed: 'Не удалось обновить отметку. Повторите попытку.',
repository: 'Репозиторий',
downloadFailed: 'Ошибка загрузки',
noReadme: 'Этот плагин не предоставляет документацию README',
@@ -812,9 +808,6 @@ const ruRU = {
url: 'URL',
headers: 'Заголовки',
timeout: 'Таймаут',
toolCallTimeout: 'Таймаут вызова инструмента (секунды)',
toolCallTimeoutDescription:
'Максимальное ожидание одного вызова. 0 отключает ограничение. По умолчанию 300 секунд.',
addArgument: 'Добавить аргумент',
addEnvVar: 'Добавить переменную окружения',
addHeader: 'Добавить заголовок',
-7
View File
@@ -690,7 +690,6 @@ const thTH = {
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
sortBy: 'เรียงตาม',
sort: {
hottest: 'ยอดนิยมที่สุด',
recentlyAdded: 'เพิ่มล่าสุด',
recentlyUpdated: 'อัปเดตล่าสุด',
mostDownloads: 'ดาวน์โหลดมากที่สุด',
@@ -698,9 +697,6 @@ const thTH = {
},
downloads: 'ดาวน์โหลด',
download: 'ดาวน์โหลด',
like: 'ถูกใจ',
unlike: 'เลิกถูกใจ',
likeFailed: 'อัปเดตการถูกใจไม่สำเร็จ โปรดลองอีกครั้ง',
repository: 'Repository',
downloadFailed: 'ดาวน์โหลดล้มเหลว',
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
@@ -790,9 +786,6 @@ const thTH = {
url: 'URL',
headers: 'ส่วนหัว',
timeout: 'หมดเวลา',
toolCallTimeout: 'หมดเวลาการเรียกเครื่องมือ (วินาที)',
toolCallTimeoutDescription:
'เวลารอสูงสุดต่อการเรียกเครื่องมือหนึ่งครั้ง ตั้งเป็น 0 เพื่อไม่จำกัด ค่าเริ่มต้นคือ 300 วินาที',
addArgument: 'เพิ่มอาร์กิวเมนต์',
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
addHeader: 'เพิ่มส่วนหัว',
-7
View File
@@ -705,7 +705,6 @@ const viVN = {
notFound: 'Không tìm thấy thông tin plugin',
sortBy: 'Sắp xếp theo',
sort: {
hottest: 'Phổ biến nhất',
recentlyAdded: 'Mới thêm gần đây',
recentlyUpdated: 'Mới cập nhật gần đây',
mostDownloads: 'Tải nhiều nhất',
@@ -713,9 +712,6 @@ const viVN = {
},
downloads: 'lượt tải',
download: 'Tải xuống',
like: 'Thích',
unlike: 'Bỏ thích',
likeFailed: 'Không thể cập nhật lượt thích. Vui lòng thử lại.',
repository: 'Kho lưu trữ',
downloadFailed: 'Tải xuống thất bại',
noReadme: 'Plugin này không cung cấp tài liệu README',
@@ -805,9 +801,6 @@ const viVN = {
url: 'URL',
headers: 'Tiêu đề',
timeout: 'Thời gian chờ',
toolCallTimeout: 'Thời gian chờ gọi công cụ (giây)',
toolCallTimeoutDescription:
'Thời gian chờ tối đa cho một lần gọi công cụ. Đặt 0 để không giới hạn. Mặc định là 300 giây.',
addArgument: 'Thêm tham số',
addEnvVar: 'Thêm biến môi trường',
addHeader: 'Thêm tiêu đề',
-7
View File
@@ -668,7 +668,6 @@ const zhHans = {
notFound: '插件信息未找到',
sortBy: '排序方式',
sort: {
hottest: '热度最高',
recentlyAdded: '最近新增',
recentlyUpdated: '最近更新',
mostDownloads: '最多下载',
@@ -676,9 +675,6 @@ const zhHans = {
},
downloads: '次下载',
download: '下载',
like: '点赞',
unlike: '取消点赞',
likeFailed: '点赞失败,请稍后重试',
repository: '代码仓库',
downloadFailed: '下载失败',
noReadme: '该插件没有提供 README 文档',
@@ -767,9 +763,6 @@ const zhHans = {
url: 'URL地址',
headers: '请求头',
timeout: '超时时间',
toolCallTimeout: '工具调用超时(秒)',
toolCallTimeoutDescription:
'单次工具调用的最长等待时间。设为 0 表示不限制,默认 300 秒。',
addArgument: '添加参数',
addEnvVar: '添加环境变量',
addHeader: '添加请求头',
-7
View File
@@ -668,7 +668,6 @@ const zhHant = {
notFound: '插件資訊未找到',
sortBy: '排序方式',
sort: {
hottest: '熱度最高',
recentlyAdded: '最近新增',
recentlyUpdated: '最近更新',
mostDownloads: '最多下載',
@@ -676,9 +675,6 @@ const zhHant = {
},
downloads: '次下載',
download: '下載',
like: '按讚',
unlike: '取消按讚',
likeFailed: '按讚失敗,請稍後重試',
repository: '代碼倉庫',
downloadFailed: '下載失敗',
noReadme: '該插件沒有提供 README 文件',
@@ -766,9 +762,6 @@ const zhHant = {
url: 'URL位址',
headers: '請求標頭',
timeout: '逾時時間',
toolCallTimeout: '工具呼叫逾時(秒)',
toolCallTimeoutDescription:
'單次工具呼叫的最長等待時間。設為 0 表示不限制,預設 300 秒。',
addArgument: '新增參數',
addEnvVar: '新增環境變數',
addHeader: '新增請求標頭',
@@ -1,51 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
);
function loadNormalizer() {
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('DynamicFormSaveValues must not have runtime imports');
},
loadedModule,
loadedModule.exports,
);
return loadedModule.exports.normalizeDynamicFormValuesForSave;
}
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
const normalizeDynamicFormValuesForSave = loadNormalizer();
const specs = [
{ name: 'single-line', type: 'string', default: '' },
{ name: 'multiline', type: 'text', default: '' },
{ name: 'string-list', type: 'array[string]', default: [] },
{ name: 'count', type: 'integer', default: 0 },
];
const values = {
'single-line': '\t hello world \n',
multiline: ' keep multiline whitespace \n',
'string-list': [' first ', ' second '],
count: 3,
};
assert.deepEqual(normalizeDynamicFormValuesForSave(specs, values), {
'single-line': 'hello world',
multiline: ' keep multiline whitespace \n',
'string-list': [' first ', ' second '],
count: 3,
});
});
-86
View File
@@ -1,86 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const webRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const read = (relativePath) =>
fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => {
const market = read(
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
);
const card = read(
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
);
const cardVO = read(
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts',
);
const entity = read('src/app/infra/entities/plugin/index.ts');
const client = read('src/app/infra/http/CloudServiceClient.ts');
const likes = read(
'src/app/home/plugins/components/plugin-market/marketplace-likes.ts',
);
assert.match(
market,
/hot_score_desc/,
'market must expose and default to hot-score sorting',
);
assert.match(
market,
/sortBy:\s*['"]hot_score['"]/,
'hot option must use the shared API field',
);
assert.match(
entity,
/like_count\??:\s*number/,
'marketplace extension responses must carry like counts',
);
assert.match(
cardVO,
/likeCount:\s*number/,
'market cards must carry like counts',
);
assert.match(card, /\bHeart\b/, 'market cards must render a like affordance');
assert.match(
card,
/toggleMarketplaceExtensionLike/,
'market cards must support liking and unliking',
);
assert.match(
client,
/marketplace\/extensions\/likes/,
'client must load browser likes',
);
assert.match(
client,
/setMarketplaceExtensionLike\(/,
'client must update likes through the shared API',
);
assert.match(
client,
/data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/,
'older Space servers must fall back from hot score to install sorting',
);
assert.match(
likes,
/@fingerprintjs\/fingerprintjs/,
'anonymous likes must use browser fingerprinting',
);
assert.match(
likes,
/FingerprintJS\.load\(\{/,
'fingerprint agent must be initialized with options',
);
assert.match(
likes,
/monitoring:\s*false/,
'fingerprinting must not send vendor monitoring requests',
);
});
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/home/plugins/components/plugin-market/PluginComponentIcons.ts',
);
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const sourceRequire = createRequire(sourcePath);
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
sourceRequire,
loadedModule,
loadedModule.exports,
);
const { AppWindow } = sourceRequire('lucide-react');
const { pluginComponentIconMap } = loadedModule.exports;
test('maps the Page plugin component to the AppWindow icon', () => {
assert.equal(pluginComponentIconMap.Page, AppWindow);
});